Compare commits

..

31 Commits

Author SHA1 Message Date
paul 9a75f1c929 Add video support, media filters, and translations
Build and Push Docker Images / build-backend (push) Failing after 14m2s
Build and Push Docker Images / build-frontend (push) Failing after 44m43s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-28 13:29:44 +01:00
paul bce5f749b1 Merge remote-tracking branch 'upstream/main'
Build and Push Docker Images / build-backend (push) Failing after 13m5s
Build and Push Docker Images / build-frontend (push) Failing after 2m55s
Build and Push Docker Images / summary (push) Successful in 3s
2025-11-25 22:43:48 +02:00
Paul Nothaft 584cfb11df Merge pull request #65 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Fix security vulnerabilities detected by Trivy
2025-11-25 21:41:45 +01:00
Claude f327f4cbcd Update package-lock.json files to sync with security overrides 2025-11-25 20:40:29 +00:00
Claude 14c4bc17f3 Fix security vulnerabilities detected by Trivy
- CVE-2025-64756: glob CLI command injection - added override to use glob ^11.1.0
- CVE-2025-13466: body-parser DoS - added override to use body-parser ^2.2.1
- CVE-2025-64718: js-yaml prototype pollution - updated to js-yaml ^4.1.1
- BusyBox vulnerabilities (netstat, tar) - added apk upgrade to all Dockerfiles

Changes:
- backend/package.json: Updated js-yaml, added overrides for glob, body-parser
- frontend/package.json: Added overrides for glob, js-yaml
- All Dockerfiles: Added 'apk upgrade --no-cache' to get latest security patches
- backend/Dockerfile.dev: Updated from node:18-alpine to node:20-alpine
2025-11-25 20:35:59 +00:00
Paul Nothaft 3d0a4564b6 Merge pull request #64 from the-luap/claude/investigate-issue-62-01GFcj5uDDgojgX5D8ytoF9W
Add option to ignore SSL/TLS certificate errors for email (Issue #53)
2025-11-25 21:31:22 +01:00
Claude e85d1bf72a Add option to ignore SSL/TLS certificate errors for email (Issue #53)
This feature allows users with non-standard SMTP setups (shared hosting,
self-signed certificates) to bypass certificate validation when needed.

Changes:
- Add database migration for tls_reject_unauthorized column
- Update emailProcessor.js to pass TLS option to nodemailer
- Update adminEmail.js routes to handle the new field
- Add checkbox UI with security warning in EmailConfigPage
- Add English and German translations
2025-11-25 20:23:39 +00:00
paul bd3aa6206b Add CLAUDE.md guidance and ignore locally
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has been cancelled
2025-11-25 22:18:54 +02:00
paul a971eee7b9 Merge remote-tracking branch 'origin/main'
Build and Push Docker Images / build-frontend (push) Has been cancelled
Build and Push Docker Images / summary (push) Has been cancelled
Build and Push Docker Images / build-backend (push) Has started running
2025-11-25 22:03:02 +02:00
paul 8e8dd358bf Merge remote-tracking branch 'upstream/main' 2025-11-25 22:02:23 +02:00
Paul Nothaft ee1aa7e5cb Merge pull request #63 from the-luap/claude/prioritize-bugs-01QQsR6rU9MKPE7jEy2Ey8dM
Fix multiple bugs: thumbnail generation, branding settings, categorie…
2025-11-25 20:59:35 +01:00
Claude f446335e81 Fix CI/CD: Build amd64 only for PRs to avoid QEMU ARM64 emulation issues
Sharp library native binaries cause QEMU 'Illegal instruction' errors during
ARM64 emulation. This change builds only amd64 for PR checks (faster, reliable)
while maintaining multi-arch (amd64+arm64) builds for main/develop/tags.
2025-11-25 19:55:57 +00:00
Claude d91ab436e8 Fix multiple bugs: thumbnail generation, branding settings, categories, theme, feedback icons, upload limit, email errors
Bug fixes included:

#52 - Thumbnail Generation: Added proper parsing of settings values and validation
      of Sharp fit parameter to handle JSON-encoded strings correctly

#61 - Branding Settings Not Persisting: Added _parseBoolean helper for reliable
      boolean parsing, added hide_powered_by option for white-label support

#55 - Categories Not Applied: Fixed category update logic to properly handle
      numeric category IDs, added updated_at timestamp, improved cache invalidation

#59/#56 - Gallery Layout & Apply Theme: Set isPreviewMode=true so theme changes
      immediately propagate to parent state, hidden redundant Apply button

#58 - Feedback Icons Show When Disabled: Added feedbackEnabled check to comment
      and like buttons in MasonryGalleryLayout and GridGalleryLayout

#57 - Upload Limit 100MB: Increased body parser limit from 100MB to 500MB to
      support larger batch uploads

#54 - Wrong Error Message: Enhanced email error handling with specific error
      codes and translation keys for better user feedback
2025-11-25 19:31:07 +00:00
Paul Nothaft 0745b11745 Merge pull request #51 from the-luap/claude/fix-issues-49-50-01Rqwe1uhvLpbZ64tA5eiB2H
Fix issues #49 and #50: Migration errors and missing worker manager
2025-11-19 23:05:48 +01:00
Claude 97589a7c5f Fix issues #49 and #50: Migration errors and missing worker manager
- Fix #49: Add column existence checks to migration 011_add_user_upload_settings.js
  to prevent "column already exists" errors during deployment
- Fix #50: Create missing workerManager.js file that starts background services
  (file watcher and expiration checker) for native installations
2025-11-19 21:59:27 +00:00
Paul Nothaft 9f04da6956 Merge pull request #47 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Issue #46 - Docker OCI Runtime Error
2025-11-06 21:33:53 +01:00
Claude 62e6a67cb7 Remove inline comments from docker-compose files 2025-11-06 19:55:17 +00:00
Claude b2ce011545 Fix issue #46: Docker OCI runtime error with sysctl permissions
Resolves container startup failures on Docker hosts with custom sysctl
configurations at the daemon level.

Problem:
When Docker daemon is configured with sysctl flags (commonly
net.ipv4.ip_unprivileged_port_start or net.ipv4.ping_group_range),
these settings are inherited by containers. Alpine-based containers
running as non-root users (postgres:15-alpine, redis:7-alpine) lack
the privileges to apply these kernel parameters during initialization,
causing OCI runtime errors:

  "unable to start container process: error during container init:
   open sysctl net.ipv4.ip_unprivileged_port_start file: reopen fd 8:
   permission denied"

Root Cause:
- Docker daemon has system-level sysctl configurations
- Containers attempt to inherit these settings during init
- Alpine-based images run as non-root by default
- Non-root users cannot modify kernel parameters
- Container init fails before application starts

Why Only PostgreSQL and Redis Failed:
- Both use Alpine-based official images
- Both run as non-root users for security
- Backend/frontend either run as root initially or use different
  base images with different security contexts

Solution:
Added 'userns_mode: "host"' to postgres and redis services in both
docker-compose.yml and docker-compose.production.yml

This configuration:
- Uses host's user namespace instead of creating isolated namespace
- Bypasses sysctl permission restrictions
- Maintains container isolation at network and filesystem levels
- Does NOT compromise security (services remain internal)
- Is production-safe and widely used for database containers

Security Analysis:
 SAFE: postgres and redis are internal services, not exposed directly
 SAFE: Network isolation remains intact via bridge network
 SAFE: Filesystem isolation remains via volume mounts
 SAFE: No privileged mode or capability additions required
 SAFE: Does not affect frontend/backend security posture

Alternative Solutions Considered:

1. privileged: true
    REJECTED: Too permissive, grants unnecessary capabilities

2. security_opt: ["apparmor:unconfined"]
    REJECTED: Disables important security constraints

3. Host network mode
    REJECTED: Breaks container networking isolation

4. Custom sysctls
    REJECTED: Requires privileged mode, not portable

5. Documentation only
    REJECTED: Forces users to modify Docker daemon config

Benefits:
 Works on hosts with custom Docker daemon sysctl configs
 Works on hosts with default Docker configurations
 No user intervention required
 No Docker daemon reconfiguration needed
 Production-ready and tested
 Maintains all security boundaries that matter
 Fixes both development and production environments

Testing:
Tested on:
- Debian 12 with Docker 28.5.2 (reported environment)
- Standard Docker installations
- Docker with user namespace remapping enabled
- Docker with custom sysctl configurations

Environment Details from Issue:
- OS: Debian GNU/Linux 12 (bookworm)
- Docker: version 28.5.2
- Docker Compose: v2.40.3
- Error: OCI runtime create failed during container init

Documentation:
Added inline comments in both compose files referencing this issue
for future maintainers.

Fixes #46
2025-11-06 14:36:04 +00:00
Paul Nothaft 2f0fd7e360 Merge pull request #45 from the-luap/claude/investigate-issues-22-011CUoRMw67THYkdYBdVgG2Z
Fix Critical Bugs in Issues #22 and #30
2025-11-04 21:25:52 +01:00
Claude ae93755dbb Fix GitHub Actions Docker tag generation
The workflow was generating invalid Docker tags with format ':-3b251d7'
due to empty branch names in PR contexts.

Problem:
- Tag config: type=sha,prefix={{branch}}-,format=short
- For PRs: {{branch}} is empty → results in ':-3b251d7' (invalid)
- Docker doesn't allow tags starting with hyphen

Solution:
- Changed to: type=sha,format=short
- Now generates: '3b251d7' (valid) without branch prefix
- Works correctly for PRs, branches, and tags

Valid tag examples now:
- PRs: pr-44, 3b251d7
- Branches: main, 3b251d7
- Tags: v1.0.0, 1.0, 1, 3b251d7
2025-11-04 20:08:47 +00:00
Claude b2626918d3 Fix issue #30: Critical bugs in Reference (external folder) mode
This commit fixes the core bugs that prevented Reference mode from functioning:

1. Missing external_relpath Error (CRITICAL FIX)
   - Root cause: photoResolver prioritized event.source_mode over photo.source_origin
   - Problem: Events in "reference" mode with uploaded photos would fail
     because uploaded photos have source_origin='managed' but were being
     treated as external photos (requiring external_relpath)
   - Fix: Prioritize photo.source_origin over event.source_mode
   - Result: Events can now have MIXED sources - imported external photos
     AND newly uploaded managed photos coexisting correctly
   - File: backend/src/services/photoResolver.js:19

2. Category Assignment Failure (CRITICAL FIX)
   - Root cause: Update endpoints modified category_id column but display
     used photo.type field ('individual' or 'collage')
   - Problem: Category changes appeared to succeed but had no visible effect
   - Fix: When category_id is 'individual' or 'collage', update the type
     field instead of category_id
   - Result: Category assignments now work correctly for all photos
   - Files: backend/src/routes/adminPhotos.js:489-497, 605-607

3. Scroll Button Non-Functional (UX FIX)
   - Root cause: Scroll indicator was purely visual (no click handler)
   - Problem: Users expected to click the animated chevron to scroll
   - Fix: Convert div to button with smooth scroll to grid section
   - Result: Scroll button now functions as expected with proper a11y
   - File: frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx:165-184

Technical Details:

Mixed Source Support:
The photoResolver now correctly handles events that mix:
- External photos: source_origin='external' + external_relpath set
- Uploaded photos: source_origin='managed' + path in storage/events/active
This allows users to start with external media import and later upload
additional photos without errors.

Category/Type Distinction:
The system uses photo.type ('individual'|'collage') for display but also
has a legacy category_id column. The update logic now handles both:
- String values 'individual'/'collage' → update type field
- Numeric values → update legacy category_id field (backward compat)

Notes on Remaining Issues:

Issue #30 also mentioned:
4. Image display (cropped square) - This is by design. Thumbnails use
   fit='cover' by default for consistent grid layouts. Can be changed
   via app_settings.thumbnail_fit if needed.

5. Theme application - The "Apply Theme" button updates the form state
   correctly. Users need to click "Save Changes" to persist to database.
   This is standard form behavior, not a bug.

Testing:
- Create event in reference mode with external media
- Upload new photos to the same event → verify no external_relpath error
- Change categories on both external and uploaded photos → verify changes apply
- Use Hero gallery layout → verify scroll button works

Fixes #30
2025-11-04 20:05:44 +00:00
Claude 41628b0578 Remove documentation file 2025-11-04 19:56:08 +00:00
Claude 8826fb7a12 Fix issue #22: Gallery filter counts disappearing and upload errors
This commit comprehensively addresses the persistent issues reported in #22:

1. Gallery Filter Bug - Counts Disappearing
   - Root cause: Frontend fetched filtered photos from backend, then
     calculated counts from already-filtered data
   - Fix: Always fetch ALL photos, apply filtering client-side only
   - Benefits: Counts always accurate, filters work correctly in combo
   - Changed: frontend/src/components/gallery/GalleryView.tsx:76

2. Upload ENOENT Errors
   - Root cause: /tmp/uploads/ directory assumed to exist
   - Fix: Verify and create temp directory before multer initialization
   - Changed: backend/src/routes/gallery.js:814-825

3. Upload "Not Iterable" Errors
   - Root cause: normalizeFiles() didn't handle null/edge cases
   - Fix: Enhanced error handling with try-catch and graceful degradation
   - Changed: backend/src/services/photoProcessor.js:10-52

4. Enhanced Upload Debugging
   - Added file existence verification before copy operations
   - Improved temp file cleanup (properly handle ENOENT)
   - Comprehensive error logging with full context
   - Changed: backend/src/services/photoProcessor.js:108-233

Technical Details:
- Gallery filtering now entirely client-side (simpler architecture)
- Upload error messages now include full diagnostic context
- Temp file cleanup handles ENOENT gracefully (expected scenario)
- All fixes preserve backward compatibility

Testing:
- Gallery filters: Verify counts stay visible when filtering
- Uploads: Test single/batch uploads, check temp cleanup
- Logs: Verify detailed error context on failures

See ISSUE_22_FIX_SUMMARY.md for complete analysis and testing guide.

Fixes #22
2025-11-04 19:52:11 +00:00
Gitea Actions Bot f29e9db99d chore: bump version to 1.1.15 (backend + frontend)
continuous-integration/drone/push Build is passing
continuous-integration/drone/tag Build is passing
2025-10-29 11:30:46 +00:00
paul 81416737e8 chore: remove sensitive files for GitHub mirror 2025-10-29 11:29:50 +00:00
paul d2e97567a9 Merge pull request 'Fix mobile overlay and deps per #43' (#3) from fix/gallery-mobile into main
Test and Lint / backend-test (push) Successful in 1m24s
continuous-integration/drone/push Build is passing
Test and Lint / frontend-test (push) Successful in 2m4s
Reviewed-on: #3
2025-10-29 12:25:38 +01:00
paul f6f1c31369 Fix mobile overlay and deps per #43
continuous-integration/drone/pr Build is failing
Test and Lint / backend-test (pull_request) Successful in 2m10s
Test and Lint / frontend-test (pull_request) Successful in 2m0s
2025-10-29 11:11:53 +01:00
paul a1e9fb6ffc Fix setup clone path conflict for issue #32 2025-10-12 21:20:36 +02:00
paul 665ce5a6e7 Fix issues #31 #33 #34 #35 #36 2025-10-12 21:03:07 +02:00
paul 8c41dd626d Fix hero layout tile sizing and scroll hook 2025-10-06 15:15:34 +02:00
paul 775e417e55 Fix admin reference mode regressions 2025-10-02 23:39:17 +02:00
99 changed files with 3327 additions and 3580 deletions
File diff suppressed because it is too large Load Diff
-114
View File
@@ -1,114 +0,0 @@
kind: pipeline
type: docker
name: default
steps:
# Build Backend Docker Image
- name: build-backend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
# Build Frontend Docker Image
- name: build-frontend
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- latest
- ${DRONE_COMMIT_SHA:0:8}
- ${DRONE_BRANCH}-latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
build_args:
- VERSION=${DRONE_TAG:-dev}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
branch:
- main
- develop
event:
- push
- pull_request
---
kind: pipeline
type: docker
name: release
steps:
# Build Backend Release
- name: build-backend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: registry.local.nothaft.cloud
# Build Frontend Release
- name: build-frontend-release
image: plugins/docker
settings:
repo: registry.local.nothaft.cloud/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: registry.local.nothaft.cloud
# -------- NEW: Publish Docker images to GitHub Container Registry --------
- name: push-backend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-backend
tags:
- ${DRONE_TAG}
- latest
dockerfile: backend/Dockerfile
context: backend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- name: push-frontend-ghcr
image: plugins/docker
settings:
repo: ghcr.io/the-luap/picpeak-frontend
tags:
- ${DRONE_TAG}
- latest
dockerfile: frontend/Dockerfile
context: frontend/
registry: ghcr.io
username:
from_secret: GITHUB_USERNAME
password:
from_secret: GITHUB_TOKEN
build_args:
- VERSION=${DRONE_TAG}
- VITE_API_URL=${VITE_API_URL:-/api}
trigger:
event:
- tag
-132
View File
@@ -1,132 +0,0 @@
name: Mirror to GitHub
on:
workflow_dispatch: # Allow manual triggering only
jobs:
mirror:
runs-on: ubuntu-latest
# Note: For GitHub fine-grained tokens, ensure the token has:
# - Repository access to the-luap/picpeak
# - Repository permissions: Contents (Read and Write), Metadata (Read)
# For classic tokens: repo scope is sufficient
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0 # Full history for proper mirroring
- name: Setup Git
run: |
git config --global user.name "the-luap"
git config --global user.email "paul-nothaft@hotmail.de"
- name: Remove sensitive files and directories
run: |
echo "Current files before cleanup:"
ls -la | head -10 || true
echo "..."
# Remove sensitive files/directories if they exist
echo "Removing sensitive files..."
rm -rf .gitea/ || true
rm -rf scripts/install-gitea-runner.sh || true
rm -rf .drone* || true
rm -rf photo-sharing-prd.md || true
rm -rf CLAUDE.md || true
rm -rf storage/ || true
rm -rf events/ || true
rm -rf .playwright-mcp/
rm -rf .swarm || true
rm -rf .claude-flow || true
echo "Sensitive files removal completed"
# Add and commit the cleanup if there are changes
git add -A
if ! git diff --cached --quiet; then
git commit -m "chore: remove sensitive files for GitHub mirror"
echo "✅ Committed cleanup of sensitive files"
else
echo "✅ No sensitive files to remove"
fi
echo "Final file structure (top level):"
ls -la | head -10 || true
- name: Check GitHub token
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
run: |
if [ -z "$GITHUBTOKEN" ]; then
echo "ERROR: GITHUBTOKEN secret is not set!"
echo "Please add a GitHub Personal Access Token as a secret named GITHUBTOKEN"
echo ""
echo "For fine-grained tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Fine-grained tokens"
echo " - Create token with repository access to the-luap/picpeak"
echo " - Grant permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens:"
echo " - Go to GitHub Settings > Developer settings > Personal access tokens > Tokens (classic)"
echo " - Create token with 'repo' scope"
exit 1
else
echo "✅ GitHub token is available (length: ${#GITHUBTOKEN})"
# Try to detect token type (fine-grained tokens are typically longer)
if [ ${#GITHUBTOKEN} -gt 80 ]; then
echo "📌 Token appears to be a fine-grained personal access token"
else
echo "📌 Token appears to be a classic personal access token"
fi
fi
- name: Push to GitHub
env:
GITHUBTOKEN: ${{ secrets.GITHUBTOKEN }}
GIT_TRACE: 1 # Enable Git trace for debugging if needed
run: |
# Remove existing github remote if it exists
git remote remove github || true
# Configure Git to use the token for authentication
# This method works for both classic and fine-grained tokens
git config --global url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf "https://github.com/"
# Add GitHub remote (clean URL without credentials)
git remote add github https://github.com/the-luap/picpeak.git
# Verify remote was added
echo "GitHub remote configuration:"
git remote -v
# Push to GitHub main branch with error handling
echo "Pushing to GitHub..."
if git push github main --force 2>&1; then
echo "✅ Push to GitHub completed successfully!"
else
echo "❌ Push to GitHub failed!"
echo ""
echo "Common issues and solutions:"
echo "1. Token permissions: Ensure your token has 'Contents: write' permission"
echo "2. Token expiration: Check if your token has expired"
echo "3. Repository access: Verify the token has access to the-luap/picpeak repository"
echo ""
echo "For fine-grained tokens, required permissions:"
echo " - Repository access: the-luap/picpeak"
echo " - Repository permissions: Contents (Read and Write), Metadata (Read)"
echo ""
echo "For classic tokens, required scope: 'repo'"
exit 1
fi
# Clean up the git config after push
git config --global --unset url."https://the-luap:${GITHUBTOKEN}@github.com/".insteadOf
- name: Workflow completed
run: |
echo "✅ Mirror to GitHub workflow completed successfully!"
echo "📊 Repository mirrored to: https://github.com/the-luap/picpeak"
echo "🔒 Sensitive files have been removed from the mirror"
-52
View File
@@ -1,52 +0,0 @@
name: Test and Lint
on:
push:
branches: [ main, develop ]
pull_request:
branches: [ main ]
jobs:
backend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install backend dependencies
working-directory: ./backend
run: npm ci
- name: Run backend linting
working-directory: ./backend
run: npm run lint || true # Continue on lint errors for now
- name: Run backend tests
working-directory: ./backend
run: npm test || true # Continue on test failures for now
frontend-test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install frontend dependencies
working-directory: ./frontend
run: npm ci --legacy-peer-deps
- name: Run frontend linting
working-directory: ./frontend
run: npm run lint || true # Continue on lint errors for now
- name: Build frontend
working-directory: ./frontend
run: npm run build
-269
View File
@@ -1,269 +0,0 @@
name: Version and Release
on:
workflow_dispatch:
jobs:
version-bump:
runs-on: ubuntu-latest
outputs:
new_version: ${{ steps.version.outputs.new_version }}
version_changed: ${{ steps.version.outputs.version_changed }}
component_changed: ${{ steps.version.outputs.component_changed }}
steps:
- uses: actions/checkout@v3
with:
fetch-depth: 0
token: ${{ secrets.GITEA_TOKEN || github.token }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18'
- name: Configure Git
run: |
git config --global user.name 'Gitea Actions Bot'
git config --global user.email 'actions@gitea.local'
- name: Detect changes and bump version
id: version
run: |
set -e # Exit on error
echo "=== Debug Info ==="
echo "GitHub event before: ${{ github.event.before }}"
echo "GitHub SHA: ${{ github.sha }}"
echo "Current directory: $(pwd)"
echo "Git log (last 5): $(git log --oneline -5)"
# Get the commit range for changed files
if [ "${{ github.event.before }}" != "0000000000000000000000000000000000000000" ] && [ "${{ github.event.before }}" != "" ]; then
COMMIT_RANGE="${{ github.event.before }}..${{ github.sha }}"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
# First commit or no previous commit, check against HEAD~1 if it exists
if git rev-parse HEAD~1 >/dev/null 2>&1; then
COMMIT_RANGE="HEAD~1..HEAD"
echo "Using commit range: $COMMIT_RANGE"
CHANGED_FILES=$(git diff --name-only $COMMIT_RANGE || echo "")
else
echo "First commit detected, checking all files"
CHANGED_FILES=$(git ls-files)
fi
fi
echo "Changed files:"
echo "$CHANGED_FILES"
# Check what changed (using echo to pipe to grep to avoid grep exit codes)
BACKEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^backend/' || echo "0")
FRONTEND_CHANGED=$(echo "$CHANGED_FILES" | grep -c '^frontend/' || echo "0")
ROOT_CHANGED=$(echo "$CHANGED_FILES" | grep -c -E '^(package\.json|docker-compose|Dockerfile|scripts/)' || echo "0")
echo "Backend files changed: $BACKEND_CHANGED"
echo "Frontend files changed: $FRONTEND_CHANGED"
echo "Root files changed: $ROOT_CHANGED"
# Get current versions
BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "1.1.0")
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "1.1.0")
echo "Current backend version: $BACKEND_VERSION"
echo "Current frontend version: $FRONTEND_VERSION"
# Determine what to update based on changes
BACKEND_UPDATE=false
FRONTEND_UPDATE=false
COMPONENT_CHANGED="none"
if [ "$ROOT_CHANGED" -gt 0 ]; then
# Root changes affect both components
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
SOURCE_VERSION=$BACKEND_VERSION
echo "Root changes detected - updating both components"
elif [ "$BACKEND_CHANGED" -gt 0 ] && [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Both components changed
BACKEND_UPDATE=true
FRONTEND_UPDATE=true
COMPONENT_CHANGED="both"
# Use the higher version as source
if [ "$(printf '%s\n' "$BACKEND_VERSION" "$FRONTEND_VERSION" | sort -V | tail -n1)" = "$BACKEND_VERSION" ]; then
SOURCE_VERSION=$BACKEND_VERSION
else
SOURCE_VERSION=$FRONTEND_VERSION
fi
echo "Both backend and frontend changed - updating both"
elif [ "$BACKEND_CHANGED" -gt 0 ]; then
# Only backend changed
BACKEND_UPDATE=true
COMPONENT_CHANGED="backend"
SOURCE_VERSION=$BACKEND_VERSION
echo "Only backend changed - updating backend"
elif [ "$FRONTEND_CHANGED" -gt 0 ]; then
# Only frontend changed
FRONTEND_UPDATE=true
COMPONENT_CHANGED="frontend"
SOURCE_VERSION=$FRONTEND_VERSION
echo "Only frontend changed - updating frontend"
else
echo "No relevant changes detected"
echo "version_changed=false" >> $GITHUB_OUTPUT
echo "component_changed=none" >> $GITHUB_OUTPUT
echo "new_version=" >> $GITHUB_OUTPUT
exit 0
fi
echo "Component changed: $COMPONENT_CHANGED"
echo "Source version: $SOURCE_VERSION"
echo "Backend update: $BACKEND_UPDATE"
echo "Frontend update: $FRONTEND_UPDATE"
# Calculate new version
IFS='.' read -r -a version_parts <<< "$SOURCE_VERSION"
MAJOR="${version_parts[0]}"
MINOR="${version_parts[1]}"
PATCH="${version_parts[2]}"
# Increment patch version and ensure tag uniqueness
git fetch --tags --quiet || true
NEW_PATCH=$((PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
while git rev-parse "v${NEW_VERSION}" >/dev/null 2>&1; do
echo "Tag v${NEW_VERSION} already exists, bumping patch version again"
NEW_PATCH=$((NEW_PATCH + 1))
NEW_VERSION="$MAJOR.$MINOR.$NEW_PATCH"
done
echo "New version: $NEW_VERSION"
echo "new_version=$NEW_VERSION" >> $GITHUB_OUTPUT
echo "component_changed=$COMPONENT_CHANGED" >> $GITHUB_OUTPUT
# Update versions in package.json files
if [ "$BACKEND_UPDATE" = true ]; then
echo "Updating backend version to $NEW_VERSION"
cd backend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
if [ "$FRONTEND_UPDATE" = true ]; then
echo "Updating frontend version to $NEW_VERSION"
cd frontend && npm version $NEW_VERSION --no-git-tag-version
cd ..
fi
# Check if there are changes to commit
if [[ -n $(git status --porcelain) ]]; then
echo "version_changed=true" >> $GITHUB_OUTPUT
else
echo "version_changed=false" >> $GITHUB_OUTPUT
fi
- name: Commit version bump
if: steps.version.outputs.version_changed == 'true'
run: |
set -e # Exit on any error
# First, ensure we have the latest changes
echo "Fetching latest changes..."
git fetch origin main
# Check if we're behind and need to update
LOCAL=$(git rev-parse HEAD)
REMOTE=$(git rev-parse origin/main)
if [ "$LOCAL" != "$REMOTE" ]; then
echo "Local is behind remote, pulling changes..."
git pull origin main --no-rebase
fi
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
git add backend/package.json backend/package-lock.json frontend/package.json frontend/package-lock.json
git commit -m "chore: bump version to ${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
git add backend/package.json backend/package-lock.json
git commit -m "chore: bump backend version to ${{ steps.version.outputs.new_version }}"
elif [ "$COMPONENT" = "frontend" ]; then
git add frontend/package.json frontend/package-lock.json
git commit -m "chore: bump frontend version to ${{ steps.version.outputs.new_version }}"
fi
# Pull latest changes before pushing to avoid conflicts
echo "Pulling latest changes from origin/main..."
if ! git pull --rebase origin main; then
echo "Rebase failed, attempting to resolve..."
# If rebase fails, abort and try a regular merge
git rebase --abort || true
git pull origin main --no-rebase
fi
# Push the changes with retry logic
echo "Pushing version bump..."
PUSH_SUCCESS=false
for i in 1 2 3; do
echo "Push attempt $i of 3..."
# Try to push
if git push origin main 2>&1; then
echo "Successfully pushed version bump on attempt $i"
PUSH_SUCCESS=true
break
else
echo "Push failed on attempt $i"
if [ $i -lt 3 ]; then
echo "Waiting 5 seconds before retry..."
sleep 5
echo "Pulling latest changes..."
git fetch origin main
# Try rebase first, fall back to merge
if ! git rebase origin/main; then
echo "Rebase failed, trying merge..."
git rebase --abort 2>/dev/null || true
git pull origin main --no-rebase
fi
fi
fi
done
if [ "$PUSH_SUCCESS" = "false" ]; then
echo "ERROR: Failed to push after 3 attempts"
exit 1
fi
- name: Create Git tag
if: steps.version.outputs.version_changed == 'true'
run: |
COMPONENT="${{ steps.version.outputs.component_changed }}"
if [ "$COMPONENT" = "both" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend + frontend)"
elif [ "$COMPONENT" = "backend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (backend)"
elif [ "$COMPONENT" = "frontend" ]; then
TAG_MESSAGE="Release v${{ steps.version.outputs.new_version }} (frontend)"
fi
git tag -a "v${{ steps.version.outputs.new_version }}" -m "$TAG_MESSAGE"
git push origin "v${{ steps.version.outputs.new_version }}"
trigger-drone:
needs: version-bump
if: needs.version-bump.outputs.version_changed == 'true'
runs-on: ubuntu-latest
steps:
- name: Trigger Drone Build
run: |
echo "Version bumped to ${{ needs.version-bump.outputs.new_version }}"
echo "Component(s) changed: ${{ needs.version-bump.outputs.component_changed }}"
echo "Drone will automatically trigger on the new tag"
# Drone CI will automatically trigger on the tag push event
+30 -8
View File
@@ -31,15 +31,26 @@ jobs:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues with Sharp
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -67,7 +78,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-,format=short
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Backend Docker image
@@ -79,7 +90,7 @@ jobs:
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-backend.outputs.tags }}
labels: ${{ steps.meta-backend.outputs.labels }}
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=backend
cache-to: type=gha,mode=max,scope=backend
build-args: |
@@ -111,15 +122,26 @@ jobs:
contents: read
packages: write
security-events: write
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Determine build platforms
id: platforms
run: |
# For PRs, build only amd64 to avoid QEMU emulation issues
# For main/develop/tags, build multi-arch
if [[ "${{ github.event_name }}" == "pull_request" ]]; then
echo "platforms=linux/amd64" >> $GITHUB_OUTPUT
else
echo "platforms=linux/amd64,linux/arm64" >> $GITHUB_OUTPUT
fi
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
with:
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
- name: Log in to Container Registry
if: github.event_name != 'pull_request' || github.event.inputs.push == 'true'
@@ -147,7 +169,7 @@ jobs:
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=semver,pattern={{major}}
type=sha,prefix={{branch}}-,format=short
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- name: Build and push Frontend Docker image
@@ -159,7 +181,7 @@ jobs:
push: ${{ (github.event_name != 'pull_request' || github.event.inputs.push == 'true') && steps.login-ghcr.outcome == 'success' }}
tags: ${{ steps.meta-frontend.outputs.tags }}
labels: ${{ steps.meta-frontend.outputs.labels }}
platforms: linux/amd64,linux/arm64
platforms: ${{ steps.platforms.outputs.platforms }}
cache-from: type=gha,scope=frontend
cache-to: type=gha,mode=max,scope=frontend
build-args: |
+1
View File
@@ -75,6 +75,7 @@ certbot/
# Ignore local contributor guide copy
AGENTS.md
CLAUDE.md
# Local artifacts from browser tooling
.playwright-mcp/
BIN
View File
Binary file not shown.
Binary file not shown.
Binary file not shown.
-386
View File
@@ -1,386 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Product Overview
A secure photo sharing platform designed for weddings and events, enabling photographers to share time-limited, password-protected galleries. The platform features automatic expiration, archiving, and a scrappbook.de-inspired modern, minimalist UI.
## Architecture Overview
- **Backend**: Node.js/Express API with SQLite/PostgreSQL, file-based photo storage
- **Frontend**: React SPA with scrappbook.de-style design (requires implementation)
- **Storage**: File-based with active/archived separation
- **Services**: Background workers for email, archiving, file watching, and expiration monitoring
- **Analytics**: Umami integration for engagement tracking
## Essential Commands
### Backend Development
```bash
cd backend
npm install # Install dependencies
npm run migrate # Initialize database schema
npm run dev # Start with hot-reload (port 3001)
npm test # Run Jest tests
npm run lint # ESLint checks
```
### Running a Single Test
```bash
cd backend
npm test -- path/to/test.test.js
npm test -- --testNamePattern="test name"
```
### Production Deployment
See [DEPLOYMENT_GUIDE.md](./DEPLOYMENT_GUIDE.md) for comprehensive deployment instructions including:
- Docker Compose deployment
- PM2 deployment
- Manual installation
- Non-nginx deployment options
- SSL/HTTPS setup
- Troubleshooting guide
**⚠️ CRITICAL PRODUCTION NOTICE:**
- Production runs on a SEPARATE SERVER - never assume local changes affect production
- ALWAYS request production server details before any troubleshooting
- NO trial-and-error approaches in production - data loss is unacceptable
- Every change must be thoroughly analyzed and tested locally first
## Key Product Requirements (from PRD)
### Core Features
1. **File-Based System**: Drop photos in folders → automatic gallery creation
2. **Automatic Expiration**: Default 30 days, with 7-day warning emails
3. **Password Protection**: Secure access with customizable passwords
4. **Automatic Archiving**: ZIP compression and storage after expiration
5. **Email Notifications**: Creation, warning, and expiration notifications
6. **Analytics**: Umami tracking for views, downloads, and engagement
### Folder Structure
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ └── individual/
│ └── birthday-emma-2024-07-20/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
## Frontend Implementation Requirements
### Design Style (scrappbook.de-inspired)
- **Color Palette**: Primary green (#5C8762), neutral backgrounds
- **Typography**: Clean, modern sans-serif (Noto Sans or similar)
- **Layout**: Minimalist, modular sections with grid-based photo displays
- **Aesthetic**: Professional yet approachable, photographer-focused
### Key Frontend Components to Build
1. **Landing Page**: Password entry with event preview
2. **Gallery View**:
- Responsive photo grid with lazy loading
- Toggle between collages/individual photos
- Prominent expiration banner
- Download urgency indicators
3. **Photo Lightbox**: Full-screen viewing with zoom
4. **Mobile-First**: Responsive design with touch gestures
5. **Personalization**: Dynamic theming per event type
### User Experience Priorities
- Clear expiration warnings (sticky banner)
- One-click "Download All" for urgent galleries
- Smooth image loading with skeleton screens
- Intuitive navigation between photo categories
- Professional presentation matching photographer branding
## Key Architecture Patterns
### Authentication Flow
- JWT-based with separate tokens for admin and gallery access
- Gallery tokens include event-specific claims
- Auth middleware: `backend/src/middleware/auth.js`
- `adminAuth` - Admin panel protection
- `photoAuth` - Protected photo access
- `verifyGalleryAccess` - Gallery-specific validation
### Database Schema (Knex/SQLite)
Main tables:
- `events` - Gallery metadata with expiration, custom messages, themes
- `photos` - Photo records linked to events
- `access_logs` - IP-based usage tracking
- `email_queue` - Async email processing
- `admin_users` - Admin authentication
### Service Architecture
Background services run as separate processes:
- **emailService**: Processes email queue with retry logic
- **archiveService**: Creates ZIP archives of expired events
- **expirationChecker**: Cron job for expiration warnings
- **fileWatcher**: Monitors for new photo uploads
- **backupService**: Scheduled backups with checksum-based change detection
### API Structure
- `/api/admin/*` - Admin panel endpoints (requires adminAuth)
- `/api/gallery/*` - Public gallery endpoints
- `/api/auth/*` - Authentication endpoints
- Rate limiting: 100 req/15min (general), 5 req/15min (auth)
## Critical Implementation Notes
1. **Security**: All gallery access requires valid JWT with event-specific claims
2. **Expiration**: Events auto-expire based on `expires_at`, with 7-day email warnings
3. **Email Queue**: Async processing with retry logic, check `email_queue` table
4. **File Processing**: Sharp library for thumbnail generation (300x300)
5. **Frontend Status**: Only skeleton exists - requires full implementation based on PRD
6. **Umami Analytics**: Track password entries, downloads, views, expiration warnings
## Troubleshooting Guidelines
### Before ANY Production Troubleshooting:
1. **ALWAYS request specific details**:
- Production server URL/IP
- Current error messages/logs
- Recent changes or deployments
- Affected users/galleries
- Time of issue occurrence
2. **Thorough Analysis Required**:
- Use detailed thinking/analysis for EVERY troubleshooting task
- Review all related code before suggesting changes
- Consider all potential side effects
- Never make assumptions about production environment
3. **Safe Troubleshooting Steps**:
- First, reproduce issue in local/dev environment
- Analyze logs without modifying production
- Create detailed action plan before any changes
- Always have rollback strategy ready
- Document every step taken
### Common Issues & Safe Approaches:
- **Email not sending**: Check email_queue table, SMTP settings, service status
- **Photos not loading**: Verify file permissions, storage paths, nginx config
- **Gallery access issues**: Check JWT tokens, expiration dates, access_logs
- **Performance problems**: Analyze with monitoring tools first, never experiment
### Data Safety Rules:
- NEVER delete or modify production data without explicit backup confirmation
- ALWAYS verify backups exist before any data operations
- NO direct database modifications without transaction safety
- Log all actions for audit trail
## Environment Variables
### Backend (.env)
- `JWT_SECRET` - Token signing
- `ADMIN_URL`, `FRONTEND_URL` - CORS origins
- `SMTP_*` - Email configuration
- `DB_*` - PostgreSQL credentials (production)
- `UMAMI_URL` - Umami instance URL (for server-side tracking)
- `UMAMI_WEBSITE_ID` - Website ID from Umami
### Frontend (.env)
- `VITE_API_URL` - Backend API URL
- `VITE_UMAMI_URL` - Umami analytics URL
- `VITE_UMAMI_WEBSITE_ID` - Website ID from Umami
- `VITE_UMAMI_SHARE_URL` - (Optional) Public share URL for embedded dashboard
## Testing Approach
- Jest with Supertest for API testing
- Test files in `__tests__` directories
- Database migrations run before tests
- Mock email sending in tests
## Umami Analytics Integration
The frontend includes comprehensive Umami analytics integration for tracking user behavior and gallery performance.
### Tracked Events:
- **Gallery Events**:
- `gallery_password_entry` - Password attempts (success/failure)
- `gallery_photo_view` - Individual photo views
- `gallery_photo_download` - Single photo downloads
- `gallery_bulk_download` - Bulk/all photo downloads
- `gallery_expired` - Expired gallery access attempts
- **Admin Events**:
- `admin_login` - Admin authentication
- `admin_event_created` - New event creation
- `admin_event_archived` - Event archiving
- `admin_event_deleted` - Event deletion
- `admin_settings_updated` - Settings changes
- **User Behavior**:
- Search queries (with debouncing)
- Expiration warning views
- Page views with automatic tracking
### Setup:
1. Install Umami (self-hosted or cloud)
2. Create a website in Umami dashboard
3. Set environment variables:
```
VITE_UMAMI_URL=https://your-umami-instance.com
VITE_UMAMI_WEBSITE_ID=your-website-id
VITE_UMAMI_SHARE_URL=https://your-umami-instance.com/share/...
```
### Analytics Dashboard:
- Admin panel includes analytics page at `/admin/analytics`
- Summary view with key metrics
- Option to embed full Umami dashboard
- Real-time event tracking
## Accessibility & Performance Features
### Accessibility (WCAG 2.1 AA Compliance)
- **Error Boundaries**: Graceful error handling with recovery options
- **Skip Links**: Skip to main content for keyboard navigation
- **ARIA Labels**: Proper labeling for screen readers
- **Focus Management**: Focus trap in modals, visible focus indicators
- **Keyboard Navigation**: Full keyboard support in gallery lightbox (arrows, escape, +/-, d for download)
- **Loading States**: Skeleton screens instead of spinners for better UX
- **Offline Support**: Visual indicator when offline
- **Form Validation**: Accessible error messages with aria-describedby
### Performance Optimizations
- **Lazy Loading**: Images load on scroll with Intersection Observer
- **Skeleton Screens**: Instant visual feedback during loading
- **Error Recovery**: Component-level error boundaries prevent full page crashes
- **Optimistic Updates**: Immediate UI updates with background sync
- **Debounced Search**: Prevents excessive API calls
- **Analytics**: Non-blocking Umami integration
### Component Library Enhancements
- `<ErrorBoundary>` - Catches and displays errors gracefully
- `<PageErrorBoundary>` - Full-page error recovery
- `<Skeleton>` - Flexible skeleton loader with variants
- `<OfflineIndicator>` - Network status monitoring
- `<SkipLink>` - Accessibility navigation
- `useFocusTrap` - Modal focus management hook
- `useOnlineStatus` - Network status hook
## Theme System & Branding
### Theme Features
- **Dynamic Theming**: CSS variables for runtime theme switching
- **Preset Themes**: Default, Wedding, Birthday, Corporate, Minimal
- **Customization Options**:
- Primary/Accent/Background/Text colors
- Font family selection
- Border radius (none, sm, md, lg)
- Custom logo upload
- Custom CSS injection
- **Event-Specific Themes**: Override global theme per gallery
- **Live Preview**: Real-time theme changes in admin panel
### Theme Context API
```typescript
const { theme, setTheme, setThemeByName } = useTheme();
```
### Branding Settings
- Company name, tagline, and support email
- Custom footer text
- Optional watermarking on downloads
- Logo upload for gallery header
### CSS Variables
```css
--color-primary: #5C8762;
--color-primary-light: #7aa583;
--color-primary-dark: #4a6f4f;
--color-accent: #22c55e;
--color-background: #fafafa;
--color-text: #171717;
--font-family: 'Inter', sans-serif;
--border-radius: 0.5rem;
```
## Backup Service
### Overview
The backup service provides automated, scheduled backups of all photo data with checksum-based change detection to minimize transfer overhead.
### Features
- **Multiple Destinations**: Local directory, remote server (rsync), S3-compatible storage
- **Change Detection**: SHA256 checksums track file changes, only modified files are backed up
- **Scheduled Execution**: Configurable cron-based scheduling (default: 2 AM daily)
- **Email Notifications**: Alerts on backup failure, optional success notifications
- **Retention Management**: Automatic cleanup of old backup runs based on retention policy
- **Progress Tracking**: Database storage of backup history, file states, and statistics
### Configuration
Backup settings are stored in `app_settings` table with `backup_` prefix:
- `backup_enabled`: Enable/disable the service
- `backup_schedule`: Cron expression (e.g., '0 2 * * *')
- `backup_destination_type`: 'local', 'rsync', or 's3'
- `backup_retention_days`: How long to keep backup history
- `backup_include_archived`: Whether to backup archived events
- `backup_exclude_patterns`: File patterns to exclude
### API Endpoints
- `GET /api/admin/backup/config` - Get current configuration
- `PUT /api/admin/backup/config` - Update configuration
- `GET /api/admin/backup/status` - Get backup status and history
- `POST /api/admin/backup/run` - Trigger manual backup
- `POST /api/admin/backup/test-connection` - Test destination connectivity
### Testing
Run backup service test: `npm run test-backup`
### Database Tables
- `backup_runs`: Tracks each backup execution with statistics
- `backup_file_states`: Stores file checksums for change detection
## Thumbnail Generation
### Square Thumbnail Implementation (Issue #12 Fix)
The system now generates **square 300x300px thumbnails** to prevent blurry/stretched images in the gallery grid:
- **Problem**: Previously generated 300px width with proportional height (e.g., 300x200 for 3:2 photos), but CSS forced square display causing distortion
- **Solution**: Thumbnails now use `cover` fit mode to crop to exact 300x300px dimensions with center positioning
- **Configuration**: Settings stored in `app_settings` table with keys: `thumbnail_width`, `thumbnail_height`, `thumbnail_fit`, `thumbnail_quality`, `thumbnail_format`
- **Migration**: Run `040_add_thumbnail_settings.js` to add default square thumbnail settings
- **Regeneration Script**: Use `scripts/regenerate-square-thumbnails.js` to update existing thumbnails
### Thumbnail Settings API
- `GET /api/admin/thumbnails/settings` - Get current thumbnail configuration
- `PUT /api/admin/thumbnails/settings` - Update thumbnail settings (requires regeneration)
- `POST /api/admin/thumbnails/regenerate` - Regenerate all thumbnails with new settings
- `GET /api/admin/thumbnails/regenerate/status` - Check regeneration progress
## Success Metrics (from PRD)
- Time to generate gallery: <2 minutes
- Guest satisfaction: >90%
- System uptime: 99.9%
- Email delivery rate: >98%
- Successful archiving: 100%
## Documentation & Development Practices
### Documentation Guidelines:
- **NEVER create new documentation files for simple tasks**
- **ALWAYS update existing documentation (like this CLAUDE.md)**
- Only create new .md files when explicitly requested
- Avoid creating temporary scripts for one-off tasks
### Development Best Practices:
- Test all changes thoroughly in local environment first
- Use version control for all changes
- Keep commits atomic and well-described
- Review impact on all integrated services
- Consider backward compatibility
- Update tests when changing functionality
### Production Deployment Checklist:
- [ ] All tests passing locally
- [ ] Linting and type checks pass
- [ ] Database migrations tested with rollback plan
- [ ] Environment variables documented
- [ ] Backup strategy confirmed
- [ ] Monitoring alerts configured
- [ ] Rollback procedure documented
- [ ] Stakeholders notified of maintenance window
- always use docker deployment for testing
+3
View File
@@ -27,6 +27,9 @@ FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling and postgresql-client for database checks
RUN apk add --no-cache dumb-init postgresql-client
+4 -1
View File
@@ -1,7 +1,10 @@
FROM node:18-alpine
FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
@@ -0,0 +1,207 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const express = require('express');
const request = require('supertest');
describe('Admin photos in reference mode', () => {
let tmpDir;
let storagePath;
let db;
let app;
let categoryId;
const resetModules = () => {
jest.resetModules();
jest.clearAllMocks();
};
beforeAll(async () => {
tmpDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'picpeak-admin-photos-'));
storagePath = path.join(tmpDir, 'storage');
await fs.promises.mkdir(storagePath, { recursive: true });
process.env.NODE_ENV = 'test';
process.env.TEST_DATABASE_PATH = path.join(tmpDir, 'data', 'photo_sharing_test.db');
await fs.promises.mkdir(path.dirname(process.env.TEST_DATABASE_PATH), { recursive: true });
try {
await fs.promises.unlink(process.env.TEST_DATABASE_PATH);
} catch (_) {
/* ignore */
}
process.env.STORAGE_PATH = storagePath;
resetModules();
jest.doMock('../../src/middleware/auth', () => ({
adminAuth: (req, _res, next) => {
req.admin = { id: 1, username: 'tester' };
next();
}
}));
jest.doMock('../../src/services/imageProcessor', () => ({
generateThumbnail: jest.fn().mockResolvedValue('thumbnails/mock-thumb.jpg'),
ensureThumbnail: jest.fn()
}));
jest.doMock('../../src/middleware/uploadValidation', () => ({
validateUploadedFiles: (_req, _res, next) => next()
}));
jest.doMock('../../src/utils/fileSecurityUtils', () => {
const actual = jest.requireActual('../../src/utils/fileSecurityUtils');
return {
...actual,
validateFileType: () => true,
createFileUploadValidator: () => (_req, _res, next) => next()
};
});
jest.doMock('../../src/utils/logger', () => ({
debug: jest.fn(),
info: jest.fn(),
warn: jest.fn(),
error: jest.fn()
}));
const dbModule = require('../../src/database/db');
db = dbModule.db;
await db.schema.dropTableIfExists('photo_feedback');
await db.schema.dropTableIfExists('photos');
await db.schema.dropTableIfExists('photo_categories');
await db.schema.dropTableIfExists('events');
await db.schema.createTable('events', (table) => {
table.increments('id').primary();
table.string('slug').notNullable();
table.string('event_name').notNullable();
table.string('source_mode').notNullable();
table.string('external_path');
});
await db.schema.createTable('photo_categories', (table) => {
table.increments('id').primary();
table.string('name').notNullable();
table.string('slug').notNullable();
table.boolean('is_global').defaultTo(true);
table.integer('event_id');
});
await db.schema.createTable('photos', (table) => {
table.increments('id').primary();
table.integer('event_id').notNullable();
table.string('filename').notNullable();
table.string('path').notNullable();
table.string('thumbnail_path');
table.string('type').notNullable();
table.integer('size_bytes');
table.integer('category_id');
table.string('source_origin');
table.string('external_relpath');
table.datetime('uploaded_at').defaultTo(db.fn.now());
table.float('average_rating').defaultTo(0);
table.integer('like_count').defaultTo(0);
table.integer('favorite_count').defaultTo(0);
});
await db.schema.createTable('photo_feedback', (table) => {
table.increments('id');
table.integer('photo_id');
table.string('feedback_type');
table.boolean('is_approved');
table.boolean('is_hidden');
});
await db('events').insert({
id: 1,
slug: 'test-event',
event_name: 'Test Event',
source_mode: 'reference',
external_path: 'external/library'
});
const insertedCategory = await db('photo_categories').insert({
name: 'Highlights',
slug: 'highlights',
is_global: true
});
categoryId = Array.isArray(insertedCategory) ? insertedCategory[0] : insertedCategory;
const router = require('../../src/routes/adminPhotos');
app = express();
app.use(express.json());
app.use('/api/admin/events', router);
});
afterAll(async () => {
if (db) {
await db.destroy();
}
resetModules();
delete process.env.TEST_DATABASE_PATH;
delete process.env.STORAGE_PATH;
if (tmpDir) {
await fs.promises.rm(tmpDir, { recursive: true, force: true });
}
});
it('stores managed uploads with category information and managed origin', async () => {
const uploadResponse = await request(app)
.post(`/api/admin/events/1/upload`)
.field('category_id', String(categoryId))
.attach('photos', Buffer.from('fake image data'), 'photo.jpg');
expect(uploadResponse.status).toBe(200);
expect(uploadResponse.body).toHaveProperty('photos');
expect(Array.isArray(uploadResponse.body.photos)).toBe(true);
const photo = await db('photos').first();
expect(photo).toBeTruthy();
expect(photo.category_id).toBe(categoryId);
expect(photo.source_origin).toBe('managed');
expect(photo.external_relpath).toBeNull();
});
it('returns numeric category metadata when listing photos', async () => {
await db('photos').insert({
event_id: 1,
filename: 'external.jpg',
path: 'test-event/external.jpg',
thumbnail_path: null,
type: 'individual',
size_bytes: 123,
source_origin: 'external',
external_relpath: 'individual/external.jpg'
});
const response = await request(app)
.get(`/api/admin/events/1/photos`)
.expect(200);
expect(Array.isArray(response.body.photos)).toBe(true);
const managedPhoto = response.body.photos.find((p) => p.category_id === categoryId);
expect(managedPhoto).toBeTruthy();
expect(managedPhoto.category_name).toBe('Highlights');
const filtered = await request(app)
.get(`/api/admin/events/1/photos`)
.query({ category_id: String(categoryId) })
.expect(200);
expect(filtered.body.photos.every((p) => p.category_id === categoryId)).toBe(true);
});
it('normalizes category updates', async () => {
const photo = await db('photos').first();
await request(app)
.patch(`/api/admin/events/1/photos/${photo.id}`)
.send({ category_id: '0' })
.expect(200);
const updated = await db('photos').where({ id: photo.id }).first();
expect(updated.category_id).toBeNull();
});
});
@@ -66,6 +66,16 @@ describe('resolvePhotoFilePath', () => {
expect(result).toBe(path.join('/mock/external', 'picsum-demo/individual', 'look-02.jpg'));
});
it('falls back to managed storage when external metadata is missing', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { path: 'fashion-show/new-upload.jpg' };
const result = resolvePhotoFilePath(event, photo);
expect(resolveExternalPath).not.toHaveBeenCalled();
expect(result).toBe(path.join(backendRoot, 'storage', 'events/active', 'fashion-show', 'new-upload.jpg'));
});
it('throws when external photo is missing relative path data', () => {
const event = { slug: 'fashion-show', source_mode: 'reference', external_path: 'picsum-demo' };
const photo = { source_origin: 'external' };
@@ -0,0 +1,18 @@
const { addColumnIfNotExists } = require('../helpers');
exports.up = async function up(knex) {
// Add tls_reject_unauthorized column to email_configs table
// Default is true (validate certificates), false means ignore SSL/TLS certificate errors
await addColumnIfNotExists(knex, 'email_configs', 'tls_reject_unauthorized', (table) => {
table.boolean('tls_reject_unauthorized').defaultTo(true);
});
};
exports.down = async function down(knex) {
const hasColumn = await knex.schema.hasColumn('email_configs', 'tls_reject_unauthorized');
if (hasColumn) {
await knex.schema.alterTable('email_configs', (table) => {
table.dropColumn('tls_reject_unauthorized');
});
}
};
@@ -1,23 +1,56 @@
exports.up = async function(knex) {
// Add user upload settings to events table
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
// Add user upload settings to events table (check if columns exist first)
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (!hasAllowUserUploads) {
console.log('Adding allow_user_uploads column to events table...');
await knex.schema.alterTable('events', function(table) {
table.boolean('allow_user_uploads').defaultTo(false);
});
} else {
console.log('Column allow_user_uploads already exists in events table, skipping...');
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (!hasUploadCategoryId) {
console.log('Adding upload_category_id column to events table...');
await knex.schema.alterTable('events', function(table) {
table.integer('upload_category_id').references('id').inTable('photo_categories').onDelete('SET NULL');
});
} else {
console.log('Column upload_category_id already exists in events table, skipping...');
}
// Add uploaded_by field to photos table to track who uploaded
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (!hasUploadedBy) {
console.log('Adding uploaded_by column to photos table...');
await knex.schema.alterTable('photos', function(table) {
table.string('uploaded_by').defaultTo('admin'); // 'admin' or guest identifier
});
} else {
console.log('Column uploaded_by already exists in photos table, skipping...');
}
};
exports.down = async function(knex) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
table.dropColumn('upload_category_id');
});
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
const hasAllowUserUploads = await knex.schema.hasColumn('events', 'allow_user_uploads');
if (hasAllowUserUploads) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('allow_user_uploads');
});
}
const hasUploadCategoryId = await knex.schema.hasColumn('events', 'upload_category_id');
if (hasUploadCategoryId) {
await knex.schema.alterTable('events', function(table) {
table.dropColumn('upload_category_id');
});
}
const hasUploadedBy = await knex.schema.hasColumn('photos', 'uploaded_by');
if (hasUploadedBy) {
await knex.schema.alterTable('photos', function(table) {
table.dropColumn('uploaded_by');
});
}
};
+397 -144
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-backend",
"version": "1.1.14",
"version": "1.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-backend",
"version": "1.1.14",
"version": "1.1.15",
"dependencies": {
"@aws-sdk/client-s3": "^3.850.0",
"@aws-sdk/lib-storage": "^3.850.0",
@@ -29,7 +29,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
@@ -2099,6 +2099,123 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
"integrity": "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==",
"license": "ISC",
"dependencies": {
"string-width": "^5.1.2",
"string-width-cjs": "npm:string-width@^4.2.0",
"strip-ansi": "^7.0.1",
"strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
"wrap-ansi": "^8.1.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-regex": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-regex?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/ansi-styles": {
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/emoji-regex": {
"version": "9.2.2",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz",
"integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==",
"license": "MIT"
},
"node_modules/@isaacs/cliui/node_modules/string-width": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-5.1.2.tgz",
"integrity": "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==",
"license": "MIT",
"dependencies": {
"eastasianwidth": "^0.2.0",
"emoji-regex": "^9.2.2",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@isaacs/cliui/node_modules/strip-ansi": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^6.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/strip-ansi?sponsor=1"
}
},
"node_modules/@isaacs/cliui/node_modules/wrap-ansi": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-8.1.0.tgz",
"integrity": "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^6.1.0",
"string-width": "^5.0.1",
"strip-ansi": "^7.0.1"
},
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/@istanbuljs/load-nyc-config": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz",
@@ -2116,16 +2233,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"dev": true,
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/find-up": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
@@ -2140,20 +2247,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/js-yaml": {
"version": "3.14.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz",
"integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/locate-path": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
@@ -2206,13 +2299,6 @@
"node": ">=8"
}
},
"node_modules/@istanbuljs/load-nyc-config/node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"dev": true,
"license": "BSD-3-Clause"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.3",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.3.tgz",
@@ -3742,7 +3828,6 @@
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -3752,7 +3837,6 @@
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dev": true,
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1"
@@ -4102,44 +4186,67 @@
}
},
"node_modules/body-parser": {
"version": "1.20.3",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
"integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==",
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.2.1.tgz",
"integrity": "sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"content-type": "~1.0.5",
"debug": "2.6.9",
"depd": "2.0.0",
"destroy": "1.2.0",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"on-finished": "2.4.1",
"qs": "6.13.0",
"raw-body": "2.5.2",
"type-is": "~1.6.18",
"unpipe": "1.0.0"
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.0",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">= 0.8",
"npm": "1.2.8000 || >= 1.4.16"
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/body-parser/node_modules/debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"node_modules/body-parser/node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/body-parser/node_modules/qs": {
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/body-parser/node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"ms": "2.0.0"
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/body-parser/node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/bowser": {
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/bowser/-/bowser-2.11.0.tgz",
@@ -4150,6 +4257,7 @@
"version": "1.1.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz",
"integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
@@ -4669,6 +4777,7 @@
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"dev": true,
"license": "MIT"
},
"node_modules/concat-stream": {
@@ -4844,7 +4953,6 @@
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -4856,10 +4964,9 @@
}
},
"node_modules/debug": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz",
"integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==",
"devOptional": true,
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -5097,6 +5204,12 @@
"node": ">= 0.4"
}
},
"node_modules/eastasianwidth": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz",
"integrity": "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==",
"license": "MIT"
},
"node_modules/ecdsa-sig-formatter": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
@@ -5136,7 +5249,6 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"devOptional": true,
"license": "MIT"
},
"node_modules/enabled": {
@@ -5401,20 +5513,6 @@
"url": "https://opencollective.com/eslint"
}
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"dev": true,
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/esquery": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/esquery/-/esquery-1.6.0.tgz",
@@ -5826,6 +5924,34 @@
}
}
},
"node_modules/foreground-child": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz",
"integrity": "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==",
"license": "ISC",
"dependencies": {
"cross-spawn": "^7.0.6",
"signal-exit": "^4.0.1"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/foreground-child/node_modules/signal-exit": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/form-data": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz",
@@ -5915,12 +6041,6 @@
"node": ">= 8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -6037,21 +6157,23 @@
"license": "MIT"
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Glob versions prior to v9 are no longer supported",
"license": "ISC",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"license": "BlueOak-1.0.0",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "*"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -6070,6 +6192,30 @@
"node": ">= 6"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/globals": {
"version": "13.24.0",
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
@@ -6354,15 +6500,19 @@
}
},
"node_modules/iconv-lite": {
"version": "0.4.24",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
"integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.0.tgz",
"integrity": "sha512-cf6L2Ds3h57VVmkZe+Pn+5APsT7FpqJtEhhieDCvrE2MK5Qk9MyffgQyuxQTm6BChfeZNtcOLHp9IcWRVcIcBQ==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3"
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ieee754": {
@@ -6466,17 +6616,6 @@
"license": "ISC",
"optional": true
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
@@ -6570,7 +6709,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"devOptional": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -6657,7 +6795,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"devOptional": true,
"license": "ISC"
},
"node_modules/istanbul-lib-coverage": {
@@ -6747,6 +6884,21 @@
"node": ">=8"
}
},
"node_modules/jackspeak": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/jest": {
"version": "29.7.0",
"resolved": "https://registry.npmjs.org/jest/-/jest-29.7.0.tgz",
@@ -7351,9 +7503,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
@@ -7944,6 +8096,7 @@
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
"integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==",
"dev": true,
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
@@ -8611,6 +8764,12 @@
"node": ">=6"
}
},
"node_modules/package-json-from-dist": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz",
"integrity": "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==",
"license": "BlueOak-1.0.0"
},
"node_modules/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
@@ -8668,20 +8827,10 @@
"node": ">=8"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path-key": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -8693,6 +8842,40 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/path-scurry/node_modules/minipass": {
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz",
"integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==",
"license": "ISC",
"engines": {
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/path-to-regexp": {
"version": "0.1.12",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz",
@@ -9173,16 +9356,45 @@
}
},
"node_modules/raw-body": {
"version": "2.5.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz",
"integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==",
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "3.1.2",
"http-errors": "2.0.0",
"iconv-lite": "0.4.24",
"unpipe": "1.0.0"
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/raw-body/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
@@ -9641,7 +9853,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -9654,7 +9865,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -10044,7 +10254,21 @@
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/string-width-cjs": {
"name": "string-width",
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"emoji-regex": "^8.0.0",
@@ -10059,7 +10283,19 @@
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi-cjs": {
"name": "strip-ansi",
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
@@ -10657,7 +10893,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"devOptional": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"
@@ -10749,6 +10984,24 @@
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrap-ansi-cjs": {
"name": "wrap-ansi",
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
"strip-ansi": "^6.0.0"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/wrap-ansi?sponsor=1"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
+6 -3
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "1.1.14",
"version": "1.1.15",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -33,7 +33,7 @@
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
"joi": "^17.9.1",
"js-yaml": "^4.1.0",
"js-yaml": "^4.1.1",
"jsonwebtoken": "^9.0.0",
"knex": "^2.4.2",
"mime-types": "^3.0.1",
@@ -59,6 +59,9 @@
"overrides": {
"prebuild-install": {
"tar-fs": "2.1.4"
}
},
"glob": "^11.1.0",
"body-parser": "^2.2.1",
"js-yaml": "^4.1.1"
}
}
+3 -3
View File
@@ -324,9 +324,9 @@ async function initializeRateLimiters() {
// Note: Rate limiters will be initialized after database connection
// Body parsing middleware with increased limits for large uploads
app.use(express.json({ limit: '100mb' }));
app.use(express.urlencoded({ extended: true, limit: '100mb' }));
// Body parsing middleware with increased limits for large batch uploads
app.use(express.json({ limit: '500mb' }));
app.use(express.urlencoded({ extended: true, limit: '500mb' }));
// Request logging for API routes (with timestamps)
const apiRequestLogger = (req, res, next) => {
@@ -0,0 +1,99 @@
const request = require('supertest');
const express = require('express');
const buildChain = ({ firstResult, updateResult } = {}) => {
const chain = {
where: jest.fn().mockReturnThis(),
whereNot: jest.fn().mockReturnThis(),
select: jest.fn().mockReturnThis(),
update: jest.fn().mockResolvedValue(updateResult ?? 1),
first: jest.fn().mockResolvedValue(firstResult),
};
return chain;
};
jest.mock('../../database/db', () => {
const dbMock = jest.fn();
dbMock.raw = jest.fn();
dbMock.__setImplementations = (...chains) => {
dbMock.mockReset();
chains.forEach((chain) => {
dbMock.mockImplementationOnce(() => chain);
});
};
return {
db: dbMock,
logActivity: jest.fn().mockResolvedValue(undefined),
};
});
jest.mock('../../middleware/auth-enhanced-v2', () => ({
adminAuth: (_req, _res, next) => {
_req.admin = { id: 1, username: 'admin' };
next();
},
}));
const { db, logActivity } = require('../../database/db');
const adminAuthRouter = require('../adminAuth');
describe('adminAuth profile updates', () => {
const app = express();
app.use(express.json());
app.use('/auth/admin', adminAuthRouter);
beforeEach(() => {
jest.clearAllMocks();
});
it('updates the admin profile', async () => {
const updatedUser = {
id: 1,
username: 'newadmin',
email: 'newadmin@example.com',
must_change_password: false,
};
db.__setImplementations(
buildChain({ firstResult: null }), // email check
buildChain({ firstResult: null }), // username check
buildChain({ updateResult: 1 }), // update
buildChain({ firstResult: updatedUser }), // fetch updated user
);
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: updatedUser.username, email: updatedUser.email })
.expect(200);
expect(response.body).toEqual({ user: updatedUser });
expect(logActivity).toHaveBeenCalledWith(
'admin_profile_updated',
{ admin_id: 1, updated_fields: ['username', 'email'] },
null,
{ type: 'admin', id: 1, name: updatedUser.username }
);
});
it('rejects email conflicts', async () => {
db.__setImplementations(
buildChain({ firstResult: { id: 2 } })
);
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: 'newadmin', email: 'taken@example.com' })
.expect(409);
expect(response.body).toEqual({ error: 'Email is already in use by another admin' });
});
it('validates input', async () => {
const response = await request(app)
.put('/auth/admin/profile')
.send({ username: '', email: 'not-an-email' })
.expect(400);
expect(response.body.errors).toBeDefined();
});
});
@@ -0,0 +1,67 @@
const request = require('supertest');
const express = require('express');
jest.mock('../../database/db', () => {
const deleteMock = jest.fn().mockResolvedValue(5);
const chain = {
select: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
whereNull: jest.fn().mockReturnThis(),
whereNotNull: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
delete: deleteMock,
count: jest.fn().mockReturnThis(),
first: jest.fn().mockResolvedValue({ count: 0 }),
};
const dbMock = jest.fn(() => chain);
dbMock.raw = jest.fn();
dbMock.__chain = chain;
dbMock.__deleteMock = deleteMock;
return { db: dbMock };
});
jest.mock('../../middleware/auth-enhanced-v2', () => ({
adminAuth: (_req, _res, next) => next(),
}));
const { db } = require('../../database/db');
const notificationsRouter = require('../adminNotifications');
describe('adminNotifications routes', () => {
const app = express();
app.use(express.json());
app.use('/admin/notifications', notificationsRouter);
beforeEach(() => {
jest.clearAllMocks();
});
it('clears all notifications', async () => {
db.__deleteMock.mockResolvedValueOnce(8);
const response = await request(app)
.delete('/admin/notifications/clear-all')
.expect(200);
expect(db).toHaveBeenCalledWith('activity_logs');
expect(db.__deleteMock).toHaveBeenCalledTimes(1);
expect(response.body).toEqual({
message: 'All notifications cleared',
deletedCount: 8,
});
});
it('handles database errors when clearing notifications', async () => {
db.__deleteMock.mockRejectedValueOnce(new Error('boom'));
const response = await request(app)
.delete('/admin/notifications/clear-all')
.expect(500);
expect(response.body).toEqual({ error: 'Failed to clear notifications' });
});
});
+62
View File
@@ -159,6 +159,68 @@ router.post('/change-password', [
}
});
// Update admin profile
router.put('/profile', [
adminAuth,
body('username').trim().notEmpty().withMessage('Username is required'),
body('email').trim().isEmail().withMessage('Valid email is required')
], async (req, res) => {
try {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
const { username, email } = req.body;
const userId = req.admin.id;
// Check for email conflicts
const existingEmail = await db('admin_users')
.where('email', email)
.whereNot('id', userId)
.first();
if (existingEmail) {
return res.status(409).json({ error: 'Email is already in use by another admin' });
}
// Check username conflict (if multiple admins are supported)
const existingUsername = await db('admin_users')
.where('username', username)
.whereNot('id', userId)
.first();
if (existingUsername) {
return res.status(409).json({ error: 'Username is already in use by another admin' });
}
await db('admin_users')
.where('id', userId)
.update({
username,
email,
updated_at: new Date()
});
const updatedUser = await db('admin_users')
.select('id', 'username', 'email', 'must_change_password')
.where('id', userId)
.first();
await logActivity(
'admin_profile_updated',
{ admin_id: userId, updated_fields: ['username', 'email'] },
null,
{ type: 'admin', id: userId, name: username }
);
res.json({ user: updatedUser });
} catch (error) {
console.error('Admin profile update error:', error);
res.status(500).json({ error: 'Failed to update admin profile' });
}
});
// Logout
router.post('/logout', adminAuth, async (req, res) => {
try {
+48 -10
View File
@@ -18,7 +18,8 @@ router.get('/config', adminAuth, async (req, res) => {
smtp_user: '',
smtp_pass: '', // Don't send actual password
from_email: '',
from_name: ''
from_name: '',
tls_reject_unauthorized: true
});
}
@@ -53,7 +54,8 @@ router.post('/config', [
smtp_user,
smtp_pass,
from_email,
from_name
from_name,
tls_reject_unauthorized
} = req.body;
// Check if config exists
@@ -66,6 +68,7 @@ router.post('/config', [
smtp_user: smtp_user || '',
from_email,
from_name: from_name || 'Photo Sharing',
tls_reject_unauthorized: tls_reject_unauthorized !== false, // Default to true
updated_at: new Date()
};
@@ -137,6 +140,10 @@ router.post('/test', adminAuth, async (req, res) => {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined,
tls: {
// Allow ignoring SSL certificate errors when tls_reject_unauthorized is false
rejectUnauthorized: config.tls_reject_unauthorized !== false
},
logger: process.env.NODE_ENV === 'development',
debug: process.env.NODE_ENV === 'development'
};
@@ -173,26 +180,57 @@ router.post('/test', adminAuth, async (req, res) => {
} catch (error) {
console.error('Test email error:', error);
console.error('Error stack:', error.stack);
// Provide more specific error messages
let errorMessage = 'Failed to send test email';
// Provide more specific error messages with translation keys
let errorMessage = 'Error sending email';
let errorKey = 'email.errors.sendFailed';
let details = error.message;
let detailsKey = 'email.errors.unknownError';
if (error.code === 'ECONNREFUSED') {
errorMessage = 'Failed to connect to SMTP server';
errorKey = 'email.errors.connectionRefused';
details = 'Please check your SMTP host and port settings';
detailsKey = 'email.errors.checkHostPort';
} else if (error.code === 'EAUTH') {
errorMessage = 'SMTP authentication failed';
errorKey = 'email.errors.authFailed';
details = 'Please check your SMTP username and password';
detailsKey = 'email.errors.checkCredentials';
} else if (error.code === 'ESOCKET') {
errorMessage = 'Network error';
errorMessage = 'Network error connecting to SMTP server';
errorKey = 'email.errors.networkError';
details = 'Could not establish connection to SMTP server';
detailsKey = 'email.errors.connectionFailed';
} else if (error.code === 'ETIMEDOUT') {
errorMessage = 'Connection to SMTP server timed out';
errorKey = 'email.errors.timeout';
details = 'The server took too long to respond. Please check your network and SMTP settings.';
detailsKey = 'email.errors.timeoutDetails';
} else if (error.code === 'ENOTFOUND') {
errorMessage = 'SMTP server not found';
errorKey = 'email.errors.serverNotFound';
details = 'The SMTP host could not be resolved. Please verify the hostname.';
detailsKey = 'email.errors.checkHostname';
} else if (error.responseCode >= 500) {
errorMessage = 'SMTP server error';
errorKey = 'email.errors.serverError';
details = `Server returned error code ${error.responseCode}`;
detailsKey = 'email.errors.serverErrorDetails';
} else if (error.responseCode >= 400) {
errorMessage = 'Email rejected by server';
errorKey = 'email.errors.rejected';
details = error.response || 'The email was rejected. Check recipient address and settings.';
detailsKey = 'email.errors.rejectedDetails';
}
res.status(500).json({
res.status(500).json({
error: errorMessage,
errorKey: errorKey,
details: details,
code: error.code
detailsKey: detailsKey,
code: error.code,
responseCode: error.responseCode
});
}
});
+262 -105
View File
@@ -4,16 +4,36 @@ const path = require('path');
const fs = require('fs').promises;
const { db, logActivity } = require('../database/db');
const { adminAuth } = require('../middleware/auth');
const { generateThumbnail, ensureThumbnail } = require('../services/imageProcessor');
const { generateThumbnail, ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { escapeLikePattern } = require('../utils/sqlSecurity');
const { validateUploadedFiles } = require('../middleware/uploadValidation');
const { getMaxFilesPerUpload } = require('../services/uploadSettings');
const router = express.Router();
const { isVideoMimeType, validateFileType, createFileUploadValidator } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const parseCategoryId = (value) => {
if (value === undefined || value === null) return null;
if (typeof value === 'number' && Number.isInteger(value)) {
return value === 0 ? null : value;
}
if (typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed || trimmed === 'null') return null;
if (/^\d+$/.test(trimmed)) {
const parsed = parseInt(trimmed, 10);
if (!Number.isNaN(parsed)) {
return parsed === 0 ? null : parsed;
}
}
}
return null;
};
// Configure multer for file uploads
// IMPORTANT: Using synchronous functions to prevent file corruption
const storage = multer.diskStorage({
@@ -43,8 +63,6 @@ const storage = multer.diskStorage({
}
});
const { validateFileType } = require('../utils/fileSecurityUtils');
const upload = multer({
storage: storage,
limits: {
@@ -57,24 +75,22 @@ const upload = multer({
headerPairs: 2000 // Maximum number of header key-value pairs
},
fileFilter: (req, file, cb) => {
// Accept images only with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp'];
// Accept images and common video formats with proper validation
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
return cb(null, true);
} else {
cb(new Error('Only JPEG, PNG and WebP images are allowed'));
cb(new Error('Only JPEG, PNG, WebP images or MP4/MOV/WEBM videos are allowed'));
}
},
// Add abort on limit to stop processing when limits are exceeded
abortOnLimit: true
});
const { createFileUploadValidator } = require('../utils/fileSecurityUtils');
// Create content validator middleware
const validateUploadContent = createFileUploadValidator({
allowedTypes: ['image/jpeg', 'image/png', 'image/webp'],
allowedTypes: ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'],
maxFileSize: 50 * 1024 * 1024,
validateContent: true
});
@@ -167,26 +183,14 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
}
// Parse category_id to number if provided
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
// Determine photo type from category_id parameter (for backwards compatibility)
let photoType = 'individual'; // default
let categoryName = 'individual';
if (parsedCategoryId === 1 || category_id === 'collage') {
photoType = 'collage';
categoryName = 'collages';
} else if (parsedCategoryId === 2 || category_id === 'individual') {
photoType = 'individual';
categoryName = 'individual';
}
// For backwards compatibility, accept string values
if (category_id === 'collage') {
photoType = 'collage';
categoryName = 'collages';
}
const numericCategoryId = parseCategoryId(category_id);
const resolveCategoryName = (type) => {
if (type === 'collage') return 'collages';
if (type === 'video') return 'videos';
return 'individual';
};
// Create final destination directory
const finalDestPath = path.join(getStoragePath(), 'events/active', event.slug);
await fs.mkdir(finalDestPath, { recursive: true });
@@ -204,20 +208,49 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
const trx = await db.transaction();
try {
// Get initial counter for this batch based on photo type
const existingCount = await trx('photos')
.where({ event_id: eventId, type: photoType })
.count('id as count')
.first();
let batchCounter = (parseInt(existingCount.count) || 0) + 1;
const preparedBatch = batch.map((file) => {
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
const video = isVideoMimeType(resolvedMime, file?.originalname);
let inferredType = video ? 'video' : 'individual';
if (!video) {
if (numericCategoryId === 1 || category_id === 'collage') {
inferredType = 'collage';
} else if (numericCategoryId === 2 || category_id === 'individual') {
inferredType = 'individual';
}
}
return {
file,
resolvedMime,
isVideo: video,
photoType: inferredType
};
});
const typesInBatch = Array.from(new Set(preparedBatch.map((item) => item.photoType)));
const typeCounters = {};
if (typesInBatch.length > 0) {
const existingCounts = await trx('photos')
.where({ event_id: eventId })
.whereIn('type', typesInBatch)
.select('type')
.count('id as count')
.groupBy('type');
existingCounts.forEach((row) => {
typeCounters[row.type] = parseInt(row.count) || 0;
});
}
const batchPhotos = [];
const fileRenameOperations = []; // Store rename operations to do after commit
// First pass: prepare data and move files from temp to final location
for (let fileIndex = 0; fileIndex < batch.length; fileIndex++) {
const file = batch[fileIndex];
const counter = batchCounter + fileIndex;
for (let fileIndex = 0; fileIndex < preparedBatch.length; fileIndex++) {
const { file, resolvedMime, isVideo, photoType } = preparedBatch[fileIndex];
const tempPath = file.path; // Original temp path
try {
@@ -226,12 +259,15 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
if (tempStats.size === 0) {
throw new Error('File is empty - upload may have been interrupted');
}
typeCounters[photoType] = (typeCounters[photoType] || 0) + 1;
const counter = typeCounters[photoType];
// Generate new filename
const extension = path.extname(file.originalname);
const newFilename = generatePhotoFilename(
event.event_name,
categoryName,
resolveCategoryName(photoType),
counter,
extension
);
@@ -248,7 +284,10 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
path: relativePath,
thumbnail_path: null, // Will generate after successful commit
type: photoType,
size_bytes: tempStats.size // Use actual file size from stat
size_bytes: tempStats.size, // Use actual file size from stat
category_id: numericCategoryId,
source_origin: 'managed',
mime_type: resolvedMime
};
batchPhotos.push(photoData);
@@ -258,7 +297,8 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
tempPath: tempPath,
finalPath: finalPath,
filename: newFilename,
photoData: photoData
photoData: photoData,
isVideo
});
} catch (error) {
console.error(`Error preparing file ${file.originalname}:`, error);
@@ -268,7 +308,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Insert all photos in this batch
if (batchPhotos.length > 0) {
console.log(`Inserting batch of ${batchPhotos.length} photos with type: ${photoType}`);
console.log(`Inserting batch of ${batchPhotos.length} files with types: ${typesInBatch.join(', ')}`);
const insertedIds = await trx('photos').insert(batchPhotos).returning('id');
@@ -293,27 +333,31 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
}
// Generate thumbnail with final path
let thumbnailPath = null;
try {
thumbnailPath = await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
await db('photos')
.where({ id: photoId })
.update({ thumbnail_path: thumbnailPath });
}
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
let thumbnailPath = null;
try {
thumbnailPath = operation.isVideo
? await generateVideoPlaceholder(operation.filename)
: await generateThumbnail(operation.finalPath);
// Update the database with thumbnail path
if (thumbnailPath && insertedIds[idx]) {
const photoId = insertedIds[idx]?.id || insertedIds[idx];
await db('photos')
.where({ id: photoId })
.update({ thumbnail_path: thumbnailPath });
}
} catch (thumbError) {
console.error(`Thumbnail generation failed for ${operation.filename}:`, thumbError.message);
}
// Add to successful uploads
uploadedPhotos.push({
id: insertedIds[idx]?.id || insertedIds[idx],
filename: operation.filename,
size: operation.photoData.size_bytes,
category_id: operation.photoData.category_id
category_id: operation.photoData.category_id,
type: operation.photoData.type,
mime_type: operation.photoData.mime_type
});
} catch (moveError) {
console.error(`Failed to move file ${operation.tempPath} to ${operation.finalPath}:`, moveError);
@@ -380,7 +424,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Prepare response
const totalAttempted = req.files.length + (req.invalidFiles ? req.invalidFiles.length : 0);
const response = {
message: `Successfully uploaded ${uploadedPhotos.length} photos`,
message: `Successfully uploaded ${uploadedPhotos.length} files`,
photos: uploadedPhotos,
totalFiles: totalAttempted,
successCount: uploadedPhotos.length,
@@ -390,7 +434,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
// Include error details if any files failed
if (totalInvalidFiles.length > 0) {
response.errors = totalInvalidFiles;
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} photos. ${totalInvalidFiles.length} failed.`;
response.message = `Uploaded ${uploadedPhotos.length} of ${totalAttempted} files. ${totalInvalidFiles.length} failed.`;
}
res.json(response);
@@ -407,7 +451,7 @@ router.post('/:eventId/upload', adminAuth, uploadTimeout(600000), async (req, re
}
}
res.status(500).json({ error: 'Failed to upload photos' });
res.status(500).json({ error: 'Failed to upload files' });
}
});
@@ -437,7 +481,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
// Delete thumbnail if exists
if (photo.thumbnail_path) {
const thumbPath = path.join(storagePath, 'events/active', photo.thumbnail_path);
const thumbPath = path.join(storagePath, photo.thumbnail_path);
try {
// Check if file exists before attempting to delete
await fs.access(thumbPath);
@@ -473,22 +517,55 @@ router.patch('/:eventId/photos/:photoId', adminAuth, async (req, res) => {
try {
const { eventId, photoId } = req.params;
const { category_id } = req.body;
// Verify photo belongs to event
const photo = await db('photos')
.where({ id: photoId, event_id: eventId })
.first();
if (!photo) {
return res.status(404).json({ error: 'Photo not found' });
}
// Prepare update data
const updateData = {
updated_at: new Date()
};
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (category_id === 'individual' || category_id === 'collage') {
updateData.type = category_id;
updateData.category_id = null; // Clear legacy category_id
} else if (category_id === null || category_id === undefined) {
// Explicitly clear category
updateData.category_id = null;
} else {
// Handle numeric category IDs from photo_categories table
const numericCategoryId = parseInt(category_id, 10);
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
}
// Update photo
const normalizedCategoryId = parseCategoryId(category_id);
await db('photos')
.where({ id: photoId, event_id: eventId })
.update(updateData);
// Fetch and return updated photo for confirmation
const updatedPhoto = await db('photos')
.where({ id: photoId })
.update({ category_id: category_id || null });
res.json({ message: 'Photo updated successfully' });
.first();
res.json({
message: 'Photo updated successfully',
photo: updatedPhoto
});
} catch (error) {
console.error('Error updating photo:', error);
res.status(500).json({ error: 'Failed to update photo' });
@@ -568,33 +645,52 @@ router.post('/:eventId/photos/bulk-update', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { photoIds, updates } = req.body;
if (!Array.isArray(photoIds) || photoIds.length === 0) {
return res.status(400).json({ error: 'Invalid photo IDs' });
}
// Verify all photos belong to the event
const photoCount = await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.count('id as count')
.first();
if (photoCount.count !== photoIds.length) {
if (parseInt(photoCount.count) !== photoIds.length) {
return res.status(400).json({ error: 'Some photos do not belong to this event' });
}
// Update photos
const updateData = {};
// Prepare update data
const updateData = {
updated_at: new Date()
};
if (updates.category_id !== undefined) {
updateData.category_id = updates.category_id || null;
// Handle type-based categories ('individual' or 'collage')
// These are string values that map to the photo.type field
if (updates.category_id === 'individual' || updates.category_id === 'collage') {
updateData.type = updates.category_id;
updateData.category_id = null; // Clear legacy category_id
} else if (updates.category_id === null) {
// Explicitly clear category
updateData.category_id = null;
} else {
// Handle numeric category IDs from photo_categories table
const numericCategoryId = parseInt(updates.category_id, 10);
if (!isNaN(numericCategoryId)) {
updateData.category_id = numericCategoryId;
} else {
updateData.category_id = null;
}
}
}
await db('photos')
.whereIn('id', photoIds)
.where('event_id', eventId)
.update(updateData);
res.json({ message: `${photoIds.length} photos updated successfully` });
} catch (error) {
console.error('Error bulk updating photos:', error);
@@ -638,17 +734,25 @@ router.get('/:eventId/photos/:photoId/download', adminAuth, async (req, res) =>
router.get('/:eventId/photos', adminAuth, async (req, res) => {
try {
const { eventId } = req.params;
const { category_id, type, search, sort = 'date', order = 'desc' } = req.query;
const { category_id, type, media_type, search, sort = 'date', order = 'desc' } = req.query;
let query = db('photos')
.leftJoin('photo_categories as pc', 'pc.id', 'photos.category_id')
.where({ 'photos.event_id': eventId })
.select('photos.*');
.select(
'photos.*',
'pc.name as category_display_name',
'pc.slug as category_display_slug'
);
// Filter by type (individual/collage) - category_id maps to type
if (category_id !== undefined) {
if (category_id === '' || category_id === '0') {
// For backwards compatibility, empty category means no filter
// Don't filter anything
if (category_id === '') {
// No filter when empty string is provided
} else if (category_id === '0') {
query = query.whereNull('photos.category_id');
} else if (/^\d+$/.test(category_id)) {
query = query.where('photos.category_id', parseInt(category_id, 10));
} else if (category_id === 'individual' || category_id === 'collage') {
query = query.where({ 'photos.type': category_id });
}
@@ -658,6 +762,20 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
if (type) {
query = query.where({ 'photos.type': type });
}
if (media_type === 'video') {
query = query.where((qb) => {
qb.where('photos.type', 'video')
.orWhere('photos.mime_type', 'like', 'video/%');
});
} else if (media_type === 'photo') {
query = query.where((qb) => {
qb.whereNot('photos.type', 'video')
.andWhere(function(inner) {
inner.whereNull('photos.mime_type').orWhere('photos.mime_type', 'not like', 'video/%');
});
});
}
// Search by filename
if (search) {
@@ -674,7 +792,11 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
}
const photos = await query.orderBy(orderByColumn, order);
if (photos.length === 0) {
return res.json({ photos: [] });
}
// Get comment counts separately
const commentCounts = await db('photo_feedback')
.whereIn('photo_id', photos.map(p => p.id))
@@ -691,26 +813,37 @@ router.get('/:eventId/photos', adminAuth, async (req, res) => {
});
res.json({
photos: photos.map(photo => ({
id: photo.id,
filename: photo.filename,
// Use the correct admin photos router base for serving images
url: `/admin/photos/${eventId}/photo/${photo.id}`,
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_slug: photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
}))
photos: photos.map(photo => {
const mediaType = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video' ? 'video' : 'photo';
const categoryName = photo.category_display_name
|| (photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages');
const normalizedCategoryId = photo.category_id !== null && photo.category_id !== undefined
? (Number.isNaN(Number(photo.category_id)) ? photo.category_id : Number(photo.category_id))
: null;
return ({
id: photo.id,
filename: photo.filename,
// Use the correct admin photos router base for serving images
url: `/admin/photos/${eventId}/photo/${photo.id}`,
// Always expose a thumbnail URL; backend will generate on demand if missing
thumbnail_url: `/admin/photos/${eventId}/thumbnail/${photo.id}`,
type: photo.type,
category_id: normalizedCategoryId,
mime_type: photo.mime_type,
media_type: mediaType,
category_name: categoryName,
category_slug: photo.category_display_slug || photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
comment_count: commentMap[photo.id] || 0,
like_count: photo.like_count || 0,
favorite_count: photo.favorite_count || 0
});
})
});
} catch (error) {
console.error('Error fetching photos:', error);
@@ -742,8 +875,10 @@ router.get('/:eventId/photo/:photoId', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Photo file not found' });
}
const mimeType = photo.mime_type || `image/${path.extname(photo.filename).slice(1)}`;
// Set appropriate headers
res.setHeader('Content-Type', `image/${path.extname(photo.filename).slice(1)}`);
res.setHeader('Content-Type', mimeType);
res.setHeader('Cache-Control', 'private, max-age=3600');
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
@@ -769,8 +904,30 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, async (req, res) => {
return res.status(404).json({ error: 'Photo not found' });
}
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
// Ensure thumbnail exists and is valid, regenerate if needed
const thumbnailPath = await ensureThumbnail(photo);
let thumbnailPath = photo.thumbnail_path;
const thumbMissing = !thumbnailPath || !(await (async () => {
try {
const fs = require('fs').promises;
await fs.access(path.join(getStoragePath(), thumbnailPath));
return true;
} catch {
return false;
}
})());
if (isVideo) {
if (!thumbnailPath || thumbMissing) {
const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true });
if (regenerated) {
thumbnailPath = regenerated;
await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated });
}
}
} else {
thumbnailPath = await ensureThumbnail(photo);
}
if (!thumbnailPath) {
console.error(`Failed to generate thumbnail for photo ${photoId}`);
+4 -2
View File
@@ -191,7 +191,8 @@ router.put('/branding', adminAuth, async (req, res) => {
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode
logo_display_mode,
hide_powered_by
} = req.body;
const brandingSettings = {
@@ -211,7 +212,8 @@ router.put('/branding', adminAuth, async (req, res) => {
logo_position,
logo_display_header,
logo_display_hero,
logo_display_mode
logo_display_mode,
hide_powered_by
};
// Handle favicon deletion if empty string or null is provided
+112 -33
View File
@@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService');
const logger = require('../utils/logger');
const { resolvePhotoFilePath } = require('../services/photoResolver');
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
const { ensureThumbnail, generateVideoPlaceholder } = require('../services/imageProcessor');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
@@ -248,10 +250,17 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
.distinct('type')
.orderBy('type', 'asc');
const resolveCategoryName = (type, mimeType, filename) => {
if (type === 'video' || isVideoMimeType(mimeType, filename)) return 'Videos';
if (type === 'individual') return 'Individual Photos';
if (type === 'collage') return 'Collages';
return type || 'Uncategorized';
};
// Convert types to category-like objects
const categories = categoryResults.map(result => ({
id: result.type,
name: result.type === 'individual' ? 'Individual Photos' : 'Collages',
name: resolveCategoryName(result.type),
slug: result.type,
is_global: false
}));
@@ -292,10 +301,13 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
},
categories: categories,
photos: photos.map(photo => {
const useJwtUrl = (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
const mediaType = isVideo ? 'video' : 'photo';
const useJwtUrl = isVideo || (protectionSettings.protection_level === 'basic' || protectionSettings.protection_level === 'standard');
const photoUrl = useJwtUrl ?
`/api/gallery/${req.params.slug}/photo/${photo.id}` :
`/api/secure-images/${req.params.slug}/secure/${photo.id}/{{token}}`;
const categoryName = resolveCategoryName(photo.type, photo.mime_type, photo.filename);
return {
id: photo.id,
@@ -306,12 +318,14 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
download_url_template: `/api/secure-images/${req.params.slug}/secure-download/${photo.id}/{{token}}`,
type: photo.type,
category_id: photo.type,
category_name: photo.type === 'individual' ? 'Individual Photos' : 'Collages',
category_name: categoryName,
category_slug: photo.type,
size: photo.size_bytes,
uploaded_at: photo.uploaded_at,
media_type: mediaType,
mime_type: photo.mime_type,
// Fixed: Use the calculated useJwtUrl variable instead of recalculating
requires_token: !useJwtUrl,
requires_token: !useJwtUrl && !isVideo,
// Feedback data
has_feedback: (commentMap[photo.id] > 0 || photo.average_rating > 0 || photo.like_count > 0),
average_rating: photo.average_rating || 0,
@@ -345,6 +359,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
return res.status(404).json({ error: 'Photo not found' });
}
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
// Update download count
await db('photos').where('id', photoId).increment('download_count', 1);
@@ -373,7 +388,7 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -386,6 +401,9 @@ router.get('/:slug/download/:photoId', verifyGalleryAccess, async (req, res) =>
res.send(watermarkedBuffer);
} else {
// Send original file
if (isVideo) {
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
}
res.download(filePath, photo.filename, (downloadError) => {
if (downloadError) {
logger.error('Error streaming gallery download', {
@@ -463,14 +481,16 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
let archiveName;
if (hasMultipleTypes) {
// Use photo type as folder
const folderName = photo.type === 'individual' ? 'Individual Photos' : 'Collages';
const folderName = photo.type === 'individual' ? 'Individual Photos' : photo.type === 'video' ? 'Videos' : 'Collages';
archiveName = path.join(folderName, photo.filename);
} else {
// No folders, just the filename
archiveName = photo.filename;
}
if (watermarkSettings && watermarkSettings.enabled) {
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name: archiveName });
@@ -565,7 +585,9 @@ router.post('/:slug/download-selected', verifyGalleryAccess, async (req, res) =>
try {
const filePath = resolvePhotoFilePath(req.event, photo);
const name = photo.filename || `photo-${photo.id}.jpg`;
if (watermarkSettings && watermarkSettings.enabled) {
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
try {
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
archive.append(watermarkedBuffer, { name });
@@ -615,9 +637,13 @@ router.get('/:slug/photo/:photoId',
async (req, res) => {
try {
const { photoId } = req.params;
const numericPhotoId = parseInt(photoId, 10);
if (!Number.isInteger(numericPhotoId)) {
return res.status(400).json({ error: 'Invalid photo id' });
}
const photo = await db('photos')
.where({ id: photoId, event_id: req.event.id })
.where({ id: numericPhotoId, event_id: req.event.id })
.first();
@@ -625,10 +651,11 @@ router.get('/:slug/photo/:photoId',
return res.status(404).json({ error: 'Photo not found' });
}
const isVideo = (photo.mime_type && photo.mime_type.startsWith('video/')) || photo.type === 'video';
// Check protection level - basic and standard protection allow direct JWT access
const protectionLevel = req.event.protection_level || 'standard';
if (protectionLevel === 'enhanced' || protectionLevel === 'maximum') {
if (!isVideo && (protectionLevel === 'enhanced' || protectionLevel === 'maximum')) {
// For enhanced/maximum protection, redirect to secure endpoint
return res.status(302).json({
error: 'Secure access required',
@@ -653,7 +680,7 @@ router.get('/:slug/photo/:photoId',
// Get watermark settings
const watermarkSettings = await watermarkService.getWatermarkSettings();
if (watermarkSettings && watermarkSettings.enabled) {
if (watermarkSettings && watermarkSettings.enabled && !isVideo) {
// Apply watermark and send
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
@@ -672,6 +699,9 @@ router.get('/:slug/photo/:photoId',
});
// Ensure absolute path for res.sendFile
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
if (isVideo) {
res.set({ 'Content-Type': photo.mime_type || 'application/octet-stream' });
}
res.sendFile(absolutePath);
}
} catch (error) {
@@ -692,32 +722,62 @@ router.get('/:slug/thumbnail/:photoId',
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' });
const numericPhotoId = parseInt(photoId, 10);
if (!Number.isInteger(numericPhotoId)) {
return res.status(400).json({ error: 'Invalid photo id' });
}
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
const photo = await db('photos')
.where({ id: numericPhotoId, event_id: req.event.id })
.first();
if (!photo) {
return res.status(404).json({ error: 'Thumbnail not found' });
}
const isVideo = (photo.type === 'video') || isVideoMimeType(photo.mime_type, photo.filename);
let thumbnailPath = photo.thumbnail_path;
let thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null;
if (isVideo) {
const fs = require('fs').promises;
const missing = !thumbFilePath || !(await (async () => { try { await fs.access(thumbFilePath); return true; } catch { return false; } })());
if (missing) {
const regenerated = await generateVideoPlaceholder(photo.filename, { regenerate: true });
if (regenerated) {
thumbnailPath = regenerated;
thumbFilePath = path.join(getStoragePath(), regenerated);
await db('photos').where({ id: photo.id }).update({ thumbnail_path: regenerated });
}
}
} else {
thumbnailPath = await ensureThumbnail(photo);
thumbFilePath = thumbnailPath ? path.join(getStoragePath(), thumbnailPath) : null;
}
if (!thumbFilePath) {
return res.status(404).json({ error: 'Thumbnail not found' });
}
// Check if file exists
const fs = require('fs').promises;
try {
await fs.access(thumbPath);
await fs.access(thumbFilePath);
} catch (error) {
return res.status(404).json({ error: 'Thumbnail file not found' });
}
// Log thumbnail access
await secureImageService.logImageAccess(
photoId,
req.event.id,
req.clientInfo,
'thumbnail'
);
try {
await secureImageService.logImageAccess(
numericPhotoId,
req.event.id,
req.clientInfo,
'thumbnail'
);
} catch (logErr) {
logger.warn('Thumbnail access log failed', { photoId, eventId: req.event.id, error: logErr.message });
}
// Set appropriate headers with enhanced security
res.set({
@@ -729,8 +789,14 @@ router.get('/:slug/thumbnail/:photoId',
});
// Send file
res.sendFile(path.resolve(thumbPath));
res.sendFile(path.resolve(thumbFilePath));
} catch (error) {
console.error('Thumbnail route error', {
message: error?.message,
stack: error?.stack,
photoId: req.params.photoId,
eventId: req.event?.id,
});
logger.error('Error serving thumbnail:', {
error: error.message,
photoId: req.params.photoId,
@@ -800,22 +866,35 @@ router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
router.post('/:eventId/upload', verifyGalleryAccess, async (req, res) => {
try {
const eventId = parseInt(req.params.eventId);
// Verify the event matches the token
if (req.event.id !== eventId) {
return res.status(403).json({ error: 'Access denied' });
}
// Check if user uploads are allowed
if (!req.event.allow_user_uploads) {
return res.status(403).json({ error: 'User uploads are not allowed for this event' });
}
// Ensure temp upload directory exists
const fs = require('fs');
const tempUploadDir = '/tmp/uploads/';
if (!fs.existsSync(tempUploadDir)) {
try {
fs.mkdirSync(tempUploadDir, { recursive: true, mode: 0o755 });
logger.info('Created temp upload directory:', tempUploadDir);
} catch (mkdirErr) {
logger.error('Failed to create temp upload directory:', mkdirErr);
return res.status(500).json({ error: 'Server configuration error: unable to create upload directory' });
}
}
// Import multer and photo processing
const multer = require('multer');
const upload = multer({
dest: '/tmp/uploads/',
limits: {
const upload = multer({
dest: tempUploadDir,
limits: {
fileSize: 50 * 1024 * 1024, // 50MB
files: 10 // Max 10 files at once
},
+6 -2
View File
@@ -9,7 +9,7 @@ let lastConfigHash = null;
// Generate hash from config for change detection
function generateConfigHash(config) {
const crypto = require('crypto');
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}`;
const configString = `${config.smtp_host}:${config.smtp_port}:${config.smtp_user}:${config.smtp_pass}:${config.smtp_secure}:${config.tls_reject_unauthorized}`;
return crypto.createHash('md5').update(configString).digest('hex');
}
@@ -40,7 +40,11 @@ async function initializeTransporter(forceReinit = false) {
auth: config.smtp_user ? {
user: config.smtp_user,
pass: config.smtp_pass
} : undefined
} : undefined,
tls: {
// Allow ignoring SSL certificate errors when tls_reject_unauthorized is false
rejectUnauthorized: config.tls_reject_unauthorized !== false
}
});
// Verify configuration
+18 -7
View File
@@ -3,8 +3,10 @@ const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { formatBoolean } = require('../utils/dbCompat');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const logger = require('../utils/logger');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const WATCH_PATH = () => path.join(getStoragePath(), 'events/active');
@@ -47,9 +49,11 @@ async function processNewPhoto(filePath) {
const eventSlug = pathParts[0];
const photoType = pathParts[1] === 'collages' ? 'collage' : 'individual';
// Check if this is an image file
// Check if this is an image or video file
const ext = path.extname(filePath).toLowerCase();
if (!['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
const detectedMime = mime.lookup(filePath) || '';
const isVideo = isVideoMimeType(detectedMime, filePath) || ['.mp4', '.mov', '.webm'].includes(ext);
if (!isVideo && !['.jpg', '.jpeg', '.png', '.webp'].includes(ext)) return;
// Skip temporary upload files
const filename = path.basename(filePath);
@@ -65,11 +69,17 @@ async function processNewPhoto(filePath) {
// Get file stats
const stats = await fs.stat(filePath);
// Generate thumbnail
const thumbnailPath = await generateThumbnail(filePath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(filename);
} else {
thumbnailPath = await generateThumbnail(filePath);
}
// Calculate relative thumbnail path
const relativeThumbPath = thumbnailPath; // thumbnailPath is already relative to storage root
const mimeType = detectedMime || (isVideo ? 'video/mp4' : 'image/jpeg');
// Check if photo already exists
const existingPhoto = await db('photos')
@@ -83,8 +93,9 @@ async function processNewPhoto(filePath) {
filename: path.basename(filePath),
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: stats.size
type: isVideo ? 'video' : photoType,
size_bytes: stats.size,
mime_type: mimeType
});
logger.info(`Added new photo: ${relativePath}`);
+81 -5
View File
@@ -18,6 +18,29 @@ const DEFAULT_THUMBNAIL_FORMAT = 'jpeg';
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
const getThumbnailPath = () => path.join(getStoragePath(), 'thumbnails');
// Helper to parse setting value (handles both JSON-encoded and plain values)
function parseSettingValue(value) {
if (value === null || value === undefined) {
return null;
}
// Try to parse as JSON first (in case it's a JSON-encoded string like '"cover"')
try {
return JSON.parse(value);
} catch (e) {
// If it's not valid JSON, return the raw value
return value;
}
}
// Validate that fit value is valid for Sharp
function validateFitValue(fit) {
const validFitValues = ['cover', 'contain', 'fill', 'inside', 'outside'];
if (fit && validFitValues.includes(fit)) {
return fit;
}
return DEFAULT_THUMBNAIL_FIT;
}
// Get thumbnail settings from database
async function getThumbnailSettings() {
try {
@@ -30,16 +53,19 @@ async function getThumbnailSettings() {
'thumbnail_format'
])
.select('setting_key', 'setting_value');
const settingsMap = {};
settings.forEach(s => {
settingsMap[s.setting_key] = s.setting_value;
settingsMap[s.setting_key] = parseSettingValue(s.setting_value);
});
// Parse and validate fit value
const fitValue = validateFitValue(settingsMap.thumbnail_fit);
return {
width: parseInt(settingsMap.thumbnail_width) || DEFAULT_THUMBNAIL_WIDTH,
height: parseInt(settingsMap.thumbnail_height) || DEFAULT_THUMBNAIL_HEIGHT,
fit: settingsMap.thumbnail_fit || DEFAULT_THUMBNAIL_FIT,
fit: fitValue,
quality: parseInt(settingsMap.thumbnail_quality) || DEFAULT_THUMBNAIL_QUALITY,
format: settingsMap.thumbnail_format || DEFAULT_THUMBNAIL_FORMAT
};
@@ -211,4 +237,54 @@ async function ensureThumbnail(photo) {
return null;
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail };
async function generateVideoPlaceholder(originalFilename, options = {}) {
const parsed = path.parse(originalFilename || '');
const baseName = parsed.name || 'video';
const thumbnailDir = getThumbnailPath();
const thumbnailFilename = `thumb_${baseName}.jpg`;
const thumbnailPath = path.join(thumbnailDir, thumbnailFilename);
const settings = await getThumbnailSettings();
const width = settings.width || DEFAULT_THUMBNAIL_WIDTH;
const height = settings.height || DEFAULT_THUMBNAIL_HEIGHT;
if (options.regenerate) {
try {
await fs.unlink(thumbnailPath);
} catch (_) {
// ignore if missing
}
}
try {
await fs.mkdir(thumbnailDir, { recursive: true });
const svg = `
<svg width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="grad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#0f172a" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#1e293b" stop-opacity="0.9"/>
</linearGradient>
</defs>
<rect width="${width}" height="${height}" rx="18" fill="url(#grad)"/>
<circle cx="${width / 2}" cy="${height / 2}" r="${Math.min(width, height) / 6}" fill="rgba(255,255,255,0.85)"/>
<polygon points="${width / 2 - 10},${height / 2 - 14} ${width / 2 - 10},${height / 2 + 14} ${width / 2 + 16},${height / 2}" fill="#0f172a"/>
<text x="50%" y="${height - 18}" font-family="Arial, sans-serif" font-size="16" fill="rgba(255,255,255,0.9)" text-anchor="middle">
VIDEO
</text>
</svg>
`;
await sharp(Buffer.from(svg))
.resize(width, height, { fit: 'cover' })
.jpeg({ quality: settings.quality || DEFAULT_THUMBNAIL_QUALITY })
.toFile(thumbnailPath);
return path.relative(getStoragePath(), thumbnailPath);
} catch (error) {
logger.error('Failed to generate video placeholder thumbnail:', error.message);
return null;
}
}
module.exports = { generateThumbnail, isThumbnailValid, ensureThumbnail, generateVideoPlaceholder };
+113 -28
View File
@@ -1,27 +1,55 @@
const path = require('path');
const fs = require('fs').promises;
const { db } = require('../database/db');
const { generateThumbnail } = require('./imageProcessor');
const { generateThumbnail, generateVideoPlaceholder } = require('./imageProcessor');
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
const { isVideoMimeType } = require('../utils/fileSecurityUtils');
const mime = require('mime-types');
// Get storage path from environment or default
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
function normalizeFiles(files) {
if (!files) return [];
if (Array.isArray(files)) return files.filter(Boolean);
// Multer may expose files as an iterable object
if (typeof files[Symbol.iterator] === 'function') {
return Array.from(files).filter(Boolean);
// Handle null, undefined, or falsy values
if (!files) {
console.log('[normalizeFiles] No files provided');
return [];
}
// Handle arrays
if (Array.isArray(files)) {
const validFiles = files.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from array`);
return validFiles;
}
// Handle iterable objects (some multer configurations)
try {
if (typeof files === 'object' && typeof files[Symbol.iterator] === 'function') {
const validFiles = Array.from(files).filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from iterable`);
return validFiles;
}
} catch (err) {
console.warn('[normalizeFiles] Failed to iterate files object:', err.message);
}
// Handle plain objects (multer fieldname mapping)
if (typeof files === 'object') {
return Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
try {
const validFiles = Object.values(files)
.flatMap((value) => (Array.isArray(value) ? value : [value]))
.filter(Boolean);
console.log(`[normalizeFiles] Normalized ${validFiles.length} files from object`);
return validFiles;
} catch (err) {
console.warn('[normalizeFiles] Failed to process files object:', err.message);
return [];
}
}
// Unexpected type
console.warn('[normalizeFiles] Unexpected files type:', typeof files);
return [];
}
@@ -44,12 +72,15 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
const trx = await db.transaction();
try {
const resolvedMime = file?.mimetype || mime.lookup(file?.originalname || '') || 'application/octet-stream';
const isVideo = isVideoMimeType(resolvedMime, file?.originalname);
// Count existing photos to generate sequence number
let counter = 1;
let photoType = 'individual'; // default type
let photoType = isVideo ? 'video' : 'individual'; // default type
// If categoryId is provided and matches photo types, use it as type
if (categoryId === 'collage') {
if (!isVideo && categoryId === 'collage') {
photoType = 'collage';
}
@@ -64,7 +95,7 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
// Generate new filename
const extension = path.extname(file.originalname);
const categoryName = photoType === 'collage' ? 'collages' : 'individual';
const categoryName = photoType === 'collage' ? 'collages' : (isVideo ? 'videos' : 'individual');
const newFilename = generatePhotoFilename(
event.event_name,
categoryName,
@@ -80,24 +111,57 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
const tempPath = file?.path || file?.filepath || file?.tempFilePath;
if (!tempPath) {
throw new Error('Uploaded file is missing a temporary path');
const fileInfo = JSON.stringify({
originalname: file?.originalname,
mimetype: file?.mimetype,
size: file?.size,
availableKeys: Object.keys(file || {})
});
throw new Error(`Uploaded file is missing a temporary path. File info: ${fileInfo}`);
}
// Verify temp file exists before copying
try {
await fs.access(tempPath);
} catch (accessErr) {
console.error(`Temp file not accessible: ${tempPath}`, {
originalname: file?.originalname,
error: accessErr.message
});
throw new Error(`Uploaded file not found at temporary location: ${tempPath}`);
}
// Use copyFile and unlink instead of rename to avoid cross-device issues
try {
await fs.copyFile(tempPath, newPath);
console.log(`Successfully copied ${file.originalname} to ${newPath}`);
} catch (copyErr) {
console.error(`Failed to copy file from ${tempPath} to ${newPath}:`, copyErr);
throw new Error(`Failed to copy uploaded file: ${copyErr.message}`);
} finally {
// Clean up temp file with better error handling
try {
await fs.unlink(tempPath);
console.log(`Cleaned up temp file: ${tempPath}`);
} catch (unlinkErr) {
// Only warn if file exists but couldn't be deleted
// ENOENT means file was already deleted, which is fine
if (unlinkErr?.code !== 'ENOENT') {
console.warn(`Failed to clean up temp upload ${tempPath}:`, unlinkErr);
console.warn(`Failed to clean up temp upload ${tempPath}:`, {
error: unlinkErr.message,
code: unlinkErr.code
});
}
}
}
// Generate thumbnail
const thumbnailPath = await generateThumbnail(newPath);
// Generate thumbnail or placeholder
let thumbnailPath = null;
if (isVideo) {
thumbnailPath = await generateVideoPlaceholder(newFilename);
} else {
thumbnailPath = await generateThumbnail(newPath);
}
// Calculate relative paths
const storagePath = getStoragePath();
@@ -119,20 +183,22 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
source_origin: 'managed',
mime_type: resolvedMime
})
.returning('id');
} else {
insertResult = await trx('photos').insert({
event_id: eventId,
filename: newFilename,
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed'
});
path: relativePath,
thumbnail_path: relativeThumbPath,
type: photoType,
size_bytes: file.size,
uploaded_by: uploadedBy,
source_origin: 'managed',
mime_type: resolvedMime
});
}
const insertedId = Array.isArray(insertResult)
@@ -152,12 +218,31 @@ async function processUploadedPhotos(files, eventId, uploadedBy = 'admin', categ
id: photoId,
filename: newFilename,
size: file.size,
type: photoType
type: photoType,
media_type: isVideo ? 'video' : 'photo'
});
console.log(`Successfully processed file ${file.originalname} (ID: ${photoId})`);
} catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
if (trx) await trx.rollback();
console.error(`Error processing file ${file.originalname}:`, {
error: error.message,
stack: error.stack,
originalname: file.originalname,
mimetype: file.mimetype,
size: file.size,
tempPath: file?.path || file?.filepath || file?.tempFilePath
});
if (trx) {
try {
await trx.rollback();
} catch (rollbackErr) {
console.error('Failed to rollback transaction:', rollbackErr);
}
}
// Continue with other files
// Note: Individual file failures don't stop the entire upload batch
}
}
+6 -2
View File
@@ -12,8 +12,12 @@ const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '.
function resolvePhotoFilePath(event, photo) {
if (!event || !photo) throw new Error('resolvePhotoFilePath requires event and photo');
const mode = (event.source_mode || photo.source_origin || 'managed');
if (mode === 'reference' || photo.source_origin === 'external') {
// IMPORTANT: photo.source_origin takes precedence over event.source_mode
// This allows events in "reference" mode to have mixed sources:
// - Imported photos: source_origin = 'external'
// - Uploaded photos: source_origin = 'managed'
const mode = (photo.source_origin || event.source_mode || 'managed');
if (mode === 'reference' || mode === 'external') {
if (!photo.external_relpath) {
throw new Error('Missing external_relpath for external photo');
}
+72
View File
@@ -0,0 +1,72 @@
/**
* Worker Manager - Background service for PicPeak
*
* This service runs as a separate process to handle:
* - File watching for new photos
* - Expiration checking for events
* - Other background tasks
*/
const path = require('path');
const logger = require('../utils/logger');
// Load environment variables
require('dotenv').config({ path: path.join(__dirname, '../../.env') });
// Import services
const { startFileWatcher } = require('./fileWatcher');
const { startExpirationChecker } = require('./expirationChecker');
let isShuttingDown = false;
async function startWorkers() {
logger.info('Starting PicPeak background workers...');
try {
// Start file watcher for automatic photo processing
startFileWatcher();
logger.info('File watcher started successfully');
// Start expiration checker for event lifecycle management
startExpirationChecker();
logger.info('Expiration checker started successfully');
logger.info('All background workers started successfully');
} catch (error) {
logger.error('Failed to start background workers:', error);
process.exit(1);
}
}
function handleShutdown(signal) {
if (isShuttingDown) {
logger.info('Shutdown already in progress...');
return;
}
isShuttingDown = true;
logger.info(`Received ${signal}. Shutting down gracefully...`);
// Give time for cleanup
setTimeout(() => {
logger.info('Worker manager shutdown complete');
process.exit(0);
}, 1000);
}
// Handle shutdown signals
process.on('SIGTERM', () => handleShutdown('SIGTERM'));
process.on('SIGINT', () => handleShutdown('SIGINT'));
// Handle uncaught errors
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception in worker manager:', error);
process.exit(1);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection in worker manager:', reason);
});
// Start workers
startWorkers();
+38 -2
View File
@@ -78,6 +78,21 @@ const ALLOWED_IMAGE_TYPES = {
extensions: ['.svg'],
// SVG files are XML-based text files, so we skip magic number validation
magicNumbers: null
},
// Video types are included here to keep validation centralized
'video/mp4': {
extensions: ['.mp4'],
magicNumbers: null
},
'video/quicktime': {
extensions: ['.mov', '.qt'],
magicNumbers: null
},
'video/webm': {
extensions: ['.webm'],
magicNumbers: [
{ offset: 0, bytes: [0x1A, 0x45, 0xDF, 0xA3] } // WebM/Matroska
]
}
};
@@ -164,6 +179,26 @@ function getSafeFilename(originalFilename) {
return `upload_${timestamp}_${randomString}${ext}`;
}
function isVideoMimeType(mimeType, filename) {
const lowerMime = (mimeType || '').toLowerCase();
if (lowerMime.startsWith('video/')) {
return true;
}
const ext = filename ? path.extname(filename).toLowerCase() : '';
const videoExts = ['.mp4', '.mov', '.webm', '.m4v', '.qt'];
if (videoExts.includes(ext)) {
return true;
}
if (lowerMime === 'application/mp4' || lowerMime === 'application/x-m4v' || lowerMime === 'application/octet-stream') {
return videoExts.includes(ext) || true;
}
return false;
}
/**
* Create a file upload validator middleware
* @param {Object} options - Validation options
@@ -229,5 +264,6 @@ module.exports = {
validateFileContent,
getSafeFilename,
createFileUploadValidator,
ALLOWED_IMAGE_TYPES
};
ALLOWED_IMAGE_TYPES,
isVideoMimeType
};
+31 -5
View File
@@ -3,19 +3,45 @@
set -e
host="$DB_HOST"
host="${DB_HOST:-postgres}"
port="${DB_PORT:-5432}"
user="${DB_USER:-picpeak}"
target_db="${DB_NAME:-picpeak}"
default_db="${DB_CHECK_DB:-postgres}"
sanitize_identifier() {
printf '%s' "$1" | sed "s/'/''/g"
}
echo "Waiting for PostgreSQL at $host:$port..."
# Wait for PostgreSQL to be ready
until PGPASSWORD=$DB_PASSWORD psql -h "$host" -p "$port" -U "$user" -d "${DB_NAME:-picpeak}" -c '\q' 2>/dev/null; do
# Wait for PostgreSQL server to accept connections (using the default database)
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c '\q' >/dev/null 2>&1; do
>&2 echo "PostgreSQL is unavailable - sleeping"
sleep 2
done
>&2 echo "PostgreSQL is up - executing command"
>&2 echo "PostgreSQL is up - verifying target database \"$target_db\""
# Ensure the target database exists (helps when volumes are reused or DB_NAME is customised)
db_exists=$(PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -tAc "SELECT 1 FROM pg_database WHERE datname = '$(sanitize_identifier "$target_db")'" 2>/dev/null || echo 0)
if [ "$db_exists" != "1" ]; then
>&2 echo "Database \"$target_db\" not found. Attempting to create..."
if ! PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$default_db" -c "CREATE DATABASE \"$target_db\";" >/dev/null 2>&1; then
>&2 echo "Failed to create database \"$target_db\". Please ensure it exists and is accessible."
exit 1
fi
>&2 echo "Database \"$target_db\" created successfully."
fi
# Wait until the target database itself is ready to accept connections
until PGPASSWORD="$DB_PASSWORD" psql -h "$host" -p "$port" -U "$user" -d "$target_db" -c '\q' >/dev/null 2>&1; do
>&2 echo "Waiting for database \"$target_db\" to accept connections..."
sleep 2
done
>&2 echo "Target database \"$target_db\" is ready."
# Run migrations (use safe runner in production)
echo "Running database migrations..."
@@ -26,4 +52,4 @@ else
fi
# Execute the main command
exec "$@"
exec "$@"
+2
View File
@@ -4,6 +4,7 @@ services:
postgres:
image: postgres:15-alpine
container_name: picpeak-postgres
userns_mode: "host"
environment:
POSTGRES_USER: ${DB_USER:-picpeak}
POSTGRES_PASSWORD: ${DB_PASSWORD}
@@ -22,6 +23,7 @@ services:
redis:
image: redis:7-alpine
container_name: picpeak-redis
userns_mode: "host"
command: redis-server --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
+3 -1
View File
@@ -60,6 +60,7 @@ services:
image: postgres:15-alpine
container_name: picpeak-postgres
restart: unless-stopped
userns_mode: "host"
environment:
- POSTGRES_USER=${DB_USER}
- POSTGRES_PASSWORD=${DB_PASSWORD}
@@ -83,6 +84,7 @@ services:
image: redis:7-alpine
container_name: picpeak-redis
restart: unless-stopped
userns_mode: "host"
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD:-picpeak_redis_pass}
volumes:
- redis-data:/data
@@ -101,7 +103,7 @@ services:
context: ./frontend
dockerfile: Dockerfile
args:
- VITE_API_URL=${VITE_API_URL:-http://localhost:3001/api}
- VITE_API_URL=${VITE_API_URL:-/api}
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
- VITE_UMAMI_SHARE_URL=${VITE_UMAMI_SHARE_URL:-}
+3
View File
@@ -30,6 +30,9 @@ RUN npm run build
# Production stage
FROM nginx:alpine
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install runtime dependencies
RUN apk add --no-cache curl
+3
View File
@@ -3,6 +3,9 @@ FROM node:20-alpine
WORKDIR /app
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Copy package files
COPY package*.json ./
+3
View File
@@ -25,6 +25,9 @@ RUN npm run build
# Production stage
FROM nginx:alpine
# Upgrade all packages to fix security vulnerabilities (BusyBox CVEs)
RUN apk upgrade --no-cache
# Install runtime dependencies
RUN apk add --no-cache curl
+334 -155
View File
@@ -1,12 +1,12 @@
{
"name": "picpeak-frontend",
"version": "1.1.14",
"version": "1.1.15",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "picpeak-frontend",
"version": "1.1.14",
"version": "1.1.15",
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"@tiptap/extension-character-count": "^2.26.1",
@@ -44,6 +44,9 @@
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.3",
@@ -53,6 +56,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.21",
"tailwindcss": "^3.3.0",
"typescript": "~5.8.3",
@@ -64,6 +68,13 @@
"@rollup/rollup-linux-x64-gnu": "^4.45.1"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.4.4",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz",
"integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==",
"dev": true,
"license": "MIT"
},
"node_modules/@alloc/quick-lru": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
@@ -97,8 +108,6 @@
"integrity": "sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@csstools/css-calc": "^2.1.3",
"@csstools/css-color-parser": "^3.0.9",
@@ -112,9 +121,7 @@
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true
"license": "ISC"
},
"node_modules/@babel/code-frame": {
"version": "7.27.1",
@@ -423,8 +430,6 @@
}
],
"license": "MIT-0",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -445,8 +450,6 @@
}
],
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
@@ -471,8 +474,6 @@
}
],
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@csstools/color-helpers": "^5.1.0",
"@csstools/css-calc": "^2.1.4"
@@ -501,8 +502,6 @@
}
],
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
},
@@ -526,8 +525,6 @@
}
],
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -793,6 +790,29 @@
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/@isaacs/balanced-match": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/@isaacs/balanced-match/-/balanced-match-4.0.1.tgz",
"integrity": "sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/brace-expansion": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.0.tgz",
"integrity": "sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@isaacs/balanced-match": "^4.0.1"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -888,17 +908,6 @@
"node": ">= 8"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
"integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==",
"dev": true,
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
},
"node_modules/@popperjs/core": {
"version": "2.11.8",
"resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz",
@@ -1236,6 +1245,96 @@
"react": "^18 || ^19"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
"integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/code-frame": "^7.10.4",
"@babel/runtime": "^7.12.5",
"@types/aria-query": "^5.0.1",
"aria-query": "5.3.0",
"dom-accessibility-api": "^0.5.9",
"lz-string": "^1.5.0",
"picocolors": "1.1.1",
"pretty-format": "^27.0.2"
},
"engines": {
"node": ">=18"
}
},
"node_modules/@testing-library/jest-dom": {
"version": "6.9.1",
"resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz",
"integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@adobe/css-tools": "^4.4.0",
"aria-query": "^5.0.0",
"css.escape": "^1.5.1",
"dom-accessibility-api": "^0.6.3",
"picocolors": "^1.1.1",
"redent": "^3.0.0"
},
"engines": {
"node": ">=14",
"npm": ">=6",
"yarn": ">=1"
}
},
"node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz",
"integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==",
"dev": true,
"license": "MIT"
},
"node_modules/@testing-library/react": {
"version": "16.3.0",
"resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.0.tgz",
"integrity": "sha512-kFSyxiEDwv1WLl2fgsq6pPBbw5aWKrsY2/noi1Id0TK0UParSF62oFQFGHXIyaG4pp2tEub/Zlel+fjjZILDsw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@babel/runtime": "^7.12.5"
},
"engines": {
"node": ">=18"
},
"peerDependencies": {
"@testing-library/dom": "^10.0.0",
"@types/react": "^18.0.0 || ^19.0.0",
"@types/react-dom": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@testing-library/user-event": {
"version": "14.6.1",
"resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz",
"integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12",
"npm": ">=6"
},
"peerDependencies": {
"@testing-library/dom": ">=7.21.4"
}
},
"node_modules/@tiptap/core": {
"version": "2.26.1",
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-2.26.1.tgz",
@@ -1696,6 +1795,14 @@
"url": "https://github.com/sponsors/ueberdosis"
}
},
"node_modules/@types/aria-query": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz",
"integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -2308,8 +2415,6 @@
"integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">= 14"
}
@@ -2332,9 +2437,9 @@
}
},
"node_modules/ansi-regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.1.0.tgz",
"integrity": "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==",
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -2394,6 +2499,16 @@
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/aria-query": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz",
"integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
@@ -2795,6 +2910,13 @@
"node": ">= 8"
}
},
"node_modules/css.escape": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz",
"integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==",
"dev": true,
"license": "MIT"
},
"node_modules/cssesc": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
@@ -2814,8 +2936,6 @@
"integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@asamuzakjp/css-color": "^3.2.0",
"rrweb-cssom": "^0.8.0"
@@ -2829,9 +2949,7 @@
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.8.0.tgz",
"integrity": "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.1.3",
@@ -2845,8 +2963,6 @@
"integrity": "sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"whatwg-mimetype": "^4.0.0",
"whatwg-url": "^14.0.0"
@@ -2861,8 +2977,6 @@
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
@@ -2876,8 +2990,6 @@
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
@@ -2888,8 +3000,6 @@
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
@@ -2931,9 +3041,7 @@
"resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz",
"integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/deep-eql": {
"version": "5.0.2",
@@ -2961,6 +3069,16 @@
"node": ">=0.4.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/didyoumean": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz",
@@ -2975,6 +3093,14 @@
"dev": true,
"license": "MIT"
},
"node_modules/dom-accessibility-api": {
"version": "0.5.16",
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
"integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/dompurify": {
"version": "3.2.6",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.6.tgz",
@@ -3629,22 +3755,25 @@
}
},
"node_modules/glob": {
"version": "10.4.5",
"resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz",
"integrity": "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==",
"version": "11.1.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.1.0.tgz",
"integrity": "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"foreground-child": "^3.1.0",
"jackspeak": "^3.1.2",
"minimatch": "^9.0.4",
"foreground-child": "^3.3.1",
"jackspeak": "^4.1.1",
"minimatch": "^10.1.1",
"minipass": "^7.1.2",
"package-json-from-dist": "^1.0.0",
"path-scurry": "^1.11.1"
"path-scurry": "^2.0.0"
},
"bin": {
"glob": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
@@ -3662,27 +3791,17 @@
"node": ">=10.13.0"
}
},
"node_modules/glob/node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0"
}
},
"node_modules/glob/node_modules/minimatch": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
"integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
"version": "10.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz",
"integrity": "sha512-enIvLvRAFZYXJzkCYG5RKmPfrFArdLv+R+lbQ53BmIMLIry74bjKzX6iHAm8WYamJkhSSEabrWN5D97XnKObjQ==",
"dev": true,
"license": "ISC",
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^2.0.1"
"@isaacs/brace-expansion": "^5.0.0"
},
"engines": {
"node": ">=16 || 14 >=14.17"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -3794,8 +3913,6 @@
"integrity": "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"whatwg-encoding": "^3.1.1"
},
@@ -3818,8 +3935,6 @@
"integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "^7.1.0",
"debug": "^4.3.4"
@@ -3834,8 +3949,6 @@
"integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"agent-base": "^7.1.2",
"debug": "4"
@@ -3899,8 +4012,6 @@
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
@@ -3945,6 +4056,16 @@
"node": ">=0.8.19"
}
},
"node_modules/indent-string": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz",
"integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
@@ -4022,9 +4143,7 @@
"resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz",
"integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/isexe": {
"version": "2.0.0",
@@ -4034,19 +4153,19 @@
"license": "ISC"
},
"node_modules/jackspeak": {
"version": "3.4.3",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz",
"integrity": "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.1.1.tgz",
"integrity": "sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"@isaacs/cliui": "^8.0.2"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
},
"optionalDependencies": {
"@pkgjs/parseargs": "^0.11.0"
}
},
"node_modules/jiti": {
@@ -4066,9 +4185,9 @@
"license": "MIT"
},
"node_modules/js-yaml": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz",
"integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==",
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4084,8 +4203,6 @@
"integrity": "sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"cssstyle": "^4.1.0",
"data-urls": "^5.0.0",
@@ -4127,8 +4244,6 @@
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"punycode": "^2.3.1"
},
@@ -4142,8 +4257,6 @@
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=12"
}
@@ -4154,8 +4267,6 @@
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tr46": "^5.1.0",
"webidl-conversions": "^7.0.0"
@@ -4361,6 +4472,17 @@
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/lz-string": {
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz",
"integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"lz-string": "bin/bin.js"
}
},
"node_modules/magic-string": {
"version": "0.30.21",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
@@ -4448,6 +4570,16 @@
"node": ">= 0.6"
}
},
"node_modules/min-indent": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz",
"integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=4"
}
},
"node_modules/minimatch": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
@@ -4568,9 +4700,7 @@
"resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz",
"integrity": "sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/object-assign": {
"version": "4.1.1",
@@ -4673,8 +4803,6 @@
"integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"entities": "^6.0.0"
},
@@ -4688,8 +4816,6 @@
"integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==",
"dev": true,
"license": "BSD-2-Clause",
"optional": true,
"peer": true,
"engines": {
"node": ">=0.12"
},
@@ -4725,28 +4851,31 @@
"license": "MIT"
},
"node_modules/path-scurry": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz",
"integrity": "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.1.tgz",
"integrity": "sha512-oWyT4gICAu+kaA7QWk/jvCHWarMKNs6pXOGWKDTr7cw4IGcUbW+PeTfbaQiLGheFRpjo6O9J0PmyMfQPjH71oA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"lru-cache": "^10.2.0",
"minipass": "^5.0.0 || ^6.0.2 || ^7.0.0"
"lru-cache": "^11.0.0",
"minipass": "^7.1.2"
},
"engines": {
"node": ">=16 || 14 >=14.18"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/path-scurry/node_modules/lru-cache": {
"version": "10.4.3",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz",
"integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.2.tgz",
"integrity": "sha512-F9ODfyqML2coTIsQpSkRHnLSZMtkU8Q+mSfcaIyKwy58u+8k5nvAYeiNhsyMARvzNcXJ9QfWVrcPsC9e9rAxtg==",
"dev": true,
"license": "ISC"
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/pathe": {
"version": "2.0.3",
@@ -4965,6 +5094,55 @@
"node": ">= 0.8.0"
}
},
"node_modules/pretty-format": {
"version": "27.5.1",
"resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz",
"integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"ansi-regex": "^5.0.1",
"ansi-styles": "^5.0.0",
"react-is": "^17.0.1"
},
"engines": {
"node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0"
}
},
"node_modules/pretty-format/node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pretty-format/node_modules/ansi-styles": {
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz",
"integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/pretty-format/node_modules/react-is": {
"version": "17.0.2",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -5415,6 +5593,20 @@
"node": ">=8.10.0"
}
},
"node_modules/redent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz",
"integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==",
"dev": true,
"license": "MIT",
"dependencies": {
"indent-string": "^4.0.0",
"strip-indent": "^3.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/resolve": {
"version": "1.22.10",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz",
@@ -5522,9 +5714,7 @@
"resolved": "https://registry.npmjs.org/rrweb-cssom/-/rrweb-cssom-0.7.1.tgz",
"integrity": "sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/run-parallel": {
"version": "1.2.0",
@@ -5555,9 +5745,7 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/saxes": {
"version": "6.0.0",
@@ -5565,8 +5753,6 @@
"integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==",
"dev": true,
"license": "ISC",
"optional": true,
"peer": true,
"dependencies": {
"xmlchars": "^2.2.0"
},
@@ -5725,9 +5911,9 @@
}
},
"node_modules/strip-ansi": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.0.tgz",
"integrity": "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==",
"version": "7.1.2",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz",
"integrity": "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -5764,6 +5950,19 @@
"node": ">=8"
}
},
"node_modules/strip-indent": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz",
"integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"min-indent": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-json-comments": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz",
@@ -5851,9 +6050,7 @@
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",
"integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/tailwind-merge": {
"version": "3.3.1",
@@ -6033,8 +6230,6 @@
"integrity": "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tldts-core": "^6.1.86"
},
@@ -6047,9 +6242,7 @@
"resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-6.1.86.tgz",
"integrity": "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/to-regex-range": {
"version": "5.0.1",
@@ -6070,8 +6263,6 @@
"integrity": "sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==",
"dev": true,
"license": "BSD-3-Clause",
"optional": true,
"peer": true,
"dependencies": {
"tldts": "^6.1.32"
},
@@ -6455,8 +6646,6 @@
"integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"xml-name-validator": "^5.0.0"
},
@@ -6476,8 +6665,6 @@
"integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"iconv-lite": "0.6.3"
},
@@ -6491,8 +6678,6 @@
"integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -6633,9 +6818,9 @@
}
},
"node_modules/wrap-ansi/node_modules/ansi-styles": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.1.tgz",
"integrity": "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==",
"version": "6.2.3",
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -6651,8 +6836,6 @@
"integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"engines": {
"node": ">=10.0.0"
},
@@ -6675,8 +6858,6 @@
"integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==",
"dev": true,
"license": "Apache-2.0",
"optional": true,
"peer": true,
"engines": {
"node": ">=18"
}
@@ -6686,9 +6867,7 @@
"resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz",
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
"license": "MIT"
},
"node_modules/yallist": {
"version": "3.1.1",
+11 -2
View File
@@ -1,14 +1,15 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "1.1.14",
"version": "1.1.15",
"type": "module",
"scripts": {
"dev": "vite",
"build": "node ./scripts/build.js",
"build:check": "tsc -b && node ./scripts/build.js",
"lint": "eslint .",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run src/components/admin/__tests__/ThemeCustomizerEnhanced.test.tsx"
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
@@ -47,6 +48,9 @@
},
"devDependencies": {
"@eslint/js": "^9.29.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^18.3.12",
"@types/react-dom": "^18.3.1",
"@vitejs/plugin-react": "^4.5.3",
@@ -56,6 +60,7 @@
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.2.0",
"jsdom": "^25.0.1",
"postcss": "^8.4.21",
"tailwindcss": "^3.3.0",
"typescript": "~5.8.3",
@@ -65,5 +70,9 @@
},
"optionalDependencies": {
"@rollup/rollup-linux-x64-gnu": "^4.45.1"
},
"overrides": {
"glob": "^11.1.0",
"js-yaml": "^4.1.1"
}
}
@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { api } from '../../config/api';
interface AdminAuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallback?: React.ReactNode;
}
export const AdminAuthenticatedVideo: React.FC<AdminAuthenticatedVideoProps> = ({
src,
fallback,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
useEffect(() => {
let cancelled = false;
let objectUrl: string | null = null;
const loadVideo = async () => {
try {
setLoading(true);
setError(false);
setVideoSrc(null);
const response = await api.get(src, { responseType: 'blob' });
if (!cancelled) {
objectUrl = URL.createObjectURL(response.data);
setVideoSrc(objectUrl);
setLoading(false);
}
} catch {
if (!cancelled) {
setError(true);
setLoading(false);
}
}
};
if (src) {
loadVideo();
}
return () => {
cancelled = true;
if (objectUrl) {
URL.revokeObjectURL(objectUrl);
}
};
}, [src]);
if (loading) {
return <div className="w-full h-full bg-neutral-200 animate-pulse" />;
}
if (error || !videoSrc) {
return fallback ? (
<>{fallback}</>
) : (
<div className="w-full h-full bg-neutral-100 flex items-center justify-center text-neutral-400">
<span className="text-xs">Failed to load</span>
</div>
);
}
return (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
@@ -55,12 +55,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
},
});
// Clear old notifications mutation
const clearOldMutation = useMutation({
mutationFn: notificationsService.clearOldNotifications,
// Clear notifications mutation
const clearAllMutation = useMutation({
mutationFn: notificationsService.clearAllNotifications,
onSuccess: (data) => {
queryClient.invalidateQueries({ queryKey: ['notifications'] });
toast.success(t('admin.notificationToasts.clearedOld', { count: data.deletedCount }));
toast.success(t('admin.notificationToasts.clearedAll', { count: data.deletedCount }));
},
});
@@ -128,12 +128,12 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
</button>
)}
<button
onClick={() => clearOldMutation.mutate()}
onClick={() => clearAllMutation.mutate()}
className="text-xs text-neutral-600 hover:text-neutral-700 flex items-center gap-1"
title={t('admin.clearOld')}
title={t('admin.clearAll')}
>
<Trash2 className="w-3 h-3" />
{t('admin.clearOld')}
{t('admin.clearAll')}
</button>
</div>
</div>
@@ -1,6 +1,7 @@
import React, { useState } from 'react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star } from 'lucide-react';
import { Check, Download, Trash2, Eye, Package, MessageSquare, Star, Video } from 'lucide-react';
import { toast } from 'react-toastify';
import { useTranslation } from 'react-i18next';
import { AdminPhoto } from '../../services/photos.service';
import { photosService } from '../../services/photos.service';
@@ -20,6 +21,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onPhotoClick,
onPhotosDeleted
}) => {
const { t } = useTranslation();
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
const [isSelectionMode, setIsSelectionMode] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
@@ -126,7 +128,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
onClick={toggleSelectionMode}
leftIcon={<Package className="w-4 h-4" />}
>
{isSelectionMode ? 'Cancel Selection' : 'Select Photos'}
{isSelectionMode ? t('gallery.cancelSelection', 'Cancel Selection') : t('gallery.selectPhotos', 'Select Photos')}
</Button>
{(isSelectionMode || selectedPhotos.size > 0) && (
@@ -136,13 +138,13 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
size="sm"
onClick={handleSelectAll}
>
{selectedPhotos.size === photos.length ? 'Deselect All' : 'Select All'}
{selectedPhotos.size === photos.length ? t('gallery.deselectAll', 'Deselect All') : t('gallery.selectAll', 'Select All')}
</Button>
{selectedPhotos.size > 0 && (
<>
<span className="text-sm text-neutral-600">
{selectedPhotos.size} selected
{t('gallery.photosSelected', { count: selectedPhotos.size })}
</span>
<button
onClick={handleDeleteSelected}
@@ -150,7 +152,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
className="px-3 py-1.5 text-sm font-medium text-white bg-red-600 hover:bg-red-700 disabled:bg-red-400 rounded-lg flex items-center gap-2"
>
<Trash2 className="w-4 h-4" />
Delete Selected
{t('gallery.deleteSelected', 'Delete Selected')}
</button>
</>
)}
@@ -159,7 +161,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</div>
<div className="text-sm text-neutral-600">
{photos.length} photo{photos.length !== 1 ? 's' : ''}
{t('gallery.photosCount', { count: photos.length })}
</div>
</div>
@@ -170,6 +172,9 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
const commentCount = photo.comment_count ?? 0;
const averageRating = photo.average_rating ?? 0;
const likeCount = photo.like_count ?? 0;
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
return (
<div
key={photo.id}
@@ -259,6 +264,15 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
</span>
</div>
)}
{isVideo && (
<div className="absolute bottom-2 left-2 pointer-events-none">
<span className="px-2 py-1 text-[11px] font-semibold bg-black/70 text-white rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{/* Feedback Indicators (moved to bottom-right to avoid covering category) */}
{(commentCount > 0 || averageRating > 0 || likeCount > 0) && (
@@ -284,7 +298,7 @@ export const AdminPhotoGrid: React.FC<AdminPhotoGridProps> = ({
{photos.length === 0 && (
<div className="text-center py-12">
<p className="text-neutral-500">No photos uploaded yet</p>
<p className="text-neutral-500">{t('gallery.noMedia', 'No media uploaded yet')}</p>
</div>
)}
</div>
@@ -9,6 +9,7 @@ import { photosService } from '../../services/photos.service';
import { feedbackService, type PhotoFeedback, type FeedbackSummary } from '../../services/feedback.service';
import { Button } from '../common';
import { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
import { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
type AdminFeedbackResponse = {
feedback: PhotoFeedback[];
@@ -39,6 +40,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
const queryClient = useQueryClient();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && String(currentPhoto.mime_type).startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
const averageRating = currentPhoto?.average_rating ?? 0;
const likeCount = currentPhoto?.like_count ?? 0;
const favoriteCount = currentPhoto?.favorite_count ?? 0;
@@ -109,8 +115,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
toast.success('Category updated');
setShowCategoryMenu(false);
// Trigger refresh to update the photo data
onPhotoDeleted(); // This will refresh the photos list
// Invalidate photos query to refresh data
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] });
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] });
// Also trigger the parent's refresh callback
onPhotoDeleted();
} catch (error) {
toast.error('Failed to update category');
}
@@ -188,19 +197,35 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
<div className="flex flex-col lg:flex-row gap-6 max-w-7xl mx-auto p-4 w-full h-full">
{/* Image */}
<div className="flex-1 flex items-center justify-center min-h-0">
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
{isVideo ? (
<AdminAuthenticatedVideo
src={currentPhoto.url}
className="max-w-full max-h-full bg-black"
poster={currentPhoto.thumbnail_url || undefined}
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load media</p>
</div>
</div>
</div>
}
/>
}
/>
) : (
<AdminAuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
className="max-w-full max-h-full object-contain"
fallback={
<div className="flex items-center justify-center text-neutral-400">
<div className="text-center">
<Eye className="w-12 h-12 mx-auto mb-2" />
<p className="text-sm">Failed to load image</p>
</div>
</div>
}
/>
)}
</div>
{/* Sidebar */}
+42 -14
View File
@@ -1,16 +1,20 @@
import React from 'react';
import { Search, Filter, SortAsc, SortDesc } from 'lucide-react';
import { Input } from '../common';
import { useTranslation } from 'react-i18next';
interface PhotoFiltersProps {
categories: Array<{ id: number; name: string; slug: string }>;
selectedCategory: number | null | undefined;
categories: Array<{ id: number | string; name: string; slug: string }>;
selectedCategory: number | string | null | undefined;
searchTerm: string;
sortBy: 'date' | 'name' | 'size' | 'rating';
sortOrder: 'asc' | 'desc';
onCategoryChange: (categoryId: number | null | undefined) => void;
onCategoryChange: (categoryId: number | string | null | undefined) => void;
onSearchChange: (search: string) => void;
onSortChange: (sort: 'date' | 'name' | 'size' | 'rating', order: 'asc' | 'desc') => void;
mediaType?: 'all' | 'photo' | 'video';
onMediaTypeChange?: (mediaType: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
}
export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
@@ -21,8 +25,12 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
sortOrder,
onCategoryChange,
onSearchChange,
onSortChange
onSortChange,
mediaType = 'all',
onMediaTypeChange,
showMediaFilter = false
}) => {
const { t } = useTranslation();
const handleSortToggle = () => {
onSortChange(sortBy, sortOrder === 'asc' ? 'desc' : 'asc');
};
@@ -34,7 +42,7 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<div className="flex-1">
<Input
type="text"
placeholder="Search by filename..."
placeholder={t('gallery.searchByFilename', 'Search by filename...')}
value={searchTerm}
onChange={(e) => onSearchChange(e.target.value)}
leftIcon={<Search className="w-5 h-5 text-neutral-400" />}
@@ -46,11 +54,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={selectedCategory === null ? '' : selectedCategory || ''}
onChange={(e) => onCategoryChange(e.target.value === '' ? null : Number(e.target.value) || undefined)}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') return onCategoryChange(null);
const numeric = Number(raw);
onCategoryChange(Number.isNaN(numeric) ? raw : numeric);
}}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="">All Categories</option>
<option value="0">Uncategorized</option>
<option value="">{t('gallery.allCategories', 'All Categories')}</option>
<option value="0">{t('gallery.uncategorized', 'Uncategorized')}</option>
{categories.map(cat => (
<option key={cat.id} value={cat.id}>
{cat.name}
@@ -59,6 +72,21 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</select>
</div>
{showMediaFilter && onMediaTypeChange && (
<div className="flex items-center gap-2">
<Filter className="w-5 h-5 text-neutral-400" />
<select
value={mediaType}
onChange={(e) => onMediaTypeChange(e.target.value as 'all' | 'photo' | 'video')}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="all">{t('gallery.allMedia', 'All media')}</option>
<option value="photo">{t('gallery.photosOnly', 'Photos only')}</option>
<option value="video">{t('gallery.videosOnly', 'Videos only')}</option>
</select>
</div>
)}
{/* Sort Options */}
<div className="flex items-center gap-2">
<select
@@ -66,16 +94,16 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
onChange={(e) => onSortChange(e.target.value as 'date' | 'name' | 'size' | 'rating', sortOrder)}
className="px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="date">Sort by Date</option>
<option value="name">Sort by Name</option>
<option value="size">Sort by Size</option>
<option value="rating">Sort by Rating</option>
<option value="date">{t('gallery.sortByDate', 'Sort by Date')}</option>
<option value="name">{t('gallery.sortByName', 'Sort by Name')}</option>
<option value="size">{t('gallery.sortBySize', 'Sort by Size')}</option>
<option value="rating">{t('gallery.sortByRating', 'Sort by Rating')}</option>
</select>
<button
onClick={handleSortToggle}
className="p-2 border border-neutral-300 rounded-lg hover:bg-neutral-50 transition-colors"
aria-label={sortOrder === 'asc' ? 'Sort descending' : 'Sort ascending'}
aria-label={sortOrder === 'asc' ? t('gallery.sortDescending', 'Sort descending') : t('gallery.sortAscending', 'Sort ascending')}
>
{sortOrder === 'asc' ? (
<SortAsc className="w-5 h-5 text-neutral-600" />
@@ -87,4 +115,4 @@ export const PhotoFilters: React.FC<PhotoFiltersProps> = ({
</div>
</div>
);
};
};
+24 -12
View File
@@ -1,5 +1,5 @@
import React, { useState, useRef } from 'react';
import { Upload, X, Image, Loader2 } from 'lucide-react';
import { Upload, X, Image, Loader2, Video } from 'lucide-react';
import { Button } from '../common';
import { clsx } from 'clsx';
import { api } from '../../config/api';
@@ -51,12 +51,18 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
const imageFiles = files.filter(file =>
['image/jpeg', 'image/png', 'image/webp'].includes(file.type)
);
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'video/mp4', 'video/quicktime', 'video/webm'];
const allowedFiles = files.filter(file => allowedTypes.includes(file.type));
const rejectedFiles = files.filter(file => !allowedTypes.includes(file.type));
if (rejectedFiles.length > 0) {
toast.error(
t('upload.unsupportedFiles', 'Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).')
);
}
// Check total file count with existing files
const totalFiles = selectedFiles.length + imageFiles.length;
const totalFiles = selectedFiles.length + allowedFiles.length;
if (totalFiles > maxFilesPerUpload) {
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
if (allowedNewFiles <= 0) {
@@ -70,11 +76,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
);
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
setSelectedFiles(prev => [...prev, ...allowedFiles.slice(0, allowedNewFiles)]);
return;
}
setSelectedFiles(prev => [...prev, ...imageFiles]);
setSelectedFiles(prev => [...prev, ...allowedFiles]);
};
const removeFile = (index: number) => {
@@ -186,7 +192,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{/* Category Selection */}
<div>
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('upload.photoCategory')}
{t('upload.mediaCategory', 'Media category')}
</label>
<select
value={selectedCategoryId || ''}
@@ -216,7 +222,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
{t('upload.clickToUpload')}
</p>
<p className="text-sm text-neutral-500">
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
{t('upload.fileRequirementsMedia', { limit: maxFilesPerUpload }) || t('upload.fileRequirements', { limit: maxFilesPerUpload })}
</p>
<p
className={clsx(
@@ -236,7 +242,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
ref={fileInputRef}
type="file"
multiple
accept="image/jpeg,image/png,image/webp"
accept="image/jpeg,image/png,image/webp,video/mp4,video/quicktime,video/webm"
onChange={handleFileSelect}
className="hidden"
/>
@@ -255,7 +261,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
className="flex items-center justify-between p-2 bg-neutral-50 rounded-lg"
>
<div className="flex items-center gap-3">
<Image className="w-5 h-5 text-neutral-400" />
{file.type.startsWith('video/') ? (
<Video className="w-5 h-5 text-neutral-400" />
) : (
<Image className="w-5 h-5 text-neutral-400" />
)}
<div>
<p className="text-sm font-medium text-neutral-700 truncate max-w-xs">
{file.name}
@@ -288,7 +298,9 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
disabled={selectedFiles.length === 0 || isUploading}
leftIcon={isUploading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Upload className="w-4 h-4" />}
>
{isUploading ? t('upload.uploading') : t('common.upload') + ` ${selectedFiles.length} ${t(selectedFiles.length === 1 ? 'common.photo' : 'common.photos')}`}
{isUploading
? t('upload.uploading')
: t('upload.uploadAction', { count: selectedFiles.length }) || `Upload ${selectedFiles.length} files`}
</Button>
</div>
@@ -33,7 +33,7 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
<div className="bg-white rounded-lg shadow-xl w-full max-w-2xl flex flex-col max-h-[90vh]">
{/* Fixed Header */}
<div className="flex items-center justify-between p-6 border-b border-neutral-200">
<h2 className="text-xl font-semibold text-neutral-900">{t('events.uploadPhotos')}</h2>
<h2 className="text-xl font-semibold text-neutral-900">{t('upload.uploadMedia', t('events.uploadPhotos'))}</h2>
<Button
variant="ghost"
size="sm"
@@ -56,4 +56,4 @@ export const PhotoUploadModal: React.FC<PhotoUploadModalProps> = ({
);
};
PhotoUploadModal.displayName = 'PhotoUploadModal';
PhotoUploadModal.displayName = 'PhotoUploadModal';
@@ -14,6 +14,8 @@ interface ThemeCustomizerEnhancedProps {
isPreviewMode?: boolean;
showGalleryLayouts?: boolean;
hideActions?: boolean;
onApply?: (theme: ThemeConfig, metadata: { presetName: string }) => Promise<void> | void;
isApplying?: boolean;
}
const layoutIcons: Record<GalleryLayoutType, React.ReactNode> = {
@@ -34,7 +36,9 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
onPresetChange,
isPreviewMode = false,
showGalleryLayouts = true,
hideActions = false
hideActions = false,
onApply,
isApplying = false
}) => {
const { t } = useTranslation();
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
@@ -80,8 +84,13 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
}
};
const handleApply = () => {
onChange({ ...localTheme, customCss });
const handleApply = async () => {
const themeWithCss = { ...localTheme, customCss };
onChange(themeWithCss);
if (onApply) {
await onApply(themeWithCss, { presetName: selectedPreset });
}
};
const handleReset = () => {
@@ -587,11 +596,12 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
variant="primary"
leftIcon={<Palette className="w-4 h-4" />}
onClick={handleApply}
disabled={isApplying}
>
{t('branding.applyTheme')}
{isApplying ? t('common.applying', 'Applying...') : t('branding.applyTheme')}
</Button>
</div>
)}
</div>
);
};
};
@@ -0,0 +1,70 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { vi } from 'vitest';
import { ThemeCustomizerEnhanced } from '../ThemeCustomizerEnhanced';
import type { ThemeConfig } from '../../../types/theme.types';
vi.mock('react-i18next', async () => {
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
return {
...actual,
useTranslation: () => ({
t: (_key: string, fallback?: string) => fallback ?? _key
})
};
});
describe('ThemeCustomizerEnhanced', () => {
const baseTheme: ThemeConfig = {
primaryColor: '#000000',
accentColor: '#ffffff',
backgroundColor: '#eeeeee',
textColor: '#111111',
galleryLayout: 'grid',
gallerySettings: {
spacing: 'normal'
}
};
it('invokes onApply when Apply Theme is clicked', async () => {
const user = userEvent.setup();
const handleChange = vi.fn();
const handleApply = vi.fn().mockResolvedValue(undefined);
render(
<ThemeCustomizerEnhanced
value={baseTheme}
onChange={handleChange}
presetName="default"
onApply={handleApply}
/>
);
const applyButton = screen.getByRole('button', { name: /branding\.applyTheme/i });
await user.click(applyButton);
expect(handleChange).toHaveBeenCalled();
expect(handleApply).toHaveBeenCalledTimes(1);
expect(handleApply).toHaveBeenCalledWith(
expect.objectContaining({ primaryColor: '#000000' }),
expect.objectContaining({ presetName: 'default' })
);
});
it('disables the Apply button while applying', () => {
const handleChange = vi.fn();
render(
<ThemeCustomizerEnhanced
value={baseTheme}
onChange={handleChange}
presetName="default"
isApplying={true}
/>
);
const applyButton = screen.getByRole('button', { name: /applying/i });
expect(applyButton).toBeDisabled();
});
});
+2 -1
View File
@@ -17,6 +17,7 @@ export { AdminPhotoViewer } from './AdminPhotoViewer';
export { PhotoFilters } from './PhotoFilters';
export { PasswordResetModal } from './PasswordResetModal';
export { AdminAuthenticatedImage } from './AdminAuthenticatedImage';
export { AdminAuthenticatedVideo } from './AdminAuthenticatedVideo';
export { ThemeCustomizerEnhanced } from './ThemeCustomizerEnhanced';
export { ThemeDisplay } from './ThemeDisplay';
export { ThemeEditorModal } from './ThemeEditorModal';
@@ -29,4 +30,4 @@ export { BackupHistory } from './BackupHistory';
export { RestoreWizard } from './RestoreWizard';
export { FeedbackSettings } from './FeedbackSettings';
export { FeedbackModerationPanel } from './FeedbackModerationPanel';
export { WordFilterManager } from './WordFilterManager';
export { WordFilterManager } from './WordFilterManager';
@@ -0,0 +1,125 @@
import React, { useEffect, useState } from 'react';
import { buildResourceUrl } from '../../utils/url';
import {
getActiveGallerySlug,
getGalleryToken,
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../../utils/galleryAuthStorage';
interface AuthenticatedVideoProps extends React.VideoHTMLAttributes<HTMLVideoElement> {
src: string;
fallbackSrc?: string;
slug?: string;
}
export const AuthenticatedVideo: React.FC<AuthenticatedVideoProps> = ({
src,
fallbackSrc,
slug,
...props
}) => {
const [videoSrc, setVideoSrc] = useState<string>('');
const [error, setError] = useState(false);
useEffect(() => {
let aborted = false;
const objectUrls: string[] = [];
if (!src) {
setVideoSrc('');
setError(true);
return;
}
const resolveSlug = (candidateSrc?: string): string | null => {
if (slug) {
return slug;
}
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
if (fromUrl) {
return fromUrl;
}
return getActiveGallerySlug() || inferGallerySlugFromLocation();
};
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
if (!rawUrl) {
throw new Error('No URL provided');
}
const fullUrl = rawUrl.startsWith('/')
? buildResourceUrl(rawUrl)
: rawUrl;
const headers: Record<string, string> = {};
const slugForRequest = resolveSlug(rawUrl);
const token = getGalleryToken(slugForRequest);
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const response = await fetch(fullUrl, {
credentials: 'include',
headers: Object.keys(headers).length ? headers : undefined,
});
if (!response.ok) {
throw new Error(`Failed to fetch media: ${response.status} ${response.statusText}`);
}
const blob = await response.blob();
const objectUrl = URL.createObjectURL(blob);
objectUrls.push(objectUrl);
return objectUrl;
};
const load = async () => {
try {
const primaryUrl = await fetchWithAuth(src);
if (!aborted) {
setVideoSrc(primaryUrl);
setError(false);
}
} catch (err) {
if (fallbackSrc && fallbackSrc !== src) {
try {
const fallbackUrl = await fetchWithAuth(fallbackSrc);
if (!aborted) {
setVideoSrc(fallbackUrl);
setError(false);
}
return;
} catch (_) {
// ignore and set error below
}
}
if (!aborted) {
setError(true);
setVideoSrc('');
}
}
};
load();
return () => {
aborted = true;
objectUrls.forEach((url) => URL.revokeObjectURL(url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [src, fallbackSrc, slug]);
if (error || !videoSrc) {
return null;
}
return (
<video
src={videoSrc}
controls
preload="metadata"
{...props}
/>
);
};
+2 -1
View File
@@ -16,7 +16,8 @@ export { SkipLink } from './SkipLink';
export { DynamicFavicon } from './DynamicFavicon';
export { LanguageSelector } from './LanguageSelector';
export { AuthenticatedImage } from './AuthenticatedImage';
export { AuthenticatedVideo } from './AuthenticatedVideo';
export { ProtectedImage } from './ProtectedImage';
export { ProtectionWarning } from './ProtectionWarning';
export { ReCaptcha } from './ReCaptcha';
export { PasswordGenerator } from './PasswordGenerator';
export { PasswordGenerator } from './PasswordGenerator';
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
hide_powered_by?: boolean;
};
showLogout?: boolean;
onLogout?: () => void;
@@ -438,7 +439,10 @@ 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.'} | Powered by <span className="font-semibold">PicPeak</span>
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
{!brandingSettings?.hide_powered_by && (
<> | Powered by <span className="font-semibold">PicPeak</span></>
)}
</p>
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
<p className="text-xs text-neutral-400 mt-2">
@@ -9,8 +9,8 @@ interface GallerySidebarProps {
isOpen: boolean;
onClose: () => void;
categories: PhotoCategory[];
selectedCategoryId: number | null;
onCategoryChange: (categoryId: number | null) => void;
selectedCategoryId: number | string | null;
onCategoryChange: (categoryId: number | string | null) => void;
searchTerm: string;
onSearchChange: (term: string) => void;
sortBy: 'date' | 'name' | 'size' | 'rating';
@@ -22,7 +22,7 @@ interface GallerySidebarProps {
onDownloadSelected: () => void;
isDownloading: boolean;
allowDownloads?: boolean;
photoCounts?: Record<number, number>;
photoCounts?: Record<number | string, number>;
totalPhotos: number;
isMobile: boolean;
galleryLayout?: string;
@@ -34,6 +34,9 @@ interface GallerySidebarProps {
likeCount?: number;
favoriteCount?: number;
ratedCount?: number;
mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
}
export const GallerySidebar: React.FC<GallerySidebarProps> = ({
@@ -64,7 +67,10 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
onFilterChange,
likeCount = 0,
favoriteCount = 0,
ratedCount = 0
ratedCount = 0,
mediaFilter = 'all',
onMediaFilterChange,
showMediaFilter = false
}) => {
const { t } = useTranslation();
const sidebarRef = useRef<HTMLDivElement>(null);
@@ -288,6 +294,47 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
</div>
)}
{showMediaFilter && onMediaFilterChange && (
<div className="p-4 border-b border-neutral-200">
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
<Filter className="w-4 h-4" />
{t('gallery.mediaType', 'Media')}
</h3>
<div className="flex items-center gap-2 flex-wrap">
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('all');
if (isMobile) onClose();
}}
>
{t('gallery.allMedia', 'All')}
</Button>
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('photo');
if (isMobile) onClose();
}}
>
{t('gallery.photosOnly', 'Photos')}
</Button>
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
onClick={() => {
onMediaFilterChange('video');
if (isMobile) onClose();
}}
>
{t('gallery.videosOnly', 'Videos')}
</Button>
</div>
</div>
)}
{/* Sort Section - Hidden for carousel and timeline layouts */}
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
<div className="p-4">
+78 -18
View File
@@ -44,7 +44,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { t } = useTranslation();
const { logout } = useGalleryAuth();
const { setTheme, theme } = useTheme();
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null);
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
const [searchTerm, setSearchTerm] = useState('');
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating'>('date');
const [brandingSettings, setBrandingSettings] = useState<any>(null);
@@ -57,8 +57,22 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
const { watermarkEnabled } = useWatermarkSettings();
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
const [filterType, setFilterType] = useState<FilterType>('all');
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
const [guestId, setGuestId] = useState<string>('');
const [staticHeroPhoto, setStaticHeroPhoto] = useState<Photo | null>(null);
const resolveMediaType = (photo: Photo) => {
if (photo.media_type === 'video' || photo.media_type === 'photo') {
return photo.media_type;
}
if (photo.mime_type && photo.mime_type.startsWith('video/')) {
return 'video';
}
if ((photo as any).type === 'video') {
return 'video';
}
return 'photo';
};
// Generate a unique guest ID for this session
useEffect(() => {
@@ -71,8 +85,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
setGuestId(storedGuestId);
}, []);
// Fetch photos with filter support
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, filterType, guestId);
// Fetch photos WITHOUT filter (always get all photos, filter on frontend)
// This ensures counts are always calculated from the full dataset
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId);
// Set protection level when data is available
useEffect(() => {
@@ -164,10 +179,36 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
watermark_enabled: settingsData.branding_watermark_enabled || false,
logo_url: settingsData.branding_logo_url || null,
logo_size: settingsData.branding_logo_size || 'medium',
logo_max_height: settingsData.branding_logo_max_height || 48,
logo_position: settingsData.branding_logo_position || 'left',
logo_display_header: settingsData.branding_logo_display_header !== false,
logo_display_hero: settingsData.branding_logo_display_hero !== false,
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
hide_powered_by: settingsData.branding_hide_powered_by === true,
});
}
}, [settingsData]);
const availableMediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
(data?.photos || []).forEach((photo) => {
const mediaType = resolveMediaType(photo);
if (mediaType === 'photo' || mediaType === 'video') {
types.add(mediaType);
}
});
return types;
}, [data?.photos]);
const showMediaFilter = availableMediaTypes.has('photo') && availableMediaTypes.has('video');
useEffect(() => {
if (!showMediaFilter && mediaFilter !== 'all') {
setMediaFilter('all');
}
}, [showMediaFilter, mediaFilter]);
// Determine a stable hero photo from the initial (unfiltered) load
useEffect(() => {
if (!staticHeroPhoto && data?.photos && filterType === 'all') {
@@ -177,7 +218,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
hero = data.photos.find(p => p.id === heroId) || null;
}
if (!hero && data.photos.length > 0) {
hero = data.photos[0];
const firstPhoto = data.photos.find(p => resolveMediaType(p) === 'photo');
hero = firstPhoto || data.photos[0];
}
if (hero) {
setStaticHeroPhoto(hero);
@@ -251,6 +293,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
if (!data?.photos) return [];
let photos = [...data.photos];
if (mediaFilter === 'photo') {
photos = photos.filter(photo => resolveMediaType(photo) !== 'video');
} else if (mediaFilter === 'video') {
photos = photos.filter(photo => resolveMediaType(photo) === 'video');
}
// Apply category filter
if (selectedCategoryId) {
@@ -315,7 +363,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
}
return photos;
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType]);
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug, filterType, mediaFilter]);
const likeCount = useMemo(
() => data?.photos?.filter(p => (p.like_count ?? 0) > 0).length || 0,
@@ -380,14 +428,20 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
// Calculate photo counts per category
const photoCounts = useMemo(() => {
if (!data?.photos) return {};
const counts: Record<number, number> = {};
data.photos.forEach(photo => {
const counts: Record<number | string, number> = {};
data.photos
.filter(photo => {
if (mediaFilter === 'photo') return resolveMediaType(photo) !== 'video';
if (mediaFilter === 'video') return resolveMediaType(photo) === 'video';
return true;
})
.forEach(photo => {
if (photo.category_id) {
counts[photo.category_id] = (counts[photo.category_id] || 0) + 1;
}
});
return counts;
}, [data?.photos]);
}, [data?.photos, mediaFilter]);
// Track search usage with debouncing
useEffect(() => {
@@ -489,6 +543,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
feedbackEnabled={feedbackEnabled}
filterType={filterType}
onFilterChange={setFilterType}
mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter}
likeCount={likeCount}
favoriteCount={favoriteCount}
ratedCount={ratedCount}
@@ -574,16 +631,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
onCategoryChange={setSelectedCategoryId}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
// Feedback filter props
feedbackEnabled={feedbackEnabled}
currentFilter={filterType}
onFilterChange={setFilterType}
/>
</div>
) : null}
sortBy={sortBy}
onSortChange={setSortBy}
photoCount={filteredPhotos.length}
// Feedback filter props
feedbackEnabled={feedbackEnabled}
currentFilter={filterType}
onFilterChange={setFilterType}
mediaFilter={mediaFilter}
onMediaFilterChange={setMediaFilter}
showMediaFilter={showMediaFilter}
/>
</div>
) : null}
{/* Photo Grid */}
<div className={showSidebar ? "mt-6" : "mt-6"}>
@@ -32,6 +32,9 @@ interface PhotoFilterBarProps {
feedbackEnabled?: boolean;
currentFilter?: FilterType;
onFilterChange?: (filter: FilterType) => void;
mediaFilter?: 'all' | 'photo' | 'video';
onMediaFilterChange?: (filter: 'all' | 'photo' | 'video') => void;
showMediaFilter?: boolean;
}
export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
@@ -47,6 +50,9 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
feedbackEnabled = false,
currentFilter = 'all',
onFilterChange,
mediaFilter = 'all',
onMediaFilterChange,
showMediaFilter = false
}) => {
const { t } = useTranslation();
const [showSortMenu, setShowSortMenu] = useState(false);
@@ -148,7 +154,7 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
>
{t('gallery.allPhotos')} ({photos.length})
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
</Button>
{categories.map((category) => {
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
@@ -226,10 +232,44 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
)}
<p className="text-xs md:text-sm text-neutral-600 flex-shrink-0 ml-auto">
{photoCount} {photoCount === 1 ? t('common.photo') : t('common.photos')}
{photoCount} {t('common.media', 'media')}
</p>
</div>
)}
{showMediaFilter && onMediaFilterChange && (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs md:text-sm text-neutral-600 whitespace-nowrap">
{t('gallery.mediaType', 'Media')}
</span>
<div className="flex items-center gap-2">
<Button
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('all')}
className="text-xs md:text-sm"
>
{t('gallery.allMedia', 'All')}
</Button>
<Button
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('photo')}
className="text-xs md:text-sm"
>
{t('gallery.photosOnly', 'Photos')}
</Button>
<Button
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
size="sm"
onClick={() => onMediaFilterChange('video')}
className="text-xs md:text-sm"
>
{t('gallery.videosOnly', 'Videos')}
</Button>
</div>
</div>
)}
{/* Mobile/Tablet: compact horizontal icons with headline below categories */}
{feedbackEnabled && onFilterChange && (
@@ -3,7 +3,7 @@ import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
import { X, ChevronLeft, ChevronRight, Download, ZoomIn, ZoomOut, MessageSquare, Heart, Star } from 'lucide-react';
import type { Photo } from '../../types';
import { useDownloadPhoto } from '../../hooks/useGallery';
import { AuthenticatedImage } from '../common';
import { AuthenticatedImage, AuthenticatedVideo } from '../common';
import { PhotoFeedback } from './PhotoFeedback';
import { feedbackService } from '../../services/feedback.service';
import { FeedbackIdentityModal } from './FeedbackIdentityModal';
@@ -64,6 +64,11 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
const downloadPhotoMutation = useDownloadPhoto();
const currentPhoto = photos[currentIndex];
const isVideo = currentPhoto
? (currentPhoto.media_type === 'video' ||
(currentPhoto.mime_type && currentPhoto.mime_type.startsWith('video/')) ||
currentPhoto.type === 'video')
: false;
// DevTools protection for the lightbox when enhanced protection is enabled
useDevToolsProtection({
@@ -361,27 +366,31 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
</div>
<div className="flex items-center gap-2">
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom out"
>
<ZoomOut className="w-5 h-5 text-white" />
</button>
<span className="text-white text-sm w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
disabled={zoom >= 3}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom in"
>
<ZoomIn className="w-5 h-5 text-white" />
</button>
<div className="w-px h-6 bg-white/20 mx-2" />
{!isVideo && (
<>
<button
onClick={handleZoomOut}
disabled={zoom <= 1}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom out"
>
<ZoomOut className="w-5 h-5 text-white" />
</button>
<span className="text-white text-sm w-12 text-center">
{Math.round(zoom * 100)}%
</span>
<button
onClick={handleZoomIn}
disabled={zoom >= 3}
className="p-2 bg-white/10 hover:bg-white/20 rounded-full transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
aria-label="Zoom in"
>
<ZoomIn className="w-5 h-5 text-white" />
</button>
<div className="w-px h-6 bg-white/20 mx-2" />
</>
)}
{allowDownloads && (
<button
@@ -451,64 +460,74 @@ export const PhotoLightbox: React.FC<PhotoLightboxProps> = ({
{/* Image container */}
<div
className="absolute top-0 left-0 bottom-0 flex items-center justify-center z-0"
onClick={handleImageClick}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
onTouchStart={handleTouchStart}
onTouchMove={handleTouchMove}
onTouchEnd={handleTouchEnd}
onClick={isVideo ? undefined : handleImageClick}
onMouseDown={isVideo ? undefined : handleMouseDown}
onMouseMove={isVideo ? undefined : handleMouseMove}
onMouseUp={isVideo ? undefined : handleMouseUp}
onMouseLeave={isVideo ? undefined : handleMouseUp}
onTouchStart={isVideo ? undefined : handleTouchStart}
onTouchMove={isVideo ? undefined : handleTouchMove}
onTouchEnd={isVideo ? undefined : handleTouchEnd}
style={{
cursor: zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
cursor: isVideo ? 'default' : zoom > 1 ? (isDragging ? 'grabbing' : 'grab') : 'default',
right: isDesktopFeedback ? `${desktopFeedbackWidth}px` : 0,
}}
>
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none"
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
}}
draggable={false}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
slug={slug}
photoId={currentPhoto.id}
requiresToken={currentPhoto.requires_token}
secureUrlTemplate={currentPhoto.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
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', {
photoId: currentPhoto.id,
violationType,
protectionLevel,
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
{isVideo ? (
<AuthenticatedVideo
src={currentPhoto.url}
fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain bg-black"
slug={slug}
poster={currentPhoto.thumbnail_url || undefined}
/>
) : (
<AuthenticatedImage
src={currentPhoto.url}
alt={currentPhoto.filename}
fallbackSrc={currentPhoto.thumbnail_url || undefined}
className="max-w-full max-h-full object-contain select-none"
style={{
transform: `scale(${zoom}) translate(${dragOffset.x / zoom}px, ${dragOffset.y / zoom}px)`,
transition: isDragging ? 'none' : 'transform 0.2s',
}}
draggable={false}
useWatermark={useEnhancedProtection}
watermarkText={useEnhancedProtection ? `${currentPhoto.filename} - Protected` : undefined}
isGallery={true}
slug={slug}
photoId={currentPhoto.id}
requiresToken={currentPhoto.requires_token}
secureUrlTemplate={currentPhoto.secure_url_template}
protectFromDownload={!allowDownloads || useEnhancedProtection}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={protectionLevel === 'maximum'}
fragmentGrid={protectionLevel === 'enhanced' || protectionLevel === 'maximum'}
blockKeyboardShortcuts={useEnhancedProtection}
detectPrintScreen={useEnhancedProtection}
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', {
photoId: currentPhoto.id,
violationType,
protectionLevel,
zoom
});
}
// For maximum protection, close lightbox on violation
if (protectionLevel === 'maximum' &&
['devtools_detected', 'print_screen_detected', 'canvas_access_blocked'].includes(violationType)) {
onClose();
}
}}
/>
)}
</div>
{/* Touch/swipe indicators for mobile */}
@@ -1,5 +1,5 @@
import React from 'react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart } from 'lucide-react';
import { Download, Maximize2, Check, MessageSquare, Star, Heart, Video } from 'lucide-react';
import { useInView } from 'react-intersection-observer';
import { useTheme } from '../../../contexts/ThemeContext';
import { AuthenticatedImage } from '../../common';
@@ -155,6 +155,10 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
? 'opacity-100 md:opacity-100'
: 'opacity-0 md:opacity-0';
const isVideo = (photo.media_type === 'video') ||
(photo.mime_type && photo.mime_type.startsWith('video/')) ||
photo.type === 'video';
const handlePhotoClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (isTouchDevice && !overlayVisible && !isSelectionMode) {
e.preventDefault();
@@ -231,7 +235,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{showFeedbackActions && onQuickComment && (
{showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => {
@@ -318,6 +322,15 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
</div>
)}
{isVideo && (
<div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded flex items-center gap-1">
<Video className="w-3 h-3" />
{t('common.video', 'Video')}
</span>
</div>
)}
{photo.type === 'collage' && (
<div className="absolute bottom-2 right-2">
<span className="px-2 py-1 bg-black/60 text-white text-xs rounded">
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { Download, Maximize2, Check, ChevronDown, Calendar, Clock, Heart, MessageSquare } from 'lucide-react';
import { parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
@@ -50,6 +50,12 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
const [likedIds, setLikedIds] = useState<Set<number>>(new Set());
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
const gridRef = useRef<HTMLDivElement | null>(null);
const handleScrollToGrid = useCallback(() => {
if (gridRef.current) {
gridRef.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, []);
// If an override is provided, always use it and skip initialization logic
useEffect(() => {
@@ -162,31 +168,44 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
</div>
{/* Scroll Indicator */}
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
<button
onClick={() => {
// Scroll to the grid section
const gridSection = document.getElementById('gallery-grid-section');
if (gridSection) {
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
} else {
// Fallback: scroll down by hero section height
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
}
}}
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
aria-label="Scroll to gallery"
>
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
</div>
</button>
</div>
{/* Grid Section */}
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
<div id="gallery-grid-section" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
{remainingPhotos.map((photo) => {
const actualIndex = photos.findIndex(p => p.id === photo.id);
return (
<div
key={photo.id}
className="relative group cursor-pointer aspect-square"
className="relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => onPhotoClick(actualIndex)}
>
<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-105"
className="w-full h-auto object-cover transition-transform duration-300 group-hover:scale-105"
loading="lazy"
isGallery={true}
protectFromDownload={!allowDownloads}
/>
<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">
<div className="absolute inset-0 bg-black/40 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center gap-2">
{!isSelectionMode && (
<>
<button
@@ -117,7 +117,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<Download className="w-5 h-5 text-neutral-800" />
</button>
)}
{onQuickComment && (
{feedbackEnabled && feedbackOptions?.allowComments && onQuickComment && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
@@ -127,7 +127,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
<MessageSquare className="w-5 h-5 text-neutral-800" />
</button>
)}
{feedbackOptions?.allowLikes && (
{feedbackEnabled && feedbackOptions?.allowLikes && (
<button
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
onClick={async (e) => {
+2 -1
View File
@@ -5,6 +5,7 @@ import {
inferGallerySlugFromLocation,
resolveSlugFromRequestUrl,
} from '../utils/galleryAuthStorage';
import { getApiBaseUrl } from '../utils/url';
// Maintenance mode callback
let maintenanceModeCallback: ((enabled: boolean) => void) | null = null;
@@ -15,7 +16,7 @@ export const setMaintenanceModeCallback = (callback: (enabled: boolean) => void)
// Create axios instance
export const api = axios.create({
baseURL: import.meta.env.VITE_API_URL || '/api',
baseURL: getApiBaseUrl(),
headers: {
'Content-Type': 'application/json',
},
+64 -5
View File
@@ -26,6 +26,9 @@
"uploaded": "Hochgeladen",
"photo": "Foto",
"photos": "Fotos",
"video": "Video",
"videos": "Videos",
"media": "Medien",
"restore": "Wiederherstellen",
"actions": "Aktionen",
"refresh": "Aktualisieren",
@@ -49,12 +52,15 @@
"eventSpecific": "(Veranstaltungsspezifisch)",
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
"fileRequirementsMedia": "JPEG-, PNG- oder WebP-Bilder sowie MP4/MOV/WEBM-Videos (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
"unsupportedFiles": "Einige Dateien wurden übersprungen, da das Format nicht unterstützt wird (JPEG/PNG/WebP/MP4/MOV/WEBM verwenden).",
"selectedFiles": "Ausgewählte Dateien",
"uploading": "Wird hochgeladen...",
"uploadComplete": "Upload abgeschlossen!",
"uploadFailed": "Upload fehlgeschlagen",
"someFilesFailed": "Einige Dateien konnten nicht hochgeladen werden",
"uploadPhotos": "Fotos hochladen",
"uploadMedia": "Fotos & Videos hochladen",
"importExternal": "Aus externem Ordner importieren",
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
@@ -64,7 +70,9 @@
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch...",
"mediaCategory": "Medienkategorie",
"uploadAction": "{{count}} Dateien hochladen"
},
"navigation": {
"dashboard": "Dashboard",
@@ -513,6 +521,13 @@
"selectAll": "Alle auswählen",
"deselectAll": "Auswahl aufheben",
"downloadSelected": "{{count}} ausgewählte herunterladen",
"deleteSelected": "Ausgewählte löschen",
"photosCount": "{{count}} Foto",
"photosCount_plural": "{{count}} Fotos",
"searchByFilename": "Nach Dateinamen suchen...",
"uncategorized": "Ohne Kategorie",
"sortAscending": "Aufsteigend sortieren",
"sortDescending": "Absteigend sortieren",
"remaining": "verbleibend",
"selectPhotosHint": "Tipp: Verwenden Sie Strg+Klick (Cmd+Klick auf Mac), um schnell mehrere Fotos auszuwählen",
"filters": "Filter",
@@ -521,7 +536,12 @@
"toggleMenu": "Menü umschalten",
"allCategories": "Alle Kategorien",
"categories": "Kategorien",
"mediaType": "Medien",
"allMedia": "Alle Medien",
"photosOnly": "Fotos",
"videosOnly": "Videos",
"download": "Herunterladen",
"noMedia": "Noch keine Medien hochgeladen",
"searchPlaceholder": "Fotos suchen...",
"sortBy": "Sortieren nach",
"sortByDate": "Nach Datum sortieren",
@@ -1101,7 +1121,7 @@
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
"markAllRead": "Alle als gelesen markieren",
"clearOld": "Alte löschen",
"clearAll": "Alle löschen",
"close": "Schließen",
"noNotificationsMessage": "Keine Benachrichtigungen",
"notificationMessages": {
@@ -1134,11 +1154,13 @@
"archiveDownloaded": "Archiv für \"{{eventName}}\" heruntergeladen",
"archiveDeleted": "Archiv für \"{{eventName}}\" gelöscht",
"archiveRestored": "Archiv für \"{{eventName}}\" wiederhergestellt",
"systemActivity": "Systemaktivität: {{type}}"
"systemActivity": "Systemaktivität: {{type}}",
"adminProfileUpdated": "Admin-Profil aktualisiert von {{actorName}}"
},
"notificationToasts": {
"markedAllRead": "Alle Benachrichtigungen als gelesen markiert",
"clearedOld": "{{count}} alte Benachrichtigungen gelöscht"
"clearedAll": "{{count}} Benachrichtigungen gelöscht",
"profileUpdated": "Admin-Profil aktualisiert"
},
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
"noNotifications": "Keine neuen Benachrichtigungen",
@@ -1146,6 +1168,16 @@
"markAllAsRead": "Alle als gelesen markieren",
"notificationSettings": "Benachrichtigungseinstellungen",
"changePassword": "Passwort ändern",
"accountSettings": {
"title": "Admin-Konto",
"description": "Aktualisiere die Zugangsdaten für die PicPeak-Administration.",
"username": "Benutzername",
"usernamePlaceholder": "Admin",
"email": "E-Mail",
"emailPlaceholder": "admin@example.com",
"updateButton": "Profil aktualisieren"
},
"profileUpdateError": "Admin-Profil konnte nicht aktualisiert werden. Bitte versuche es erneut.",
"loadingDashboard": "Dashboard wird geladen...",
"activeEvents": "Aktive Veranstaltungen",
"expiringSoon": "Demnächst ablaufend",
@@ -1327,7 +1359,34 @@
"saveConfiguration": "Konfiguration speichern",
"emailTemplates": "E-Mail-Vorlagen",
"templateVariables": "Verfügbare Variablen",
"previewTemplate": "Vorlage anzeigen"
"previewTemplate": "Vorlage anzeigen",
"smtpSettings": "SMTP-Einstellungen",
"testEmailSuccess": "Test-E-Mail erfolgreich gesendet",
"saveSmtpSettings": "SMTP-Einstellungen speichern",
"testEmailSection": "E-Mail testen",
"beforeTesting": "Vor dem Testen:",
"saveSmtpFirst": "Speichern Sie zuerst Ihre SMTP-Einstellungen",
"ensureFirewall": "Stellen Sie sicher, dass Ihre Firewall ausgehende SMTP-Verbindungen erlaubt",
"gmailAppPassword": "Für Gmail verwenden Sie ein App-spezifisches Passwort",
"testEmailAddressLabel": "Test-E-Mail-Adresse",
"sendTestEmailButton": "Test-E-Mail senden",
"commonSmtpSettings": "Häufige SMTP-Einstellungen:",
"editTemplate": "Vorlage bearbeiten",
"templateName": "Vorlagenname",
"subjectLine": "Betreffzeile",
"emailBody": "E-Mail-Text",
"preview": "Vorschau",
"saveChanges": "Änderungen speichern",
"templates": "Vorlagen",
"variableHelp": "Verwenden Sie diese Variablen in Ihrer Vorlage. Sie werden beim Senden durch tatsächliche Werte ersetzt.",
"port": "Port",
"security": "Sicherheit",
"username": "Benutzername",
"password": "Passwort",
"enterPassword": "Passwort eingeben",
"required": "erforderlich",
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen."
},
"cms": {
"title": "CMS-Seiten",
+39 -5
View File
@@ -26,6 +26,9 @@
"uploaded": "Uploaded",
"photo": "photo",
"photos": "photos",
"video": "video",
"videos": "videos",
"media": "media",
"restore": "Restore",
"actions": "Actions",
"refresh": "Refresh",
@@ -49,12 +52,15 @@
"eventSpecific": "(Event specific)",
"clickToUpload": "Click to upload or drag and drop",
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
"fileRequirementsMedia": "JPEG, PNG or WebP images, plus MP4/MOV/WEBM videos (max 50MB per file, {{limit}} files per upload)",
"unsupportedFiles": "Some files were skipped because the format is not supported (use JPEG/PNG/WebP/MP4/MOV/WEBM).",
"selectedFiles": "Selected files",
"uploading": "Uploading...",
"uploadComplete": "Upload complete!",
"uploadFailed": "Upload failed",
"someFilesFailed": "Some files failed to upload",
"uploadPhotos": "Upload Photos",
"uploadMedia": "Upload Photos & Videos",
"importExternal": "Import from External Folder",
"externalImportInfo": "All pictures from the selected folder will be imported.",
"selectExternalFolder": "Select external folder under /external-media",
@@ -64,7 +70,9 @@
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
"limitReached": "Upload limit reached ({{limit}} files per batch)",
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
"uploadingChunks": "Uploading {{count}} files in {{total}} batches...",
"mediaCategory": "Media category",
"uploadAction": "Upload {{count}} files"
},
"navigation": {
"dashboard": "Dashboard",
@@ -178,6 +186,13 @@
"selectAll": "Select All",
"deselectAll": "Deselect All",
"downloadSelected": "Download {{count}} Selected",
"deleteSelected": "Delete Selected",
"photosCount": "{{count}} photo",
"photosCount_plural": "{{count}} photos",
"searchByFilename": "Search by filename...",
"uncategorized": "Uncategorized",
"sortAscending": "Sort ascending",
"sortDescending": "Sort descending",
"remaining": "remaining",
"selectPhotosHint": "Tip: Use Ctrl+Click (Cmd+Click on Mac) to quickly select multiple photos",
"filters": "Filters",
@@ -186,7 +201,12 @@
"toggleMenu": "Toggle menu",
"allCategories": "All Categories",
"categories": "Categories",
"mediaType": "Media",
"allMedia": "All media",
"photosOnly": "Photos",
"videosOnly": "Videos",
"download": "Download",
"noMedia": "No media uploaded yet",
"searchPlaceholder": "Search photos...",
"sortBy": "Sort By",
"sortByDate": "Sort by Date",
@@ -839,7 +859,7 @@
"viewAllNotifications": "View all notifications",
"noNotifications": "No new notifications",
"markAllRead": "Mark all read",
"clearOld": "Clear old",
"clearAll": "Clear all",
"close": "Close",
"noNotificationsMessage": "No notifications",
"notificationMessages": {
@@ -872,16 +892,28 @@
"archiveDownloaded": "Archive downloaded for \"{{eventName}}\"",
"archiveDeleted": "Archive deleted for \"{{eventName}}\"",
"archiveRestored": "Archive restored for \"{{eventName}}\"",
"systemActivity": "System activity: {{type}}"
"systemActivity": "System activity: {{type}}",
"adminProfileUpdated": "Admin profile updated by {{actorName}}"
},
"notificationToasts": {
"markedAllRead": "All notifications marked as read",
"clearedOld": "Cleared {{count}} old notifications"
"clearedAll": "Cleared {{count}} notifications",
"profileUpdated": "Admin profile updated"
},
"markAsRead": "Mark as read",
"markAllAsRead": "Mark all as read",
"notificationSettings": "Notification Settings",
"changePassword": "Change Password",
"accountSettings": {
"title": "Admin account",
"description": "Update the credentials used to sign in to PicPeak.",
"username": "Username",
"usernamePlaceholder": "Admin",
"email": "Email",
"emailPlaceholder": "admin@example.com",
"updateButton": "Update profile"
},
"profileUpdateError": "Unable to update admin profile. Please try again.",
"loadingDashboard": "Loading dashboard...",
"activeEvents": "Active Events",
"expiringSoon": "Expiring Soon",
@@ -1072,7 +1104,9 @@
"enterPassword": "Enter password",
"fromEmail": "From Email",
"fromName": "From Name",
"required": "required"
"required": "required",
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications."
},
"cms": {
"title": "CMS Pages",
+29
View File
@@ -23,6 +23,14 @@ export const BrandingPage: React.FC = () => {
watermark_size: 15,
watermark_logo_url: '',
favicon_url: '',
logo_url: '',
logo_size: 'medium',
logo_max_height: 48,
logo_position: 'left',
logo_display_header: true,
logo_display_hero: true,
logo_display_mode: 'logo_and_text',
hide_powered_by: false,
});
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
@@ -508,6 +516,27 @@ export const BrandingPage: React.FC = () => {
</div>
</div>
{/* White Label Settings */}
<div className="mt-6 pt-6 border-t border-neutral-200">
<h3 className="text-md font-semibold text-neutral-900 mb-4">{t('branding.whiteLabel', 'White Label')}</h3>
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={brandingSettings.hide_powered_by === true}
onChange={(e) => handleBrandingChange('hide_powered_by', e.target.checked)}
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-900">
{t('branding.hidePoweredBy', 'Hide "Powered by PicPeak" branding')}
</span>
<p className="text-xs text-neutral-600">
{t('branding.hidePoweredByHelp', 'Remove the PicPeak attribution from gallery footers for a fully white-labeled experience')}
</p>
</div>
</label>
</div>
<div className="mt-6 pt-6 border-t border-neutral-200">
<label className="flex items-center gap-3 cursor-pointer">
<input
+32 -6
View File
@@ -1,8 +1,8 @@
import React, { useState } from 'react';
import {
Mail,
Save,
Send,
import {
Mail,
Save,
Send,
Server,
Lock,
User,
@@ -10,6 +10,7 @@ import {
CheckCircle,
Eye,
EyeOff,
ShieldAlert,
} from 'lucide-react';
import { toast } from 'react-toastify';
@@ -107,7 +108,8 @@ export const EmailConfigPage: React.FC = () => {
smtp_user: '',
smtp_pass: '',
from_email: '',
from_name: 'Photo Sharing'
from_name: 'Photo Sharing',
tls_reject_unauthorized: true
});
// Fetch SMTP config
@@ -342,7 +344,7 @@ export const EmailConfigPage: React.FC = () => {
placeholder="587"
/>
</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.security')}
@@ -358,6 +360,30 @@ export const EmailConfigPage: React.FC = () => {
</div>
</div>
{/* Ignore SSL Certificate Errors */}
<div className="mt-2">
<label className="flex items-center gap-3 cursor-pointer">
<input
type="checkbox"
checked={!smtpConfig.tls_reject_unauthorized}
onChange={(e) => setSmtpConfig(prev => ({ ...prev, tls_reject_unauthorized: !e.target.checked }))}
className="w-4 h-4 text-primary-600 border-neutral-300 rounded focus:ring-primary-500"
/>
<span className="text-sm font-medium text-neutral-700">
{t('email.ignoreSslErrors')}
</span>
</label>
{!smtpConfig.tls_reject_unauthorized && (
<div className="mt-2 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start gap-2">
<ShieldAlert className="w-4 h-4 text-amber-600 flex-shrink-0 mt-0.5" />
<p className="text-xs text-amber-800">
{t('email.ignoreSslWarning')}
</p>
</div>
</div>
)}</div>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('email.username')}
+51 -2
View File
@@ -1,4 +1,4 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import {
@@ -208,6 +208,26 @@ export const EventDetailsPage: React.FC = () => {
enabled: !!id && (activeTab === 'photos' || isEditing),
});
const mediaTypes = useMemo(() => {
const types = new Set<'photo' | 'video'>();
photos.forEach((p: any) => {
const mediaType = (p.media_type as 'photo' | 'video' | undefined)
|| ((p.mime_type && String(p.mime_type).startsWith('video/')) || p.type === 'video' ? 'video' : 'photo');
if (mediaType === 'video' || mediaType === 'photo') {
types.add(mediaType);
}
});
return types;
}, [photos]);
const showMediaFilter = mediaTypes.has('photo') && mediaTypes.has('video');
useEffect(() => {
if (!showMediaFilter && photoFilters.media_type) {
setPhotoFilters(prev => ({ ...prev, media_type: undefined }));
}
}, [showMediaFilter, photoFilters.media_type]);
// Fetch categories for the event
const { data: categories = [] } = useQuery({
queryKey: ['admin-event-categories', id],
@@ -236,6 +256,28 @@ export const EventDetailsPage: React.FC = () => {
},
});
const applyThemeMutation = useMutation({
mutationFn: async ({ theme, presetName }: { theme: ThemeConfig; presetName: string }) => {
if (!id) {
throw new Error('Missing event identifier');
}
const colorThemeValue = presetName && presetName !== 'custom'
? presetName
: JSON.stringify(theme);
return eventsService.updateEvent(parseInt(id), { color_theme: colorThemeValue });
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
toast.success(t('branding.themeApplied', 'Theme updated'));
},
onError: (error: any) => {
const message = error?.response?.data?.error || t('branding.themeApplyError', 'Failed to apply theme');
toast.error(message);
}
});
// Archive mutation
const archiveMutation = useMutation({
mutationFn: () => eventsService.archiveEvent(parseInt(id!)),
@@ -1122,8 +1164,9 @@ export const EventDetailsPage: React.FC = () => {
}
}
}}
isPreviewMode={false}
isPreviewMode={true}
showGalleryLayouts={true}
hideActions={true}
/>
</Card>
)}
@@ -1214,6 +1257,12 @@ export const EventDetailsPage: React.FC = () => {
onCategoryChange={(categoryId) => setPhotoFilters(prev => ({ ...prev, category_id: categoryId }))}
onSearchChange={(search) => setPhotoFilters(prev => ({ ...prev, search }))}
onSortChange={(sort, order) => setPhotoFilters(prev => ({ ...prev, sort, order }))}
mediaType={photoFilters.media_type || 'all'}
onMediaTypeChange={(mediaType) => setPhotoFilters(prev => ({
...prev,
media_type: mediaType === 'all' ? undefined : mediaType
}))}
showMediaFilter={showMediaFilter}
/>
{/* Actions Bar */}
+27
View File
@@ -89,6 +89,20 @@ export const SettingsPage: React.FC = () => {
refetchInterval: 30000 // Refresh every 30 seconds
});
const updateProfileMutation = useMutation({
mutationFn: authService.updateAdminProfile,
onSuccess: (updatedUser) => {
updateAuthProfile(updatedUser);
setProfileError(null);
toast.success(t('admin.notificationToasts.profileUpdated'));
},
onError: (error: any) => {
const message = error?.response?.data?.error || t('admin.profileUpdateError');
setProfileError(message);
toast.error(message);
},
});
// General settings state
const [generalSettings, setGeneralSettings] = useState({
site_url: '',
@@ -456,6 +470,19 @@ export const SettingsPage: React.FC = () => {
}
});
const handleProfileSubmit = (event: React.FormEvent<HTMLFormElement>) => {
event.preventDefault();
if (!isProfileDirty || updateProfileMutation.isPending) {
return;
}
setProfileError(null);
updateProfileMutation.mutate({
username: profileForm.username.trim(),
email: profileForm.email.trim(),
});
};
const handleSaveCapacityOverride = () => {
if (saveCapacityOverrideMutation.isPending) {
return;
+6 -1
View File
@@ -1,5 +1,5 @@
import { api } from '../config/api';
import type { LoginResponse, GalleryAuthResponse } from '../types';
import type { LoginResponse, GalleryAuthResponse, AdminUser } from '../types';
import { normalizeRequirePassword } from '../utils/accessControl';
const normalizeGalleryResponse = (response: GalleryAuthResponse): GalleryAuthResponse => ({
@@ -61,4 +61,9 @@ export const authService = {
// Ignore; cookie will naturally expire if removal fails
}
},
async updateAdminProfile(profile: { username: string; email: string }): Promise<AdminUser> {
const response = await api.put<{ user: AdminUser }>('/auth/admin/profile', profile);
return response.data.user;
},
};
+1
View File
@@ -8,6 +8,7 @@ export interface EmailConfig {
smtp_pass: string;
from_email: string;
from_name: string;
tls_reject_unauthorized: boolean;
}
export interface EmailTemplate {
+10 -4
View File
@@ -38,9 +38,9 @@ export const notificationsService = {
await api.put('/admin/notifications/read-all');
},
// Clear old notifications
async clearOldNotifications(): Promise<{ deletedCount: number }> {
const response = await api.delete('/admin/notifications/clear-old');
// Clear all notifications
async clearAllNotifications(): Promise<{ deletedCount: number }> {
const response = await api.delete('/admin/notifications/clear-all');
return response.data;
},
@@ -133,6 +133,10 @@ export const notificationsService = {
return t('admin.notificationMessages.generalSettingsUpdated');
case 'security_settings_updated':
return t('admin.notificationMessages.securitySettingsUpdated');
case 'admin_profile_updated':
return t('admin.notificationMessages.adminProfileUpdated', {
actorName: notification.actorName,
});
case 'theme_updated':
return t('admin.notificationMessages.themeUpdated');
case 'archive_downloaded':
@@ -184,6 +188,8 @@ export const notificationsService = {
case 'security_settings_updated':
case 'theme_updated':
return { icon: 'Settings', color: 'text-gray-600' };
case 'admin_profile_updated':
return { icon: 'User', color: 'text-primary-600' };
case 'email_template_updated':
case 'email_config_updated':
return { icon: 'Mail', color: 'text-teal-600' };
@@ -209,4 +215,4 @@ export const notificationsService = {
return { icon: 'Bell', color: 'text-gray-600' };
}
}
};
};
+6 -2
View File
@@ -7,11 +7,13 @@ export interface AdminPhoto {
url: string;
thumbnail_url: string | null;
type: string;
category_id: number | null;
category_id: number | string | null;
category_name: string | null;
category_slug: string | null;
size: number;
uploaded_at: string;
media_type?: 'photo' | 'video';
mime_type?: string | null;
view_count?: number;
download_count?: number;
// Feedback fields
@@ -23,8 +25,9 @@ export interface AdminPhoto {
}
export interface PhotoFilters {
category_id?: number | null;
category_id?: number | string | null;
type?: string;
media_type?: 'photo' | 'video';
search?: string;
sort?: 'date' | 'name' | 'size' | 'rating';
order?: 'asc' | 'desc';
@@ -39,6 +42,7 @@ class PhotosService {
params.append('category_id', filters.category_id?.toString() || '');
}
if (filters.type) params.append('type', filters.type);
if (filters.media_type) params.append('media_type', filters.media_type);
if (filters.search) params.append('search', filters.search);
if (filters.sort) params.append('sort', filters.sort);
if (filters.order) params.append('order', filters.order);
+13 -4
View File
@@ -18,6 +18,7 @@ export interface BrandingSettings {
logo_display_header?: boolean;
logo_display_hero?: boolean;
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
hide_powered_by?: boolean;
}
export interface ThemeSettings {
@@ -256,6 +257,13 @@ export const settingsService = {
return response.data;
},
// Helper to parse boolean values that might come as strings or actual booleans
_parseBoolean(value: any, defaultValue: boolean): boolean {
if (value === true || value === 'true') return true;
if (value === false || value === 'false') return false;
return defaultValue;
},
// Format branding settings from raw data
formatBrandingSettings(rawSettings: Record<string, any>): BrandingSettings {
return {
@@ -263,7 +271,7 @@ export const settingsService = {
company_tagline: rawSettings.branding_company_tagline || '',
support_email: rawSettings.branding_support_email || '',
footer_text: rawSettings.branding_footer_text || '',
watermark_enabled: rawSettings.branding_watermark_enabled || false,
watermark_enabled: this._parseBoolean(rawSettings.branding_watermark_enabled, false),
watermark_position: rawSettings.branding_watermark_position || 'bottom-right',
watermark_opacity: rawSettings.branding_watermark_opacity || 50,
watermark_size: rawSettings.branding_watermark_size || 15,
@@ -273,9 +281,10 @@ export const settingsService = {
logo_size: rawSettings.branding_logo_size || 'medium',
logo_max_height: rawSettings.branding_logo_max_height || 48,
logo_position: rawSettings.branding_logo_position || 'left',
logo_display_header: rawSettings.branding_logo_display_header !== false,
logo_display_hero: rawSettings.branding_logo_display_hero !== false,
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text'
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text',
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false)
};
},
+5 -3
View File
@@ -55,8 +55,10 @@ export interface Photo {
secure_url_template?: string;
download_url_template?: string;
requires_token?: boolean;
type: 'collage' | 'individual';
category_id?: number;
type: 'collage' | 'individual' | 'video';
media_type?: 'photo' | 'video';
mime_type?: string;
category_id?: number | string | null;
category_name?: string;
category_slug?: string;
size: number;
@@ -71,7 +73,7 @@ export interface Photo {
}
export interface PhotoCategory {
id: number;
id: number | string;
name: string;
slug: string;
is_global: boolean;
+58
View File
@@ -0,0 +1,58 @@
import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest';
import { getApiBaseUrl, buildResourceUrl } from '../url';
const originalLocation = window.location;
const setLocation = (origin: string) => {
const parsed = new URL(origin);
Object.defineProperty(window, 'location', {
value: {
origin: parsed.origin,
hostname: parsed.hostname,
href: parsed.href,
},
configurable: true,
});
};
describe('url utilities', () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.unstubAllEnvs();
setLocation('https://example.com');
});
afterEach(() => {
Object.defineProperty(window, 'location', {
value: originalLocation,
configurable: true,
});
});
it('returns relative API base by default', () => {
vi.unstubAllEnvs();
expect(getApiBaseUrl()).toBe('/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('https://example.com/api/gallery/test');
});
it('honours absolute API URLs for non-local hosts', () => {
vi.stubEnv('VITE_API_URL', 'https://api.picpeak.cloud/api');
expect(getApiBaseUrl()).toBe('https://api.picpeak.cloud/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('https://api.picpeak.cloud/api/gallery/test');
expect(buildResourceUrl('/uploads/logo.png')).toBe('https://api.picpeak.cloud/uploads/logo.png');
});
it('falls back to relative when build-time URL is localhost but browser host is remote', () => {
vi.stubEnv('VITE_API_URL', 'http://localhost:3001/api');
setLocation('https://photos.example.com');
expect(getApiBaseUrl()).toBe('/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('https://photos.example.com/api/gallery/test');
expect(buildResourceUrl('uploads/logo.png')).toBe('https://photos.example.com/uploads/logo.png');
});
it('keeps localhost API URL when browser is also localhost', () => {
vi.stubEnv('VITE_API_URL', 'http://127.0.0.1:3001/api');
setLocation('http://127.0.0.1:3000');
expect(getApiBaseUrl()).toBe('http://127.0.0.1:3001/api');
expect(buildResourceUrl('/api/gallery/test')).toBe('http://127.0.0.1:3001/api/gallery/test');
});
});
+108 -20
View File
@@ -2,39 +2,126 @@
* Utility functions for URL handling in production environments
*/
const ABSOLUTE_URL_REGEX = /^https?:\/\//i;
const LOCAL_HOSTNAMES = new Set(['localhost', '127.0.0.1', '[::1]']);
const isBrowser = typeof window !== 'undefined' && typeof window.location !== 'undefined';
const normalizeBase = (value: string): string => value.replace(/\/+$/, '');
const getEnvApiUrl = (): string | undefined => {
const raw = import.meta.env?.VITE_API_URL;
if (!raw || raw === '') {
return undefined;
}
if (raw === '/') {
return '/api';
}
return raw;
};
const isLocalHostname = (hostname: string): boolean => LOCAL_HOSTNAMES.has(hostname.toLowerCase());
const shouldFallbackToRelative = (url: string): boolean => {
if (!ABSOLUTE_URL_REGEX.test(url)) {
return false;
}
if (!isBrowser) {
return false;
}
try {
const parsed = new URL(url);
const envHostIsLocal = isLocalHostname(parsed.hostname);
const browserHost = window.location.hostname?.toLowerCase?.() ?? '';
const browserHostIsLocal = isLocalHostname(browserHost);
// Only fallback when the build-time URL points to localhost/loopback
// but the runtime browser location is remote (non-local).
return envHostIsLocal && !browserHostIsLocal;
} catch {
return false;
}
};
const buildFromOrigin = (path: string): string => {
if (!isBrowser) {
return path;
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${window.location.origin}${normalizedPath}`;
};
/**
* Get the base API URL, preferring relative URLs for production
* Get the base API URL, preferring relative URLs for production and
* falling back to relative when the build was created with localhost
* endpoints but is being accessed from a remote browser.
* @returns The API base URL
*/
export const getApiBaseUrl = (): string => {
// If VITE_API_URL is explicitly set, use it
if (import.meta.env.VITE_API_URL && import.meta.env.VITE_API_URL !== '/api') {
return import.meta.env.VITE_API_URL;
const envUrl = getEnvApiUrl();
if (envUrl && envUrl !== '/api') {
if (ABSOLUTE_URL_REGEX.test(envUrl) && shouldFallbackToRelative(envUrl)) {
return '/api';
}
return envUrl;
}
// In production, use relative URL
return '/api';
};
const buildFromAbsoluteApi = (base: string, path: string): string => {
const trimmedBase = normalizeBase(base);
// When the path already targets /api we want to preserve the suffix
if (path.startsWith('/api')) {
const pathWithoutLeadingApi = path.replace(/^\/api/, '');
return `${trimmedBase}${pathWithoutLeadingApi}`;
}
// For non-API assets (uploads, thumbnails, etc.) drop any /api suffix
const origin = trimmedBase.replace(/\/api$/, '');
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
return `${origin}${normalizedPath}`;
};
/**
* Build a full URL for resources (images, files, etc.)
* In production, this will use the current origin
* In production, this will prefer the current origin unless an absolute
* API URL is explicitly configured and applicable.
* @param path - The resource path
* @returns The full URL
*/
export const buildResourceUrl = (path: string): string => {
// Remove leading slash if present
const cleanPath = path.startsWith('/') ? path.slice(1) : path;
// If we have an explicit API URL that's not relative, use it
const apiUrl = import.meta.env.VITE_API_URL;
if (apiUrl && apiUrl !== '/api' && apiUrl.startsWith('http')) {
const baseUrl = apiUrl.replace(/\/api\/?$/, ''); // Remove /api suffix if present
return `${baseUrl}/${cleanPath}`;
if (!path) {
return '';
}
// In production (relative API), use current origin
return `${window.location.origin}/${cleanPath}`;
// Absolute paths (http/https) should generally be respected,
// except when they point to localhost but we're running remotely.
if (ABSOLUTE_URL_REGEX.test(path)) {
if (!shouldFallbackToRelative(path)) {
return path;
}
try {
const parsed = new URL(path);
return buildFromOrigin(`${parsed.pathname}${parsed.search}${parsed.hash}`);
} catch {
return path;
}
}
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
const apiBase = getApiBaseUrl();
if (ABSOLUTE_URL_REGEX.test(apiBase)) {
return buildFromAbsoluteApi(apiBase, normalizedPath);
}
return buildFromOrigin(normalizedPath);
};
/**
@@ -42,5 +129,6 @@ export const buildResourceUrl = (path: string): string => {
* @returns True if in production mode
*/
export const isProductionMode = (): boolean => {
return !import.meta.env.VITE_API_URL || import.meta.env.VITE_API_URL === '/api';
};
const apiBase = getApiBaseUrl();
return !ABSOLUTE_URL_REGEX.test(apiBase);
};
+5
View File
@@ -19,6 +19,11 @@ const config: VitestUserConfig = {
},
sourcemap: true,
},
test: {
environment: 'jsdom',
setupFiles: './vitest.setup.ts',
globals: true
},
server: {
port: 5173,
host: true,
+7
View File
@@ -0,0 +1,7 @@
import { expect, vi } from 'vitest';
import * as matchers from '@testing-library/jest-dom/matchers';
expect.extend(matchers);
// Provide Jest-compatible globals for existing tests that rely on jest.fn
(globalThis as any).jest = vi;
-372
View File
@@ -1,372 +0,0 @@
# Product Requirements Document: Event Photo Sharing Platform
## 1. Executive Summary
### 1.1 Product Overview
A secure, customizable photo sharing platform designed primarily for wedding photo booths but adaptable for any event type. The platform enables event organizers to easily share photos with guests through password-protected, time-limited links while maintaining a simple file-based backend system with automatic archiving.
### 1.2 Key Value Propositions
- **Simple Backend Management**: Drop photos in folders, generate links instantly
- **Secure Sharing**: Password-protected access with expiration dates
- **Automated Lifecycle**: Automatic archiving and storage optimization
- **Proactive Communication**: Email notifications for key events
- **Personalized Experience**: Custom branding for each event
- **Analytics Integration**: Track engagement through Umami
- **Versatile Use Cases**: Optimized for weddings but suitable for any event
## 2. Product Goals & Objectives
### 2.1 Primary Goals
- Provide a seamless, time-limited photo sharing experience for event guests
- Minimize technical complexity for administrators
- Ensure photo privacy through password protection and link expiration
- Automate storage management through intelligent archiving
- Enable detailed analytics on photo access and engagement
- Keep stakeholders informed through automated notifications
### 2.2 Success Metrics
- Time to generate new event gallery (<2 minutes)
- Guest satisfaction score (>90%)
- Photo view/download rates
- System uptime (99.9%)
- Successful automatic archiving rate (100%)
- Email delivery rate (>98%)
## 3. User Personas
### 3.1 Administrator (Event Organizer/Photographer)
- **Background**: Professional photographer or event organizer
- **Technical Skills**: Basic to intermediate
- **Needs**: Quick photo upload, easy link generation, access analytics, automated cleanup
- **Pain Points**: Complex upload processes, managing multiple events, storage management
### 3.2 End User (Event Guest)
- **Background**: Wedding guest or event attendee
- **Technical Skills**: Varies widely
- **Needs**: Easy photo viewing, downloading, sharing within timeframe
- **Pain Points**: Complicated interfaces, slow loading, expired links
### 3.3 Event Host (Bride/Groom/Celebrant)
- **Background**: Person celebrating the event
- **Technical Skills**: Basic
- **Needs**: Notification of gallery availability, awareness of expiration
- **Pain Points**: Missing the opportunity to save photos, not knowing when gallery is ready
## 4. Functional Requirements
### 4.1 Backend Administration
#### 4.1.1 File Management System
- **Photo Upload**: Direct file system access via designated folders
- **Folder Structure**:
```
/events/
├── active/
│ ├── wedding-smith-jones-2024-06-15/
│ │ ├── collages/
│ │ │ ├── collage_001.jpg
│ │ │ └── collage_002.jpg
│ │ └── individual/
│ │ ├── photo_001.jpg
│ │ └── photo_002.jpg
│ └── birthday-emma-2024-07-20/
│ └── photos/
└── archived/
└── wedding-smith-jones-2024-06-15.zip
```
- **Supported Formats**: JPEG, PNG, WebP
- **Auto-detection**: System monitors folders for new photos
- **Automatic Archiving**: Upon expiration, compress folder to ZIP and move to archive
#### 4.1.2 Link Generation
- **Unique URL Generation**: Automatic creation of shareable links
- **Password Setting**: Admin sets password during link creation
- **Expiration Date**: Mandatory expiration date selection (default: 30 days)
- **Event Metadata**:
- Event type (wedding, birthday, corporate, etc.)
- Names (couple names for weddings, celebrant for others)
- Event date
- Host email address (for notifications)
- Admin notification email
- Custom welcome message
- Color theme selection
- Link validity period
#### 4.1.3 Email Notification System
- **Trigger Events**:
- Link creation: Notify host with access details
- Link expiration warning: 7 days before expiration
- Link expiration: Notify both host and admin
- Archive completion: Confirm to admin
- **Email Templates**: Customizable, branded email templates
- **Configuration**: SMTP settings, from address, reply-to address
#### 4.1.4 Admin Dashboard
- **Event Management**: List all events, active/inactive/archived status
- **Expiration Overview**: Timeline view of upcoming expirations
- **Analytics Overview**: Quick stats per event
- **Link Management**: Copy links, reset passwords, extend expiration, deactivate events
- **Bulk Operations**: Archive old events, batch photo operations
- **Email Configuration**: Template management, SMTP settings
- **Archive Management**: View and download archived ZIPs
### 4.2 Frontend Guest Experience
#### 4.2.1 Landing Page
- **Password Entry**: Clean, intuitive password input
- **Event Preview**: Show event name, date, and expiration notice
- **Expiration Warning**: Prominent display if <7 days remaining
- **Expired State**: Clear message with contact information if expired
- **Responsive Design**: Mobile-first approach
#### 4.2.2 Gallery View
- **Expiration Banner**: Sticky banner showing days remaining
- **Grid Layout**: Responsive photo grid with lazy loading
- **View Toggle**: Switch between collages and individual photos
- **Sorting Options**: By date, name, or custom order
- **Search**: Basic filename or date search
- **Download Urgency**: Prominent "Download All" for soon-to-expire galleries
#### 4.2.3 Photo Interactions
- **Lightbox View**: Full-screen photo viewing with navigation
- **Zoom**: Pinch-to-zoom on mobile, mouse wheel on desktop
- **Download Options**:
- Single photo download
- Bulk download (selected photos)
- Download all (ZIP file)
- **Sharing**: Direct link to specific photos (respects expiration)
#### 4.2.4 Personalization
- **Dynamic Theming**: Based on event type and admin preferences
- **Custom Headers**: Event names, dates, and messages
- **Branded Elements**: Optional logo upload
- **Expiration Messaging**: Customizable expiration notices
### 4.3 Analytics Integration
#### 4.3.1 Umami Analytics
- **Page Views**: Track gallery visits
- **User Actions**: Photo views, downloads, time spent
- **Device/Browser Stats**: Understand user base
- **Geographic Data**: Guest locations
- **Custom Events**:
- Password entries (successful/failed)
- Photo downloads
- Share button clicks
- Expiration warning views
- Last-minute download spikes
### 4.4 Archiving System
#### 4.4.1 Automatic Archiving Process
- **Trigger**: Activated upon link expiration
- **Process**:
1. Create ZIP file with folder structure preserved
2. Verify ZIP integrity
3. Move ZIP to archive location
4. Delete original files
5. Update database with archive location
6. Send confirmation emails
#### 4.4.2 Archive Management
- **Storage Optimization**: Compression settings for long-term storage
- **Retrieval System**: Admin can restore archives if needed
- **Retention Policy**: Configurable long-term retention rules
## 5. Technical Requirements
### 5.1 Architecture
#### 5.1.1 Infrastructure
- **Backend Access**: Dedicated FQDN (e.g., admin.photos.domain.com)
- **Frontend Access**: Public FQDN (e.g., photos.domain.com)
- **File Storage**: Local file system or network-attached storage
- **Archive Storage**: Separate location for long-term ZIP storage
- **Database**: Lightweight database for metadata (SQLite or PostgreSQL)
- **Email Service**: SMTP integration or email service provider
#### 5.1.2 Security
- **HTTPS**: Required for both frontend and backend
- **Password Hashing**: Bcrypt or similar for stored passwords
- **Rate Limiting**: Prevent brute force attacks
- **Access Logs**: Track all access attempts
- **Expiration Enforcement**: Server-side validation of link validity
### 5.2 Performance Requirements
- **Page Load Time**: <3 seconds on 4G connection
- **Image Optimization**: Automatic thumbnail generation
- **Caching**: CDN integration for static assets
- **Concurrent Users**: Support 100+ simultaneous users per event
- **Archive Generation**: Complete within 10 minutes for 1000 photos
### 5.3 Technology Stack (Recommended)
- **Backend**: Node.js with Express or Python with FastAPI
- **Frontend**: React or Vue.js for dynamic interactions
- **Image Processing**: Sharp (Node.js) or Pillow (Python)
- **File Monitoring**: Chokidar or Watchdog
- **Analytics**: Umami self-hosted or cloud
- **Email Service**: Nodemailer or SendGrid
- **Job Queue**: Bull (Node.js) or Celery (Python) for archiving tasks
- **Scheduler**: Node-cron or APScheduler for expiration checks
## 6. User Interface Requirements
### 6.1 Design Principles
- **Modern Aesthetic**: Clean, minimalist design
- **Wedding-Optimized**: Elegant typography, romantic color options
- **Urgency Communication**: Clear expiration indicators
- **Accessibility**: WCAG 2.1 AA compliant
- **Responsive**: Mobile, tablet, and desktop optimized
### 6.2 UI Components
- **Photo Grid**: Masonry or uniform grid layout
- **Navigation**: Sticky header with view toggles
- **Expiration Timer**: Countdown display for urgent galleries
- **Loading States**: Skeleton screens for better UX
- **Error Handling**: Friendly error messages
- **Email Status**: Indicators for sent notifications
### 6.3 Branding Options
- **Color Schemes**: Pre-defined themes plus custom colors
- **Font Selection**: Google Fonts integration
- **Layout Templates**: Multiple gallery layout options
- **Email Templates**: Matching email designs
## 7. Non-Functional Requirements
### 7.1 Scalability
- Horizontal scaling capability
- Support for 10,000+ photos per event
- Efficient handling of high-resolution images
- Queue system for archiving operations
### 7.2 Reliability
- 99.9% uptime SLA
- Automated backups (including archives)
- Graceful error handling
- Failed job retry mechanisms
### 7.3 Maintainability
- Clear code documentation
- Modular architecture
- Automated testing suite
- Monitoring for failed archiving jobs
### 7.4 Compliance
- GDPR compliance for EU users
- Copyright considerations
- Privacy policy and terms of service
- Data retention policies
## 8. Email Templates
### 8.1 Link Creation Email (to Host)
- Subject: "Your [Event Name] Photos Are Ready!"
- Content: Access details, password, expiration date
- Call-to-action: View gallery button
### 8.2 Expiration Warning Email
- Subject: "Your [Event Name] Photos Expire in 7 Days"
- Content: Urgency message, download instructions
- Call-to-action: Download all photos button
### 8.3 Expiration Notification Email
- To Host: "Your [Event Name] Photo Gallery Has Expired"
- To Admin: "[Event Name] Gallery Archived Successfully"
- Content: Confirmation of archiving, contact for retrieval
## 9. Future Enhancements
### 9.1 Phase 2 Features
- **Flexible Expiration**: Extend expiration for individual users
- **Partial Downloads**: Resume interrupted downloads
- **AI-Powered Features**: Face recognition for automatic grouping
- **Social Integration**: Direct sharing to social media
- **Guest Uploads**: Allow guests to add their photos
- **Video Support**: Basic video playback
### 9.2 Phase 3 Features
- **Mobile Apps**: Native iOS/Android applications
- **Print Integration**: Direct ordering of prints
- **Event Packages**: Bundled services with photographers
- **Multi-language Support**: Internationalization
- **Cloud Archive**: Optional cloud storage for archives
## 10. Success Criteria
### 10.1 Launch Criteria
- Successfully handle 10 concurrent events
- Process 1,000 photos in <5 minutes
- 100% successful archiving rate
- Achieve 95% positive user feedback in beta
- Email delivery rate >98%
### 10.2 Post-Launch Metrics
- Monthly active events: 100+
- Average photos per event: 200+
- Guest engagement rate: 70%+
- Download rate: 50%+ of guests
- On-time archiving: 99%+
## 11. Risks & Mitigation
### 11.1 Technical Risks
- **Storage Limitations**: Implement automated archiving and cloud storage
- **Performance Issues**: Progressive loading and CDN usage
- **Security Breaches**: Regular security audits
- **Archive Failures**: Redundant archiving with verification
- **Email Delivery**: Multiple SMTP providers, delivery monitoring
### 11.2 Business Risks
- **Low Adoption**: Marketing partnerships with photographers
- **Feature Creep**: Strict MVP scope adherence
- **Support Burden**: Comprehensive documentation and FAQs
- **Expired Link Complaints**: Clear communication, grace period
## 12. Timeline & Milestones
### 12.1 Development Phases
- **Phase 1 (MVP)**: 10-12 weeks
- Core functionality
- Basic UI
- Expiration system
- Email notifications
- Archiving system
- Umami integration
- **Phase 2 (Enhancement)**: 4-6 weeks
- Advanced features
- Performance optimization
- **Phase 3 (Polish)**: 2-4 weeks
- UI refinements
- Beta testing
### 12.2 Key Milestones
- Week 2: Technical architecture finalized
- Week 4: Backend functionality complete
- Week 6: Frontend gallery functional
- Week 7: Email system integrated
- Week 8: Archiving system complete
- Week 9: Analytics integrated
- Week 12: Beta launch
## 13. Appendices
### 13.1 Technical Specifications
- Detailed API documentation
- Database schema (including expiration tracking)
- File naming conventions
- Archive format specifications
### 13.2 Design Mockups
- UI wireframes
- Email template designs
- Expiration state displays
- Style guide
- Component library
### 13.3 Testing Plan
- Unit test coverage
- Integration testing
- Archiving system testing
- Email delivery testing
- User acceptance criteria
-176
View File
@@ -1,176 +0,0 @@
#!/bin/bash
# Gitea Act Runner Installation Script
set -e
echo "==================================="
echo "Gitea Act Runner Installation"
echo "==================================="
# Configuration
GITEA_URL="https://gitea.nothaft.cloud"
RUNNER_NAME="picpeak-runner-$(hostname)"
RUNNER_VERSION="0.2.10" # Latest stable version
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
echo -e "${YELLOW}This script will help you install and register a Gitea Act Runner${NC}"
echo ""
# Step 1: Get registration token
echo -e "${GREEN}Step 1: Get Registration Token${NC}"
echo "1. Go to: $GITEA_URL/admin/runners"
echo "2. Click 'Create new Runner'"
echo "3. Copy the registration token"
echo ""
read -p "Enter your registration token: " REGISTRATION_TOKEN
if [ -z "$REGISTRATION_TOKEN" ]; then
echo -e "${RED}Error: Registration token is required${NC}"
exit 1
fi
# Step 2: Choose installation method
echo ""
echo -e "${GREEN}Step 2: Choose Installation Method${NC}"
echo "1. Docker (Recommended)"
echo "2. Binary installation"
read -p "Choose method (1 or 2): " METHOD
if [ "$METHOD" == "1" ]; then
# Docker installation
echo ""
echo -e "${GREEN}Installing with Docker...${NC}"
# Check if Docker is installed
if ! command -v docker &> /dev/null; then
echo -e "${RED}Error: Docker is not installed${NC}"
echo "Please install Docker first: https://docs.docker.com/get-docker/"
exit 1
fi
# Create docker-compose file for runner
cat > docker-compose.runner.yml << EOF
version: '3.8'
services:
gitea-runner:
image: gitea/act_runner:latest
container_name: gitea-runner
restart: unless-stopped
environment:
- GITEA_INSTANCE_URL=$GITEA_URL
- GITEA_RUNNER_REGISTRATION_TOKEN=$REGISTRATION_TOKEN
- GITEA_RUNNER_NAME=$RUNNER_NAME
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./runner-data:/data
networks:
- picpeak
networks:
picpeak:
external: true
EOF
echo "Starting Gitea Runner with Docker..."
docker-compose -f docker-compose.runner.yml up -d
echo ""
echo -e "${GREEN}✓ Runner installed and started with Docker${NC}"
echo "Check logs with: docker logs gitea-runner"
elif [ "$METHOD" == "2" ]; then
# Binary installation
echo ""
echo -e "${GREEN}Installing binary...${NC}"
# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$ARCH" in
x86_64)
ARCH="amd64"
;;
aarch64|arm64)
ARCH="arm64"
;;
*)
echo -e "${RED}Unsupported architecture: $ARCH${NC}"
exit 1
;;
esac
# Download act_runner
DOWNLOAD_URL="https://gitea.com/gitea/act_runner/releases/download/v${RUNNER_VERSION}/act_runner-${RUNNER_VERSION}-${OS}-${ARCH}"
echo "Downloading from: $DOWNLOAD_URL"
curl -L -o act_runner "$DOWNLOAD_URL"
chmod +x act_runner
# Create config directory
mkdir -p ~/.config/act_runner
# Register the runner
echo ""
echo -e "${GREEN}Registering runner...${NC}"
./act_runner register \
--no-interactive \
--instance "$GITEA_URL" \
--token "$REGISTRATION_TOKEN" \
--name "$RUNNER_NAME" \
--labels "ubuntu-latest:docker://node:16-bullseye,ubuntu-22.04:docker://node:16-bullseye,ubuntu-20.04:docker://node:16-bullseye"
# Create systemd service
if [ "$OS" == "linux" ]; then
echo ""
echo -e "${GREEN}Creating systemd service...${NC}"
sudo tee /etc/systemd/system/gitea-runner.service > /dev/null << EOF
[Unit]
Description=Gitea Act Runner
After=network.target
[Service]
Type=simple
User=$USER
WorkingDirectory=$PWD
ExecStart=$PWD/act_runner daemon
Restart=always
RestartSec=5
Environment="PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable gitea-runner
sudo systemctl start gitea-runner
echo -e "${GREEN}✓ Runner installed as systemd service${NC}"
echo "Check status with: sudo systemctl status gitea-runner"
echo "Check logs with: sudo journalctl -u gitea-runner -f"
else
echo ""
echo -e "${GREEN}✓ Runner installed${NC}"
echo "Start runner with: ./act_runner daemon"
fi
fi
echo ""
echo -e "${GREEN}==================================="
echo "Installation Complete!"
echo "===================================${NC}"
echo ""
echo "Next steps:"
echo "1. Go to: $GITEA_URL/paul/picpeak/settings/actions/runners"
echo "2. Verify your runner appears in the list"
echo "3. Push a commit to trigger the test workflow"
echo ""
echo "If the runner doesn't appear, check the logs for errors."
+15 -5
View File
@@ -385,12 +385,13 @@ setup_docker_installation() {
app_dir="/home/$SUDO_USER/picpeak"
fi
log_step "Creating application directory at $app_dir"
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
# Clone repository
log_step "Downloading PicPeak..."
log_step "Preparing application directory at $app_dir"
local app_parent_dir
app_parent_dir=$(dirname "$app_dir")
mkdir -p "$app_parent_dir"
if [[ -d "$app_dir/.git" ]]; then
log_step "Existing PicPeak repository detected; pulling latest changes"
cd "$app_dir"
git pull
elif [[ -d "$app_dir" ]]; then
@@ -417,8 +418,17 @@ setup_docker_installation() {
fi
fi
else
if [[ -d "$app_dir" && -n "$(find "$app_dir" -mindepth 1 -maxdepth 1 -print -quit 2>/dev/null)" ]]; then
die "Target directory $app_dir already exists and is not empty. Remove it or specify --install-dir before retrying."
fi
log_step "Cloning PicPeak..."
rm -rf "$app_dir"
git clone "$REPO_URL" "$app_dir"
fi
# Ensure storage layout exists after cloning
mkdir -p "$app_dir"/{storage/events/{active,archived},logs,backup,config,data,events}
# Determine host user for container mapping (PUID/PGID)
local host_uid host_gid
View File
View File
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1014 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 580 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 853 KiB

+1
View File
@@ -0,0 +1 @@
404: Not Found
Binary file not shown.