Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9b50f3d6b7 | |||
| e945bc9413 | |||
| 3b720ed56e | |||
| fe772b52d6 | |||
| f29b77998b | |||
| f843e4c25c | |||
| 41f9b6d45d | |||
| 7b5916d3b9 | |||
| 657c205a4d |
@@ -39,11 +39,6 @@ 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.
|
||||
|
||||
-19
@@ -86,14 +86,6 @@ 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/
|
||||
|
||||
@@ -102,14 +94,3 @@ backup/
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
|
||||
# Test files and artifacts
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
# New layout development files
|
||||
new-layouts/
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.12.0-beta.0"
|
||||
".": "3.0.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.3.0"
|
||||
".": "2.3.3"
|
||||
}
|
||||
|
||||
+12
-212
@@ -5,229 +5,29 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.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))
|
||||
## [2.3.3](https://github.com/the-luap/picpeak/compare/v2.3.2...v2.3.3) (2026-01-15)
|
||||
|
||||
|
||||
### 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 ([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))
|
||||
* 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)
|
||||
* 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))
|
||||
|
||||
## [2.3.2](https://github.com/the-luap/picpeak/compare/v2.3.1...v2.3.2) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* watermark thumbnails, custom logo display, and German translations ([f843e4c](https://github.com/the-luap/picpeak/commit/f843e4c25cef02eef354fd3ee25824e20e4f8fc8))
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
|
||||
## [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)
|
||||
## [2.3.1](https://github.com/the-luap/picpeak/compare/v2.3.0...v2.3.1) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* CI workflow fixes for protected branches ([657c205](https://github.com/the-luap/picpeak/commit/657c205a4d8ca49070b69973f4c7a3d1418633af))
|
||||
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
|
||||
|
||||
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
|
||||
|
||||
@@ -7,27 +7,12 @@
|
||||
[](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:
|
||||
@@ -342,8 +327,6 @@ 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>
|
||||
|
||||
@@ -14,11 +14,6 @@ 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
|
||||
@@ -44,7 +39,6 @@ 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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Migrations
|
||||
|
||||
This directory contains database migrations for the PicPeak photo sharing platform.
|
||||
This directory contains database migrations for the Wedding Photo Sharing platform.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -9,7 +9,6 @@ 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.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
};
|
||||
@@ -1,28 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
};
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
@@ -1,23 +0,0 @@
|
||||
/**
|
||||
* 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.
|
||||
};
|
||||
@@ -1,53 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
};
|
||||
@@ -1,105 +0,0 @@
|
||||
/**
|
||||
* 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)');
|
||||
};
|
||||
@@ -1,131 +0,0 @@
|
||||
/**
|
||||
* Migration: Add header_style and hero_divider_style columns
|
||||
*
|
||||
* This migration decouples the hero header style from gallery layout,
|
||||
* allowing any combination of header style with any layout type.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('[Migration 065] Adding header_style and hero_divider_style columns');
|
||||
|
||||
// Check if columns already exist
|
||||
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
|
||||
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
|
||||
|
||||
if (!hasHeaderStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('header_style', 20).defaultTo('standard');
|
||||
});
|
||||
console.log('[Migration 065] Added header_style column');
|
||||
}
|
||||
|
||||
if (!hasDividerStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.string('hero_divider_style', 20).defaultTo('wave');
|
||||
});
|
||||
console.log('[Migration 065] Added hero_divider_style column');
|
||||
}
|
||||
|
||||
// Migrate existing events with hero layout in color_theme
|
||||
console.log('[Migration 065] Migrating existing hero layouts...');
|
||||
|
||||
const events = await knex('events')
|
||||
.whereNotNull('color_theme')
|
||||
.select('id', 'color_theme');
|
||||
|
||||
let migratedCount = 0;
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
// Skip if color_theme is not JSON
|
||||
if (!event.color_theme || !event.color_theme.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const theme = JSON.parse(event.color_theme);
|
||||
|
||||
// Check if this event uses hero layout
|
||||
if (theme.galleryLayout === 'hero') {
|
||||
// Migrate: set headerStyle to 'hero' and galleryLayout to 'grid'
|
||||
const updatedTheme = {
|
||||
...theme,
|
||||
headerStyle: 'hero',
|
||||
galleryLayout: 'grid',
|
||||
heroDividerStyle: theme.heroDividerStyle || 'wave'
|
||||
};
|
||||
|
||||
await knex('events')
|
||||
.where('id', event.id)
|
||||
.update({
|
||||
color_theme: JSON.stringify(updatedTheme),
|
||||
header_style: 'hero',
|
||||
hero_divider_style: theme.heroDividerStyle || 'wave'
|
||||
});
|
||||
|
||||
migratedCount++;
|
||||
}
|
||||
} catch (err) {
|
||||
// Invalid JSON in color_theme, skip
|
||||
console.warn(`[Migration 065] Could not parse color_theme for event ${event.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[Migration 065] Migrated ${migratedCount} events from hero layout`);
|
||||
console.log('[Migration 065] Completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('[Migration 065] Removing header_style and hero_divider_style columns');
|
||||
|
||||
// First, migrate any hero header styles back to hero layout
|
||||
const events = await knex('events')
|
||||
.where('header_style', 'hero')
|
||||
.whereNotNull('color_theme')
|
||||
.select('id', 'color_theme');
|
||||
|
||||
for (const event of events) {
|
||||
try {
|
||||
if (!event.color_theme || !event.color_theme.startsWith('{')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const theme = JSON.parse(event.color_theme);
|
||||
|
||||
// Revert: set galleryLayout back to 'hero'
|
||||
const revertedTheme = {
|
||||
...theme,
|
||||
galleryLayout: 'hero'
|
||||
};
|
||||
|
||||
// Remove the new properties
|
||||
delete revertedTheme.headerStyle;
|
||||
delete revertedTheme.heroDividerStyle;
|
||||
|
||||
await knex('events')
|
||||
.where('id', event.id)
|
||||
.update({
|
||||
color_theme: JSON.stringify(revertedTheme)
|
||||
});
|
||||
} catch (err) {
|
||||
console.warn(`[Migration 065] Could not revert color_theme for event ${event.id}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Remove the columns
|
||||
const hasHeaderStyle = await knex.schema.hasColumn('events', 'header_style');
|
||||
const hasDividerStyle = await knex.schema.hasColumn('events', 'hero_divider_style');
|
||||
|
||||
if (hasHeaderStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('header_style');
|
||||
});
|
||||
}
|
||||
|
||||
if (hasDividerStyle) {
|
||||
await knex.schema.alterTable('events', (table) => {
|
||||
table.dropColumn('hero_divider_style');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Migration 065] Rollback completed');
|
||||
};
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* 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');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 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();
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
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();
|
||||
};
|
||||
@@ -1,27 +1,22 @@
|
||||
exports.up = async function(knex) {
|
||||
// Check if photo_counter column already exists to make migration idempotent
|
||||
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
|
||||
// Add photo_counter column to photo_categories table
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('photo_counter').defaultTo(0).notNullable();
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
// 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,33 +1,23 @@
|
||||
exports.up = async function(knex) {
|
||||
// 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');
|
||||
// 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');
|
||||
});
|
||||
|
||||
// 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')
|
||||
});
|
||||
}
|
||||
// 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,37 +67,26 @@ 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
|
||||
// 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) {
|
||||
if (error.code === '42P07' || // PostgreSQL: relation already exists
|
||||
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
|
||||
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
|
||||
await markMigrationAsApplied(path.basename(filepath));
|
||||
} else {
|
||||
|
||||
@@ -26,22 +26,11 @@ 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}`);
|
||||
|
||||
// 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 });
|
||||
}
|
||||
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
console.log(`Migration ${filepath} completed`);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "1.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "1.1.15",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -8040,9 +8040,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.12.0-beta.0",
|
||||
"version": "2.3.3",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,7 +8,6 @@
|
||||
"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/"
|
||||
},
|
||||
|
||||
@@ -1,161 +0,0 @@
|
||||
#!/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);
|
||||
});
|
||||
@@ -252,29 +252,10 @@ 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">
|
||||
@@ -284,7 +265,6 @@ 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" />
|
||||
@@ -316,21 +296,6 @@ 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');
|
||||
@@ -430,22 +395,6 @@ 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 {
|
||||
@@ -488,7 +437,6 @@ 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,10 +168,9 @@ async function galleryAuth(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// 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({
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
|
||||
@@ -106,53 +106,42 @@ 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('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')
|
||||
body('name').notEmpty().withMessage('Category name is required')
|
||||
], 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, hero_photo_id } = req.body;
|
||||
|
||||
const { name } = 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(updateData);
|
||||
|
||||
.update({
|
||||
name,
|
||||
slug: name.toLowerCase()
|
||||
.replace(/[^\w\s-]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.trim()
|
||||
});
|
||||
|
||||
const updated = await db('photo_categories').where('id', id).first();
|
||||
|
||||
|
||||
// Log activity
|
||||
await logActivity('category_updated',
|
||||
{ categoryName: name, heroPhotoId: hero_photo_id },
|
||||
{ categoryName: name },
|
||||
category.event_id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
|
||||
res.json(updated);
|
||||
} catch (error) {
|
||||
console.error('Error updating category:', error);
|
||||
@@ -160,55 +149,6 @@ 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 {
|
||||
|
||||
@@ -1,258 +0,0 @@
|
||||
/**
|
||||
* 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,10 +6,8 @@ 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').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const express = require('express');
|
||||
const { body, validationResult } = require('express-validator');
|
||||
const { body, query, validationResult } = require('express-validator');
|
||||
const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
@@ -9,7 +9,6 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
@@ -17,48 +16,7 @@ const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
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'));
|
||||
}
|
||||
}
|
||||
});
|
||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
@@ -67,18 +25,14 @@ const getEventFieldRequirements = async () => {
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
'event_require_admin_email'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
require_admin_email: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
@@ -93,8 +47,6 @@ 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;
|
||||
@@ -103,9 +55,7 @@ const getEventFieldRequirements = async () => {
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
require_admin_email: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -154,15 +104,9 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
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_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').optional().isDate(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
@@ -199,16 +143,7 @@ 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(),
|
||||
// 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)
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -247,16 +182,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
// CSS Template
|
||||
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'
|
||||
css_template_id = null
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -275,9 +201,6 @@ 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 });
|
||||
@@ -322,13 +245,10 @@ 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
|
||||
|
||||
// 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}`;
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
@@ -336,7 +256,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
|
||||
// Generate share link respecting configured format
|
||||
const shareToken = crypto.randomBytes(16).toString('hex');
|
||||
const { shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
const { sharePath, shareUrl, shareLinkToStore } = await buildShareLinkVariants({ slug, shareToken });
|
||||
|
||||
// Hash password with configurable rounds (random placeholder when not required)
|
||||
const password_hash = requirePassword
|
||||
@@ -344,20 +264,15 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after 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));
|
||||
// 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);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -365,32 +280,12 @@ 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 || null,
|
||||
event_date,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
@@ -400,7 +295,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
@@ -410,13 +305,7 @@ 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,
|
||||
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'
|
||||
css_template_id: css_template_id || null
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -461,7 +350,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
@@ -478,7 +367,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
expires_at: expires_at.toISOString(),
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -652,7 +541,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({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||
body('expires_at').optional().isISO8601(),
|
||||
body('welcome_message').optional({ nullable: true, checkFalsy: true }).trim(),
|
||||
body('color_theme').optional({ nullable: true }),
|
||||
body('allow_user_uploads').optional().isBoolean(),
|
||||
@@ -684,7 +573,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) => {
|
||||
body('password').optional().isString().custom((value, { req }) => {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return true;
|
||||
}
|
||||
@@ -693,16 +582,7 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
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)
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -814,43 +694,6 @@ 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)
|
||||
@@ -916,7 +759,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);
|
||||
@@ -925,16 +768,6 @@ 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)
|
||||
@@ -1094,8 +927,7 @@ 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
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
let galleryPassword = req.body.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
@@ -1262,103 +1094,4 @@ 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;
|
||||
|
||||
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.path',
|
||||
'photos.file_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.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
),
|
||||
eventId
|
||||
);
|
||||
|
||||
@@ -12,7 +12,6 @@ const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -176,9 +175,8 @@ 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 (handle string values like 'individual', 'collage')
|
||||
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
|
||||
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
|
||||
// Determine photo type and category name
|
||||
let photoType = 'individual'; // default
|
||||
@@ -258,7 +256,6 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname, // Preserve original filename for Lightroom export
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
@@ -311,7 +308,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
@@ -322,14 +319,6 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Queue watermark generation in background (non-blocking)
|
||||
// This pre-generates watermarked versions for fast serving in lightbox
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
watermarkGeneratorService.generateForPhoto(photoId)
|
||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
@@ -472,12 +461,7 @@ 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();
|
||||
|
||||
@@ -598,13 +582,8 @@ 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)
|
||||
@@ -647,7 +626,9 @@ router.post('/:eventId/photos/bulk-update', adminAuth, requirePermission('photos
|
||||
}
|
||||
|
||||
// Prepare update data
|
||||
const updateData = {};
|
||||
const updateData = {
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
if (updates.category_id !== undefined) {
|
||||
// Handle type-based categories ('individual' or 'collage')
|
||||
@@ -724,20 +705,13 @@ router.get('/:eventId/photos', adminAuth, requirePermission('photos.view'), asyn
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.select('photos.*', 'photo_categories.name as pc_name', 'photo_categories.slug as pc_slug');
|
||||
|
||||
// Filter by category_id
|
||||
if (category_id !== undefined && category_id !== '' && category_id !== '0') {
|
||||
if (category_id === 'individual' || category_id === 'collage') {
|
||||
// Legacy type-based filtering
|
||||
// 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') {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ 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');
|
||||
|
||||
@@ -102,20 +100,13 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
// 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') {
|
||||
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
|
||||
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;
|
||||
@@ -136,24 +127,17 @@ 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 => {
|
||||
// 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') {
|
||||
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
|
||||
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;
|
||||
@@ -212,9 +196,6 @@ 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,
|
||||
@@ -325,40 +306,7 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
// 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
|
||||
});
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update branding settings' });
|
||||
@@ -493,27 +441,9 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// 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({
|
||||
res.json({
|
||||
message: 'Watermark logo uploaded successfully',
|
||||
watermarkLogoUrl: publicPath,
|
||||
watermarkRegenerationStarted
|
||||
watermarkLogoUrl: publicPath
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Watermark logo upload error:', error);
|
||||
@@ -733,70 +663,6 @@ 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 {
|
||||
|
||||
@@ -11,7 +11,6 @@ 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);
|
||||
@@ -56,13 +55,7 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
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_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
|
||||
+33
-164
@@ -5,7 +5,6 @@ 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');
|
||||
@@ -16,7 +15,7 @@ const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
|
||||
// Check for slug redirect (for renamed events)
|
||||
async function checkSlugRedirect(slug) {
|
||||
@@ -108,21 +107,13 @@ 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',
|
||||
'hero_logo_visible',
|
||||
'hero_logo_size',
|
||||
'hero_logo_position',
|
||||
'hero_logo_url',
|
||||
'header_style',
|
||||
'hero_divider_style',
|
||||
'hero_image_anchor'
|
||||
'use_canvas_rendering'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -160,23 +151,15 @@ 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 || (event.expires_at && new Date(event.expires_at) < new Date()),
|
||||
is_expired: !event.is_active || 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',
|
||||
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'
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -189,13 +172,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
|
||||
// Get watermark settings to generate cache-busting version for URLs
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const wmVersion = watermarkSettings?.enabled
|
||||
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '';
|
||||
|
||||
|
||||
// First get all photos
|
||||
let photos = await db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
@@ -296,36 +273,20 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
commentMap[c.photo_id] = parseInt(c.comment_count);
|
||||
});
|
||||
|
||||
// Get actual categories used by photos in this event
|
||||
// This includes both global categories and event-specific ones
|
||||
const usedCategoryIds = await db('photos')
|
||||
// Get distinct photo types for this event
|
||||
const categoryResults = await db('photos')
|
||||
.where('event_id', req.event.id)
|
||||
.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;
|
||||
});
|
||||
.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
|
||||
}));
|
||||
|
||||
// Log view
|
||||
await db('access_logs').insert({
|
||||
@@ -356,46 +317,33 @@ 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');
|
||||
// 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}` :
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
`/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}${wmQuery}` : null,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
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,
|
||||
category_id: photo.type,
|
||||
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Image dimensions for layout calculations
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
@@ -751,34 +699,8 @@ 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 fs = require('fs');
|
||||
const filePath = resolvePhotoFilePath(req.event, photo);
|
||||
|
||||
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(
|
||||
@@ -790,13 +712,14 @@ 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;
|
||||
@@ -831,52 +754,13 @@ 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) {
|
||||
// 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
|
||||
// Apply watermark and send
|
||||
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'
|
||||
});
|
||||
|
||||
@@ -885,7 +769,6 @@ 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
|
||||
@@ -937,32 +820,18 @@ 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',
|
||||
'ETag': etag
|
||||
'X-Protected-Thumbnail': 'true'
|
||||
});
|
||||
|
||||
// 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);
|
||||
|
||||
@@ -12,8 +12,7 @@ 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_%')
|
||||
.orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']);
|
||||
.orWhere('setting_key', 'like', 'event_require_%');
|
||||
})
|
||||
.select('setting_key', 'setting_value');
|
||||
});
|
||||
@@ -75,13 +74,7 @@ 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_event_date: settingsObject.event_require_event_date !== false,
|
||||
event_require_expiration: settingsObject.event_require_expiration !== false,
|
||||
// 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
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -14,9 +14,8 @@ 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;
|
||||
@@ -68,23 +67,13 @@ const mapEventForApi = (event) => {
|
||||
|
||||
/**
|
||||
* Generate a unique slug for an event
|
||||
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
|
||||
* @param {string} eventType
|
||||
* @param {string} eventName
|
||||
* @param {string} eventDate
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const generateUniqueSlug = async (eventType, eventName, 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}`;
|
||||
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
@@ -1,314 +0,0 @@
|
||||
/**
|
||||
* 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,11 +21,9 @@ 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);
|
||||
|
||||
@@ -42,11 +40,9 @@ 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) {
|
||||
|
||||
@@ -23,13 +23,13 @@ class PhotoExportService {
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.path',
|
||||
'photos.file_path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -37,9 +37,9 @@ class PhotoExportService {
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.size_bytes',
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
'photos.file_size',
|
||||
'photos.created_at',
|
||||
'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) : photo.filename
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename
|
||||
);
|
||||
|
||||
let content;
|
||||
@@ -126,14 +126,14 @@ class PhotoExportService {
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
'size_bytes',
|
||||
'uploaded_at'
|
||||
'file_size',
|
||||
'created_at'
|
||||
];
|
||||
|
||||
const rows = photos.map(photo => [
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename,
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename,
|
||||
photo.original_filename || '',
|
||||
photo.average_rating ? parseFloat(photo.average_rating).toFixed(2) : '0.00',
|
||||
photo.average_rating ? 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.size_bytes || '',
|
||||
photo.uploaded_at ? new Date(photo.uploaded_at).toISOString() : ''
|
||||
photo.file_size || '',
|
||||
photo.created_at ? new Date(photo.created_at).toISOString() : ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
@@ -170,7 +170,7 @@ class PhotoExportService {
|
||||
|
||||
for (const photo of photos) {
|
||||
const baseFilename = filename_format === 'original'
|
||||
? (photo.original_filename || photo.filename)
|
||||
? photo.original_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 || null,
|
||||
original_filename: photo.original_filename,
|
||||
category: photo.category_name || null,
|
||||
rating: {
|
||||
average: photo.average_rating ? parseFloat(parseFloat(photo.average_rating).toFixed(2)) : 0,
|
||||
average: photo.average_rating ? 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 || null,
|
||||
height: photo.height || null
|
||||
width: photo.width,
|
||||
height: photo.height
|
||||
},
|
||||
size_bytes: photo.size_bytes || null,
|
||||
uploaded_at: photo.uploaded_at || null
|
||||
file_size: photo.file_size,
|
||||
created_at: photo.created_at
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -158,7 +158,6 @@ 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
|
||||
@@ -170,22 +169,8 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail and extract dimensions
|
||||
// Process image: generate thumbnail
|
||||
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
|
||||
@@ -201,7 +186,6 @@ 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,
|
||||
@@ -221,12 +205,6 @@ 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
|
||||
|
||||
@@ -1,128 +0,0 @@
|
||||
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
|
||||
};
|
||||
@@ -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}/invite/${token}`,
|
||||
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||
role_name: role.display_name,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
@@ -1,433 +0,0 @@
|
||||
/**
|
||||
* 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,7 +2,6 @@ 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() {
|
||||
@@ -233,125 +232,6 @@ 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 ? parseFloat(photo.average_rating).toFixed(1) : '0';
|
||||
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
|
||||
const likes = photo.like_count || 0;
|
||||
const favorites = photo.favorite_count || 0;
|
||||
|
||||
|
||||
@@ -46,7 +46,6 @@ 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:
|
||||
|
||||
+4
-15
@@ -8,8 +8,8 @@ server {
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
resolver_timeout 5s;
|
||||
|
||||
# Allow larger file uploads (up to 1GB for video support)
|
||||
client_max_body_size 1G;
|
||||
# Allow larger file uploads (up to 100MB)
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
|
||||
# Gzip compression
|
||||
@@ -60,8 +60,8 @@ server {
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
proxy_read_timeout 86400;
|
||||
|
||||
# Allow larger uploads for API endpoints (up to 1GB for video support)
|
||||
client_max_body_size 1G;
|
||||
# Allow larger uploads for API endpoints
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 300s;
|
||||
}
|
||||
|
||||
@@ -111,17 +111,6 @@ 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
+19
-154
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "1.1.15",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "3.3.0-beta.0",
|
||||
"version": "1.1.15",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
@@ -24,16 +24,13 @@
|
||||
"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",
|
||||
@@ -41,12 +38,9 @@
|
||||
"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",
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"yet-another-react-lightbox": "^3.28.0"
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
@@ -1287,9 +1281,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"version": "1.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
||||
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -2349,7 +2343,7 @@
|
||||
"version": "18.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
|
||||
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
|
||||
"devOptional": true,
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"peerDependencies": {
|
||||
@@ -4095,33 +4089,6 @@
|
||||
"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",
|
||||
@@ -4697,12 +4664,6 @@
|
||||
"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",
|
||||
@@ -4779,9 +4740,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -4978,21 +4939,6 @@
|
||||
"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",
|
||||
@@ -5240,15 +5186,6 @@
|
||||
"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",
|
||||
@@ -5876,27 +5813,6 @@
|
||||
"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",
|
||||
@@ -5908,12 +5824,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
|
||||
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2"
|
||||
"@remix-run/router": "1.23.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -5923,13 +5839,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
|
||||
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
"@remix-run/router": "1.23.1",
|
||||
"react-router": "6.30.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -6311,25 +6227,6 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/swiper": {
|
||||
"version": "12.1.0",
|
||||
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.1.0.tgz",
|
||||
"integrity": "sha512-BD4CpAOOyEvZ2f0CDx362ea+vmOwukVcmbsQx+0BhRIaBUz8wvcCd//E7RFmvBZCrfyqXCHUVqmgUwts6ywlxw==",
|
||||
"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",
|
||||
@@ -6550,12 +6447,6 @@
|
||||
"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",
|
||||
@@ -6999,32 +6890,6 @@
|
||||
"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": "3.12.0-beta.0",
|
||||
"version": "2.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -28,16 +28,13 @@
|
||||
"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",
|
||||
@@ -45,12 +42,9 @@
|
||||
"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",
|
||||
"swiper": "^12.1.0",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"yet-another-react-lightbox": "^3.28.0"
|
||||
"tailwind-merge": "^3.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.29.0",
|
||||
|
||||
+4
-18
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect } 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,12 +24,11 @@ import {
|
||||
SettingsPage,
|
||||
BackupManagement,
|
||||
CMSPage,
|
||||
UserManagementPage,
|
||||
EventTypesPage
|
||||
UserManagementPage
|
||||
} from './pages/admin';
|
||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon, RobotsMetaTags } from './components/common';
|
||||
import { PageErrorBoundary, OfflineIndicator, SkipLink, DynamicFavicon } from './components/common';
|
||||
import { MaintenanceWrapper } from './components/MaintenanceWrapper';
|
||||
import { GlobalThemeProvider } from './components/GlobalThemeProvider';
|
||||
import { getApiBaseUrl } from './utils/url';
|
||||
@@ -45,17 +44,6 @@ 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 () => {
|
||||
@@ -114,7 +102,6 @@ function App() {
|
||||
<ThemeProvider>
|
||||
<GlobalThemeProvider>
|
||||
<DynamicFavicon />
|
||||
<RobotsMetaTags />
|
||||
<Router>
|
||||
<MaintenanceWrapper>
|
||||
<SkipLink />
|
||||
@@ -141,7 +128,6 @@ 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 />} />
|
||||
@@ -177,7 +163,7 @@ function App() {
|
||||
pauseOnFocusLoss
|
||||
draggable
|
||||
pauseOnHover
|
||||
theme={toastTheme}
|
||||
theme="light"
|
||||
/>
|
||||
</GlobalThemeProvider>
|
||||
</ThemeProvider>
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
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>
|
||||
<AdminDarkModeProvider>
|
||||
<Outlet />
|
||||
</AdminDarkModeProvider>
|
||||
<Outlet />
|
||||
</PermissionsProvider>
|
||||
</AdminAuthProvider>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2, Sun, Moon } from 'lucide-react';
|
||||
import { Menu, User, LogOut, Settings, Bell, Lock, CheckCircle, Trash2 } 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';
|
||||
@@ -21,7 +20,6 @@ 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();
|
||||
@@ -70,7 +68,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
const unreadCount = notificationsData?.unreadCount || 0;
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 bg-white dark:bg-neutral-900 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<header className="sticky top-0 z-30 bg-white border-b border-neutral-200">
|
||||
<div className="px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex items-center justify-between h-16">
|
||||
{/* Left side - Menu button, Logo, and Date */}
|
||||
@@ -89,8 +87,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 dark:border-neutral-700 ml-1">
|
||||
<p className="text-base text-neutral-700 dark:text-neutral-300">
|
||||
<div className="hidden xl:block pl-3 border-l border-neutral-200 ml-1">
|
||||
<p className="text-base text-neutral-700">
|
||||
{format(new Date(), 'PPPP')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -100,21 +98,12 @@ 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 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"
|
||||
className="relative p-2 text-neutral-500 hover:text-neutral-700 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<Bell className="w-5 h-5" />
|
||||
{unreadCount > 0 && (
|
||||
@@ -124,14 +113,14 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
|
||||
{/* Notifications dropdown */}
|
||||
{showNotifications && (
|
||||
<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="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="flex items-center gap-2">
|
||||
{unreadCount > 0 && (
|
||||
<button
|
||||
onClick={() => markAllAsReadMutation.mutate()}
|
||||
className="text-xs text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300 flex items-center gap-1"
|
||||
className="text-xs text-primary-600 hover:text-primary-700 flex items-center gap-1"
|
||||
title={t('admin.markAllRead')}
|
||||
>
|
||||
<CheckCircle className="w-3 h-3" />
|
||||
@@ -140,7 +129,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
)}
|
||||
<button
|
||||
onClick={() => clearAllMutation.mutate()}
|
||||
className="text-xs text-neutral-600 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-300 flex items-center gap-1"
|
||||
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
|
||||
title={t('admin.clearAll')}
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
@@ -150,7 +139,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 dark:text-neutral-400">
|
||||
<div className="px-4 py-8 text-center text-sm text-neutral-500">
|
||||
{t('admin.noNotificationsMessage')}
|
||||
</div>
|
||||
) : (
|
||||
@@ -159,7 +148,7 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
return (
|
||||
<div
|
||||
key={notification.id}
|
||||
className={`px-4 py-3 hover:bg-neutral-50 dark:hover:bg-neutral-700 cursor-pointer border-l-4 ${
|
||||
className={`px-4 py-3 hover:bg-neutral-50 cursor-pointer border-l-4 ${
|
||||
notification.isRead ? 'border-transparent opacity-75' : 'border-primary-500'
|
||||
}`}
|
||||
>
|
||||
@@ -168,10 +157,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 dark:text-neutral-100">
|
||||
<p className="text-sm text-neutral-900">
|
||||
{notificationsService.formatNotificationMessage(notification)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{formatTimeAgo(notification.createdAt)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -182,10 +171,10 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
)}
|
||||
</div>
|
||||
{notifications.length > 0 && (
|
||||
<div className="px-4 py-2 border-t border-neutral-100 dark:border-neutral-700 text-center">
|
||||
<button
|
||||
<div className="px-4 py-2 border-t border-neutral-100 text-center">
|
||||
<button
|
||||
onClick={() => setShowNotifications(false)}
|
||||
className="text-sm text-primary-600 dark:text-primary-400 hover:text-primary-700 dark:hover:text-primary-300"
|
||||
className="text-sm text-primary-600 hover:text-primary-700"
|
||||
>
|
||||
{t('admin.close')}
|
||||
</button>
|
||||
@@ -199,11 +188,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 dark:hover:bg-neutral-800 rounded-lg transition-colors"
|
||||
className="flex items-center gap-3 p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<div className="text-right hidden sm:block">
|
||||
<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>
|
||||
<p className="text-sm font-medium text-neutral-900">{user?.username}</p>
|
||||
<p className="text-xs text-neutral-500">{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" />
|
||||
@@ -212,17 +201,17 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
|
||||
{/* User dropdown */}
|
||||
{showUserMenu && (
|
||||
<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 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>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowUserMenu(false);
|
||||
navigate('/admin/settings');
|
||||
}}
|
||||
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"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 flex items-center gap-3"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
{t('navigation.settings')}
|
||||
@@ -232,14 +221,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 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 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 dark:text-neutral-300 hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3"
|
||||
className="w-full px-4 py-2 text-left text-sm text-neutral-700 hover:bg-neutral-50 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 dark:bg-neutral-950 flex items-center justify-center">
|
||||
<div className="min-h-screen bg-neutral-50 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 dark:bg-neutral-950 flex overflow-hidden">
|
||||
<div className="h-screen bg-neutral-50 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, FolderOpen } from 'lucide-react';
|
||||
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -7,12 +7,6 @@ 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[];
|
||||
@@ -20,7 +14,6 @@ interface AdminPhotoGridProps {
|
||||
onPhotoClick: (photo: AdminPhoto, index: number) => void;
|
||||
onPhotosDeleted: () => void;
|
||||
onSelectionChange?: (selectedIds: number[]) => void;
|
||||
categories?: CategoryOption[];
|
||||
}
|
||||
|
||||
export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
@@ -28,16 +21,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
eventId,
|
||||
onPhotoClick,
|
||||
onPhotosDeleted,
|
||||
onSelectionChange,
|
||||
categories = []
|
||||
onSelectionChange
|
||||
}) => {
|
||||
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) {
|
||||
@@ -135,35 +125,6 @@ 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 */}
|
||||
@@ -190,17 +151,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{selectedPhotos.size > 0 && (
|
||||
<>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<span className="text-sm text-neutral-600">
|
||||
{t('gallery.photosSelected', { count: selectedPhotos.size })}
|
||||
</span>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setIsCategoryModalOpen(true)}
|
||||
leftIcon={<FolderOpen className="w-4 h-4" />}
|
||||
>
|
||||
{t('photos.moveToCategory', 'Move to Category')}
|
||||
</Button>
|
||||
<button
|
||||
onClick={handleDeleteSelected}
|
||||
disabled={isDeleting}
|
||||
@@ -215,7 +168,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div className="text-sm text-neutral-600">
|
||||
{t('gallery.photosCount', { count: photos.length })}
|
||||
</div>
|
||||
</div>
|
||||
@@ -234,7 +187,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 dark:bg-neutral-800 transition-opacity ${
|
||||
className={`relative group cursor-pointer rounded-lg overflow-hidden bg-neutral-100 transition-opacity ${
|
||||
isSelectionMode ? 'ring-2 ring-offset-2 ' + (selectedPhotos.has(photo.id) ? 'ring-primary-500' : 'ring-transparent') : ''
|
||||
} ${isDeleting ? 'opacity-50' : ''}`}
|
||||
onClick={() => !isDeleting && onPhotoClick(photo, index)}
|
||||
@@ -353,19 +306,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
|
||||
|
||||
{photos.length === 0 && (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-neutral-500 dark:text-neutral-400">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk Category Modal */}
|
||||
<BulkCategoryModal
|
||||
isOpen={isCategoryModalOpen}
|
||||
onClose={() => setIsCategoryModalOpen(false)}
|
||||
onConfirm={handleMoveToCategory}
|
||||
photoCount={selectedPhotos.size}
|
||||
categories={categories}
|
||||
isLoading={isUpdatingCategory}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
Palette,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Users,
|
||||
Tags
|
||||
Users
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -40,7 +39,6 @@ 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' },
|
||||
@@ -59,15 +57,15 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
|
||||
return (
|
||||
<div
|
||||
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 ${
|
||||
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 ${
|
||||
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 dark:border-neutral-700 flex-shrink-0">
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<span className="text-xl font-bold text-neutral-900 dark:text-neutral-100">{t('admin.title')}</span>
|
||||
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@@ -90,8 +88,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 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'
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'text-neutral-700 hover:bg-neutral-100 hover:text-neutral-900'
|
||||
}`}
|
||||
>
|
||||
<item.icon className={`w-5 h-5 mr-3 ${
|
||||
@@ -138,26 +136,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 dark:bg-red-900/30 border border-red-200 dark:border-red-800'
|
||||
: 'bg-neutral-100 dark:bg-neutral-800';
|
||||
? 'bg-red-50 border border-red-200'
|
||||
: 'bg-neutral-100';
|
||||
const softLimitDisplay = settingsService.formatBytes(limitInUse);
|
||||
|
||||
return (
|
||||
<div className="p-4 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<div className="p-4 border-t border-neutral-200">
|
||||
<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 dark:text-neutral-300">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<span className="text-neutral-700">{t('admin.storageUsed')}</span>
|
||||
<span className="font-medium text-neutral-900">
|
||||
{settingsService.formatBytes(storageInfo.total_used)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<div
|
||||
<div className="mt-2 w-full bg-neutral-200 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 dark:text-neutral-400 mt-1">
|
||||
<p className="text-xs text-neutral-600 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-neutral-900 dark:text-neutral-100">{t('backup.configuration.enableBackup')}</h3>
|
||||
<p className="mt-1 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{t('backup.configuration.enableBackup')}</h3>
|
||||
<p className="mt-1 text-sm text-gray-600">
|
||||
{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-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>
|
||||
<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>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Destination Configuration */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.destinationType')}</h3>
|
||||
|
||||
<h3 className="text-lg font-semibold text-gray-900 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 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-600 hover:border-neutral-300 dark:hover:border-neutral-500'
|
||||
? 'border-primary bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-8 w-8 mb-2 mx-auto ${
|
||||
formData.backup_destination_type === type.id
|
||||
? 'text-primary'
|
||||
: 'text-neutral-400'
|
||||
: 'text-gray-400'
|
||||
}`} />
|
||||
<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>
|
||||
<h4 className="font-medium text-gray-900">{type.name}</h4>
|
||||
<p className="text-xs text-gray-500 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-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"
|
||||
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"
|
||||
rows={4}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowSecrets(prev => ({ ...prev, ssh_key: !prev.ssh_key }))}
|
||||
className="absolute top-2 right-2 text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
className="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{showSecrets.ssh_key ? <EyeOff size={20} /> : <Eye size={20} />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t('backup.configuration.fields.s3EndpointHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{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-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.schedule.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-4">{t('backup.configuration.schedule.title')}</h3>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-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"
|
||||
className="w-full px-3 py-2 border border-gray-300 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-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{t('backup.configuration.schedule.customCronHelp')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-gray-500">
|
||||
{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-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.whatToBackup.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<Database className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.database')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.databaseHelp')}</p>
|
||||
<p className="text-xs text-gray-500">{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-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<Image className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.photosHelp')}</p>
|
||||
<p className="text-xs text-gray-500">{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-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<FileArchive className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.archivesHelp')}</p>
|
||||
<p className="text-xs text-gray-500">{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-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<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>
|
||||
<Image className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700">{t('backup.configuration.whatToBackup.thumbnails')}</span>
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{t('backup.configuration.whatToBackup.thumbnailsHelp')}</p>
|
||||
<p className="text-xs text-gray-500">{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-neutral-900 dark:text-neutral-100 mb-4">{t('backup.configuration.advancedOptions.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<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>
|
||||
<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>
|
||||
</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-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div className="ml-3">
|
||||
<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>
|
||||
<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>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
{formData.backup_encryption && (
|
||||
<div className="ml-7">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-gray-700 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-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
className="absolute top-1/2 -translate-y-1/2 right-2 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
{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-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>
|
||||
<p className="text-sm font-medium text-gray-600">{label}</p>
|
||||
<p className="mt-2 text-3xl font-semibold text-gray-900">{value}</p>
|
||||
{subtext && (
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtext}</p>
|
||||
<p className="mt-1 text-sm text-gray-500">{subtext}</p>
|
||||
)}
|
||||
</div>
|
||||
<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 className={`p-3 bg-${color}-100 rounded-lg`}>
|
||||
<Icon className={`h-6 w-6 text-${color}-600`} />
|
||||
</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 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="bg-amber-50 border border-amber-200 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 dark:text-amber-200">
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
{t('backup.dashboard.notConfigured.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
{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-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`}>
|
||||
<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`}>
|
||||
{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-neutral-200 dark:text-neutral-700"
|
||||
className="text-gray-200"
|
||||
/>
|
||||
<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-neutral-900 dark:text-neutral-100">{health.score}%</span>
|
||||
<span className="text-2xl font-bold text-gray-900">{health.score}%</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-neutral-700 dark:text-neutral-300 font-medium">{health.message}</p>
|
||||
<p className="text-gray-700 font-medium">{health.message}</p>
|
||||
{lastBackup && (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
<p className="text-sm text-gray-500 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-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.recentActivity.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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-neutral-100 dark:border-neutral-700 last:border-0">
|
||||
<div key={backup.id} className="flex items-center justify-between py-3 border-b border-gray-100 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-neutral-900 dark:text-neutral-100">
|
||||
<p className="font-medium text-gray-900">
|
||||
{t('backup.dashboard.backupType', { type: backup.backup_type })}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-500">
|
||||
{format(new Date(backup.created_at), 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{formatBytes(backup.statistics?.total_size || 0)}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-500">
|
||||
{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-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.coverage.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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-neutral-400" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">Database</span>
|
||||
<Database className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-700">Database</span>
|
||||
</div>
|
||||
<span className={`px-2 py-1 rounded text-xs font-medium ${
|
||||
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 ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-700'
|
||||
}`}>
|
||||
{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-neutral-400" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
<Image className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-700">{t('backup.configuration.whatToBackup.photos')}</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<span className="text-sm text-gray-500">
|
||||
{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-neutral-400" />
|
||||
<span className="text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
<FileArchive className="h-5 w-5 text-gray-400" />
|
||||
<span className="text-gray-700">{t('backup.configuration.whatToBackup.archives')}</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<span className="text-sm text-gray-500">
|
||||
{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-neutral-900 dark:text-neutral-100 mb-4">{t('backup.dashboard.storageDestination')}</h3>
|
||||
<h3 className="text-lg font-semibold text-gray-900 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-neutral-500" />
|
||||
<HardDrive className="h-5 w-5 text-gray-500" />
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
{config?.backup_destination_type
|
||||
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||
<p className="font-medium text-gray-900">
|
||||
{config?.backup_destination_type
|
||||
? t(`backup.configuration.destinationTypes.${config.backup_destination_type}.name`)
|
||||
: t('backup.dashboard.notConfigured.title')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-500">
|
||||
{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-neutral-50 dark:bg-neutral-700 rounded-lg">
|
||||
<div className="mt-4 p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Info className="h-4 w-4 text-neutral-400" />
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-300">
|
||||
<Info className="h-4 w-4 text-gray-400" />
|
||||
<span className="text-sm text-gray-600">
|
||||
{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-neutral-400" />
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-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-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"
|
||||
className="px-3 py-2 border border-gray-300 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-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">
|
||||
<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">
|
||||
{t('backup.history.columns.status')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('backup.history.columns.dateTime')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('backup.history.columns.type')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('backup.history.columns.size')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('backup.history.columns.duration')}
|
||||
</th>
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
<th className="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||
{t('backup.history.columns.actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-neutral-800 divide-y divide-neutral-200 dark:divide-neutral-700">
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{backups.length === 0 ? (
|
||||
<tr>
|
||||
<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>
|
||||
<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>
|
||||
<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-neutral-50 dark:hover:bg-neutral-700/50">
|
||||
<tr className="hover:bg-gray-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-neutral-900 dark:text-neutral-100 capitalize">
|
||||
<span className="ml-2 text-sm font-medium text-gray-900 capitalize">
|
||||
{backup.status}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<p className="text-sm font-medium text-gray-900">
|
||||
{format(new Date(backup.created_at), 'PPP')}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-xs text-gray-500">
|
||||
{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 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300 capitalize">
|
||||
<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">
|
||||
{backup.backup_type || 'Manual'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<p className="text-sm text-neutral-900 dark:text-neutral-100">
|
||||
<p className="text-sm text-gray-900">
|
||||
{formatBytes(stats.total_size || 0)}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-xs text-gray-500">
|
||||
{stats.files_processed || 0} {t('backup.dashboard.stats.files')}
|
||||
</p>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{backup.duration_seconds
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{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-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
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-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300"
|
||||
className="text-gray-400 hover:text-gray-600"
|
||||
title={t('backup.actions.download')}
|
||||
>
|
||||
<Download size={20} />
|
||||
@@ -245,7 +245,7 @@ export const BackupHistory = () => {
|
||||
)}
|
||||
<button
|
||||
onClick={() => handleDelete(backup)}
|
||||
className="text-neutral-400 hover:text-red-600"
|
||||
className="text-gray-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-neutral-50 dark:bg-neutral-700/50">
|
||||
<td colSpan={6} className="px-6 py-4 bg-gray-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-neutral-900 dark:text-neutral-100">{t('backup.history.details.backupDetails')}</h4>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.history.details.backupDetails')}</h4>
|
||||
<div className="text-sm space-y-1">
|
||||
<div className="flex justify-between">
|
||||
<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>
|
||||
<span className="text-gray-500">{t('backup.history.details.destination')}:</span>
|
||||
<span className="text-gray-900">{backup.destination_type || 'Unknown'}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<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>
|
||||
<span className="text-gray-500">{t('backup.history.details.started')}:</span>
|
||||
<span className="text-gray-900">{format(new Date(backup.created_at), 'p')}</span>
|
||||
</div>
|
||||
{backup.completed_at && (
|
||||
<div className="flex justify-between">
|
||||
<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>
|
||||
<span className="text-gray-500">{t('backup.history.details.completed')}:</span>
|
||||
<span className="text-gray-900">{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-neutral-900 dark:text-neutral-100">{t('backup.history.details.contentBackedUp')}</h4>
|
||||
<h4 className="font-medium text-gray-900">{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-neutral-300 dark:text-neutral-600'}`} />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">{t('backup.configuration.whatToBackup.database')}</span>
|
||||
<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>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<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">
|
||||
<Image className={`h-4 w-4 ${stats.photos_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
||||
<span className="text-sm text-gray-700">
|
||||
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-neutral-300 dark:text-neutral-600'}`} />
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300">
|
||||
<FileArchive className={`h-4 w-4 ${stats.archives_backed_up > 0 ? 'text-green-500' : 'text-gray-300'}`} />
|
||||
<span className="text-sm text-gray-700">
|
||||
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 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">
|
||||
<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">
|
||||
{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-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">
|
||||
<h4 className="font-medium text-gray-900">{t('backup.history.details.manifest')}</h4>
|
||||
<p className="text-sm text-gray-600 font-mono break-all">
|
||||
{backup.manifest_path}
|
||||
</p>
|
||||
</div>
|
||||
@@ -337,7 +337,7 @@ export const BackupHistory = () => {
|
||||
|
||||
{/* Pagination */}
|
||||
{pagination.pages > 1 && (
|
||||
<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="bg-white px-4 py-3 border-t border-gray-200 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-neutral-700 dark:text-neutral-300">
|
||||
<p className="text-sm text-gray-700">
|
||||
{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-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"
|
||||
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"
|
||||
>
|
||||
{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 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'
|
||||
? 'z-10 bg-primary-50 border-primary text-primary'
|
||||
: 'bg-white border-gray-300 text-gray-700 hover:bg-gray-50'
|
||||
}`}
|
||||
>
|
||||
{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-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"
|
||||
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"
|
||||
>
|
||||
{t('backup.history.pagination.next')}
|
||||
</button>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
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 dark:text-neutral-100">{t('categories.title')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">{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 dark:bg-neutral-800 rounded-lg">
|
||||
<div className="flex gap-2 p-3 bg-neutral-50 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 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"
|
||||
className="flex-1 px-3 py-2 border border-neutral-300 rounded-md 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 dark:text-neutral-400 text-center py-8">
|
||||
<p className="text-neutral-500 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 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"
|
||||
className="flex items-center justify-between p-3 bg-white rounded-lg border border-neutral-200 hover:border-neutral-300 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 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"
|
||||
className="flex-1 px-3 py-1 border border-neutral-300 rounded-md 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 dark:text-neutral-100">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||
<p className="font-medium text-neutral-900">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500">/{category.slug}</p>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={() => startEdit(category)}
|
||||
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"
|
||||
className="p-1.5 text-neutral-600 hover:text-primary-600 hover:bg-primary-50 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 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"
|
||||
className="p-1.5 text-neutral-600 hover:text-red-600 hover:bg-red-50 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, Code, AlertTriangle, Check } from 'lucide-react';
|
||||
import { Save, RotateCcw, Eye, 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 dark:text-neutral-100 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 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 dark:border-neutral-700 mb-6">
|
||||
<div className="flex border-b border-neutral-200 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 dark:text-neutral-400 hover:text-neutral-900 dark:hover:text-neutral-100 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
: 'border-transparent text-neutral-600 hover:text-neutral-900 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{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 dark:text-neutral-300 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 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 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg 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 dark:text-neutral-300">
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('cssTemplates.enableTemplate', 'Enable this template')}
|
||||
</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1 ml-6">
|
||||
<p className="text-xs text-neutral-500 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 dark:text-neutral-300 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 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 dark:text-neutral-400 mt-2">
|
||||
<p className="text-xs text-neutral-500 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 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">
|
||||
<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">
|
||||
<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 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between pt-4 border-t border-neutral-100">
|
||||
<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 dark:text-neutral-500 text-right">
|
||||
<p className="text-xs text-neutral-400 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 dark:border-neutral-700">
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<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 dark:text-neutral-100">Email Preview</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">Email Preview</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-neutral-400 hover:text-neutral-600 dark:hover:text-neutral-300 transition-colors"
|
||||
className="text-neutral-400 hover:text-neutral-600 transition-colors"
|
||||
>
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Subject */}
|
||||
<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 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>
|
||||
|
||||
{/* View mode toggle */}
|
||||
<div className="px-6 py-3 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<div className="px-6 py-3 border-b border-neutral-200">
|
||||
<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 dark:border-neutral-700 rounded-lg shadow-sm">
|
||||
<div className="bg-white border border-neutral-200 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 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">
|
||||
<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">
|
||||
{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 dark:border-neutral-700">
|
||||
<div className="flex justify-end gap-3 p-6 border-t border-neutral-200">
|
||||
<Button variant="outline" onClick={onClose}>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check } from 'lucide-react';
|
||||
import { Plus, X, Loader2 } from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
import { Button } from '../common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface EventCategoryManagerProps {
|
||||
@@ -16,7 +15,6 @@ 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({
|
||||
@@ -24,23 +22,16 @@ 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] });
|
||||
@@ -65,20 +56,6 @@ 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());
|
||||
@@ -91,14 +68,6 @@ 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">
|
||||
@@ -110,7 +79,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 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||
<h3 className="text-sm font-medium text-neutral-700">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!isAdding && (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -132,7 +101,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 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"
|
||||
className="flex-1 px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-2 focus:ring-primary-500"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
@@ -162,176 +131,49 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
<p className="text-sm text-neutral-500 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<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"
|
||||
<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="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>
|
||||
);
|
||||
})}
|
||||
{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 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">
|
||||
<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">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.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>
|
||||
);
|
||||
})}
|
||||
.map(cat => (
|
||||
<span key={cat.id} className="px-2 py-1 text-xs bg-neutral-100 text-neutral-600 rounded">
|
||||
{cat.name}
|
||||
</span>
|
||||
))}
|
||||
</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 dark:text-neutral-100 flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 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 dark:text-neutral-300">
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{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 dark:text-neutral-300">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
{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 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<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 dark:text-neutral-400" />
|
||||
<Star className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.ratings', 'Star Ratings')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{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 dark:bg-neutral-800 rounded-lg cursor-pointer hover:bg-neutral-100 dark:hover:bg-neutral-700">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<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 dark:text-neutral-400" />
|
||||
<Heart className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.likes', 'Likes')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.likesDesc', 'Simple like/unlike functionality')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<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">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<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 dark:text-neutral-400" />
|
||||
<MessageSquare className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.comments', 'Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{t('feedback.settings.commentsDesc', 'Text comments on photos')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<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">
|
||||
<label className="flex items-center gap-3 p-3 bg-neutral-50 rounded-lg cursor-pointer hover:bg-neutral-100">
|
||||
<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 dark:text-neutral-400" />
|
||||
<Bookmark className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.favorites', 'Favorites')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{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 border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
<div className="border-t pt-4" />
|
||||
|
||||
{/* Privacy & Moderation */}
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
<h3 className="text-sm font-medium text-neutral-700">
|
||||
{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 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.requireInfo', 'Require Name & Email')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{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 dark:text-neutral-400" />
|
||||
<Shield className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.moderateComments', 'Moderate Comments')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{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 dark:text-neutral-400" />
|
||||
<Eye className="w-5 h-5 text-neutral-600" />
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.showToGuests', 'Show Feedback to Guests')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{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 border-neutral-200 dark:border-neutral-700 pt-4" />
|
||||
<div className="border-t 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 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{t('feedback.settings.enableRateLimiting', 'Enable Rate Limiting')}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{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 dark:text-neutral-400 mb-1">
|
||||
<label className="block text-xs font-medium text-neutral-600 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 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"
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-xs font-medium text-neutral-600 dark:text-neutral-400 mb-1">
|
||||
<label className="block text-xs font-medium text-neutral-600 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 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"
|
||||
className="w-full px-3 py-1.5 text-sm border border-neutral-300 rounded-md focus:ring-primary-500 focus:border-primary-500"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,117 +0,0 @@
|
||||
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,6 +1,6 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Camera, Calendar } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, HeroDividerStyle } from '../../types/theme.types';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
|
||||
interface GalleryPreviewBranding {
|
||||
@@ -92,41 +92,6 @@ 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';
|
||||
@@ -186,6 +151,18 @@ 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`}>
|
||||
@@ -204,7 +181,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',
|
||||
@@ -212,119 +189,45 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
fontFamily: theme.fontFamily || 'Inter, sans-serif',
|
||||
}}
|
||||
>
|
||||
{/* 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>
|
||||
{/* 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>
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute bottom-0 left-0 right-0">
|
||||
{renderHeroDivider()}
|
||||
</div>
|
||||
)}
|
||||
{!showLogo && !showText && (
|
||||
<p className="text-sm font-semibold">{brandName}</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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 className="text-xs text-neutral-500 flex justify-between">
|
||||
<span>Gallery preview</span>
|
||||
<span className="capitalize">{activeLayout} layout</span>
|
||||
</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 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 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 dark:text-neutral-300 mb-1">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroPhoto')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">
|
||||
<p className="text-xs text-neutral-500 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 dark:border-neutral-700">
|
||||
<div className="p-6 border-b border-neutral-200">
|
||||
<div className="flex items-center justify-between">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('events.selectHeroPhoto')}</h2>
|
||||
<h2 className="text-xl font-semibold">{t('events.selectHeroPhoto')}</h2>
|
||||
<button
|
||||
onClick={() => setIsOpen(false)}
|
||||
className="p-2 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
className="p-2 hover:bg-neutral-100 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 dark:border-neutral-700 flex justify-end gap-3">
|
||||
<div className="p-6 border-t border-neutral-200 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 dark:text-neutral-100">{t('passwordChange.title')}</h2>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('passwordChange.title')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 dark:hover:bg-neutral-700 rounded-lg transition-colors"
|
||||
className="p-1 hover:bg-neutral-100 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 dark:text-neutral-300 mb-1">
|
||||
<label htmlFor="currentPassword" className="block text-sm font-medium text-neutral-700 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 dark:hover:bg-neutral-700 rounded"
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 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 dark:text-neutral-300 mb-1">
|
||||
<label htmlFor="newPassword" className="block text-sm font-medium text-neutral-700 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 dark:hover:bg-neutral-700 rounded"
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 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 dark:text-neutral-300 mb-1">
|
||||
<label htmlFor="confirmPassword" className="block text-sm font-medium text-neutral-700 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 dark:hover:bg-neutral-700 rounded"
|
||||
className="absolute right-3 top-2 p-1 hover:bg-neutral-100 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 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg p-3">
|
||||
<div className="bg-blue-50 border border-blue-200 rounded-lg p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<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">
|
||||
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-sm text-blue-800">
|
||||
<p className="font-medium">{t('passwordChange.requirements')}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-1">
|
||||
<li>{t('passwordChange.minLength')}</li>
|
||||
|
||||
@@ -101,8 +101,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 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'
|
||||
? 'bg-neutral-100 text-neutral-400 border-neutral-200 cursor-not-allowed'
|
||||
: 'bg-white text-neutral-700 border-neutral-300 hover:bg-neutral-50'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -129,9 +129,9 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
/>
|
||||
|
||||
{/* Dropdown Menu */}
|
||||
<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="absolute right-0 mt-2 w-72 bg-white rounded-lg shadow-lg border border-neutral-200 z-20">
|
||||
<div className="p-2">
|
||||
<p className="px-3 py-2 text-xs font-medium text-neutral-500 dark:text-neutral-400 uppercase tracking-wider">
|
||||
<p className="px-3 py-2 text-xs font-medium text-neutral-500 uppercase tracking-wider">
|
||||
{hasSelection
|
||||
? t('export.exportSelected', 'Export {{count}} selected', { count: selectedPhotoIds.length })
|
||||
: t('export.exportFiltered', 'Export filtered photos')
|
||||
@@ -145,14 +145,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 dark:hover:bg-neutral-700 text-left transition-colors"
|
||||
className="w-full flex items-start gap-3 px-3 py-2 rounded-md hover:bg-neutral-50 text-left transition-colors"
|
||||
>
|
||||
<Icon className="w-5 h-5 text-neutral-500 dark:text-neutral-400 mt-0.5" />
|
||||
<Icon className="w-5 h-5 text-neutral-500 mt-0.5" />
|
||||
<div>
|
||||
<div className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<div className="text-sm font-medium text-neutral-900">
|
||||
{format.label}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
{format.description}
|
||||
</div>
|
||||
</div>
|
||||
@@ -165,7 +165,7 @@ export const PhotoExportMenu: React.FC<PhotoExportMenuProps> = ({
|
||||
)}
|
||||
|
||||
{!hasSelection && !hasFilters && (
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{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 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 p-4 mb-4">
|
||||
<div className="bg-white rounded-lg border border-neutral-200 p-4 mb-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-medium text-neutral-900 dark:text-neutral-100 flex items-center gap-2">
|
||||
<h3 className="font-medium text-neutral-900 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 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 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 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{RATING_OPTIONS.map(option => (
|
||||
<option key={option.label} value={option.value ?? ''}>
|
||||
{t(option.label, { defaultValue: option.label.split('.').pop() })}
|
||||
{t(option.label, 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 dark:text-neutral-300">
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('filter.hasLikes', 'Has likes')}
|
||||
{summary && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withLikes})</span>
|
||||
<span className="text-neutral-500 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 dark:text-neutral-300">
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('filter.hasFavorites', 'Has favorites')}
|
||||
{summary && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withFavorites})</span>
|
||||
<span className="text-neutral-500 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 dark:text-neutral-300">
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('filter.hasComments', 'Has comments')}
|
||||
{summary && (
|
||||
<span className="text-neutral-500 dark:text-neutral-400 ml-1">({summary.withComments})</span>
|
||||
<span className="text-neutral-500 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 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">
|
||||
<span className="text-sm text-neutral-600">{t('filter.combineWith', 'Combine with')}:</span>
|
||||
<div className="flex rounded-lg border border-neutral-200 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 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
: 'bg-white text-neutral-600 hover:bg-neutral-50'
|
||||
}`}
|
||||
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 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-700'
|
||||
: 'bg-white text-neutral-600 hover:bg-neutral-50'
|
||||
}`}
|
||||
disabled={isLoading}
|
||||
>
|
||||
@@ -185,7 +185,7 @@ export const PhotoFilterPanel: React.FC<PhotoFilterPanelProps> = ({
|
||||
|
||||
{/* Summary */}
|
||||
{summary && (
|
||||
<div className="pt-2 border-t border-neutral-100 dark:border-neutral-700 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<div className="pt-2 border-t border-neutral-100 text-sm text-neutral-600">
|
||||
{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 dark:bg-neutral-800 border border-neutral-200 dark:border-neutral-700 rounded-lg p-4 mb-6">
|
||||
<div className="bg-white border border-neutral-200 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 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"
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg 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 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"
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg 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 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"
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg 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 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 transition-colors"
|
||||
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 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 dark:text-neutral-400" />
|
||||
<SortAsc className="w-5 h-5 text-neutral-600" />
|
||||
) : (
|
||||
<SortDesc className="w-5 h-5 text-neutral-600 dark:text-neutral-400" />
|
||||
<SortDesc className="w-5 h-5 text-neutral-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -96,30 +96,12 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setIsUploading(true);
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files by both count AND size to prevent memory/network issues
|
||||
const MAX_FILES_PER_CHUNK = Math.max(1, Math.min(50, maxFilesPerUpload)); // Max 50 files per chunk
|
||||
const MAX_BYTES_PER_CHUNK = 500 * 1024 * 1024; // Max 500MB per chunk (nginx limit is 1GB)
|
||||
const chunks: File[][] = [];
|
||||
|
||||
let currentChunk: File[] = [];
|
||||
let currentChunkSize = 0;
|
||||
|
||||
for (const file of selectedFiles) {
|
||||
// Start a new chunk if adding this file would exceed limits
|
||||
if (currentChunk.length >= MAX_FILES_PER_CHUNK ||
|
||||
(currentChunkSize + file.size > MAX_BYTES_PER_CHUNK && currentChunk.length > 0)) {
|
||||
chunks.push(currentChunk);
|
||||
currentChunk = [];
|
||||
currentChunkSize = 0;
|
||||
}
|
||||
|
||||
currentChunk.push(file);
|
||||
currentChunkSize += file.size;
|
||||
}
|
||||
|
||||
// Don't forget the last chunk
|
||||
if (currentChunk.length > 0) {
|
||||
chunks.push(currentChunk);
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
chunks.push(selectedFiles.slice(i, i + CHUNK_SIZE));
|
||||
}
|
||||
|
||||
setTotalChunks(chunks.length);
|
||||
@@ -203,13 +185,13 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
<div className="space-y-4">
|
||||
{/* Category Selection */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('upload.photoCategory')}
|
||||
</label>
|
||||
<select
|
||||
value={selectedCategoryId || ''}
|
||||
onChange={(e) => setSelectedCategoryId(e.target.value ? Number(e.target.value) : null)}
|
||||
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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="">{t('upload.noCategory')}</option>
|
||||
{categories.map((category) => (
|
||||
@@ -225,21 +207,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
className={clsx(
|
||||
"border-2 border-dashed rounded-lg p-8 text-center transition-colors",
|
||||
"hover:border-primary-400 hover:bg-primary-50/50",
|
||||
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30 dark:bg-primary-900/20" : "border-neutral-300 dark:border-neutral-600"
|
||||
selectedFiles.length > 0 ? "border-primary-400 bg-primary-50/30" : "border-neutral-300"
|
||||
)}
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-12 h-12 mx-auto text-neutral-400 dark:text-neutral-500 mb-4" />
|
||||
<p className="text-neutral-700 dark:text-neutral-300 font-medium mb-1">
|
||||
<Upload className="w-12 h-12 mx-auto text-neutral-400 mb-4" />
|
||||
<p className="text-neutral-700 font-medium mb-1">
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-xs mt-2",
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500 dark:text-neutral-400"
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{remainingSlots === 0
|
||||
@@ -263,22 +245,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{/* Selected Files */}
|
||||
{selectedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
|
||||
<p className="text-sm font-medium text-neutral-700">
|
||||
{t('upload.selectedFiles')} ({selectedFiles.length})
|
||||
</p>
|
||||
<div className="max-h-48 overflow-y-auto space-y-2">
|
||||
{selectedFiles.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-neutral-50 dark:bg-neutral-800 rounded-lg"
|
||||
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Image className="w-5 h-5 text-neutral-400" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 truncate max-w-xs">
|
||||
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
|
||||
{file.name}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-xs text-neutral-500">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
@@ -288,7 +270,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
e.stopPropagation();
|
||||
removeFile(index);
|
||||
}}
|
||||
className="p-1 hover:bg-neutral-200 dark:hover:bg-neutral-700 rounded"
|
||||
className="p-1 hover:bg-neutral-200 rounded"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
@@ -313,21 +295,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{/* Progress Bar */}
|
||||
{isUploading && (
|
||||
<div className="mt-4">
|
||||
<div className="flex justify-between text-sm text-neutral-600 dark:text-neutral-400 mb-1">
|
||||
<div className="flex justify-between text-sm text-neutral-600 mb-1">
|
||||
<span>
|
||||
{t('upload.uploading')}
|
||||
{totalChunks > 1 && ` (${t('common.chunk')} ${currentChunk}/${totalChunks})`}
|
||||
</span>
|
||||
<span>{uploadProgress}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-2">
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${uploadProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
{totalChunks > 1 && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('upload.uploadingChunks', { count: selectedFiles.length, total: totalChunks })}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -30,10 +30,10 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white dark:bg-neutral-800 rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
|
||||
{/* Fixed Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200 dark:border-neutral-700">
|
||||
<h2 className="text-xl font-semibold text-neutral-900 dark:text-neutral-100">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
|
||||
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
|
||||
@@ -38,7 +38,7 @@ export const RestoreWizard = () => {
|
||||
{ id: 'backup', title: t('backup.restore.steps.chooseBackup') },
|
||||
{ id: 'options', title: t('backup.restore.steps.restoreOptions') },
|
||||
{ id: 'confirm', title: t('backup.restore.steps.reviewConfirm') },
|
||||
{ id: 'progress', title: t('backup.restore.steps.progress') }
|
||||
{ id: 'progress', title: t('backup.restore.steps.restoreProgress') }
|
||||
];
|
||||
|
||||
const restoreTypes = [
|
||||
@@ -187,8 +187,8 @@ export const RestoreWizard = () => {
|
||||
const renderSourceSelection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.source.title')}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.subtitle')}</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.source.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.source.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
@@ -196,52 +196,52 @@ export const RestoreWizard = () => {
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 'local' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
restoreData.source === 'local'
|
||||
? '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'
|
||||
? 'border-primary bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<HardDrive className={`h-12 w-12 mb-3 mx-auto ${
|
||||
restoreData.source === 'local' ? 'text-primary' : 'text-neutral-400'
|
||||
restoreData.source === 'local' ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.local.name')}</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.local.description')}</p>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.local.name')}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.local.description')}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 's3' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
restoreData.source === 's3'
|
||||
? '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'
|
||||
? 'border-primary bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Cloud className={`h-12 w-12 mb-3 mx-auto ${
|
||||
restoreData.source === 's3' ? 'text-primary' : 'text-neutral-400'
|
||||
restoreData.source === 's3' ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.s3.name')}</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.s3.description')}</p>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.s3.name')}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.s3.description')}</p>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, source: 'upload' }))}
|
||||
className={`p-6 rounded-lg border-2 transition-all ${
|
||||
restoreData.source === 'upload'
|
||||
? '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'
|
||||
? 'border-primary bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<Upload className={`h-12 w-12 mb-3 mx-auto ${
|
||||
restoreData.source === 'upload' ? 'text-primary' : 'text-neutral-400'
|
||||
restoreData.source === 'upload' ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.upload.name')}</h4>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">{t('backup.restore.source.upload.description')}</p>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.upload.name')}</h4>
|
||||
<p className="text-xs text-gray-500 mt-1">{t('backup.restore.source.upload.description')}</p>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Source-specific configuration */}
|
||||
{restoreData.source === 's3' && (
|
||||
<Card className="p-4 space-y-4">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.source.configuration.s3')}</h4>
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.source.configuration.s3')}</h4>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Input
|
||||
placeholder={t('backup.restore.source.configuration.endpoint')}
|
||||
@@ -283,8 +283,8 @@ export const RestoreWizard = () => {
|
||||
{restoreData.source === 'upload' && (
|
||||
<Card className="p-4">
|
||||
<div className="text-center py-8">
|
||||
<Upload className="h-12 w-12 mx-auto mb-3 text-neutral-400" />
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.source.upload.comingSoon')}</p>
|
||||
<Upload className="h-12 w-12 mx-auto mb-3 text-gray-400" />
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.source.upload.comingSoon')}</p>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
@@ -294,16 +294,16 @@ export const RestoreWizard = () => {
|
||||
const renderBackupSelection = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.backup.title')}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.backup.subtitle')}</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.backup.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.backup.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{loadingBackups ? (
|
||||
<Loading />
|
||||
) : availableBackups?.length === 0 ? (
|
||||
<Card className="p-8 text-center">
|
||||
<FileArchive className="h-12 w-12 mx-auto mb-3 text-neutral-300 dark:text-neutral-600" />
|
||||
<p className="text-neutral-500 dark:text-neutral-400">{t('backup.restore.backup.noBackupsFound')}</p>
|
||||
<FileArchive className="h-12 w-12 mx-auto mb-3 text-gray-300" />
|
||||
<p className="text-gray-500">{t('backup.restore.backup.noBackupsFound')}</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
@@ -312,7 +312,7 @@ export const RestoreWizard = () => {
|
||||
key={backup.id}
|
||||
className={`p-4 cursor-pointer transition-all ${
|
||||
restoreData.selectedBackup?.id === backup.id
|
||||
? 'ring-2 ring-primary bg-primary-50 dark:bg-primary-900/30'
|
||||
? 'ring-2 ring-primary bg-primary-50'
|
||||
: 'hover:shadow-md'
|
||||
}`}
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, selectedBackup: backup }))}
|
||||
@@ -320,25 +320,25 @@ export const RestoreWizard = () => {
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-4">
|
||||
<div className={`p-2 rounded-lg ${
|
||||
backup.status === 'completed' ? 'bg-green-100 dark:bg-green-900/40' : 'bg-amber-100 dark:bg-amber-900/40'
|
||||
backup.status === 'completed' ? 'bg-green-100' : 'bg-amber-100'
|
||||
}`}>
|
||||
{backup.status === 'completed' ? (
|
||||
<CheckCircle className="h-6 w-6 text-green-600 dark:text-green-400" />
|
||||
<CheckCircle className="h-6 w-6 text-green-600" />
|
||||
) : (
|
||||
<AlertCircle className="h-6 w-6 text-amber-600 dark:text-amber-400" />
|
||||
<AlertCircle className="h-6 w-6 text-amber-600" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<p className="font-medium text-gray-900">
|
||||
{format(new Date(backup.created_at), 'PPP')} {t('backup.restore.backup.at')} {format(new Date(backup.created_at), 'p')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-500">
|
||||
{t('backup.dashboard.backupType', { type: backup.backup_type })} • {formatBytes(backup.total_size || 0)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{backup.encrypted && (
|
||||
<Shield className="h-5 w-5 text-neutral-400" />
|
||||
<Shield className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
@@ -347,12 +347,12 @@ export const RestoreWizard = () => {
|
||||
)}
|
||||
|
||||
{restoreData.selectedBackup?.encrypted && (
|
||||
<Card className="p-4 bg-amber-50 dark:bg-amber-900/30 border-amber-200 dark:border-amber-800">
|
||||
<Card className="p-4 bg-amber-50 border-amber-200">
|
||||
<div className="flex items-start space-x-3">
|
||||
<Shield className="h-5 w-5 text-amber-600 dark:text-amber-400 mt-0.5" />
|
||||
<Shield className="h-5 w-5 text-amber-600 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-900 dark:text-amber-200">{t('backup.restore.backup.encrypted')}</p>
|
||||
<p className="text-sm text-amber-700 dark:text-amber-300 mt-1">
|
||||
<p className="text-sm font-medium text-amber-900">{t('backup.restore.backup.encrypted')}</p>
|
||||
<p className="text-sm text-amber-700 mt-1">
|
||||
{t('backup.restore.backup.encryptedMessage')}
|
||||
</p>
|
||||
<Input
|
||||
@@ -375,8 +375,8 @@ export const RestoreWizard = () => {
|
||||
const renderRestoreOptions = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.options.title')}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.options.subtitle')}</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.options.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.options.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -388,18 +388,18 @@ export const RestoreWizard = () => {
|
||||
onClick={() => setRestoreData(prev => ({ ...prev, restoreType: type.id }))}
|
||||
className={`p-4 rounded-lg border-2 text-left transition-all ${
|
||||
restoreData.restoreType === type.id
|
||||
? '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'
|
||||
? 'border-primary bg-primary-50'
|
||||
: 'border-gray-200 hover:border-gray-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start space-x-3">
|
||||
<Icon className={`h-6 w-6 mt-1 ${
|
||||
restoreData.restoreType === type.id ? 'text-primary' : 'text-neutral-400'
|
||||
restoreData.restoreType === type.id ? 'text-primary' : 'text-gray-400'
|
||||
}`} />
|
||||
<div className="flex-1">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{type.name}</h4>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mt-1">{type.description}</p>
|
||||
<p className="text-xs text-amber-600 dark:text-amber-400 mt-2">
|
||||
<h4 className="font-medium text-gray-900">{type.name}</h4>
|
||||
<p className="text-sm text-gray-600 mt-1">{type.description}</p>
|
||||
<p className="text-xs text-amber-600 mt-2">
|
||||
<AlertTriangle className="inline h-3 w-3 mr-1" />
|
||||
{type.warning}
|
||||
</p>
|
||||
@@ -412,8 +412,8 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Additional Options */}
|
||||
<Card className="p-4 space-y-4">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100">{t('backup.restore.options.additionalOptions.title')}</h4>
|
||||
|
||||
<h4 className="font-medium text-gray-900">{t('backup.restore.options.additionalOptions.title')}</h4>
|
||||
|
||||
<label className="flex items-start space-x-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
@@ -422,11 +422,11 @@ export const RestoreWizard = () => {
|
||||
...prev,
|
||||
skipPreBackup: e.target.checked
|
||||
}))}
|
||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.restore.options.additionalOptions.skipPreBackup')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm font-medium text-gray-700">{t('backup.restore.options.additionalOptions.skipPreBackup')}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('backup.restore.options.additionalOptions.skipPreBackupHelp')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -440,11 +440,11 @@ export const RestoreWizard = () => {
|
||||
...prev,
|
||||
force: e.target.checked
|
||||
}))}
|
||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-neutral-300 dark:border-neutral-600 rounded bg-white dark:bg-neutral-700"
|
||||
className="mt-1 h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded"
|
||||
/>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('backup.restore.options.additionalOptions.force')}</p>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<p className="text-sm font-medium text-gray-700">{t('backup.restore.options.additionalOptions.force')}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{t('backup.restore.options.additionalOptions.forceHelp')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -456,17 +456,17 @@ export const RestoreWizard = () => {
|
||||
const renderConfirmation = () => (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.confirmation.title')}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.subtitle')}</p>
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.confirmation.title')}</h3>
|
||||
<p className="text-sm text-gray-600">{t('backup.restore.confirmation.subtitle')}</p>
|
||||
</div>
|
||||
|
||||
{validationResult ? (
|
||||
<>
|
||||
{/* Validation Results */}
|
||||
<Card className={`p-4 ${
|
||||
validationResult.validation?.isValid
|
||||
? 'bg-green-50 dark:bg-green-900/30 border-green-200 dark:border-green-800'
|
||||
: 'bg-red-50 dark:bg-red-900/30 border-red-200 dark:border-red-800'
|
||||
validationResult.validation?.isValid
|
||||
? 'bg-green-50 border-green-200'
|
||||
: 'bg-red-50 border-red-200'
|
||||
}`}>
|
||||
<div className="flex items-start space-x-3">
|
||||
{validationResult.validation?.isValid ? (
|
||||
@@ -476,14 +476,14 @@ export const RestoreWizard = () => {
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className={`text-sm font-medium ${
|
||||
validationResult.validation?.isValid ? 'text-green-900 dark:text-green-200' : 'text-red-900 dark:text-red-200'
|
||||
validationResult.validation?.isValid ? 'text-green-900' : 'text-red-900'
|
||||
}`}>
|
||||
{validationResult.validation?.isValid
|
||||
? t('backup.restore.confirmation.validation.passed')
|
||||
: t('backup.restore.confirmation.validation.failed')}
|
||||
</p>
|
||||
{validationResult.validation?.errors?.length > 0 && (
|
||||
<ul className="mt-2 text-sm text-red-700 dark:text-red-300 list-disc list-inside">
|
||||
<ul className="mt-2 text-sm text-red-700 list-disc list-inside">
|
||||
{validationResult.validation.errors.map((error, idx) => (
|
||||
<li key={idx}>{error}</li>
|
||||
))}
|
||||
@@ -496,17 +496,17 @@ export const RestoreWizard = () => {
|
||||
{/* Space Check */}
|
||||
{validationResult.spaceCheck && (
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-3">{t('backup.restore.confirmation.spaceCheck.title')}</h4>
|
||||
<h4 className="font-medium text-gray-900 mb-3">{t('backup.restore.confirmation.spaceCheck.title')}</h4>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.required')}:</span>
|
||||
<span className="font-medium">
|
||||
{validationResult.spaceCheck.requiredFormatted || formatBytes(validationResult.spaceCheck.required || 0)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<span className="text-gray-600">{t('backup.restore.confirmation.spaceCheck.available')}:</span>
|
||||
<span className="font-medium">
|
||||
{validationResult.spaceCheck.availableFormatted ||
|
||||
(validationResult.spaceCheck.available != null ? formatBytes(validationResult.spaceCheck.available) : t('common.unknown', 'Unknown'))}
|
||||
</span>
|
||||
@@ -523,38 +523,38 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Summary */}
|
||||
<Card className="p-4">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-3">{t('backup.restore.confirmation.summary.title')}</h4>
|
||||
<h4 className="font-medium text-gray-900 mb-3">{t('backup.restore.confirmation.summary.title')}</h4>
|
||||
<dl className="space-y-2 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.source')}:</dt>
|
||||
<dd className="font-medium text-neutral-900 dark:text-neutral-100 capitalize">{restoreData.source}</dd>
|
||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.source')}:</dt>
|
||||
<dd className="font-medium capitalize">{restoreData.source}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
|
||||
<dd className="font-medium text-neutral-900 dark:text-neutral-100">
|
||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.backupDate')}:</dt>
|
||||
<dd className="font-medium">
|
||||
{format(new Date(restoreData.selectedBackup.created_at), 'PPp')}
|
||||
</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.restoreType')}:</dt>
|
||||
<dd className="font-medium text-neutral-900 dark:text-neutral-100 capitalize">{restoreData.restoreType}</dd>
|
||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.restoreType')}:</dt>
|
||||
<dd className="font-medium capitalize">{restoreData.restoreType}</dd>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<dt className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
|
||||
<dd className="font-medium text-neutral-900 dark:text-neutral-100">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
|
||||
<dt className="text-gray-600">{t('backup.restore.confirmation.summary.preBackup')}:</dt>
|
||||
<dd className="font-medium">{restoreData.skipPreBackup ? t('backup.restore.confirmation.summary.skipped') : t('backup.restore.confirmation.summary.enabled')}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-800 rounded-lg p-4">
|
||||
<div className="bg-amber-50 border border-amber-200 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 dark:text-amber-200">
|
||||
<h3 className="text-sm font-medium text-amber-800">
|
||||
{t('backup.restore.confirmation.warning.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-amber-700 dark:text-amber-300">
|
||||
<p className="mt-1 text-sm text-amber-700">
|
||||
{t('backup.restore.confirmation.warning.message')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -564,7 +564,7 @@ export const RestoreWizard = () => {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-primary" />
|
||||
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">{t('backup.restore.confirmation.validation.checking')}</p>
|
||||
<p className="mt-2 text-sm text-gray-600">{t('backup.restore.confirmation.validation.checking')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -577,8 +577,8 @@ export const RestoreWizard = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">{t('backup.restore.progress.title')}</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">{t('backup.restore.progress.title')}</h3>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isRunning ? t('backup.restore.progress.inProgress') : t('backup.restore.progress.completed')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -587,17 +587,17 @@ export const RestoreWizard = () => {
|
||||
<Card className="p-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-neutral-600 dark:text-neutral-400">{t('backup.restore.progress.overallProgress')}</span>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{progress.percentage || 0}%</span>
|
||||
<span className="text-gray-600">{t('backup.restore.progress.overallProgress')}</span>
|
||||
<span className="font-medium">{progress.percentage || 0}%</span>
|
||||
</div>
|
||||
<div className="w-full bg-neutral-200 dark:bg-neutral-700 rounded-full h-3">
|
||||
<div className="w-full bg-gray-200 rounded-full h-3">
|
||||
<div
|
||||
className="bg-primary h-3 rounded-full transition-all duration-500"
|
||||
style={{ width: `${progress.percentage || 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
{progress.currentFile && (
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<p className="text-sm text-gray-600">
|
||||
{t('backup.restore.progress.current')}: {progress.currentFile}
|
||||
</p>
|
||||
)}
|
||||
@@ -606,7 +606,7 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Status Details */}
|
||||
<Card className="p-6">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.restore.progress.statusDetails')}</h4>
|
||||
<h4 className="font-medium text-gray-900 mb-4">{t('backup.restore.progress.statusDetails')}</h4>
|
||||
<div className="space-y-3">
|
||||
{progress.steps?.map((step, idx) => (
|
||||
<div key={idx} className="flex items-center space-x-3">
|
||||
@@ -617,16 +617,16 @@ export const RestoreWizard = () => {
|
||||
) : step.status === 'failed' ? (
|
||||
<XCircle className="h-5 w-5 text-red-500" />
|
||||
) : (
|
||||
<Clock className="h-5 w-5 text-neutral-300 dark:text-neutral-600" />
|
||||
<Clock className="h-5 w-5 text-gray-300" />
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">{step.name}</p>
|
||||
<p className="text-sm font-medium text-gray-900">{step.name}</p>
|
||||
{step.message && (
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">{step.message}</p>
|
||||
<p className="text-xs text-gray-500">{step.message}</p>
|
||||
)}
|
||||
</div>
|
||||
{step.duration && (
|
||||
<span className="text-xs text-neutral-500 dark:text-neutral-400">{step.duration}</span>
|
||||
<span className="text-xs text-gray-500">{step.duration}</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
@@ -636,9 +636,9 @@ export const RestoreWizard = () => {
|
||||
{/* Logs */}
|
||||
{progress.logs && progress.logs.length > 0 && (
|
||||
<Card className="p-6">
|
||||
<h4 className="font-medium text-neutral-900 dark:text-neutral-100 mb-4">{t('backup.restore.progress.restoreLogs')}</h4>
|
||||
<div className="bg-neutral-900 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||
<pre className="text-xs text-neutral-300 font-mono">
|
||||
<h4 className="font-medium text-gray-900 mb-4">{t('backup.restore.progress.restoreLogs')}</h4>
|
||||
<div className="bg-gray-900 rounded-lg p-4 max-h-64 overflow-y-auto">
|
||||
<pre className="text-xs text-gray-300 font-mono">
|
||||
{progress.logs.join('\n')}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -647,14 +647,14 @@ export const RestoreWizard = () => {
|
||||
|
||||
{/* Completion Actions */}
|
||||
{!isRunning && progress.status === 'completed' && (
|
||||
<div className="bg-green-50 dark:bg-green-900/30 border border-green-200 dark:border-green-800 rounded-lg p-4">
|
||||
<div className="bg-green-50 border border-green-200 rounded-lg p-4">
|
||||
<div className="flex">
|
||||
<CheckCircle className="h-5 w-5 text-green-400 mt-0.5" />
|
||||
<div className="ml-3">
|
||||
<h3 className="text-sm font-medium text-green-800 dark:text-green-200">
|
||||
<h3 className="text-sm font-medium text-green-800">
|
||||
{t('backup.restore.progress.success.title')}
|
||||
</h3>
|
||||
<p className="mt-1 text-sm text-green-700 dark:text-green-300">
|
||||
<p className="mt-1 text-sm text-green-700">
|
||||
{t('backup.restore.progress.success.message')}
|
||||
</p>
|
||||
</div>
|
||||
@@ -684,11 +684,11 @@ export const RestoreWizard = () => {
|
||||
<div className="flex items-center">
|
||||
<div className={`
|
||||
relative flex h-8 w-8 items-center justify-center rounded-full
|
||||
${currentStep > stepIdx
|
||||
? 'bg-primary'
|
||||
: currentStep === stepIdx
|
||||
? 'bg-primary'
|
||||
: 'bg-neutral-300 dark:bg-neutral-600'
|
||||
${currentStep > stepIdx
|
||||
? 'bg-primary'
|
||||
: currentStep === stepIdx
|
||||
? 'bg-primary'
|
||||
: 'bg-gray-300'
|
||||
}
|
||||
`}>
|
||||
{currentStep > stepIdx ? (
|
||||
@@ -699,14 +699,14 @@ export const RestoreWizard = () => {
|
||||
</div>
|
||||
{stepIdx !== steps.length - 1 && (
|
||||
<div className={`
|
||||
absolute top-4 w-full h-0.5
|
||||
${currentStep > stepIdx ? 'bg-primary' : 'bg-neutral-300 dark:bg-neutral-600'}
|
||||
absolute top-4 w-full h-0.5
|
||||
${currentStep > stepIdx ? 'bg-primary' : 'bg-gray-300'}
|
||||
`} style={{ left: '2rem', right: '-2rem' }} />
|
||||
)}
|
||||
</div>
|
||||
<span className={`
|
||||
mt-2 text-xs font-medium
|
||||
${currentStep >= stepIdx ? 'text-neutral-900 dark:text-neutral-100' : 'text-neutral-500 dark:text-neutral-400'}
|
||||
${currentStep >= stepIdx ? 'text-gray-900' : 'text-gray-500'}
|
||||
`}>
|
||||
{step.title}
|
||||
</span>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode, ImageIcon, Minimize2, EyeOff, Menu, SlidersHorizontal, Columns, Film } from 'lucide-react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info, FileCode } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType, HeaderStyleType, HeroDividerStyle } from '../../types/theme.types';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
|
||||
import type { EnabledTemplate } from '../../services/cssTemplates.service';
|
||||
// import { settingsService } from '../../services/settings.service';
|
||||
// import { toast } from 'react-toastify';
|
||||
@@ -28,45 +28,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
masonry: <Layers className="w-5 h-5" />,
|
||||
carousel: <Play className="w-5 h-5" />,
|
||||
timeline: <Clock className="w-5 h-5" />,
|
||||
mosaic: <LayoutGrid className="w-5 h-5" />,
|
||||
'gallery-premium': <Columns className="w-5 h-5" />,
|
||||
'gallery-story': <Film className="w-5 h-5" />
|
||||
};
|
||||
|
||||
const headerStyleIcons: Record<HeaderStyleType, React.ReactNode> = {
|
||||
hero: <Image className="w-5 h-5" />,
|
||||
standard: <Layout className="w-5 h-5" />,
|
||||
minimal: <Minimize2 className="w-5 h-5" />,
|
||||
none: <EyeOff className="w-5 h-5" />
|
||||
};
|
||||
|
||||
const dividerStylePreviews: Record<HeroDividerStyle, React.ReactNode> = {
|
||||
wave: (
|
||||
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
|
||||
<path d="M0,12 C12,18 37,6 50,12 C63,18 88,6 100,12 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
|
||||
</svg>
|
||||
),
|
||||
straight: (
|
||||
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
|
||||
<rect x="0" y="12" width="100" height="12" fill="currentColor" className="text-neutral-300" />
|
||||
</svg>
|
||||
),
|
||||
angle: (
|
||||
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
|
||||
<path d="M0,24 L100,8 L100,24 Z" fill="currentColor" className="text-neutral-300" />
|
||||
</svg>
|
||||
),
|
||||
curve: (
|
||||
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
|
||||
<path d="M0,16 Q50,0 100,16 L100,24 L0,24 Z" fill="currentColor" className="text-neutral-300" />
|
||||
</svg>
|
||||
),
|
||||
none: (
|
||||
<svg className="w-full h-6" viewBox="0 0 100 24" preserveAspectRatio="none">
|
||||
<rect x="0" y="0" width="100" height="24" fill="currentColor" className="text-neutral-100" />
|
||||
<text x="50" y="16" textAnchor="middle" fontSize="10" fill="currentColor" className="text-neutral-400">No divider</text>
|
||||
</svg>
|
||||
)
|
||||
mosaic: <LayoutGrid className="w-5 h-5" />
|
||||
};
|
||||
|
||||
// Layout descriptions will use translation keys
|
||||
@@ -104,16 +67,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
||||
const updated = { ...localTheme, [key]: newValue };
|
||||
setLocalTheme(updated);
|
||||
|
||||
|
||||
// When any change is made, mark it as custom
|
||||
if (selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
|
||||
|
||||
if (isPreviewMode) {
|
||||
// Include customCss in the propagated theme
|
||||
onChange({ ...updated, customCss });
|
||||
onChange(updated);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -122,7 +84,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
if (preset) {
|
||||
setSelectedPreset(presetKey);
|
||||
setLocalTheme(preset.config);
|
||||
setCustomCss(''); // Clear custom CSS when selecting a preset
|
||||
if (onPresetChange) {
|
||||
onPresetChange(presetKey);
|
||||
}
|
||||
@@ -180,7 +141,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<div className="space-y-6">
|
||||
{/* Preset Themes */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Sparkles className="w-5 h-5" />
|
||||
{t('branding.themePresets')}
|
||||
</h3>
|
||||
@@ -191,15 +152,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handlePresetSelect(key)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
selectedPreset === key
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<span className="font-medium text-sm block text-neutral-900 dark:text-neutral-100">{theme.name}</span>
|
||||
<span className="font-medium text-sm block">{theme.name}</span>
|
||||
{theme.description && (
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">{theme.description}</span>
|
||||
<span className="text-xs text-neutral-600 mt-1 block">{theme.description}</span>
|
||||
)}
|
||||
</div>
|
||||
{selectedPreset === key && (
|
||||
@@ -209,15 +170,15 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<div className="flex items-center gap-2 mt-3">
|
||||
<div className="flex gap-1">
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
||||
className="w-5 h-5 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.primaryColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
||||
className="w-5 h-5 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.accentColor }}
|
||||
/>
|
||||
<div
|
||||
className="w-5 h-5 rounded-full border border-neutral-200 dark:border-neutral-600"
|
||||
className="w-5 h-5 rounded-full border border-neutral-200"
|
||||
style={{ backgroundColor: theme.config.backgroundColor }}
|
||||
/>
|
||||
</div>
|
||||
@@ -235,7 +196,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* Gallery Layout */}
|
||||
{showGalleryLayouts && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Layout className="w-5 h-5" />
|
||||
{t('branding.galleryLayout')}
|
||||
</h3>
|
||||
@@ -246,21 +207,16 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => handleChange('galleryLayout', layout)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
localTheme.galleryLayout === layout
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
|
||||
<div className="mb-2 text-neutral-700">
|
||||
{layoutIcons[layout]}
|
||||
</div>
|
||||
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
|
||||
{layout}
|
||||
{(layout === 'gallery-premium' || layout === 'gallery-story') && (
|
||||
<span className="ml-1 text-xs text-amber-600 dark:text-amber-400">(Beta)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
<span className="font-medium text-sm capitalize">{layout}</span>
|
||||
<span className="text-xs text-neutral-600 mt-1">
|
||||
{t(`branding.layoutDescriptions.${layout}`)}
|
||||
</span>
|
||||
</div>
|
||||
@@ -273,19 +229,19 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Layout-specific settings */}
|
||||
{localTheme.galleryLayout && (
|
||||
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h4 className="font-medium text-sm text-neutral-700 dark:text-neutral-300">{t('branding.layoutSettings')}</h4>
|
||||
<div className="mt-6 space-y-4 pt-6 border-t border-neutral-200">
|
||||
<h4 className="font-medium text-sm text-neutral-700">{t('branding.layoutSettings')}</h4>
|
||||
|
||||
{/* Common settings */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.photoSpacing')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.spacing || 'normal'}
|
||||
onChange={(e) => updateGallerySettings('spacing', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="tight">{t('branding.spacing.tight')}</option>
|
||||
<option value="normal">{t('branding.spacing.normal')}</option>
|
||||
@@ -294,13 +250,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.photoAnimation')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.photoAnimation || 'fade'}
|
||||
onChange={(e) => updateGallerySettings('photoAnimation', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.animation.none')}</option>
|
||||
<option value="fade">{t('branding.animation.fade')}</option>
|
||||
@@ -313,12 +269,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* Grid specific */}
|
||||
{localTheme.galleryLayout === 'grid' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.columns')}
|
||||
</label>
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.mobile')}</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.mobile')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="1"
|
||||
@@ -331,7 +287,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.tablet')}</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.tablet')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="2"
|
||||
@@ -344,7 +300,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-neutral-600 dark:text-neutral-400">{t('branding.desktop')}</label>
|
||||
<label className="text-xs text-neutral-600">{t('branding.desktop')}</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="3"
|
||||
@@ -371,12 +327,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onChange={(e) => updateGallerySettings('carouselAutoplay', e.target.checked)}
|
||||
className="rounded"
|
||||
/>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('branding.enableAutoplay')}</span>
|
||||
<span className="text-sm font-medium text-neutral-700">{t('branding.enableAutoplay')}</span>
|
||||
</label>
|
||||
</div>
|
||||
{localTheme.gallerySettings?.carouselAutoplay && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.autoplayInterval')}
|
||||
</label>
|
||||
<Input
|
||||
@@ -394,13 +350,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* Timeline specific */}
|
||||
{localTheme.galleryLayout === 'timeline' && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.groupPhotosBy')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.timelineGrouping || 'day'}
|
||||
onChange={(e) => updateGallerySettings('timelineGrouping', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="day">{t('branding.grouping.day')}</option>
|
||||
<option value="week">{t('branding.grouping.week')}</option>
|
||||
@@ -408,223 +364,6 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masonry specific */}
|
||||
{localTheme.galleryLayout === 'masonry' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.masonryMode', 'Layout Mode')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryMode || 'columns'}
|
||||
onChange={(e) => updateGallerySettings('masonryMode', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
|
||||
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Custom justified)')}</option>
|
||||
<option value="flickr">{t('branding.masonryModeOptions.flickr', 'Flickr (Battle-tested justified)')}</option>
|
||||
<option value="justified">{t('branding.masonryModeOptions.justified', 'Google Photos (Knuth-Plass algorithm)')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{localTheme.gallerySettings?.masonryMode === 'columns'
|
||||
? t('branding.masonryModeHint.columns', 'Pinterest-style vertical columns with varied heights')
|
||||
: localTheme.gallerySettings?.masonryMode === 'flickr'
|
||||
? t('branding.masonryModeHint.flickr', 'Flickr\'s open-source justified layout algorithm')
|
||||
: localTheme.gallerySettings?.masonryMode === 'justified'
|
||||
? t('branding.masonryModeHint.justified', 'Google Photos-style rows using Knuth-Plass algorithm for optimal breaks')
|
||||
: t('branding.masonryModeHint.rows', 'Custom row-based justified layout')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row-specific settings - show for all row-based modes */}
|
||||
{['rows', 'flickr', 'justified'].includes(localTheme.gallerySettings?.masonryMode || '') && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.targetRowHeight', 'Target Row Height')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="150"
|
||||
max="400"
|
||||
value={localTheme.gallerySettings?.masonryRowHeight || 250}
|
||||
onChange={(e) => updateGallerySettings('masonryRowHeight', parseInt(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
|
||||
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Last row behavior - only for rows and flickr modes */}
|
||||
{['rows', 'flickr'].includes(localTheme.gallerySettings?.masonryMode || '') && (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.lastRowBehavior', 'Last Row Alignment')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
|
||||
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 dark:border-neutral-600 rounded-lg bg-white dark:bg-neutral-800 text-neutral-900 dark:text-neutral-100"
|
||||
>
|
||||
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
|
||||
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
|
||||
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Header Style - Decoupled from Layout */}
|
||||
{showGalleryLayouts && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<ImageIcon className="w-5 h-5" />
|
||||
{t('branding.headerStyle', 'Header Style')}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('branding.headerStyleDescription', 'Choose how the gallery header appears. The header style is independent of the photo layout.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
{(Object.keys(headerStyleIcons) as HeaderStyleType[]).map((style) => (
|
||||
<button
|
||||
key={style}
|
||||
onClick={() => handleChange('headerStyle', style)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
(localTheme.headerStyle || 'standard') === style
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
|
||||
{headerStyleIcons[style]}
|
||||
</div>
|
||||
<span className="font-medium text-sm capitalize text-neutral-900 dark:text-neutral-100">
|
||||
{t(`branding.headerStyleOptions.${style}`, style)}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t(`branding.headerStyleDescriptions.${style}`, '')}
|
||||
</span>
|
||||
</div>
|
||||
{(localTheme.headerStyle || 'standard') === style && (
|
||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Divider Style - Only show when hero header is selected */}
|
||||
{localTheme.headerStyle === 'hero' && (
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<h4 className="font-medium text-sm text-neutral-700 dark:text-neutral-300 mb-3">
|
||||
{t('branding.heroDividerStyle', 'Divider Style')}
|
||||
</h4>
|
||||
<p className="text-xs text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('branding.heroDividerDescription', 'Choose how the transition between the hero image and gallery content looks.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-3">
|
||||
{(Object.keys(dividerStylePreviews) as HeroDividerStyle[]).map((divider) => (
|
||||
<button
|
||||
key={divider}
|
||||
onClick={() => handleChange('heroDividerStyle', divider)}
|
||||
className={`relative p-3 rounded-lg border-2 transition-all ${
|
||||
(localTheme.heroDividerStyle || 'wave') === divider
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<div className="w-full mb-2 bg-neutral-800 rounded-t overflow-hidden">
|
||||
<div className="h-8"></div>
|
||||
{dividerStylePreviews[divider]}
|
||||
</div>
|
||||
<span className="text-xs font-medium capitalize text-neutral-900 dark:text-neutral-100">
|
||||
{t(`branding.dividerOptions.${divider}`, divider)}
|
||||
</span>
|
||||
</div>
|
||||
{(localTheme.heroDividerStyle || 'wave') === divider && (
|
||||
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Controls Style */}
|
||||
{showGalleryLayouts && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<SlidersHorizontal className="w-5 h-5" />
|
||||
{t('branding.controlsStyle', 'Controls Style')}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
{t('branding.controlsStyleDescription', 'Choose how gallery filters and controls are displayed.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||
<button
|
||||
onClick={() => handleChange('controlsStyle', 'classic')}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
(localTheme.controlsStyle || 'classic') === 'classic'
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
|
||||
<SlidersHorizontal className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{t('branding.controlsStyleOptions.classic', 'Classic')}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('branding.controlsStyleDescriptions.classic', 'Inline filter bar below header')}
|
||||
</span>
|
||||
</div>
|
||||
{(localTheme.controlsStyle || 'classic') === 'classic' && (
|
||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleChange('controlsStyle', 'sidebar')}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all ${
|
||||
localTheme.controlsStyle === 'sidebar'
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center text-center">
|
||||
<div className="mb-2 text-neutral-700 dark:text-neutral-300">
|
||||
<Menu className="w-6 h-6" />
|
||||
</div>
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">
|
||||
{t('branding.controlsStyleOptions.sidebar', 'Sidebar')}
|
||||
</span>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1">
|
||||
{t('branding.controlsStyleDescriptions.sidebar', 'Menu button opens sidebar with filters')}
|
||||
</span>
|
||||
</div>
|
||||
{localTheme.controlsStyle === 'sidebar' && (
|
||||
<Check className="absolute top-2 right-2 w-4 h-4 text-primary-600" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{/* Info about hero header */}
|
||||
{localTheme.headerStyle === 'hero' && localTheme.controlsStyle !== 'sidebar' && (
|
||||
<div className="mt-4 p-3 bg-amber-50 dark:bg-amber-900/20 border border-amber-200 dark:border-amber-800 rounded-lg">
|
||||
<p className="text-sm text-amber-800 dark:text-amber-200 flex items-center gap-2">
|
||||
<Info className="w-4 h-4 flex-shrink-0" />
|
||||
{t('branding.controlsStyleHeroWarning', 'Sidebar is recommended for hero headers to prevent controls appearing above the hero image.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -632,69 +371,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Color Customization */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Palette className="w-5 h-5" />
|
||||
{t('branding.colors')}
|
||||
</h3>
|
||||
|
||||
{/* Color Mode Selector */}
|
||||
<div className="mb-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
{t('branding.colorMode', 'Color Mode')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
{(['light', 'dark', 'auto'] as const).map((mode) => (
|
||||
<button
|
||||
key={mode}
|
||||
onClick={() => {
|
||||
handleChange('colorMode', mode);
|
||||
// When switching to dark, auto-populate dark defaults if colors are still light
|
||||
if (mode === 'dark' && (!localTheme.backgroundColor || localTheme.backgroundColor === '#fafafa' || localTheme.backgroundColor === '#ffffff')) {
|
||||
const updated = {
|
||||
...localTheme,
|
||||
colorMode: mode,
|
||||
backgroundColor: '#0f0f0f',
|
||||
textColor: '#e5e5e5',
|
||||
surfaceColor: '#1a1a1a',
|
||||
surfaceBorderColor: '#2e2e2e',
|
||||
mutedTextColor: '#a3a3a3',
|
||||
};
|
||||
setLocalTheme(updated);
|
||||
if (isPreviewMode) onChange({ ...updated, customCss });
|
||||
} else if (mode === 'light' && localTheme.colorMode === 'dark') {
|
||||
const updated = {
|
||||
...localTheme,
|
||||
colorMode: mode,
|
||||
backgroundColor: '#fafafa',
|
||||
textColor: '#171717',
|
||||
surfaceColor: '#ffffff',
|
||||
surfaceBorderColor: '#e5e5e5',
|
||||
mutedTextColor: '#737373',
|
||||
};
|
||||
setLocalTheme(updated);
|
||||
if (isPreviewMode) onChange({ ...updated, customCss });
|
||||
}
|
||||
}}
|
||||
className={`px-4 py-2 text-sm font-medium rounded-lg border transition-colors ${
|
||||
(localTheme.colorMode || 'light') === mode
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300'
|
||||
: 'border-neutral-300 dark:border-neutral-600 text-neutral-600 dark:text-neutral-400 hover:bg-neutral-50 dark:hover:bg-neutral-800'
|
||||
}`}
|
||||
>
|
||||
{mode === 'light' ? t('branding.colorModeLight', 'Light') :
|
||||
mode === 'dark' ? t('branding.colorModeDark', 'Dark') :
|
||||
t('branding.colorModeAuto', 'Auto')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
{t('branding.colorModeHelp', 'Auto follows the visitor\'s system preference.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.primaryColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -702,7 +385,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
type="color"
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
onChange={(e) => handleChange('primaryColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.primaryColor || '#5C8762'}
|
||||
@@ -714,7 +397,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.accentColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -722,7 +405,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
type="color"
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
onChange={(e) => handleChange('accentColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.accentColor || '#22c55e'}
|
||||
@@ -734,7 +417,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.backgroundColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -742,7 +425,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
type="color"
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
onChange={(e) => handleChange('backgroundColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.backgroundColor || '#fafafa'}
|
||||
@@ -754,7 +437,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.textColor')}
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
@@ -762,7 +445,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
type="color"
|
||||
value={localTheme.textColor || '#171717'}
|
||||
onChange={(e) => handleChange('textColor', e.target.value)}
|
||||
className="h-10 w-20 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||
className="h-10 w-20 rounded border border-neutral-300 cursor-pointer"
|
||||
/>
|
||||
<Input
|
||||
value={localTheme.textColor || '#171717'}
|
||||
@@ -777,20 +460,20 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Typography & Style */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Type className="w-5 h-5" />
|
||||
{t('branding.typographyAndStyle')}
|
||||
</h3>
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.bodyFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => handleChange('fontFamily', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="Inter, sans-serif">Inter</option>
|
||||
<option value="system-ui, sans-serif">System UI</option>
|
||||
@@ -803,13 +486,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.headingFont')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'}
|
||||
onChange={(e) => handleChange('headingFontFamily', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="">{t('branding.sameAsBody')}</option>
|
||||
<option value="'Playfair Display', serif">Playfair Display</option>
|
||||
@@ -823,13 +506,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* Row 1: Font Size & Border Radius */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.fontSize')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.fontSize || 'normal'}
|
||||
onChange={(e) => handleChange('fontSize', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="small">{t('branding.fontSizes.small')}</option>
|
||||
<option value="normal">{t('branding.fontSizes.normal')}</option>
|
||||
@@ -838,13 +521,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.borderRadius')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.borderRadius || 'md'}
|
||||
onChange={(e) => handleChange('borderRadius', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.borderRadiusOptions.none')}</option>
|
||||
<option value="sm">{t('branding.borderRadiusOptions.small')}</option>
|
||||
@@ -857,13 +540,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* Row 2: Shadow Style & Background Pattern */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.shadowStyle')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.shadowStyle || 'normal'}
|
||||
onChange={(e) => handleChange('shadowStyle', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.shadowOptions.none')}</option>
|
||||
<option value="subtle">{t('branding.shadowOptions.subtle')}</option>
|
||||
@@ -873,13 +556,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.backgroundPattern')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.backgroundPattern || 'none'}
|
||||
onChange={(e) => handleChange('backgroundPattern', 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"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="none">{t('branding.backgroundOptions.none')}</option>
|
||||
<option value="dots">{t('branding.backgroundOptions.dots')}</option>
|
||||
@@ -894,11 +577,11 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
{/* CSS Template Selector - only show if templates are provided */}
|
||||
{cssTemplates && cssTemplates.length > 0 && onCssTemplateChange && (
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<FileCode className="w-5 h-5" />
|
||||
{t('branding.cssTemplate', 'CSS Template')}
|
||||
</h3>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400 mb-4">
|
||||
<p className="text-sm text-neutral-600 mb-4">
|
||||
{t('branding.cssTemplateDescription', 'Select a pre-built CSS template to apply application-wide styling to this gallery. Templates can be managed in Settings > CSS Templates.')}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
@@ -907,17 +590,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => onCssTemplateChange(null)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
!cssTemplateId
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{t('branding.noTemplate', 'No Template')}</span>
|
||||
<span className="font-medium text-sm">{t('branding.noTemplate', 'No Template')}</span>
|
||||
{!cssTemplateId && (
|
||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
||||
<span className="text-xs text-neutral-600 mt-1 block">
|
||||
{t('branding.noTemplateDescription', 'Use only theme settings without a CSS template')}
|
||||
</span>
|
||||
</button>
|
||||
@@ -928,17 +611,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
onClick={() => onCssTemplateChange(template.id)}
|
||||
className={`relative p-4 rounded-lg border-2 transition-all text-left ${
|
||||
cssTemplateId === template.id
|
||||
? 'border-primary-600 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600'
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium text-sm text-neutral-900 dark:text-neutral-100">{template.name}</span>
|
||||
<span className="font-medium text-sm">{template.name}</span>
|
||||
{cssTemplateId === template.id && (
|
||||
<Check className="w-4 h-4 text-primary-600 flex-shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
<span className="text-xs text-neutral-600 dark:text-neutral-400 mt-1 block">
|
||||
<span className="text-xs text-neutral-600 mt-1 block">
|
||||
{t('branding.templateSlot', 'Slot {{slot}}', { slot: template.slot_number })}
|
||||
</span>
|
||||
</button>
|
||||
@@ -949,7 +632,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Event-specific Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-4 flex items-center gap-2">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Code className="w-5 h-5" />
|
||||
{t('branding.eventCustomCSS', 'Event-specific Custom CSS')}
|
||||
</h3>
|
||||
@@ -967,13 +650,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</button>
|
||||
|
||||
{showCssInstructions && (
|
||||
<div className="mt-3 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm space-y-4">
|
||||
<div className="mt-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200 text-sm space-y-4">
|
||||
{/* Available CSS Variables */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
|
||||
</h4>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
|
||||
<p className="text-neutral-600 mb-2">
|
||||
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
@@ -988,10 +671,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Custom Gallery Layouts */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
|
||||
</h4>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
|
||||
<p className="text-neutral-600 mb-2">
|
||||
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
@@ -1007,10 +690,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Glassmorphism Example */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
|
||||
</h4>
|
||||
<p className="text-neutral-600 dark:text-neutral-400 mb-2">
|
||||
<p className="text-neutral-600 mb-2">
|
||||
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
@@ -1025,9 +708,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-50 dark:bg-blue-900/30 border border-blue-200 dark:border-blue-800 rounded-lg">
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-blue-800 dark:text-blue-200 text-xs">
|
||||
<div className="text-blue-800 text-xs">
|
||||
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
|
||||
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
|
||||
</div>
|
||||
@@ -1039,22 +722,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => {
|
||||
const newCss = e.target.value;
|
||||
setCustomCss(newCss);
|
||||
setCustomCss(e.target.value);
|
||||
// Mark as custom when CSS is added
|
||||
if (newCss && selectedPreset !== 'custom' && onPresetChange) {
|
||||
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
// Propagate customCss changes to parent in preview mode
|
||||
if (isPreviewMode) {
|
||||
onChange({ ...localTheme, customCss: newCss });
|
||||
}
|
||||
}}
|
||||
placeholder="/* Add custom CSS here */"
|
||||
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 dark:border-neutral-600 rounded-lg bg-neutral-50 dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
{t('branding.customCSSHelp')}
|
||||
</p>
|
||||
</Card>
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import React from 'react';
|
||||
import {
|
||||
Palette,
|
||||
Type,
|
||||
Grid3X3,
|
||||
Layers,
|
||||
Play,
|
||||
Clock,
|
||||
import {
|
||||
Palette,
|
||||
Type,
|
||||
Grid3X3,
|
||||
Layers,
|
||||
Play,
|
||||
Clock,
|
||||
Image,
|
||||
LayoutGrid,
|
||||
Layout,
|
||||
Columns,
|
||||
Film
|
||||
Layout
|
||||
} from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -26,9 +25,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
masonry: <Layers className="w-4 h-4" />,
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||
'gallery-premium': <Columns className="w-4 h-4" />,
|
||||
'gallery-story': <Film className="w-4 h-4" />
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
@@ -82,64 +80,64 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
{/* Theme Name & Layout */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-100">{themeName}</span>
|
||||
<Layout className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-700">{themeName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600 dark:text-neutral-200">
|
||||
<div className="flex items-center gap-2 text-sm text-neutral-600">
|
||||
{layoutIcons[galleryLayout]}
|
||||
<span className="capitalize">{t(`branding.layoutDescriptions.${galleryLayout}`)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{showDetails && (
|
||||
<>
|
||||
{/* Color Palette */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.colors')}:</span>
|
||||
<Palette className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.colors')}:</span>
|
||||
<div className="flex gap-1">
|
||||
{themeConfig.primaryColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.primaryColor }}
|
||||
title={t('branding.primaryColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.accentColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.accentColor }}
|
||||
title={t('branding.accentColor')}
|
||||
/>
|
||||
)}
|
||||
{themeConfig.backgroundColor && (
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300 dark:border-neutral-600"
|
||||
<div
|
||||
className="w-6 h-6 rounded border border-neutral-300"
|
||||
style={{ backgroundColor: themeConfig.backgroundColor }}
|
||||
title={t('branding.backgroundColor')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Typography */}
|
||||
{themeConfig.fontFamily && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Type className="w-4 h-4 text-neutral-500 dark:text-neutral-300" />
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-200">{t('branding.bodyFont')}:</span>
|
||||
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-100" style={{ fontFamily: themeConfig.fontFamily }}>
|
||||
<Type className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-600">{t('branding.bodyFont')}:</span>
|
||||
<span className="text-sm font-medium" style={{ fontFamily: themeConfig.fontFamily }}>
|
||||
{themeConfig.fontFamily}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/* Layout Settings */}
|
||||
{themeConfig.gallerySettings && (
|
||||
<div className="text-sm text-neutral-600 dark:text-neutral-200">
|
||||
<div className="text-sm text-neutral-600">
|
||||
{themeConfig.gallerySettings.spacing && (
|
||||
<span className="inline-flex items-center gap-1 mr-3">
|
||||
<span>{t('branding.photoSpacing')}:</span>
|
||||
<span className="font-medium capitalize text-neutral-700 dark:text-neutral-100">
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.spacing.${themeConfig.gallerySettings.spacing}`)}
|
||||
</span>
|
||||
</span>
|
||||
@@ -147,7 +145,7 @@ export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
{themeConfig.gallerySettings.photoAnimation && themeConfig.gallerySettings.photoAnimation !== 'none' && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<span>{t('branding.photoAnimation')}:</span>
|
||||
<span className="font-medium capitalize text-neutral-700 dark:text-neutral-100">
|
||||
<span className="font-medium capitalize">
|
||||
{t(`branding.animation.${themeConfig.gallerySettings.photoAnimation}`)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, LayoutGrid, Check, Columns, Film } from 'lucide-react';
|
||||
import { X, Save, RotateCcw, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, Check } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
import { GalleryPreview } from './GalleryPreview';
|
||||
@@ -21,9 +21,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
masonry: <Layers className="w-4 h-4" />,
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||
'gallery-premium': <Columns className="w-4 h-4" />,
|
||||
'gallery-story': <Film className="w-4 h-4" />
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
@@ -177,12 +176,7 @@ export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
<div className="text-neutral-700">
|
||||
{layoutIcons[layout]}
|
||||
</div>
|
||||
<span className="text-xs capitalize">
|
||||
{layout}
|
||||
{(layout === 'gallery-premium' || layout === 'gallery-story') && (
|
||||
<span className="ml-0.5 text-amber-600">(Beta)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="text-xs capitalize">{layout}</span>
|
||||
</div>
|
||||
{(previewLayout || theme.galleryLayout || 'grid') === layout && (
|
||||
<Check className="absolute top-1 right-1 w-3 h-3 text-primary-600" />
|
||||
|
||||
@@ -56,25 +56,25 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
||||
: t('admin.updates.channelStable', 'Stable');
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 dark:bg-blue-900/30 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start">
|
||||
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-blue-800 dark:text-blue-200">
|
||||
<h4 className="text-sm font-semibold text-blue-800">
|
||||
{t('admin.updates.available', 'Update Available')}
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 mt-1">
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
{t('admin.updates.newVersion', 'Version {{version}} is available', {
|
||||
version: updateInfo.latest.forChannel
|
||||
})}
|
||||
<span className="text-blue-500 dark:text-blue-400 ml-2">
|
||||
<span className="text-blue-500 ml-2">
|
||||
({t('admin.updates.currentVersion', 'Current: {{version}}', {
|
||||
version: updateInfo.current
|
||||
})})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 dark:text-blue-400 mt-1">
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{t('admin.updates.channel', 'Channel: {{channel}}', {
|
||||
channel: channelLabel
|
||||
})}
|
||||
@@ -83,7 +83,7 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
||||
href="https://github.com/the-luap/picpeak/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-xs text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 mt-2"
|
||||
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||
>
|
||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
@@ -92,7 +92,7 @@ export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismis
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-blue-400 hover:text-blue-600 dark:hover:text-blue-300 p-1"
|
||||
className="text-blue-400 hover:text-blue-600 p-1"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
|
||||
@@ -43,23 +43,23 @@ export const WelcomeMessageEditor: React.FC<WelcomeMessageEditorProps> = ({
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
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 placeholder-neutral-400 dark:placeholder-neutral-500 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 transition-colors resize-none font-mono text-sm"
|
||||
/>
|
||||
<div className="absolute top-2 right-2 text-neutral-400" title="Line breaks will be preserved in emails">
|
||||
<HelpCircle className="w-4 h-4" aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-xs text-neutral-500">
|
||||
Tip: Press Enter to create a new line. Each line will appear as a separate paragraph in emails.
|
||||
</div>
|
||||
|
||||
{value && (
|
||||
<div className="mt-4">
|
||||
<p className="text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">Preview:</p>
|
||||
<div className="p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700">
|
||||
<div
|
||||
className="text-sm text-neutral-700 dark:text-neutral-300 whitespace-pre-wrap"
|
||||
<p className="text-sm font-medium text-neutral-700 mb-2">Preview:</p>
|
||||
<div className="p-4 bg-neutral-50 rounded-lg border border-neutral-200">
|
||||
<div
|
||||
className="text-sm text-neutral-700 whitespace-pre-wrap"
|
||||
dangerouslySetInnerHTML={{ __html: getPreviewHtml() }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -151,15 +151,15 @@ export const WordFilterManager: React.FC = () => {
|
||||
const getSeverityBadgeClass = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'low':
|
||||
return 'bg-blue-100 dark:bg-blue-900/40 text-blue-800 dark:text-blue-300';
|
||||
return 'bg-blue-100 text-blue-800';
|
||||
case 'moderate':
|
||||
return 'bg-yellow-100 dark:bg-yellow-900/40 text-yellow-800 dark:text-yellow-300';
|
||||
return 'bg-yellow-100 text-yellow-800';
|
||||
case 'high':
|
||||
return 'bg-orange-100 dark:bg-orange-900/40 text-orange-800 dark:text-orange-300';
|
||||
return 'bg-orange-100 text-orange-800';
|
||||
case 'block':
|
||||
return 'bg-red-100 dark:bg-red-900/40 text-red-800 dark:text-red-300';
|
||||
return 'bg-red-100 text-red-800';
|
||||
default:
|
||||
return 'bg-neutral-100 dark:bg-neutral-700 text-neutral-800 dark:text-neutral-300';
|
||||
return 'bg-gray-100 text-gray-800';
|
||||
}
|
||||
};
|
||||
|
||||
@@ -182,17 +182,17 @@ export const WordFilterManager: React.FC = () => {
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100 mb-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
||||
{t('settings.moderation.wordFilters', 'Word Filters')}
|
||||
</h2>
|
||||
<p className="text-sm text-neutral-600 dark:text-neutral-400">
|
||||
<p className="text-sm text-neutral-600">
|
||||
{t('settings.moderation.description', 'Manage words that should be filtered or blocked in comments')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Add new filter */}
|
||||
<div className="mb-6 p-4 bg-neutral-50 dark:bg-neutral-800 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-neutral-900 dark:text-neutral-100 dark:text-neutral-100 mb-3">
|
||||
<div className="mb-6 p-4 bg-neutral-50 rounded-lg">
|
||||
<h3 className="text-sm font-medium text-neutral-900 mb-3">
|
||||
{t('settings.moderation.addFilter', 'Add New Filter')}
|
||||
</h3>
|
||||
<div className="flex gap-3">
|
||||
@@ -207,7 +207,7 @@ export const WordFilterManager: React.FC = () => {
|
||||
<select
|
||||
value={newSeverity}
|
||||
onChange={(e) => setNewSeverity(e.target.value as any)}
|
||||
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:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
||||
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
||||
@@ -239,7 +239,7 @@ export const WordFilterManager: React.FC = () => {
|
||||
{/* Filters list */}
|
||||
<div className="space-y-2">
|
||||
{filteredFilters.length === 0 ? (
|
||||
<div className="text-center py-8 text-neutral-500 dark:text-neutral-400">
|
||||
<div className="text-center py-8 text-neutral-500">
|
||||
{searchTerm ?
|
||||
t('settings.moderation.noMatchingFilters', 'No matching filters found') :
|
||||
t('settings.moderation.noFilters', 'No word filters configured yet')
|
||||
@@ -250,7 +250,7 @@ export const WordFilterManager: React.FC = () => {
|
||||
<div
|
||||
key={filter.id}
|
||||
className={`flex items-center justify-between p-3 rounded-lg border ${
|
||||
filter.is_active ? 'border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800' : 'border-neutral-100 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-900 opacity-60'
|
||||
filter.is_active ? 'border-neutral-200 bg-white' : 'border-neutral-100 bg-neutral-50 opacity-60'
|
||||
}`}
|
||||
>
|
||||
{editingId === filter.id ? (
|
||||
@@ -265,7 +265,7 @@ export const WordFilterManager: React.FC = () => {
|
||||
<select
|
||||
value={editSeverity}
|
||||
onChange={(e) => setEditSeverity(e.target.value as any)}
|
||||
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:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<option value="low">{t('settings.moderation.severityLow', 'Low')}</option>
|
||||
<option value="moderate">{t('settings.moderation.severityModerate', 'Moderate')}</option>
|
||||
@@ -302,7 +302,7 @@ export const WordFilterManager: React.FC = () => {
|
||||
onChange={() => handleToggleActive(filter)}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{filter.word}</span>
|
||||
<span className="font-medium text-neutral-900">{filter.word}</span>
|
||||
<span className={`inline-flex items-center gap-1 px-2 py-1 rounded-full text-xs font-medium ${getSeverityBadgeClass(filter.severity)}`}>
|
||||
{getSeverityIcon(filter.severity)}
|
||||
{filter.severity}
|
||||
@@ -340,15 +340,15 @@ export const WordFilterManager: React.FC = () => {
|
||||
{/* Severity explanation */}
|
||||
<Card>
|
||||
<div className="p-6">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100 mb-3">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3">
|
||||
{t('settings.moderation.severityLevels', 'Severity Levels')}
|
||||
</h3>
|
||||
<div className="space-y-2 text-sm">
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('low')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityLow', 'Low')}: </span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityLow', 'Low')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.lowDescription', 'Word is flagged for review but not automatically blocked')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -356,8 +356,8 @@ export const WordFilterManager: React.FC = () => {
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('moderate')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityModerate', 'Moderate')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.moderateDescription', 'Comment requires manual approval before being visible')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -365,8 +365,8 @@ export const WordFilterManager: React.FC = () => {
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('high')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityHigh', 'High')}: </span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityHigh', 'High')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.highDescription', 'Comment is automatically hidden and requires admin review')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -374,8 +374,8 @@ export const WordFilterManager: React.FC = () => {
|
||||
<div className="flex items-start gap-3">
|
||||
{getSeverityIcon('block')}
|
||||
<div>
|
||||
<span className="font-medium text-neutral-900 dark:text-neutral-100">{t('settings.moderation.severityBlock', 'Block')}: </span>
|
||||
<span className="text-neutral-600 dark:text-neutral-400">
|
||||
<span className="font-medium text-neutral-900">{t('settings.moderation.severityBlock', 'Block')}: </span>
|
||||
<span className="text-neutral-600">
|
||||
{t('settings.moderation.blockDescription', 'Comment is rejected immediately and cannot be submitted')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -22,7 +22,6 @@ export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
|
||||
export { ThemeDisplay } from './ThemeDisplay';
|
||||
export { ThemeEditorModal } from './ThemeEditorModal';
|
||||
export { HeroPhotoSelector } from './HeroPhotoSelector';
|
||||
export { FocalPointPicker } from './FocalPointPicker';
|
||||
export { PhotoUploadModal } from './PhotoUploadModal';
|
||||
export { GalleryPreview } from './GalleryPreview';
|
||||
export { BackupDashboard } from './BackupDashboard';
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
@@ -29,7 +29,6 @@ interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImage
|
||||
detectDevTools?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
@@ -55,7 +54,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
onLoad,
|
||||
...props
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
@@ -223,7 +221,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
drawToCanvas();
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
@@ -238,7 +235,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -285,5 +282,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
|
||||
return <img src={imageSrc} alt={alt} {...props} />;
|
||||
};
|
||||
|
||||
@@ -32,7 +32,7 @@ export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
primary: 'btn-primary',
|
||||
secondary: 'btn-secondary',
|
||||
outline: 'btn-outline',
|
||||
ghost: 'bg-transparent hover:bg-neutral-100 dark:hover:bg-neutral-700 text-neutral-600 dark:text-neutral-300',
|
||||
ghost: 'bg-transparent hover:bg-neutral-100 text-neutral-700',
|
||||
};
|
||||
|
||||
const sizes = {
|
||||
|
||||
@@ -57,9 +57,9 @@ export const CardHeader: React.FC<CardHeaderProps> = ({
|
||||
{...props}
|
||||
>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{title}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900">{title}</h3>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-sm text-neutral-500 dark:text-neutral-400">{subtitle}</p>
|
||||
<p className="mt-1 text-sm text-neutral-500">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
{action && <div className="ml-4">{action}</div>}
|
||||
@@ -95,7 +95,7 @@ export const CardFooter: React.FC<CardFooterProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'mt-6 pt-6 border-t border-neutral-200 dark:border-neutral-700',
|
||||
'mt-6 pt-6 border-t border-neutral-200',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -2,8 +2,6 @@ import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
|
||||
|
||||
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
|
||||
|
||||
export const DynamicFavicon: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
@@ -21,7 +19,6 @@ export const DynamicFavicon: React.FC = () => {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Update favicon when branding settings change
|
||||
useEffect(() => {
|
||||
if (settings?.branding_favicon_url) {
|
||||
// Remove existing favicon links
|
||||
@@ -32,27 +29,13 @@ export const DynamicFavicon: React.FC = () => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/png';
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
: buildResourceUrl(settings.branding_favicon_url);
|
||||
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
// Update document title when company name or tagline changes
|
||||
useEffect(() => {
|
||||
const companyName = settings?.branding_company_name?.trim();
|
||||
const tagline = settings?.branding_company_tagline?.trim();
|
||||
|
||||
if (companyName && tagline) {
|
||||
document.title = `${companyName} - ${tagline}`;
|
||||
} else if (companyName) {
|
||||
document.title = companyName;
|
||||
} else {
|
||||
document.title = DEFAULT_TITLE;
|
||||
}
|
||||
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -30,7 +30,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
{label && (
|
||||
<label
|
||||
htmlFor={inputId}
|
||||
className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1.5"
|
||||
className="block text-sm font-medium text-neutral-700 mb-1.5"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
@@ -38,7 +38,7 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<div className="relative">
|
||||
{leftIcon && (
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{leftIcon}</span>
|
||||
<span className="text-neutral-500">{leftIcon}</span>
|
||||
</div>
|
||||
)}
|
||||
<input
|
||||
@@ -59,17 +59,17 @@ export const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
/>
|
||||
{rightIcon && (
|
||||
<div className="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||
<span className="text-neutral-500 dark:text-neutral-400">{rightIcon}</span>
|
||||
<span className="text-neutral-500">{rightIcon}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600 dark:text-red-400">
|
||||
<p id={`${inputId}-error`} className="mt-1.5 text-sm text-red-600">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
{helperText && !error && (
|
||||
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500 dark:text-neutral-400">
|
||||
<p id={`${inputId}-helper`} className="mt-1.5 text-sm text-neutral-500">
|
||||
{helperText}
|
||||
</p>
|
||||
)}
|
||||
|
||||
@@ -41,7 +41,7 @@ export const LanguageSelector: React.FC = () => {
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 dark:text-neutral-200 bg-white dark:bg-neutral-800 border border-neutral-300 dark:border-neutral-600 rounded-lg hover:bg-neutral-50 dark:hover:bg-neutral-700 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
className="flex items-center gap-2 px-3 py-2 text-sm font-medium text-neutral-700 bg-white border border-neutral-300 rounded-lg hover:bg-neutral-50 focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||
>
|
||||
<Globe className="w-4 h-4" />
|
||||
<currentLanguage.Flag className="w-5 h-5" />
|
||||
@@ -49,15 +49,15 @@ export const LanguageSelector: React.FC = () => {
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white dark:bg-neutral-800 rounded-lg shadow-lg border border-neutral-200 dark:border-neutral-700 py-1 z-50">
|
||||
<div className="absolute right-0 mt-2 w-48 bg-white rounded-lg shadow-lg border border-neutral-200 py-1 z-50">
|
||||
{languages.map((language) => (
|
||||
<button
|
||||
key={language.code}
|
||||
onClick={() => handleLanguageChange(language.code)}
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-700 flex items-center gap-3 ${
|
||||
className={`w-full text-left px-4 py-2 text-sm hover:bg-neutral-50 flex items-center gap-3 ${
|
||||
language.code === i18n.language
|
||||
? 'text-primary-600 dark:text-primary-400 bg-primary-50 dark:bg-primary-900/30'
|
||||
: 'text-neutral-700 dark:text-neutral-300'
|
||||
? 'text-primary-600 bg-primary-50'
|
||||
: 'text-neutral-700'
|
||||
}`}
|
||||
>
|
||||
<language.Flag className="w-5 h-5" />
|
||||
|
||||
@@ -1,52 +0,0 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl } from '../../utils/url';
|
||||
|
||||
export const RobotsMetaTags: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
const response = await fetch(`${getApiBaseUrl()}/public/settings`);
|
||||
if (response.ok) {
|
||||
return response.json();
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
// Remove any existing robots meta tags we previously injected
|
||||
document.querySelectorAll('meta[name="robots"][data-picpeak]').forEach(el => el.remove());
|
||||
|
||||
const directives: string[] = [];
|
||||
if (settings?.seo_meta_noindex) directives.push('noindex');
|
||||
if (settings?.seo_meta_nofollow) directives.push('nofollow');
|
||||
|
||||
if (directives.length > 0) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'robots';
|
||||
meta.content = directives.join(', ');
|
||||
meta.setAttribute('data-picpeak', 'true');
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
|
||||
if (settings?.seo_meta_noai) {
|
||||
const metaAi = document.createElement('meta');
|
||||
metaAi.name = 'robots';
|
||||
metaAi.content = 'noai, noimageai';
|
||||
metaAi.setAttribute('data-picpeak', 'true');
|
||||
document.head.appendChild(metaAi);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.querySelectorAll('meta[name="robots"][data-picpeak]').forEach(el => el.remove());
|
||||
};
|
||||
}, [settings?.seo_meta_noindex, settings?.seo_meta_nofollow, settings?.seo_meta_noai]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -1,62 +1,51 @@
|
||||
import React from 'react';
|
||||
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
import { ProtectedImage } from '../ProtectedImage';
|
||||
|
||||
// Create a stable mock context (same reference for all getContext calls)
|
||||
const mockContext = {
|
||||
clearRect: vi.fn(),
|
||||
drawImage: vi.fn(),
|
||||
getImageData: vi.fn(() => ({
|
||||
data: new Uint8ClampedArray(400).fill(255)
|
||||
// Mock canvas and image APIs
|
||||
const mockCanvas = {
|
||||
getContext: jest.fn(() => ({
|
||||
clearRect: jest.fn(),
|
||||
drawImage: jest.fn(),
|
||||
getImageData: jest.fn(() => ({
|
||||
data: new Uint8ClampedArray(4).fill(255)
|
||||
})),
|
||||
putImageData: jest.fn(),
|
||||
fillRect: jest.fn(),
|
||||
fillText: jest.fn(),
|
||||
strokeText: jest.fn(),
|
||||
measureText: jest.fn(() => ({ width: 100 }))
|
||||
})),
|
||||
putImageData: vi.fn(),
|
||||
fillRect: vi.fn(),
|
||||
fillText: vi.fn(),
|
||||
strokeText: vi.fn(),
|
||||
measureText: vi.fn(() => ({ width: 100 })),
|
||||
globalAlpha: 1.0,
|
||||
globalCompositeOperation: 'source-over',
|
||||
font: '',
|
||||
fillStyle: '',
|
||||
strokeStyle: '',
|
||||
lineWidth: 1,
|
||||
textAlign: 'center',
|
||||
textBaseline: 'middle',
|
||||
shadowColor: 'transparent',
|
||||
shadowBlur: 0,
|
||||
shadowOffsetX: 0,
|
||||
shadowOffsetY: 0,
|
||||
width: 100,
|
||||
height: 100,
|
||||
style: {},
|
||||
addEventListener: jest.fn(),
|
||||
removeEventListener: jest.fn()
|
||||
};
|
||||
|
||||
// Mock HTMLCanvasElement.getContext to always return our stable context
|
||||
// Mock HTMLCanvasElement
|
||||
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
|
||||
value: () => mockContext,
|
||||
writable: true,
|
||||
value: () => mockCanvas.getContext()
|
||||
});
|
||||
|
||||
// Default Image mock that simulates successful loading
|
||||
const createSuccessImage = () => {
|
||||
return class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
naturalWidth = 100;
|
||||
naturalHeight = 100;
|
||||
width = 100;
|
||||
height = 100;
|
||||
crossOrigin = '';
|
||||
complete = true;
|
||||
// Mock Image constructor
|
||||
global.Image = class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
src = '';
|
||||
naturalWidth = 100;
|
||||
naturalHeight = 100;
|
||||
width = 100;
|
||||
height = 100;
|
||||
crossOrigin = '';
|
||||
|
||||
constructor() {
|
||||
setTimeout(() => {
|
||||
if (this.onload) this.onload();
|
||||
}, 10);
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
};
|
||||
|
||||
global.Image = createSuccessImage();
|
||||
constructor() {
|
||||
// Simulate image loading
|
||||
setTimeout(() => {
|
||||
if (this.onload) this.onload();
|
||||
}, 10);
|
||||
}
|
||||
} as any;
|
||||
|
||||
describe('ProtectedImage', () => {
|
||||
const defaultProps = {
|
||||
@@ -65,34 +54,28 @@ describe('ProtectedImage', () => {
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
// Reset Image mock to success variant
|
||||
global.Image = createSuccessImage();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('renders canvas with loading styles initially', () => {
|
||||
it('renders loading state initially', () => {
|
||||
render(<ProtectedImage {...defaultProps} />);
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toBeInTheDocument();
|
||||
// While loading, canvas has opacity 0
|
||||
expect(canvas).toHaveStyle({ opacity: '0' });
|
||||
expect(screen.getByRole('img', { name: /loading test image/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders canvas after image loads', async () => {
|
||||
render(<ProtectedImage {...defaultProps} />);
|
||||
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('applies protection level classes and events', async () => {
|
||||
const onViolation = vi.fn();
|
||||
|
||||
const onViolation = jest.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="enhanced"
|
||||
onProtectionViolation={onViolation}
|
||||
/>
|
||||
@@ -106,32 +89,31 @@ describe('ProtectedImage', () => {
|
||||
// Test context menu blocking
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
fireEvent.contextMenu(canvas);
|
||||
|
||||
|
||||
expect(onViolation).toHaveBeenCalledWith('canvas_context_menu');
|
||||
});
|
||||
|
||||
it('applies watermark text when specified', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
watermarkText="Test Watermark"
|
||||
protectionLevel="standard"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify canvas context methods were called for watermark
|
||||
expect(mockContext.fillText).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().fillText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('handles fragment grid rendering', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
gridSize={4}
|
||||
protectionLevel="enhanced"
|
||||
@@ -139,20 +121,19 @@ describe('ProtectedImage', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify multiple drawImage calls for fragments
|
||||
expect(mockContext.drawImage).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('blocks interactions in maximum protection mode', async () => {
|
||||
const onViolation = vi.fn();
|
||||
|
||||
const onViolation = jest.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="maximum"
|
||||
onProtectionViolation={onViolation}
|
||||
/>
|
||||
@@ -161,7 +142,7 @@ describe('ProtectedImage', () => {
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toBeInTheDocument();
|
||||
|
||||
|
||||
// Test click blocking
|
||||
fireEvent.click(canvas);
|
||||
expect(onViolation).toHaveBeenCalledWith('canvas_interaction_blocked');
|
||||
@@ -169,37 +150,26 @@ describe('ProtectedImage', () => {
|
||||
});
|
||||
|
||||
it('handles image loading errors gracefully', async () => {
|
||||
// Track how many times src is set to detect fallback attempts
|
||||
let loadAttempt = 0;
|
||||
|
||||
// Mock image error
|
||||
global.Image = class {
|
||||
onload: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
private _src = '';
|
||||
naturalWidth = 0;
|
||||
naturalHeight = 0;
|
||||
width = 0;
|
||||
height = 0;
|
||||
crossOrigin = '';
|
||||
complete = false;
|
||||
src = '';
|
||||
|
||||
get src() { return this._src; }
|
||||
set src(value: string) {
|
||||
this._src = value;
|
||||
loadAttempt++;
|
||||
constructor() {
|
||||
setTimeout(() => {
|
||||
if (this.onerror) this.onerror();
|
||||
}, 10);
|
||||
}
|
||||
} as unknown as typeof Image;
|
||||
} as any;
|
||||
|
||||
const onViolation = vi.fn();
|
||||
|
||||
// Render WITHOUT fallbackSrc so error state is reached immediately
|
||||
const onViolation = jest.fn();
|
||||
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
onProtectionViolation={onViolation}
|
||||
fallbackSrc="/fallback.jpg"
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -212,8 +182,8 @@ describe('ProtectedImage', () => {
|
||||
|
||||
it('applies invisible watermark for enhanced protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
watermarkText="Hidden"
|
||||
invisibleWatermark={true}
|
||||
protectionLevel="enhanced"
|
||||
@@ -221,19 +191,18 @@ describe('ProtectedImage', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify getImageData and putImageData called for steganography
|
||||
expect(mockContext.getImageData).toHaveBeenCalled();
|
||||
expect(mockContext.putImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('scrambles fragments when enabled', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
fragmentGrid={true}
|
||||
scrambleFragments={true}
|
||||
protectionLevel="maximum"
|
||||
@@ -241,29 +210,27 @@ describe('ProtectedImage', () => {
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Fragment scrambling should result in multiple drawImage calls
|
||||
expect(mockContext.drawImage).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().drawImage).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('adds random noise in maximum protection', async () => {
|
||||
render(
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
<ProtectedImage
|
||||
{...defaultProps}
|
||||
protectionLevel="maximum"
|
||||
/>
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
const canvas = screen.getByRole('img', { name: 'Test image' });
|
||||
expect(canvas).toHaveStyle({ opacity: '1' });
|
||||
expect(screen.getByRole('img', { name: 'Test image' })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Noise injection requires getImageData and putImageData
|
||||
expect(mockContext.getImageData).toHaveBeenCalled();
|
||||
expect(mockContext.putImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().getImageData).toHaveBeenCalled();
|
||||
expect(mockCanvas.getContext().putImageData).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,7 +14,6 @@ export {
|
||||
export { OfflineIndicator, useOnlineStatus } from './OfflineIndicator';
|
||||
export { SkipLink } from './SkipLink';
|
||||
export { DynamicFavicon } from './DynamicFavicon';
|
||||
export { RobotsMetaTags } from './RobotsMetaTags';
|
||||
export { LanguageSelector } from './LanguageSelector';
|
||||
export { AuthenticatedImage } from './AuthenticatedImage';
|
||||
export { AuthenticatedVideo } from './AuthenticatedVideo';
|
||||
|
||||
@@ -20,28 +20,28 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
||||
if (!isDownloading) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 bg-surface rounded-lg shadow-lg border border-surface p-4 min-w-[300px] z-50">
|
||||
<div className="fixed bottom-4 right-4 bg-white rounded-lg shadow-lg border border-neutral-200 p-4 min-w-[300px] z-50">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Download className="w-5 h-5 text-primary-600 animate-bounce" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-theme">{t('download.downloading')}</p>
|
||||
<p className="text-sm font-medium text-neutral-900">{t('download.downloading')}</p>
|
||||
{fileName && (
|
||||
<p className="text-xs text-muted-theme truncate max-w-[200px]">{fileName}</p>
|
||||
<p className="text-xs text-neutral-500 truncate max-w-[200px]">{fileName}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onCancel && (
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="p-1 hover:bg-black/10 rounded transition-colors"
|
||||
className="p-1 hover:bg-neutral-100 rounded transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-theme" />
|
||||
<X className="w-4 h-4 text-neutral-500" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="w-full bg-black/10 rounded-full h-2">
|
||||
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||
<div
|
||||
className="bg-primary-600 h-2 rounded-full transition-all duration-300"
|
||||
style={{ width: `${progress}%` }}
|
||||
@@ -49,7 +49,7 @@ export const DownloadProgress: React.FC<DownloadProgressProps> = ({
|
||||
</div>
|
||||
|
||||
{progress > 0 && (
|
||||
<p className="text-xs text-muted-theme mt-1">{Math.round(progress)}{t('download.percentComplete')}</p>
|
||||
<p className="text-xs text-neutral-500 mt-1">{Math.round(progress)}{t('download.percentComplete')}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -47,18 +47,18 @@ export const FeedbackIdentityModal: React.FC<FeedbackIdentityModalProps> = ({
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50" onClick={onClose} />
|
||||
<div className="relative bg-surface rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<div className="relative bg-white rounded-lg shadow-xl max-w-md w-full p-6">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="absolute top-4 right-4 p-1 hover:bg-black/10 rounded-lg transition-colors"
|
||||
className="absolute top-4 right-4 p-1 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
</button>
|
||||
|
||||
<h2 className="text-lg font-semibold text-theme mb-2">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">
|
||||
{t('feedback.identityRequired', 'Your Information Required')}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-theme mb-4">
|
||||
<p className="text-sm text-neutral-600 mb-4">
|
||||
{t('feedback.identityReason', 'Please provide your name and email to submit {{type}}.', { type: feedbackType })}
|
||||
</p>
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
return (
|
||||
<div className={`${className}`}>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-theme whitespace-nowrap">
|
||||
<span className="text-sm text-neutral-700 whitespace-nowrap">
|
||||
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -99,7 +99,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
{/* Mobile-optimized vertical layout */}
|
||||
{isMobile ? (
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs text-muted-theme font-medium">
|
||||
<div className="text-xs text-neutral-600 font-medium">
|
||||
{t('gallery.feedbackFilter', 'Feedback Filter')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
@@ -146,7 +146,7 @@ export const GalleryFilter: React.FC<GalleryFilterProps> = ({
|
||||
) : (
|
||||
/* Desktop layout - inline with categories */
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-theme font-medium whitespace-nowrap">
|
||||
<span className="text-sm text-neutral-600 font-medium whitespace-nowrap">
|
||||
{t('gallery.feedbackFilter', 'Feedback Filter')}:
|
||||
</span>
|
||||
<div className="flex gap-2">
|
||||
|
||||
@@ -8,14 +8,13 @@ import { Button } from '../common';
|
||||
import { DynamicFavicon } from '../common/DynamicFavicon';
|
||||
import { useTheme } from '../../contexts/ThemeContext';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import type { HeaderStyleType } from '../../types/theme.types';
|
||||
|
||||
interface GalleryLayoutProps {
|
||||
event: {
|
||||
event_name: string;
|
||||
event_type?: string;
|
||||
event_date?: string | null;
|
||||
expires_at?: string | null;
|
||||
event_date?: string;
|
||||
expires_at?: string;
|
||||
};
|
||||
brandingSettings?: {
|
||||
company_name?: string;
|
||||
@@ -39,7 +38,6 @@ interface GalleryLayoutProps {
|
||||
isDownloading?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
menuButton?: React.ReactNode;
|
||||
headerStyle?: HeaderStyleType;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -53,21 +51,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
isDownloading = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
headerStyle: headerStyleProp,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
|
||||
// Determine header style - use prop first (from event data), then theme, then fall back to 'standard'
|
||||
const headerStyle: HeaderStyleType = headerStyleProp || theme.headerStyle || 'standard';
|
||||
const isHeroHeader = headerStyle === 'hero';
|
||||
const isMinimalHeader = headerStyle === 'minimal';
|
||||
const isNoHeader = headerStyle === 'none';
|
||||
|
||||
// Non-grid layouts that need the sidebar (excluding layouts using hero header)
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid';
|
||||
|
||||
const isNonGridLayout = theme.galleryLayout && theme.galleryLayout !== 'grid' && theme.galleryLayout !== 'hero';
|
||||
const fontFamily = theme.fontFamily || 'Inter, sans-serif';
|
||||
const headingFontFamily = theme.headingFontFamily || fontFamily;
|
||||
|
||||
@@ -128,15 +118,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const heroLogoSize = getLogoDimensions('hero');
|
||||
|
||||
return (
|
||||
<div className="gallery-page min-h-screen" style={{ backgroundColor: 'var(--color-background)' }}>
|
||||
<div className="gallery-page min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header structure */}
|
||||
<header className={`gallery-header bg-surface border-b border-surface sticky top-0 z-40 ${isNonGridLayout || isHeroHeader ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts - keep the current structure (standard and minimal/none) */}
|
||||
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
<div className="bg-surface border-b border-surface">
|
||||
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||
{isNonGridLayout && (
|
||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
@@ -180,8 +170,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For grid layout - everything in one bar (standard header) */}
|
||||
{!isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
{/* For grid layout - everything in one bar */}
|
||||
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||
{/* Left side - Menu button, Logo */}
|
||||
@@ -206,7 +196,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
style={headerLogoSize.style}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
<span className="hidden sm:inline text-lg font-semibold text-theme">
|
||||
<span className="hidden sm:inline text-lg font-semibold text-neutral-900">
|
||||
{brandingSettings.company_name}
|
||||
</span>
|
||||
)}
|
||||
@@ -214,7 +204,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
)}
|
||||
{!shouldShowLogo('header') && shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
<div className={`flex-shrink-0 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||
<span className="text-lg font-semibold text-theme">
|
||||
<span className="text-lg font-semibold text-neutral-900">
|
||||
{brandingSettings.company_name || 'PicPeak'}
|
||||
</span>
|
||||
</div>
|
||||
@@ -224,13 +214,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{/* Center - Event info */}
|
||||
<div className="flex-1 min-w-0 text-center sm:text-left">
|
||||
<h1
|
||||
className="text-base sm:text-lg lg:text-xl font-bold text-theme leading-tight truncate"
|
||||
className="text-base sm:text-lg lg:text-xl font-bold text-neutral-900 leading-tight truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-muted-theme">
|
||||
<div className="hidden sm:flex flex-wrap gap-x-3 gap-y-1 mt-1 text-xs sm:text-sm text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1 flex-shrink-0" />
|
||||
@@ -250,7 +240,11 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* Extra header items (upload button, etc.) */}
|
||||
{headerExtra}
|
||||
{headerExtra && (
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
@@ -284,7 +278,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
{/* Mobile dates row */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex sm:hidden justify-center gap-x-3 mt-2 text-xs text-muted-theme">
|
||||
<div className="flex sm:hidden justify-center gap-x-3 mt-2 text-xs text-neutral-600">
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-3 h-3 mr-1 flex-shrink-0" />
|
||||
@@ -302,136 +296,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For minimal/none header + non-grid layouts - compact menu bar */}
|
||||
{isNonGridLayout && (isMinimalHeader || isNoHeader) && (
|
||||
<div className="bg-surface border-b border-surface">
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
{isMinimalHeader && (
|
||||
<h1
|
||||
className="text-sm font-semibold text-theme truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For minimal header + grid layout - compact bar with event name */}
|
||||
{!isNonGridLayout && isMinimalHeader && (
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{menuButton}
|
||||
<h1
|
||||
className="text-sm font-semibold text-theme truncate"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{headerExtra}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download hidden sm:flex"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For none header + grid layout - just functional buttons, no event info */}
|
||||
{!isNonGridLayout && isNoHeader && (
|
||||
<div className="container py-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download hidden sm:flex"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
</Button>
|
||||
)}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* For hero header style - minimal header with just menu and logout */}
|
||||
{isHeroHeader && (
|
||||
{/* For hero layout - minimal header with just menu and logout */}
|
||||
{theme.galleryLayout === 'hero' && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button */}
|
||||
@@ -475,8 +341,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Colored banner for non-grid layouts when using standard header style */}
|
||||
{isNonGridLayout && !isHeroHeader && !isMinimalHeader && !isNoHeader && (
|
||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||
{isNonGridLayout && (
|
||||
<div
|
||||
className="gallery-hero relative text-white overflow-hidden"
|
||||
style={{
|
||||
@@ -564,10 +430,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<main className="container">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-surface">
|
||||
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
||||
<div className="container text-center px-4">
|
||||
{brandingSettings?.support_email && (
|
||||
<p className="text-xs sm:text-sm text-muted-theme mb-2">
|
||||
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
|
||||
{t('gallery.needHelp')}{' '}
|
||||
<a
|
||||
href={`mailto:${brandingSettings.support_email}`}
|
||||
@@ -577,14 +443,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-muted-theme">
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
|
||||
{!brandingSettings?.hide_powered_by && (
|
||||
<> | Powered by <span className="font-semibold">PicPeak</span></>
|
||||
)}
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-muted-theme mt-2">
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
{brandingSettings.company_name} - {brandingSettings.company_tagline}
|
||||
</p>
|
||||
)}
|
||||
@@ -592,14 +458,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="mt-4 flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-muted-theme">|</span>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-muted-theme hover:text-theme transition-colors"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -120,37 +120,37 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`
|
||||
gallery-sidebar fixed top-0 left-0 h-full bg-surface shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
||||
gallery-sidebar fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-surface">
|
||||
<h2 className="gallery-sidebar-title text-lg font-semibold text-theme">{t('gallery.filters')}</h2>
|
||||
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="gallery-sidebar-title text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="gallery-sidebar-close p-2 hover:bg-black/10 rounded-lg transition-colors"
|
||||
className="gallery-sidebar-close p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-muted-theme" />
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
|
||||
{/* Upload Section - Show prominently at top for mobile users */}
|
||||
{allowUploads && onUploadClick && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-surface">
|
||||
{/* Upload Section - Only show on mobile when uploads are allowed */}
|
||||
{isMobile && allowUploads && onUploadClick && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
onUploadClick();
|
||||
if (isMobile) onClose();
|
||||
onClose();
|
||||
}}
|
||||
className="gallery-btn w-full"
|
||||
className="w-full"
|
||||
>
|
||||
{t('upload.uploadPhotos')}
|
||||
</Button>
|
||||
@@ -159,15 +159,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Search Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-surface">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-neutral-200">
|
||||
<div className="relative">
|
||||
<Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-muted-theme" />
|
||||
<Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t('gallery.searchPlaceholder')}
|
||||
className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 bg-surface border border-surface rounded-lg text-theme placeholder:text-muted-theme focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,8 +175,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
|
||||
{allowDownloads && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-surface">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-neutral-200">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{t('gallery.download')}
|
||||
</h3>
|
||||
@@ -220,7 +220,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Feedback Filter Section */}
|
||||
{feedbackEnabled && onFilterChange && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-surface">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-neutral-200">
|
||||
<GalleryFilter
|
||||
currentFilter={filterType}
|
||||
onFilterChange={(filter) => {
|
||||
@@ -239,8 +239,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Categories Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-surface">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-neutral-200">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.categories')}
|
||||
</h3>
|
||||
@@ -254,13 +254,13 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className={`
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-600/20 text-primary-500'
|
||||
: 'hover:bg-black/10 text-muted-theme'
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<span>{t('gallery.allCategories')}</span>
|
||||
<span className="text-sm text-muted-theme">{totalPhotos}</span>
|
||||
<span className="text-sm text-neutral-500">{totalPhotos}</span>
|
||||
</button>
|
||||
|
||||
{categories.map((category) => {
|
||||
@@ -277,8 +277,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className={`
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-600/20 text-primary-500'
|
||||
: 'hover:bg-black/10 text-muted-theme'
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
@@ -286,7 +286,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
{isSelected && <Check className="w-4 h-4" />}
|
||||
{category.name}
|
||||
</span>
|
||||
<span className="text-sm text-muted-theme">{count}</span>
|
||||
<span className="text-sm text-neutral-500">{count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
@@ -295,8 +295,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
)}
|
||||
|
||||
{showMediaFilter && onMediaFilterChange && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-surface">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-neutral-200">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.mediaType', 'Media')}
|
||||
</h3>
|
||||
@@ -341,7 +341,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-sort p-4">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-muted-theme mb-3 flex items-center gap-2">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
{t('gallery.sortBy')}
|
||||
</h3>
|
||||
@@ -361,8 +361,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
className={`
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-600/20 text-primary-500'
|
||||
: 'hover:bg-black/10 text-muted-theme'
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
}
|
||||
`}
|
||||
>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user