Compare commits
82 Commits
v2.2.3
...
v3.6.0-beta.0
| Author | SHA1 | Date | |
|---|---|---|---|
| e3024e6ffd | |||
| b4978c0869 | |||
| cd1d50474f | |||
| 8097a0cb53 | |||
| e081b56a44 | |||
| c2309af3e0 | |||
| 32fc939c7a | |||
| 4c081601e0 | |||
| 85170b883f | |||
| c018604e5d | |||
| 9c8b5e9fd6 | |||
| 151e1bf50f | |||
| c5a8ffc08c | |||
| d4a15dbe74 | |||
| 0790a1ddad | |||
| f8881d5bd6 | |||
| 6b3ead747b | |||
| dadef81158 | |||
| 644ea22b5f | |||
| f4da354ae7 | |||
| a59f41463f | |||
| 9872ad3aef | |||
| d0880ccb03 | |||
| 237eeea5a6 | |||
| 41bf6ff884 | |||
| 991aa98f98 | |||
| 86fa1046d5 | |||
| 3397807670 | |||
| cdda709886 | |||
| 023bb97e66 | |||
| cf38305f28 | |||
| 0e3674b2b0 | |||
| 3ccb8154eb | |||
| b5ac18121d | |||
| b613f8fbc7 | |||
| cacaffa5c3 | |||
| 691e3aba09 | |||
| 70a0caa11f | |||
| e808e529cd | |||
| 05a5307e22 | |||
| 2a2c23d116 | |||
| a092d98523 | |||
| b5f06af126 | |||
| 6cb43428d1 | |||
| df7dbffbff | |||
| 94421a6b12 | |||
| 7805e89bfe | |||
| 3079eaa2e5 | |||
| 2151147f2d | |||
| 3e69579f5a | |||
| 808ed1d2f1 | |||
| b40e085d28 | |||
| d603567e21 | |||
| c6fdd38e84 | |||
| ae181cf92f | |||
| 1be974afbb | |||
| d29aab7c70 | |||
| 050ed37819 | |||
| 83a4344a01 | |||
| ce8587b24d | |||
| ea20446a79 | |||
| 1c8f686c19 | |||
| a0f38053d3 | |||
| cb012186d9 | |||
| fe7d45dd12 | |||
| c05ae5b0b9 | |||
| dab012c3d1 | |||
| 32492c5a91 | |||
| 2add85eccf | |||
| 5edfb44776 | |||
| eedb0fe49c | |||
| 3c7dc2013f | |||
| 617e778a48 | |||
| e3c3c4c951 | |||
| 0e3b50d1b6 | |||
| bd8b885f7f | |||
| 3cdc0ea715 | |||
| a2ff9eae3f | |||
| 3f7631cd95 | |||
| 53b8764ed7 | |||
| 082d8ab205 | |||
| 749100c92a |
@@ -39,6 +39,11 @@ EMAIL_FROM=noreply@yourdomain.com
|
||||
FRONTEND_URL=https://yourdomain.com
|
||||
ADMIN_URL=https://yourdomain.com
|
||||
|
||||
# API URL for email assets (logos, images in notification emails)
|
||||
# This must be the publicly accessible URL where email recipients can load images.
|
||||
# If not set, defaults to http://localhost:3001 which will show broken images in emails.
|
||||
API_URL=https://yourdomain.com/api
|
||||
|
||||
# Frontend API base
|
||||
# For pre-built images and production behind a reverse proxy, keep '/api'.
|
||||
# If you rebuild the frontend yourself, you may set a full URL at build time.
|
||||
@@ -50,6 +55,16 @@ VITE_API_URL=/api
|
||||
# DB_PORT=5432
|
||||
# REDIS_PORT=6379
|
||||
|
||||
# Release Channel
|
||||
# Options: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
# 'stable' uses the :stable tag (same as :latest on main)
|
||||
# 'beta' uses the :beta tag for pre-release versions
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# Update Check Configuration
|
||||
# Set to 'false' to disable update notifications in admin UI
|
||||
UPDATE_CHECK_ENABLED=true
|
||||
|
||||
# Timezone
|
||||
TZ=UTC
|
||||
|
||||
|
||||
@@ -8,10 +8,10 @@ name: Build and Push Docker Images
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main, develop ]
|
||||
tags: [ 'v*.*.*' ] # Triggered by Release Please tags
|
||||
branches: [ main, beta ]
|
||||
tags: [ 'v*.*.*', 'v*.*.*-beta.*' ] # Triggered by Release Please tags (stable and beta)
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
branches: [ main, beta ]
|
||||
release:
|
||||
types: [ published ] # Triggered when Release Please creates a release
|
||||
workflow_dispatch:
|
||||
@@ -42,6 +42,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
# Determine if this is a beta or stable release
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
@@ -88,10 +100,12 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Build and push Backend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -123,7 +137,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-backend.sarif'
|
||||
category: 'backend-vulnerabilities'
|
||||
@@ -139,6 +153,18 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Determine build context
|
||||
id: context
|
||||
run: |
|
||||
# Determine if this is a beta or stable release
|
||||
if [[ "${{ github.ref }}" == refs/tags/v*-beta* ]] || [[ "${{ github.ref }}" == refs/heads/beta ]]; then
|
||||
echo "channel=beta" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=true" >> $GITHUB_OUTPUT
|
||||
else
|
||||
echo "channel=stable" >> $GITHUB_OUTPUT
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Determine build platforms
|
||||
id: platforms
|
||||
run: |
|
||||
@@ -185,10 +211,12 @@ jobs:
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=sha,format=short
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=stable,enable=${{ github.ref == 'refs/heads/main' || (startsWith(github.ref, 'refs/tags/v') && steps.context.outputs.is_prerelease == 'false') }}
|
||||
type=raw,value=beta,enable=${{ github.ref == 'refs/heads/beta' || steps.context.outputs.is_prerelease == 'true' }}
|
||||
|
||||
- name: Build and push Frontend Docker image
|
||||
uses: docker/build-push-action@v5
|
||||
@@ -220,7 +248,7 @@ jobs:
|
||||
|
||||
- name: Upload Trivy scan results to GitHub Security tab
|
||||
if: github.event_name != 'pull_request' && steps.login-ghcr.outcome == 'success'
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
uses: github/codeql-action/upload-sarif@v4
|
||||
with:
|
||||
sarif_file: 'trivy-frontend.sarif'
|
||||
category: 'frontend-vulnerabilities'
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Release Please (Beta)
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [beta]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
release-please:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
release_created: ${{ steps.release.outputs.release_created }}
|
||||
tag_name: ${{ steps.release.outputs.tag_name }}
|
||||
version: ${{ steps.release.outputs.version }}
|
||||
steps:
|
||||
- name: Run Release Please
|
||||
uses: googleapis/release-please-action@v4
|
||||
id: release
|
||||
with:
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config-beta.json
|
||||
manifest-file: .release-please-manifest-beta.json
|
||||
target-branch: beta
|
||||
|
||||
- name: Output Release Info
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
echo "## Beta Release Created!" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Tag:** ${{ steps.release.outputs.tag_name }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "**Version:** ${{ steps.release.outputs.version }}" >> $GITHUB_STEP_SUMMARY
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this beta version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -34,38 +34,3 @@ jobs:
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "Docker images will be built and tagged with this version." >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
# Sync version to package.json files after release
|
||||
sync-versions:
|
||||
needs: release-please
|
||||
if: ${{ needs.release-please.outputs.release_created }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- name: Update package.json versions
|
||||
run: |
|
||||
VERSION="${{ needs.release-please.outputs.version }}"
|
||||
echo "Updating package.json files to version $VERSION"
|
||||
|
||||
# Update backend package.json
|
||||
cd backend
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
cd ..
|
||||
|
||||
# Update frontend package.json
|
||||
cd frontend
|
||||
npm version $VERSION --no-git-tag-version --allow-same-version
|
||||
cd ..
|
||||
|
||||
- name: Commit version updates
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add backend/package.json frontend/package.json
|
||||
git diff --staged --quiet || git commit -m "chore: sync package.json versions to ${{ needs.release-please.outputs.version }}"
|
||||
git push
|
||||
|
||||
@@ -94,3 +94,11 @@ backup/
|
||||
|
||||
# Local SQLite files in backend
|
||||
backend/*.sqlite*
|
||||
|
||||
# Test files and artifacts
|
||||
test-logo*.jpg
|
||||
test-logo*.png
|
||||
test-results/
|
||||
|
||||
# Development docker compose
|
||||
docker-compose.dev.yml
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
".": "3.6.0-beta.0"
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "2.2.3"
|
||||
".": "2.3.0"
|
||||
}
|
||||
|
||||
+334
@@ -5,6 +5,340 @@ 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.6.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.5.0-beta.0...v3.6.0-beta.0) (2026-01-27)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) ([e081b56](https://github.com/the-luap/picpeak/commit/e081b56a44bf9fdaa3dd225d5dd4dde35bfe83d3))
|
||||
* add justified/rows layout mode to masonry gallery ([#146](https://github.com/the-luap/picpeak/issues/146)) + security fixes ([cd1d504](https://github.com/the-luap/picpeak/commit/cd1d50474f673b759c2f9401fdbe209a84773e39))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* update packages to fix security vulnerabilities ([8097a0c](https://github.com/the-luap/picpeak/commit/8097a0cb530bd8003597cde81606231efadb0bf5))
|
||||
|
||||
## [3.5.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.4.0-beta.0...v3.5.0-beta.0) (2026-01-25)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add per-event custom logo upload with bug fixes ([85170b8](https://github.com/the-luap/picpeak/commit/85170b883f504d83f1d862abb3f4e46741074826))
|
||||
* per-event custom logos, customizable event types, and multiple bug fixes ([4c08160](https://github.com/the-luap/picpeak/commit/4c081601e02888d7ad289acb7847aee9d6f5703f))
|
||||
|
||||
## [3.4.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.3.0-beta.0...v3.4.0-beta.0) (2026-01-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add customizable event types with admin management ([f8881d5](https://github.com/the-luap/picpeak/commit/f8881d5bd62d449fb40917ec8c20f0eb16c1fdad))
|
||||
* add per-event hero logo customization options ([0790a1d](https://github.com/the-luap/picpeak/commit/0790a1ddad774af89827a0a392e9fae0a945bff2))
|
||||
* new features and bug fixes for beta release ([151e1bf](https://github.com/the-luap/picpeak/commit/151e1bf50f206ae0571fa044c75b8bc9f0f40120))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* event-specific custom CSS settings not being saved ([dadef81](https://github.com/the-luap/picpeak/commit/dadef81158972d28aa32812203500f77ed08a999)), closes [#136](https://github.com/the-luap/picpeak/issues/136)
|
||||
* handle null dates in dashboard and gallery pages ([c5a8ffc](https://github.com/the-luap/picpeak/commit/c5a8ffc08cd4c53c37fe4fb9cde8519a68f1f343))
|
||||
* remove non-functional watermark toggle from Feature Toggles ([d4a15db](https://github.com/the-luap/picpeak/commit/d4a15dbe74d0d70bbe6ff03362dc7337fb8f4c5c))
|
||||
* resend gallery email fails for events without password ([6b3ead7](https://github.com/the-luap/picpeak/commit/6b3ead747b1395d8ea2b3d135a5ac24db05e2eb8)), closes [#137](https://github.com/the-luap/picpeak/issues/137)
|
||||
|
||||
## [3.3.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.5-beta.0...v3.3.0-beta.0) (2026-01-21)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add original filename preservation and Lightroom export support ([a59f414](https://github.com/the-luap/picpeak/commit/a59f41463f960a3a74ce3933dc7db84ee3a2018d))
|
||||
* add original filename preservation and Lightroom export support ([9872ad3](https://github.com/the-luap/picpeak/commit/9872ad3aef6488b359c5499a6dc3d8bfbfa48fde))
|
||||
|
||||
## [3.2.5-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.4-beta.0...v3.2.5-beta.0) (2026-01-18)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add STORAGE_PATH to production docker-compose ([cdda709](https://github.com/the-luap/picpeak/commit/cdda70988664a177b351abc6a259ec39664d17ff))
|
||||
* correct invitation activation validation and add missing translations ([991aa98](https://github.com/the-luap/picpeak/commit/991aa98f98cffd1d7785c272726615325e2c0208)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* correct invitation email link URL path ([86fa104](https://github.com/the-luap/picpeak/commit/86fa1046d5439cb451feb164175c919c49ca219a)), closes [#129](https://github.com/the-luap/picpeak/issues/129)
|
||||
* resolve admin invitation flow issues and improve STORAGE_PATH documentation ([41bf6ff](https://github.com/the-luap/picpeak/commit/41bf6ff884d5ef3181f95f3aa4a528434c23947a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* emphasize importance of STORAGE_PATH in env example ([3397807](https://github.com/the-luap/picpeak/commit/3397807670784e02cbe34a7a60db43c95d64f19c))
|
||||
|
||||
## [3.2.4-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.3-beta.0...v3.2.4-beta.0) (2026-01-17)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([0e3674b](https://github.com/the-luap/picpeak/commit/0e3674b2b0325bbcee5aa2c9ff7781da92f612d1))
|
||||
* correct storage path resolution in multiple files ([#96](https://github.com/the-luap/picpeak/issues/96)) ([3ccb815](https://github.com/the-luap/picpeak/commit/3ccb8154eb40a432aa467fb06b3f216fd0d2c6b4))
|
||||
|
||||
## [3.2.3-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.2-beta.0...v3.2.3-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add allow_user_uploads to gallery API responses ([691e3ab](https://github.com/the-luap/picpeak/commit/691e3aba09f2148afe902a0bb0139d062634e669))
|
||||
* mobile upload button not visible in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([cacaffa](https://github.com/the-luap/picpeak/commit/cacaffa5c39f67105c4cfb092ea62157121fb72e))
|
||||
|
||||
## [3.2.2-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.1-beta.0...v3.2.2-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile upload button visibility in gallery ([2a2c23d](https://github.com/the-luap/picpeak/commit/2a2c23d11610e6c81684163eb4ea934a6d6104fb)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([05a5307](https://github.com/the-luap/picpeak/commit/05a5307e22dc45be4b75b2996ff9fac65dec399d))
|
||||
|
||||
## [3.2.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.2.0-beta.0...v3.2.1-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* mobile upload button visibility in gallery ([df7dbff](https://github.com/the-luap/picpeak/commit/df7dbffbffb180e62af0d2b58326f9de0f515439)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* mobile upload button visibility in gallery ([#113](https://github.com/the-luap/picpeak/issues/113)) ([6cb4342](https://github.com/the-luap/picpeak/commit/6cb43428d1e703267edeacda9ede050a8c4f8e0c))
|
||||
|
||||
## [3.2.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.1.0-beta.0...v3.2.0-beta.0) (2026-01-16)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* add optional event date and expiration settings ([3079eaa](https://github.com/the-luap/picpeak/commit/3079eaa2e5d1728c2c0f315626cc253e4b08edc2))
|
||||
* add optional event date and expiration settings ([2151147](https://github.com/the-luap/picpeak/commit/2151147f2d3134448ff32130da44678e2942d73c)), closes [#118](https://github.com/the-luap/picpeak/issues/118)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* checkbox and toggle settings not persisting after page refresh ([808ed1d](https://github.com/the-luap/picpeak/commit/808ed1d2f1164d9fd1114586c68a1f925bf73ddf)), closes [#117](https://github.com/the-luap/picpeak/issues/117)
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add API_URL environment variable to .env.example files ([3e69579](https://github.com/the-luap/picpeak/commit/3e69579f5a171b31a253b2a42bb033bf1b97387d))
|
||||
|
||||
## [3.1.0-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.1-beta.0...v3.1.0-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* dynamic website title from branding settings ([d29aab7](https://github.com/the-luap/picpeak/commit/d29aab7c70c5777451666fb7d5c7a9729dab684a))
|
||||
* pre-generate watermarks for instant lightbox loading ([1be974a](https://github.com/the-luap/picpeak/commit/1be974afbb0b7a1bdbdd140327771907a5d3c2ae)), closes [#112](https://github.com/the-luap/picpeak/issues/112)
|
||||
* pre-generated watermarks and mobile upload button improvements ([c6fdd38](https://github.com/the-luap/picpeak/commit/c6fdd38e842e1a8c0aa9cbab9fc791e6669e402d))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add lightbox loading spinner and watermark cache invalidation ([050ed37](https://github.com/the-luap/picpeak/commit/050ed378199eb3b15c7c7f243792f68f858803f5))
|
||||
* lightbox watermark loading, white label translations, and dynamic footer year ([ce8587b](https://github.com/the-luap/picpeak/commit/ce8587b24df3f53a11a74348eff8b5c5b96c5488))
|
||||
* prevent database migration restart failures ([83a4344](https://github.com/the-luap/picpeak/commit/83a4344a01de4f65c5024fdf2d177a04457ccd2f)), closes [#107](https://github.com/the-luap/picpeak/issues/107)
|
||||
* show upload button in mobile topbar instead of sidebar ([ae181cf](https://github.com/the-luap/picpeak/commit/ae181cf92fc9c1e85cad7a7b843a4d83cec636ac)), closes [#113](https://github.com/the-luap/picpeak/issues/113)
|
||||
* watermark thumbnails, custom logo display, and German translations ([ea20446](https://github.com/the-luap/picpeak/commit/ea20446a797a00cf45dbe7bf6f06574a79c4d8a6))
|
||||
|
||||
## [3.0.1-beta.0](https://github.com/the-luap/picpeak/compare/v3.0.0-beta.0...v3.0.1-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* CI workflow fixes for protected branches ([cb01218](https://github.com/the-luap/picpeak/commit/cb012186d93403a1ac4e2d2f5283319603b290d6))
|
||||
* use Release Please extra-files instead of sync-versions job ([fe7d45d](https://github.com/the-luap/picpeak/commit/fe7d45dd122b2dca1b2a21ba5c86d32b9a193074))
|
||||
|
||||
## [3.0.0-beta.0](https://github.com/the-luap/picpeak/compare/v2.3.0-beta.0...v3.0.0-beta.0) (2026-01-15)
|
||||
|
||||
|
||||
### ⚠ BREAKING CHANGES
|
||||
|
||||
* Deployment now requires external reverse proxy for SSL/HTTPS
|
||||
|
||||
### Features
|
||||
|
||||
* add Apple Liquid Glass templates, image security settings, and automated releases ([6033461](https://github.com/the-luap/picpeak/commit/6033461be118ce78277ec568e1ef1ceeff7311c8))
|
||||
* add complete translation support for backup admin page ([e9f92e6](https://github.com/the-luap/picpeak/commit/e9f92e66d08ac7001c31a3ee8f43ee8306bc79a9))
|
||||
* Add CSS template system with custom gallery styling support ([0da45e6](https://github.com/the-luap/picpeak/commit/0da45e699ad998031aa56a92f2da5ee61a04e285))
|
||||
* add event management, gallery customization, and release automationFeature/event rename ([40ee671](https://github.com/the-luap/picpeak/commit/40ee67171d41522037bf9d4e7675b62ec564346d))
|
||||
* add feedback management enhancements ([0064122](https://github.com/the-luap/picpeak/commit/0064122eff12029300ab7f95078b5710c3c2d08c))
|
||||
* add GitHub Actions workflow for Docker image builds ([4029559](https://github.com/the-luap/picpeak/commit/40295599547b86af7fea3359c7486918d2cd0236))
|
||||
* add multi-administrator support with RBAC and fix backup/restore for S3 ([892e47d](https://github.com/the-luap/picpeak/commit/892e47d017064d7922536f8e138bbb290a45cdc9))
|
||||
* **admin:** external media import modal + thumbnail fixes for reference events\n\n- Photos tab: replace inline external folder picker with a modal opened via "Import from External Folder" button next to "Upload Photos"; add info that all pictures in the selected folder will be imported.\n- Admin thumbnails: align list endpoint to /api/admin/photos/:eventId/photos and always return thumbnail_url to trigger on-demand generation; normalize external paths to avoid duplicated folder segments (e.g., individual/individual) that broke resolver; improve thumbnail logging.\n- Use authenticated image fetching on admin feedback pages to prevent 401s in automation.\n- i18n: add backup.external.warning strings; complete German backup/restore coverage; add common keys (notSet, of, up, select, selected).\n- Docs: add Local (npm) setup for EXTERNAL_MEDIA_ROOT in deployment guide.\n\nRefs [#17](https://github.com/the-luap/picpeak/issues/17) – gallery feature request: https://github.com/the-luap/picpeak/issues/17 ([49c7778](https://github.com/the-luap/picpeak/commit/49c77785e7a776890f15c0c541dcd18b74a86c6e))
|
||||
* **admin:** refine header layout and logo placement ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* allow admin email updates in UI ([#36](https://github.com/the-luap/picpeak/issues/36)) ([3c2a79a](https://github.com/the-luap/picpeak/commit/3c2a79a31a0f1a44c8ec4f9a87f6fbcea9be651c))
|
||||
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* completely rewrite GitHub mirror to create new history from target commit ([febacb7](https://github.com/the-luap/picpeak/commit/febacb79ad86d35a222ec86a1e7da65747bbe19a))
|
||||
* consolidate setup scripts and guides into unified solution ([29a8ff9](https://github.com/the-luap/picpeak/commit/29a8ff914cf838918ab827280e4415afbce5ca8d))
|
||||
* **docker:** add PUID/PGID and user mapping to avoid bind mount permission issues; feat(setup): prompt for admin email interactively; docs: PUID/PGID in .env.example ([410a33f](https://github.com/the-luap/picpeak/commit/410a33fecf1693cc75816c53ac460ec20089e2a1))
|
||||
* enhance mirror-to-github workflow with commit-based history filtering ([b4b09c1](https://github.com/the-luap/picpeak/commit/b4b09c16504ca64ce265c7bd0bf0c901dbbd0638))
|
||||
* **events:** add CSS template selector to event edit page ([6a6c2cd](https://github.com/the-luap/picpeak/commit/6a6c2cd34db26a53b5fb96415650e8136a74e47f))
|
||||
* exclude Claude contributor from GitHub mirror workflow ([abbcdb1](https://github.com/the-luap/picpeak/commit/abbcdb11136afd8cf4eb21c2103e81d22b9c886f))
|
||||
* fix analytics dashboard and implement complete Umami integration ([45ce988](https://github.com/the-luap/picpeak/commit/45ce98806d4c87ddce8c400d07cc667bde435d75))
|
||||
* **gallery/filters:** add Rated and Commented filters (UI + backend).\n\n- UI: add star (Rated) and message (Commented) buttons to feedback filter bars (desktop + mobile)\n- Backend: support filter=rated, commented, and combinations via aggregate counts/queries ([b03760a](https://github.com/the-luap/picpeak/commit/b03760ab01e21feb3578f90d065945d437d03452))
|
||||
* **gallery:** add quick Like/Favorite actions on thumbnails across layouts ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a))
|
||||
* **gallery:** always-visible feedback indicators on grid tiles; fallback image rendering in lightbox/hero; auto-auth from shared-link token; fix external photo resolver\n\n- GridGallery: bottom-left icons for like/rated/comment on every tile\n- Hero layout grid: added same indicators (non-intrusive icons)\n- Lightbox/Hero: add fallbackSrc to display thumbnail if original fails\n- GalleryAuth: auto-store token from /gallery/:slug/:token and hydrate event\n- Backend gallery photo route: use resolvePhotoFilePath for external-media\n\nfix(admin): move photo feedback badges to bottom-right on admin grid tiles\n\nfix(dashboard): add missing i18n keys for activity types + fallback to formatter\n\nfix(admin/feedback): correct thumbnail URL base + robust date parsing\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([6948aaa](https://github.com/the-luap/picpeak/commit/6948aaa92afc29609f85cf7fd631095f3e32ad3f))
|
||||
* **gallery:** compact vertical icon-only feedback filter in PhotoFilterBar; remove wide buttons to prevent overflow\n\n- Desktop: vertical icon stack (All/Grid, Likes, Favorites) outside scroll area\n- Mobile: vertical icon stack below categories\n- Keeps existing category bar layout and count\n\nRefs: [#19](https://github.com/the-luap/picpeak/issues/19) ([465f997](https://github.com/the-luap/picpeak/commit/465f997752fc930ac0a3ae530e9e57a378877d53))
|
||||
* **i18n:** add translations for settings tabs ([c030e87](https://github.com/the-luap/picpeak/commit/c030e872135b39701ef1f4bbb2f28bcaf4ce7fae))
|
||||
* implement 4 new features with bug fixes and refactoring plan ([77a4bfd](https://github.com/the-luap/picpeak/commit/77a4bfd49975551bf509354097f280cab3e48c7a))
|
||||
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
|
||||
* implement comprehensive backup and restore system with S3 support ([f6a79c8](https://github.com/the-luap/picpeak/commit/f6a79c815e3085a56cbe7bac2964dd135f5e88bb))
|
||||
* implement feedback filter for liked/favorited photos (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([41857ec](https://github.com/the-luap/picpeak/commit/41857ec499e2aab4347173cb031db246b9a032f6))
|
||||
* implement gallery feedback system with version tracking for backups ([dc1419c](https://github.com/the-luap/picpeak/commit/dc1419c051dae44532bfc2b2c2bc00942577dc22))
|
||||
* implement gallery logo customization (Issue [#17](https://github.com/the-luap/picpeak/issues/17)) ([909e760](https://github.com/the-luap/picpeak/commit/909e760447c76bb35dbffa553a4665edc5ebccd9))
|
||||
* **lightbox:** keep feedback usable while navigating ([6368f10](https://github.com/the-luap/picpeak/commit/6368f1027f96107ba64964eb126911bfe185f54a)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#78](https://github.com/the-luap/picpeak/issues/78)) ([16b3ab0](https://github.com/the-luap/picpeak/commit/16b3ab039ae95f5641dc15a4811eb2b503f1791c))
|
||||
* **native:** auto-serve SPA when dist exists (unless SERVE_FRONTEND=false); add clear logging; serve index.html for /admin ([fb16b7b](https://github.com/the-luap/picpeak/commit/fb16b7bbb8225192160c08050f1b164c36c8dc74))
|
||||
* **native:** build frontend and serve SPA from backend (SERVE_FRONTEND); fix Cannot GET /admin on native installs ([9fe10bc](https://github.com/the-luap/picpeak/commit/9fe10bcce2871a48f2409b4936d95c00249deb51))
|
||||
* **native:** serve built frontend from backend; build frontend during install/update; ensure env flags (SERVE_FRONTEND, FRONTEND_DIR) ([61ad2d6](https://github.com/the-luap/picpeak/commit/61ad2d61c137196c229817989f991e50fa389a6e))
|
||||
* overhaul public landing page and backup tooling ([2a4d388](https://github.com/the-luap/picpeak/commit/2a4d38813f7ab64a6bbb3a666f3c98a29443488d))
|
||||
* **select:** add per-tile checkbox selection in Admin grid and all gallery layouts; tile click opens viewer; checkbox toggles selection; auto-enable selection mode; add testids ([9fda54b](https://github.com/the-luap/picpeak/commit/9fda54bd06d37cd8f8f71056bf4f59e158cd8112))
|
||||
* **setup/docker:** auto-set PUID/PGID from invoking user and chown bind-mount folders; create missing data/events dirs ([0618b78](https://github.com/the-luap/picpeak/commit/0618b78725e85f97f0a4b4e834c17811c033c8f4))
|
||||
* **setup:** remove --admin-password; print admin credentials from ADMIN_CREDENTIALS.txt; fix ADMIN_URL to avoid /admin/admin; update native service commands ([84d0f63](https://github.com/the-luap/picpeak/commit/84d0f63d36c68532fea83e7087b1afeaa9b82f39))
|
||||
* support per-gallery password toggle ([5d6c061](https://github.com/the-luap/picpeak/commit/5d6c061f1c4fd20581b1e74fa114c96530b5de53))
|
||||
* update GitHub mirror workflow to start history from specific commit ([08da01f](https://github.com/the-luap/picpeak/commit/08da01f021788a1b81a3a3aabf120636c4e1a90a))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* add missing route for feedback management page ([517128f](https://github.com/the-luap/picpeak/commit/517128fd99863ea203e39268ffa6c1ff093bcbd0))
|
||||
* add missing translations and fix BackupHistory useTranslation error ([99e4778](https://github.com/the-luap/picpeak/commit/99e47785e4a53c7ef9f95421413a2704b15b456d))
|
||||
* Add settings translations and fix manual backup process ([#82](https://github.com/the-luap/picpeak/issues/82)) ([476fcce](https://github.com/the-luap/picpeak/commit/476fcce13f30f9f2d2f98a0c87c25fba09e9eebc))
|
||||
* **admin/feedback:** use correct event id when rendering photo thumbnails ([4c7b49a](https://github.com/the-luap/picpeak/commit/4c7b49a5f69a3fce4f9a0e837a082b56bb7e47d6)), closes [#19](https://github.com/the-luap/picpeak/issues/19)
|
||||
* **admin:** prevent category badge overlap in grid ([d64e7d0](https://github.com/the-luap/picpeak/commit/d64e7d08deae7ad1b6f744f447fe546115427942))
|
||||
* align backend port to 3000 across all configurations ([3a8d53f](https://github.com/the-luap/picpeak/commit/3a8d53f4927f577c4031c4bc3531e08191dc632a))
|
||||
* Align nginx backend port for production Docker deployments (v2.2.2) ([#88](https://github.com/the-luap/picpeak/issues/88)) ([e0bd19a](https://github.com/the-luap/picpeak/commit/e0bd19a74dd81bdd45be2384820830bd96769e1c))
|
||||
* auto-convert old date formats to new date-fns syntax ([e1aca6b](https://github.com/the-luap/picpeak/commit/e1aca6b00c5affb914a0db44a6264c8e54fdffd6))
|
||||
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
|
||||
* **backup:** allow manual backups when automated backups are disabled ([e6dd89e](https://github.com/the-luap/picpeak/commit/e6dd89e969fb7018633159155975bd2bd2fb0409))
|
||||
* **ci:** add QEMU setup for multi-arch builds and skip for PRs ([0d36a27](https://github.com/the-luap/picpeak/commit/0d36a273bb58ffd0172efacd828e7171d954b41c))
|
||||
* clear notifications via API ([#35](https://github.com/the-luap/picpeak/issues/35)) ([013be18](https://github.com/the-luap/picpeak/commit/013be18d982986333e2ac24c7ede907de49690bc))
|
||||
* complete backup page translations and improve UI ([7387a5e](https://github.com/the-luap/picpeak/commit/7387a5e9f90965a6cfb75589b2338bf28263b840))
|
||||
* complete restore page translations and fix structure ([618e269](https://github.com/the-luap/picpeak/commit/618e2695fdf844cc0ae961b50a9b6eb99bc46a03))
|
||||
* configure github-release plugin to use GitHub API instead of Gitea ([2624ea6](https://github.com/the-luap/picpeak/commit/2624ea6130a38224597f0c4d3f3d0341c334472f))
|
||||
* correct GitHub repository path in Drone CI release config ([247e154](https://github.com/the-luap/picpeak/commit/247e154afefd3aef285e459bb7fc39ea460e53e2))
|
||||
* correct import statements for api in backup JSX files ([30f6780](https://github.com/the-luap/picpeak/commit/30f678048417aeffe6eabefc7bed5e4dc2267f25))
|
||||
* correct malformed gallery URLs in admin panel View Gallery links ([3074748](https://github.com/the-luap/picpeak/commit/3074748bbc6a8cb8fc0e95d2f24d626f0d0444d0))
|
||||
* correct password generator function name in reset password route ([65d796b](https://github.com/the-luap/picpeak/commit/65d796b9f09417f85bb3209c5e5fbe597a4bb2d3))
|
||||
* correct script name in Gitea mirror workflow ([828d6bc](https://github.com/the-luap/picpeak/commit/828d6bc456175007b72998db7116eec993750435))
|
||||
* **cors:** scope CORS to /api only and avoid throwing on disallowed origins; prevents static asset 500s on native ([90bb21e](https://github.com/the-luap/picpeak/commit/90bb21e38bf1ba97e3fb8185b8d05f1296d745ee))
|
||||
* critical database connection pool exhaustion issues ([8588133](https://github.com/the-luap/picpeak/commit/8588133a4e35774e46f7c605638758e5b2a4a9e2))
|
||||
* **db:** improve PostgreSQL connection check in wait-for-db.sh ([e85a68a](https://github.com/the-luap/picpeak/commit/e85a68a386c72c276b4958599b5246e60dfac716))
|
||||
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* force github-release plugin to use GitHub API instead of Gitea ([558a966](https://github.com/the-luap/picpeak/commit/558a966f8509ac7b77c732f8cc5855c9f88a4bab))
|
||||
* **frontend:** add missing externalMedia service and mount admin external-media routes; verify Vite build ([ab324f1](https://github.com/the-luap/picpeak/commit/ab324f192859204a3ea3c129530ccfe8f5a36968))
|
||||
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
|
||||
* **gallery/filters:** always apply global liked/favorited filters by aggregate counts (ignore guest_id); resolves mismatch between client guest_id and server identifier ([526dcd8](https://github.com/the-luap/picpeak/commit/526dcd8dfc030d86143cee799a88a1004d96b116))
|
||||
* **gallery/filters:** make feedback filters work globally when no guest_id is provided; remove guest_id from client photos query\n\n- Backend /api/gallery/:slug/photos: if filter present and guest_id missing, filter by like_count/favorite_count\n- Frontend useGalleryPhotos: stop passing random guestId (does not match server guest_identifier)\n\nThis makes Liked/Favorited filters reflect photos with aggregate feedback counts as expected. ([5b2561b](https://github.com/the-luap/picpeak/commit/5b2561b6f1da2665d6092ba954f8ff26df3959a4))
|
||||
* **gallery/sidebar:** compact icon-only feedback filter in sidebar (vertical, small) to avoid overflow; use GalleryFilter variant=compact ([ff89f96](https://github.com/the-luap/picpeak/commit/ff89f96e31130f75bcd7a406c5d895eac17b65de))
|
||||
* **gallery:** feedback filter headline + horizontal icons in sidebar (compact variant); ensure sidebar content scrolls (flex-col container) ([3a6d061](https://github.com/the-luap/picpeak/commit/3a6d06192a280ead8bd5d1fbfe06554e63f3346e))
|
||||
* handle auth errors and JSON parsing in admin panel ([b2ae5f1](https://github.com/the-luap/picpeak/commit/b2ae5f18ad4622ea9cb0b5b593dad19e5d14cf60))
|
||||
* handle legacy non-JSON logo paths when replacing logo ([0d5ce48](https://github.com/the-luap/picpeak/commit/0d5ce48dccf0c61f210725ffae15dafc5e9f7cab))
|
||||
* harden gallery downloads and per-gallery auth ([fc1bf53](https://github.com/the-luap/picpeak/commit/fc1bf534129092ca3638e4a4bc47274cd297fa5f))
|
||||
* implement 9 production enhancements and security fixes ([c584369](https://github.com/the-luap/picpeak/commit/c584369d5d5c33fd794cf82a2aea8089bd10e514))
|
||||
* improve admin credentials display and configuration ([ad495a9](https://github.com/the-luap/picpeak/commit/ad495a92c46d02849ce0d9176cff43c83c5c4b57))
|
||||
* improve version bump workflow with better conflict resolution ([c787510](https://github.com/the-luap/picpeak/commit/c7875102c5196a9ef3038c2d5e0ee313fbb2782a))
|
||||
* JSON serialize favicon and logo URLs for PostgreSQL storage ([b83f427](https://github.com/the-luap/picpeak/commit/b83f4272b584f937fea1f47656182e514b12d980))
|
||||
* Multi-administrator RBAC, CSS templates & security hardening ([#80](https://github.com/the-luap/picpeak/issues/80)) ([37d4e1c](https://github.com/the-luap/picpeak/commit/37d4e1cb6132346699a90aebfbaec83d84f931f4))
|
||||
* multiple improvements and CI/CD updates ([bf70567](https://github.com/the-luap/picpeak/commit/bf705674d505b0cb1b82fecc74aa8d95edd50a47))
|
||||
* **native/http:** disable CSP upgrade-insecure-requests and HSTS unless ENABLE_HSTS=true; prevents HTTPS upgrades on HTTP installs ([24b4a31](https://github.com/the-luap/picpeak/commit/24b4a314a9e97b6c640ca29067e95028a23a8973))
|
||||
* **native:** correct setup paths to /opt/picpeak/app, update repo URL, add sqlite prod support; docs path fixes ([b992b15](https://github.com/the-luap/picpeak/commit/b992b151d3ca6ccb4a9b2434d94edcdc90ada3b0))
|
||||
* **native:** remove obsolete workers service; restart only backend; add API request logging and preflight handler; keep static assets outside CORS ([f3604b4](https://github.com/the-luap/picpeak/commit/f3604b438b37e5f2bddf98e79f458bfa2367cb75))
|
||||
* **nginx:** add Docker DNS resolver for Swarm/dynamic service discovery ([049837f](https://github.com/the-luap/picpeak/commit/049837f9d675ff5a4d93c02e5eb771bf65bc2616))
|
||||
* **nginx:** Add Docker DNS resolver for Swarm/dynamic service discovery (v2.2.3) ([cc1ddfd](https://github.com/the-luap/picpeak/commit/cc1ddfd42cccac07d5869fe2ee19c25a9ffa50e8))
|
||||
* **photos:** category changes now persist and display correctly ([#77](https://github.com/the-luap/picpeak/issues/77)) ([d9da98c](https://github.com/the-luap/picpeak/commit/d9da98c355011c247c526b28e6f07b329a632b55))
|
||||
* **photos:** resolve upload category selection and improve feedback buttons ([#77](https://github.com/the-luap/picpeak/issues/77)) ([856d533](https://github.com/the-luap/picpeak/commit/856d53343c6805706e1498892a29b120938f8547))
|
||||
* prefer admin token on admin routes ([#23](https://github.com/the-luap/picpeak/issues/23) [#28](https://github.com/the-luap/picpeak/issues/28)) ([d4404e3](https://github.com/the-luap/picpeak/commit/d4404e39bd7953649da02d3e300ffef46573ac97))
|
||||
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
|
||||
* remove description field from migration 035 app_settings inserts ([22cc406](https://github.com/the-luap/picpeak/commit/22cc40617f88e1f0a636fc049c78601fc1f38c33))
|
||||
* remove file requirement from GitHub release in Drone CI ([8335916](https://github.com/the-luap/picpeak/commit/833591681adf29d99a1dfa7c43d5aee7a6cb98ba))
|
||||
* remove formatBoolean calls from migration 032 - critical production fix ([0502ed3](https://github.com/the-luap/picpeak/commit/0502ed34c9fe76acacc2aecd02151564d109cf0b))
|
||||
* remove unnecessary publish-manifest job from Docker workflow ([986b101](https://github.com/the-luap/picpeak/commit/986b101040674f2253fcdfda99a9e603535daaa0))
|
||||
* remove unused formatBoolean import from migration 033 ([1238db5](https://github.com/the-luap/picpeak/commit/1238db58c25e97513c9bdcb5dcc26b1034e9f074))
|
||||
* remove updated_at field from password reset query ([ed0243e](https://github.com/the-luap/picpeak/commit/ed0243ec398acca26490ef27cbe3cfe5fa9b95a6))
|
||||
* remove updated_at from app_settings inserts in multiple migrations ([4c42b4c](https://github.com/the-luap/picpeak/commit/4c42b4c60157755b770bea3b78d42fe6abd60afa))
|
||||
* replace github-release plugin with direct curl API call ([76a466c](https://github.com/the-luap/picpeak/commit/76a466c0776eeabe3eac6a480bd699c2ae5c60bc))
|
||||
* resolve backend startup errors in development ([f8fb1c3](https://github.com/the-luap/picpeak/commit/f8fb1c3f4b2b5de53182e987a9dfe042704320b9))
|
||||
* resolve branding display issues and invitation parsing errors ([1931d73](https://github.com/the-luap/picpeak/commit/1931d73b60d3419203cc8b420841abbfc9e14d2d))
|
||||
* Resolve branding display issues and invitation parsing errors (v2.2.1) ([#86](https://github.com/the-luap/picpeak/issues/86)) ([d7ecf83](https://github.com/the-luap/picpeak/commit/d7ecf83d32ec6608280b96e6cdee48e9a0ad0afa))
|
||||
* resolve CI/CD version bump race condition ([0bf4764](https://github.com/the-luap/picpeak/commit/0bf4764a0720f6f199442a738a885a2edaae2a4d))
|
||||
* resolve database connection error for analytics settings ([95939d5](https://github.com/the-luap/picpeak/commit/95939d57e6857646d261b0f049bdda752602caeb))
|
||||
* resolve date formatting error in event creation ([c51d756](https://github.com/the-luap/picpeak/commit/c51d7565035146cc3f689c0cc4b508b78d9bb5ee))
|
||||
* resolve development environment issues ([61299a3](https://github.com/the-luap/picpeak/commit/61299a33c4f92730fe8b14f6035f61325d952b94))
|
||||
* resolve duplicate logger declaration and syntax error in rate limit service ([0fe6d73](https://github.com/the-luap/picpeak/commit/0fe6d738b222555b27cbf8a36f455b1c15c4f4e8))
|
||||
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([f26beca](https://github.com/the-luap/picpeak/commit/f26becad1dfa72c62b6ecec491be025644426d67))
|
||||
* resolve feedback validation issues from GitHub issue [#16](https://github.com/the-luap/picpeak/issues/16) ([67ff415](https://github.com/the-luap/picpeak/commit/67ff4158404347bc7c13dee5b4e13260eb0e743d))
|
||||
* resolve GitHub issues [#4](https://github.com/the-luap/picpeak/issues/4), [#8](https://github.com/the-luap/picpeak/issues/8), [#9](https://github.com/the-luap/picpeak/issues/9), and [#10](https://github.com/the-luap/picpeak/issues/10) ([934d6dd](https://github.com/the-luap/picpeak/commit/934d6ddc5847f65db6371a4043b764f6d4cd6c8b))
|
||||
* resolve GitHub mirror workflow cherry-pick failure with merge commits ([d6adde4](https://github.com/the-luap/picpeak/commit/d6adde4e093537aeecf8b190513a1171c3ecc82c))
|
||||
* resolve language-specific column issues in core migrations ([62617f6](https://github.com/the-luap/picpeak/commit/62617f627f56aedd132fa20528b1d7c7e272c85c))
|
||||
* resolve migration conflicts and duplicate numbering ([a401fbd](https://github.com/the-luap/picpeak/commit/a401fbdc54f30c18b5aa2440d7b6887ca12e00eb))
|
||||
* resolve multiple feedback management issues ([ad75818](https://github.com/the-luap/picpeak/commit/ad758185666bf4ac52965f16d1c0e1e052887ac2))
|
||||
* resolve multiple issues from GitHub issue [#14](https://github.com/the-luap/picpeak/issues/14) ([e91209f](https://github.com/the-luap/picpeak/commit/e91209f7cb38a5b840e74ed6acd8d490ef9d2294))
|
||||
* resolve port configuration issues and database column mismatch ([6de64a1](https://github.com/the-luap/picpeak/commit/6de64a1df18932badd7bb1b9928d09e9477f0c3f))
|
||||
* resolve PostgreSQL migration issues for development environment ([ee855a3](https://github.com/the-luap/picpeak/commit/ee855a3502ecd1a5556e378e9995de86e3548de1))
|
||||
* resolve production UI and API issues ([d5790ad](https://github.com/the-luap/picpeak/commit/d5790ad635596842926a358753932e5c422590d6))
|
||||
* resolve SIGPIPE error in GitHub mirror workflow file cleanup ([b7c8953](https://github.com/the-luap/picpeak/commit/b7c8953cb4d4a2541dcb38865c8a7beef0edf494))
|
||||
* resolve translation interpolation issue for download button ([c1e10f1](https://github.com/the-luap/picpeak/commit/c1e10f14a30797c76169c2531de5d04976ee4888))
|
||||
* **security:** upgrade Alpine base image to fix libpng and c-ares CVEs ([b706eeb](https://github.com/the-luap/picpeak/commit/b706eeb5d332e9618706193976a7241aee53d879))
|
||||
* **setup/native:** correct repo URL, paths, and systemd for native install; support sqlite in production knex config ([87b8414](https://github.com/the-luap/picpeak/commit/87b8414e449802db6dc9f762453f7672616b83c9))
|
||||
* **setup/native:** Debian 12 compatibility (reliable RAM detection, sudo-less run_as_user, git safe.directory); ensure SQLite data dir; use user for migrate ([dc482e6](https://github.com/the-luap/picpeak/commit/dc482e614a5fbac44c6570d812669511301a4403))
|
||||
* **setup/native:** handle forced updates safely by fetch+checkout/reset instead of pull; stable on rewritten histories ([3697344](https://github.com/the-luap/picpeak/commit/3697344cd0add28b4da71c3b33e2ccc0a96f50f9))
|
||||
* **setup/update:** detect native installs first (/opt/picpeak/app/backend or systemd unit); avoid false docker updates on root ([adf576f](https://github.com/the-luap/picpeak/commit/adf576fbe17f40c13c1d77dd9751f2e9dbf523a1))
|
||||
* simplify Drone github-release step to avoid shell parsing issues ([94f10e1](https://github.com/the-luap/picpeak/commit/94f10e164502e6848cd720ee5a5c2822abbde46f))
|
||||
* stabilize uploads and guest feedback filters ([aaaf598](https://github.com/the-luap/picpeak/commit/aaaf59817b3978635d2282c006853e183ab944d4))
|
||||
* update all deployment guide links in README.md ([6389b9d](https://github.com/the-luap/picpeak/commit/6389b9df3f616c09a9bbbf1a2988764b0c3aeb77))
|
||||
* update deployment guide with critical URL configuration and nginx port fixes ([1cadce1](https://github.com/the-luap/picpeak/commit/1cadce196bb04a0575d83437618454d4ca5bcdac))
|
||||
* update form-data and multer to address security vulnerabilities ([7750170](https://github.com/the-luap/picpeak/commit/7750170832dddf81a33c7c2409b37b0b7bc1f290))
|
||||
* update Gitea mirror workflow to selectively remove scripts ([296430e](https://github.com/the-luap/picpeak/commit/296430e4d7e01a6be031dbb89dd25f563b163a97))
|
||||
* update GitHub mirror action to support fine-grained personal access tokens ([827eb48](https://github.com/the-luap/picpeak/commit/827eb4819b7da6171d48613963d176399cad80c6))
|
||||
* use admin API for Umami config in analytics page ([a54a2c0](https://github.com/the-luap/picpeak/commit/a54a2c0fdaa28193d1359da73bc7fb61476e2a58))
|
||||
* use plugins/gitea-release for Drone CI/CD ([0c783c6](https://github.com/the-luap/picpeak/commit/0c783c66d0dfe8cb637f0db7349ae9637d7bf787))
|
||||
* use plugins/github-release for Drone CI/CD ([f926cd3](https://github.com/the-luap/picpeak/commit/f926cd3adf513858bc7b291582c7ca2efdf93ff8))
|
||||
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* add minimum system requirements section to README ([4615a5d](https://github.com/the-luap/picpeak/commit/4615a5d795b415367edf4882628377936b29ab32))
|
||||
* add PUID/PGID note for Docker bind mounts to avoid permission issues ([0178e71](https://github.com/the-luap/picpeak/commit/0178e71c67f198c6013ece52b0a2da0e2f1a6b2a))
|
||||
* add transparency note about AI-assisted development ([35e360d](https://github.com/the-luap/picpeak/commit/35e360dcf7ac68833bec81f2e79f4a11a76a0e87))
|
||||
* add warnings about $ character in Docker Compose passwords ([87d1761](https://github.com/the-luap/picpeak/commit/87d1761091bb97747821aa810a58f3978a59d08f))
|
||||
* clarify VITE_API_URL usage; remove FRONTEND_API_URL; add storage vars; simplify compose mounts and external DB example (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([758c085](https://github.com/the-luap/picpeak/commit/758c085467e579e9f6b16df2298747fdddf2b205))
|
||||
* **compose:** fix backend healthcheck path; remove frontend VITE_API_URL env and document /api proxy (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([ecbc488](https://github.com/the-luap/picpeak/commit/ecbc48815ded99a052ef057e69427c823cd34ece))
|
||||
* fix deployment/admin routing and CORS guidance; add AGENTS.md; ignore AGENTS.md (refs [#18](https://github.com/the-luap/picpeak/issues/18)) ([dad1787](https://github.com/the-luap/picpeak/commit/dad1787aad8763637373e8eb87a47728d3d568cc))
|
||||
* follow-up on PR [#15](https://github.com/the-luap/picpeak/issues/15) — clarify VITE_API_URL usage, compose mounts, and admin routing (refs [#15](https://github.com/the-luap/picpeak/issues/15)) ([e9171c7](https://github.com/the-luap/picpeak/commit/e9171c71159cb41b91a099621bd2d7a7985dd239))
|
||||
* **readme:** reflect new External Media reference mode and update roadmap (gallery feedback status) ([ee13556](https://github.com/the-luap/picpeak/commit/ee13556c5cb4f24fe88e14fd00b821acf65b11cb))
|
||||
* replace email addresses with GitHub issue links ([0c989ce](https://github.com/the-luap/picpeak/commit/0c989ce08699ce68b131a9cc4ba4f14e06e3d221))
|
||||
* update deployment guide with GitHub Container Registry images ([2c9a56f](https://github.com/the-luap/picpeak/commit/2c9a56f217218f0700817d150b3de115e9503baa))
|
||||
|
||||
|
||||
### Code Refactoring
|
||||
|
||||
* simplify deployment structure with direct port exposure ([6492cb9](https://github.com/the-luap/picpeak/commit/6492cb9ec8f8b811297aa71c153b9fe6a00e947a))
|
||||
|
||||
## [2.3.0](https://github.com/the-luap/picpeak/compare/v2.2.4...v2.3.0) (2026-01-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* beta/stable release channels with update notifications and bug fixes ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* beta/stable release channels with update notifications and bug fixes ([#98](https://github.com/the-luap/picpeak/issues/98)) ([3c7dc20](https://github.com/the-luap/picpeak/commit/3c7dc2013fc3b57712ddf16db85f495b3cc7bfd7))
|
||||
* implement beta/stable release channels with update notifications ([617e778](https://github.com/the-luap/picpeak/commit/617e778a48e0f0c24fcb8441d00ed2a816f19c03))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* display new password after admin password reset ([bd8b885](https://github.com/the-luap/picpeak/commit/bd8b885f7f060160eb852870d143f25ce628f3db))
|
||||
* gallery thumbnails not loading (404 errors) [#96](https://github.com/the-luap/picpeak/issues/96) ([e3c3c4c](https://github.com/the-luap/picpeak/commit/e3c3c4c951c52de99bd0afd95b08d119153997b4))
|
||||
* prevent unnecessary image recompression and fix SQLite migration [#95](https://github.com/the-luap/picpeak/issues/95) ([3cdc0ea](https://github.com/the-luap/picpeak/commit/3cdc0ea7152e63cd72124a91394741a6e6904af3))
|
||||
* watermark upload JSON parsing and image quality preservation ([0e3b50d](https://github.com/the-luap/picpeak/commit/0e3b50d1b6a2dc532ebdc0981f81f77722e8f23a))
|
||||
|
||||
## [2.2.4](https://github.com/the-luap/picpeak/compare/v2.2.3...v2.2.4) (2026-01-08)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **backup:** add lastBackup alias and totalBackups for frontend compatibility ([749100c](https://github.com/the-luap/picpeak/commit/749100c92abd2bb123b137e3d3c6bb342b8f5f00))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
* Docker Swarm DNS resolution and backup status display (v2.2.3) ([082d8ab](https://github.com/the-luap/picpeak/commit/082d8ab2054416b2a4f9e0438aa2bda0a8f4277e))
|
||||
|
||||
## [2.2.3](https://github.com/the-luap/picpeak/compare/v2.2.2...v2.2.3) (2026-01-08)
|
||||
|
||||
|
||||
|
||||
+116
-148
@@ -2,9 +2,24 @@
|
||||
|
||||
This guide covers multiple deployment options for PicPeak, from simple local setups to production-ready configurations.
|
||||
|
||||
## 🎯 Quick Start - Simple Setup (Recommended for Beginners)
|
||||
## 📋 Table of Contents
|
||||
|
||||
For the easiest installation without Docker or complex configurations, use our **unified setup script**:
|
||||
- [Quick Start](#-quick-start)
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Configuration](#-configuration)
|
||||
- [Deployment](#-deployment)
|
||||
- [First Login](#-first-login)
|
||||
- [Release Channels](#-release-channels)
|
||||
- [Reverse Proxy Setup](#-reverse-proxy-setup)
|
||||
- [External Media Library](#external-media-library)
|
||||
- [Maintenance](#-maintenance)
|
||||
- [Troubleshooting](#-troubleshooting)
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Option 1: Automated Setup Script (Easiest)
|
||||
|
||||
For the simplest installation, use our unified setup script:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://raw.githubusercontent.com/the-luap/picpeak/main/scripts/picpeak-setup.sh -o picpeak-setup.sh && \
|
||||
@@ -12,27 +27,11 @@ chmod +x picpeak-setup.sh && \
|
||||
sudo ./picpeak-setup.sh
|
||||
```
|
||||
|
||||
This automated script handles everything including:
|
||||
- Choice between Docker or Native installation
|
||||
- OS detection and dependency installation
|
||||
- Database setup and service configuration
|
||||
- SSL/HTTPS setup (optional)
|
||||
|
||||
Perfect for:
|
||||
- Small to medium deployments
|
||||
- Local or VPS installations
|
||||
- Users new to server management
|
||||
- Quick testing and evaluation
|
||||
This script handles Docker/Native installation choice, OS detection, dependencies, database setup, and optional SSL.
|
||||
|
||||
👉 **See [SIMPLE_SETUP.md](./SIMPLE_SETUP.md) for detailed instructions.**
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Compose Deployment
|
||||
|
||||
### Option 1: Using Pre-built Images (Recommended)
|
||||
|
||||
PicPeak provides official Docker images via GitHub Container Registry for quick deployment without building:
|
||||
### Option 2: Docker with Pre-built Images (Recommended)
|
||||
|
||||
```bash
|
||||
# Clone repository for configuration files
|
||||
@@ -43,35 +42,40 @@ cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
# Use pre-built images deployment
|
||||
# Create required directories
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
# Deploy using pre-built images
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
|
||||
# Check logs
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
The production compose file uses:
|
||||
- **Backend**: `ghcr.io/the-luap/picpeak/backend:latest`
|
||||
- **Frontend**: `ghcr.io/the-luap/picpeak/frontend:latest`
|
||||
**Available image tags:**
|
||||
| Channel | Tags | Description |
|
||||
|---------|------|-------------|
|
||||
| Stable | `stable`, `latest`, `v2.3.0` | Production-ready releases |
|
||||
| Beta | `beta`, `v2.3.0-beta.1` | Early access to new features |
|
||||
| Branch | `main`, `beta` | Latest from each branch |
|
||||
|
||||
Available tags:
|
||||
- `latest` - Latest stable release
|
||||
- `main` - Latest main branch build
|
||||
- `develop` - Development branch (may be unstable)
|
||||
- `v1.0.0` - Specific version tags
|
||||
To select a channel, set `PICPEAK_CHANNEL` in your `.env` file (see [Release Channels](#release-channels) section)
|
||||
|
||||
### Option 2: Building from Source
|
||||
### Option 3: Build from Source
|
||||
|
||||
If you need to customize the application or the pre-built images aren't available, you can build locally:
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
|
||||
## 📋 Table of Contents
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
|
||||
- [Prerequisites](#prerequisites)
|
||||
- [Quick Start](#quick-start)
|
||||
- [Configuration](#configuration)
|
||||
- [Deployment](#deployment)
|
||||
- [First Login](#first-login)
|
||||
- [Reverse Proxy Setup](#reverse-proxy-setup)
|
||||
- [Maintenance](#maintenance)
|
||||
- [Troubleshooting](#troubleshooting)
|
||||
- [External Media Library](#external-media-library)
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@@ -80,106 +84,6 @@ If you need to customize the application or the pre-built images aren't availabl
|
||||
- SMTP server credentials for emails
|
||||
- At least 2GB RAM and 20GB storage
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### Method 1: Using Pre-built Images (Fastest)
|
||||
|
||||
1. **Clone the repository for configs**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Deploy using pre-built images**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose -f docker-compose.production.yml logs -f
|
||||
```
|
||||
|
||||
## External Media Library
|
||||
|
||||
PicPeak can reference an existing, read‑only media library mounted into the backend container. This avoids copying originals into PicPeak storage.
|
||||
|
||||
- Map your host library path to the container as read‑only in `docker-compose.production.yml`:
|
||||
- Add volume under `backend`: `- ${EXTERNAL_MEDIA}:/external-media:ro`
|
||||
- Add backend env: `EXTERNAL_MEDIA_ROOT=/external-media`
|
||||
- In `.env`, set:
|
||||
- `EXTERNAL_MEDIA=/mnt/photos` (example host path)
|
||||
- `EXTERNAL_MEDIA_ROOT=/external-media`
|
||||
|
||||
Usage:
|
||||
- In Admin → Events, set “Source Mode” to “Reference (external folder)”, select a folder under `/external-media`, then import to index and generate thumbnails. Originals stay in your library.
|
||||
|
||||
Backups and Archives:
|
||||
- Backups only include data under `STORAGE_PATH` and exclude external originals. The backup manifest includes `metadata.external_references = { excluded: true, events: N, photos: M }` and the Admin UI surfaces a warning.
|
||||
- Archiving reference events creates a manifest‑only ZIP and deletes thumbnails for that event. External originals are never moved or deleted.
|
||||
|
||||
Local (npm) setup (no Docker):
|
||||
|
||||
1. Create or choose a folder that contains your external originals, e.g. `/Users/you/Pictures/picpeak-external` (macOS/Linux) or `C:\\Pictures\\picpeak-external` (Windows).
|
||||
2. In `backend/.env` (or your shell), set:
|
||||
- `EXTERNAL_MEDIA_ROOT=/absolute/path/to/picpeak-external`
|
||||
- Ensure `STORAGE_PATH` points to your PicPeak storage (defaults to `./storage`).
|
||||
3. Start services from source:
|
||||
- Backend: `cd backend && npm install && npm run migrate && JWT_SECRET=... npm start`
|
||||
- Frontend: `cd frontend && npm install && npm run dev` (or build + serve)
|
||||
4. In Admin → Events:
|
||||
- Create an event, set “Source Mode” to “Reference (external folder)”.
|
||||
- Use the folder picker to browse under your `EXTERNAL_MEDIA_ROOT` and select the subfolder to reference.
|
||||
- Click “Import from selected folder” to index files and generate thumbnails on demand.
|
||||
|
||||
Notes:
|
||||
- PicPeak only reads from `EXTERNAL_MEDIA_ROOT`; it never modifies or deletes your originals there.
|
||||
- Thumbnails are generated under `STORAGE_PATH/thumbnails` and are included in backups; originals in `EXTERNAL_MEDIA_ROOT` are excluded.
|
||||
- On Windows, use absolute paths (e.g., `C:\\Photos\\Library`) for `EXTERNAL_MEDIA_ROOT`.
|
||||
|
||||
### Method 2: Building from Source
|
||||
|
||||
1. **Clone the repository**
|
||||
```bash
|
||||
git clone https://github.com/the-luap/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
2. **Set up environment**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
nano .env # Edit with your values
|
||||
```
|
||||
|
||||
3. **Create required directories**
|
||||
```bash
|
||||
mkdir -p events/active events/archived data logs backup storage
|
||||
chmod -R 755 events data logs backup storage
|
||||
```
|
||||
|
||||
4. **Build and deploy**
|
||||
```bash
|
||||
docker compose build
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
5. **Check logs**
|
||||
```bash
|
||||
docker compose logs -f
|
||||
```
|
||||
|
||||
## 🔧 Configuration
|
||||
|
||||
### Essential Environment Variables
|
||||
@@ -358,14 +262,16 @@ docker exec picpeak-backend cat data/ADMIN_CREDENTIALS.txt
|
||||
# Show current admin username and email (password is hidden)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js
|
||||
|
||||
# Reset the admin password to a new random password
|
||||
# Reset the admin password to a new random password (displays new password in console)
|
||||
docker exec picpeak-backend node scripts/show-admin-credentials.js --reset
|
||||
```
|
||||
|
||||
> **Note:** When using `--reset`, the new password will be displayed in the console output. Save it immediately - it will not be shown again!
|
||||
|
||||
#### Important Security Notes
|
||||
|
||||
- **Login requires the email address**, not username
|
||||
- The admin password is only displayed once during initial setup
|
||||
- When resetting password, the new password is displayed once in the console - save it immediately
|
||||
- **Password change is MANDATORY** on first login - the system will force you to change it
|
||||
- If you lose the password before first login, use the `--reset` option to generate a new one
|
||||
- New password requirements: minimum 12 characters, mixed case, numbers, and special characters
|
||||
@@ -446,6 +352,61 @@ ADMIN_EMAIL=your-email@yourdomain.com
|
||||
|
||||
**Note**: This only works on first deployment. To change the admin email after deployment, you'll need to update it in the database or create a new admin user through the admin panel.
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Configuring Your Channel
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
The `docker-compose.production.yml` uses this variable for both backend and frontend images:
|
||||
```yaml
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
```
|
||||
|
||||
### Switching Channels
|
||||
|
||||
To switch between channels:
|
||||
|
||||
```bash
|
||||
# Edit your .env file
|
||||
nano .env
|
||||
# Change PICPEAK_CHANNEL=stable to PICPEAK_CHANNEL=beta (or vice versa)
|
||||
|
||||
# Pull the new images and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. This feature:
|
||||
- Checks GitHub releases hourly (cached to avoid rate limits)
|
||||
- Shows updates relevant to your current channel (stable or beta)
|
||||
- Can be disabled by setting `UPDATE_CHECK_ENABLED=false` in your `.env`
|
||||
|
||||
## 🔒 Reverse Proxy Setup
|
||||
|
||||
For production deployments, you should use a reverse proxy for SSL/HTTPS. The application exposes ports directly, allowing you to use any reverse proxy solution.
|
||||
@@ -656,20 +617,27 @@ docker compose up -d
|
||||
docker compose ps
|
||||
```
|
||||
|
||||
#### Specific Version Updates
|
||||
#### Specific Version or Channel Updates
|
||||
|
||||
To use a specific version of the images:
|
||||
To use a specific version or switch channels, update your `.env` file:
|
||||
|
||||
```bash
|
||||
# Edit docker-compose.production.yml to specify version tags
|
||||
# Change: ghcr.io/the-luap/picpeak/backend:latest
|
||||
# To: ghcr.io/the-luap/picpeak/backend:v1.0.0
|
||||
# Edit .env to change the channel or pin to a specific version
|
||||
nano .env
|
||||
|
||||
# Options for PICPEAK_CHANNEL:
|
||||
# - stable (recommended, production-ready)
|
||||
# - beta (early access to new features)
|
||||
# - v2.3.0 (pin to specific stable version)
|
||||
# - v2.3.0-beta.1 (pin to specific beta version)
|
||||
|
||||
# Then pull and restart
|
||||
docker compose -f docker-compose.production.yml pull
|
||||
docker compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
The admin dashboard will notify you when updates are available for your configured channel.
|
||||
|
||||
### Database Migrations
|
||||
|
||||
Migrations run automatically on startup, but you can run them manually:
|
||||
|
||||
@@ -79,7 +79,51 @@ Note on Docker file permissions (PUID/PGID)
|
||||
- Example in `.env`:
|
||||
- `PUID=1000`
|
||||
- `PGID=1000`
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with “Permission denied”.
|
||||
- Without this, creating events, uploads, thumbnails, or logs can fail with "Permission denied".
|
||||
|
||||
## 🔄 Release Channels
|
||||
|
||||
PicPeak offers two release channels for different needs:
|
||||
|
||||
### Stable Channel (Recommended)
|
||||
- Production-ready releases
|
||||
- Thoroughly tested before release
|
||||
- Docker tags: `stable`, `latest`, or specific version like `v2.3.0`
|
||||
|
||||
### Beta Channel
|
||||
- Early access to new features
|
||||
- May contain bugs or incomplete functionality
|
||||
- Docker tags: `beta` or specific version like `v2.3.0-beta.1`
|
||||
|
||||
### Switching Channels
|
||||
|
||||
Set the `PICPEAK_CHANNEL` environment variable in your `.env` file:
|
||||
|
||||
```bash
|
||||
# For stable releases (default)
|
||||
PICPEAK_CHANNEL=stable
|
||||
|
||||
# For beta releases
|
||||
PICPEAK_CHANNEL=beta
|
||||
|
||||
# For a specific version
|
||||
PICPEAK_CHANNEL=v2.3.0
|
||||
```
|
||||
|
||||
Then update your containers:
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.production.yml pull
|
||||
docker-compose -f docker-compose.production.yml up -d
|
||||
```
|
||||
|
||||
### Update Notifications
|
||||
|
||||
The admin dashboard automatically notifies you when updates are available for your channel. To disable update checks, set:
|
||||
|
||||
```bash
|
||||
UPDATE_CHECK_ENABLED=false
|
||||
```
|
||||
|
||||
## 📖 Documentation
|
||||
|
||||
|
||||
@@ -451,6 +451,8 @@ cd /opt/picpeak/app/backend
|
||||
sudo -u picpeak node scripts/reset-admin-password.js
|
||||
```
|
||||
|
||||
> **Note:** The new password will be displayed in the console output and saved to `ADMIN_PASSWORD_RESET.txt`. Save it immediately!
|
||||
|
||||
### Getting Help
|
||||
|
||||
1. **Check logs:**
|
||||
|
||||
@@ -14,6 +14,11 @@ ADMIN_URL=https://photos.example.com
|
||||
FRONTEND_URL=https://photos.example.com
|
||||
BACKEND_URL=https://photos.example.com # Or https://api.photos.example.com if separate
|
||||
|
||||
# API URL for email assets (logos, images in emails)
|
||||
# This must be the publicly accessible URL where recipients can load images
|
||||
# If not set, defaults to http://localhost:3001 which will break images in production emails
|
||||
API_URL=https://photos.example.com/api
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_CLIENT=pg
|
||||
DB_HOST=localhost
|
||||
@@ -39,6 +44,7 @@ SMTP_PASS=your-sendgrid-api-key
|
||||
EMAIL_FROM=noreply@example.com
|
||||
|
||||
# Storage Paths
|
||||
# IMPORTANT: STORAGE_PATH must be set to avoid file path resolution issues
|
||||
# Docker deployment:
|
||||
STORAGE_PATH=/app/storage
|
||||
EVENTS_PATH=/app/storage/events
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Database Migrations
|
||||
|
||||
This directory contains database migrations for the Wedding Photo Sharing platform.
|
||||
This directory contains database migrations for the PicPeak photo sharing platform.
|
||||
|
||||
## Directory Structure
|
||||
|
||||
@@ -9,6 +9,7 @@ Essential migrations that are always run for new deployments. These include:
|
||||
- `init.js` - Initial database schema creation
|
||||
- Backup service tables (029-035)
|
||||
- Gallery feedback tables (033)
|
||||
- Pre-generated watermarks (061)
|
||||
|
||||
### `/legacy`
|
||||
Migrations needed only when upgrading from older versions. New deployments can skip these as the core schema already includes all necessary tables and columns.
|
||||
|
||||
@@ -3,6 +3,81 @@
|
||||
* These templates support the RBAC (Role-Based Access Control) feature
|
||||
*/
|
||||
exports.up = async function(knex) {
|
||||
// First, ensure the email_templates table has multilingual columns
|
||||
// This is needed for fresh installations where legacy migrations don't run
|
||||
const columnInfo = await knex('email_templates').columnInfo();
|
||||
|
||||
if (!columnInfo.subject_en) {
|
||||
// Need to add multilingual columns
|
||||
console.log('Adding multilingual columns to email_templates table...');
|
||||
|
||||
// Check if we're using SQLite or PostgreSQL
|
||||
const client = knex.client.config.client;
|
||||
const isSqlite = client === 'sqlite3' || client === 'better-sqlite3';
|
||||
|
||||
if (isSqlite) {
|
||||
// SQLite doesn't support column rename directly in all versions
|
||||
// We need to recreate the table with new structure
|
||||
|
||||
// Get existing data
|
||||
const existingData = await knex('email_templates').select('*');
|
||||
|
||||
// Drop the old table
|
||||
await knex.schema.dropTable('email_templates');
|
||||
|
||||
// Create new table with multilingual columns
|
||||
await knex.schema.createTable('email_templates', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('template_key').unique().notNullable();
|
||||
table.string('subject_en');
|
||||
table.string('subject_de');
|
||||
table.text('body_html_en');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_en');
|
||||
table.text('body_text_de');
|
||||
table.json('variables');
|
||||
table.datetime('updated_at').defaultTo(knex.fn.now());
|
||||
});
|
||||
|
||||
// Re-insert existing data with column mapping
|
||||
for (const row of existingData) {
|
||||
await knex('email_templates').insert({
|
||||
template_key: row.template_key,
|
||||
subject_en: row.subject,
|
||||
subject_de: row.subject, // Copy to German as default
|
||||
body_html_en: row.body_html,
|
||||
body_html_de: row.body_html,
|
||||
body_text_en: row.body_text,
|
||||
body_text_de: row.body_text,
|
||||
variables: row.variables,
|
||||
updated_at: row.updated_at
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Migrated email_templates table to multilingual structure');
|
||||
} else {
|
||||
// PostgreSQL supports ALTER TABLE for column operations
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
});
|
||||
|
||||
await knex.schema.alterTable('email_templates', (table) => {
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
|
||||
// Copy English values to German as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Check which templates already exist
|
||||
const existingTemplates = await knex('email_templates')
|
||||
.select('template_key')
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* Migration: Add event_types table
|
||||
*
|
||||
* Creates a customizable event types system to replace hardcoded event types.
|
||||
* This allows users to:
|
||||
* - Rename existing event types (wedding, birthday, corporate, other)
|
||||
* - Create custom event types with custom slug prefixes
|
||||
* - Associate default theme presets with event types
|
||||
*
|
||||
* Backward compatible: Existing events keep their event_type strings.
|
||||
* New events can use either legacy strings or custom event type slug_prefix.
|
||||
*/
|
||||
|
||||
const { createTableIfNotExists, insertIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Creating event_types table...');
|
||||
|
||||
// Create event_types table
|
||||
const hasEventTypesTable = await knex.schema.hasTable('event_types');
|
||||
if (!hasEventTypesTable) {
|
||||
await knex.schema.createTable('event_types', (table) => {
|
||||
table.increments('id').primary();
|
||||
table.string('name', 100).notNullable(); // Display name: "Family Shoot"
|
||||
table.string('slug_prefix', 50).unique().notNullable(); // URL prefix: "family"
|
||||
table.string('emoji', 10); // Icon emoji: "👨👩👧"
|
||||
table.string('theme_preset', 50); // Default theme: "elegantWedding"
|
||||
table.text('theme_config'); // Custom theme JSON overrides (optional)
|
||||
table.integer('display_order').defaultTo(0); // Sorting in dropdowns
|
||||
table.boolean('is_system').defaultTo(false); // Protect default types
|
||||
table.boolean('is_active').defaultTo(true); // Allow hiding types
|
||||
table.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
table.timestamp('updated_at').defaultTo(knex.fn.now());
|
||||
|
||||
// Indexes for performance
|
||||
table.index('slug_prefix');
|
||||
table.index('display_order');
|
||||
table.index('is_active');
|
||||
});
|
||||
console.log('event_types table created');
|
||||
} else {
|
||||
console.log('event_types table already exists, skipping creation');
|
||||
}
|
||||
|
||||
// Seed default event types (matching current hardcoded values)
|
||||
const existingTypes = await knex('event_types').select('slug_prefix');
|
||||
const existingSlugs = existingTypes.map(t => t.slug_prefix);
|
||||
|
||||
const defaultTypes = [
|
||||
{
|
||||
name: 'Wedding',
|
||||
slug_prefix: 'wedding',
|
||||
emoji: '💒',
|
||||
theme_preset: 'elegantWedding',
|
||||
display_order: 1,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
name: 'Birthday',
|
||||
slug_prefix: 'birthday',
|
||||
emoji: '🎂',
|
||||
theme_preset: 'birthdayFun',
|
||||
display_order: 2,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
name: 'Corporate',
|
||||
slug_prefix: 'corporate',
|
||||
emoji: '🏢',
|
||||
theme_preset: 'corporateTimeline',
|
||||
display_order: 3,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
},
|
||||
{
|
||||
name: 'Other',
|
||||
slug_prefix: 'other',
|
||||
emoji: '📸',
|
||||
theme_preset: 'default',
|
||||
display_order: 4,
|
||||
is_system: true,
|
||||
is_active: true
|
||||
}
|
||||
];
|
||||
|
||||
const typesToInsert = defaultTypes.filter(type => !existingSlugs.includes(type.slug_prefix));
|
||||
|
||||
if (typesToInsert.length > 0) {
|
||||
await knex('event_types').insert(typesToInsert);
|
||||
console.log(`Inserted ${typesToInsert.length} default event types`);
|
||||
} else {
|
||||
console.log('Default event types already exist, skipping seed');
|
||||
}
|
||||
|
||||
console.log('Migration 061_add_event_types_table completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rolling back event_types table...');
|
||||
|
||||
// Drop the table (data will be lost)
|
||||
await knex.schema.dropTableIfExists('event_types');
|
||||
|
||||
console.log('event_types table dropped');
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Migration: Add optional event date and expiration settings
|
||||
* These settings control whether event_date and expiration are required
|
||||
* when creating new events, supporting non-event use cases like portraits.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
// Add new settings for optional date and expiration
|
||||
const settings = [
|
||||
{ setting_key: 'event_require_event_date', setting_value: JSON.stringify(true), setting_type: 'boolean' },
|
||||
{ setting_key: 'event_require_expiration', setting_value: JSON.stringify(true), setting_type: 'boolean' }
|
||||
];
|
||||
|
||||
for (const setting of settings) {
|
||||
const exists = await knex('app_settings').where('setting_key', setting.setting_key).first();
|
||||
if (!exists) {
|
||||
await knex('app_settings').insert({
|
||||
...setting,
|
||||
updated_at: knex.fn.now()
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Make event_date and expires_at columns nullable
|
||||
// PostgreSQL supports ALTER COLUMN ... DROP NOT NULL
|
||||
// SQLite requires table recreation (handled differently)
|
||||
const client = knex.client.config.client;
|
||||
|
||||
if (client === 'pg' || client === 'postgresql') {
|
||||
// PostgreSQL: directly alter columns
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN event_date DROP NOT NULL');
|
||||
await knex.raw('ALTER TABLE events ALTER COLUMN expires_at DROP NOT NULL');
|
||||
} else if (client === 'sqlite3' || client === 'better-sqlite3') {
|
||||
// SQLite: columns are already effectively nullable in most cases
|
||||
// SQLite doesn't enforce NOT NULL as strictly, and altering requires table recreation
|
||||
// For safety, we'll skip the schema change for SQLite as it's complex
|
||||
// The application logic will handle null values appropriately
|
||||
console.log('SQLite detected - skipping schema alteration (columns will accept NULL values)');
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
// Remove the settings
|
||||
await knex('app_settings')
|
||||
.whereIn('setting_key', [
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.del();
|
||||
|
||||
// Note: We don't restore NOT NULL constraints as that could fail
|
||||
// if there are existing NULL values in the database
|
||||
};
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Migration 061: Add pre-generated watermark path to photos table
|
||||
* - photos.watermark_path: path to pre-generated watermarked image
|
||||
* - photos.watermark_generated_at: timestamp of watermark generation
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 061_add_watermark_path');
|
||||
|
||||
// photos.watermark_path (nullable - path to pre-generated watermarked image)
|
||||
await addColumnIfNotExists(knex, 'photos', 'watermark_path', (table) => {
|
||||
table.string('watermark_path', 512);
|
||||
});
|
||||
|
||||
// photos.watermark_generated_at (nullable - when watermark was last generated)
|
||||
await addColumnIfNotExists(knex, 'photos', 'watermark_generated_at', (table) => {
|
||||
table.timestamp('watermark_generated_at');
|
||||
});
|
||||
|
||||
console.log('Migration 061_add_watermark_path completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 061_add_watermark_path');
|
||||
// Keep columns (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Migration: Add hero logo customization settings to events table
|
||||
*
|
||||
* Allows per-event customization of the hero gallery logo:
|
||||
* - hero_logo_visible: Show/hide the logo overlay
|
||||
* - hero_logo_size: Logo size (small, medium, large, xlarge)
|
||||
* - hero_logo_position: Logo position (top, center, bottom)
|
||||
*
|
||||
* Addresses GitHub Issue #138: Add Option to customize the Hero gallery layout
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
console.log('Adding hero logo settings to events table...');
|
||||
|
||||
// Add hero_logo_visible column
|
||||
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
|
||||
if (!hasVisibleColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.boolean('hero_logo_visible').notNullable().defaultTo(true);
|
||||
});
|
||||
console.log('Added hero_logo_visible column');
|
||||
}
|
||||
|
||||
// Add hero_logo_size column
|
||||
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
|
||||
if (!hasSizeColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_size', 20).notNullable().defaultTo('medium');
|
||||
});
|
||||
console.log('Added hero_logo_size column');
|
||||
}
|
||||
|
||||
// Add hero_logo_position column
|
||||
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
|
||||
if (!hasPositionColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_position', 20).notNullable().defaultTo('top');
|
||||
});
|
||||
console.log('Added hero_logo_position column');
|
||||
}
|
||||
|
||||
console.log('Migration 062_add_hero_logo_settings completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
console.log('Rolling back hero logo settings...');
|
||||
|
||||
const hasVisibleColumn = await knex.schema.hasColumn('events', 'hero_logo_visible');
|
||||
if (hasVisibleColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_visible');
|
||||
});
|
||||
}
|
||||
|
||||
const hasSizeColumn = await knex.schema.hasColumn('events', 'hero_logo_size');
|
||||
if (hasSizeColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_size');
|
||||
});
|
||||
}
|
||||
|
||||
const hasPositionColumn = await knex.schema.hasColumn('events', 'hero_logo_position');
|
||||
if (hasPositionColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_position');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Hero logo settings columns dropped');
|
||||
};
|
||||
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Migration 062: Add original_filename to photos table
|
||||
* - photos.original_filename: preserves the original filename from upload
|
||||
* This enables Lightroom integration by exporting filtered filenames
|
||||
*/
|
||||
|
||||
const { addColumnIfNotExists } = require('../helpers');
|
||||
|
||||
exports.up = async function(knex) {
|
||||
console.log('Running migration: 062_add_original_filename');
|
||||
|
||||
// photos.original_filename (nullable - original filename before renaming)
|
||||
await addColumnIfNotExists(knex, 'photos', 'original_filename', (table) => {
|
||||
table.string('original_filename', 512);
|
||||
});
|
||||
|
||||
console.log('Migration 062_add_original_filename completed');
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
console.log('Rollback: 062_add_original_filename');
|
||||
// Keep column (safe rollback not removing data). Intentionally no-op.
|
||||
};
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Migration: Add custom logo support to events table
|
||||
*
|
||||
* Allows per-event custom logo that overrides the global branding logo:
|
||||
* - hero_logo_url: Public path to the uploaded custom logo
|
||||
* - hero_logo_path: Full filesystem path to the custom logo
|
||||
*
|
||||
* Logo priority: Event custom logo > Global branding logo > Default PicPeak logo
|
||||
*
|
||||
* Addresses GitHub Issue #138: Per-event custom logo option
|
||||
*/
|
||||
|
||||
exports.up = async function (knex) {
|
||||
console.log('Adding custom logo columns to events table...');
|
||||
|
||||
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
|
||||
if (!hasUrlColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_url', 500).nullable().defaultTo(null);
|
||||
});
|
||||
console.log('Added hero_logo_url column');
|
||||
}
|
||||
|
||||
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
|
||||
if (!hasPathColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.string('hero_logo_path', 500).nullable().defaultTo(null);
|
||||
});
|
||||
console.log('Added hero_logo_path column');
|
||||
}
|
||||
|
||||
console.log('Migration 063_add_event_custom_logo completed successfully');
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
console.log('Rolling back custom logo columns...');
|
||||
|
||||
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
|
||||
if (hasUrlColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_url');
|
||||
});
|
||||
}
|
||||
|
||||
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
|
||||
if (hasPathColumn) {
|
||||
await knex.schema.table('events', (table) => {
|
||||
table.dropColumn('hero_logo_path');
|
||||
});
|
||||
}
|
||||
|
||||
console.log('Custom logo columns dropped');
|
||||
};
|
||||
@@ -1,22 +1,27 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add photo_counter column to photo_categories table
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('photo_counter').defaultTo(0).notNullable();
|
||||
});
|
||||
// Check if photo_counter column already exists to make migration idempotent
|
||||
const hasPhotoCounter = await knex.schema.hasColumn('photo_categories', 'photo_counter');
|
||||
|
||||
// Initialize counters based on existing photos
|
||||
const categories = await knex('photo_categories').select('id');
|
||||
|
||||
for (const category of categories) {
|
||||
const photoCount = await knex('photos')
|
||||
.where('category_id', category.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount && photoCount.count > 0) {
|
||||
await knex('photo_categories')
|
||||
.where('id', category.id)
|
||||
.update({ photo_counter: photoCount.count });
|
||||
if (!hasPhotoCounter) {
|
||||
// Add photo_counter column to photo_categories table
|
||||
await knex.schema.alterTable('photo_categories', function(table) {
|
||||
table.integer('photo_counter').defaultTo(0).notNullable();
|
||||
});
|
||||
|
||||
// Initialize counters based on existing photos
|
||||
const categories = await knex('photo_categories').select('id');
|
||||
|
||||
for (const category of categories) {
|
||||
const photoCount = await knex('photos')
|
||||
.where('category_id', category.id)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (photoCount && photoCount.count > 0) {
|
||||
await knex('photo_categories')
|
||||
.where('id', category.id)
|
||||
.update({ photo_counter: photoCount.count });
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,23 +1,33 @@
|
||||
exports.up = async function(knex) {
|
||||
// Add language-specific columns to email_templates
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
// Add English versions (rename existing columns for consistency)
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
|
||||
// Add German versions
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
// Check which columns already exist to make migration idempotent
|
||||
const hasSubjectEn = await knex.schema.hasColumn('email_templates', 'subject_en');
|
||||
const hasSubjectDe = await knex.schema.hasColumn('email_templates', 'subject_de');
|
||||
const hasSubjectOriginal = await knex.schema.hasColumn('email_templates', 'subject');
|
||||
|
||||
// Copy existing values to German columns as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
// Only rename columns if they haven't been renamed yet
|
||||
if (hasSubjectOriginal && !hasSubjectEn) {
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
table.renameColumn('subject', 'subject_en');
|
||||
table.renameColumn('body_html', 'body_html_en');
|
||||
table.renameColumn('body_text', 'body_text_en');
|
||||
});
|
||||
}
|
||||
|
||||
// Only add German columns if they don't exist
|
||||
if (!hasSubjectDe) {
|
||||
await knex.schema.alterTable('email_templates', function(table) {
|
||||
table.string('subject_de');
|
||||
table.text('body_html_de');
|
||||
table.text('body_text_de');
|
||||
});
|
||||
|
||||
// Copy existing values to German columns as defaults
|
||||
await knex('email_templates').update({
|
||||
subject_de: knex.raw('subject_en'),
|
||||
body_html_de: knex.raw('body_html_en'),
|
||||
body_text_de: knex.raw('body_text_en')
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(knex) {
|
||||
|
||||
@@ -67,26 +67,37 @@ async function runMigrationSafely(filepath) {
|
||||
const migrationPath = path.join(__dirname, filepath);
|
||||
const migration = require(migrationPath);
|
||||
const filename = path.basename(filepath);
|
||||
|
||||
|
||||
if (migration.up) {
|
||||
console.log(`Running migration: ${filepath}`);
|
||||
|
||||
|
||||
// Run migration in a transaction if possible
|
||||
// IMPORTANT: Include the migrations table insert INSIDE the transaction
|
||||
// to ensure atomicity between schema changes and tracking
|
||||
if (db.client.config.client === 'pg') {
|
||||
await db.transaction(async (trx) => {
|
||||
await migration.up(trx);
|
||||
// Insert migration record inside transaction for atomicity
|
||||
await trx('migrations').insert({ filename });
|
||||
});
|
||||
} else {
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
}
|
||||
|
||||
await db('migrations').insert({ filename });
|
||||
|
||||
console.log(`Migration ${filepath} completed successfully`);
|
||||
}
|
||||
} catch (error) {
|
||||
// Check if error is because schema already exists
|
||||
if (error.code === '42P07' || // PostgreSQL: relation already exists
|
||||
error.code === 'SQLITE_ERROR' && error.message.includes('already exists')) {
|
||||
// PostgreSQL error codes:
|
||||
// - 42P07: duplicate_table (relation already exists)
|
||||
// - 42701: duplicate_column (column already exists)
|
||||
// - 42710: duplicate_object (constraint, index, etc. already exists)
|
||||
// - 23505: unique_violation (migration record already exists)
|
||||
const schemaExistsErrors = ['42P07', '42701', '42710', '23505'];
|
||||
const isSQLiteAlreadyExists = error.code === 'SQLITE_ERROR' && error.message.includes('already exists');
|
||||
|
||||
if (schemaExistsErrors.includes(error.code) || isSQLiteAlreadyExists) {
|
||||
console.log(`Migration ${filepath} - schema already exists, marking as applied`);
|
||||
await markMigrationAsApplied(path.basename(filepath));
|
||||
} else {
|
||||
|
||||
@@ -26,11 +26,22 @@ async function runMigration(filepath) {
|
||||
const migrationPath = path.join(__dirname, filepath);
|
||||
const migration = require(migrationPath);
|
||||
const filename = path.basename(filepath);
|
||||
|
||||
|
||||
if (migration.up) {
|
||||
console.log(`Running migration: ${filepath}`);
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
|
||||
// Run migration in a transaction if PostgreSQL to ensure atomicity
|
||||
// between schema changes and migration tracking
|
||||
if (db.client.config.client === 'pg') {
|
||||
await db.transaction(async (trx) => {
|
||||
await migration.up(trx);
|
||||
await trx('migrations').insert({ filename });
|
||||
});
|
||||
} else {
|
||||
await migration.up(db);
|
||||
await db('migrations').insert({ filename });
|
||||
}
|
||||
|
||||
console.log(`Migration ${filepath} completed`);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+5
-5
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.15",
|
||||
"version": "3.3.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.1.15",
|
||||
"version": "3.3.0-beta.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.850.0",
|
||||
"@aws-sdk/lib-storage": "^3.850.0",
|
||||
@@ -8040,9 +8040,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.defaults": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "2.2.3",
|
||||
"version": "3.6.0-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
@@ -8,6 +8,7 @@
|
||||
"dev": "nodemon server.js",
|
||||
"migrate": "node migrations/run-migrations.js",
|
||||
"migrate:safe": "node migrations/run-migrations-safe.js",
|
||||
"generate:watermarks": "node scripts/generate-watermarks.js",
|
||||
"test": "jest",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
/**
|
||||
* Script to generate pre-watermarked versions for existing photos
|
||||
* This is a one-time migration script to populate watermarks for photos
|
||||
* that existed before the pre-generation feature was implemented.
|
||||
*
|
||||
* Usage: node scripts/generate-watermarks.js [eventId]
|
||||
*
|
||||
* Options:
|
||||
* eventId - Optional: Only generate watermarks for a specific event
|
||||
*
|
||||
* Examples:
|
||||
* node scripts/generate-watermarks.js # Generate for all photos
|
||||
* node scripts/generate-watermarks.js 5 # Generate for event ID 5
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { db } = require('../src/database/db');
|
||||
const watermarkService = require('../src/services/watermarkService');
|
||||
const watermarkGeneratorService = require('../src/services/watermarkGeneratorService');
|
||||
|
||||
async function generateWatermarks(eventId = null) {
|
||||
try {
|
||||
console.log('='.repeat(60));
|
||||
console.log('PicPeak Watermark Generation Script');
|
||||
console.log('='.repeat(60));
|
||||
|
||||
// Check if watermarking is enabled
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (!settings || !settings.enabled) {
|
||||
console.log('\nWatermarking is currently DISABLED in settings.');
|
||||
console.log('Enable watermarking in Admin > Branding settings first.');
|
||||
console.log('Exiting without generating watermarks.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log('\nWatermark Settings:');
|
||||
console.log(` Enabled: ${settings.enabled}`);
|
||||
console.log(` Position: ${settings.position}`);
|
||||
console.log(` Opacity: ${settings.opacity}%`);
|
||||
console.log(` Size: ${settings.size}%`);
|
||||
console.log(` Logo: ${settings.logoPath || '(using text fallback)'}`);
|
||||
|
||||
// Build query
|
||||
let query = db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.whereNull('photos.watermark_path')
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.event_id',
|
||||
'events.event_name'
|
||||
);
|
||||
|
||||
if (eventId) {
|
||||
query = query.where('photos.event_id', eventId);
|
||||
console.log(`\nFiltering to event ID: ${eventId}`);
|
||||
}
|
||||
|
||||
const photos = await query;
|
||||
|
||||
if (photos.length === 0) {
|
||||
console.log('\nNo photos found without watermarks.');
|
||||
if (eventId) {
|
||||
console.log(`(Checked event ID: ${eventId})`);
|
||||
}
|
||||
console.log('All photos already have pre-generated watermarks or watermarking is disabled.');
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log(`\nFound ${photos.length} photos without watermarks.`);
|
||||
|
||||
// Group by event for display
|
||||
const eventCounts = {};
|
||||
photos.forEach(p => {
|
||||
eventCounts[p.event_name] = (eventCounts[p.event_name] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('\nPhotos by event:');
|
||||
Object.entries(eventCounts).forEach(([name, count]) => {
|
||||
console.log(` ${name}: ${count} photos`);
|
||||
});
|
||||
|
||||
console.log('\nStarting watermark generation...\n');
|
||||
|
||||
let successCount = 0;
|
||||
let failCount = 0;
|
||||
const startTime = Date.now();
|
||||
|
||||
// Process photos with progress display
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const progress = Math.round(((i + 1) / photos.length) * 100);
|
||||
|
||||
process.stdout.write(`\r[${progress}%] Processing photo ${i + 1}/${photos.length}: ${photo.filename.substring(0, 30)}...`);
|
||||
|
||||
try {
|
||||
const result = await watermarkGeneratorService.generateForPhoto(photo.id);
|
||||
|
||||
if (result.success) {
|
||||
successCount++;
|
||||
} else {
|
||||
failCount++;
|
||||
console.log(`\n Failed: ${photo.filename} - ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
failCount++;
|
||||
console.log(`\n Error: ${photo.filename} - ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
const duration = ((Date.now() - startTime) / 1000).toFixed(1);
|
||||
|
||||
console.log('\n');
|
||||
console.log('='.repeat(60));
|
||||
console.log('Watermark Generation Complete');
|
||||
console.log('='.repeat(60));
|
||||
console.log(` Total processed: ${photos.length}`);
|
||||
console.log(` Successful: ${successCount}`);
|
||||
console.log(` Failed: ${failCount}`);
|
||||
console.log(` Duration: ${duration} seconds`);
|
||||
console.log(` Average: ${(photos.length / parseFloat(duration)).toFixed(1)} photos/second`);
|
||||
|
||||
if (failCount > 0) {
|
||||
console.log('\nSome watermarks failed to generate. Check the errors above.');
|
||||
console.log('You can re-run this script to retry failed photos.');
|
||||
}
|
||||
|
||||
process.exit(failCount > 0 ? 1 : 0);
|
||||
} catch (error) {
|
||||
console.error('\nFatal error:', error.message);
|
||||
console.error(error.stack);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Parse command line arguments
|
||||
const args = process.argv.slice(2);
|
||||
const eventId = args[0] ? parseInt(args[0], 10) : null;
|
||||
|
||||
if (args[0] && isNaN(eventId)) {
|
||||
console.error('Error: eventId must be a number');
|
||||
console.log('Usage: node scripts/generate-watermarks.js [eventId]');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Run the script
|
||||
generateWatermarks(eventId)
|
||||
.then(() => {
|
||||
process.exit(0);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Unhandled error:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -36,12 +36,14 @@ async function showAdminCredentials(resetPassword = false) {
|
||||
.where('id', admin.id)
|
||||
.update({
|
||||
password_hash: passwordHash,
|
||||
must_change_password: true,
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
// Password logging removed for security - check logs or database if needed
|
||||
console.log('Password: [NEWLY RESET - stored in database]');
|
||||
console.log('\n⚠️ IMPORTANT: New password has been set in database!');
|
||||
|
||||
console.log(`Password: ${newPassword}`);
|
||||
console.log('\n⚠️ IMPORTANT:');
|
||||
console.log('1. Save this password securely - it will not be shown again');
|
||||
console.log('2. You will be required to change it on next login');
|
||||
} else {
|
||||
console.log('Password: [hidden - use --reset flag to generate new password]');
|
||||
}
|
||||
|
||||
@@ -437,6 +437,7 @@ app.use('/api/admin/photo-export', require('./src/routes/adminPhotoExport'));
|
||||
app.use('/api/admin/css-templates', require('./src/routes/adminCssTemplates'));
|
||||
app.use('/api/admin/events', require('./src/routes/adminEventRename'));
|
||||
app.use('/api/admin/users', require('./src/routes/adminUsers'));
|
||||
app.use('/api/admin/event-types', require('./src/routes/adminEventTypes'));
|
||||
app.use('/api/invite', require('./src/routes/acceptInvite'));
|
||||
app.use('/api/public/settings', require('./src/routes/publicSettings'));
|
||||
app.use('/api/public', require('./src/routes/publicCMS'));
|
||||
|
||||
@@ -168,9 +168,10 @@ async function galleryAuth(req, res, next) {
|
||||
return res.status(404).json({ error: 'Gallery not found or expired' });
|
||||
}
|
||||
|
||||
// Check if gallery has expired
|
||||
if (new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
// Check if gallery has expired (only if expires_at is set)
|
||||
// Galleries with null expires_at never expire
|
||||
if (event.expires_at && new Date(event.expires_at) < new Date()) {
|
||||
return res.status(410).json({
|
||||
error: 'Gallery has expired',
|
||||
code: 'GALLERY_EXPIRED'
|
||||
});
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
/**
|
||||
* Admin Event Types Routes
|
||||
* CRUD operations for managing customizable event types
|
||||
*
|
||||
* @module routes/adminEventTypes
|
||||
*/
|
||||
|
||||
const express = require('express');
|
||||
const { body, param, validationResult } = require('express-validator');
|
||||
const { logActivity } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
/**
|
||||
* GET /admin/event-types
|
||||
* Get all event types (for admin management)
|
||||
*/
|
||||
router.get('/', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
const includeInactive = req.query.includeInactive === 'true';
|
||||
const eventTypes = await eventTypeService.getAllEventTypes({
|
||||
activeOnly: !includeInactive
|
||||
});
|
||||
|
||||
res.json({ eventTypes });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/event-types/active
|
||||
* Get only active event types (for dropdowns/selection)
|
||||
*/
|
||||
router.get('/active', adminAuth, async (req, res) => {
|
||||
try {
|
||||
const eventTypes = await eventTypeService.getActiveEventTypes();
|
||||
res.json({ eventTypes });
|
||||
} catch (error) {
|
||||
logger.error('Error fetching active event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event types' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /admin/event-types/:id
|
||||
* Get a single event type by ID
|
||||
*/
|
||||
router.get('/:id', adminAuth, requirePermission('settings.view'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const eventType = await eventTypeService.getEventTypeById(parseInt(id));
|
||||
|
||||
if (!eventType) {
|
||||
return res.status(404).json({ error: 'Event type not found' });
|
||||
}
|
||||
|
||||
res.json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event type:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to fetch event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/event-types
|
||||
* Create a new event type
|
||||
*/
|
||||
router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
body('name').notEmpty().trim().withMessage('Name is required'),
|
||||
body('slug_prefix')
|
||||
.notEmpty()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.matches(/^[a-z0-9-]+$/i)
|
||||
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||
body('emoji').optional().trim(),
|
||||
body('theme_preset').optional().trim(),
|
||||
body('theme_config').optional(),
|
||||
body('display_order').optional().isInt({ min: 0 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const {
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
} = req.body;
|
||||
|
||||
const eventType = await eventTypeService.createEventType({
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
});
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_created',
|
||||
{ name, slug_prefix },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.status(201).json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error creating event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to create event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /admin/event-types/:id
|
||||
* Update an event type
|
||||
*/
|
||||
router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID'),
|
||||
body('name').optional().notEmpty().trim().withMessage('Name cannot be empty'),
|
||||
body('slug_prefix')
|
||||
.optional()
|
||||
.trim()
|
||||
.isLength({ min: 2, max: 50 })
|
||||
.matches(/^[a-z0-9-]+$/i)
|
||||
.withMessage('Slug prefix must be 2-50 characters and contain only letters, numbers, and hyphens'),
|
||||
body('emoji').optional().trim(),
|
||||
body('theme_preset').optional().trim(),
|
||||
body('theme_config').optional(),
|
||||
body('display_order').optional().isInt({ min: 0 }),
|
||||
body('is_active').optional().isBoolean()
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const updates = req.body;
|
||||
|
||||
const eventType = await eventTypeService.updateEventType(parseInt(id), updates);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_updated',
|
||||
{ id, changes: Object.keys(updates) },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(eventType);
|
||||
} catch (error) {
|
||||
logger.error('Error updating event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to update event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /admin/event-types/:id
|
||||
* Delete an event type (only non-system types with no events)
|
||||
*/
|
||||
router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
param('id').isInt().withMessage('Invalid event type ID')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { id } = req.params;
|
||||
const result = await eventTypeService.deleteEventType(parseInt(id));
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_type_deleted',
|
||||
{ id, name: result.deleted.name, slug_prefix: result.deleted.slug_prefix },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event type deleted successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event type:', { error: error.message });
|
||||
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
res.status(500).json({ error: 'Failed to delete event type' });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /admin/event-types/reorder
|
||||
* Reorder event types by providing an array of IDs in the desired order
|
||||
*/
|
||||
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
body('orderedIds').isArray().withMessage('orderedIds must be an array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each ID must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const { orderedIds } = req.body;
|
||||
const eventTypes = await eventTypeService.reorderEventTypes(orderedIds);
|
||||
|
||||
// Log activity
|
||||
await logActivity('event_types_reordered',
|
||||
{ newOrder: orderedIds },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ eventTypes, message: 'Event types reordered successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error reordering event types:', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to reorder event types' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -6,8 +6,10 @@ const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
|
||||
// Enhanced event creation with password validation
|
||||
// Note: This is a partial/reference file - dynamic event type validation should be implemented
|
||||
// similar to adminEvents.js using eventTypeService.isValidEventType()
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim(), // Dynamic validation via eventTypeService
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_email').isEmail().normalizeEmail(),
|
||||
|
||||
@@ -9,6 +9,7 @@ const bcrypt = require('bcrypt');
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const multer = require('multer');
|
||||
const { archiveEvent } = require('../services/archiveService');
|
||||
const { queueEmail } = require('../services/emailProcessor');
|
||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||
@@ -17,6 +18,37 @@ const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwor
|
||||
const logger = require('../utils/logger');
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Configure multer for event logo uploads
|
||||
const eventLogoStorage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
|
||||
await fs.mkdir(uploadDir, { recursive: true });
|
||||
cb(null, uploadDir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname);
|
||||
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const eventLogoUpload = multer({
|
||||
storage: eventLogoStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||
return cb(null, true);
|
||||
} else {
|
||||
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Helper to get event field requirements from settings
|
||||
const getEventFieldRequirements = async () => {
|
||||
@@ -25,14 +57,18 @@ const getEventFieldRequirements = async () => {
|
||||
.whereIn('setting_key', [
|
||||
'event_require_customer_name',
|
||||
'event_require_customer_email',
|
||||
'event_require_admin_email'
|
||||
'event_require_admin_email',
|
||||
'event_require_event_date',
|
||||
'event_require_expiration'
|
||||
])
|
||||
.select('setting_key', 'setting_value');
|
||||
|
||||
const requirements = {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
|
||||
settings.forEach(s => {
|
||||
@@ -47,6 +83,8 @@ const getEventFieldRequirements = async () => {
|
||||
if (s.setting_key === 'event_require_customer_name') requirements.require_customer_name = value;
|
||||
if (s.setting_key === 'event_require_customer_email') requirements.require_customer_email = value;
|
||||
if (s.setting_key === 'event_require_admin_email') requirements.require_admin_email = value;
|
||||
if (s.setting_key === 'event_require_event_date') requirements.require_event_date = value;
|
||||
if (s.setting_key === 'event_require_expiration') requirements.require_expiration = value;
|
||||
});
|
||||
|
||||
return requirements;
|
||||
@@ -55,7 +93,9 @@ const getEventFieldRequirements = async () => {
|
||||
return {
|
||||
require_customer_name: true,
|
||||
require_customer_email: true,
|
||||
require_admin_email: true
|
||||
require_admin_email: true,
|
||||
require_event_date: true,
|
||||
require_expiration: true
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -104,9 +144,15 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty().trim(),
|
||||
body('event_date').isDate(),
|
||||
body('event_date').optional().isDate(),
|
||||
body('customer_name').optional().trim(),
|
||||
body('customer_email').optional().isEmail().normalizeEmail(),
|
||||
body('admin_email').optional().isEmail().normalizeEmail(),
|
||||
@@ -143,7 +189,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
body('disable_right_click').optional().isBoolean(),
|
||||
body('watermark_downloads').optional().isBoolean(),
|
||||
body('watermark_text').optional().trim(),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom'])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
logger.debug('Create event request body', { body: req.body });
|
||||
@@ -182,7 +232,11 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
moderate_comments = true,
|
||||
show_feedback_to_guests = true,
|
||||
// CSS Template
|
||||
css_template_id = null
|
||||
css_template_id = null,
|
||||
// Hero logo settings
|
||||
hero_logo_visible = true,
|
||||
hero_logo_size = 'medium',
|
||||
hero_logo_position = 'top'
|
||||
} = req.body;
|
||||
|
||||
const customerName = getCustomerNameFromPayload(req.body);
|
||||
@@ -201,6 +255,9 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
if (fieldRequirements.require_admin_email && !admin_email) {
|
||||
validationErrors.push({ path: 'admin_email', msg: 'Admin email is required' });
|
||||
}
|
||||
if (fieldRequirements.require_event_date && !event_date) {
|
||||
validationErrors.push({ path: 'event_date', msg: 'Event date is required' });
|
||||
}
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
return res.status(400).json({ errors: validationErrors });
|
||||
@@ -239,16 +296,23 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
}
|
||||
}
|
||||
|
||||
// Get event type info for slug generation
|
||||
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(event_type);
|
||||
const slugPrefix = eventTypeInfo.slug_prefix || event_type;
|
||||
|
||||
// Generate unique slug
|
||||
const processedEventName = event_name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-') // Replace non-alphanumeric with dash
|
||||
.replace(/-+/g, '-') // Replace multiple dashes with single dash
|
||||
.replace(/^-|-$/g, ''); // Remove leading/trailing dashes
|
||||
const baseSlug = `${event_type}-${processedEventName}-${event_date}`;
|
||||
|
||||
// Use event_date in slug if provided, otherwise use random suffix
|
||||
const slugSuffix = event_date || crypto.randomBytes(3).toString('hex');
|
||||
const baseSlug = `${event_type}-${processedEventName}-${slugSuffix}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
while (await db('events').where({ slug }).first()) {
|
||||
slug = `${baseSlug}-${counter}`;
|
||||
counter++;
|
||||
@@ -264,15 +328,20 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
: await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
|
||||
// Calculate expiration date (days after event date)
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
let expires_at;
|
||||
if (event_date.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = event_date.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(event_date);
|
||||
// If expiration is not required, expires_at will be null (never expires)
|
||||
// If event_date is not provided, use current date as base for expiration
|
||||
let expires_at = null;
|
||||
if (fieldRequirements.require_expiration) {
|
||||
const baseDate = event_date || new Date().toISOString().split('T')[0];
|
||||
// Parse YYYY-MM-DD format as local date to avoid timezone issues
|
||||
if (baseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
|
||||
const [year, month, day] = baseDate.split('-').map(num => parseInt(num, 10));
|
||||
expires_at = new Date(year, month - 1, day);
|
||||
} else {
|
||||
expires_at = new Date(baseDate);
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
}
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -285,7 +354,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
slug,
|
||||
event_type,
|
||||
event_name,
|
||||
event_date,
|
||||
event_date: event_date || null,
|
||||
...(customerColumnsAvailable ? { customer_name: customerName, customer_email: customerEmail } : {}),
|
||||
host_name: customerName,
|
||||
host_email: customerEmail,
|
||||
@@ -295,7 +364,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
color_theme,
|
||||
share_link: shareLinkToStore,
|
||||
share_token: shareToken,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString(),
|
||||
created_by: req.admin.id,
|
||||
allow_user_uploads,
|
||||
@@ -305,7 +374,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||
watermark_text,
|
||||
require_password: formatBoolean(requirePassword),
|
||||
css_template_id: css_template_id || null
|
||||
css_template_id: css_template_id || null,
|
||||
hero_logo_visible: formatBoolean(hero_logo_visible !== undefined ? hero_logo_visible : true),
|
||||
hero_logo_size: hero_logo_size || 'medium',
|
||||
hero_logo_position: hero_logo_position || 'top'
|
||||
}).returning('id');
|
||||
|
||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||
@@ -350,7 +422,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
event_date: event_date, // Pass raw date - will be formatted by email processor
|
||||
gallery_link: shareUrl,
|
||||
gallery_password: requirePassword ? password : 'No password required',
|
||||
expiry_date: expires_at.toISOString(), // Pass ISO string - will be formatted by email processor
|
||||
expiry_date: expires_at ? expires_at.toISOString() : null, // Pass ISO string - will be formatted by email processor
|
||||
welcome_message: welcome_message || ''
|
||||
}),
|
||||
status: 'pending',
|
||||
@@ -367,7 +439,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
|
||||
customer_email: customerEmail,
|
||||
require_password: requirePassword,
|
||||
share_link: shareUrl,
|
||||
expires_at: expires_at.toISOString(),
|
||||
expires_at: expires_at ? expires_at.toISOString() : null,
|
||||
created_at: new Date().toISOString()
|
||||
});
|
||||
} catch (error) {
|
||||
@@ -582,7 +654,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt()
|
||||
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
|
||||
// Hero logo settings
|
||||
body('hero_logo_visible').optional().isBoolean(),
|
||||
body('hero_logo_size').optional().isIn(['small', 'medium', 'large', 'xlarge']),
|
||||
body('hero_logo_position').optional().isIn(['top', 'center', 'bottom'])
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -694,6 +770,11 @@ router.put('/:id', adminAuth, requirePermission('events.edit'), [
|
||||
updates.password_hash = await bcrypt.hash(crypto.randomBytes(32).toString('hex'), getBcryptRounds());
|
||||
}
|
||||
|
||||
// Format hero logo settings if provided
|
||||
if (Object.prototype.hasOwnProperty.call(updates, 'hero_logo_visible')) {
|
||||
updates.hero_logo_visible = formatBoolean(updates.hero_logo_visible);
|
||||
}
|
||||
|
||||
// Update event
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
@@ -759,7 +840,7 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
if (event.archive_path) {
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
const archivePath = path.join(storagePath, event.archive_path);
|
||||
|
||||
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(archivePath);
|
||||
@@ -768,6 +849,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
||||
// Don't fail the transaction if file deletion fails
|
||||
}
|
||||
}
|
||||
|
||||
// Delete custom event logo if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
const fsPromises = require('fs').promises;
|
||||
await fsPromises.unlink(event.hero_logo_path);
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Log activity (outside transaction)
|
||||
@@ -927,7 +1018,8 @@ router.post('/:id/resend-email', adminAuth, requirePermission('events.edit'), as
|
||||
|
||||
// For resending creation email, we need the actual password
|
||||
// First, try to get it from the request body if provided
|
||||
let galleryPassword = req.body.password;
|
||||
// Use optional chaining to handle cases where req.body might be undefined
|
||||
let galleryPassword = req.body?.password;
|
||||
|
||||
// If no password provided, we can't decrypt the existing one
|
||||
// So we'll show a security message
|
||||
@@ -1094,4 +1186,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
||||
}
|
||||
});
|
||||
|
||||
// Upload event custom logo
|
||||
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
// Check if event exists
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No logo file provided' });
|
||||
}
|
||||
|
||||
// Delete old logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
|
||||
const logoPath = req.file.path;
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: logoUrl,
|
||||
hero_logo_path: logoPath
|
||||
});
|
||||
|
||||
await logActivity('event_logo_uploaded',
|
||||
{ eventName: event.event_name, filename: req.file.filename },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({
|
||||
message: 'Event logo uploaded successfully',
|
||||
hero_logo_url: logoUrl
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
|
||||
res.status(500).json({ error: 'Failed to upload event logo' });
|
||||
}
|
||||
});
|
||||
|
||||
// Delete event custom logo
|
||||
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||
try {
|
||||
const { id } = req.params;
|
||||
|
||||
let eventQuery = db('events').where('id', id);
|
||||
if (req.admin.roleName === 'editor') {
|
||||
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||
}
|
||||
const event = await eventQuery.first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
|
||||
// Delete logo file if exists
|
||||
if (event.hero_logo_path) {
|
||||
try {
|
||||
await fs.unlink(event.hero_logo_path);
|
||||
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
|
||||
} catch (err) {
|
||||
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
await db('events')
|
||||
.where('id', id)
|
||||
.update({
|
||||
hero_logo_url: null,
|
||||
hero_logo_path: null
|
||||
});
|
||||
|
||||
await logActivity('event_logo_removed',
|
||||
{ eventName: event.event_name },
|
||||
id,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json({ message: 'Event logo removed successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
|
||||
res.status(500).json({ error: 'Failed to delete event logo' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -71,12 +71,12 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
// Build filtered query
|
||||
const filterBuilder = new PhotoFilterBuilder(
|
||||
db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -84,8 +84,8 @@ router.get('/:eventId/filtered', adminAuth, requirePermission('photos.view'), [
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
),
|
||||
eventId
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ const { validateUploadedFiles } = require('../middleware/uploadValidation');
|
||||
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
|
||||
const { processUploadedPhotos } = require('../services/photoProcessor');
|
||||
const chunkedUpload = require('../services/chunkedUploadService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get storage path from environment or default
|
||||
@@ -175,8 +176,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
return res.status(400).json({ error: 'No files uploaded' });
|
||||
}
|
||||
|
||||
// Parse category_id to number if provided
|
||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
||||
// Parse category_id to number if provided (handle string values like 'individual', 'collage')
|
||||
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
|
||||
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
|
||||
|
||||
// Determine photo type and category name
|
||||
let photoType = 'individual'; // default
|
||||
@@ -256,6 +258,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
const photoData = {
|
||||
event_id: parseInt(eventId),
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname, // Preserve original filename for Lightroom export
|
||||
path: relativePath,
|
||||
thumbnail_path: null, // Will generate after successful commit
|
||||
type: photoType,
|
||||
@@ -308,7 +311,7 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
let thumbnailPath = null;
|
||||
try {
|
||||
thumbnailPath = await generateThumbnail(operation.finalPath);
|
||||
|
||||
|
||||
// Update the database with thumbnail path
|
||||
if (thumbnailPath && insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
@@ -319,6 +322,14 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
||||
} catch (thumbError) {
|
||||
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
|
||||
}
|
||||
|
||||
// Queue watermark generation in background (non-blocking)
|
||||
// This pre-generates watermarked versions for fast serving in lightbox
|
||||
if (insertedIds[idx]) {
|
||||
const photoId = insertedIds[idx]?.id || insertedIds[idx];
|
||||
watermarkGeneratorService.generateForPhoto(photoId)
|
||||
.catch(err => console.warn(`Watermark generation queued failed for photo ${photoId}:`, err.message));
|
||||
}
|
||||
|
||||
// Add to successful uploads
|
||||
uploadedPhotos.push({
|
||||
@@ -461,7 +472,12 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete pre-generated watermark if exists
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
|
||||
// Remove from database
|
||||
await db('photos').where({ id: photoId }).delete();
|
||||
|
||||
@@ -582,8 +598,13 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Delete pre-generated watermark
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Delete from database
|
||||
await db('photos')
|
||||
.whereIn('id', photoIds)
|
||||
|
||||
@@ -23,6 +23,8 @@ const { clearShareLinkSettingsCache } = require('../services/shareLinkService');
|
||||
const { resetSecurityConfigCache } = require('../utils/authSecurity');
|
||||
const router = express.Router();
|
||||
const { clearMaxFilesPerUploadCache, MAX_ALLOWED_FILES_PER_UPLOAD } = require('../services/uploadSettings');
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
@@ -100,13 +102,20 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||||
// If the value is already parsed (from json column), use it directly
|
||||
if (typeof setting.setting_value !== 'string') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
} else {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
@@ -127,17 +136,24 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
const settings = await db('app_settings')
|
||||
.where('setting_type', type)
|
||||
.select('*');
|
||||
|
||||
|
||||
// Convert to object format
|
||||
const settingsObject = {};
|
||||
settings.forEach(setting => {
|
||||
if (setting.setting_value) {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
// Check for null/undefined explicitly to handle boolean false and 0 values
|
||||
// PostgreSQL json column returns parsed values (false as boolean, not string)
|
||||
if (setting.setting_value !== null && setting.setting_value !== undefined) {
|
||||
// If the value is already parsed (from json column), use it directly
|
||||
if (typeof setting.setting_value !== 'string') {
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
} else {
|
||||
try {
|
||||
// Try to parse as JSON first
|
||||
settingsObject[setting.setting_key] = JSON.parse(setting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
settingsObject[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
settingsObject[setting.setting_key] = null;
|
||||
@@ -196,6 +212,9 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
hide_powered_by
|
||||
} = req.body;
|
||||
|
||||
// Get current watermark settings hash for change detection
|
||||
const oldSettingsHash = await watermarkService.getSettingsHash();
|
||||
|
||||
const brandingSettings = {
|
||||
company_name,
|
||||
company_tagline,
|
||||
@@ -306,7 +325,40 @@ router.put('/branding', adminAuth, requirePermission('settings.edit'), async (re
|
||||
|
||||
clearPublicSiteCache();
|
||||
|
||||
res.json({ message: 'Branding settings updated successfully' });
|
||||
// Check if watermark settings changed and trigger regeneration
|
||||
const newSettingsHash = await watermarkService.getSettingsHash();
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (oldSettingsHash !== newSettingsHash) {
|
||||
// Clear watermark cache
|
||||
watermarkService.clearCache();
|
||||
|
||||
// Check if watermarking is now enabled or settings changed
|
||||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
// Start background regeneration of all watermarks
|
||||
console.log('Watermark settings changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
} else {
|
||||
// Watermarking was disabled, clear all pre-generated watermarks
|
||||
console.log('Watermarking disabled, clearing pre-generated watermarks');
|
||||
watermarkGeneratorService.clearAllWatermarks()
|
||||
.catch(err => console.error('Failed to clear watermarks:', err));
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Branding settings updated successfully',
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Branding update error:', error);
|
||||
res.status(500).json({ error: 'Failed to update branding settings' });
|
||||
@@ -392,11 +444,21 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
.first();
|
||||
|
||||
if (oldWatermarkLogoSetting && oldWatermarkLogoSetting.setting_value) {
|
||||
const oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
|
||||
let oldPath;
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old watermark logo:', error);
|
||||
// Try to parse as JSON first (for JSON-stringified paths)
|
||||
oldPath = JSON.parse(oldWatermarkLogoSetting.setting_value);
|
||||
} catch (e) {
|
||||
// If it's not valid JSON, use the raw value
|
||||
oldPath = oldWatermarkLogoSetting.setting_value;
|
||||
}
|
||||
|
||||
if (oldPath && typeof oldPath === 'string') {
|
||||
try {
|
||||
await fs.unlink(oldPath);
|
||||
} catch (error) {
|
||||
console.error('Failed to delete old watermark logo:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -421,19 +483,37 @@ router.post('/branding/watermark-logo', adminAuth, requirePermission('settings.e
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: 'branding_watermark_logo_url',
|
||||
setting_value: publicPath,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
setting_type: 'branding',
|
||||
updated_at: new Date()
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({
|
||||
setting_value: publicPath,
|
||||
setting_value: JSON.stringify(publicPath),
|
||||
updated_at: new Date()
|
||||
});
|
||||
|
||||
res.json({
|
||||
// Trigger watermark regeneration since the logo changed
|
||||
watermarkService.clearCache();
|
||||
const currentSettings = await watermarkService.getWatermarkSettings();
|
||||
let watermarkRegenerationStarted = false;
|
||||
|
||||
if (currentSettings && currentSettings.enabled) {
|
||||
console.log('Watermark logo changed, starting background regeneration');
|
||||
watermarkGeneratorService.regenerateAll()
|
||||
.then(result => {
|
||||
console.log(`Watermark regeneration completed: ${result.success}/${result.total} successful`);
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Watermark regeneration failed:', err);
|
||||
});
|
||||
watermarkRegenerationStarted = true;
|
||||
}
|
||||
|
||||
res.json({
|
||||
message: 'Watermark logo uploaded successfully',
|
||||
watermarkLogoUrl: publicPath
|
||||
watermarkLogoUrl: publicPath,
|
||||
watermarkRegenerationStarted
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Watermark logo upload error:', error);
|
||||
|
||||
@@ -7,6 +7,7 @@ const path = require('path');
|
||||
const os = require('os');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const logger = require('../utils/logger');
|
||||
const { checkForUpdates, getCurrentChannel } = require('../services/updateCheckService');
|
||||
const router = express.Router();
|
||||
|
||||
// Get system version
|
||||
@@ -22,12 +23,15 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
|
||||
} catch (err) {
|
||||
console.error('Could not read package.json:', err);
|
||||
}
|
||||
|
||||
|
||||
const channel = getCurrentChannel(backendVersion);
|
||||
|
||||
res.json({
|
||||
backend: backendVersion,
|
||||
frontend: '1.0.0', // This will be set by frontend
|
||||
node: process.version,
|
||||
environment: process.env.NODE_ENV || 'production'
|
||||
environment: process.env.NODE_ENV || 'production',
|
||||
channel: channel
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching version:', error);
|
||||
@@ -35,6 +39,32 @@ router.get('/version', adminAuth, requirePermission('settings.view'), async (req
|
||||
}
|
||||
});
|
||||
|
||||
// Check for updates
|
||||
router.get('/updates', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
// Check if update checking is enabled
|
||||
const updateCheckEnabled = process.env.UPDATE_CHECK_ENABLED !== 'false';
|
||||
|
||||
if (!updateCheckEnabled) {
|
||||
return res.json({
|
||||
enabled: false,
|
||||
message: 'Update checking is disabled'
|
||||
});
|
||||
}
|
||||
|
||||
const forceRefresh = req.query.refresh === 'true';
|
||||
const updateInfo = await checkForUpdates(forceRefresh);
|
||||
|
||||
res.json({
|
||||
enabled: true,
|
||||
...updateInfo
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error checking for updates:', error);
|
||||
res.status(500).json({ error: 'Failed to check for updates' });
|
||||
}
|
||||
});
|
||||
|
||||
// Get comprehensive system status
|
||||
router.get('/status', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -11,6 +11,7 @@ const path = require('path');
|
||||
const router = express.Router();
|
||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('../services/eventTypeService');
|
||||
|
||||
// Use parseStringInput from shared parsers for customer data extraction
|
||||
const getCustomerNameFromPayload = (payload = {}) => parseStringInput(payload.customer_name);
|
||||
@@ -55,7 +56,13 @@ const hasCustomerContactColumns = async () => {
|
||||
|
||||
// Create new event
|
||||
router.post('/', adminAuth, [
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other']),
|
||||
body('event_type').notEmpty().trim().custom(async (value) => {
|
||||
const isValid = await eventTypeService.isValidEventType(value);
|
||||
if (!isValid) {
|
||||
throw new Error('Invalid event type');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_name').notEmpty(),
|
||||
body('event_date').isDate(),
|
||||
body('customer_name').notEmpty().trim(),
|
||||
|
||||
+123
-30
@@ -5,6 +5,7 @@ const archiver = require('archiver');
|
||||
const path = require('path');
|
||||
const router = express.Router();
|
||||
const watermarkService = require('../services/watermarkService');
|
||||
const watermarkGeneratorService = require('../services/watermarkGeneratorService');
|
||||
const { verifyGalleryAccess } = require('../middleware/gallery');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
@@ -12,9 +13,10 @@ const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
const { ensureThumbnail } = require('../services/imageProcessor');
|
||||
|
||||
// Get storage path from environment or default
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Check for slug redirect (for renamed events)
|
||||
async function checkSlugRedirect(slug) {
|
||||
@@ -106,13 +108,18 @@ router.get('/:slug/info', async (req, res) => {
|
||||
'share_link',
|
||||
'share_token',
|
||||
'allow_downloads',
|
||||
'allow_user_uploads',
|
||||
'disable_right_click',
|
||||
'watermark_downloads',
|
||||
'watermark_text',
|
||||
'require_password',
|
||||
'color_theme',
|
||||
'enable_devtools_protection',
|
||||
'use_canvas_rendering'
|
||||
'use_canvas_rendering',
|
||||
'hero_logo_visible',
|
||||
'hero_logo_size',
|
||||
'hero_logo_position',
|
||||
'hero_logo_url'
|
||||
)
|
||||
.first();
|
||||
|
||||
@@ -150,15 +157,20 @@ router.get('/:slug/info', async (req, res) => {
|
||||
event_date: event.event_date,
|
||||
expires_at: event.expires_at,
|
||||
is_active: event.is_active,
|
||||
is_expired: !event.is_active || new Date(event.expires_at) < new Date(),
|
||||
is_expired: !event.is_active || (event.expires_at && new Date(event.expires_at) < new Date()),
|
||||
requires_password: requiresPassword,
|
||||
color_theme: event.color_theme,
|
||||
allow_downloads: !(event.allow_downloads === false || event.allow_downloads === 0 || event.allow_downloads === '0'),
|
||||
allow_user_uploads: event.allow_user_uploads === true || event.allow_user_uploads === 1 || event.allow_user_uploads === '1',
|
||||
disable_right_click: event.disable_right_click === true || event.disable_right_click === 1 || event.disable_right_click === '1',
|
||||
watermark_downloads: event.watermark_downloads === true || event.watermark_downloads === 1 || event.watermark_downloads === '1',
|
||||
watermark_text: event.watermark_text,
|
||||
enable_devtools_protection: event.enable_devtools_protection === true || event.enable_devtools_protection === 1 || event.enable_devtools_protection === '1',
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1'
|
||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
hero_logo_url: event.hero_logo_url || null
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching gallery info:', error);
|
||||
@@ -171,7 +183,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
// Get filter parameters from query
|
||||
const { filter, guest_id } = req.query;
|
||||
|
||||
|
||||
// Get watermark settings to generate cache-busting version for URLs
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
const wmVersion = watermarkSettings?.enabled
|
||||
? `wm=${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '';
|
||||
|
||||
// First get all photos
|
||||
let photos = await db('photos')
|
||||
.where('photos.event_id', req.event.id)
|
||||
@@ -316,25 +334,32 @@ 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,
|
||||
...protectionSettings
|
||||
},
|
||||
categories: categories,
|
||||
photos: photos.map(photo => {
|
||||
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
|
||||
// Add watermark version to URLs for cache busting when settings change
|
||||
const wmQuery = wmVersion ? `?${wmVersion}` : '';
|
||||
const photoUrl = useJwtUrl ?
|
||||
`/api/gallery/${req.params.slug}/photo/${photo.id}${wmQuery}` :
|
||||
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
|
||||
|
||||
|
||||
return {
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
url: photoUrl,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}` : null,
|
||||
thumbnail_url: photo.thumbnail_path ? `/api/gallery/${req.params.slug}/thumbnail/${photo.id}${wmQuery}` : null,
|
||||
secure_url_template: `/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`,
|
||||
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
|
||||
type: photo.type,
|
||||
@@ -343,6 +368,9 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
||||
category_slug: photo.type,
|
||||
size: photo.size_bytes,
|
||||
uploaded_at: photo.uploaded_at,
|
||||
// Image dimensions for layout calculations
|
||||
width: photo.width || null,
|
||||
height: photo.height || null,
|
||||
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
|
||||
requires_token: !useJwtUrl,
|
||||
// Feedback data
|
||||
@@ -753,13 +781,54 @@ router.get('/:slug/photo/:photoId',
|
||||
// Get watermark settings
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// Generate ETag based on photo id, modification time, and watermark settings
|
||||
// This ensures cache invalidation when watermark settings change
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(filePath);
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark and send
|
||||
// Try to serve pre-generated watermarked file for instant loading
|
||||
if (photo.watermark_path) {
|
||||
const watermarkFilePath = path.join(getStoragePath(), photo.watermark_path);
|
||||
try {
|
||||
const fs = require('fs');
|
||||
// Check if pre-generated watermark file exists
|
||||
if (fs.existsSync(watermarkFilePath)) {
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
return res.sendFile(watermarkFilePath);
|
||||
}
|
||||
} catch (err) {
|
||||
// File doesn't exist or error, fall through to on-the-fly generation
|
||||
logger.warn(`Pre-generated watermark not found for photo ${photoId}, falling back to on-the-fly`);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: Apply watermark on-the-fly (slower, but ensures image is served)
|
||||
// Also queue regeneration for next time
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||
|
||||
// Queue watermark generation in background for next request
|
||||
watermarkGeneratorService.generateForPhoto(photo.id)
|
||||
.catch(err => logger.warn(`Background watermark generation failed for photo ${photo.id}:`, err.message));
|
||||
|
||||
res.set({
|
||||
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Cache for 30 minutes
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
|
||||
@@ -768,6 +837,7 @@ router.get('/:slug/photo/:photoId',
|
||||
// Send original file with basic protection headers
|
||||
res.set({
|
||||
'Cache-Control': 'private, max-age=1800',
|
||||
'ETag': etag,
|
||||
'X-Protection-Level': 'basic'
|
||||
});
|
||||
// Ensure absolute path for res.sendFile
|
||||
@@ -787,30 +857,30 @@ router.get('/:slug/photo/:photoId',
|
||||
);
|
||||
|
||||
// Serve thumbnail
|
||||
router.get('/:slug/thumbnail/:photoId',
|
||||
verifyGalleryAccess,
|
||||
router.get('/:slug/thumbnail/:photoId',
|
||||
verifyGalleryAccess,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { photoId } = req.params;
|
||||
|
||||
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId, event_id: req.event.id })
|
||||
.first();
|
||||
|
||||
if (!photo || !photo.thumbnail_path) {
|
||||
return res.status(404).json({ error: 'Thumbnail not found' });
|
||||
|
||||
if (!photo) {
|
||||
return res.status(404).json({ error: 'Photo not found' });
|
||||
}
|
||||
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
|
||||
// Check if file exists
|
||||
const fs = require('fs').promises;
|
||||
try {
|
||||
await fs.access(thumbPath);
|
||||
} catch (error) {
|
||||
return res.status(404).json({ error: 'Thumbnail file not found' });
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
|
||||
if (!thumbnailPath) {
|
||||
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
return res.status(404).json({ error: 'Thumbnail generation failed' });
|
||||
}
|
||||
|
||||
const thumbPath = path.join(getStoragePath(), thumbnailPath);
|
||||
|
||||
// Log thumbnail access
|
||||
await secureImageService.logImageAccess(
|
||||
photoId,
|
||||
@@ -818,18 +888,41 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
req.clientInfo,
|
||||
'thumbnail'
|
||||
);
|
||||
|
||||
|
||||
// Check if watermarks are enabled and apply to thumbnail
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
|
||||
// Generate ETag based on photo id, thumbnail modification time, and watermark settings
|
||||
const fs = require('fs');
|
||||
const stat = fs.statSync(thumbPath);
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"thumb-${photoId}-${stat.mtime.getTime()}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
return res.status(304).end();
|
||||
}
|
||||
|
||||
// Set appropriate headers with enhanced security
|
||||
res.set({
|
||||
'Content-Type': 'image/jpeg',
|
||||
'Cache-Control': 'private, max-age=1800', // Reduced cache time
|
||||
'Cross-Origin-Resource-Policy': 'cross-origin',
|
||||
'X-Content-Type-Options': 'nosniff',
|
||||
'X-Protected-Thumbnail': 'true'
|
||||
'X-Protected-Thumbnail': 'true',
|
||||
'ETag': etag
|
||||
});
|
||||
|
||||
// Send file
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
|
||||
if (watermarkSettings && watermarkSettings.enabled) {
|
||||
// Apply watermark to thumbnail
|
||||
const watermarkedBuffer = await watermarkService.applyWatermark(thumbPath, watermarkSettings);
|
||||
res.send(watermarkedBuffer);
|
||||
} else {
|
||||
// Send file without watermark
|
||||
res.sendFile(path.resolve(thumbPath));
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Error serving thumbnail:', {
|
||||
error: error.message,
|
||||
|
||||
@@ -90,38 +90,50 @@ router.get('/:slug/photo/:photoId/view', verifyGalleryAccess, async (req, res) =
|
||||
}, 'view');
|
||||
|
||||
// Get protection settings from event
|
||||
const eventProtectionLevel = req.event.protection_level || protectionLevel;
|
||||
const protectionSettings = {
|
||||
protectionLevel: req.event.protection_level || protectionLevel,
|
||||
protectionLevel: eventProtectionLevel,
|
||||
quality: req.event.image_quality || 85,
|
||||
addFingerprint: req.event.add_fingerprint !== false,
|
||||
fragmentImage: protectionLevel === 'maximum'
|
||||
fragmentImage: eventProtectionLevel === 'maximum'
|
||||
};
|
||||
|
||||
|
||||
// Build full path to photo
|
||||
const photoPath = path.join(getStoragePath(), 'events/active', req.event.slug, photo.path);
|
||||
|
||||
// Process image with protection
|
||||
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
|
||||
|
||||
// Apply watermark if enabled
|
||||
|
||||
// For basic/standard protection without special features, serve original file
|
||||
// This avoids unnecessary recompression
|
||||
const needsProcessing = eventProtectionLevel === 'enhanced' ||
|
||||
eventProtectionLevel === 'maximum' ||
|
||||
protectionSettings.addFingerprint;
|
||||
|
||||
let finalImage;
|
||||
if (processedImage.type === 'fragmented') {
|
||||
// Return fragmented image data for canvas reconstruction
|
||||
return res.json({
|
||||
type: 'fragmented',
|
||||
fragments: processedImage.fragments.map(f => ({
|
||||
index: f.index,
|
||||
row: f.row,
|
||||
col: f.col,
|
||||
data: f.buffer.toString('base64'),
|
||||
position: f.position
|
||||
})),
|
||||
dimensions: processedImage.originalDimensions,
|
||||
fragmentDimensions: processedImage.fragmentDimensions
|
||||
});
|
||||
|
||||
if (!needsProcessing) {
|
||||
// Serve original file without processing
|
||||
const fs = require('fs').promises;
|
||||
finalImage = await fs.readFile(photoPath);
|
||||
} else {
|
||||
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||
finalImage = await watermarkService.applyWatermark(photoPath, watermarkSettings);
|
||||
// Process image with protection measures
|
||||
const processedImage = await secureImageService.processProtectedImage(photoPath, protectionSettings);
|
||||
|
||||
if (processedImage.type === 'fragmented') {
|
||||
// Return fragmented image data for canvas reconstruction
|
||||
return res.json({
|
||||
type: 'fragmented',
|
||||
fragments: processedImage.fragments.map(f => ({
|
||||
index: f.index,
|
||||
row: f.row,
|
||||
col: f.col,
|
||||
data: f.buffer.toString('base64'),
|
||||
position: f.position
|
||||
})),
|
||||
dimensions: processedImage.originalDimensions,
|
||||
fragmentDimensions: processedImage.fragmentDimensions
|
||||
});
|
||||
}
|
||||
|
||||
finalImage = processedImage;
|
||||
}
|
||||
|
||||
// Set security headers
|
||||
|
||||
@@ -74,7 +74,9 @@ router.get('/', async (req, res) => {
|
||||
// Event field requirements
|
||||
event_require_customer_name: settingsObject.event_require_customer_name !== false,
|
||||
event_require_customer_email: settingsObject.event_require_customer_email !== false,
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false
|
||||
event_require_admin_email: settingsObject.event_require_admin_email !== false,
|
||||
event_require_event_date: settingsObject.event_require_event_date !== false,
|
||||
event_require_expiration: settingsObject.event_require_expiration !== false
|
||||
};
|
||||
|
||||
res.json(publicSettings);
|
||||
|
||||
@@ -993,12 +993,16 @@ async function getBackupStatus(limit = 10) {
|
||||
}
|
||||
}
|
||||
|
||||
const lastRunWithManifest = lastRun ? { ...lastRun, manifestValid } : null;
|
||||
|
||||
return {
|
||||
isRunning,
|
||||
isHealthy: Boolean(lastRun && lastRun.status === 'completed'),
|
||||
lastRun: lastRun ? { ...lastRun, manifestValid } : null,
|
||||
lastRun: lastRunWithManifest,
|
||||
lastBackup: lastRunWithManifest, // Alias for frontend compatibility
|
||||
recentRuns: runs,
|
||||
recentBackups: runs, // Alias for frontend compatibility
|
||||
totalBackups: runs.filter(r => r.status === 'completed').length,
|
||||
nextScheduledRun: getNextScheduledRun()
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
@@ -14,8 +14,9 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { validatePasswordInContext, getBcryptRounds } = require('../utils/passwordValidation');
|
||||
const { buildShareLinkVariants } = require('./shareLinkService');
|
||||
const { parseBooleanInput, parseStringInput } = require('../utils/parsers');
|
||||
const eventTypeService = require('./eventTypeService');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Cache for schema detection
|
||||
let customerColumnCache = null;
|
||||
@@ -67,13 +68,23 @@ const mapEventForApi = (event) => {
|
||||
|
||||
/**
|
||||
* Generate a unique slug for an event
|
||||
* @param {string} eventType
|
||||
* @param {string} eventType - Event type identifier (slug_prefix or legacy type)
|
||||
* @param {string} eventName
|
||||
* @param {string} eventDate
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const generateUniqueSlug = async (eventType, eventName, eventDate) => {
|
||||
const baseSlug = `${eventType}-${eventName.toLowerCase().replace(/[^a-z0-9]/g, '-')}-${eventDate}`;
|
||||
// Get the slug_prefix from event type (supports both new dynamic types and legacy)
|
||||
const eventTypeInfo = await eventTypeService.getEventTypeForSlug(eventType);
|
||||
const slugPrefix = eventTypeInfo.slug_prefix || eventType;
|
||||
|
||||
const processedName = eventName
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]/g, '-')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
|
||||
const baseSlug = `${slugPrefix}-${processedName}-${eventDate}`;
|
||||
let slug = baseSlug;
|
||||
let counter = 1;
|
||||
|
||||
|
||||
@@ -0,0 +1,314 @@
|
||||
/**
|
||||
* Event Type Service Layer
|
||||
* Handles all event type-related business logic
|
||||
*
|
||||
* @module services/eventTypeService
|
||||
*/
|
||||
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
/**
|
||||
* Get all event types
|
||||
* @param {Object} options - Filter options
|
||||
* @param {boolean} options.activeOnly - Only return active types
|
||||
* @returns {Promise<Array>} - Array of event types
|
||||
*/
|
||||
const getAllEventTypes = async (options = {}) => {
|
||||
const { activeOnly = false } = options;
|
||||
|
||||
let query = db('event_types').select('*');
|
||||
|
||||
if (activeOnly) {
|
||||
query = query.where('is_active', formatBoolean(true));
|
||||
}
|
||||
|
||||
const types = await query.orderBy('display_order', 'asc');
|
||||
return types;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get active event types for dropdown/selection
|
||||
* @returns {Promise<Array>} - Array of active event types
|
||||
*/
|
||||
const getActiveEventTypes = async () => {
|
||||
return getAllEventTypes({ activeOnly: true });
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event type by ID
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventTypeById = async (id) => {
|
||||
const eventType = await db('event_types').where('id', id).first();
|
||||
return eventType || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get an event type by slug prefix
|
||||
* @param {string} slugPrefix - The slug prefix
|
||||
* @returns {Promise<Object|null>}
|
||||
*/
|
||||
const getEventTypeBySlugPrefix = async (slugPrefix) => {
|
||||
const eventType = await db('event_types')
|
||||
.where('slug_prefix', slugPrefix.toLowerCase())
|
||||
.first();
|
||||
return eventType || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a slug prefix is valid (exists in event_types or is a legacy type)
|
||||
* @param {string} slugPrefix - The slug prefix to validate
|
||||
* @returns {Promise<boolean>}
|
||||
*/
|
||||
const isValidEventType = async (slugPrefix) => {
|
||||
const normalized = slugPrefix.toLowerCase();
|
||||
|
||||
// Check in database
|
||||
const eventType = await getEventTypeBySlugPrefix(normalized);
|
||||
if (eventType && eventType.is_active) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Legacy fallback: Accept old hardcoded values for backward compatibility
|
||||
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
|
||||
return legacyTypes.includes(normalized);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all valid slug prefixes (for validation)
|
||||
* @returns {Promise<string[]>}
|
||||
*/
|
||||
const getValidSlugPrefixes = async () => {
|
||||
const types = await db('event_types')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.select('slug_prefix');
|
||||
|
||||
return types.map(t => t.slug_prefix);
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new event type
|
||||
* @param {Object} eventTypeData - Event type data
|
||||
* @returns {Promise<Object>} - Created event type
|
||||
*/
|
||||
const createEventType = async (eventTypeData) => {
|
||||
const {
|
||||
name,
|
||||
slug_prefix,
|
||||
emoji,
|
||||
theme_preset,
|
||||
theme_config,
|
||||
display_order
|
||||
} = eventTypeData;
|
||||
|
||||
// Normalize slug_prefix
|
||||
const normalizedSlugPrefix = slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Check for duplicate slug_prefix
|
||||
const existing = await getEventTypeBySlugPrefix(normalizedSlugPrefix);
|
||||
if (existing) {
|
||||
const error = new Error('An event type with this slug prefix already exists');
|
||||
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Get max display order if not provided
|
||||
let finalDisplayOrder = display_order;
|
||||
if (finalDisplayOrder === undefined || finalDisplayOrder === null) {
|
||||
const maxOrder = await db('event_types').max('display_order as max').first();
|
||||
finalDisplayOrder = (maxOrder?.max || 0) + 1;
|
||||
}
|
||||
|
||||
const insertData = {
|
||||
name,
|
||||
slug_prefix: normalizedSlugPrefix,
|
||||
emoji: emoji || '📷',
|
||||
theme_preset: theme_preset || 'default',
|
||||
theme_config: theme_config ? JSON.stringify(theme_config) : null,
|
||||
display_order: finalDisplayOrder,
|
||||
is_system: false,
|
||||
is_active: true,
|
||||
created_at: new Date(),
|
||||
updated_at: new Date()
|
||||
};
|
||||
|
||||
const insertResult = await db('event_types').insert(insertData).returning('id');
|
||||
const eventTypeId = insertResult[0]?.id || insertResult[0];
|
||||
|
||||
return getEventTypeById(eventTypeId);
|
||||
};
|
||||
|
||||
/**
|
||||
* Update an event type
|
||||
* @param {number} id - Event type ID
|
||||
* @param {Object} updates - Fields to update
|
||||
* @returns {Promise<Object>} - Updated event type
|
||||
*/
|
||||
const updateEventType = async (id, updates) => {
|
||||
const eventType = await getEventTypeById(id);
|
||||
if (!eventType) {
|
||||
const error = new Error('Event type not found');
|
||||
error.code = 'NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Build update object
|
||||
const updateData = {};
|
||||
|
||||
if (updates.name !== undefined) {
|
||||
updateData.name = updates.name;
|
||||
}
|
||||
|
||||
if (updates.slug_prefix !== undefined) {
|
||||
const normalizedSlugPrefix = updates.slug_prefix.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// Check for duplicate (excluding current)
|
||||
const existing = await db('event_types')
|
||||
.where('slug_prefix', normalizedSlugPrefix)
|
||||
.whereNot('id', id)
|
||||
.first();
|
||||
|
||||
if (existing) {
|
||||
const error = new Error('An event type with this slug prefix already exists');
|
||||
error.code = 'DUPLICATE_SLUG_PREFIX';
|
||||
throw error;
|
||||
}
|
||||
|
||||
updateData.slug_prefix = normalizedSlugPrefix;
|
||||
}
|
||||
|
||||
if (updates.emoji !== undefined) {
|
||||
updateData.emoji = updates.emoji;
|
||||
}
|
||||
|
||||
if (updates.theme_preset !== undefined) {
|
||||
updateData.theme_preset = updates.theme_preset;
|
||||
}
|
||||
|
||||
if (updates.theme_config !== undefined) {
|
||||
updateData.theme_config = updates.theme_config ? JSON.stringify(updates.theme_config) : null;
|
||||
}
|
||||
|
||||
if (updates.display_order !== undefined) {
|
||||
updateData.display_order = updates.display_order;
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
updateData.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
updateData.updated_at = new Date();
|
||||
|
||||
await db('event_types').where('id', id).update(updateData);
|
||||
|
||||
return getEventTypeById(id);
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
const deleteEventType = async (id) => {
|
||||
const eventType = await getEventTypeById(id);
|
||||
if (!eventType) {
|
||||
const error = new Error('Event type not found');
|
||||
error.code = 'NOT_FOUND';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deletion of system types
|
||||
if (eventType.is_system) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Check if any events use this type
|
||||
const eventsUsingType = await db('events')
|
||||
.where('event_type', eventType.slug_prefix)
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
if (eventsUsingType && parseInt(eventsUsingType.count) > 0) {
|
||||
const error = new Error(`Cannot delete: ${eventsUsingType.count} events are using this type. Deactivate it instead or reassign those events.`);
|
||||
error.code = 'IN_USE';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db('event_types').where('id', id).del();
|
||||
|
||||
return { success: true, deleted: eventType };
|
||||
};
|
||||
|
||||
/**
|
||||
* Reorder event types
|
||||
* @param {Array} orderedIds - Array of IDs in new order
|
||||
* @returns {Promise<Array>}
|
||||
*/
|
||||
const reorderEventTypes = async (orderedIds) => {
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < orderedIds.length; i++) {
|
||||
await trx('event_types')
|
||||
.where('id', orderedIds[i])
|
||||
.update({ display_order: i + 1, updated_at: new Date() });
|
||||
}
|
||||
});
|
||||
|
||||
return getAllEventTypes();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get event type info for slug generation
|
||||
* Returns the slug_prefix to use for a given event type identifier
|
||||
* @param {string} eventTypeIdentifier - Either an ID or slug_prefix
|
||||
* @returns {Promise<Object>} - Event type with slug_prefix and theme_preset
|
||||
*/
|
||||
const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
||||
// Try to find by slug_prefix first
|
||||
let eventType = await getEventTypeBySlugPrefix(eventTypeIdentifier);
|
||||
|
||||
if (eventType) {
|
||||
return eventType;
|
||||
}
|
||||
|
||||
// Try by ID if numeric
|
||||
if (!isNaN(eventTypeIdentifier)) {
|
||||
eventType = await getEventTypeById(parseInt(eventTypeIdentifier));
|
||||
if (eventType) {
|
||||
return eventType;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback for legacy types - return a compatible object
|
||||
const legacyDefaults = {
|
||||
wedding: { slug_prefix: 'wedding', theme_preset: 'elegantWedding', emoji: '💒' },
|
||||
birthday: { slug_prefix: 'birthday', theme_preset: 'birthdayFun', emoji: '🎂' },
|
||||
corporate: { slug_prefix: 'corporate', theme_preset: 'corporateTimeline', emoji: '🏢' },
|
||||
other: { slug_prefix: 'other', theme_preset: 'default', emoji: '📸' }
|
||||
};
|
||||
|
||||
const normalized = eventTypeIdentifier.toLowerCase();
|
||||
if (legacyDefaults[normalized]) {
|
||||
return legacyDefaults[normalized];
|
||||
}
|
||||
|
||||
// Default fallback
|
||||
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllEventTypes,
|
||||
getActiveEventTypes,
|
||||
getEventTypeById,
|
||||
getEventTypeBySlugPrefix,
|
||||
isValidEventType,
|
||||
getValidSlugPrefixes,
|
||||
createEventType,
|
||||
updateEventType,
|
||||
deleteEventType,
|
||||
reorderEventTypes,
|
||||
getEventTypeForSlug
|
||||
};
|
||||
@@ -21,9 +21,11 @@ async function checkExpirations() {
|
||||
const warningDate = new Date(now.getTime() + 7 * 24 * 60 * 60 * 1000); // 7 days from now
|
||||
|
||||
// Check for events needing warning emails
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const eventsNeedingWarning = await db('events')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', warningDate)
|
||||
.where('expires_at', '>', now);
|
||||
|
||||
@@ -40,9 +42,11 @@ async function checkExpirations() {
|
||||
}
|
||||
|
||||
// Check for expired events
|
||||
// Skip events with null expires_at (they never expire)
|
||||
const expiredEvents = await db('events')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.where('is_archived', formatBoolean(false))
|
||||
.whereNotNull('expires_at')
|
||||
.where('expires_at', '<=', now);
|
||||
|
||||
for (const event of expiredEvents) {
|
||||
|
||||
@@ -23,13 +23,13 @@ class PhotoExportService {
|
||||
*/
|
||||
async getPhotosWithFeedback(eventId, photoIds = null) {
|
||||
let query = db('photos')
|
||||
.leftJoin('categories', 'photos.category_id', 'categories.id')
|
||||
.leftJoin('photo_categories', 'photos.category_id', 'photo_categories.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.select(
|
||||
'photos.id',
|
||||
'photos.filename',
|
||||
'photos.original_filename',
|
||||
'photos.file_path',
|
||||
'photos.path',
|
||||
'photos.average_rating',
|
||||
'photos.feedback_count',
|
||||
'photos.like_count',
|
||||
@@ -37,9 +37,9 @@ class PhotoExportService {
|
||||
'photos.comment_count',
|
||||
'photos.width',
|
||||
'photos.height',
|
||||
'photos.file_size',
|
||||
'photos.created_at',
|
||||
'categories.name as category_name'
|
||||
'photos.size_bytes',
|
||||
'photos.uploaded_at',
|
||||
'photo_categories.name as category_name'
|
||||
)
|
||||
.orderBy('photos.filename', 'asc');
|
||||
|
||||
@@ -86,7 +86,7 @@ class PhotoExportService {
|
||||
const { filename_format = 'original', separator = 'newline' } = options;
|
||||
|
||||
const filenames = photos.map(photo =>
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename
|
||||
);
|
||||
|
||||
let content;
|
||||
@@ -126,14 +126,14 @@ class PhotoExportService {
|
||||
'category',
|
||||
'width',
|
||||
'height',
|
||||
'file_size',
|
||||
'created_at'
|
||||
'size_bytes',
|
||||
'uploaded_at'
|
||||
];
|
||||
|
||||
const rows = photos.map(photo => [
|
||||
filename_format === 'original' ? photo.original_filename : photo.filename,
|
||||
filename_format === 'original' ? (photo.original_filename || photo.filename) : photo.filename,
|
||||
photo.original_filename || '',
|
||||
photo.average_rating ? photo.average_rating.toFixed(2) : '0.00',
|
||||
photo.average_rating ? parseFloat(photo.average_rating).toFixed(2) : '0.00',
|
||||
photo.feedback_count || 0,
|
||||
photo.like_count || 0,
|
||||
photo.favorite_count || 0,
|
||||
@@ -141,8 +141,8 @@ class PhotoExportService {
|
||||
photo.category_name || '',
|
||||
photo.width || '',
|
||||
photo.height || '',
|
||||
photo.file_size || '',
|
||||
photo.created_at ? new Date(photo.created_at).toISOString() : ''
|
||||
photo.size_bytes || '',
|
||||
photo.uploaded_at ? new Date(photo.uploaded_at).toISOString() : ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
@@ -170,7 +170,7 @@ class PhotoExportService {
|
||||
|
||||
for (const photo of photos) {
|
||||
const baseFilename = filename_format === 'original'
|
||||
? photo.original_filename
|
||||
? (photo.original_filename || photo.filename)
|
||||
: photo.filename;
|
||||
const xmpFilename = this.xmpGenerator.getXmpFilename(baseFilename);
|
||||
const xmpContent = this.xmpGenerator.generateXmp(photo, options);
|
||||
@@ -209,21 +209,21 @@ class PhotoExportService {
|
||||
photos: photos.map(photo => ({
|
||||
id: photo.id,
|
||||
filename: photo.filename,
|
||||
original_filename: photo.original_filename,
|
||||
original_filename: photo.original_filename || null,
|
||||
category: photo.category_name || null,
|
||||
rating: {
|
||||
average: photo.average_rating ? parseFloat(photo.average_rating.toFixed(2)) : 0,
|
||||
average: photo.average_rating ? parseFloat(parseFloat(photo.average_rating).toFixed(2)) : 0,
|
||||
count: photo.feedback_count || 0
|
||||
},
|
||||
likes: photo.like_count || 0,
|
||||
favorites: photo.favorite_count || 0,
|
||||
comments: photo.comment_count || 0,
|
||||
dimensions: {
|
||||
width: photo.width,
|
||||
height: photo.height
|
||||
width: photo.width || null,
|
||||
height: photo.height || null
|
||||
},
|
||||
file_size: photo.file_size,
|
||||
created_at: photo.created_at
|
||||
size_bytes: photo.size_bytes || null,
|
||||
uploaded_at: photo.uploaded_at || null
|
||||
}))
|
||||
};
|
||||
|
||||
|
||||
@@ -158,6 +158,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
// Generate thumbnail and extract metadata
|
||||
let thumbnailPath;
|
||||
let videoMetadata = null;
|
||||
let imageMetadata = null;
|
||||
|
||||
if (isVideo) {
|
||||
// Process video: extract metadata and generate thumbnail
|
||||
@@ -169,8 +170,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
videoMetadata = result.metadata;
|
||||
thumbnailPath = path.relative(getStoragePath(), videoThumbnailPath);
|
||||
} else {
|
||||
// Process image: generate thumbnail
|
||||
// Process image: generate thumbnail and extract dimensions
|
||||
thumbnailPath = await generateThumbnail(newPath);
|
||||
|
||||
// Extract image dimensions using sharp
|
||||
try {
|
||||
const sharp = require('sharp');
|
||||
const metadata = await sharp(newPath).metadata();
|
||||
if (metadata.width && metadata.height) {
|
||||
imageMetadata = {
|
||||
width: metadata.width,
|
||||
height: metadata.height
|
||||
};
|
||||
}
|
||||
} catch (metadataError) {
|
||||
console.warn(`Could not extract image dimensions for ${file.originalname}:`, metadataError.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate relative paths
|
||||
@@ -186,6 +201,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
const photoData = {
|
||||
event_id: eventId,
|
||||
filename: newFilename,
|
||||
original_filename: file.originalname,
|
||||
path: relativePath,
|
||||
thumbnail_path: relativeThumbPath,
|
||||
type: photoType,
|
||||
@@ -205,6 +221,12 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
|
||||
photoData.height = videoMetadata.height;
|
||||
}
|
||||
|
||||
// Add image dimensions if available
|
||||
if (!isVideo && imageMetadata) {
|
||||
photoData.width = imageMetadata.width;
|
||||
photoData.height = imageMetadata.height;
|
||||
}
|
||||
|
||||
if (supportsReturning) {
|
||||
insertResult = await trx('photos')
|
||||
.insert(photoData)
|
||||
|
||||
@@ -10,7 +10,7 @@ const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
/**
|
||||
* Get photos for an event with optional filtering
|
||||
|
||||
@@ -157,6 +157,8 @@ class SecureImageService {
|
||||
|
||||
/**
|
||||
* Process image with protection measures
|
||||
* For basic/standard protection without fingerprinting, returns original file
|
||||
* For enhanced/maximum protection, applies quality reduction and fingerprinting
|
||||
*/
|
||||
async processProtectedImage(imagePath, options = {}) {
|
||||
const {
|
||||
@@ -169,11 +171,58 @@ class SecureImageService {
|
||||
} = options;
|
||||
|
||||
try {
|
||||
// For basic protection level, always return original file without processing
|
||||
if (protectionLevel === 'basic') {
|
||||
return await fs.readFile(imagePath);
|
||||
}
|
||||
|
||||
// For standard protection without fingerprinting, return original file
|
||||
// This avoids unnecessary recompression when no protection features are needed
|
||||
if (protectionLevel === 'standard' && !addFingerprint && !fragmentImage) {
|
||||
return await fs.readFile(imagePath);
|
||||
}
|
||||
|
||||
// Get metadata to check if processing is actually needed
|
||||
const metadata = await sharp(imagePath).metadata();
|
||||
|
||||
// For standard protection with fingerprint only (no resize needed, no quality change),
|
||||
// we can add fingerprint without full recompression by preserving format
|
||||
const needsResize = metadata.width > maxWidth || metadata.height > maxHeight;
|
||||
const needsQualityReduction = protectionLevel === 'enhanced' || protectionLevel === 'maximum';
|
||||
|
||||
// If standard protection and only fingerprinting is needed, and image doesn't need resize,
|
||||
// just add metadata without recompressing
|
||||
if (protectionLevel === 'standard' && addFingerprint && !needsResize) {
|
||||
let image = sharp(imagePath);
|
||||
|
||||
// Add fingerprint to metadata without changing image quality
|
||||
const fingerprint = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
// Preserve original format with high quality
|
||||
const format = metadata.format || 'jpeg';
|
||||
if (format === 'png') {
|
||||
image = image.png({ compressionLevel: 6 });
|
||||
} else if (format === 'webp') {
|
||||
image = image.webp({ quality: 95 });
|
||||
} else {
|
||||
image = image.jpeg({ quality: 100, mozjpeg: true });
|
||||
}
|
||||
|
||||
image = image.withMetadata({
|
||||
exif: {
|
||||
[sharp.EXIF.IFD0.ImageDescription]: `Protected:${fingerprint}`
|
||||
}
|
||||
});
|
||||
|
||||
return await image.toBuffer();
|
||||
}
|
||||
|
||||
// For enhanced/maximum protection or when resize is needed, do full processing
|
||||
let image = sharp(imagePath);
|
||||
const metadata = await image.metadata();
|
||||
let effectiveQuality = quality;
|
||||
|
||||
// Resize if too large
|
||||
if (metadata.width > maxWidth || metadata.height > maxHeight) {
|
||||
if (needsResize) {
|
||||
image = image.resize(maxWidth, maxHeight, {
|
||||
fit: 'inside',
|
||||
withoutEnlargement: true
|
||||
@@ -182,18 +231,26 @@ class SecureImageService {
|
||||
|
||||
// Apply quality reduction for protection
|
||||
if (protectionLevel === 'enhanced') {
|
||||
quality = Math.min(quality, 70);
|
||||
effectiveQuality = Math.min(quality, 70);
|
||||
} else if (protectionLevel === 'maximum') {
|
||||
quality = Math.min(quality, 60);
|
||||
effectiveQuality = Math.min(quality, 60);
|
||||
}
|
||||
|
||||
// Convert to appropriate format
|
||||
image = image.jpeg({ quality, progressive: true });
|
||||
// Preserve original format when possible, apply quality settings
|
||||
const format = metadata.format || 'jpeg';
|
||||
if (format === 'png' && !needsQualityReduction) {
|
||||
image = image.png({ compressionLevel: 6 });
|
||||
} else if (format === 'webp') {
|
||||
image = image.webp({ quality: effectiveQuality });
|
||||
} else {
|
||||
// JPEG or when quality reduction is needed (convert to JPEG)
|
||||
image = image.jpeg({ quality: effectiveQuality, progressive: true });
|
||||
}
|
||||
|
||||
// Add invisible watermark/fingerprint
|
||||
if (addFingerprint) {
|
||||
const fingerprint = crypto.randomBytes(16).toString('hex');
|
||||
|
||||
|
||||
// Embed fingerprint in metadata
|
||||
image = image.withMetadata({
|
||||
exif: {
|
||||
|
||||
@@ -0,0 +1,212 @@
|
||||
const axios = require('axios');
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
// Cache for version info (avoid hitting GitHub API too often)
|
||||
let versionCache = null;
|
||||
let lastCheck = 0;
|
||||
const CACHE_TTL = 60 * 60 * 1000; // 1 hour cache
|
||||
|
||||
/**
|
||||
* Get current installed version from package.json
|
||||
*/
|
||||
async function getCurrentVersion() {
|
||||
try {
|
||||
const packagePath = path.join(__dirname, '../../package.json');
|
||||
const packageContent = await fs.readFile(packagePath, 'utf8');
|
||||
const packageJson = JSON.parse(packageContent);
|
||||
return packageJson.version || '0.0.0';
|
||||
} catch (err) {
|
||||
logger.error('Could not read package.json for version:', err);
|
||||
return '0.0.0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine current release channel from version or environment
|
||||
*/
|
||||
function getCurrentChannel(version) {
|
||||
// Check environment variable first
|
||||
const envChannel = process.env.PICPEAK_RELEASE_CHANNEL;
|
||||
if (envChannel && ['stable', 'beta'].includes(envChannel)) {
|
||||
return envChannel;
|
||||
}
|
||||
|
||||
// Infer from version string
|
||||
if (version && version.includes('-beta')) {
|
||||
return 'beta';
|
||||
}
|
||||
return 'stable';
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse version string into comparable parts
|
||||
*/
|
||||
function parseVersion(version) {
|
||||
if (!version) return null;
|
||||
|
||||
// Handle versions like "2.3.0" or "2.3.0-beta.1"
|
||||
const match = version.match(/^(\d+)\.(\d+)\.(\d+)(?:-beta\.(\d+))?$/);
|
||||
if (!match) return null;
|
||||
|
||||
return {
|
||||
major: parseInt(match[1], 10),
|
||||
minor: parseInt(match[2], 10),
|
||||
patch: parseInt(match[3], 10),
|
||||
beta: match[4] ? parseInt(match[4], 10) : null,
|
||||
isBeta: !!match[4]
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Compare two versions
|
||||
* Returns: 1 if a > b, -1 if a < b, 0 if equal
|
||||
*/
|
||||
function compareVersions(a, b) {
|
||||
const va = parseVersion(a);
|
||||
const vb = parseVersion(b);
|
||||
|
||||
if (!va || !vb) return 0;
|
||||
|
||||
// Compare major.minor.patch
|
||||
if (va.major !== vb.major) return va.major > vb.major ? 1 : -1;
|
||||
if (va.minor !== vb.minor) return va.minor > vb.minor ? 1 : -1;
|
||||
if (va.patch !== vb.patch) return va.patch > vb.patch ? 1 : -1;
|
||||
|
||||
// Handle beta vs stable
|
||||
if (va.isBeta && !vb.isBeta) return -1; // beta < stable
|
||||
if (!va.isBeta && vb.isBeta) return 1; // stable > beta
|
||||
|
||||
// Both are beta - compare beta numbers
|
||||
if (va.isBeta && vb.isBeta) {
|
||||
if (va.beta !== vb.beta) return va.beta > vb.beta ? 1 : -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch available versions from GitHub Releases
|
||||
* Uses GitHub Releases API which is publicly accessible without authentication
|
||||
*/
|
||||
async function fetchAvailableVersions() {
|
||||
try {
|
||||
// Use GitHub Releases API (public, no auth required)
|
||||
const response = await axios.get(
|
||||
'https://api.github.com/repos/the-luap/picpeak/releases',
|
||||
{
|
||||
headers: {
|
||||
'Accept': 'application/vnd.github+json',
|
||||
'X-GitHub-Api-Version': '2022-11-28',
|
||||
'User-Agent': 'PicPeak-Update-Checker'
|
||||
},
|
||||
timeout: 10000
|
||||
}
|
||||
);
|
||||
|
||||
// Extract version tags from releases
|
||||
const versions = {
|
||||
stable: [],
|
||||
beta: []
|
||||
};
|
||||
|
||||
for (const release of response.data) {
|
||||
const tag = release.tag_name;
|
||||
if (!tag) continue;
|
||||
|
||||
// Remove 'v' prefix if present
|
||||
const version = tag.startsWith('v') ? tag.substring(1) : tag;
|
||||
|
||||
if (version.match(/^\d+\.\d+\.\d+$/)) {
|
||||
// Stable version
|
||||
versions.stable.push(version);
|
||||
} else if (version.match(/^\d+\.\d+\.\d+-beta\.\d+$/)) {
|
||||
// Beta version
|
||||
versions.beta.push(version);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort versions descending (newest first)
|
||||
versions.stable.sort((a, b) => compareVersions(b, a));
|
||||
versions.beta.sort((a, b) => compareVersions(b, a));
|
||||
|
||||
return versions;
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch available versions from GitHub:', error.message);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for available updates
|
||||
*/
|
||||
async function checkForUpdates(forceRefresh = false) {
|
||||
const now = Date.now();
|
||||
|
||||
// Use cache if available and not expired
|
||||
if (!forceRefresh && versionCache && (now - lastCheck) < CACHE_TTL) {
|
||||
return versionCache;
|
||||
}
|
||||
|
||||
const currentVersion = await getCurrentVersion();
|
||||
const currentChannel = getCurrentChannel(currentVersion);
|
||||
const availableVersions = await fetchAvailableVersions();
|
||||
|
||||
if (!availableVersions) {
|
||||
return {
|
||||
current: currentVersion,
|
||||
channel: currentChannel,
|
||||
updateAvailable: false,
|
||||
error: 'Unable to check for updates'
|
||||
};
|
||||
}
|
||||
|
||||
// Determine latest version for current channel
|
||||
const latestStable = availableVersions.stable[0] || currentVersion;
|
||||
const latestBeta = availableVersions.beta[0] || currentVersion;
|
||||
const latestForChannel = currentChannel === 'beta' ? latestBeta : latestStable;
|
||||
|
||||
const updateAvailable = compareVersions(latestForChannel, currentVersion) > 0;
|
||||
|
||||
// Also check if there's a newer beta for stable users who want to preview
|
||||
const newerBetaAvailable = currentChannel === 'stable' &&
|
||||
availableVersions.beta.length > 0 &&
|
||||
compareVersions(latestBeta, currentVersion) > 0;
|
||||
|
||||
const result = {
|
||||
current: currentVersion,
|
||||
channel: currentChannel,
|
||||
latest: {
|
||||
stable: latestStable,
|
||||
beta: latestBeta,
|
||||
forChannel: latestForChannel
|
||||
},
|
||||
updateAvailable,
|
||||
newerBetaAvailable,
|
||||
lastChecked: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Update cache
|
||||
versionCache = result;
|
||||
lastCheck = now;
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the version cache (useful for testing)
|
||||
*/
|
||||
function clearCache() {
|
||||
versionCache = null;
|
||||
lastCheck = 0;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
checkForUpdates,
|
||||
getCurrentVersion,
|
||||
getCurrentChannel,
|
||||
compareVersions,
|
||||
parseVersion,
|
||||
clearCache
|
||||
};
|
||||
@@ -60,7 +60,7 @@ async function createInvitation({ email, roleId, invitedById }) {
|
||||
// Queue invitation email
|
||||
const frontendUrl = process.env.FRONTEND_URL || process.env.ADMIN_URL || 'http://localhost:3005';
|
||||
await queueEmail(null, email, 'admin_invitation', {
|
||||
invite_link: `${frontendUrl}/admin/accept-invite/${token}`,
|
||||
invite_link: `${frontendUrl}/invite/${token}`,
|
||||
role_name: role.display_name,
|
||||
expires_at: expiresAt.toISOString()
|
||||
});
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
/**
|
||||
* WatermarkGeneratorService
|
||||
*
|
||||
* Handles batch generation of pre-watermarked images for fast serving.
|
||||
* This service is responsible for:
|
||||
* - Generating watermarks for newly uploaded photos
|
||||
* - Regenerating all watermarks when settings change
|
||||
* - Tracking regeneration progress
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const { db } = require('../database/db');
|
||||
const watermarkService = require('./watermarkService');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
class WatermarkGeneratorService {
|
||||
constructor() {
|
||||
// Track active regeneration jobs
|
||||
this.activeJobs = new Map();
|
||||
// Batch size for processing (to manage memory)
|
||||
this.batchSize = 10;
|
||||
// Concurrent processing limit
|
||||
this.concurrentLimit = 2;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermark for a single photo
|
||||
* @param {number} photoId - The photo ID
|
||||
* @returns {Object} Result with success status and watermark path
|
||||
*/
|
||||
async generateForPhoto(photoId) {
|
||||
try {
|
||||
// Get photo with event info
|
||||
const photo = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('photos.id', photoId)
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
)
|
||||
.first();
|
||||
|
||||
if (!photo) {
|
||||
return { success: false, error: 'Photo not found' };
|
||||
}
|
||||
|
||||
// Skip video files
|
||||
if (photo.media_type === 'video' || (photo.mime_type && photo.mime_type.startsWith('video/'))) {
|
||||
return { success: false, error: 'Videos do not support watermarks' };
|
||||
}
|
||||
|
||||
// Get watermark settings
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return { success: false, error: 'Watermarking is disabled' };
|
||||
}
|
||||
|
||||
// Resolve the original file path
|
||||
const originalPath = this.resolvePhotoPath(photo);
|
||||
if (!originalPath) {
|
||||
return { success: false, error: 'Could not resolve photo path' };
|
||||
}
|
||||
|
||||
// Generate and save watermark
|
||||
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
|
||||
|
||||
if (result.success) {
|
||||
// Update database with watermark path
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
watermark_path: result.watermarkPath,
|
||||
watermark_generated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the full file path for a photo
|
||||
*/
|
||||
resolvePhotoPath(photo) {
|
||||
const storagePath = getStoragePath();
|
||||
|
||||
// Handle external/reference mode
|
||||
if (photo.source_mode === 'reference' && photo.external_relpath) {
|
||||
const externalRoot = process.env.EXTERNAL_MEDIA_PATH || path.join(storagePath, 'external');
|
||||
return path.join(externalRoot, photo.external_path || '', photo.external_relpath);
|
||||
}
|
||||
|
||||
// Standard managed mode
|
||||
if (photo.file_path) {
|
||||
// file_path might be absolute or relative
|
||||
if (path.isAbsolute(photo.file_path)) {
|
||||
return photo.file_path;
|
||||
}
|
||||
return path.join(storagePath, photo.file_path);
|
||||
}
|
||||
|
||||
// Fallback to constructing path from slug and filename
|
||||
return path.join(storagePath, 'events', 'active', photo.slug, photo.filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermarks for all photos in an event
|
||||
* @param {number} eventId - The event ID
|
||||
* @param {Function} onProgress - Optional callback for progress updates
|
||||
* @returns {Object} Result with success count and errors
|
||||
*/
|
||||
async generateForEvent(eventId, onProgress = null) {
|
||||
const results = { total: 0, success: 0, failed: 0, errors: [] };
|
||||
|
||||
try {
|
||||
// Get all photos for the event (excluding videos)
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.where('photos.event_id', eventId)
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
);
|
||||
|
||||
results.total = photos.length;
|
||||
|
||||
if (photos.length === 0) {
|
||||
return results;
|
||||
}
|
||||
|
||||
// Get watermark settings once
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return { ...results, errors: ['Watermarking is disabled'] };
|
||||
}
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
const batch = photos.slice(i, i + this.batchSize);
|
||||
|
||||
// Process batch with limited concurrency
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(photo => this.processPhotoWatermark(photo, settings))
|
||||
);
|
||||
|
||||
// Collect results
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.error) {
|
||||
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Progress callback
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
total: results.total,
|
||||
processed: results.success + results.failed,
|
||||
success: results.success,
|
||||
failed: results.failed
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermarks for event ${eventId}:`, error);
|
||||
return { ...results, errors: [...results.errors, error.message] };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process watermark for a single photo (internal helper)
|
||||
*/
|
||||
async processPhotoWatermark(photo, settings) {
|
||||
try {
|
||||
const originalPath = this.resolvePhotoPath(photo);
|
||||
if (!originalPath) {
|
||||
return { success: false, photoId: photo.id, error: 'Could not resolve path' };
|
||||
}
|
||||
|
||||
const result = await watermarkService.generateAndSaveWatermark(photo, originalPath, settings);
|
||||
|
||||
if (result.success) {
|
||||
await db('photos')
|
||||
.where({ id: photo.id })
|
||||
.update({
|
||||
watermark_path: result.watermarkPath,
|
||||
watermark_generated_at: db.fn.now()
|
||||
});
|
||||
}
|
||||
|
||||
return { ...result, photoId: photo.id };
|
||||
} catch (error) {
|
||||
return { success: false, photoId: photo.id, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Regenerate watermarks for all photos in the system
|
||||
* @param {Function} onProgress - Optional callback for progress updates
|
||||
* @returns {Object} Result with success count and errors
|
||||
*/
|
||||
async regenerateAll(onProgress = null) {
|
||||
const jobId = Date.now().toString();
|
||||
const results = { jobId, total: 0, success: 0, failed: 0, errors: [], status: 'running' };
|
||||
|
||||
try {
|
||||
this.activeJobs.set(jobId, results);
|
||||
|
||||
// Get watermark settings
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
results.status = 'completed';
|
||||
results.errors.push('Watermarking is disabled');
|
||||
return results;
|
||||
}
|
||||
|
||||
// First, clear existing watermarks from DB (the files will be overwritten)
|
||||
// This ensures stale paths don't persist if regeneration fails
|
||||
|
||||
// Get all image photos (exclude videos)
|
||||
const photos = await db('photos')
|
||||
.join('events', 'photos.event_id', 'events.id')
|
||||
.whereNot(function() {
|
||||
this.where('photos.media_type', 'video')
|
||||
.orWhere('photos.mime_type', 'like', 'video/%');
|
||||
})
|
||||
.select(
|
||||
'photos.*',
|
||||
'events.slug',
|
||||
'events.source_mode',
|
||||
'events.external_path'
|
||||
);
|
||||
|
||||
results.total = photos.length;
|
||||
|
||||
if (photos.length === 0) {
|
||||
results.status = 'completed';
|
||||
return results;
|
||||
}
|
||||
|
||||
console.log(`Starting watermark regeneration for ${photos.length} photos`);
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < photos.length; i += this.batchSize) {
|
||||
// Check if job was cancelled
|
||||
if (!this.activeJobs.has(jobId)) {
|
||||
results.status = 'cancelled';
|
||||
return results;
|
||||
}
|
||||
|
||||
const batch = photos.slice(i, i + this.batchSize);
|
||||
|
||||
// Process batch with limited concurrency
|
||||
const batchResults = await Promise.all(
|
||||
batch.map(photo => this.processPhotoWatermark(photo, settings))
|
||||
);
|
||||
|
||||
// Collect results
|
||||
for (const result of batchResults) {
|
||||
if (result.success) {
|
||||
results.success++;
|
||||
} else {
|
||||
results.failed++;
|
||||
if (result.error && results.errors.length < 50) {
|
||||
results.errors.push(`Photo ${result.photoId}: ${result.error}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update job status
|
||||
this.activeJobs.set(jobId, { ...results });
|
||||
|
||||
// Progress callback
|
||||
if (onProgress) {
|
||||
onProgress({
|
||||
jobId,
|
||||
total: results.total,
|
||||
processed: results.success + results.failed,
|
||||
success: results.success,
|
||||
failed: results.failed,
|
||||
percentComplete: Math.round(((results.success + results.failed) / results.total) * 100)
|
||||
});
|
||||
}
|
||||
|
||||
// Small delay between batches to prevent CPU saturation
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
results.status = 'completed';
|
||||
console.log(`Watermark regeneration completed: ${results.success}/${results.total} successful`);
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
console.error('Error during watermark regeneration:', error);
|
||||
results.status = 'failed';
|
||||
results.errors.push(error.message);
|
||||
return results;
|
||||
} finally {
|
||||
// Clean up job tracking after a delay
|
||||
setTimeout(() => {
|
||||
this.activeJobs.delete(jobId);
|
||||
}, 60000); // Keep for 1 minute for status queries
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all watermarks (when watermarking is disabled)
|
||||
*/
|
||||
async clearAllWatermarks() {
|
||||
try {
|
||||
// Get all photos with watermarks
|
||||
const photos = await db('photos')
|
||||
.whereNotNull('watermark_path')
|
||||
.select('id', 'watermark_path');
|
||||
|
||||
// Delete watermark files
|
||||
for (const photo of photos) {
|
||||
await watermarkService.deleteWatermarkFile(photo.watermark_path);
|
||||
}
|
||||
|
||||
// Clear database paths
|
||||
await db('photos')
|
||||
.whereNotNull('watermark_path')
|
||||
.update({
|
||||
watermark_path: null,
|
||||
watermark_generated_at: null
|
||||
});
|
||||
|
||||
console.log(`Cleared ${photos.length} watermarks`);
|
||||
return { success: true, cleared: photos.length };
|
||||
} catch (error) {
|
||||
console.error('Error clearing watermarks:', error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete watermark for a specific photo
|
||||
*/
|
||||
async deleteForPhoto(photoId) {
|
||||
try {
|
||||
const photo = await db('photos')
|
||||
.where({ id: photoId })
|
||||
.select('watermark_path')
|
||||
.first();
|
||||
|
||||
if (photo && photo.watermark_path) {
|
||||
await watermarkService.deleteWatermarkFile(photo.watermark_path);
|
||||
await db('photos')
|
||||
.where({ id: photoId })
|
||||
.update({
|
||||
watermark_path: null,
|
||||
watermark_generated_at: null
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error(`Error deleting watermark for photo ${photoId}:`, error);
|
||||
return { success: false, error: error.message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get status of an active regeneration job
|
||||
*/
|
||||
getJobStatus(jobId) {
|
||||
return this.activeJobs.get(jobId) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an active regeneration job
|
||||
*/
|
||||
cancelJob(jobId) {
|
||||
if (this.activeJobs.has(jobId)) {
|
||||
this.activeJobs.delete(jobId);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an active regeneration job
|
||||
*/
|
||||
hasActiveJob() {
|
||||
for (const [, job] of this.activeJobs) {
|
||||
if (job.status === 'running') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get count of photos needing watermark generation
|
||||
*/
|
||||
async getPendingCount() {
|
||||
const settings = await watermarkService.getWatermarkSettings();
|
||||
if (!settings || !settings.enabled) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const result = await db('photos')
|
||||
.whereNull('watermark_path')
|
||||
.whereNot(function() {
|
||||
this.where('media_type', 'video')
|
||||
.orWhere('mime_type', 'like', 'video/%');
|
||||
})
|
||||
.count('id as count')
|
||||
.first();
|
||||
|
||||
return parseInt(result.count) || 0;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatermarkGeneratorService();
|
||||
@@ -2,6 +2,7 @@ const sharp = require('sharp');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const { db } = require('../database/db');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
|
||||
class WatermarkService {
|
||||
constructor() {
|
||||
@@ -177,14 +178,25 @@ class WatermarkService {
|
||||
settings.position
|
||||
);
|
||||
|
||||
// Apply watermark
|
||||
const watermarkedBuffer = await image
|
||||
.composite([{
|
||||
input: watermarkBuffer,
|
||||
top: position.top,
|
||||
left: position.left
|
||||
}])
|
||||
.toBuffer();
|
||||
// Apply watermark with high quality output to preserve original image quality
|
||||
let watermarkedImage = image.composite([{
|
||||
input: watermarkBuffer,
|
||||
top: position.top,
|
||||
left: position.left
|
||||
}]);
|
||||
|
||||
// Preserve original format with high quality settings
|
||||
const format = metadata.format || 'jpeg';
|
||||
let watermarkedBuffer;
|
||||
|
||||
if (format === 'png') {
|
||||
watermarkedBuffer = await watermarkedImage.png({ quality: 100, compressionLevel: 6 }).toBuffer();
|
||||
} else if (format === 'webp') {
|
||||
watermarkedBuffer = await watermarkedImage.webp({ quality: 95, lossless: false }).toBuffer();
|
||||
} else {
|
||||
// Default to JPEG with maximum quality (100) to prevent recompression
|
||||
watermarkedBuffer = await watermarkedImage.jpeg({ quality: 100, mozjpeg: true }).toBuffer();
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
this.cache.set(cacheKey, {
|
||||
@@ -221,6 +233,125 @@ class WatermarkService {
|
||||
clearCache() {
|
||||
this.cache.clear();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the watermarks directory path, creating it if needed
|
||||
*/
|
||||
async getWatermarksDir() {
|
||||
const watermarksDir = path.join(getStoragePath(), 'watermarks');
|
||||
try {
|
||||
await fs.access(watermarksDir);
|
||||
} catch {
|
||||
await fs.mkdir(watermarksDir, { recursive: true });
|
||||
}
|
||||
return watermarksDir;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the file extension from a filename
|
||||
*/
|
||||
getFileExtension(filename) {
|
||||
const ext = path.extname(filename).toLowerCase();
|
||||
// Map common extensions
|
||||
if (ext === '.jpeg') return '.jpg';
|
||||
return ext || '.jpg';
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate watermarked version of a photo and save to disk
|
||||
* @param {Object} photo - Photo object with id, filename, and path info
|
||||
* @param {string} originalPath - Full path to the original image file
|
||||
* @param {Object} settings - Watermark settings (optional, will fetch if not provided)
|
||||
* @returns {Object} { success, watermarkPath, error }
|
||||
*/
|
||||
async generateAndSaveWatermark(photo, originalPath, settings = null) {
|
||||
try {
|
||||
// Get settings if not provided
|
||||
if (!settings) {
|
||||
settings = await this.getWatermarkSettings();
|
||||
}
|
||||
|
||||
// If watermarking is disabled, return early
|
||||
if (!settings || !settings.enabled) {
|
||||
return { success: false, watermarkPath: null, error: 'Watermarking is disabled' };
|
||||
}
|
||||
|
||||
// Verify original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
} catch {
|
||||
return { success: false, watermarkPath: null, error: 'Original file not found' };
|
||||
}
|
||||
|
||||
// Generate watermarked buffer using existing method
|
||||
const watermarkedBuffer = await this.applyWatermark(originalPath, settings);
|
||||
|
||||
// Determine output path
|
||||
const watermarksDir = await this.getWatermarksDir();
|
||||
const ext = this.getFileExtension(photo.filename);
|
||||
const outputFilename = `${photo.id}_watermarked${ext}`;
|
||||
const outputPath = path.join(watermarksDir, outputFilename);
|
||||
|
||||
// Write the watermarked image to disk
|
||||
await fs.writeFile(outputPath, watermarkedBuffer);
|
||||
|
||||
// Return relative path for database storage
|
||||
const relativePath = `watermarks/${outputFilename}`;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
watermarkPath: relativePath,
|
||||
error: null
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Error generating watermark for photo ${photo.id}:`, error);
|
||||
return {
|
||||
success: false,
|
||||
watermarkPath: null,
|
||||
error: error.message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a pre-generated watermark file
|
||||
* @param {string} watermarkPath - Relative path to the watermark file
|
||||
* @returns {boolean} - True if deleted successfully
|
||||
*/
|
||||
async deleteWatermarkFile(watermarkPath) {
|
||||
if (!watermarkPath) return false;
|
||||
|
||||
try {
|
||||
const fullPath = path.join(getStoragePath(), watermarkPath);
|
||||
await fs.unlink(fullPath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
// File might not exist, which is fine
|
||||
if (error.code !== 'ENOENT') {
|
||||
console.error('Error deleting watermark file:', error);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a hash of current watermark settings for change detection
|
||||
* @returns {string} - Hash string of settings
|
||||
*/
|
||||
async getSettingsHash() {
|
||||
const settings = await this.getWatermarkSettings();
|
||||
if (!settings) return '';
|
||||
|
||||
const hashData = `${settings.enabled}-${settings.logoPath || ''}-${settings.position}-${settings.opacity}-${settings.size}`;
|
||||
// Simple hash for change detection (not cryptographic)
|
||||
let hash = 0;
|
||||
for (let i = 0; i < hashData.length; i++) {
|
||||
const char = hashData.charCodeAt(i);
|
||||
hash = ((hash << 5) - hash) + char;
|
||||
hash = hash & hash; // Convert to 32bit integer
|
||||
}
|
||||
return hash.toString(16);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new WatermarkService();
|
||||
@@ -76,7 +76,7 @@ class XmpGenerator {
|
||||
* @returns {string} Description XML
|
||||
*/
|
||||
generateDescription(photo) {
|
||||
const rating = photo.average_rating ? photo.average_rating.toFixed(1) : '0';
|
||||
const rating = photo.average_rating ? parseFloat(photo.average_rating).toFixed(1) : '0';
|
||||
const likes = photo.like_count || 0;
|
||||
const favorites = photo.favorite_count || 0;
|
||||
|
||||
|
||||
@@ -38,14 +38,17 @@ services:
|
||||
|
||||
backend:
|
||||
# Use pre-built image from GitHub Container Registry
|
||||
image: ghcr.io/the-luap/picpeak/backend:latest
|
||||
# PICPEAK_CHANNEL: 'stable' (default), 'beta', or specific version like 'v2.3.0'
|
||||
image: ghcr.io/the-luap/picpeak/backend:${PICPEAK_CHANNEL:-stable}
|
||||
container_name: picpeak-backend
|
||||
env_file: .env
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DB_HOST=${DB_HOST:-postgres}
|
||||
- REDIS_HOST=redis
|
||||
- STORAGE_PATH=/app/storage
|
||||
- PHOTOS_DIR=/app/storage/events
|
||||
- PICPEAK_RELEASE_CHANNEL=${PICPEAK_CHANNEL:-stable}
|
||||
volumes:
|
||||
- ${APP_STORAGE}:/app/storage
|
||||
- ${LOGS}:/app/logs
|
||||
@@ -69,7 +72,8 @@ services:
|
||||
|
||||
frontend:
|
||||
# Use pre-built image from GitHub Container Registry
|
||||
image: ghcr.io/the-luap/picpeak/frontend:latest
|
||||
# Uses same channel as backend for consistency
|
||||
image: ghcr.io/the-luap/picpeak/frontend:${PICPEAK_CHANNEL:-stable}
|
||||
container_name: picpeak-frontend
|
||||
# Note: Pre-built frontend uses Nginx to proxy /api to backend:3001.
|
||||
# Prefer keeping API base as '/api' in builds to avoid CORS.
|
||||
|
||||
Generated
+17
-17
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.15",
|
||||
"version": "3.3.0-beta.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "picpeak-frontend",
|
||||
"version": "1.1.15",
|
||||
"version": "3.3.0-beta.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.0.0",
|
||||
"@tiptap/extension-character-count": "^2.26.1",
|
||||
@@ -1281,9 +1281,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@remix-run/router": {
|
||||
"version": "1.23.1",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.1.tgz",
|
||||
"integrity": "sha512-vDbaOzF7yT2Qs4vO6XV1MHcJv+3dgR1sT+l3B8xxOVhUC336prMvqrvsLL/9Dnw2xr6Qhz4J0dmS0llNAbnUmQ==",
|
||||
"version": "1.23.2",
|
||||
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz",
|
||||
"integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -4740,9 +4740,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||
"version": "4.17.23",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/lodash.merge": {
|
||||
@@ -5824,12 +5824,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router": {
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.2.tgz",
|
||||
"integrity": "sha512-H2Bm38Zu1bm8KUE5NVWRMzuIyAV8p/JrOaBJAwVmp37AXG72+CZJlEBw6pdn9i5TBgLMhNDgijS4ZlblpHyWTA==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz",
|
||||
"integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.1"
|
||||
"@remix-run/router": "1.23.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
@@ -5839,13 +5839,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-router-dom": {
|
||||
"version": "6.30.2",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.2.tgz",
|
||||
"integrity": "sha512-l2OwHn3UUnEVUqc6/1VMmR1cvZryZ3j3NzapC2eUXO1dB0sYp5mvwdjiXhpUbRb21eFow3qSxpP8Yv6oAU824Q==",
|
||||
"version": "6.30.3",
|
||||
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz",
|
||||
"integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@remix-run/router": "1.23.1",
|
||||
"react-router": "6.30.2"
|
||||
"@remix-run/router": "1.23.2",
|
||||
"react-router": "6.30.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14.0.0"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "2.2.3",
|
||||
"version": "3.6.0-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -24,7 +24,8 @@ import {
|
||||
SettingsPage,
|
||||
BackupManagement,
|
||||
CMSPage,
|
||||
UserManagementPage
|
||||
UserManagementPage,
|
||||
EventTypesPage
|
||||
} from './pages/admin';
|
||||
import { AcceptInvitePage } from './pages/public/AcceptInvitePage';
|
||||
import { AdminLayout, AdminAuthWrapper } from './components/admin';
|
||||
@@ -128,6 +129,7 @@ function App() {
|
||||
<Route path="analytics" element={<AnalyticsPage />} />
|
||||
<Route path="branding" element={<BrandingPage />} />
|
||||
<Route path="settings" element={<SettingsPage />} />
|
||||
<Route path="event-types" element={<EventTypesPage />} />
|
||||
<Route path="backup" element={<BackupManagement />} />
|
||||
<Route path="cms" element={<CMSPage />} />
|
||||
<Route path="users" element={<UserManagementPage />} />
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
Palette,
|
||||
FileText,
|
||||
HardDrive,
|
||||
Users
|
||||
Users,
|
||||
Tags
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -39,6 +40,7 @@ const navigation: NavItem[] = [
|
||||
{ nameKey: 'navigation.emailSettings', href: '/admin/email', icon: Mail, permission: 'email.view' },
|
||||
{ nameKey: 'navigation.branding', href: '/admin/branding', icon: Palette, permission: 'branding.view' },
|
||||
{ nameKey: 'navigation.settings', href: '/admin/settings', icon: Settings, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.eventTypes', href: '/admin/event-types', icon: Tags, permission: 'settings.view' },
|
||||
{ nameKey: 'navigation.backup', href: '/admin/backup', icon: HardDrive, permission: 'backup.view' },
|
||||
{ nameKey: 'navigation.cmsPages', href: '/admin/cms', icon: FileText, permission: 'cms.view' },
|
||||
{ nameKey: 'navigation.users', href: '/admin/users', icon: Users, permission: 'users.view' },
|
||||
|
||||
@@ -67,15 +67,16 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
const handleChange = (key: keyof ThemeConfig, newValue: any) => {
|
||||
const updated = { ...localTheme, [key]: newValue };
|
||||
setLocalTheme(updated);
|
||||
|
||||
|
||||
// When any change is made, mark it as custom
|
||||
if (selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
|
||||
|
||||
if (isPreviewMode) {
|
||||
onChange(updated);
|
||||
// Include customCss in the propagated theme
|
||||
onChange({ ...updated, customCss });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -84,6 +85,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
if (preset) {
|
||||
setSelectedPreset(presetKey);
|
||||
setLocalTheme(preset.config);
|
||||
setCustomCss(''); // Clear custom CSS when selecting a preset
|
||||
if (onPresetChange) {
|
||||
onPresetChange(presetKey);
|
||||
}
|
||||
@@ -364,6 +366,63 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Masonry specific */}
|
||||
{localTheme.galleryLayout === 'masonry' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.masonryMode', 'Layout Mode')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryMode || 'columns'}
|
||||
onChange={(e) => updateGallerySettings('masonryMode', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="columns">{t('branding.masonryModeOptions.columns', 'Columns (Pinterest-style)')}</option>
|
||||
<option value="rows">{t('branding.masonryModeOptions.rows', 'Rows (Google Photos-style)')}</option>
|
||||
</select>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('branding.masonryModeHint', 'Columns arranges photos vertically, rows fills horizontal lines')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Row-specific settings */}
|
||||
{localTheme.gallerySettings?.masonryMode === 'rows' && (
|
||||
<>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.targetRowHeight', 'Target Row Height')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
min="150"
|
||||
max="400"
|
||||
value={localTheme.gallerySettings?.masonryRowHeight || 250}
|
||||
onChange={(e) => updateGallerySettings('masonryRowHeight', parseInt(e.target.value))}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('branding.targetRowHeightHint', 'Height in pixels (150-400). Photos will scale to fit rows.')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.lastRowBehavior', 'Last Row Alignment')}
|
||||
</label>
|
||||
<select
|
||||
value={localTheme.gallerySettings?.masonryLastRowBehavior || 'left'}
|
||||
onChange={(e) => updateGallerySettings('masonryLastRowBehavior', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg"
|
||||
>
|
||||
<option value="left">{t('branding.lastRowOptions.left', 'Left aligned')}</option>
|
||||
<option value="center">{t('branding.lastRowOptions.center', 'Centered')}</option>
|
||||
<option value="justify">{t('branding.lastRowOptions.justify', 'Justified (stretch)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
@@ -722,12 +781,17 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => {
|
||||
setCustomCss(e.target.value);
|
||||
const newCss = e.target.value;
|
||||
setCustomCss(newCss);
|
||||
// Mark as custom when CSS is added
|
||||
if (e.target.value && selectedPreset !== 'custom' && onPresetChange) {
|
||||
if (newCss && selectedPreset !== 'custom' && onPresetChange) {
|
||||
setSelectedPreset('custom');
|
||||
onPresetChange('custom');
|
||||
}
|
||||
// Propagate customCss changes to parent in preview mode
|
||||
if (isPreviewMode) {
|
||||
onChange({ ...localTheme, customCss: newCss });
|
||||
}
|
||||
}}
|
||||
placeholder="/* Add custom CSS here */"
|
||||
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
|
||||
|
||||
@@ -26,7 +26,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||
justified: <Layers className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeDisplay: React.FC<ThemeDisplayProps> = ({
|
||||
|
||||
@@ -22,7 +22,8 @@ const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
|
||||
carousel: <Play className="w-4 h-4" />,
|
||||
timeline: <Clock className="w-4 h-4" />,
|
||||
hero: <Image className="w-4 h-4" />,
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />
|
||||
mosaic: <LayoutGrid className="w-4 h-4" />,
|
||||
justified: <Layers className="w-4 h-4" />
|
||||
};
|
||||
|
||||
export const ThemeEditorModal: React.FC<ThemeEditorModalProps> = ({
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ArrowUpCircle, X, ExternalLink } from 'lucide-react';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface UpdateInfo {
|
||||
enabled: boolean;
|
||||
current: string;
|
||||
channel: 'stable' | 'beta';
|
||||
latest: {
|
||||
stable: string;
|
||||
beta: string;
|
||||
forChannel: string;
|
||||
};
|
||||
updateAvailable: boolean;
|
||||
newerBetaAvailable?: boolean;
|
||||
lastChecked: string;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
async function fetchUpdateInfo(): Promise<UpdateInfo> {
|
||||
const response = await api.get<UpdateInfo>('/admin/system/updates');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
interface UpdateNotificationProps {
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
export const UpdateNotification: React.FC<UpdateNotificationProps> = ({ onDismiss }) => {
|
||||
const { t } = useTranslation();
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
|
||||
const { data: updateInfo } = useQuery({
|
||||
queryKey: ['update-check'],
|
||||
queryFn: fetchUpdateInfo,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
retry: false,
|
||||
refetchOnWindowFocus: false
|
||||
});
|
||||
|
||||
// Don't render if no update available, not enabled, or dismissed
|
||||
if (!updateInfo?.enabled || !updateInfo?.updateAvailable || dismissed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleDismiss = () => {
|
||||
setDismissed(true);
|
||||
onDismiss?.();
|
||||
};
|
||||
|
||||
const channelLabel = updateInfo.channel === 'beta'
|
||||
? t('admin.updates.channelBeta', 'Beta')
|
||||
: t('admin.updates.channelStable', 'Stable');
|
||||
|
||||
return (
|
||||
<div className="bg-blue-50 border-l-4 border-blue-500 p-4 mb-4 rounded-r-lg">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start">
|
||||
<ArrowUpCircle className="w-5 h-5 text-blue-500 mt-0.5 mr-3 flex-shrink-0" />
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-blue-800">
|
||||
{t('admin.updates.available', 'Update Available')}
|
||||
</h4>
|
||||
<p className="text-sm text-blue-700 mt-1">
|
||||
{t('admin.updates.newVersion', 'Version {{version}} is available', {
|
||||
version: updateInfo.latest.forChannel
|
||||
})}
|
||||
<span className="text-blue-500 ml-2">
|
||||
({t('admin.updates.currentVersion', 'Current: {{version}}', {
|
||||
version: updateInfo.current
|
||||
})})
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-xs text-blue-600 mt-1">
|
||||
{t('admin.updates.channel', 'Channel: {{channel}}', {
|
||||
channel: channelLabel
|
||||
})}
|
||||
</p>
|
||||
<a
|
||||
href="https://github.com/the-luap/picpeak/releases"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center text-xs text-blue-600 hover:text-blue-800 mt-2"
|
||||
>
|
||||
{t('admin.updates.viewReleaseNotes', 'View Release Notes')}
|
||||
<ExternalLink className="w-3 h-3 ml-1" />
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleDismiss}
|
||||
className="text-blue-400 hover:text-blue-600 p-1"
|
||||
aria-label={t('common.close', 'Close')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Info } from 'lucide-react';
|
||||
import { Info, ArrowUpCircle } from 'lucide-react';
|
||||
import { api } from '../../config/api';
|
||||
import packageJson from '../../../package.json';
|
||||
|
||||
@@ -13,6 +13,15 @@ interface SystemVersion {
|
||||
frontend: string;
|
||||
node: string;
|
||||
environment: string;
|
||||
channel?: 'stable' | 'beta';
|
||||
}
|
||||
|
||||
interface UpdateInfo {
|
||||
enabled: boolean;
|
||||
updateAvailable: boolean;
|
||||
latest?: {
|
||||
forChannel: string;
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchSystemVersion(): Promise<SystemVersion> {
|
||||
@@ -20,6 +29,11 @@ async function fetchSystemVersion(): Promise<SystemVersion> {
|
||||
return response.data;
|
||||
}
|
||||
|
||||
async function fetchUpdateInfo(): Promise<UpdateInfo> {
|
||||
const response = await api.get<UpdateInfo>('/admin/system/updates');
|
||||
return response.data;
|
||||
}
|
||||
|
||||
export const VersionInfo: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const { data: versionInfo } = useQuery({
|
||||
@@ -28,11 +42,25 @@ export const VersionInfo: React.FC = () => {
|
||||
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
|
||||
});
|
||||
|
||||
const { data: updateInfo } = useQuery({
|
||||
queryKey: ['update-check'],
|
||||
queryFn: fetchUpdateInfo,
|
||||
staleTime: 60 * 60 * 1000, // 1 hour
|
||||
retry: false
|
||||
});
|
||||
|
||||
const channelBadge = versionInfo?.channel === 'beta' ? (
|
||||
<span className="ml-1 px-1.5 py-0.5 text-xs bg-amber-100 text-amber-700 rounded">
|
||||
{t('admin.updates.beta', 'BETA')}
|
||||
</span>
|
||||
) : null;
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 border-t border-neutral-200">
|
||||
<div className="flex items-center gap-2 text-xs text-neutral-600">
|
||||
<Info className="w-3 h-3" />
|
||||
<span className="font-medium">{t('admin.version')}</span>
|
||||
{channelBadge}
|
||||
</div>
|
||||
<div className="mt-1 space-y-0.5 text-xs text-neutral-500">
|
||||
<div>Frontend: v{FRONTEND_VERSION}</div>
|
||||
@@ -40,6 +68,16 @@ export const VersionInfo: React.FC = () => {
|
||||
<div>Backend: v{versionInfo.backend}</div>
|
||||
)}
|
||||
</div>
|
||||
{updateInfo?.enabled && updateInfo?.updateAvailable && (
|
||||
<div className="mt-2 flex items-center gap-1 text-xs text-blue-600">
|
||||
<ArrowUpCircle className="w-3 h-3" />
|
||||
<span>
|
||||
{t('admin.updates.updateAvailableShort', 'v{{version}} available', {
|
||||
version: updateInfo.latest?.forChannel
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
interface AuthenticatedImageProps extends Omit<React.ImgHTMLAttributes<HTMLImageElement>, 'onLoad'> {
|
||||
src: string;
|
||||
fallbackSrc?: string;
|
||||
useWatermark?: boolean;
|
||||
@@ -29,6 +29,7 @@ interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageEleme
|
||||
detectDevTools?: boolean;
|
||||
protectionLevel?: 'basic' | 'standard' | 'enhanced' | 'maximum';
|
||||
useEnhancedProtection?: boolean;
|
||||
onLoad?: () => void;
|
||||
}
|
||||
|
||||
export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
@@ -54,6 +55,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
detectDevTools,
|
||||
protectionLevel,
|
||||
useEnhancedProtection,
|
||||
onLoad,
|
||||
...props
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
@@ -221,6 +223,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
img.onload = () => {
|
||||
imageRef.current = img;
|
||||
drawToCanvas();
|
||||
onLoad?.();
|
||||
};
|
||||
|
||||
img.onerror = (e) => {
|
||||
@@ -235,7 +238,7 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
img.onload = null;
|
||||
img.onerror = null;
|
||||
};
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas]);
|
||||
}, [imageSrc, useCanvasRendering, drawToCanvas, onLoad]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -282,5 +285,5 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
);
|
||||
}
|
||||
|
||||
return <img src={imageSrc} alt={alt} {...props} />;
|
||||
return <img src={imageSrc} alt={alt} onLoad={onLoad} {...props} />;
|
||||
};
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useEffect } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getApiBaseUrl, buildResourceUrl } from '../../utils/url';
|
||||
|
||||
const DEFAULT_TITLE = 'PicPeak - Photo Sharing Platform';
|
||||
|
||||
export const DynamicFavicon: React.FC = () => {
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['public-settings'],
|
||||
@@ -19,6 +21,7 @@ export const DynamicFavicon: React.FC = () => {
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
// Update favicon when branding settings change
|
||||
useEffect(() => {
|
||||
if (settings?.branding_favicon_url) {
|
||||
// Remove existing favicon links
|
||||
@@ -29,13 +32,27 @@ export const DynamicFavicon: React.FC = () => {
|
||||
const link = document.createElement('link');
|
||||
link.rel = 'icon';
|
||||
link.type = 'image/png';
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
link.href = settings.branding_favicon_url.startsWith('http')
|
||||
? settings.branding_favicon_url
|
||||
: buildResourceUrl(settings.branding_favicon_url);
|
||||
|
||||
|
||||
document.head.appendChild(link);
|
||||
}
|
||||
}, [settings?.branding_favicon_url]);
|
||||
|
||||
// Update document title when company name or tagline changes
|
||||
useEffect(() => {
|
||||
const companyName = settings?.branding_company_name?.trim();
|
||||
const tagline = settings?.branding_company_tagline?.trim();
|
||||
|
||||
if (companyName && tagline) {
|
||||
document.title = `${companyName} - ${tagline}`;
|
||||
} else if (companyName) {
|
||||
document.title = companyName;
|
||||
} else {
|
||||
document.title = DEFAULT_TITLE;
|
||||
}
|
||||
}, [settings?.branding_company_name, settings?.branding_company_tagline]);
|
||||
|
||||
return null;
|
||||
};
|
||||
@@ -240,11 +240,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* Extra header items (upload button, etc.) */}
|
||||
{headerExtra && (
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
{headerExtra}
|
||||
|
||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
@@ -366,7 +362,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
className={`${heroLogoSize.className} w-auto object-contain mx-auto`}
|
||||
style={{
|
||||
...(heroLogoSize.style || {}),
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
// Only apply brightness/invert filter to default logo; custom logos display as-is
|
||||
filter: brandingSettings?.logo_url
|
||||
? 'drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
}}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
@@ -441,7 +440,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
||||
{brandingSettings?.footer_text || `© ${new Date().getFullYear()}${brandingSettings?.company_name ? ` ${brandingSettings.company_name}` : ''}. All rights reserved.`}
|
||||
{!brandingSettings?.hide_powered_by && (
|
||||
<> | Powered by <span className="font-semibold">PicPeak</span></>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Upload, Star } from 'lucide-react';
|
||||
import { X, Download, Filter, SortAsc, Search, Calendar, Type, HardDrive, Check, Star, Upload } from 'lucide-react';
|
||||
import { Button } from '../common';
|
||||
import { PhotoCategory } from '../../types';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -139,18 +139,18 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Content */}
|
||||
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
|
||||
{/* Upload Section - Only show on mobile when uploads are allowed */}
|
||||
{isMobile && allowUploads && onUploadClick && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
{/* Upload Section - Show prominently at top for mobile users */}
|
||||
{allowUploads && onUploadClick && (
|
||||
<div className="gallery-sidebar-section gallery-sidebar-upload p-4 border-b border-neutral-200">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => {
|
||||
onUploadClick();
|
||||
onClose();
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className="w-full"
|
||||
className="gallery-btn w-full"
|
||||
>
|
||||
{t('upload.uploadPhotos')}
|
||||
</Button>
|
||||
|
||||
@@ -203,7 +203,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
company_name: settingsData.branding_company_name || '',
|
||||
company_tagline: settingsData.branding_company_tagline || '',
|
||||
support_email: settingsData.branding_support_email || '',
|
||||
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
|
||||
footer_text: settingsData.branding_footer_text || '',
|
||||
watermark_enabled: settingsData.branding_watermark_enabled || false,
|
||||
logo_url: settingsData.branding_logo_url || null,
|
||||
logo_size: settingsData.branding_logo_size || 'medium',
|
||||
@@ -310,10 +310,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
const showUrgentWarning = daysUntilExpiration <= 7;
|
||||
const isExpired = daysUntilExpiration < 0;
|
||||
// Calculate days until expiration (null means never expires)
|
||||
const daysUntilExpiration = event.expires_at
|
||||
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||
: null;
|
||||
const showUrgentWarning = daysUntilExpiration !== null && daysUntilExpiration <= 7;
|
||||
const isExpired = daysUntilExpiration !== null && daysUntilExpiration < 0;
|
||||
|
||||
// Filter and sort photos
|
||||
const filteredPhotos = useMemo(() => {
|
||||
@@ -380,12 +382,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Transform URLs for watermarks if enabled
|
||||
// Transform full-size URLs for watermarks if enabled
|
||||
// Note: Thumbnails are watermarked server-side at the thumbnail endpoint
|
||||
if (watermarkEnabled) {
|
||||
photos = photos.map(photo => ({
|
||||
...photo,
|
||||
url: `/gallery/${slug}/photo/${photo.id}`,
|
||||
thumbnail_url: `/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||
url: `/api/gallery/${slug}/photo/${photo.id}`
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -602,15 +604,15 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
if (daysUntilExpiration !== null && daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
<CountdownTimer key="countdown" expiresAt={event.expires_at} className="mr-2" />
|
||||
);
|
||||
}
|
||||
|
||||
// Upload button only on desktop when sidebar is shown
|
||||
// Upload button - always show when uploads are allowed (regardless of layout/theme loading state)
|
||||
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
|
||||
if (allowUploads && showSidebar && !isMobile) {
|
||||
if (allowUploads) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
@@ -618,22 +620,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
>
|
||||
{t('upload.uploadPhotos')}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// Upload button for non-sidebar layouts
|
||||
if (allowUploads && !showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Upload className="w-4 h-4" />}
|
||||
onClick={() => setShowUploadModal(true)}
|
||||
className="flex-1 sm:flex-initial"
|
||||
className={!showSidebar ? 'flex-1 sm:flex-initial' : ''}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('upload.uploadPhotos')}</span>
|
||||
<span className="sm:hidden">{t('common.upload')}</span>
|
||||
@@ -695,7 +682,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
showSelectionControls={!showSidebar}
|
||||
eventName={event.event_name}
|
||||
eventLogo={brandingSettings?.logo_url}
|
||||
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
|
||||
eventDate={event.event_date}
|
||||
expiresAt={event.expires_at}
|
||||
allowDownloads={allowDownloads}
|
||||
@@ -704,6 +691,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
disableRightClick={disableRightClick}
|
||||
enableDevtoolsProtection={enableDevtoolsProtection}
|
||||
useCanvasRendering={useCanvasRendering}
|
||||
heroLogoVisible={data?.event?.hero_logo_visible !== false}
|
||||
heroLogoSize={data?.event?.hero_logo_size || 'medium'}
|
||||
heroLogoPosition={data?.event?.hero_logo_position || 'top'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
|
||||
@@ -52,6 +52,10 @@ interface PhotoGridWithLayoutsProps {
|
||||
requireNameEmail?: boolean;
|
||||
};
|
||||
onFeedbackChange?: () => void;
|
||||
// Hero logo customization options
|
||||
heroLogoVisible?: boolean;
|
||||
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
||||
}
|
||||
|
||||
export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
@@ -76,7 +80,10 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
eventName,
|
||||
eventLogo,
|
||||
eventDate,
|
||||
expiresAt
|
||||
expiresAt,
|
||||
heroLogoVisible = true,
|
||||
heroLogoSize = 'medium',
|
||||
heroLogoPosition = 'top'
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const { theme } = useTheme();
|
||||
@@ -200,6 +207,9 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
||||
expiresAt,
|
||||
feedbackEnabled,
|
||||
feedbackOptions,
|
||||
heroLogoVisible,
|
||||
heroLogoSize,
|
||||
heroLogoPosition,
|
||||
};
|
||||
|
||||
let LayoutComponent;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
|
||||
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star, Loader2 } from 'lucide-react';
|
||||
import type { Photo } from '../../types';
|
||||
import { useDownloadPhoto } from '../../hooks/useGallery';
|
||||
import { AuthenticatedImage } from '../common';
|
||||
@@ -62,12 +62,18 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const [showIdentityModal, setShowIdentityModal] = useState(false);
|
||||
const [pendingAction, setPendingAction] = useState<null | { type: 'like' | 'rating'; rating?: number }>(null);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onResize = () => setIsSmallScreen(window.innerWidth < 640);
|
||||
window.addEventListener('resize', onResize);
|
||||
return () => window.removeEventListener('resize', onResize);
|
||||
}, []);
|
||||
|
||||
// Reset image loaded state when changing photos
|
||||
useEffect(() => {
|
||||
setImageLoaded(false);
|
||||
}, [currentIndex]);
|
||||
|
||||
const downloadPhotoMutation = useDownloadPhoto();
|
||||
const currentPhoto = photos[currentIndex];
|
||||
@@ -488,6 +494,13 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
|
||||
}}
|
||||
>
|
||||
{/* Loading spinner */}
|
||||
{!imageLoaded && currentPhoto.media_type !== 'video' && (
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<Loader2 className="w-12 h-12 text-white animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{currentPhoto.media_type === 'video' ? (
|
||||
<VideoPlayer
|
||||
src={currentPhoto.url}
|
||||
@@ -505,8 +518,10 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
style={{
|
||||
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
|
||||
transition: isDragging ? 'none' : 'transform 0.2s',
|
||||
opacity: imageLoaded ? 1 : 0,
|
||||
}}
|
||||
draggable={false}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
useWatermark={useEnhancedProtection}
|
||||
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
|
||||
isGallery={true}
|
||||
@@ -524,7 +539,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
detectDevTools={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
|
||||
onProtectionViolation={(violationType) => {
|
||||
console.warn(`Protection violation in lightbox for photo ${currentPhoto.id}: ${violationType}`);
|
||||
|
||||
|
||||
// Track analytics
|
||||
if (typeof window !== 'undefined' && (window as any).umami) {
|
||||
(window as any).umami.track('lightbox_protection_violation', {
|
||||
@@ -534,7 +549,7 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
|
||||
zoom
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// For maximum protection, close lightbox on violation
|
||||
if (protectionLevel === 'maximum' &&
|
||||
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
|
||||
|
||||
@@ -49,6 +49,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
|
||||
@@ -18,6 +18,10 @@ interface HeroGalleryLayoutProps extends BaseGalleryLayoutProps {
|
||||
expiresAt?: string;
|
||||
// Use a static hero photo independent of current filter
|
||||
heroPhotoOverride?: Photo | null;
|
||||
// Hero logo customization options
|
||||
heroLogoVisible?: boolean;
|
||||
heroLogoSize?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
heroLogoPosition?: 'top' | 'center' | 'bottom';
|
||||
}
|
||||
|
||||
export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
@@ -34,6 +38,9 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
eventDate,
|
||||
expiresAt,
|
||||
heroPhotoOverride,
|
||||
heroLogoVisible = true,
|
||||
heroLogoSize = 'medium',
|
||||
heroLogoPosition = 'top',
|
||||
allowDownloads = true,
|
||||
protectionLevel = 'standard',
|
||||
useEnhancedProtection = false,
|
||||
@@ -51,6 +58,22 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||
|
||||
// Helper function to get logo size classes
|
||||
const getLogoSizeClasses = (size: string): string => {
|
||||
switch (size) {
|
||||
case 'small':
|
||||
return 'h-12 sm:h-14 lg:h-16';
|
||||
case 'medium':
|
||||
return 'h-20 sm:h-24 lg:h-32';
|
||||
case 'large':
|
||||
return 'h-28 sm:h-32 lg:h-40';
|
||||
case 'xlarge':
|
||||
return 'h-36 sm:h-40 lg:h-48';
|
||||
default:
|
||||
return 'h-20 sm:h-24 lg:h-32';
|
||||
}
|
||||
};
|
||||
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
|
||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||
const gridRef = useRef<HTMLDivElement | null>(null);
|
||||
@@ -133,28 +156,51 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
buildResourceUrl(eventLogo) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto"
|
||||
style={{
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Logo at top position */}
|
||||
{heroLogoVisible && heroLogoPosition === 'top' && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
buildResourceUrl(eventLogo) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||
style={{
|
||||
filter: eventLogo
|
||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
<h1 className="text-3xl sm:text-4xl lg:text-5xl xl:text-6xl font-bold text-white drop-shadow-lg mb-4">
|
||||
{eventName}
|
||||
</h1>
|
||||
)}
|
||||
|
||||
|
||||
{/* Logo at center position (between title and dates) */}
|
||||
{heroLogoVisible && heroLogoPosition === 'center' && (
|
||||
<div className="my-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
buildResourceUrl(eventLogo) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||
style={{
|
||||
filter: eventLogo
|
||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event Dates */}
|
||||
{(eventDate || expiresAt) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/90">
|
||||
@@ -172,6 +218,25 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo at bottom position */}
|
||||
{heroLogoVisible && heroLogoPosition === 'bottom' && (
|
||||
<div className="mt-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
buildResourceUrl(eventLogo) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className={`${getLogoSizeClasses(heroLogoSize)} mx-auto`}
|
||||
style={{
|
||||
filter: eventLogo
|
||||
? 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,9 +1,14 @@
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState, useMemo } from 'react';
|
||||
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
|
||||
import { useTheme } from '../../../contexts/ThemeContext';
|
||||
import { AuthenticatedImage } from '../../common';
|
||||
import { FeedbackIdentityModal } from '../../gallery/FeedbackIdentityModal';
|
||||
import { feedbackService } from '../../../services/feedback.service';
|
||||
import {
|
||||
calculateJustifiedLayout,
|
||||
createJustifiedPhotos,
|
||||
type JustifiedLayoutItem,
|
||||
} from '../../../utils/justifiedLayoutCalculator';
|
||||
import type { BaseGalleryLayoutProps } from './BaseGalleryLayout';
|
||||
import type { Photo } from '../../../types';
|
||||
|
||||
@@ -216,14 +221,19 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const { theme } = useTheme();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [columns, setColumns] = useState(3);
|
||||
const [containerWidth, setContainerWidth] = useState(0);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const gutter = gallerySettings.masonryGutter || 16;
|
||||
const mode = gallerySettings.masonryMode || 'columns';
|
||||
const targetRowHeight = gallerySettings.masonryRowHeight || 250;
|
||||
const lastRowBehavior = gallerySettings.masonryLastRowBehavior || 'left';
|
||||
|
||||
// Calculate number of columns based on container width
|
||||
// Calculate number of columns based on container width (for columns mode)
|
||||
useEffect(() => {
|
||||
const updateColumns = () => {
|
||||
const updateDimensions = () => {
|
||||
if (containerRef.current) {
|
||||
const width = containerRef.current.offsetWidth;
|
||||
setContainerWidth(width);
|
||||
if (width < 640) setColumns(2);
|
||||
else if (width < 1024) setColumns(3);
|
||||
else if (width < 1280) setColumns(4);
|
||||
@@ -231,17 +241,187 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
}
|
||||
};
|
||||
|
||||
updateColumns();
|
||||
window.addEventListener('resize', updateColumns);
|
||||
return () => window.removeEventListener('resize', updateColumns);
|
||||
updateDimensions();
|
||||
|
||||
// Use ResizeObserver for better performance
|
||||
const resizeObserver = new ResizeObserver((entries) => {
|
||||
for (const entry of entries) {
|
||||
if (entry.contentRect.width > 0) {
|
||||
setContainerWidth(entry.contentRect.width);
|
||||
const width = entry.contentRect.width;
|
||||
if (width < 640) setColumns(2);
|
||||
else if (width < 1024) setColumns(3);
|
||||
else if (width < 1280) setColumns(4);
|
||||
else setColumns(5);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (containerRef.current) {
|
||||
resizeObserver.observe(containerRef.current);
|
||||
}
|
||||
|
||||
return () => resizeObserver.disconnect();
|
||||
}, []);
|
||||
|
||||
// Distribute photos across columns
|
||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
photos.forEach((photo, index) => {
|
||||
photoColumns[index % columns].push(photo);
|
||||
});
|
||||
// Calculate justified layout for rows mode
|
||||
const rowsLayout = useMemo(() => {
|
||||
if (mode !== 'rows' || containerWidth <= 0 || photos.length === 0) {
|
||||
return { items: [], containerHeight: 0, rowCount: 0 };
|
||||
}
|
||||
|
||||
const justifiedPhotos = createJustifiedPhotos(
|
||||
photos.map((p) => ({
|
||||
id: p.id,
|
||||
width: p.width,
|
||||
height: p.height,
|
||||
}))
|
||||
);
|
||||
|
||||
return calculateJustifiedLayout(justifiedPhotos, {
|
||||
containerWidth,
|
||||
targetRowHeight,
|
||||
spacing: gutter,
|
||||
lastRowBehavior,
|
||||
});
|
||||
}, [mode, photos, containerWidth, targetRowHeight, gutter, lastRowBehavior]);
|
||||
|
||||
// Create a map for quick lookup of layout items by photo ID (rows mode)
|
||||
const layoutItemMap = useMemo(() => {
|
||||
const map = new Map<number, JustifiedLayoutItem>();
|
||||
for (const item of rowsLayout.items) {
|
||||
map.set(item.photoId, item);
|
||||
}
|
||||
return map;
|
||||
}, [rowsLayout.items]);
|
||||
|
||||
// Distribute photos across columns (for columns mode)
|
||||
const photoColumns: Photo[][] = Array.from({ length: columns }, () => []);
|
||||
if (mode === 'columns') {
|
||||
photos.forEach((photo, index) => {
|
||||
photoColumns[index % columns].push(photo);
|
||||
});
|
||||
}
|
||||
|
||||
// ROWS MODE - Google Photos style justified layout
|
||||
if (mode === 'rows') {
|
||||
// Show loading state while measuring container width
|
||||
const isCalculating = containerWidth <= 0 || rowsLayout.items.length === 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="photo-grid relative"
|
||||
style={{
|
||||
height: isCalculating ? 'auto' : rowsLayout.containerHeight,
|
||||
minHeight: isCalculating ? 200 : undefined
|
||||
}}
|
||||
>
|
||||
{isCalculating ? (
|
||||
// Render a simple grid while calculating to get container width
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{photos.slice(0, 8).map((photo) => (
|
||||
<div key={photo.id} className="aspect-square bg-neutral-200 rounded-lg animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : photos.map((photo, index) => {
|
||||
const layoutItem = layoutItemMap.get(photo.id);
|
||||
if (!layoutItem) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="photo-card absolute group cursor-pointer transition-all duration-300 hover:z-10"
|
||||
style={{
|
||||
left: layoutItem.x,
|
||||
top: layoutItem.y,
|
||||
width: layoutItem.width,
|
||||
height: layoutItem.height,
|
||||
}}
|
||||
onClick={() => onPhotoClick(index)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
src={photo.thumbnail_url || photo.url}
|
||||
alt={photo.filename}
|
||||
className="w-full h-full object-cover rounded-lg transition-transform duration-300 group-hover:scale-[1.02]"
|
||||
loading="lazy"
|
||||
isGallery={true}
|
||||
protectFromDownload={!allowDownloads}
|
||||
/>
|
||||
|
||||
{/* Feedback Indicators */}
|
||||
{feedbackEnabled && ((photo.comment_count ?? 0) > 0 || (photo.average_rating ?? 0) > 0 || (photo.like_count ?? 0) > 0) && (
|
||||
<div className="absolute top-2 left-2 flex gap-1 z-10">
|
||||
{(photo.comment_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<MessageSquare className="w-3.5 h-3.5 text-primary-600" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.comment_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
{(photo.average_rating ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<Star className="w-3.5 h-3.5 text-yellow-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{Number(photo.average_rating ?? 0).toFixed(1)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(photo.like_count ?? 0) > 0 && (
|
||||
<div className="bg-white/90 backdrop-blur-sm rounded-full px-2 py-1 flex items-center gap-1">
|
||||
<Heart className="w-3.5 h-3.5 text-red-500" fill="currentColor" />
|
||||
<span className="text-xs font-medium text-neutral-700">{photo.like_count ?? 0}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Hover overlay with actions */}
|
||||
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center gap-2">
|
||||
{!isSelectionMode && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="View full size"
|
||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onPhotoClick(index); }}
|
||||
>
|
||||
<Maximize2 className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
{allowDownloads && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download photo"
|
||||
className="p-2 bg-white/20 hover:bg-white/40 rounded-full transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onDownload(photo, e); }}
|
||||
>
|
||||
<Download className="w-5 h-5 text-white" />
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selection Checkbox */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Select ${photo.filename}`}
|
||||
role="checkbox"
|
||||
aria-checked={selectedPhotos.has(photo.id)}
|
||||
className={`absolute top-2 right-2 z-20 transition-opacity ${
|
||||
selectedPhotos.has(photo.id) ? 'opacity-100' : 'opacity-0 group-hover:opacity-100'
|
||||
}`}
|
||||
onClick={(e) => { e.stopPropagation(); onPhotoSelect && onPhotoSelect(photo.id); }}
|
||||
>
|
||||
<div className={`w-6 h-6 rounded-full border-2 ${selectedPhotos.has(photo.id) ? 'bg-primary-600 border-primary-600' : 'bg-white/90 border-white'} flex items-center justify-center transition-colors`}>
|
||||
{selectedPhotos.has(photo.id) && <Check className="w-4 h-4 text-white" />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// COLUMNS MODE - Pinterest style masonry (default)
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -249,8 +429,8 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
{photoColumns.map((column, columnIndex) => (
|
||||
<div
|
||||
key={columnIndex}
|
||||
<div
|
||||
key={columnIndex}
|
||||
className="flex-1 flex flex-col"
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
|
||||
@@ -218,16 +218,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||
if (response?.event) {
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
// Store token and slug BEFORE setting authenticated state to avoid
|
||||
// race condition where photo queries fire before token is available
|
||||
if (response.token) {
|
||||
storeGalleryToken(currentSlug, response.token);
|
||||
}
|
||||
setActiveGallerySlug(currentSlug);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -263,17 +265,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
// Store token and slug BEFORE setting authenticated state to avoid
|
||||
// race condition where photo queries fire before token is available
|
||||
if (response.token) {
|
||||
storeGalleryToken(slug, response.token);
|
||||
}
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid password');
|
||||
throw err;
|
||||
|
||||
@@ -16,7 +16,6 @@ export interface GeneralSettings {
|
||||
max_file_size_mb: number;
|
||||
max_files_per_upload: number;
|
||||
allowed_file_types: string;
|
||||
enable_watermark: boolean;
|
||||
enable_analytics: boolean;
|
||||
enable_registration: boolean;
|
||||
maintenance_mode: boolean;
|
||||
@@ -49,6 +48,8 @@ export interface EventSettings {
|
||||
event_require_customer_name: boolean;
|
||||
event_require_customer_email: boolean;
|
||||
event_require_admin_email: boolean;
|
||||
event_require_event_date: boolean;
|
||||
event_require_expiration: boolean;
|
||||
}
|
||||
|
||||
export function useSettingsState() {
|
||||
@@ -74,7 +75,6 @@ export function useSettingsState() {
|
||||
max_file_size_mb: 50,
|
||||
max_files_per_upload: 500,
|
||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: false,
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
@@ -109,7 +109,9 @@ export function useSettingsState() {
|
||||
const [eventSettings, setEventSettings] = useState<EventSettings>({
|
||||
event_require_customer_name: true,
|
||||
event_require_customer_email: true,
|
||||
event_require_admin_email: true
|
||||
event_require_admin_email: true,
|
||||
event_require_event_date: true,
|
||||
event_require_expiration: true
|
||||
});
|
||||
|
||||
// Account form state
|
||||
@@ -142,7 +144,6 @@ export function useSettingsState() {
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||
@@ -178,7 +179,9 @@ export function useSettingsState() {
|
||||
setEventSettings({
|
||||
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
|
||||
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
|
||||
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
|
||||
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
|
||||
event_require_event_date: toBoolean(settings.event_require_event_date, true),
|
||||
event_require_expiration: toBoolean(settings.event_require_expiration, true)
|
||||
});
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
|
||||
@@ -99,6 +99,56 @@ export const EventsTab: React.FC<EventsTabProps> = ({
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventSettings.event_require_event_date}
|
||||
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_event_date: e.target.checked }))}
|
||||
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('settings.events.requireEventDate', 'Require event date')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.events.requireEventDateHelp', 'Event date must be provided when creating events')}
|
||||
</p>
|
||||
{!eventSettings.event_require_event_date && (
|
||||
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{t('settings.events.eventDateWarning', 'Gallery URLs will use random identifiers instead of dates')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventSettings.event_require_expiration}
|
||||
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_expiration: e.target.checked }))}
|
||||
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-700">
|
||||
{t('settings.events.requireExpiration', 'Require expiration date')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.events.requireExpirationHelp', 'Galleries must have an expiration date')}
|
||||
</p>
|
||||
{!eventSettings.event_require_expiration && (
|
||||
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
{t('settings.events.expirationWarning', 'Galleries without expiration will remain active until manually archived')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
|
||||
@@ -184,16 +184,6 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.enable_watermark}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableWatermark')}</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"events": "Veranstaltungen",
|
||||
"archives": "Archive",
|
||||
"settings": "Einstellungen",
|
||||
"eventTypes": "Veranstaltungsarten",
|
||||
"branding": "Branding",
|
||||
"analytics": "Analytik",
|
||||
"emailSettings": "E-Mail-Einstellungen",
|
||||
@@ -161,6 +162,54 @@
|
||||
"cmsPages": "CMS-Seiten",
|
||||
"users": "Benutzer"
|
||||
},
|
||||
"eventTypes": {
|
||||
"title": "Veranstaltungsarten",
|
||||
"subtitle": "Veranstaltungsarten und deren Standard-Themes anpassen",
|
||||
"loading": "Lade Veranstaltungsarten...",
|
||||
"loadError": "Fehler beim Laden der Veranstaltungsarten",
|
||||
"createNew": "Neue Veranstaltungsart",
|
||||
"edit": "Veranstaltungsart bearbeiten",
|
||||
"created": "Veranstaltungsart erfolgreich erstellt",
|
||||
"updated": "Veranstaltungsart erfolgreich aktualisiert",
|
||||
"deleted": "Veranstaltungsart erfolgreich gelöscht",
|
||||
"createError": "Fehler beim Erstellen der Veranstaltungsart",
|
||||
"updateError": "Fehler beim Aktualisieren der Veranstaltungsart",
|
||||
"deleteError": "Fehler beim Löschen der Veranstaltungsart",
|
||||
"searchPlaceholder": "Veranstaltungsarten suchen...",
|
||||
"showInactive": "Inaktive anzeigen",
|
||||
"noResults": "Keine Veranstaltungsarten gefunden",
|
||||
"empty": "Noch keine Veranstaltungsarten",
|
||||
"system": "System",
|
||||
"table": {
|
||||
"type": "Typ",
|
||||
"slugPrefix": "URL-Präfix",
|
||||
"theme": "Standard-Theme",
|
||||
"status": "Status",
|
||||
"actions": "Aktionen"
|
||||
},
|
||||
"form": {
|
||||
"name": "Anzeigename",
|
||||
"namePlaceholder": "z.B. Familienshooting",
|
||||
"slugPrefix": "URL-Präfix",
|
||||
"slugPrefixPlaceholder": "z.B. familie",
|
||||
"slugPreview": "Beispiel-URL:",
|
||||
"emoji": "Symbol",
|
||||
"themePreset": "Standard-Theme",
|
||||
"isActive": "Aktiv (sichtbar bei Veranstaltungserstellung)"
|
||||
},
|
||||
"validation": {
|
||||
"slugFormat": "Nur Buchstaben, Zahlen und Bindestriche erlaubt"
|
||||
},
|
||||
"slugInfo": {
|
||||
"title": "URL-Präfix Info:",
|
||||
"description": "Das URL-Präfix wird zur Generierung von Galerie-URLs verwendet. Zum Beispiel erstellt eine Veranstaltungsart mit Präfix \"familie\" URLs wie: familie-mueller-familie-2025-01-22"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Veranstaltungsart löschen",
|
||||
"message": "Sind Sie sicher, dass Sie löschen möchten",
|
||||
"warning": "Diese Aktion kann nicht rückgängig gemacht werden. Stellen Sie sicher, dass keine Veranstaltungen diesen Typ verwenden."
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"external": {
|
||||
"warning": {
|
||||
@@ -759,6 +808,8 @@
|
||||
"themeAndStyle": "Design & Stil",
|
||||
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
||||
"expirationWarning": "Gäste erhalten 7 Tage vor Ablauf eine Warn-E-Mail.",
|
||||
"noExpiration": "Kein Ablaufdatum",
|
||||
"noExpirationHelp": "Diese Galerie bleibt aktiv, bis sie manuell archiviert wird.",
|
||||
"userUploads": "Benutzer-Upload-Einstellungen",
|
||||
"allowUserUploads": "Gästen erlauben, Fotos hochzuladen",
|
||||
"allowUserUploadsHelp": "Ermöglichen Sie Gästen, ihre eigenen Fotos in diese Galerie hochzuladen",
|
||||
@@ -778,6 +829,35 @@
|
||||
"enableDevtoolsProtection": "Entwicklertools erkennen",
|
||||
"useCanvasRendering": "Canvas-Rendering (erweiterter Schutz)",
|
||||
"protectionInfo": "Schutzfunktionen helfen, unerlaubte Downloads zu verhindern, können jedoch nicht alle Methoden blockieren.",
|
||||
"protectionLevelBasic": "Einfach - Nur Rechtsklick-Sperre",
|
||||
"protectionLevelStandard": "Standard - Tastenkombinationen blockiert",
|
||||
"protectionLevelEnhanced": "Erweitert - Bildschirmfoto-Erkennung",
|
||||
"protectionLevelMaximum": "Maximum - DevTools-Erkennung & Canvas-Rendering",
|
||||
"heroLogoSettings": "Hero-Logo-Einstellungen",
|
||||
"heroLogoVisible": "Logo im Hero-Bereich anzeigen",
|
||||
"heroLogoSize": "Logo-Größe",
|
||||
"heroLogoSizeSmall": "Klein",
|
||||
"heroLogoSizeMedium": "Mittel",
|
||||
"heroLogoSizeLarge": "Groß",
|
||||
"heroLogoSizeXLarge": "Extra Groß",
|
||||
"heroLogoPosition": "Logo-Position",
|
||||
"heroLogoPositionTop": "Oben (über dem Titel)",
|
||||
"heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)",
|
||||
"heroLogoPositionBottom": "Unten (unter dem Datum)",
|
||||
"heroLogoInfo": "Diese Einstellungen gelten für das Hero-Layout der Galerie. Sie können das Logo ausblenden oder Größe und Position anpassen.",
|
||||
"eventCustomLogo": "Individuelles Event-Logo",
|
||||
"eventCustomLogoDescription": "Laden Sie ein individuelles Logo für dieses Event hoch. Dies überschreibt das globale Branding-Logo nur für diese Galerie.",
|
||||
"uploadEventLogo": "Logo hochladen",
|
||||
"replaceLogo": "Ersetzen",
|
||||
"removeLogo": "Entfernen",
|
||||
"eventLogoUploaded": "Event-Logo erfolgreich hochgeladen",
|
||||
"eventLogoUploadFailed": "Event-Logo konnte nicht hochgeladen werden",
|
||||
"eventLogoRemoved": "Event-Logo erfolgreich entfernt",
|
||||
"eventLogoRemoveFailed": "Event-Logo konnte nicht entfernt werden",
|
||||
"heroLogoVisibleLabel": "Logo sichtbar",
|
||||
"heroLogoSizeLabel": "Größe",
|
||||
"heroLogoPositionLabel": "Position",
|
||||
"heroLogoHidden": "Logo ausgeblendet",
|
||||
"heroPhoto": "Hero-Foto",
|
||||
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
|
||||
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||
@@ -904,7 +984,6 @@
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||
@@ -1032,6 +1111,54 @@
|
||||
"saveSettings": "Sicherheitseinstellungen speichern",
|
||||
"saveSecuritySettings": "Sicherheitseinstellungen speichern"
|
||||
},
|
||||
"events": {
|
||||
"title": "Veranstaltungserstellung",
|
||||
"requiredFields": "Pflichtfelder",
|
||||
"requiredFieldsDescription": "Konfigurieren Sie, welche Kontaktfelder beim Erstellen neuer Veranstaltungen erforderlich sind.",
|
||||
"requireCustomerName": "Kundenname erforderlich",
|
||||
"requireCustomerNameHelp": "Kundenname muss für neue Veranstaltungen angegeben werden",
|
||||
"requireCustomerEmail": "Kunden-E-Mail erforderlich",
|
||||
"requireCustomerEmailHelp": "Kunden-E-Mail muss für neue Veranstaltungen angegeben werden",
|
||||
"customerEmailWarning": "Erforderlich für das Versenden von Galerie-Einladungen",
|
||||
"requireAdminEmail": "Admin-E-Mail erforderlich",
|
||||
"requireAdminEmailHelp": "Admin-E-Mail muss für neue Veranstaltungen angegeben werden",
|
||||
"adminEmailWarning": "Erforderlich für den Erhalt von Veranstaltungsbenachrichtigungen",
|
||||
"requireEventDate": "Veranstaltungsdatum erforderlich",
|
||||
"requireEventDateHelp": "Veranstaltungsdatum muss beim Erstellen angegeben werden",
|
||||
"eventDateWarning": "Galerie-URLs verwenden zufällige Kennungen anstelle von Daten",
|
||||
"requireExpiration": "Ablaufdatum erforderlich",
|
||||
"requireExpirationHelp": "Galerien müssen ein Ablaufdatum haben",
|
||||
"expirationWarning": "Galerien ohne Ablaufdatum bleiben aktiv, bis sie manuell archiviert werden",
|
||||
"saveSettings": "Veranstaltungseinstellungen speichern",
|
||||
"noteTitle": "Hinweis",
|
||||
"noteText": "Diese Einstellungen betreffen nur die Erstellung neuer Veranstaltungen. Bestehende Veranstaltungen sind nicht betroffen. Standardmäßig sind alle Felder erforderlich."
|
||||
},
|
||||
"imageSecurity": {
|
||||
"title": "Bildschutz",
|
||||
"saveSuccess": "Bildschutz-Einstellungen gespeichert",
|
||||
"saveError": "Fehler beim Speichern der Einstellungen",
|
||||
"loadError": "Fehler beim Laden der Bildschutz-Einstellungen",
|
||||
"defaultProtection": "Standard-Schutzeinstellungen",
|
||||
"defaultProtectionHelp": "Diese Einstellungen gelten für alle neuen Veranstaltungen. Einzelne Veranstaltungen können diese Standardwerte überschreiben.",
|
||||
"protectionLevel": "Standard-Schutzstufe",
|
||||
"imageQuality": "Standard-Bildqualität",
|
||||
"fragmentationLevel": "Fragmentierungsstufe",
|
||||
"enableDevtools": "DevTools-Erkennung standardmäßig aktivieren",
|
||||
"enableCanvas": "Canvas-Rendering standardmäßig aktivieren (erweiterter Schutz)",
|
||||
"rateLimiting": "Ratenbegrenzung",
|
||||
"rateLimitingHelp": "Begrenzen Sie, wie viele Bilder angefordert werden können, um Scraping zu verhindern.",
|
||||
"requestsPerMinute": "Anfragen pro Minute",
|
||||
"requestsPer5Minutes": "Anfragen pro 5 Min.",
|
||||
"requestsPerHour": "Anfragen pro Stunde",
|
||||
"securityMonitoring": "Sicherheitsüberwachung",
|
||||
"suspiciousThreshold": "Schwellenwert für verdächtige Aktivitäten",
|
||||
"autoBlockThreshold": "Automatische Sperrschwelle",
|
||||
"enableMonitoring": "Sicherheitsüberwachung aktivieren",
|
||||
"blockSuspiciousIps": "Verdächtige IPs automatisch blockieren",
|
||||
"logEvents": "Sicherheitsereignisse in Datenbank protokollieren",
|
||||
"infoTitle": "Über Bildschutz",
|
||||
"infoText": "Diese Schutzfunktionen helfen, gelegentliches Herunterladen und Kopieren zu verhindern, können aber nicht alle Methoden blockieren. Entschlossene Benutzer finden möglicherweise trotzdem Wege, Bilder zu erfassen. Erwägen Sie die Verwendung von Wasserzeichen und rechtlichen Vereinbarungen für umfassenden Schutz."
|
||||
},
|
||||
"categories": {
|
||||
"title": "Kategorien",
|
||||
"about": "Über Fotokategorien",
|
||||
@@ -1092,7 +1219,6 @@
|
||||
"faviconHelp": "PNG- oder ICO-Format, empfohlene Größe: 32x32px",
|
||||
"watermark": "Wasserzeichen",
|
||||
"watermarkSettings": "Wasserzeichen-Einstellungen",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableWatermarks": "Wasserzeichen aktivieren",
|
||||
"watermarkHelp": "Fügen Sie Ihren Firmennamen als Wasserzeichen auf heruntergeladenen Fotos hinzu",
|
||||
"watermarkLogo": "Wasserzeichen-Logo",
|
||||
@@ -1132,7 +1258,8 @@
|
||||
"carousel": "Vollbild-Diashow mit Navigation",
|
||||
"timeline": "Nach Datum organisierte Fotos",
|
||||
"hero": "Hervorgehobenes Bild mit Raster darunter",
|
||||
"mosaic": "Künstlerisches Layout mit gemischten Größen"
|
||||
"mosaic": "Künstlerisches Layout mit gemischten Größen",
|
||||
"justified": "Zeilenbasiertes Layout mit Seitenverhältnis-Erhaltung"
|
||||
},
|
||||
"layoutSettings": "Layout-Einstellungen",
|
||||
"photoSpacing": "Foto-Abstand",
|
||||
@@ -1160,6 +1287,30 @@
|
||||
"week": "Woche",
|
||||
"month": "Monat"
|
||||
},
|
||||
"masonryMode": "Layout-Modus",
|
||||
"masonryModeOptions": {
|
||||
"columns": "Spalten (Pinterest-Stil)",
|
||||
"rows": "Zeilen (Google Fotos-Stil)"
|
||||
},
|
||||
"masonryModeHint": "Spalten ordnet Fotos vertikal an, Zeilen füllt horizontale Linien",
|
||||
"targetRowHeight": "Ziel-Zeilenhöhe",
|
||||
"targetRowHeightHint": "Höhe in Pixeln (150-400). Fotos werden skaliert, um in Zeilen zu passen.",
|
||||
"lastRowBehavior": "Letzte Zeile Ausrichtung",
|
||||
"lastRowOptions": {
|
||||
"left": "Linksbündig",
|
||||
"center": "Zentriert",
|
||||
"justify": "Blocksatz (gestreckt)"
|
||||
},
|
||||
"showHeroSection": "Hero-Bereich anzeigen",
|
||||
"showHeroSectionHint": "Zeigt ein hervorgehobenes Titelbild über der Galerie an",
|
||||
"heroHeight": "Hero-Bereich Höhe",
|
||||
"heroHeightOptions": {
|
||||
"small": "Klein (40-50%)",
|
||||
"medium": "Mittel (50-70%)",
|
||||
"large": "Groß (60-80%)"
|
||||
},
|
||||
"heroOverlayOpacity": "Hero-Overlay Deckkraft",
|
||||
"heroOverlayHint": "Verdunkelt das Titelbild für bessere Lesbarkeit",
|
||||
"typographyAndStyle": "Typografie & Stil",
|
||||
"bodyFont": "Fließtext-Schriftart",
|
||||
"headingFont": "Überschriften-Schriftart",
|
||||
@@ -1209,7 +1360,31 @@
|
||||
"customizeTheme": "Design anpassen",
|
||||
"saveTheme": "Design speichern",
|
||||
"previewLayout": "Vorschau-Layout",
|
||||
"livePreview": "Live-Vorschau"
|
||||
"livePreview": "Live-Vorschau",
|
||||
"whiteLabel": "White Label",
|
||||
"hidePoweredBy": "\"Powered by PicPeak\" Branding ausblenden",
|
||||
"hidePoweredByHelp": "Entfernen Sie die PicPeak-Kennzeichnung aus Galerie-Fußzeilen für ein vollständig personalisiertes Erscheinungsbild",
|
||||
"logoCustomization": "Logo-Anpassung",
|
||||
"changeLogo": "Logo ändern",
|
||||
"logoSizeSmall": "Klein (32px)",
|
||||
"logoSizeMedium": "Mittel (48px)",
|
||||
"logoSizeLarge": "Groß (64px)",
|
||||
"logoSizeXLarge": "Extra Groß (96px)",
|
||||
"logoSizeCustom": "Benutzerdefiniert",
|
||||
"logoMaxHeight": "Maximale Höhe (Pixel)",
|
||||
"logoMaxHeightHelp": "Legen Sie eine benutzerdefinierte maximale Höhe für das Logo fest (20-200 Pixel)",
|
||||
"logoPosition": "Logo-Position im Header",
|
||||
"positionLeft": "Links",
|
||||
"positionCenter": "Mitte",
|
||||
"positionRight": "Rechts",
|
||||
"logoDisplayMode": "Anzeigemodus",
|
||||
"logoOnly": "Nur Logo",
|
||||
"textOnly": "Nur Firmenname",
|
||||
"logoAndText": "Logo und Firmenname",
|
||||
"showLogoInHeader": "Logo im Galerie-Header anzeigen",
|
||||
"showLogoInHeaderHelp": "Logo in der Hauptkopfzeile anzeigen",
|
||||
"showLogoInHero": "Logo im Hero-Bereich anzeigen",
|
||||
"showLogoInHeroHelp": "Logo in Hero-Bereichen anzeigen (für Nicht-Raster-Layouts)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin-Panel",
|
||||
@@ -1232,6 +1407,20 @@
|
||||
"error": "Fehler",
|
||||
"checking": "Prüfe..."
|
||||
},
|
||||
"updates": {
|
||||
"available": "Update verfügbar",
|
||||
"newVersion": "Version {{version}} ist verfügbar",
|
||||
"currentVersion": "Aktuell: {{version}}",
|
||||
"channel": "Kanal: {{channel}}",
|
||||
"channelStable": "Stabil",
|
||||
"channelBeta": "Beta",
|
||||
"beta": "BETA",
|
||||
"viewReleaseNotes": "Versionshinweise anzeigen",
|
||||
"updateAvailableShort": "v{{version}} verfügbar",
|
||||
"checkForUpdates": "Nach Updates suchen",
|
||||
"upToDate": "Alles aktuell",
|
||||
"lastChecked": "Zuletzt geprüft: {{time}}"
|
||||
},
|
||||
"notifications": "Benachrichtigungen",
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
@@ -1431,6 +1620,10 @@
|
||||
"successMessage": "Ihr Konto wurde erfolgreich erstellt. Sie können sich jetzt mit Ihren Zugangsdaten anmelden.",
|
||||
"redirecting": "Weiterleitung zur Anmeldung in {{seconds}}...",
|
||||
"goToLogin": "Zur Anmeldung",
|
||||
"contactAdminMessage": "Bitte kontaktieren Sie Ihren Administrator, um eine neue Einladung anzufordern.",
|
||||
"passwordsMatch": "Passwörter stimmen überein",
|
||||
"alreadyHaveAccount": "Bereits ein Konto?",
|
||||
"signIn": "Anmelden",
|
||||
"errors": {
|
||||
"usernameRequired": "Benutzername ist erforderlich",
|
||||
"usernameTooShort": "Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
|
||||
@@ -154,6 +154,7 @@
|
||||
"events": "Events",
|
||||
"archives": "Archives",
|
||||
"settings": "Settings",
|
||||
"eventTypes": "Event Types",
|
||||
"branding": "Branding",
|
||||
"analytics": "Analytics",
|
||||
"emailSettings": "Email Settings",
|
||||
@@ -433,6 +434,8 @@
|
||||
"expiresOn": "Expires on",
|
||||
"galleryWillExpireOn": "Gallery will expire on {{date}}",
|
||||
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||
"noExpiration": "No Expiration",
|
||||
"noExpirationHelp": "This gallery will remain active until manually archived.",
|
||||
"userUploads": "User Upload Settings",
|
||||
"allowUserUploads": "Allow guests to upload photos",
|
||||
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
|
||||
@@ -452,6 +455,35 @@
|
||||
"enableDevtoolsProtection": "Detect developer tools",
|
||||
"useCanvasRendering": "Canvas rendering (advanced protection)",
|
||||
"protectionInfo": "Protection features help prevent unauthorized downloads but cannot block all methods.",
|
||||
"protectionLevelBasic": "Basic - Right-click blocking only",
|
||||
"protectionLevelStandard": "Standard - Keyboard shortcuts blocked",
|
||||
"protectionLevelEnhanced": "Enhanced - Print screen detection",
|
||||
"protectionLevelMaximum": "Maximum - DevTools detection & canvas rendering",
|
||||
"heroLogoSettings": "Hero Logo Settings",
|
||||
"heroLogoVisible": "Display logo in hero section",
|
||||
"heroLogoSize": "Logo Size",
|
||||
"heroLogoSizeSmall": "Small",
|
||||
"heroLogoSizeMedium": "Medium",
|
||||
"heroLogoSizeLarge": "Large",
|
||||
"heroLogoSizeXLarge": "Extra Large",
|
||||
"heroLogoPosition": "Logo Position",
|
||||
"heroLogoPositionTop": "Top (above title)",
|
||||
"heroLogoPositionCenter": "Center (between title and dates)",
|
||||
"heroLogoPositionBottom": "Bottom (below dates)",
|
||||
"heroLogoInfo": "These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.",
|
||||
"eventCustomLogo": "Custom Event Logo",
|
||||
"eventCustomLogoDescription": "Upload a custom logo for this event. This overrides the global branding logo for this gallery only.",
|
||||
"uploadEventLogo": "Upload Logo",
|
||||
"replaceLogo": "Replace",
|
||||
"removeLogo": "Remove",
|
||||
"eventLogoUploaded": "Event logo uploaded successfully",
|
||||
"eventLogoUploadFailed": "Failed to upload event logo",
|
||||
"eventLogoRemoved": "Event logo removed successfully",
|
||||
"eventLogoRemoveFailed": "Failed to remove event logo",
|
||||
"heroLogoVisibleLabel": "Logo visible",
|
||||
"heroLogoSizeLabel": "Size",
|
||||
"heroLogoPositionLabel": "Position",
|
||||
"heroLogoHidden": "Logo hidden",
|
||||
"heroPhoto": "Hero Photo",
|
||||
"heroPhotoHelp": "Select a featured photo for the hero gallery layout",
|
||||
"selectHeroPhoto": "Select Hero Photo",
|
||||
@@ -577,7 +609,6 @@
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableAnalytics": "Enable analytics tracking",
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||
@@ -749,6 +780,12 @@
|
||||
"requireAdminEmail": "Require admin email",
|
||||
"requireAdminEmailHelp": "Admin email must be provided for new events",
|
||||
"adminEmailWarning": "Required for receiving event notifications",
|
||||
"requireEventDate": "Require event date",
|
||||
"requireEventDateHelp": "Event date must be provided when creating events",
|
||||
"eventDateWarning": "Gallery URLs will use random identifiers instead of dates",
|
||||
"requireExpiration": "Require expiration date",
|
||||
"requireExpirationHelp": "Galleries must have an expiration date",
|
||||
"expirationWarning": "Galleries without expiration will remain active until manually archived",
|
||||
"saveSettings": "Save Event Settings",
|
||||
"noteTitle": "Note",
|
||||
"noteText": "These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields."
|
||||
@@ -897,7 +934,6 @@
|
||||
"faviconHelp": "PNG or ICO format, recommended size: 32x32px",
|
||||
"watermark": "Watermark",
|
||||
"watermarkSettings": "Watermark Settings",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableWatermarks": "Enable Watermarks",
|
||||
"watermarkHelp": "Add your company name as a watermark on downloaded photos",
|
||||
"watermarkLogo": "Watermark Logo",
|
||||
@@ -937,7 +973,8 @@
|
||||
"carousel": "Full-screen slideshow with navigation",
|
||||
"timeline": "Photos organized by date",
|
||||
"hero": "Featured image with grid below",
|
||||
"mosaic": "Artistic layout with mixed sizes"
|
||||
"mosaic": "Artistic layout with mixed sizes",
|
||||
"justified": "Row-based layout preserving aspect ratios"
|
||||
},
|
||||
"layoutSettings": "Layout Settings",
|
||||
"photoSpacing": "Photo Spacing",
|
||||
@@ -965,6 +1002,30 @@
|
||||
"week": "Week",
|
||||
"month": "Month"
|
||||
},
|
||||
"masonryMode": "Layout Mode",
|
||||
"masonryModeOptions": {
|
||||
"columns": "Columns (Pinterest-style)",
|
||||
"rows": "Rows (Google Photos-style)"
|
||||
},
|
||||
"masonryModeHint": "Columns arranges photos vertically, rows fills horizontal lines",
|
||||
"targetRowHeight": "Target Row Height",
|
||||
"targetRowHeightHint": "Height in pixels (150-400). Photos will scale to fit rows.",
|
||||
"lastRowBehavior": "Last Row Alignment",
|
||||
"lastRowOptions": {
|
||||
"left": "Left aligned",
|
||||
"center": "Centered",
|
||||
"justify": "Justified (stretch)"
|
||||
},
|
||||
"showHeroSection": "Show Hero Section",
|
||||
"showHeroSectionHint": "Display a featured hero image above the justified gallery",
|
||||
"heroHeight": "Hero Section Height",
|
||||
"heroHeightOptions": {
|
||||
"small": "Small (40-50%)",
|
||||
"medium": "Medium (50-70%)",
|
||||
"large": "Large (60-80%)"
|
||||
},
|
||||
"heroOverlayOpacity": "Hero Overlay Opacity",
|
||||
"heroOverlayHint": "Darken the hero image to improve text readability",
|
||||
"typographyAndStyle": "Typography & Style",
|
||||
"bodyFont": "Body Font",
|
||||
"headingFont": "Heading Font",
|
||||
@@ -1014,7 +1075,31 @@
|
||||
"customizeTheme": "Customize Theme",
|
||||
"saveTheme": "Save Theme",
|
||||
"previewLayout": "Preview Layout",
|
||||
"livePreview": "Live Preview"
|
||||
"livePreview": "Live Preview",
|
||||
"whiteLabel": "White Label",
|
||||
"hidePoweredBy": "Hide \"Powered by PicPeak\" branding",
|
||||
"hidePoweredByHelp": "Remove the PicPeak attribution from gallery footers for a fully white-labeled experience",
|
||||
"logoCustomization": "Logo Customization",
|
||||
"changeLogo": "Change Logo",
|
||||
"logoSizeSmall": "Small (32px)",
|
||||
"logoSizeMedium": "Medium (48px)",
|
||||
"logoSizeLarge": "Large (64px)",
|
||||
"logoSizeXLarge": "Extra Large (96px)",
|
||||
"logoSizeCustom": "Custom",
|
||||
"logoMaxHeight": "Maximum Height (pixels)",
|
||||
"logoMaxHeightHelp": "Set a custom maximum height for the logo (20-200 pixels)",
|
||||
"logoPosition": "Logo Position in Header",
|
||||
"positionLeft": "Left",
|
||||
"positionCenter": "Center",
|
||||
"positionRight": "Right",
|
||||
"logoDisplayMode": "Display Mode",
|
||||
"logoOnly": "Logo Only",
|
||||
"textOnly": "Company Name Only",
|
||||
"logoAndText": "Logo and Company Name",
|
||||
"showLogoInHeader": "Show logo in gallery header",
|
||||
"showLogoInHeaderHelp": "Display the logo in the main header bar",
|
||||
"showLogoInHero": "Show logo in hero section",
|
||||
"showLogoInHeroHelp": "Display the logo in hero sections (for non-grid layouts)"
|
||||
},
|
||||
"admin": {
|
||||
"title": "Admin Panel",
|
||||
@@ -1037,6 +1122,20 @@
|
||||
"error": "Error",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"updates": {
|
||||
"available": "Update Available",
|
||||
"newVersion": "Version {{version}} is available",
|
||||
"currentVersion": "Current: {{version}}",
|
||||
"channel": "Channel: {{channel}}",
|
||||
"channelStable": "Stable",
|
||||
"channelBeta": "Beta",
|
||||
"beta": "BETA",
|
||||
"viewReleaseNotes": "View Release Notes",
|
||||
"updateAvailableShort": "v{{version}} available",
|
||||
"checkForUpdates": "Check for Updates",
|
||||
"upToDate": "You're up to date",
|
||||
"lastChecked": "Last checked: {{time}}"
|
||||
},
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"noNotifications": "No new notifications",
|
||||
@@ -1234,6 +1333,10 @@
|
||||
"successMessage": "Your account has been created successfully. You can now log in with your credentials.",
|
||||
"redirecting": "Redirecting to login in {{seconds}}...",
|
||||
"goToLogin": "Go to Login",
|
||||
"contactAdminMessage": "Please contact your administrator to request a new invitation.",
|
||||
"passwordsMatch": "Passwords match",
|
||||
"alreadyHaveAccount": "Already have an account?",
|
||||
"signIn": "Sign in",
|
||||
"errors": {
|
||||
"usernameRequired": "Username is required",
|
||||
"usernameTooShort": "Username must be at least 3 characters",
|
||||
@@ -1391,6 +1494,54 @@
|
||||
"datenschutz": "Privacy Policy",
|
||||
"pageUpdated": "Page updated successfully"
|
||||
},
|
||||
"eventTypes": {
|
||||
"title": "Event Types",
|
||||
"subtitle": "Customize event types and their default themes",
|
||||
"loading": "Loading event types...",
|
||||
"loadError": "Failed to load event types",
|
||||
"createNew": "New Event Type",
|
||||
"edit": "Edit Event Type",
|
||||
"created": "Event type created successfully",
|
||||
"updated": "Event type updated successfully",
|
||||
"deleted": "Event type deleted successfully",
|
||||
"createError": "Failed to create event type",
|
||||
"updateError": "Failed to update event type",
|
||||
"deleteError": "Failed to delete event type",
|
||||
"searchPlaceholder": "Search event types...",
|
||||
"showInactive": "Show inactive",
|
||||
"noResults": "No event types found",
|
||||
"empty": "No event types yet",
|
||||
"system": "System",
|
||||
"table": {
|
||||
"type": "Type",
|
||||
"slugPrefix": "URL Prefix",
|
||||
"theme": "Default Theme",
|
||||
"status": "Status",
|
||||
"actions": "Actions"
|
||||
},
|
||||
"form": {
|
||||
"name": "Display Name",
|
||||
"namePlaceholder": "e.g., Family Shoot",
|
||||
"slugPrefix": "URL Prefix",
|
||||
"slugPrefixPlaceholder": "e.g., family",
|
||||
"slugPreview": "Example URL:",
|
||||
"emoji": "Icon",
|
||||
"themePreset": "Default Theme",
|
||||
"isActive": "Active (visible in event creation)"
|
||||
},
|
||||
"validation": {
|
||||
"slugFormat": "Only letters, numbers, and hyphens allowed"
|
||||
},
|
||||
"slugInfo": {
|
||||
"title": "URL Prefix Info:",
|
||||
"description": "The URL prefix is used to generate gallery URLs. For example, an event type with prefix \"family\" will create URLs like: family-smith-family-2025-01-22"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"title": "Delete Event Type",
|
||||
"message": "Are you sure you want to delete",
|
||||
"warning": "This action cannot be undone. Make sure no events are using this type."
|
||||
}
|
||||
},
|
||||
"backup": {
|
||||
"external": {
|
||||
"warning": {
|
||||
|
||||
@@ -187,8 +187,8 @@ export const GalleryPage: React.FC = () => {
|
||||
}
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
// Calculate days until expiration (null if no expiration set)
|
||||
const daysUntilExpiration = galleryInfo?.expires_at
|
||||
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
|
||||
: null;
|
||||
|
||||
@@ -394,9 +394,11 @@ export const GalleryPage: React.FC = () => {
|
||||
<CardContent className="text-center py-12">
|
||||
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
|
||||
<p className="text-neutral-600 mb-4">
|
||||
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
|
||||
</p>
|
||||
{galleryInfo.expires_at && (
|
||||
<p className="text-neutral-600 mb-4">
|
||||
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('gallery.contactOrganizer')}
|
||||
</p>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Card, Loading } from '../../components/common';
|
||||
import { UpdateNotification } from '../../components/admin/UpdateNotification';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
@@ -72,6 +73,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
// Calculate expiring events
|
||||
const activeEvents = eventsData?.events.filter(e => e.is_active && !e.is_archived) || [];
|
||||
const expiringEvents = activeEvents.filter(e => {
|
||||
if (!e.expires_at) return false;
|
||||
const days = differenceInDays(parseISO(e.expires_at), new Date());
|
||||
return days <= 7 && days > 0;
|
||||
});
|
||||
@@ -140,6 +142,9 @@ export const AdminDashboard: React.FC = () => {
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Update Notification */}
|
||||
<UpdateNotification />
|
||||
|
||||
{/* Page Header */}
|
||||
<div className="flex justify-between items-center mb-8">
|
||||
<div>
|
||||
@@ -200,9 +205,11 @@ export const AdminDashboard: React.FC = () => {
|
||||
>
|
||||
<div>
|
||||
<h3 className="font-medium text-neutral-900">{event.event_name}</h3>
|
||||
<p className="text-sm text-neutral-600">
|
||||
{format(parseISO(event.event_date), 'PP')}
|
||||
</p>
|
||||
{event.event_date && (
|
||||
<p className="text-sm text-neutral-600">
|
||||
{format(parseISO(event.event_date), 'PP')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-medium text-orange-600">
|
||||
|
||||
@@ -15,7 +15,7 @@ export const BrandingPage: React.FC = () => {
|
||||
const [brandingSettings, setBrandingSettings] = useState<BrandingSettings>({
|
||||
company_name: '',
|
||||
company_tagline: '',
|
||||
footer_text: '© 2024 Your Company. All rights reserved.',
|
||||
footer_text: '',
|
||||
support_email: '',
|
||||
watermark_enabled: false,
|
||||
watermark_position: 'bottom-right',
|
||||
@@ -307,7 +307,7 @@ export const BrandingPage: React.FC = () => {
|
||||
onChange={(e) => handleBrandingChange('footer_text', e.target.value)}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500"
|
||||
rows={2}
|
||||
placeholder="© 2024 Your Company. All rights reserved."
|
||||
placeholder={`© ${new Date().getFullYear()} Your Company. All rights reserved.`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
||||
import { eventTypesService } from '../../services/eventTypes.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { Code } from 'lucide-react';
|
||||
@@ -57,18 +58,12 @@ interface FormData {
|
||||
};
|
||||
}
|
||||
|
||||
const EVENT_TYPE_PRESETS: Record<string, string> = {
|
||||
wedding: 'elegantWedding',
|
||||
birthday: 'birthdayFun',
|
||||
corporate: 'corporateTimeline',
|
||||
other: 'default'
|
||||
};
|
||||
|
||||
const EVENT_TYPES = [
|
||||
{ value: 'wedding', labelKey: 'events.types.wedding', emoji: '💒' },
|
||||
{ value: 'birthday', labelKey: 'events.types.birthday', emoji: '🎂' },
|
||||
{ value: 'corporate', labelKey: 'events.types.corporate', emoji: '🏢' },
|
||||
{ value: 'other', labelKey: 'events.types.other', emoji: '📸' },
|
||||
// Fallback event types (used when API is unavailable)
|
||||
const FALLBACK_EVENT_TYPES = [
|
||||
{ value: 'wedding', name: 'Wedding', emoji: '💒', theme_preset: 'elegantWedding' },
|
||||
{ value: 'birthday', name: 'Birthday', emoji: '🎂', theme_preset: 'birthdayFun' },
|
||||
{ value: 'corporate', name: 'Corporate', emoji: '🏢', theme_preset: 'corporateTimeline' },
|
||||
{ value: 'other', name: 'Other', emoji: '📸', theme_preset: 'default' },
|
||||
];
|
||||
|
||||
export const CreateEventPage: React.FC = () => {
|
||||
@@ -132,6 +127,22 @@ export const CreateEventPage: React.FC = () => {
|
||||
queryFn: () => cssTemplatesService.getEnabledTemplates()
|
||||
});
|
||||
|
||||
// Fetch event types
|
||||
const { data: eventTypes } = useQuery({
|
||||
queryKey: ['event-types', 'active'],
|
||||
queryFn: () => eventTypesService.getActiveEventTypes()
|
||||
});
|
||||
|
||||
// Compute event types to use (API data or fallback)
|
||||
const availableEventTypes = eventTypes?.length
|
||||
? eventTypes.map(et => ({
|
||||
value: et.slug_prefix,
|
||||
name: et.name,
|
||||
emoji: et.emoji,
|
||||
theme_preset: et.theme_preset
|
||||
}))
|
||||
: FALLBACK_EVENT_TYPES;
|
||||
|
||||
// Fetch default settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
@@ -148,6 +159,8 @@ export const CreateEventPage: React.FC = () => {
|
||||
const requireCustomerName = publicSettings?.event_require_customer_name !== false;
|
||||
const requireCustomerEmail = publicSettings?.event_require_customer_email !== false;
|
||||
const requireAdminEmail = publicSettings?.event_require_admin_email !== false;
|
||||
const requireEventDate = publicSettings?.event_require_event_date !== false;
|
||||
const requireExpiration = publicSettings?.event_require_expiration !== false;
|
||||
|
||||
// Update default expiration days when settings are loaded
|
||||
useEffect(() => {
|
||||
@@ -161,7 +174,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
// Update theme when event type changes
|
||||
useEffect(() => {
|
||||
const recommendedPreset = EVENT_TYPE_PRESETS[formData.event_type];
|
||||
// Find the selected event type's theme preset
|
||||
const selectedType = availableEventTypes.find(t => t.value === formData.event_type);
|
||||
const recommendedPreset = selectedType?.theme_preset || 'default';
|
||||
|
||||
if (recommendedPreset && GALLERY_THEME_PRESETS[recommendedPreset]) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
@@ -169,7 +185,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
theme_config: GALLERY_THEME_PRESETS[recommendedPreset].config
|
||||
}));
|
||||
}
|
||||
}, [formData.event_type]);
|
||||
}, [formData.event_type, availableEventTypes]);
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: eventsService.createEvent,
|
||||
@@ -201,7 +217,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.event_name = t('validation.eventNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.event_date) {
|
||||
if (requireEventDate && !formData.event_date) {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
@@ -247,7 +263,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
|
||||
if (formData.expires_in_days < 1 || formData.expires_in_days > 365) {
|
||||
if (requireExpiration && (formData.expires_in_days < 1 || formData.expires_in_days > 365)) {
|
||||
newErrors.expires_in_days = t('validation.expirationRange');
|
||||
}
|
||||
|
||||
@@ -267,7 +283,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
const payload = {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
event_date: formData.event_date || undefined,
|
||||
customer_name: formData.customer_name,
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
@@ -275,7 +291,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
welcome_message: formData.welcome_message || '',
|
||||
color_theme: JSON.stringify(formData.theme_config),
|
||||
expiration_days: formData.expires_in_days,
|
||||
expiration_days: requireExpiration ? formData.expires_in_days : undefined,
|
||||
allow_user_uploads: formData.allow_user_uploads,
|
||||
upload_category_id: formData.upload_category_id,
|
||||
css_template_id: formData.css_template_id,
|
||||
@@ -364,7 +380,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
{t('events.eventType')}
|
||||
</label>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
{EVENT_TYPES.map((type) => (
|
||||
{availableEventTypes.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
@@ -376,7 +392,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
}`}
|
||||
>
|
||||
<div className="text-2xl mb-1">{type.emoji}</div>
|
||||
<div className="text-sm font-medium">{t(type.labelKey)}</div>
|
||||
<div className="text-sm font-medium">{type.name}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
@@ -394,7 +410,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
|
||||
<Input
|
||||
type="date"
|
||||
label={t('events.eventDate')}
|
||||
label={requireEventDate ? t('events.eventDate') : `${t('events.eventDate')} (${t('common.optional')})`}
|
||||
value={formData.event_date}
|
||||
onChange={handleInputChange('event_date')}
|
||||
error={errors.event_date}
|
||||
@@ -666,30 +682,42 @@ export const CreateEventPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.galleryExpiration')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.expires_in_days}
|
||||
onChange={handleInputChange('expires_in_days')}
|
||||
error={errors.expires_in_days}
|
||||
min={1}
|
||||
max={365}
|
||||
leftIcon={<Clock className="w-5 h-5" />}
|
||||
/>
|
||||
{requireExpiration ? (
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.galleryExpiration')}
|
||||
</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-32">
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.expires_in_days}
|
||||
onChange={handleInputChange('expires_in_days')}
|
||||
error={errors.expires_in_days}
|
||||
min={1}
|
||||
max={365}
|
||||
leftIcon={<Clock className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
|
||||
</div>
|
||||
<span className="text-sm text-neutral-600">{t('events.daysAfterEvent')}</span>
|
||||
{formData.event_date && (
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{formData.event_date && (
|
||||
<p className="mt-2 text-sm text-neutral-500">
|
||||
{t('events.expiresOn')}: {format(addDays(new Date(formData.event_date), formData.expires_in_days))}
|
||||
) : (
|
||||
<div className="rounded-md border border-blue-200 bg-blue-50 p-3">
|
||||
<div className="flex items-center gap-2 text-blue-800">
|
||||
<Clock className="w-4 h-4" />
|
||||
<span className="text-sm font-medium">{t('events.noExpiration', 'No Expiration')}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-blue-700">
|
||||
{t('events.noExpirationHelp', 'This gallery will remain active until manually archived.')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* User Upload Settings */}
|
||||
<div className="pt-4 border-t border-neutral-200">
|
||||
|
||||
@@ -25,14 +25,16 @@ import {
|
||||
Shield,
|
||||
Monitor,
|
||||
Droplets,
|
||||
MousePointer
|
||||
MousePointer,
|
||||
Layout,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays, isValid } from 'date-fns';
|
||||
|
||||
// Helper to safely parse dates that might be strings, Date objects, or timestamps
|
||||
const safeParseDate = (dateValue: unknown): Date => {
|
||||
const safeParseDate = (dateValue: unknown): Date | null => {
|
||||
if (!dateValue) {
|
||||
return new Date();
|
||||
return null;
|
||||
}
|
||||
if (dateValue instanceof Date) {
|
||||
return dateValue;
|
||||
@@ -44,7 +46,7 @@ const safeParseDate = (dateValue: unknown): Date => {
|
||||
const parsed = parseISO(dateValue);
|
||||
return isValid(parsed) ? parsed : new Date(dateValue);
|
||||
}
|
||||
return new Date();
|
||||
return null;
|
||||
};
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
@@ -53,6 +55,8 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { api } from '../../config/api';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { externalMediaService } from '../../services/externalMedia.service';
|
||||
@@ -160,6 +164,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
watermark_downloads: boolean;
|
||||
enable_devtools_protection: boolean;
|
||||
use_canvas_rendering: boolean;
|
||||
// Hero logo settings
|
||||
hero_logo_visible: boolean;
|
||||
hero_logo_size: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position: 'top' | 'center' | 'bottom';
|
||||
};
|
||||
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
@@ -184,6 +192,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
watermark_downloads: false,
|
||||
enable_devtools_protection: true,
|
||||
use_canvas_rendering: false,
|
||||
// Hero logo settings
|
||||
hero_logo_visible: true,
|
||||
hero_logo_size: 'medium',
|
||||
hero_logo_position: 'top',
|
||||
});
|
||||
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
|
||||
feedback_enabled: false,
|
||||
@@ -208,6 +220,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
|
||||
@@ -380,16 +393,17 @@ export const EventDetailsPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const daysUntilExpiration = differenceInDays(safeParseDate(event.expires_at), new Date());
|
||||
const isExpired = daysUntilExpiration <= 0;
|
||||
const isExpiring = daysUntilExpiration > 0 && daysUntilExpiration <= 7;
|
||||
const expiresAtDate = safeParseDate(event.expires_at);
|
||||
const daysUntilExpiration = expiresAtDate ? differenceInDays(expiresAtDate, new Date()) : null;
|
||||
const isExpired = daysUntilExpiration !== null && daysUntilExpiration <= 0;
|
||||
const isExpiring = daysUntilExpiration !== null && daysUntilExpiration > 0 && daysUntilExpiration <= 7;
|
||||
|
||||
const handleStartEdit = () => {
|
||||
setEditForm({
|
||||
welcome_message: event.welcome_message || '',
|
||||
color_theme: event.color_theme || '',
|
||||
css_template_id: event.css_template_id || null,
|
||||
expires_at: format(safeParseDate(event.expires_at), 'yyyy-MM-dd'),
|
||||
expires_at: expiresAtDate ? format(expiresAtDate, 'yyyy-MM-dd') : '',
|
||||
allow_user_uploads: event.allow_user_uploads || false,
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
@@ -406,6 +420,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
watermark_downloads: event.watermark_downloads ?? false,
|
||||
enable_devtools_protection: event.enable_devtools_protection ?? true,
|
||||
use_canvas_rendering: event.use_canvas_rendering ?? false,
|
||||
// Load hero logo settings from event
|
||||
hero_logo_visible: event.hero_logo_visible ?? true,
|
||||
hero_logo_size: event.hero_logo_size || 'medium',
|
||||
hero_logo_position: event.hero_logo_position || 'top',
|
||||
});
|
||||
|
||||
setShowNewPassword(false);
|
||||
@@ -446,6 +464,38 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleEventLogoUpload = async (file: File) => {
|
||||
if (!id) return;
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
const response = await api.post(`/admin/events/${id}/logo`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['event', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('events.eventLogoUploadFailed', 'Failed to upload event logo'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventLogoRemove = async () => {
|
||||
if (!id) return;
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
await api.delete(`/admin/events/${id}/logo`);
|
||||
toast.success(t('events.eventLogoRemoved', 'Event logo removed successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['event', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('events.eventLogoRemoveFailed', 'Failed to remove event logo'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
// Prepare color_theme - if we have a custom theme, serialize it
|
||||
let themeToSave = editForm.color_theme;
|
||||
@@ -496,6 +546,10 @@ export const EventDetailsPage: React.FC = () => {
|
||||
watermark_downloads: editForm.watermark_downloads,
|
||||
enable_devtools_protection: editForm.enable_devtools_protection,
|
||||
use_canvas_rendering: editForm.use_canvas_rendering,
|
||||
// Hero logo settings
|
||||
hero_logo_visible: editForm.hero_logo_visible,
|
||||
hero_logo_size: editForm.hero_logo_size,
|
||||
hero_logo_position: editForm.hero_logo_position,
|
||||
};
|
||||
|
||||
// Only include fields that have defined values
|
||||
@@ -599,10 +653,12 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900">{event.event_name}</h1>
|
||||
<div className="flex items-center gap-4 mt-2 text-sm text-neutral-600">
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(safeParseDate(event.event_date), 'PPP')}
|
||||
</span>
|
||||
{event.event_date && (
|
||||
<span className="flex items-center">
|
||||
<Calendar className="w-4 h-4 mr-1" />
|
||||
{format(safeParseDate(event.event_date)!, 'PPP')}
|
||||
</span>
|
||||
)}
|
||||
<span className="capitalize">{event.event_type}</span>
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium ${
|
||||
@@ -1062,6 +1118,134 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hero Logo Settings */}
|
||||
<div className="mt-4 pt-4 border-t border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
||||
<Layout className="w-4 h-4 text-primary-600" />
|
||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-3">
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editForm.hero_logo_visible}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_visible: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<Image className="w-4 h-4 ml-2 mr-1 text-neutral-500" />
|
||||
<span className="text-sm text-neutral-700">{t('events.heroLogoVisible', 'Display logo in hero section')}</span>
|
||||
</label>
|
||||
|
||||
{editForm.hero_logo_visible && (
|
||||
<>
|
||||
<div className="ml-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroLogoSize', 'Logo Size')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.hero_logo_size}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_size: e.target.value as 'small' | 'medium' | 'large' | 'xlarge' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||
>
|
||||
<option value="small">{t('events.heroLogoSizeSmall', 'Small')}</option>
|
||||
<option value="medium">{t('events.heroLogoSizeMedium', 'Medium')}</option>
|
||||
<option value="large">{t('events.heroLogoSizeLarge', 'Large')}</option>
|
||||
<option value="xlarge">{t('events.heroLogoSizeXLarge', 'Extra Large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="ml-6">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.heroLogoPosition', 'Logo Position')}
|
||||
</label>
|
||||
<select
|
||||
value={editForm.hero_logo_position}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, hero_logo_position: e.target.value as 'top' | 'center' | 'bottom' }))}
|
||||
className="w-full sm:w-48 px-3 py-2 border border-neutral-300 rounded-md shadow-sm focus:ring-primary-500 focus:border-primary-500 text-sm"
|
||||
>
|
||||
<option value="top">{t('events.heroLogoPositionTop', 'Top (above title)')}</option>
|
||||
<option value="center">{t('events.heroLogoPositionCenter', 'Center (between title and dates)')}</option>
|
||||
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Event Logo Upload */}
|
||||
<div className="ml-6 mt-3 pt-3 border-t border-neutral-100">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
{t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')}
|
||||
</p>
|
||||
|
||||
{event.hero_logo_url ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 border border-neutral-200 rounded-md flex items-center justify-center bg-neutral-50 overflow-hidden">
|
||||
<img
|
||||
src={buildResourceUrl(event.hero_logo_url)}
|
||||
alt={t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="cursor-pointer inline-flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700">
|
||||
<Upload className="w-3 h-3" />
|
||||
{t('events.replaceLogo', 'Replace')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleEventLogoUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEventLogoRemove}
|
||||
disabled={logoUploading}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t('events.removeLogo', 'Remove')}
|
||||
</button>
|
||||
</div>
|
||||
{logoUploading && <Loading size="sm" />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-neutral-300 rounded-md hover:bg-neutral-50 ${logoUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{t('events.uploadEventLogo', 'Upload Logo')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleEventLogoUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{logoUploading && <Loading size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<p className="text-xs text-neutral-500 mt-2">
|
||||
{t('events.heroLogoInfo', 'These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<dl className="space-y-4">
|
||||
@@ -1104,18 +1288,24 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.created')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{format(safeParseDate(event.created_at), 'PP')}
|
||||
{event.created_at && format(safeParseDate(event.created_at)!, 'PP')}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.expires')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{format(safeParseDate(event.expires_at), 'PP')}
|
||||
{!event.is_archived && daysUntilExpiration > 0 && (
|
||||
<span className="text-neutral-500 ml-1">
|
||||
{t('events.daysLeft', { count: daysUntilExpiration })}
|
||||
</span>
|
||||
{event.expires_at ? (
|
||||
<>
|
||||
{format(safeParseDate(event.expires_at)!, 'PP')}
|
||||
{!event.is_archived && daysUntilExpiration !== null && daysUntilExpiration > 0 && (
|
||||
<span className="text-neutral-500 ml-1">
|
||||
{t('events.daysLeft', { count: daysUntilExpiration })}
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-neutral-500">{t('events.neverExpires', 'Never')}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
@@ -1197,6 +1387,37 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
{/* Hero Logo Settings Display */}
|
||||
<div className="pt-3 mt-3 border-t border-neutral-200">
|
||||
<dt className="text-sm font-medium text-neutral-500 flex items-center gap-2">
|
||||
<Layout className="w-4 h-4" />
|
||||
{t('events.heroLogoSettings', 'Hero Logo Settings')}
|
||||
</dt>
|
||||
<dd className="mt-2 text-sm text-neutral-900">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{event.hero_logo_visible !== false ? (
|
||||
<>
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-green-100 text-green-700 rounded">
|
||||
<Image className="w-3 h-3 mr-1" />
|
||||
{t('events.heroLogoVisibleLabel', 'Logo visible')}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||
{t('events.heroLogoSizeLabel', 'Size')}: {event.hero_logo_size || 'medium'}
|
||||
</span>
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||
{t('events.heroLogoPositionLabel', 'Position')}: {event.hero_logo_position || 'top'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-neutral-100 text-neutral-700 rounded">
|
||||
<Image className="w-3 h-3 mr-1" />
|
||||
{t('events.heroLogoHidden', 'Logo hidden')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
@@ -1409,7 +1630,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-500">{t('events.archivedOn')}</p>
|
||||
<p className="text-sm text-neutral-900">
|
||||
{event.archived_at && format(safeParseDate(event.archived_at), 'PPp')}
|
||||
{event.archived_at && format(safeParseDate(event.archived_at)!, 'PPp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Search,
|
||||
GripVertical,
|
||||
Eye,
|
||||
EyeOff,
|
||||
X,
|
||||
AlertTriangle,
|
||||
Tags
|
||||
} from 'lucide-react';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { eventTypesService, EventType, CreateEventTypeData, UpdateEventTypeData } from '../../services/eventTypes.service';
|
||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
|
||||
// Common emoji options for event types
|
||||
const EMOJI_OPTIONS = [
|
||||
'📷', '💒', '🎂', '🏢', '🎉', '🎊', '🎈', '👨👩👧', '💍', '🌸',
|
||||
'🎄', '🎃', '🐣', '🎓', '🏆', '🎸', '🎭', '🍽️', '🏖️', '✈️'
|
||||
];
|
||||
|
||||
export const EventTypesPage: React.FC = () => {
|
||||
const { t } = useTranslation();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// State
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
const [showInactive, setShowInactive] = useState(false);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingType, setEditingType] = useState<EventType | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<EventType | null>(null);
|
||||
|
||||
// Query
|
||||
const { data: eventTypes, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['admin-event-types', showInactive],
|
||||
queryFn: () => eventTypesService.getEventTypes(showInactive)
|
||||
});
|
||||
|
||||
// Mutations
|
||||
const createMutation = useMutation({
|
||||
mutationFn: eventTypesService.createEventType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setShowCreateModal(false);
|
||||
toast.success(t('eventTypes.created', 'Event type created successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.createError', 'Failed to create event type'));
|
||||
}
|
||||
});
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: ({ id, data }: { id: number; data: UpdateEventTypeData }) =>
|
||||
eventTypesService.updateEventType(id, data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setEditingType(null);
|
||||
toast.success(t('eventTypes.updated', 'Event type updated successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.updateError', 'Failed to update event type'));
|
||||
}
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: eventTypesService.deleteEventType,
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-event-types'] });
|
||||
setDeleteConfirm(null);
|
||||
toast.success(t('eventTypes.deleted', 'Event type deleted successfully'));
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast.error(error.response?.data?.error || t('eventTypes.deleteError', 'Failed to delete event type'));
|
||||
}
|
||||
});
|
||||
|
||||
// Filter event types
|
||||
const filteredTypes = useMemo(() => {
|
||||
if (!eventTypes) return [];
|
||||
if (!searchTerm) return eventTypes;
|
||||
|
||||
const term = searchTerm.toLowerCase();
|
||||
return eventTypes.filter(type =>
|
||||
type.name.toLowerCase().includes(term) ||
|
||||
type.slug_prefix.toLowerCase().includes(term)
|
||||
);
|
||||
}, [eventTypes, searchTerm]);
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-[400px]">
|
||||
<Loading size="lg" text={t('eventTypes.loading', 'Loading event types...')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (error) {
|
||||
return (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-red-600">{t('eventTypes.loadError', 'Failed to load event types')}</p>
|
||||
<Button onClick={() => refetch()} className="mt-4">
|
||||
{t('common.tryAgain', 'Try Again')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header */}
|
||||
<div className="mb-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 flex items-center gap-2">
|
||||
<Tags className="w-6 h-6" />
|
||||
{t('eventTypes.title', 'Event Types')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mt-1">
|
||||
{t('eventTypes.subtitle', 'Customize event types and their default themes')}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
leftIcon={<Plus className="w-4 h-4" />}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
{t('eventTypes.createNew', 'New Event Type')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<Card className="mb-6">
|
||||
<div className="p-4 flex flex-col sm:flex-row gap-4">
|
||||
<div className="flex-1">
|
||||
<Input
|
||||
placeholder={t('eventTypes.searchPlaceholder', 'Search event types...')}
|
||||
value={searchTerm}
|
||||
onChange={(e) => setSearchTerm(e.target.value)}
|
||||
leftIcon={<Search className="w-4 h-4" />}
|
||||
/>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showInactive}
|
||||
onChange={(e) => setShowInactive(e.target.checked)}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('eventTypes.showInactive', 'Show inactive')}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Event Types List */}
|
||||
<Card>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-neutral-50 border-b border-neutral-200">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase w-10">
|
||||
{/* Drag handle column */}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.type', 'Type')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.slugPrefix', 'URL Prefix')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.theme', 'Default Theme')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.status', 'Status')}
|
||||
</th>
|
||||
<th className="px-4 py-3 text-right text-xs font-medium text-neutral-500 uppercase">
|
||||
{t('eventTypes.table.actions', 'Actions')}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-neutral-200">
|
||||
{filteredTypes.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={6} className="px-4 py-8 text-center text-neutral-500">
|
||||
{searchTerm
|
||||
? t('eventTypes.noResults', 'No event types found')
|
||||
: t('eventTypes.empty', 'No event types yet')}
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
filteredTypes.map((type) => (
|
||||
<tr key={type.id} className={`hover:bg-neutral-50 ${!type.is_active ? 'opacity-60' : ''}`}>
|
||||
<td className="px-4 py-4">
|
||||
<GripVertical className="w-4 h-4 text-neutral-400 cursor-grab" />
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-2xl">{type.emoji}</span>
|
||||
<div>
|
||||
<div className="font-medium text-neutral-900">{type.name}</div>
|
||||
{type.is_system && (
|
||||
<span className="text-xs text-neutral-500">
|
||||
{t('eventTypes.system', 'System')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<code className="px-2 py-1 bg-neutral-100 rounded text-sm">
|
||||
{type.slug_prefix}
|
||||
</code>
|
||||
</td>
|
||||
<td className="px-4 py-4 text-sm text-neutral-600">
|
||||
{GALLERY_THEME_PRESETS[type.theme_preset]?.name || type.theme_preset || '-'}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
{type.is_active ? (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-green-100 text-green-700 rounded-full text-xs">
|
||||
<Eye className="w-3 h-3" />
|
||||
{t('common.active', 'Active')}
|
||||
</span>
|
||||
) : (
|
||||
<span className="inline-flex items-center gap-1 px-2 py-1 bg-neutral-100 text-neutral-600 rounded-full text-xs">
|
||||
<EyeOff className="w-3 h-3" />
|
||||
{t('common.inactive', 'Inactive')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setEditingType(type)}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg text-neutral-600 hover:text-primary-600"
|
||||
title={t('common.edit', 'Edit')}
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
{!type.is_system && (
|
||||
<button
|
||||
onClick={() => setDeleteConfirm(type)}
|
||||
className="p-2 hover:bg-red-50 rounded-lg text-neutral-600 hover:text-red-600"
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Slug Preview Info */}
|
||||
<div className="mt-4 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||
<p className="text-sm text-blue-800">
|
||||
<strong>{t('eventTypes.slugInfo.title', 'URL Prefix Info:')}</strong>{' '}
|
||||
{t('eventTypes.slugInfo.description', 'The URL prefix is used to generate gallery URLs. For example, an event type with prefix "family" will create URLs like: family-smith-family-2025-01-22')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Create Modal */}
|
||||
{showCreateModal && (
|
||||
<EventTypeModal
|
||||
onClose={() => setShowCreateModal(false)}
|
||||
onSubmit={(data) => createMutation.mutate(data)}
|
||||
isLoading={createMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Edit Modal */}
|
||||
{editingType && (
|
||||
<EventTypeModal
|
||||
eventType={editingType}
|
||||
onClose={() => setEditingType(null)}
|
||||
onSubmit={(data) => updateMutation.mutate({ id: editingType.id, data })}
|
||||
isLoading={updateMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Delete Confirmation */}
|
||||
{deleteConfirm && (
|
||||
<DeleteConfirmModal
|
||||
eventType={deleteConfirm}
|
||||
onClose={() => setDeleteConfirm(null)}
|
||||
onConfirm={() => deleteMutation.mutate(deleteConfirm.id)}
|
||||
isLoading={deleteMutation.isPending}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Event Type Modal Component
|
||||
interface EventTypeModalProps {
|
||||
eventType?: EventType;
|
||||
onClose: () => void;
|
||||
onSubmit: (data: CreateEventTypeData | UpdateEventTypeData) => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const EventTypeModal: React.FC<EventTypeModalProps> = ({
|
||||
eventType,
|
||||
onClose,
|
||||
onSubmit,
|
||||
isLoading
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
const isEditing = !!eventType;
|
||||
|
||||
const [form, setForm] = useState<CreateEventTypeData>({
|
||||
name: eventType?.name || '',
|
||||
slug_prefix: eventType?.slug_prefix || '',
|
||||
emoji: eventType?.emoji || '📷',
|
||||
theme_preset: eventType?.theme_preset || 'default'
|
||||
});
|
||||
|
||||
const [errors, setErrors] = useState<Partial<Record<keyof CreateEventTypeData, string>>>({});
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const newErrors: typeof errors = {};
|
||||
|
||||
if (!form.name.trim()) {
|
||||
newErrors.name = t('validation.required', 'This field is required');
|
||||
}
|
||||
|
||||
if (!form.slug_prefix.trim()) {
|
||||
newErrors.slug_prefix = t('validation.required', 'This field is required');
|
||||
} else if (!/^[a-z0-9-]+$/i.test(form.slug_prefix)) {
|
||||
newErrors.slug_prefix = t('eventTypes.validation.slugFormat', 'Only letters, numbers, and hyphens allowed');
|
||||
}
|
||||
|
||||
if (Object.keys(newErrors).length > 0) {
|
||||
setErrors(newErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
// For editing, only send changed fields (plus is_active if toggling)
|
||||
if (isEditing) {
|
||||
const updates: UpdateEventTypeData = {};
|
||||
if (form.name !== eventType?.name) updates.name = form.name;
|
||||
if (form.slug_prefix !== eventType?.slug_prefix) updates.slug_prefix = form.slug_prefix;
|
||||
if (form.emoji !== eventType?.emoji) updates.emoji = form.emoji;
|
||||
if (form.theme_preset !== eventType?.theme_preset) updates.theme_preset = form.theme_preset;
|
||||
onSubmit(updates);
|
||||
} else {
|
||||
onSubmit(form);
|
||||
}
|
||||
};
|
||||
|
||||
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-lg">
|
||||
<div className="p-6">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{isEditing
|
||||
? t('eventTypes.edit', 'Edit Event Type')
|
||||
: t('eventTypes.createNew', 'New Event Type')}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-neutral-100 rounded-lg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-500" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4">
|
||||
{/* Name */}
|
||||
<Input
|
||||
label={t('eventTypes.form.name', 'Display Name')}
|
||||
placeholder={t('eventTypes.form.namePlaceholder', 'e.g., Family Shoot')}
|
||||
value={form.name}
|
||||
onChange={(e) => {
|
||||
setForm({ ...form, name: e.target.value });
|
||||
setErrors({ ...errors, name: undefined });
|
||||
}}
|
||||
error={errors.name}
|
||||
/>
|
||||
|
||||
{/* Slug Prefix */}
|
||||
<div>
|
||||
<Input
|
||||
label={t('eventTypes.form.slugPrefix', 'URL Prefix')}
|
||||
placeholder={t('eventTypes.form.slugPrefixPlaceholder', 'e.g., family')}
|
||||
value={form.slug_prefix}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
setForm({ ...form, slug_prefix: value });
|
||||
setErrors({ ...errors, slug_prefix: undefined });
|
||||
}}
|
||||
error={errors.slug_prefix}
|
||||
/>
|
||||
{form.slug_prefix && (
|
||||
<p className="mt-1 text-xs text-neutral-500">
|
||||
{t('eventTypes.form.slugPreview', 'Example URL:')}{' '}
|
||||
<code className="bg-neutral-100 px-1 rounded">
|
||||
{form.slug_prefix}-event-name-2025-01-22
|
||||
</code>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Emoji */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('eventTypes.form.emoji', 'Icon')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{EMOJI_OPTIONS.map((emoji) => (
|
||||
<button
|
||||
key={emoji}
|
||||
type="button"
|
||||
onClick={() => setForm({ ...form, emoji })}
|
||||
className={`p-2 text-xl rounded-lg border-2 transition-all ${
|
||||
form.emoji === emoji
|
||||
? 'border-primary-600 bg-primary-50'
|
||||
: 'border-neutral-200 hover:border-neutral-300'
|
||||
}`}
|
||||
>
|
||||
{emoji}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Theme Preset */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('eventTypes.form.themePreset', 'Default Theme')}
|
||||
</label>
|
||||
<select
|
||||
value={form.theme_preset}
|
||||
onChange={(e) => setForm({ ...form, theme_preset: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Active toggle for editing */}
|
||||
{isEditing && (
|
||||
<label className="flex items-center gap-3 pt-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={eventType?.is_active}
|
||||
onChange={(e) => onSubmit({ is_active: e.target.checked })}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<span className="text-sm text-neutral-700">
|
||||
{t('eventTypes.form.isActive', 'Active (visible in event creation)')}
|
||||
</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-3 mt-6 pt-4 border-t border-neutral-200">
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" type="submit" isLoading={isLoading}>
|
||||
{isEditing ? t('common.save', 'Save') : t('common.create', 'Create')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Delete Confirmation Modal
|
||||
interface DeleteConfirmModalProps {
|
||||
eventType: EventType;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const DeleteConfirmModal: React.FC<DeleteConfirmModalProps> = ({
|
||||
eventType,
|
||||
onClose,
|
||||
onConfirm,
|
||||
isLoading
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
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 gap-3 mb-4">
|
||||
<div className="p-2 bg-red-100 rounded-full">
|
||||
<AlertTriangle className="w-6 h-6 text-red-600" />
|
||||
</div>
|
||||
<h2 className="text-xl font-semibold text-neutral-900">
|
||||
{t('eventTypes.deleteConfirm.title', 'Delete Event Type')}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
<p className="text-neutral-600 mb-4">
|
||||
{t('eventTypes.deleteConfirm.message', 'Are you sure you want to delete')} "{eventType.name}"?
|
||||
</p>
|
||||
|
||||
<p className="text-sm text-neutral-500 bg-neutral-50 p-3 rounded-lg mb-6">
|
||||
{t('eventTypes.deleteConfirm.warning', 'This action cannot be undone. Make sure no events are using this type.')}
|
||||
</p>
|
||||
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button variant="outline" onClick={onClose} disabled={isLoading}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={onConfirm}
|
||||
isLoading={isLoading}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
>
|
||||
{t('common.delete', 'Delete')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default EventTypesPage;
|
||||
@@ -11,4 +11,5 @@ export { SettingsPage } from './SettingsPage';
|
||||
export { CMSPage } from './CMSPage';
|
||||
export { BackupManagement } from './BackupManagement';
|
||||
export { EventFeedbackPage } from './EventFeedbackPage';
|
||||
export { UserManagementPage } from './UserManagementPage';
|
||||
export { UserManagementPage } from './UserManagementPage';
|
||||
export { EventTypesPage } from './EventTypesPage';
|
||||
@@ -94,16 +94,16 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
});
|
||||
setErrors(validationErrors);
|
||||
} else {
|
||||
toast.error(errorMessage || t('acceptInvitation.validationError'));
|
||||
toast.error(errorMessage || t('acceptInvitation.errors.genericError'));
|
||||
}
|
||||
} else if (error.response?.status === 422) {
|
||||
toast.error(errorMessage || t('acceptInvitation.validationError'));
|
||||
toast.error(errorMessage || t('acceptInvitation.errors.genericError'));
|
||||
} else if (error.response?.status === 404) {
|
||||
toast.error(t('acceptInvitation.invalidOrExpired'));
|
||||
toast.error(t('acceptInvitation.invalidTokenMessage'));
|
||||
} else if (error.response?.status === 409) {
|
||||
toast.error(errorMessage || t('acceptInvitation.alreadyUsed'));
|
||||
toast.error(errorMessage || t('acceptInvitation.alreadyUsedMessage'));
|
||||
} else {
|
||||
toast.error(t('acceptInvitation.generalError'));
|
||||
toast.error(t('acceptInvitation.errors.genericError'));
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -113,7 +113,7 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
{
|
||||
label: t('acceptInvitation.requirements.minLength'),
|
||||
met: false,
|
||||
test: (pwd: string) => pwd.length >= 8,
|
||||
test: (pwd: string) => pwd.length >= 12,
|
||||
},
|
||||
{
|
||||
label: t('acceptInvitation.requirements.uppercase'),
|
||||
@@ -166,16 +166,16 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
// Validate username
|
||||
const validateUsername = (username: string): string | null => {
|
||||
if (!username) {
|
||||
return t('acceptInvitation.usernameRequired');
|
||||
return t('acceptInvitation.errors.usernameRequired');
|
||||
}
|
||||
if (username.length < 3) {
|
||||
return t('acceptInvitation.usernameTooShort');
|
||||
return t('acceptInvitation.errors.usernameTooShort');
|
||||
}
|
||||
if (username.length > 50) {
|
||||
return t('acceptInvitation.usernameTooLong');
|
||||
return t('acceptInvitation.errors.usernameTooLong');
|
||||
}
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(username)) {
|
||||
return t('acceptInvitation.usernameInvalid');
|
||||
return t('acceptInvitation.errors.usernameInvalid');
|
||||
}
|
||||
return null;
|
||||
};
|
||||
@@ -190,18 +190,18 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
}
|
||||
|
||||
if (!formData.password) {
|
||||
newErrors.password = t('acceptInvitation.passwordRequired');
|
||||
newErrors.password = t('acceptInvitation.errors.passwordRequired');
|
||||
} else {
|
||||
const allRequirementsMet = passwordRequirements.every(req => req.test(formData.password));
|
||||
if (!allRequirementsMet) {
|
||||
newErrors.password = t('acceptInvitation.passwordRequirements');
|
||||
newErrors.password = t('acceptInvitation.errors.passwordTooShort');
|
||||
}
|
||||
}
|
||||
|
||||
if (!formData.confirmPassword) {
|
||||
newErrors.confirmPassword = t('acceptInvitation.confirmPasswordRequired');
|
||||
newErrors.confirmPassword = t('acceptInvitation.errors.confirmPasswordRequired');
|
||||
} else if (formData.password !== formData.confirmPassword) {
|
||||
newErrors.confirmPassword = t('acceptInvitation.passwordsDoNotMatch');
|
||||
newErrors.confirmPassword = t('acceptInvitation.errors.passwordsDoNotMatch');
|
||||
}
|
||||
|
||||
setErrors(newErrors);
|
||||
@@ -269,7 +269,7 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
|
||||
// Error state - invalid or expired token
|
||||
if (isError || !invitation?.valid) {
|
||||
const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidOrExpired');
|
||||
const errorMessage = (validationError as any)?.response?.data?.error || t('acceptInvitation.invalidTokenMessage');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center p-4" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
@@ -280,13 +280,13 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
<XCircle className="w-8 h-8 text-red-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
|
||||
{t('acceptInvitation.invalidTitle')}
|
||||
{t('acceptInvitation.invalidToken')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-6">
|
||||
{errorMessage}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500 mb-6">
|
||||
{t('acceptInvitation.contactAdmin')}
|
||||
{t('acceptInvitation.contactAdminMessage')}
|
||||
</p>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -312,7 +312,7 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
<CheckCircle className="w-8 h-8 text-green-600" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-bold text-neutral-900 mb-2">
|
||||
{t('acceptInvitation.successTitle')}
|
||||
{t('acceptInvitation.success')}
|
||||
</h1>
|
||||
<p className="text-neutral-600 mb-6">
|
||||
{t('acceptInvitation.successMessage')}
|
||||
@@ -324,7 +324,7 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
variant="primary"
|
||||
onClick={() => navigate('/admin/login')}
|
||||
>
|
||||
{t('acceptInvitation.goToLoginNow')}
|
||||
{t('acceptInvitation.goToLogin')}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
@@ -466,7 +466,7 @@ export const AcceptInvitePage: React.FC = () => {
|
||||
|
||||
{/* Password Requirements */}
|
||||
<div className="mt-3 space-y-1.5">
|
||||
<p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirementsTitle')}</p>
|
||||
<p className="text-xs font-medium text-neutral-600">{t('acceptInvitation.requirements.title')}</p>
|
||||
{passwordRequirements.map((req, index) => {
|
||||
const isMet = req.test(formData.password);
|
||||
return (
|
||||
|
||||
@@ -144,7 +144,7 @@ export const LegalPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-4">
|
||||
© 2024 PicPeak. All rights reserved.
|
||||
© {new Date().getFullYear()} PicPeak. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Event Types Service
|
||||
* API client for event type management
|
||||
*/
|
||||
|
||||
import { api } from '../config/api';
|
||||
|
||||
export interface EventType {
|
||||
id: number;
|
||||
name: string;
|
||||
slug_prefix: string;
|
||||
emoji: string;
|
||||
theme_preset: string;
|
||||
theme_config: string | null;
|
||||
display_order: number;
|
||||
is_system: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateEventTypeData {
|
||||
name: string;
|
||||
slug_prefix: string;
|
||||
emoji?: string;
|
||||
theme_preset?: string;
|
||||
theme_config?: Record<string, unknown>;
|
||||
display_order?: number;
|
||||
}
|
||||
|
||||
export interface UpdateEventTypeData {
|
||||
name?: string;
|
||||
slug_prefix?: string;
|
||||
emoji?: string;
|
||||
theme_preset?: string;
|
||||
theme_config?: Record<string, unknown>;
|
||||
display_order?: number;
|
||||
is_active?: boolean;
|
||||
}
|
||||
|
||||
export const eventTypesService = {
|
||||
/**
|
||||
* Get all event types (for admin management)
|
||||
*/
|
||||
async getEventTypes(includeInactive = false): Promise<EventType[]> {
|
||||
const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types', {
|
||||
params: { includeInactive: includeInactive.toString() }
|
||||
});
|
||||
return response.data.eventTypes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get only active event types (for dropdowns/selection)
|
||||
*/
|
||||
async getActiveEventTypes(): Promise<EventType[]> {
|
||||
const response = await api.get<{ eventTypes: EventType[] }>('/admin/event-types/active');
|
||||
return response.data.eventTypes;
|
||||
},
|
||||
|
||||
/**
|
||||
* Get a single event type by ID
|
||||
*/
|
||||
async getEventType(id: number): Promise<EventType> {
|
||||
const response = await api.get<EventType>(`/admin/event-types/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Create a new event type
|
||||
*/
|
||||
async createEventType(data: CreateEventTypeData): Promise<EventType> {
|
||||
const response = await api.post<EventType>('/admin/event-types', data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Update an event type
|
||||
*/
|
||||
async updateEventType(id: number, data: UpdateEventTypeData): Promise<EventType> {
|
||||
const response = await api.put<EventType>(`/admin/event-types/${id}`, data);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
*/
|
||||
async deleteEventType(id: number): Promise<void> {
|
||||
await api.delete(`/admin/event-types/${id}`);
|
||||
},
|
||||
|
||||
/**
|
||||
* Reorder event types
|
||||
*/
|
||||
async reorderEventTypes(orderedIds: number[]): Promise<EventType[]> {
|
||||
const response = await api.post<{ eventTypes: EventType[] }>('/admin/event-types/reorder', {
|
||||
orderedIds
|
||||
});
|
||||
return response.data.eventTypes;
|
||||
}
|
||||
};
|
||||
@@ -20,15 +20,15 @@ const normalizeEvent = (event: Event): Event => {
|
||||
interface CreateEventData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
event_date?: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
customer_email?: string;
|
||||
admin_email?: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expiration_days: number;
|
||||
expiration_days?: number;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
feedback_enabled?: boolean;
|
||||
|
||||
@@ -4,7 +4,7 @@ export interface Event {
|
||||
slug: string;
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
event_date: string | null;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
@@ -12,7 +12,7 @@ export interface Event {
|
||||
color_theme?: string;
|
||||
share_link: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
is_archived: boolean;
|
||||
archive_path?: string;
|
||||
@@ -41,13 +41,17 @@ export interface Event {
|
||||
watermark_downloads?: boolean;
|
||||
enable_devtools_protection?: boolean;
|
||||
use_canvas_rendering?: boolean;
|
||||
// Hero logo customization fields
|
||||
hero_logo_visible?: boolean;
|
||||
hero_logo_size?: 'small' | 'medium' | 'large' | 'xlarge';
|
||||
hero_logo_position?: 'top' | 'center' | 'bottom';
|
||||
}
|
||||
|
||||
export interface GalleryInfo {
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
expires_at: string;
|
||||
event_date: string | null;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
is_expired: boolean;
|
||||
requires_password?: boolean;
|
||||
@@ -97,10 +101,10 @@ export interface GalleryData {
|
||||
id: number;
|
||||
event_name: string;
|
||||
event_type: string;
|
||||
event_date: string;
|
||||
event_date: string | null;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
expires_at: string;
|
||||
expires_at: string | null;
|
||||
allow_user_uploads?: boolean;
|
||||
upload_category_id?: number | null;
|
||||
hero_photo_id?: number | null;
|
||||
|
||||
@@ -15,7 +15,10 @@ export interface GalleryLayoutSettings {
|
||||
};
|
||||
|
||||
// Masonry specific
|
||||
masonryMode?: 'columns' | 'rows'; // columns = Pinterest-style, rows = Google Photos-style
|
||||
masonryGutter?: number;
|
||||
masonryRowHeight?: number; // Target row height for rows mode (150-400)
|
||||
masonryLastRowBehavior?: 'justify' | 'left' | 'center'; // How to align incomplete last row
|
||||
|
||||
// Carousel specific
|
||||
carouselAutoplay?: boolean;
|
||||
@@ -127,7 +130,7 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
|
||||
modernMasonry: {
|
||||
name: 'Modern Masonry',
|
||||
description: 'Pinterest-style dynamic layout',
|
||||
description: 'Pinterest-style columns or Google Photos-style rows',
|
||||
config: {
|
||||
primaryColor: '#3b82f6',
|
||||
accentColor: '#1e40af',
|
||||
@@ -139,7 +142,10 @@ export const GALLERY_THEME_PRESETS: Record<string, EventTheme> = {
|
||||
gallerySettings: {
|
||||
spacing: 'tight',
|
||||
photoAnimation: 'fade',
|
||||
masonryGutter: 16
|
||||
masonryMode: 'columns',
|
||||
masonryGutter: 16,
|
||||
masonryRowHeight: 250,
|
||||
masonryLastRowBehavior: 'left'
|
||||
},
|
||||
headerStyle: 'minimal',
|
||||
footerStyle: 'minimal',
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
/**
|
||||
* Justified/row-based gallery layout algorithm (similar to Google Photos or Flickr)
|
||||
*
|
||||
* This algorithm arranges photos in rows where each row has the same height,
|
||||
* and photos are scaled to fit the container width exactly.
|
||||
*/
|
||||
|
||||
export interface JustifiedPhoto {
|
||||
id: number;
|
||||
width: number;
|
||||
height: number;
|
||||
aspectRatio: number;
|
||||
}
|
||||
|
||||
export interface JustifiedLayoutItem {
|
||||
photoId: number;
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
rowIndex: number;
|
||||
}
|
||||
|
||||
export interface JustifiedLayoutOptions {
|
||||
containerWidth: number;
|
||||
targetRowHeight: number;
|
||||
spacing: number;
|
||||
maxRowHeight?: number; // Maximum row height (for last row)
|
||||
lastRowBehavior?: 'justify' | 'left' | 'center'; // How to handle the last row
|
||||
}
|
||||
|
||||
export interface JustifiedLayoutResult {
|
||||
items: JustifiedLayoutItem[];
|
||||
containerHeight: number;
|
||||
rowCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the aspect ratio for a photo, defaulting to 1:1 if dimensions are missing
|
||||
*/
|
||||
function getAspectRatio(photo: JustifiedPhoto): number {
|
||||
// If aspectRatio is provided and valid, use it
|
||||
if (photo.aspectRatio && photo.aspectRatio > 0 && isFinite(photo.aspectRatio)) {
|
||||
return photo.aspectRatio;
|
||||
}
|
||||
|
||||
// Calculate from width/height if both are valid
|
||||
if (photo.width && photo.height && photo.width > 0 && photo.height > 0) {
|
||||
return photo.width / photo.height;
|
||||
}
|
||||
|
||||
// Default to square (1:1) if no valid dimensions
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the width a photo would have at a given height
|
||||
*/
|
||||
function getPhotoWidthAtHeight(photo: JustifiedPhoto, height: number): number {
|
||||
return height * getAspectRatio(photo);
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the total width of photos in a row at a given height, including spacing
|
||||
*/
|
||||
function calculateRowWidth(
|
||||
photos: JustifiedPhoto[],
|
||||
height: number,
|
||||
spacing: number
|
||||
): number {
|
||||
if (photos.length === 0) return 0;
|
||||
|
||||
const photosWidth = photos.reduce(
|
||||
(sum, photo) => sum + getPhotoWidthAtHeight(photo, height),
|
||||
0
|
||||
);
|
||||
const spacingWidth = (photos.length - 1) * spacing;
|
||||
|
||||
return photosWidth + spacingWidth;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the exact height needed for a row to fit the container width
|
||||
*/
|
||||
function calculateRowHeight(
|
||||
photos: JustifiedPhoto[],
|
||||
containerWidth: number,
|
||||
spacing: number
|
||||
): number {
|
||||
if (photos.length === 0) return 0;
|
||||
|
||||
// Total spacing between photos
|
||||
const totalSpacing = (photos.length - 1) * spacing;
|
||||
|
||||
// Available width for actual photo content
|
||||
const availableWidth = containerWidth - totalSpacing;
|
||||
|
||||
// Sum of aspect ratios determines how width is distributed
|
||||
const totalAspectRatio = photos.reduce(
|
||||
(sum, photo) => sum + getAspectRatio(photo),
|
||||
0
|
||||
);
|
||||
|
||||
// Height = available width / sum of aspect ratios
|
||||
// This ensures all photos at this height exactly fill the available width
|
||||
return availableWidth / totalAspectRatio;
|
||||
}
|
||||
|
||||
/**
|
||||
* Position photos in a row with calculated dimensions
|
||||
*/
|
||||
function positionRowPhotos(
|
||||
photos: JustifiedPhoto[],
|
||||
rowHeight: number,
|
||||
startY: number,
|
||||
rowIndex: number,
|
||||
spacing: number,
|
||||
containerWidth: number,
|
||||
alignment: 'justify' | 'left' | 'center' = 'justify'
|
||||
): JustifiedLayoutItem[] {
|
||||
if (photos.length === 0) return [];
|
||||
|
||||
const items: JustifiedLayoutItem[] = [];
|
||||
|
||||
// Calculate actual widths at this row height
|
||||
const photoWidths = photos.map(photo => getPhotoWidthAtHeight(photo, rowHeight));
|
||||
const totalPhotoWidth = photoWidths.reduce((sum, w) => sum + w, 0);
|
||||
const totalSpacing = (photos.length - 1) * spacing;
|
||||
const totalRowWidth = totalPhotoWidth + totalSpacing;
|
||||
|
||||
// Calculate starting X position based on alignment
|
||||
let startX = 0;
|
||||
if (alignment === 'center') {
|
||||
startX = (containerWidth - totalRowWidth) / 2;
|
||||
} else if (alignment === 'left') {
|
||||
startX = 0;
|
||||
}
|
||||
// For 'justify', startX is 0 and we'll adjust spacing below
|
||||
|
||||
let currentX = startX;
|
||||
|
||||
// For justified alignment, we might need to adjust spacing to fill the row exactly
|
||||
let actualSpacing = spacing;
|
||||
if (alignment === 'justify' && photos.length > 1) {
|
||||
// Calculate the spacing needed to fill the container exactly
|
||||
const widthDifference = containerWidth - totalRowWidth;
|
||||
actualSpacing = spacing + widthDifference / (photos.length - 1);
|
||||
}
|
||||
|
||||
for (let i = 0; i < photos.length; i++) {
|
||||
const photo = photos[i];
|
||||
const width = photoWidths[i];
|
||||
|
||||
items.push({
|
||||
photoId: photo.id,
|
||||
x: currentX,
|
||||
y: startY,
|
||||
width: width,
|
||||
height: rowHeight,
|
||||
rowIndex: rowIndex,
|
||||
});
|
||||
|
||||
currentX += width + (i < photos.length - 1 ? actualSpacing : 0);
|
||||
}
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main function to calculate the justified layout
|
||||
*/
|
||||
export function calculateJustifiedLayout(
|
||||
photos: JustifiedPhoto[],
|
||||
options: JustifiedLayoutOptions
|
||||
): JustifiedLayoutResult {
|
||||
const {
|
||||
containerWidth,
|
||||
targetRowHeight,
|
||||
spacing,
|
||||
maxRowHeight = targetRowHeight * 1.5,
|
||||
lastRowBehavior = 'left',
|
||||
} = options;
|
||||
|
||||
// Handle edge cases
|
||||
if (photos.length === 0) {
|
||||
return {
|
||||
items: [],
|
||||
containerHeight: 0,
|
||||
rowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
if (containerWidth <= 0) {
|
||||
return {
|
||||
items: [],
|
||||
containerHeight: 0,
|
||||
rowCount: 0,
|
||||
};
|
||||
}
|
||||
|
||||
const items: JustifiedLayoutItem[] = [];
|
||||
const rows: JustifiedPhoto[][] = [];
|
||||
let currentRow: JustifiedPhoto[] = [];
|
||||
|
||||
// Step 1: Assign photos to rows
|
||||
for (const photo of photos) {
|
||||
// Try adding this photo to the current row
|
||||
const testRow = [...currentRow, photo];
|
||||
const rowWidthAtTarget = calculateRowWidth(testRow, targetRowHeight, spacing);
|
||||
|
||||
if (rowWidthAtTarget <= containerWidth) {
|
||||
// Photo fits in current row at target height
|
||||
currentRow.push(photo);
|
||||
} else if (currentRow.length === 0) {
|
||||
// Single photo that's wider than container - it gets its own row
|
||||
currentRow.push(photo);
|
||||
rows.push(currentRow);
|
||||
currentRow = [];
|
||||
} else {
|
||||
// Adding this photo would exceed container width
|
||||
// Finalize current row and start new one
|
||||
rows.push(currentRow);
|
||||
currentRow = [photo];
|
||||
}
|
||||
}
|
||||
|
||||
// Don't forget the last row
|
||||
if (currentRow.length > 0) {
|
||||
rows.push(currentRow);
|
||||
}
|
||||
|
||||
// Step 2: Calculate positions for each row
|
||||
let currentY = 0;
|
||||
|
||||
for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
|
||||
const row = rows[rowIndex];
|
||||
const isLastRow = rowIndex === rows.length - 1;
|
||||
|
||||
// Calculate the height needed to justify this row
|
||||
let rowHeight = calculateRowHeight(row, containerWidth, spacing);
|
||||
|
||||
// Determine alignment and height constraints for last row
|
||||
let alignment: 'justify' | 'left' | 'center' = 'justify';
|
||||
|
||||
if (isLastRow) {
|
||||
// For the last row, we might not want to stretch photos too much
|
||||
if (lastRowBehavior === 'left' || lastRowBehavior === 'center') {
|
||||
// Use target height for last row (or max height if calculated is larger)
|
||||
if (rowHeight > maxRowHeight) {
|
||||
rowHeight = maxRowHeight;
|
||||
} else if (rowHeight > targetRowHeight * 1.2) {
|
||||
// If photos would be stretched too much, cap at a reasonable height
|
||||
rowHeight = targetRowHeight;
|
||||
}
|
||||
alignment = lastRowBehavior;
|
||||
} else {
|
||||
// Justify last row, but cap at max height
|
||||
if (rowHeight > maxRowHeight) {
|
||||
rowHeight = maxRowHeight;
|
||||
alignment = 'left'; // Fall back to left align if we can't justify within max height
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// For non-last rows, always justify (fit exactly to container)
|
||||
// The calculated height should fit perfectly
|
||||
}
|
||||
|
||||
// Position photos in this row
|
||||
const rowItems = positionRowPhotos(
|
||||
row,
|
||||
rowHeight,
|
||||
currentY,
|
||||
rowIndex,
|
||||
spacing,
|
||||
containerWidth,
|
||||
alignment
|
||||
);
|
||||
|
||||
items.push(...rowItems);
|
||||
currentY += rowHeight + spacing;
|
||||
}
|
||||
|
||||
// Remove the last spacing (no spacing after the last row)
|
||||
const containerHeight = currentY > 0 ? currentY - spacing : 0;
|
||||
|
||||
return {
|
||||
items,
|
||||
containerHeight,
|
||||
rowCount: rows.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to create a JustifiedPhoto from raw photo data
|
||||
* Handles missing or invalid dimensions gracefully
|
||||
*/
|
||||
export function createJustifiedPhoto(
|
||||
id: number,
|
||||
width?: number | null,
|
||||
height?: number | null
|
||||
): JustifiedPhoto {
|
||||
const w = width && width > 0 ? width : 0;
|
||||
const h = height && height > 0 ? height : 0;
|
||||
|
||||
let aspectRatio: number;
|
||||
if (w > 0 && h > 0) {
|
||||
aspectRatio = w / h;
|
||||
} else {
|
||||
aspectRatio = 1; // Default to square
|
||||
}
|
||||
|
||||
return {
|
||||
id,
|
||||
width: w || 1,
|
||||
height: h || 1,
|
||||
aspectRatio,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch convert photo data to JustifiedPhoto array
|
||||
*/
|
||||
export function createJustifiedPhotos(
|
||||
photos: Array<{ id: number; width?: number | null; height?: number | null }>
|
||||
): JustifiedPhoto[] {
|
||||
return photos.map(photo => createJustifiedPhoto(photo.id, photo.width, photo.height));
|
||||
}
|
||||
@@ -12,10 +12,10 @@ interface PhotoUrlOptions {
|
||||
*/
|
||||
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
|
||||
if (watermarkEnabled && token) {
|
||||
// Use the watermarked photo endpoint
|
||||
return `/gallery/${slug}/photo/${photo.id}`;
|
||||
// Use the watermarked photo endpoint (needs /api prefix)
|
||||
return `/api/gallery/${slug}/photo/${photo.id}`;
|
||||
}
|
||||
|
||||
|
||||
// Use the static photo URL
|
||||
return photo.url;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json",
|
||||
"release-type": "simple",
|
||||
"bump-minor-pre-major": true,
|
||||
"bump-patch-for-minor-pre-major": true,
|
||||
"include-component-in-tag": false,
|
||||
"include-v-in-tag": true,
|
||||
"prerelease": true,
|
||||
"prerelease-type": "beta",
|
||||
"changelog-sections": [
|
||||
{ "type": "feat", "section": "Features", "hidden": false },
|
||||
{ "type": "fix", "section": "Bug Fixes", "hidden": false },
|
||||
{ "type": "perf", "section": "Performance Improvements", "hidden": false },
|
||||
{ "type": "revert", "section": "Reverts", "hidden": false },
|
||||
{ "type": "docs", "section": "Documentation", "hidden": false },
|
||||
{ "type": "style", "section": "Styles", "hidden": true },
|
||||
{ "type": "chore", "section": "Miscellaneous", "hidden": true },
|
||||
{ "type": "refactor", "section": "Code Refactoring", "hidden": true },
|
||||
{ "type": "test", "section": "Tests", "hidden": true },
|
||||
{ "type": "build", "section": "Build System", "hidden": true },
|
||||
{ "type": "ci", "section": "CI/CD", "hidden": true }
|
||||
],
|
||||
"packages": {
|
||||
".": {
|
||||
"release-type": "simple",
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"extra-files": [
|
||||
{
|
||||
"type": "json",
|
||||
"path": "backend/package.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "frontend/package.json",
|
||||
"jsonpath": "$.version"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,18 @@
|
||||
".": {
|
||||
"release-type": "simple",
|
||||
"changelog-path": "CHANGELOG.md",
|
||||
"extra-files": []
|
||||
"extra-files": [
|
||||
{
|
||||
"type": "json",
|
||||
"path": "backend/package.json",
|
||||
"jsonpath": "$.version"
|
||||
},
|
||||
{
|
||||
"type": "json",
|
||||
"path": "frontend/package.json",
|
||||
"jsonpath": "$.version"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,11 @@ test.describe('External media gallery behavior', () => {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||
if (await viewButton.count()) {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
||||
try {
|
||||
await viewButton.click({ noWaitAfter: true, timeout: 5000 });
|
||||
} catch {
|
||||
// Auto-auth via share token may have already navigated to gallery view
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,20 +164,16 @@ test.describe('External media gallery behavior', () => {
|
||||
|
||||
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
||||
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
||||
const refetchPromise = page.waitForResponse((res) => {
|
||||
return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`);
|
||||
});
|
||||
if (!isAlreadyFavorited) {
|
||||
const favResponsePromise = page.waitForResponse((res) => {
|
||||
return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`);
|
||||
});
|
||||
await favoritesButtonInLightbox.click();
|
||||
await Promise.all([favResponsePromise, refetchPromise]);
|
||||
} else {
|
||||
await refetchPromise;
|
||||
// Wait for the mutation to complete and the subsequent refetch with updated counts
|
||||
// The onSuccess handler invalidates gallery-photos, triggering a fresh refetch
|
||||
await page.waitForTimeout(500);
|
||||
await page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||
|
||||
@@ -101,7 +101,11 @@ test.describe('Gallery grid tile quick actions', () => {
|
||||
const passwordField = gallery.getByPlaceholder(/gallery password/i).first();
|
||||
if (await passwordField.count()) {
|
||||
await passwordField.fill(GALLERY_PASSWORD);
|
||||
await gallery.getByRole('button', { name: /View Gallery/i }).click();
|
||||
try {
|
||||
await gallery.getByRole('button', { name: /View Gallery/i }).click({ noWaitAfter: true, timeout: 5000 });
|
||||
} catch {
|
||||
// Auto-auth via share token may have already navigated to gallery view
|
||||
}
|
||||
await gallery.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user