Compare commits

..

9 Commits

Author SHA1 Message Date
Paul Nothaft 9b50f3d6b7 Merge pull request #109 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.3
2026-01-15 09:24:12 -05:00
github-actions[bot] e945bc9413 chore(main): release 2.3.3 2026-01-15 14:23:34 +00:00
Paul Nothaft 3b720ed56e fix: lightbox watermark loading, white label translations, and dynamic footer year (#108)
fix: lightbox watermark loading, white label translations, and dynamic footer year
2026-01-15 09:23:10 -05:00
Paul Nothaft fe772b52d6 Merge pull request #106 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.2
2026-01-15 08:03:07 -05:00
github-actions[bot] f29b77998b chore(main): release 2.3.2 2026-01-15 13:02:43 +00:00
Paul Nothaft f843e4c25c Merge pull request #105 from the-luap/fix/codeql-v4-upgrade
fix: watermark thumbnails, custom logo display, and German translations
2026-01-15 08:02:28 -05:00
Paul Nothaft 41f9b6d45d Merge pull request #104 from the-luap/release-please--branches--main
Build and Push Docker Images / build-backend (push) Waiting to run
Build and Push Docker Images / build-frontend (push) Waiting to run
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 2.3.1
2026-01-15 06:46:06 -05:00
github-actions[bot] 7b5916d3b9 chore(main): release 2.3.1 2026-01-15 11:45:24 +00:00
Paul Nothaft 657c205a4d Merge pull request #103 from the-luap/fix/codeql-v4-upgrade
fix: CI workflow fixes for protected branches
2026-01-15 06:45:06 -05:00
182 changed files with 3034 additions and 14687 deletions
-5
View File
@@ -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
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.12.0-beta.0"
".": "3.0.0-beta.0"
}
+1 -1
View File
@@ -1,3 +1,3 @@
{
".": "2.3.0"
".": "2.3.3"
}
+12 -212
View File
@@ -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)
-17
View File
@@ -7,27 +7,12 @@
[![Docker](https://img.shields.io/badge/docker-%230db7ed.svg?style=flat&logo=docker&logoColor=white)](https://www.docker.com/)
[![Node.js](https://img.shields.io/badge/node.js-6DA55F?style=flat&logo=node.js&logoColor=white)](https://nodejs.org/)
[![React](https://img.shields.io/badge/react-%2320232a.svg?style=flat&logo=react&logoColor=%2361DAFB)](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.
![PicPeak Gallery Preview](docs/screenshot-gallery.png)
## 🎮 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>
-6
View File
@@ -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 -2
View File
@@ -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) {
+6 -17
View File
@@ -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 {
+3 -14
View File
@@ -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`);
}
}
+5 -5
View File
@@ -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 -2
View File
@@ -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/"
},
-161
View File
@@ -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);
});
-52
View File
@@ -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'));
+3 -4
View File
@@ -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'
});
+17 -77
View File
@@ -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 {
-258
View File
@@ -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;
+1 -3
View File
@@ -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(),
+30 -297
View File
@@ -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;
+4 -4
View File
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
// Build filtered query
const filterBuilder = new PhotoFilterBuilder(
db('photos')
.leftJoin('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
);
+14 -40
View File
@@ -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 });
}
}
}
+16 -150
View File
@@ -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 {
+1 -8
View File
@@ -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
View File
@@ -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);
+2 -9
View File
@@ -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);
+3 -14
View File
@@ -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;
-314
View File
@@ -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) {
+19 -19
View File
@@ -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
}))
};
+1 -23
View File
@@ -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)
+1 -1
View File
@@ -10,7 +10,7 @@ const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
/**
* Get photos for an event with optional filtering
-128
View File
@@ -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();
-120
View File
@@ -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();
+1 -1
View File
@@ -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;
-1
View File
@@ -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
View File
@@ -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)
+19 -154
View File
@@ -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",
+2 -8
View File
@@ -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
View File
@@ -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>
);
+28 -39
View File
@@ -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>
);
};
+14 -16
View File
@@ -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>
+52 -52
View File
@@ -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';
+49 -146
View File
@@ -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>
+21 -39
View File
@@ -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"
+102 -102
View File
@@ -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>
+31 -33
View File
@@ -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>
-1
View File
@@ -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} />;
};
+1 -1
View File
@@ -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 = {
+3 -3
View File
@@ -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;
};
+5 -5
View File
@@ -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();
});
});
});
-1
View File
@@ -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">
+32 -166
View File
@@ -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