refactor: rename project from wedding-photo-sharing to PicPeak
- Update Docker image names and network configurations - Rename package.json project names to picpeak-backend/frontend - Update CI/CD configurations (Drone CI and GitHub Actions) - Update documentation and setup scripts - Update application branding in source code - Change default database name to picpeak - Update PM2 ecosystem config 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
This commit is contained in:
+4
-4
@@ -7,7 +7,7 @@ steps:
|
|||||||
- name: build-backend
|
- name: build-backend
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
|
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
- ${DRONE_COMMIT_SHA:0:8}
|
- ${DRONE_COMMIT_SHA:0:8}
|
||||||
@@ -19,7 +19,7 @@ steps:
|
|||||||
- name: build-frontend
|
- name: build-frontend
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
|
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||||
tags:
|
tags:
|
||||||
- latest
|
- latest
|
||||||
- ${DRONE_COMMIT_SHA:0:8}
|
- ${DRONE_COMMIT_SHA:0:8}
|
||||||
@@ -45,7 +45,7 @@ steps:
|
|||||||
- name: build-backend-release
|
- name: build-backend-release
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
|
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||||
tags:
|
tags:
|
||||||
- ${DRONE_TAG}
|
- ${DRONE_TAG}
|
||||||
- latest
|
- latest
|
||||||
@@ -57,7 +57,7 @@ steps:
|
|||||||
- name: build-frontend-release
|
- name: build-frontend-release
|
||||||
image: plugins/docker
|
image: plugins/docker
|
||||||
settings:
|
settings:
|
||||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
|
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||||
tags:
|
tags:
|
||||||
- ${DRONE_TAG}
|
- ${DRONE_TAG}
|
||||||
- latest
|
- latest
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
name: Create Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
paths:
|
||||||
|
- 'frontend/package.json'
|
||||||
|
- 'backend/package.json'
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
check-version-change:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
outputs:
|
||||||
|
version_changed: ${{ steps.check.outputs.changed }}
|
||||||
|
new_version: ${{ steps.check.outputs.version }}
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 2
|
||||||
|
|
||||||
|
- name: Check if version changed
|
||||||
|
id: check
|
||||||
|
run: |
|
||||||
|
# Get current versions
|
||||||
|
FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version")
|
||||||
|
BACKEND_VERSION=$(node -p "require('./backend/package.json').version")
|
||||||
|
|
||||||
|
# Get previous versions
|
||||||
|
git checkout HEAD~1
|
||||||
|
PREV_FRONTEND_VERSION=$(node -p "require('./frontend/package.json').version" 2>/dev/null || echo "0.0.0")
|
||||||
|
PREV_BACKEND_VERSION=$(node -p "require('./backend/package.json').version" 2>/dev/null || echo "0.0.0")
|
||||||
|
|
||||||
|
# Check if versions changed
|
||||||
|
if [[ "$FRONTEND_VERSION" != "$PREV_FRONTEND_VERSION" ]] || [[ "$BACKEND_VERSION" != "$PREV_BACKEND_VERSION" ]]; then
|
||||||
|
echo "changed=true" >> $GITHUB_OUTPUT
|
||||||
|
echo "version=$FRONTEND_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "changed=false" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
|
||||||
|
create-release:
|
||||||
|
needs: check-version-change
|
||||||
|
if: needs.check-version-change.outputs.version_changed == 'true'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Generate Changelog
|
||||||
|
id: changelog
|
||||||
|
run: |
|
||||||
|
# Get commits since last tag
|
||||||
|
LAST_TAG=$(git describe --tags --abbrev=0 2>/dev/null || echo "")
|
||||||
|
if [[ -z "$LAST_TAG" ]]; then
|
||||||
|
COMMITS=$(git log --oneline)
|
||||||
|
else
|
||||||
|
COMMITS=$(git log ${LAST_TAG}..HEAD --oneline)
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Format changelog
|
||||||
|
echo "## What's Changed" > changelog.md
|
||||||
|
echo "" >> changelog.md
|
||||||
|
|
||||||
|
# Group commits by type
|
||||||
|
echo "### Features" >> changelog.md
|
||||||
|
echo "$COMMITS" | grep -E "^[a-f0-9]+ feat:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No new features*" >> changelog.md
|
||||||
|
|
||||||
|
echo "" >> changelog.md
|
||||||
|
echo "### Bug Fixes" >> changelog.md
|
||||||
|
echo "$COMMITS" | grep -E "^[a-f0-9]+ fix:" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No bug fixes*" >> changelog.md
|
||||||
|
|
||||||
|
echo "" >> changelog.md
|
||||||
|
echo "### Other Changes" >> changelog.md
|
||||||
|
echo "$COMMITS" | grep -vE "^[a-f0-9]+ (feat|fix):" | sed 's/^[a-f0-9]+ /- /' >> changelog.md || echo "*No other changes*" >> changelog.md
|
||||||
|
|
||||||
|
# Save changelog
|
||||||
|
echo "changelog<<EOF" >> $GITHUB_OUTPUT
|
||||||
|
cat changelog.md >> $GITHUB_OUTPUT
|
||||||
|
echo "EOF" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Create Release
|
||||||
|
uses: softprops/action-gh-release@v1
|
||||||
|
with:
|
||||||
|
tag_name: v${{ needs.check-version-change.outputs.new_version }}
|
||||||
|
name: Release v${{ needs.check-version-change.outputs.new_version }}
|
||||||
|
body: |
|
||||||
|
## PicPeak v${{ needs.check-version-change.outputs.new_version }}
|
||||||
|
|
||||||
|
${{ steps.changelog.outputs.changelog }}
|
||||||
|
|
||||||
|
### Docker Images
|
||||||
|
|
||||||
|
To use this release with Docker:
|
||||||
|
```bash
|
||||||
|
docker pull ghcr.io/${{ github.repository }}/frontend:v${{ needs.check-version-change.outputs.new_version }}
|
||||||
|
docker pull ghcr.io/${{ github.repository }}/backend:v${{ needs.check-version-change.outputs.new_version }}
|
||||||
|
```
|
||||||
|
|
||||||
|
Or use the `latest` tag for the most recent version.
|
||||||
|
draft: false
|
||||||
|
prerelease: false
|
||||||
|
generate_release_notes: true
|
||||||
@@ -0,0 +1,107 @@
|
|||||||
|
name: Automatic Version Bump
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
version_type:
|
||||||
|
description: 'Version bump type'
|
||||||
|
required: true
|
||||||
|
default: 'patch'
|
||||||
|
type: choice
|
||||||
|
options:
|
||||||
|
- patch
|
||||||
|
- minor
|
||||||
|
- major
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
version-bump:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Setup Node.js
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: '20'
|
||||||
|
|
||||||
|
- name: Configure Git
|
||||||
|
run: |
|
||||||
|
git config --global user.name "GitHub Actions Bot"
|
||||||
|
git config --global user.email "[email protected]"
|
||||||
|
|
||||||
|
- name: Determine version type
|
||||||
|
id: version_type
|
||||||
|
run: |
|
||||||
|
if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then
|
||||||
|
echo "type=${{ github.event.inputs.version_type }}" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
# Auto-detect version type based on commit message
|
||||||
|
COMMIT_MSG="${{ github.event.head_commit.message }}"
|
||||||
|
if [[ "$COMMIT_MSG" == *"BREAKING CHANGE"* ]] || [[ "$COMMIT_MSG" == *"!"* ]]; then
|
||||||
|
echo "type=major" >> $GITHUB_OUTPUT
|
||||||
|
elif [[ "$COMMIT_MSG" == *"feat:"* ]] || [[ "$COMMIT_MSG" == *"feat("* ]]; then
|
||||||
|
echo "type=minor" >> $GITHUB_OUTPUT
|
||||||
|
else
|
||||||
|
echo "type=patch" >> $GITHUB_OUTPUT
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Bump Frontend Version
|
||||||
|
id: frontend_version
|
||||||
|
working-directory: ./frontend
|
||||||
|
run: |
|
||||||
|
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
|
||||||
|
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Bump Backend Version
|
||||||
|
id: backend_version
|
||||||
|
working-directory: ./backend
|
||||||
|
run: |
|
||||||
|
npm version ${{ steps.version_type.outputs.type }} --no-git-tag-version
|
||||||
|
NEW_VERSION=$(node -p "require('./package.json').version")
|
||||||
|
echo "version=$NEW_VERSION" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Update Frontend VersionInfo component
|
||||||
|
run: |
|
||||||
|
VERSION=${{ steps.frontend_version.outputs.version }}
|
||||||
|
sed -i "s/const FRONTEND_VERSION = '[^']*'/const FRONTEND_VERSION = '$VERSION'/" frontend/src/components/admin/VersionInfo.tsx
|
||||||
|
|
||||||
|
- name: Create Pull Request
|
||||||
|
uses: peter-evans/create-pull-request@v5
|
||||||
|
with:
|
||||||
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
commit-message: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
|
||||||
|
title: "chore: bump version to ${{ steps.frontend_version.outputs.version }}"
|
||||||
|
body: |
|
||||||
|
## Version Bump
|
||||||
|
|
||||||
|
This PR automatically bumps the version numbers:
|
||||||
|
- Frontend: `${{ steps.frontend_version.outputs.version }}`
|
||||||
|
- Backend: `${{ steps.backend_version.outputs.version }}`
|
||||||
|
|
||||||
|
### Version Type: ${{ steps.version_type.outputs.type }}
|
||||||
|
|
||||||
|
### Files Changed:
|
||||||
|
- `frontend/package.json`
|
||||||
|
- `backend/package.json`
|
||||||
|
- `frontend/src/components/admin/VersionInfo.tsx`
|
||||||
|
|
||||||
|
---
|
||||||
|
*This PR was automatically created by the version bump workflow.*
|
||||||
|
branch: version-bump-${{ steps.frontend_version.outputs.version }}
|
||||||
|
delete-branch: true
|
||||||
|
labels: |
|
||||||
|
version-bump
|
||||||
|
automated
|
||||||
+1
-1
@@ -12,7 +12,7 @@ Get the photo sharing platform running locally in under 2 minutes!
|
|||||||
```bash
|
```bash
|
||||||
# 1. Clone the repository
|
# 1. Clone the repository
|
||||||
git clone <your-repo-url>
|
git clone <your-repo-url>
|
||||||
cd wedding-photo-sharing
|
cd picpeak
|
||||||
|
|
||||||
# 2. Start everything
|
# 2. Start everything
|
||||||
./start-local.sh
|
./start-local.sh
|
||||||
|
|||||||
+5
-5
@@ -1,9 +1,9 @@
|
|||||||
# Wedding Photo Sharing Platform - Complete Setup Guide
|
# PicPeak - Complete Setup Guide
|
||||||
|
|
||||||
## Repository Created Successfully! 🎉
|
## Repository Created Successfully! 🎉
|
||||||
|
|
||||||
Your wedding photo sharing platform repository has been created at:
|
Your PicPeak repository has been created at:
|
||||||
**https://gitea.nothaft.cloud/paul/wedding-photo-sharing**
|
**https://gitea.nothaft.cloud/paul/picpeak**
|
||||||
|
|
||||||
## What's Been Created
|
## What's Been Created
|
||||||
|
|
||||||
@@ -26,8 +26,8 @@ I've uploaded the core files needed to run the application:
|
|||||||
|
|
||||||
### 1. Clone the Repository
|
### 1. Clone the Repository
|
||||||
```bash
|
```bash
|
||||||
git clone https://gitea.local.nothaft.cloud/paul/wedding-photo-sharing.git
|
git clone https://gitea.local.nothaft.cloud/paul/picpeak.git
|
||||||
cd wedding-photo-sharing
|
cd picpeak
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Run the Setup Script
|
### 2. Run the Setup Script
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [{
|
apps: [{
|
||||||
name: 'photo-sharing',
|
name: 'picpeak',
|
||||||
script: './server.js',
|
script: './server.js',
|
||||||
instances: 'max',
|
instances: 'max',
|
||||||
exec_mode: 'cluster',
|
exec_mode: 'cluster',
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
exports.up = async function(knex) {
|
||||||
|
// Add default welcome message to app_settings
|
||||||
|
const existingSetting = await knex('app_settings')
|
||||||
|
.where('setting_key', 'general_default_welcome_message')
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!existingSetting) {
|
||||||
|
await knex('app_settings').insert({
|
||||||
|
setting_key: 'general_default_welcome_message',
|
||||||
|
setting_value: JSON.stringify('Thank you for using our photo sharing service! We hope you enjoy your photos.'),
|
||||||
|
setting_type: 'general',
|
||||||
|
updated_at: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update the gallery_created email template to ensure it has the welcome_message placeholder
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'gallery_created')
|
||||||
|
.update({
|
||||||
|
body_html_en: `<h2>Gallery Successfully Created</h2>
|
||||||
|
<p>Dear {{host_name}},</p>
|
||||||
|
<p>Your photo gallery "{{event_name}}" has been successfully created!</p>
|
||||||
|
{{#if welcome_message}}
|
||||||
|
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Personal Message:</p>
|
||||||
|
<p style="margin: 0; color: #4b5563;">{{welcome_message}}</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
<p><strong>Gallery Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Event Date: {{event_date}}</li>
|
||||||
|
<li>Gallery Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||||
|
<li>Password: {{gallery_password}}</li>
|
||||||
|
<li>Valid Until: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Share this link and password with your guests so they can view and download photos.</p>
|
||||||
|
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">View Gallery</a></p>`,
|
||||||
|
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||||
|
<p>Liebe(r) {{host_name}},</p>
|
||||||
|
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||||
|
{{#if welcome_message}}
|
||||||
|
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">Persönliche Nachricht:</p>
|
||||||
|
<p style="margin: 0; color: #4b5563;">{{welcome_message}}</p>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
<p><strong>Galerie-Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||||
|
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||||
|
<li>Passwort: {{gallery_password}}</li>
|
||||||
|
<li>Gültig bis: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.</p>
|
||||||
|
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">Galerie anzeigen</a></p>`,
|
||||||
|
body_text_en: `Gallery Successfully Created
|
||||||
|
|
||||||
|
Dear {{host_name}},
|
||||||
|
|
||||||
|
Your photo gallery "{{event_name}}" has been successfully created!
|
||||||
|
|
||||||
|
{{#if welcome_message}}
|
||||||
|
Personal Message:
|
||||||
|
{{welcome_message}}
|
||||||
|
|
||||||
|
{{/if}}
|
||||||
|
Gallery Details:
|
||||||
|
- Event Date: {{event_date}}
|
||||||
|
- Gallery Link: {{gallery_link}}
|
||||||
|
- Password: {{gallery_password}}
|
||||||
|
- Valid Until: {{expiry_date}}
|
||||||
|
|
||||||
|
Share this link and password with your guests so they can view and download photos.`,
|
||||||
|
body_text_de: `Galerie erfolgreich erstellt
|
||||||
|
|
||||||
|
Liebe(r) {{host_name}},
|
||||||
|
|
||||||
|
Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!
|
||||||
|
|
||||||
|
{{#if welcome_message}}
|
||||||
|
Persönliche Nachricht:
|
||||||
|
{{welcome_message}}
|
||||||
|
|
||||||
|
{{/if}}
|
||||||
|
Galerie-Details:
|
||||||
|
- Veranstaltungsdatum: {{event_date}}
|
||||||
|
- Galerie-Link: {{gallery_link}}
|
||||||
|
- Passwort: {{gallery_password}}
|
||||||
|
- Gültig bis: {{expiry_date}}
|
||||||
|
|
||||||
|
Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function(knex) {
|
||||||
|
// Remove the default welcome message setting
|
||||||
|
await knex('app_settings')
|
||||||
|
.where('setting_key', 'general_default_welcome_message')
|
||||||
|
.del();
|
||||||
|
|
||||||
|
// Revert email templates
|
||||||
|
await knex('email_templates')
|
||||||
|
.where('template_key', 'gallery_created')
|
||||||
|
.update({
|
||||||
|
body_html_en: `<h2>Gallery Successfully Created</h2>
|
||||||
|
<p>Dear {{host_name}},</p>
|
||||||
|
<p>Your photo gallery "{{event_name}}" has been successfully created!</p>
|
||||||
|
<p><strong>Gallery Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Event Date: {{event_date}}</li>
|
||||||
|
<li>Gallery Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||||
|
<li>Password: {{gallery_password}}</li>
|
||||||
|
<li>Valid Until: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Share this link and password with your guests so they can view and download photos.</p>
|
||||||
|
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">View Gallery</a></p>`,
|
||||||
|
body_html_de: `<h2>Galerie erfolgreich erstellt</h2>
|
||||||
|
<p>Liebe(r) {{host_name}},</p>
|
||||||
|
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!</p>
|
||||||
|
<p><strong>Galerie-Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||||
|
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||||
|
<li>Passwort: {{gallery_password}}</li>
|
||||||
|
<li>Gültig bis: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.</p>
|
||||||
|
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">Galerie anzeigen</a></p>`
|
||||||
|
});
|
||||||
|
};
|
||||||
Generated
+28
-89
@@ -26,7 +26,7 @@
|
|||||||
"joi": "^17.9.1",
|
"joi": "^17.9.1",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"knex": "^2.4.2",
|
"knex": "^2.4.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.1",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "^6.9.1",
|
"nodemailer": "^6.9.1",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"nodemon": "^2.0.22",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -2646,50 +2646,20 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream": {
|
"node_modules/concat-stream": {
|
||||||
"version": "1.6.2",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||||
"engines": [
|
"engines": [
|
||||||
"node >= 0.8"
|
"node >= 6.0"
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"buffer-from": "^1.0.0",
|
"buffer-from": "^1.0.0",
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"readable-stream": "^2.2.2",
|
"readable-stream": "^3.0.2",
|
||||||
"typedarray": "^0.0.6"
|
"typedarray": "^0.0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/concat-stream/node_modules/readable-stream": {
|
|
||||||
"version": "2.3.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
|
|
||||||
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"core-util-is": "~1.0.0",
|
|
||||||
"inherits": "~2.0.3",
|
|
||||||
"isarray": "~1.0.0",
|
|
||||||
"process-nextick-args": "~2.0.0",
|
|
||||||
"safe-buffer": "~5.1.1",
|
|
||||||
"string_decoder": "~1.1.1",
|
|
||||||
"util-deprecate": "~1.0.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/concat-stream/node_modules/safe-buffer": {
|
|
||||||
"version": "5.1.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
|
|
||||||
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
|
|
||||||
"license": "MIT"
|
|
||||||
},
|
|
||||||
"node_modules/concat-stream/node_modules/string_decoder": {
|
|
||||||
"version": "1.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
|
|
||||||
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/console-control-strings": {
|
"node_modules/console-control-strings": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||||
@@ -5974,22 +5944,21 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/multer": {
|
"node_modules/multer": {
|
||||||
"version": "1.4.5-lts.2",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz",
|
||||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
"integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==",
|
||||||
"deprecated": "Multer 1.x is impacted by a number of vulnerabilities, which have been patched in 2.x. You should upgrade to the latest 2.x version.",
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"append-field": "^1.0.0",
|
"append-field": "^1.0.0",
|
||||||
"busboy": "^1.0.0",
|
"busboy": "^1.6.0",
|
||||||
"concat-stream": "^1.5.2",
|
"concat-stream": "^2.0.0",
|
||||||
"mkdirp": "^0.5.4",
|
"mkdirp": "^0.5.6",
|
||||||
"object-assign": "^4.1.1",
|
"object-assign": "^4.1.1",
|
||||||
"type-is": "^1.6.4",
|
"type-is": "^1.6.18",
|
||||||
"xtend": "^4.0.0"
|
"xtend": "^4.0.2"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 6.0.0"
|
"node": ">= 10.16.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
@@ -6175,19 +6144,19 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon": {
|
"node_modules/nodemon": {
|
||||||
"version": "2.0.22",
|
"version": "3.1.10",
|
||||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||||
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"chokidar": "^3.5.2",
|
"chokidar": "^3.5.2",
|
||||||
"debug": "^3.2.7",
|
"debug": "^4",
|
||||||
"ignore-by-default": "^1.0.1",
|
"ignore-by-default": "^1.0.1",
|
||||||
"minimatch": "^3.1.2",
|
"minimatch": "^3.1.2",
|
||||||
"pstree.remy": "^1.1.8",
|
"pstree.remy": "^1.1.8",
|
||||||
"semver": "^5.7.1",
|
"semver": "^7.5.3",
|
||||||
"simple-update-notifier": "^1.0.7",
|
"simple-update-notifier": "^2.0.0",
|
||||||
"supports-color": "^5.5.0",
|
"supports-color": "^5.5.0",
|
||||||
"touch": "^3.1.0",
|
"touch": "^3.1.0",
|
||||||
"undefsafe": "^2.0.5"
|
"undefsafe": "^2.0.5"
|
||||||
@@ -6196,23 +6165,13 @@
|
|||||||
"nodemon": "bin/nodemon.js"
|
"nodemon": "bin/nodemon.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">=10"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "opencollective",
|
"type": "opencollective",
|
||||||
"url": "https://opencollective.com/nodemon"
|
"url": "https://opencollective.com/nodemon"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/debug": {
|
|
||||||
"version": "3.2.7",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz",
|
|
||||||
"integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nodemon/node_modules/has-flag": {
|
"node_modules/nodemon/node_modules/has-flag": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||||
@@ -6223,16 +6182,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/nodemon/node_modules/semver": {
|
|
||||||
"version": "5.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz",
|
|
||||||
"integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/nodemon/node_modules/supports-color": {
|
"node_modules/nodemon/node_modules/supports-color": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||||
@@ -7453,26 +7402,16 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/simple-update-notifier": {
|
"node_modules/simple-update-notifier": {
|
||||||
"version": "1.1.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||||
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"semver": "~7.0.0"
|
"semver": "^7.5.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8.10.0"
|
"node": ">=10"
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/simple-update-notifier/node_modules/semver": {
|
|
||||||
"version": "7.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.0.0.tgz",
|
|
||||||
"integrity": "sha512-+GB6zVA9LWh6zovYQLALHwv5rb2PHGlJi3lfiqIHxR0uuwCgefcOJc59v9fv1w8GbStwxuuqqAjI9NMAOOgq1A==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"bin": {
|
|
||||||
"semver": "bin/semver.js"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sisteransi": {
|
"node_modules/sisteransi": {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "photo-sharing-backend",
|
"name": "picpeak-backend",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "Backend for event photo sharing platform",
|
"description": "Backend for PicPeak event photo sharing platform",
|
||||||
"main": "server.js",
|
"main": "server.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node server.js",
|
"start": "node server.js",
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
"joi": "^17.9.1",
|
"joi": "^17.9.1",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"knex": "^2.4.2",
|
"knex": "^2.4.2",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^2.0.1",
|
||||||
"node-cron": "^3.0.2",
|
"node-cron": "^3.0.2",
|
||||||
"nodemailer": "^6.9.1",
|
"nodemailer": "^6.9.1",
|
||||||
"react-i18next": "^15.6.0",
|
"react-i18next": "^15.6.0",
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"eslint": "^8.40.0",
|
"eslint": "^8.40.0",
|
||||||
"jest": "^29.5.0",
|
"jest": "^29.5.0",
|
||||||
"nodemon": "^2.0.22",
|
"nodemon": "^3.1.10",
|
||||||
"supertest": "^6.3.3"
|
"supertest": "^6.3.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,12 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
.count('id as count')
|
.count('id as count')
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
|
// Get archived events count
|
||||||
|
const archivedEvents = await db('events')
|
||||||
|
.where('is_archived', true)
|
||||||
|
.count('id as count')
|
||||||
|
.first();
|
||||||
|
|
||||||
// Calculate trends (compare with previous 30 days)
|
// Calculate trends (compare with previous 30 days)
|
||||||
const previousViews = await db('access_logs')
|
const previousViews = await db('access_logs')
|
||||||
.where('action', 'view')
|
.where('action', 'view')
|
||||||
@@ -78,7 +84,8 @@ router.get('/stats', adminAuth, async (req, res) => {
|
|||||||
totalViews: totalViews.count || 0,
|
totalViews: totalViews.count || 0,
|
||||||
totalDownloads: totalDownloads.count || 0,
|
totalDownloads: totalDownloads.count || 0,
|
||||||
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
||||||
downloadsTrend: Math.round(downloadsTrend * 10) / 10
|
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
|
||||||
|
archivedEvents: archivedEvents.count || 0
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Dashboard stats error:', error);
|
console.error('Dashboard stats error:', error);
|
||||||
@@ -115,6 +122,75 @@ router.get('/activity', adminAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Get system health status
|
||||||
|
router.get('/health', adminAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const os = require('os');
|
||||||
|
|
||||||
|
// Check database connectivity
|
||||||
|
let dbStatus = 'healthy';
|
||||||
|
try {
|
||||||
|
await db.raw('SELECT 1');
|
||||||
|
} catch (error) {
|
||||||
|
dbStatus = 'error';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check email queue
|
||||||
|
const [pendingEmails] = await db('email_queue')
|
||||||
|
.where('status', 'pending')
|
||||||
|
.count('* as count');
|
||||||
|
|
||||||
|
const [failedEmails] = await db('email_queue')
|
||||||
|
.where('status', 'failed')
|
||||||
|
.whereRaw('created_at >= datetime("now", "-24 hours")')
|
||||||
|
.count('* as count');
|
||||||
|
|
||||||
|
const emailStatus = failedEmails.count > 10 ? 'warning' : 'healthy';
|
||||||
|
|
||||||
|
// Check disk space (simplified)
|
||||||
|
const storageStatus = 'healthy'; // In production, check actual disk usage
|
||||||
|
|
||||||
|
// Memory usage
|
||||||
|
const memoryUsage = {
|
||||||
|
total: os.totalmem(),
|
||||||
|
free: os.freemem(),
|
||||||
|
used: os.totalmem() - os.freemem(),
|
||||||
|
percentage: Math.round(((os.totalmem() - os.freemem()) / os.totalmem()) * 100)
|
||||||
|
};
|
||||||
|
|
||||||
|
const memoryStatus = memoryUsage.percentage > 90 ? 'warning' : 'healthy';
|
||||||
|
|
||||||
|
// Overall health
|
||||||
|
const statuses = [dbStatus, emailStatus, storageStatus, memoryStatus];
|
||||||
|
let overallHealth = 'healthy';
|
||||||
|
if (statuses.includes('error')) overallHealth = 'error';
|
||||||
|
else if (statuses.includes('warning')) overallHealth = 'warning';
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
overall: overallHealth,
|
||||||
|
services: {
|
||||||
|
database: dbStatus,
|
||||||
|
email: emailStatus,
|
||||||
|
storage: storageStatus,
|
||||||
|
memory: memoryStatus
|
||||||
|
},
|
||||||
|
details: {
|
||||||
|
emailQueue: {
|
||||||
|
pending: pendingEmails.count,
|
||||||
|
failed: failedEmails.count
|
||||||
|
},
|
||||||
|
memory: memoryUsage
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Health check error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
overall: 'error',
|
||||||
|
error: 'Failed to check system health'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get analytics data for charts
|
// Get analytics data for charts
|
||||||
router.get('/analytics', adminAuth, async (req, res) => {
|
router.get('/analytics', adminAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -65,9 +65,9 @@ router.post('/', adminAuth, [
|
|||||||
// Hash password
|
// Hash password
|
||||||
const password_hash = await bcrypt.hash(password, 10);
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
// Calculate expiration date
|
// Calculate expiration date (days after event date)
|
||||||
const expires_at = new Date();
|
const expires_at = new Date(event_date);
|
||||||
expires_at.setDate(expires_at.getDate() + expiration_days);
|
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||||
|
|
||||||
// Create folder structure
|
// Create folder structure
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
@@ -115,7 +115,8 @@ router.post('/', adminAuth, [
|
|||||||
event_date: await formatDate(event_date, emailLang),
|
event_date: await formatDate(event_date, emailLang),
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: password,
|
||||||
expiry_date: await formatDate(expires_at, emailLang)
|
expiry_date: await formatDate(expires_at, emailLang),
|
||||||
|
welcome_message: welcome_message || ''
|
||||||
})
|
})
|
||||||
// scheduled_at will use default value
|
// scheduled_at will use default value
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -53,9 +53,9 @@ router.post('/', adminAuth, [
|
|||||||
// Hash password
|
// Hash password
|
||||||
const password_hash = await bcrypt.hash(password, 10);
|
const password_hash = await bcrypt.hash(password, 10);
|
||||||
|
|
||||||
// Calculate expiration date
|
// Calculate expiration date (days after event date)
|
||||||
const expires_at = new Date();
|
const expires_at = new Date(event_date);
|
||||||
expires_at.setDate(expires_at.getDate() + expiration_days);
|
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||||
|
|
||||||
// Create folder structure
|
// Create folder structure
|
||||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
@@ -86,7 +86,8 @@ router.post('/', adminAuth, [
|
|||||||
event_date: new Date(event_date).toLocaleDateString(),
|
event_date: new Date(event_date).toLocaleDateString(),
|
||||||
gallery_link: shareLink,
|
gallery_link: shareLink,
|
||||||
gallery_password: password,
|
gallery_password: password,
|
||||||
expiry_date: expires_at.toLocaleDateString()
|
expiry_date: expires_at.toLocaleDateString(),
|
||||||
|
welcome_message: welcome_message || ''
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
|
|||||||
@@ -301,6 +301,44 @@ router.get('/:slug/download-all', verifyGalleryAccess, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// View single photo (with watermark if enabled)
|
||||||
|
router.get('/:slug/photo/:photoId', verifyGalleryAccess, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { photoId } = req.params;
|
||||||
|
|
||||||
|
const photo = await db('photos')
|
||||||
|
.where({ id: photoId, event_id: req.event.id })
|
||||||
|
.first();
|
||||||
|
|
||||||
|
if (!photo) {
|
||||||
|
return res.status(404).json({ error: 'Photo not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(getStoragePath(), 'events/active', photo.path);
|
||||||
|
|
||||||
|
// Get watermark settings
|
||||||
|
const watermarkSettings = await watermarkService.getWatermarkSettings();
|
||||||
|
|
||||||
|
if (watermarkSettings && watermarkSettings.enabled) {
|
||||||
|
// Apply watermark and send
|
||||||
|
const watermarkedBuffer = await watermarkService.applyWatermark(filePath, watermarkSettings);
|
||||||
|
|
||||||
|
res.set({
|
||||||
|
'Content-Type': photo.mime_type || 'image/jpeg',
|
||||||
|
'Cache-Control': 'public, max-age=3600' // Cache for 1 hour
|
||||||
|
});
|
||||||
|
|
||||||
|
res.send(watermarkedBuffer);
|
||||||
|
} else {
|
||||||
|
// Send original file
|
||||||
|
res.sendFile(filePath);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error serving photo:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to serve photo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Get photo stats
|
// Get photo stats
|
||||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -93,6 +93,17 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||||
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
const logoFullUrl = logoUrl ? `${apiUrl}${logoUrl}` : `${frontendUrl}/picpeak-logo-transparent.png`;
|
||||||
|
|
||||||
|
// Process welcome message section if present
|
||||||
|
let welcomeMessageSection = '';
|
||||||
|
if (variables.welcome_message && variables.welcome_message.trim() !== '') {
|
||||||
|
const welcomeTitle = language === 'de' ? 'Persönliche Nachricht:' : 'Personal Message:';
|
||||||
|
welcomeMessageSection = `
|
||||||
|
<div style="background-color: #f3f4f6; padding: 20px; border-radius: 8px; margin: 20px 0;">
|
||||||
|
<p style="margin: 0 0 10px 0; font-weight: 600; color: #374151;">${welcomeTitle}</p>
|
||||||
|
<p style="margin: 0; color: #4b5563;">${variables.welcome_message}</p>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Replace variables
|
// Replace variables
|
||||||
Object.entries(variables).forEach(([key, value]) => {
|
Object.entries(variables).forEach(([key, value]) => {
|
||||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||||
@@ -101,6 +112,9 @@ async function processTemplate(template, variables, language = 'en') {
|
|||||||
textBody = textBody.replace(regex, value || '');
|
textBody = textBody.replace(regex, value || '');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Replace welcome message section placeholder
|
||||||
|
htmlBody = htmlBody.replace(/{{welcome_message_section}}/g, welcomeMessageSection);
|
||||||
|
|
||||||
// Wrap HTML body in styled template
|
// Wrap HTML body in styled template
|
||||||
const styledHtmlBody = `
|
const styledHtmlBody = `
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const { db } = require('./src/database/db');
|
||||||
|
|
||||||
|
async function updateTemplate() {
|
||||||
|
try {
|
||||||
|
const englishBody = `<h2>Gallery Successfully Created</h2>
|
||||||
|
<p>Dear {{host_name}},</p>
|
||||||
|
<p>Your photo gallery "{{event_name}}" has been successfully created\!</p>
|
||||||
|
{{welcome_message_section}}
|
||||||
|
<p><strong>Gallery Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Event Date: {{event_date}}</li>
|
||||||
|
<li>Gallery Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||||
|
<li>Password: {{gallery_password}}</li>
|
||||||
|
<li>Valid Until: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Share this link and password with your guests so they can view and download photos.</p>
|
||||||
|
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">View Gallery</a></p>`;
|
||||||
|
|
||||||
|
const germanBody = `<h2>Galerie erfolgreich erstellt</h2>
|
||||||
|
<p>Liebe(r) {{host_name}},</p>
|
||||||
|
<p>Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt\!</p>
|
||||||
|
{{welcome_message_section}}
|
||||||
|
<p><strong>Galerie-Details:</strong></p>
|
||||||
|
<ul>
|
||||||
|
<li>Veranstaltungsdatum: {{event_date}}</li>
|
||||||
|
<li>Galerie-Link: <a href="{{gallery_link}}" style="color: #5C8762; text-decoration: none;">{{gallery_link}}</a></li>
|
||||||
|
<li>Passwort: {{gallery_password}}</li>
|
||||||
|
<li>Gültig bis: {{expiry_date}}</li>
|
||||||
|
</ul>
|
||||||
|
<p>Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.</p>
|
||||||
|
<p><a href="{{gallery_link}}" style="display: inline-block; padding: 10px 20px; background-color: #5C8762; color: white; text-decoration: none; border-radius: 5px;">Galerie anzeigen</a></p>`;
|
||||||
|
|
||||||
|
await db('email_templates')
|
||||||
|
.where('template_key', 'gallery_created')
|
||||||
|
.update({
|
||||||
|
body_html_en: englishBody,
|
||||||
|
body_html_de: germanBody
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Email template updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating template:', error);
|
||||||
|
} finally {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTemplate();
|
||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
# Complete setup script to create ALL remaining files
|
# Complete setup script to create ALL remaining files
|
||||||
|
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo "Wedding Photo Sharing Platform Setup"
|
echo "PicPeak Platform Setup"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
|
|||||||
@@ -8,11 +8,11 @@ YELLOW='\033[1;33m'
|
|||||||
BLUE='\033[0;34m'
|
BLUE='\033[0;34m'
|
||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
echo -e "${GREEN}Photo Sharing Platform Deployment Script${NC}"
|
echo -e "${GREEN}PicPeak Deployment Script${NC}"
|
||||||
echo "========================================"
|
echo "========================================"
|
||||||
|
|
||||||
# Default values
|
# Default values
|
||||||
STACK_NAME="photo-sharing"
|
STACK_NAME="picpeak"
|
||||||
ENV_FILE="../../.env.production"
|
ENV_FILE="../../.env.production"
|
||||||
REGISTRY_URL="${REGISTRY_URL:-}"
|
REGISTRY_URL="${REGISTRY_URL:-}"
|
||||||
VERSION="${VERSION:-latest}"
|
VERSION="${VERSION:-latest}"
|
||||||
@@ -42,7 +42,7 @@ while [[ $# -gt 0 ]]; do
|
|||||||
echo " --env FILE Path to environment file (default: ../../.env.production)"
|
echo " --env FILE Path to environment file (default: ../../.env.production)"
|
||||||
echo " --registry URL Docker registry URL"
|
echo " --registry URL Docker registry URL"
|
||||||
echo " --version VERSION Image version to deploy (default: latest)"
|
echo " --version VERSION Image version to deploy (default: latest)"
|
||||||
echo " --stack-name NAME Stack name (default: photo-sharing)"
|
echo " --stack-name NAME Stack name (default: picpeak)"
|
||||||
exit 0
|
exit 0
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ version: '3.8'
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
backend:
|
backend:
|
||||||
image: photo-sharing-backend:latest
|
image: picpeak-backend:latest
|
||||||
build:
|
build:
|
||||||
context: ./backend
|
context: ./backend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -27,10 +27,10 @@ services:
|
|||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
networks:
|
networks:
|
||||||
- photo-sharing
|
- picpeak
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
image: photo-sharing-frontend:latest
|
image: picpeak-frontend:latest
|
||||||
build:
|
build:
|
||||||
context: ./frontend
|
context: ./frontend
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
@@ -38,7 +38,7 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- photo-sharing
|
- picpeak
|
||||||
|
|
||||||
nginx:
|
nginx:
|
||||||
image: nginx:alpine
|
image: nginx:alpine
|
||||||
@@ -55,7 +55,7 @@ services:
|
|||||||
- frontend
|
- frontend
|
||||||
- backend
|
- backend
|
||||||
networks:
|
networks:
|
||||||
- photo-sharing
|
- picpeak
|
||||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||||
|
|
||||||
certbot:
|
certbot:
|
||||||
@@ -72,11 +72,11 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- POSTGRES_USER=${DB_USER:-photoapp}
|
- POSTGRES_USER=${DB_USER:-photoapp}
|
||||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||||
- POSTGRES_DB=${DB_NAME:-photo_sharing}
|
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data:/var/lib/postgresql/data
|
- postgres_data:/var/lib/postgresql/data
|
||||||
networks:
|
networks:
|
||||||
- photo-sharing
|
- picpeak
|
||||||
|
|
||||||
umami:
|
umami:
|
||||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||||
@@ -88,10 +88,10 @@ services:
|
|||||||
depends_on:
|
depends_on:
|
||||||
- db
|
- db
|
||||||
networks:
|
networks:
|
||||||
- photo-sharing
|
- picpeak
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
photo-sharing:
|
picpeak:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "photo-sharing-frontend",
|
"name": "picpeak-frontend",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
AdminLoginPage,
|
AdminLoginPage,
|
||||||
AdminDashboard,
|
AdminDashboard,
|
||||||
EventsListPage,
|
EventsListPage,
|
||||||
CreateEventPage,
|
CreateEventPageEnhanced as CreateEventPage,
|
||||||
EventDetailsPage,
|
EventDetailsPage,
|
||||||
EmailConfigPage,
|
EmailConfigPage,
|
||||||
ArchivesPage,
|
ArchivesPage,
|
||||||
|
|||||||
@@ -88,8 +88,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - PicPeak branding */}
|
{/* Center - Logo and PicPeak text */}
|
||||||
<div className="absolute left-1/2 transform -translate-x-1/2">
|
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
|
||||||
|
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
|
||||||
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,10 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
|||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div className="flex flex-col h-screen lg:h-full">
|
<div className="flex flex-col h-screen lg:h-full">
|
||||||
{/* Logo/Brand */}
|
{/* Brand */}
|
||||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<img src="/picpeak-kamera-transparent.png" alt="Camera" className="w-8 h-8 object-contain" />
|
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||||
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { Card } from '../common';
|
|
||||||
import { Camera } from 'lucide-react';
|
import { Camera } from 'lucide-react';
|
||||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||||
|
|
||||||
@@ -71,7 +70,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
|
|
||||||
switch (activeLayout) {
|
switch (activeLayout) {
|
||||||
case 'grid': {
|
case 'grid': {
|
||||||
const cols = theme.gallerySettings?.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
|
|
||||||
return (
|
return (
|
||||||
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
|
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
|
||||||
{mockPhotos.slice(0, 8).map((photo) => (
|
{mockPhotos.slice(0, 8).map((photo) => (
|
||||||
@@ -170,7 +168,9 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
|||||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h3 className="text-sm font-medium capitalize">{activeLayout} Layout</h3>
|
<h3 className="text-sm font-medium">
|
||||||
|
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
|
||||||
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Preview Content */}
|
{/* Preview Content */}
|
||||||
|
|||||||
@@ -57,17 +57,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
|||||||
// Create a new URL with auth header
|
// Create a new URL with auth header
|
||||||
const fetchImage = async () => {
|
const fetchImage = async () => {
|
||||||
try {
|
try {
|
||||||
// If watermark is requested and this is a gallery photo, use the protected images endpoint
|
// Use the src as-is since it should already be the correct endpoint
|
||||||
let imageUrl = src;
|
let imageUrl = src;
|
||||||
if (useWatermark && src.includes('/photos/')) {
|
|
||||||
// Extract gallery slug and photo ID from the URL
|
|
||||||
// URL format: /photos/events/active/{slug}/photos/{photoId}.jpg
|
|
||||||
const match = src.match(/\/photos\/events\/active\/([^\/]+)\/photos\/(\d+)\./);
|
|
||||||
if (match) {
|
|
||||||
const [, slug, photoId] = match;
|
|
||||||
imageUrl = `/api/images/${slug}/photo/${photoId}/view`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prepend API URL for absolute paths
|
// Prepend API URL for absolute paths
|
||||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
|
|||||||
onDownloadAll?: () => void;
|
onDownloadAll?: () => void;
|
||||||
isDownloading?: boolean;
|
isDownloading?: boolean;
|
||||||
headerExtra?: React.ReactNode;
|
headerExtra?: React.ReactNode;
|
||||||
|
menuButton?: React.ReactNode;
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +42,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
onDownloadAll,
|
onDownloadAll,
|
||||||
isDownloading = false,
|
isDownloading = false,
|
||||||
headerExtra,
|
headerExtra,
|
||||||
|
menuButton,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -65,6 +67,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left side - Menu button and other header extras */}
|
{/* Left side - Menu button and other header extras */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
|
{menuButton}
|
||||||
{headerExtra}
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -106,8 +109,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
||||||
<div className="container py-3">
|
<div className="container py-3">
|
||||||
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||||
{/* Left side - Logo and menu button on mobile */}
|
{/* Left side - Menu button, Logo */}
|
||||||
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
|
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
|
||||||
|
{/* Menu button */}
|
||||||
|
{menuButton && (
|
||||||
|
<div className="flex-shrink-0">
|
||||||
|
{menuButton}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex-shrink-0">
|
||||||
<img
|
<img
|
||||||
@@ -119,11 +129,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu button on mobile */}
|
|
||||||
<div className="flex-shrink-0 sm:hidden">
|
|
||||||
{headerExtra}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Center - Event info */}
|
{/* Center - Event info */}
|
||||||
@@ -154,10 +159,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
|
|
||||||
{/* Right side - Action buttons */}
|
{/* Right side - Action buttons */}
|
||||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||||
{/* Menu button on desktop */}
|
{/* Extra header items (upload button, etc.) */}
|
||||||
<div className="hidden sm:block">
|
{headerExtra && (
|
||||||
{headerExtra}
|
<div className="hidden sm:block">
|
||||||
</div>
|
{headerExtra}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||||
{showDownloadAll && onDownloadAll && (
|
{showDownloadAll && onDownloadAll && (
|
||||||
@@ -214,7 +221,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<div className="container py-3">
|
<div className="container py-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
{/* Left side - Menu button */}
|
{/* Left side - Menu button */}
|
||||||
<div className="flex-shrink-0">
|
<div className="flex items-center gap-3">
|
||||||
|
{menuButton}
|
||||||
{headerExtra}
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -273,21 +281,27 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
'/picpeak-logo-transparent.png'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto filter brightness-0 invert"
|
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto"
|
||||||
|
style={{
|
||||||
|
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Event Name */}
|
{/* Event Name */}
|
||||||
<h1
|
<h1
|
||||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
||||||
style={{ fontFamily: headingFontFamily }}
|
style={{
|
||||||
|
fontFamily: headingFontFamily,
|
||||||
|
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{event.event_name}
|
{event.event_name}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
{/* Event Details */}
|
{/* Event Details */}
|
||||||
{(event.event_date || event.expires_at) && (
|
{(event.event_date || event.expires_at) && (
|
||||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80">
|
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
|
||||||
{event.event_date && (
|
{event.event_date && (
|
||||||
<span className="flex items-center text-lg">
|
<span className="flex items-center text-lg">
|
||||||
<Calendar className="w-5 h-5 mr-2" />
|
<Calendar className="w-5 h-5 mr-2" />
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
|||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
import { Upload, Menu } from 'lucide-react';
|
import { Upload, Menu } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
slug: string;
|
slug: string;
|
||||||
@@ -48,9 +49,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
|
|
||||||
// Fetch photos
|
// Fetch photos
|
||||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||||
|
|
||||||
|
// Debug logging
|
||||||
|
useEffect(() => {
|
||||||
|
console.log('Event prop:', event);
|
||||||
|
console.log('Event prop hero_photo_id:', event?.hero_photo_id);
|
||||||
|
if (data) {
|
||||||
|
console.log('Gallery data:', data);
|
||||||
|
console.log('Event data from API:', data.event);
|
||||||
|
console.log('Hero photo ID from API:', data.event?.hero_photo_id);
|
||||||
|
}
|
||||||
|
}, [data, event]);
|
||||||
const downloadAllMutation = useDownloadAllPhotos();
|
const downloadAllMutation = useDownloadAllPhotos();
|
||||||
|
|
||||||
// Handle window resize
|
// Handle window resize
|
||||||
@@ -88,18 +101,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
|
|
||||||
// Apply theme when settings are loaded
|
// Apply theme when settings are loaded
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (settingsData && event) {
|
if (settingsData && data?.event) {
|
||||||
let themeToApply = null;
|
let themeToApply = null;
|
||||||
|
const fullEvent = data.event; // Use the full event data from API
|
||||||
|
|
||||||
if (event.color_theme) {
|
if (fullEvent.color_theme) {
|
||||||
try {
|
try {
|
||||||
// Check if it's a valid JSON string
|
// Check if it's a valid JSON string
|
||||||
if (event.color_theme.startsWith('{')) {
|
if (fullEvent.color_theme.startsWith('{')) {
|
||||||
const eventTheme = JSON.parse(event.color_theme);
|
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||||
themeToApply = eventTheme;
|
themeToApply = eventTheme;
|
||||||
} else {
|
} else {
|
||||||
// Handle legacy theme names - check if it's a preset
|
// Handle legacy theme names - check if it's a preset
|
||||||
const preset = GALLERY_THEME_PRESETS[event.color_theme];
|
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
||||||
if (preset) {
|
if (preset) {
|
||||||
themeToApply = preset.config;
|
themeToApply = preset.config;
|
||||||
} else {
|
} else {
|
||||||
@@ -126,10 +140,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
// Use setTimeout to ensure this runs after any global theme application
|
// Use setTimeout to ensure this runs after any global theme application
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
// If there's a hero photo, add it to gallery settings
|
// If there's a hero photo, add it to gallery settings
|
||||||
if (event.hero_photo_id && themeToApply.gallerySettings) {
|
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||||
themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
|
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||||
} else if (event.hero_photo_id) {
|
console.log('Setting hero photo ID in existing gallery settings:', fullEvent.hero_photo_id);
|
||||||
themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
|
} else if (fullEvent.hero_photo_id) {
|
||||||
|
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||||
|
console.log('Creating gallery settings with hero photo ID:', fullEvent.hero_photo_id);
|
||||||
}
|
}
|
||||||
setTheme(themeToApply);
|
setTheme(themeToApply);
|
||||||
}, 0);
|
}, 0);
|
||||||
@@ -137,7 +153,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
return () => clearTimeout(timer);
|
return () => clearTimeout(timer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [settingsData, event, setTheme]); // Include all dependencies
|
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||||
|
|
||||||
// Calculate days until expiration
|
// Calculate days until expiration
|
||||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||||
@@ -175,8 +191,17 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Transform URLs for watermarks if enabled
|
||||||
|
if (watermarkEnabled) {
|
||||||
|
photos = photos.map(photo => ({
|
||||||
|
...photo,
|
||||||
|
url: `/api/gallery/${slug}/photo/${photo.id}`,
|
||||||
|
thumbnail_url: `/api/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
return photos;
|
return photos;
|
||||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy]);
|
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]);
|
||||||
|
|
||||||
const handleDownloadAll = () => {
|
const handleDownloadAll = () => {
|
||||||
downloadAllMutation.mutate(slug);
|
downloadAllMutation.mutate(slug);
|
||||||
@@ -307,7 +332,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
totalPhotos={data?.photos.length || 0}
|
totalPhotos={data?.photos.length || 0}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
galleryLayout={theme.galleryLayout}
|
galleryLayout={theme.galleryLayout}
|
||||||
allowUploads={event.allow_user_uploads}
|
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
|
||||||
onUploadClick={() => setShowUploadModal(true)}
|
onUploadClick={() => setShowUploadModal(true)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
@@ -320,23 +345,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
showDownloadAll={!showSidebar}
|
showDownloadAll={!showSidebar}
|
||||||
onDownloadAll={handleDownloadAll}
|
onDownloadAll={handleDownloadAll}
|
||||||
isDownloading={downloadAllMutation.isPending}
|
isDownloading={downloadAllMutation.isPending}
|
||||||
|
menuButton={showSidebar ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
leftIcon={<Menu className="w-4 h-4" />}
|
||||||
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
|
aria-label={t('gallery.toggleMenu')}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">{t('common.menu')}</span>
|
||||||
|
</Button>
|
||||||
|
) : undefined}
|
||||||
headerExtra={(() => {
|
headerExtra={(() => {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
|
||||||
if (showSidebar) {
|
console.log('Header extra - data loaded:', !!data);
|
||||||
items.push(
|
console.log('Header extra - allow uploads:', data?.event?.allow_user_uploads);
|
||||||
<Button
|
console.log('Header extra - showSidebar:', showSidebar);
|
||||||
key="menu-button"
|
console.log('Header extra - isMobile:', isMobile);
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Menu className="w-4 h-4" />}
|
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
|
||||||
aria-label={t('gallery.toggleMenu')}
|
|
||||||
>
|
|
||||||
<span className="hidden sm:inline">{t('common.menu')}</span>
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||||
items.push(
|
items.push(
|
||||||
@@ -345,7 +371,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload button only on desktop when sidebar is shown
|
// Upload button only on desktop when sidebar is shown
|
||||||
if (event.allow_user_uploads && showSidebar && !isMobile) {
|
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
|
||||||
|
if (allowUploads && showSidebar && !isMobile) {
|
||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key="upload-button"
|
key="upload-button"
|
||||||
@@ -360,7 +387,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Upload button for non-sidebar layouts
|
// Upload button for non-sidebar layouts
|
||||||
if (event.allow_user_uploads && !showSidebar) {
|
if (allowUploads && !showSidebar) {
|
||||||
items.push(
|
items.push(
|
||||||
<Button
|
<Button
|
||||||
key="upload-button"
|
key="upload-button"
|
||||||
@@ -376,7 +403,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <>{items}</>;
|
return items.length > 0 ? <>{items}</> : null;
|
||||||
})()}
|
})()}
|
||||||
>
|
>
|
||||||
{/* Expiration Banner */}
|
{/* Expiration Banner */}
|
||||||
@@ -420,10 +447,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Upload Modal */}
|
{/* Upload Modal */}
|
||||||
{showUploadModal && (
|
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
|
||||||
<UserPhotoUpload
|
<UserPhotoUpload
|
||||||
eventId={event.id}
|
eventId={data?.event?.id || event?.id}
|
||||||
categoryId={event.upload_category_id}
|
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
|
||||||
onUploadComplete={() => {
|
onUploadComplete={() => {
|
||||||
setShowUploadModal(false);
|
setShowUploadModal(false);
|
||||||
// Refetch photos after upload
|
// Refetch photos after upload
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
|||||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { Button, Card, CardContent } from '../common';
|
import { Button } from '../common';
|
||||||
import { api } from '../../config/api';
|
import { api } from '../../config/api';
|
||||||
|
|
||||||
interface UserPhotoUploadProps {
|
interface UserPhotoUploadProps {
|
||||||
@@ -114,7 +114,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
||||||
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-2xl shadow-xl">
|
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-2xl shadow-xl overflow-hidden">
|
||||||
{/* Fixed Header */}
|
{/* Fixed Header */}
|
||||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 flex-shrink-0">
|
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 flex-shrink-0">
|
||||||
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900">{t('upload.uploadPhotos')}</h2>
|
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900">{t('upload.uploadPhotos')}</h2>
|
||||||
|
|||||||
@@ -31,24 +31,49 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
const { format } = useLocalizedDate();
|
const { format } = useLocalizedDate();
|
||||||
const { theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||||
|
const [hasInitialized, setHasInitialized] = useState(false);
|
||||||
const gallerySettings = theme.gallerySettings || {};
|
const gallerySettings = theme.gallerySettings || {};
|
||||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||||
|
|
||||||
// Select hero photo (first photo or specified one)
|
// Reset initialization when heroImageId changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (gallerySettings.heroImageId) {
|
||||||
|
setHasInitialized(false);
|
||||||
|
}
|
||||||
|
}, [gallerySettings.heroImageId]);
|
||||||
|
|
||||||
|
// Select hero photo (admin-selected or first photo only if gallery was empty)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (photos.length > 0) {
|
if (photos.length > 0) {
|
||||||
const heroId = gallerySettings.heroImageId;
|
const heroId = gallerySettings.heroImageId;
|
||||||
const hero = heroId ? photos.find(p => p.id === heroId) : photos[0];
|
console.log('HeroGalleryLayout - heroImageId:', heroId, 'photos:', photos.length);
|
||||||
setHeroPhoto(hero || photos[0]);
|
|
||||||
|
// If admin has selected a specific hero image, always use it
|
||||||
|
if (heroId) {
|
||||||
|
const adminSelectedHero = photos.find(p => p.id === heroId);
|
||||||
|
console.log('Looking for hero photo with ID:', heroId, 'Found:', adminSelectedHero?.filename);
|
||||||
|
if (adminSelectedHero) {
|
||||||
|
setHeroPhoto(adminSelectedHero);
|
||||||
|
setHasInitialized(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only auto-select first photo on initial load when gallery was empty
|
||||||
|
// This prevents changing the hero when new photos are uploaded
|
||||||
|
if (!hasInitialized) {
|
||||||
|
setHeroPhoto(photos[0]);
|
||||||
|
setHasInitialized(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [photos, gallerySettings.heroImageId]);
|
}, [photos, gallerySettings.heroImageId, hasInitialized]);
|
||||||
|
|
||||||
if (!heroPhoto) return null;
|
if (!heroPhoto) return null;
|
||||||
|
|
||||||
const remainingPhotos = photos.filter(p => p.id !== heroPhoto.id);
|
const remainingPhotos = photos.filter(p => p.id !== heroPhoto.id);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="relative -mt-6">
|
||||||
{/* Hero Section */}
|
{/* Hero Section */}
|
||||||
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
@@ -67,17 +92,20 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
{/* Hero Content */}
|
{/* Hero Content */}
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<div className="text-center px-4">
|
<div className="text-center px-4">
|
||||||
{/* Logo */}
|
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||||
{eventLogo && (
|
<div className="mb-6">
|
||||||
<div className="mb-6">
|
<img
|
||||||
<img
|
src={eventLogo ?
|
||||||
src={eventLogo}
|
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${eventLogo}` :
|
||||||
alt="Event logo"
|
'/picpeak-logo-transparent.png'
|
||||||
className="h-20 sm:h-24 lg:h-32 mx-auto drop-shadow-lg filter brightness-0 invert"
|
}
|
||||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))' }}
|
alt="Event logo"
|
||||||
/>
|
className="h-20 sm:h-24 lg:h-32 mx-auto"
|
||||||
</div>
|
style={{
|
||||||
)}
|
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Event Title */}
|
{/* Event Title */}
|
||||||
{eventName && (
|
{eventName && (
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { settingsService } from '../services/settings.service';
|
||||||
|
|
||||||
|
export function useWatermarkSettings() {
|
||||||
|
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchSettings = async () => {
|
||||||
|
try {
|
||||||
|
const settings = await settingsService.getSettingsByType('branding');
|
||||||
|
const brandingSettings = settingsService.formatBrandingSettings(settings);
|
||||||
|
setWatermarkEnabled(brandingSettings.watermark_enabled);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch watermark settings:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return { watermarkEnabled, loading };
|
||||||
|
}
|
||||||
@@ -227,6 +227,8 @@
|
|||||||
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
|
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
|
||||||
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
||||||
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||||
|
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||||
|
"extendSevenDays": "Um 7 Tage verlängern",
|
||||||
"overview": "Übersicht",
|
"overview": "Übersicht",
|
||||||
"photos": "Fotos",
|
"photos": "Fotos",
|
||||||
"categories": "Kategorien",
|
"categories": "Kategorien",
|
||||||
@@ -266,7 +268,7 @@
|
|||||||
"colorTheme": "Farbthema",
|
"colorTheme": "Farbthema",
|
||||||
"galleryExpiration": "Galerie-Ablauf",
|
"galleryExpiration": "Galerie-Ablauf",
|
||||||
"galleryExpiresIn": "Galerie läuft ab in",
|
"galleryExpiresIn": "Galerie läuft ab in",
|
||||||
"daysAfterEvent": "Tage nach der Veranstaltung",
|
"daysAfterEvent": "Tage nach dem Veranstaltungsdatum",
|
||||||
"expiresOn": "Läuft ab am",
|
"expiresOn": "Läuft ab am",
|
||||||
"themeAndStyle": "Design & Stil",
|
"themeAndStyle": "Design & Stil",
|
||||||
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
||||||
@@ -279,6 +281,12 @@
|
|||||||
"selectCategory": "Wählen Sie eine Kategorie für Benutzer-Uploads",
|
"selectCategory": "Wählen Sie eine Kategorie für Benutzer-Uploads",
|
||||||
"uploadCategoryHelp": "Alle von Benutzern hochgeladenen Fotos werden dieser Kategorie hinzugefügt",
|
"uploadCategoryHelp": "Alle von Benutzern hochgeladenen Fotos werden dieser Kategorie hinzugefügt",
|
||||||
"userUploadWarning": "Benutzer-Uploads werden moderiert und können jederzeit von Administratoren entfernt werden.",
|
"userUploadWarning": "Benutzer-Uploads werden moderiert und können jederzeit von Administratoren entfernt werden.",
|
||||||
|
"heroPhoto": "Hero-Foto",
|
||||||
|
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
|
||||||
|
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||||
|
"noHeroPhotoSelected": "Kein Hero-Foto ausgewählt",
|
||||||
|
"heroPhotoSelected": "Hero-Foto ausgewählt",
|
||||||
|
"noPhotosAvailable": "Keine Fotos verfügbar",
|
||||||
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
||||||
"eventTypeWedding": "Hochzeit",
|
"eventTypeWedding": "Hochzeit",
|
||||||
"eventTypeBirthday": "Geburtstag",
|
"eventTypeBirthday": "Geburtstag",
|
||||||
@@ -324,6 +332,8 @@
|
|||||||
"loadingEvents": "Veranstaltungen werden geladen...",
|
"loadingEvents": "Veranstaltungen werden geladen...",
|
||||||
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
|
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
|
||||||
"tryAgain": "Erneut versuchen",
|
"tryAgain": "Erneut versuchen",
|
||||||
|
"eventExpiredMessage": "Diese Veranstaltung ist abgelaufen",
|
||||||
|
"guestsCannotAccessGallery": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
||||||
"bulkArchiveSuccess": "{{count}} Veranstaltungen erfolgreich archiviert",
|
"bulkArchiveSuccess": "{{count}} Veranstaltungen erfolgreich archiviert",
|
||||||
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
|
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
|
||||||
"searchEventsPlaceholder": "Veranstaltungen suchen...",
|
"searchEventsPlaceholder": "Veranstaltungen suchen...",
|
||||||
@@ -458,6 +468,7 @@
|
|||||||
"titleFull": "Branding & Anpassung",
|
"titleFull": "Branding & Anpassung",
|
||||||
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
|
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
|
||||||
"loadingBranding": "Branding-Einstellungen werden geladen...",
|
"loadingBranding": "Branding-Einstellungen werden geladen...",
|
||||||
|
"themeAndStyle": "Theme & Stil",
|
||||||
"companyInfo": "Unternehmensinformationen",
|
"companyInfo": "Unternehmensinformationen",
|
||||||
"companyName": "Unternehmensname",
|
"companyName": "Unternehmensname",
|
||||||
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
|
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
|
||||||
@@ -598,6 +609,14 @@
|
|||||||
"totalPhotos": "Gesamte Fotos",
|
"totalPhotos": "Gesamte Fotos",
|
||||||
"storagePercent": "{{percent}}% von {{limit}}",
|
"storagePercent": "{{percent}}% von {{limit}}",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
|
"archivedEvents": "Archivierte Veranstaltungen",
|
||||||
|
"systemHealth": "Systemstatus",
|
||||||
|
"health": {
|
||||||
|
"healthy": "Gesund",
|
||||||
|
"warning": "Warnung",
|
||||||
|
"error": "Fehler",
|
||||||
|
"checking": "Prüfe..."
|
||||||
|
},
|
||||||
"notifications": "Benachrichtigungen",
|
"notifications": "Benachrichtigungen",
|
||||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||||
@@ -664,7 +683,29 @@
|
|||||||
"viewAllActivity": "Alle Aktivitäten anzeigen",
|
"viewAllActivity": "Alle Aktivitäten anzeigen",
|
||||||
"quickActions": "Schnellaktionen",
|
"quickActions": "Schnellaktionen",
|
||||||
"viewArchives": "Archive anzeigen",
|
"viewArchives": "Archive anzeigen",
|
||||||
"analytics": "Analytik"
|
"analytics": "Analytik",
|
||||||
|
"activities": {
|
||||||
|
"event_created": "Neue Veranstaltung erstellt: {{eventName}}",
|
||||||
|
"photos_uploaded": "{{count}} Fotos hochgeladen zu {{eventName}}",
|
||||||
|
"event_archived": "Veranstaltung archiviert: {{eventName}}",
|
||||||
|
"archive_restored": "Archiv wiederhergestellt: {{eventName}}",
|
||||||
|
"archive_deleted": "Archiv gelöscht: {{eventName}}",
|
||||||
|
"archive_downloaded": "Archiv heruntergeladen: {{eventName}}",
|
||||||
|
"email_config_updated": "E-Mail-Konfiguration aktualisiert",
|
||||||
|
"email_template_updated": "E-Mail-Vorlage aktualisiert: {{template}}",
|
||||||
|
"branding_updated": "Branding-Einstellungen aktualisiert",
|
||||||
|
"theme_updated": "Theme-Einstellungen aktualisiert",
|
||||||
|
"bulk_download": "{{count}} Fotos heruntergeladen von {{eventName}}",
|
||||||
|
"gallery_password_entry": "Passwort eingegeben für {{eventName}}",
|
||||||
|
"expiration_warning_viewed": "Ablaufwarnung angesehen für {{eventName}}",
|
||||||
|
"settings_updated": "Einstellungen aktualisiert",
|
||||||
|
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
||||||
|
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
||||||
|
"category_created": "Kategorie erstellt: {{categoryName}}",
|
||||||
|
"category_updated": "Kategorie aktualisiert: {{categoryName}}",
|
||||||
|
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||||
|
"unknown": "Unbekannte Aktivität"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"notFound": "Nicht gefunden",
|
"notFound": "Nicht gefunden",
|
||||||
|
|||||||
@@ -185,6 +185,7 @@
|
|||||||
},
|
},
|
||||||
"events": {
|
"events": {
|
||||||
"title": "Events",
|
"title": "Events",
|
||||||
|
"create": "Create",
|
||||||
"createEvent": "Create Event",
|
"createEvent": "Create Event",
|
||||||
"totalViews": "Total Views",
|
"totalViews": "Total Views",
|
||||||
"totalDownloads": "Total Downloads",
|
"totalDownloads": "Total Downloads",
|
||||||
@@ -198,7 +199,9 @@
|
|||||||
"hostEmailPlaceholder": "[email protected]",
|
"hostEmailPlaceholder": "[email protected]",
|
||||||
"adminEmailPlaceholder": "[email protected]",
|
"adminEmailPlaceholder": "[email protected]",
|
||||||
"securityAndAccess": "Security & Access",
|
"securityAndAccess": "Security & Access",
|
||||||
|
"accessAndSecurity": "Access & Security",
|
||||||
"enterPassword": "Enter password",
|
"enterPassword": "Enter password",
|
||||||
|
"passwordPlaceholder": "Enter a secure password",
|
||||||
"confirmPasswordPlaceholder": "Confirm password",
|
"confirmPasswordPlaceholder": "Confirm password",
|
||||||
"galleryExpiresOn": "Gallery will expire on {{date}}",
|
"galleryExpiresOn": "Gallery will expire on {{date}}",
|
||||||
"guestsWillReceiveWarning": "Guests will receive a warning email 7 days before expiration.",
|
"guestsWillReceiveWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||||
@@ -279,13 +282,18 @@
|
|||||||
"confirmPassword": "Confirm Password",
|
"confirmPassword": "Confirm Password",
|
||||||
"showPasswords": "Show passwords",
|
"showPasswords": "Show passwords",
|
||||||
"gallerySettings": "Gallery Settings",
|
"gallerySettings": "Gallery Settings",
|
||||||
|
"themeAndStyle": "Theme & Style",
|
||||||
"colorTheme": "Color Theme",
|
"colorTheme": "Color Theme",
|
||||||
|
"galleryExpiration": "Gallery Expiration",
|
||||||
"galleryExpiresIn": "Gallery Expires In",
|
"galleryExpiresIn": "Gallery Expires In",
|
||||||
|
"daysAfterEvent": "days after event date",
|
||||||
|
"expiresOn": "Expires on",
|
||||||
"galleryWillExpireOn": "Gallery will expire on {{date}}",
|
"galleryWillExpireOn": "Gallery will expire on {{date}}",
|
||||||
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
|
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||||
"userUploads": "User Upload Settings",
|
"userUploads": "User Upload Settings",
|
||||||
"allowUserUploads": "Allow guests to upload photos",
|
"allowUserUploads": "Allow guests to upload photos",
|
||||||
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
|
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
|
||||||
|
"allowUserUploadsDescription": "Enable guests to upload their own photos to this gallery",
|
||||||
"uploadCategory": "Upload Category",
|
"uploadCategory": "Upload Category",
|
||||||
"selectCategory": "Select a category for user uploads",
|
"selectCategory": "Select a category for user uploads",
|
||||||
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
|
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
|
||||||
@@ -513,6 +521,7 @@
|
|||||||
"titleFull": "Branding & Customization",
|
"titleFull": "Branding & Customization",
|
||||||
"subtitle": "Customize the look and feel of your galleries",
|
"subtitle": "Customize the look and feel of your galleries",
|
||||||
"loadingBranding": "Loading branding settings...",
|
"loadingBranding": "Loading branding settings...",
|
||||||
|
"themeAndStyle": "Theme & Style",
|
||||||
"companyInfo": "Company Information",
|
"companyInfo": "Company Information",
|
||||||
"companyName": "Company Name",
|
"companyName": "Company Name",
|
||||||
"companyNameHelp": "Displayed in gallery headers and emails",
|
"companyNameHelp": "Displayed in gallery headers and emails",
|
||||||
@@ -653,6 +662,14 @@
|
|||||||
"totalPhotos": "Total Photos",
|
"totalPhotos": "Total Photos",
|
||||||
"storagePercent": "{{percent}}% of {{limit}}",
|
"storagePercent": "{{percent}}% of {{limit}}",
|
||||||
"version": "Version",
|
"version": "Version",
|
||||||
|
"archivedEvents": "Archived Events",
|
||||||
|
"systemHealth": "System Health",
|
||||||
|
"health": {
|
||||||
|
"healthy": "Healthy",
|
||||||
|
"warning": "Warning",
|
||||||
|
"error": "Error",
|
||||||
|
"checking": "Checking..."
|
||||||
|
},
|
||||||
"notifications": "Notifications",
|
"notifications": "Notifications",
|
||||||
"viewAllNotifications": "View all notifications",
|
"viewAllNotifications": "View all notifications",
|
||||||
"noNotifications": "No new notifications",
|
"noNotifications": "No new notifications",
|
||||||
@@ -717,7 +734,29 @@
|
|||||||
"viewAllActivity": "View all activity",
|
"viewAllActivity": "View all activity",
|
||||||
"quickActions": "Quick Actions",
|
"quickActions": "Quick Actions",
|
||||||
"viewArchives": "View Archives",
|
"viewArchives": "View Archives",
|
||||||
"analytics": "Analytics"
|
"analytics": "Analytics",
|
||||||
|
"activities": {
|
||||||
|
"event_created": "New event created: {{eventName}}",
|
||||||
|
"photos_uploaded": "{{count}} photos uploaded to {{eventName}}",
|
||||||
|
"event_archived": "Event archived: {{eventName}}",
|
||||||
|
"archive_restored": "Archive restored: {{eventName}}",
|
||||||
|
"archive_deleted": "Archive deleted: {{eventName}}",
|
||||||
|
"archive_downloaded": "Archive downloaded: {{eventName}}",
|
||||||
|
"email_config_updated": "Email configuration updated",
|
||||||
|
"email_template_updated": "Email template updated: {{template}}",
|
||||||
|
"branding_updated": "Branding settings updated",
|
||||||
|
"theme_updated": "Theme settings updated",
|
||||||
|
"bulk_download": "{{count}} photos downloaded from {{eventName}}",
|
||||||
|
"gallery_password_entry": "Password entered for {{eventName}}",
|
||||||
|
"expiration_warning_viewed": "Expiration warning viewed for {{eventName}}",
|
||||||
|
"settings_updated": "Settings updated",
|
||||||
|
"event_updated": "Event updated: {{eventName}}",
|
||||||
|
"event_deleted": "Event deleted: {{eventName}}",
|
||||||
|
"category_created": "Category created: {{categoryName}}",
|
||||||
|
"category_updated": "Category updated: {{categoryName}}",
|
||||||
|
"category_deleted": "Category deleted: {{categoryName}}",
|
||||||
|
"unknown": "Unknown activity"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"notFound": "Not Found",
|
"notFound": "Not Found",
|
||||||
|
|||||||
+75
-18
@@ -186,38 +186,95 @@
|
|||||||
scroll-behavior: smooth;
|
scroll-behavior: smooth;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom range slider styles */
|
/* Custom range slider styles - Updated */
|
||||||
.slider {
|
input[type="range"].slider {
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
|
-moz-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
background: transparent;
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #d4d4d4 !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
outline: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-webkit-slider-track {
|
/* Webkit browsers like Chrome/Safari */
|
||||||
@apply bg-neutral-200 h-2 rounded-lg;
|
input[type="range"].slider::-webkit-slider-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #d4d4d4 !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-moz-range-track {
|
input[type="range"].slider::-webkit-slider-thumb {
|
||||||
@apply bg-neutral-200 h-2 rounded-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.slider::-webkit-slider-thumb {
|
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all;
|
width: 20px;
|
||||||
margin-top: -6px;
|
height: 20px;
|
||||||
|
background: #5C8762;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: -6px; /* Center the thumb vertically */
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider::-moz-range-thumb {
|
input[type="range"].slider::-webkit-slider-thumb:hover {
|
||||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all border-0;
|
background: #4a6f4f;
|
||||||
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider:hover::-webkit-slider-thumb {
|
/* Firefox */
|
||||||
@apply bg-primary-700 scale-110;
|
input[type="range"].slider::-moz-range-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: #d4d4d4 !important;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider:hover::-moz-range-thumb {
|
input[type="range"].slider::-moz-range-thumb {
|
||||||
@apply bg-primary-700 scale-110;
|
border: none;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #5C8762;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"].slider::-moz-range-thumb:hover {
|
||||||
|
background: #4a6f4f;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* IE/Edge */
|
||||||
|
input[type="range"].slider::-ms-track {
|
||||||
|
width: 100%;
|
||||||
|
height: 8px;
|
||||||
|
background: transparent;
|
||||||
|
border-color: transparent;
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"].slider::-ms-fill-lower {
|
||||||
|
background: #d4d4d4;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"].slider::-ms-fill-upper {
|
||||||
|
background: #d4d4d4;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="range"].slider::-ms-thumb {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
background: #5C8762;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-top: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import {
|
|||||||
Clock,
|
Clock,
|
||||||
Plus,
|
Plus,
|
||||||
HardDrive,
|
HardDrive,
|
||||||
Image
|
Image,
|
||||||
|
Archive,
|
||||||
|
Heart
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { differenceInDays, parseISO } from 'date-fns';
|
import { differenceInDays, parseISO } from 'date-fns';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -44,6 +46,13 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
queryFn: () => adminService.getRecentActivity(10),
|
queryFn: () => adminService.getRecentActivity(10),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch system health
|
||||||
|
const { data: systemHealth } = useQuery({
|
||||||
|
queryKey: ['admin-system-health'],
|
||||||
|
queryFn: () => adminService.getSystemHealth(),
|
||||||
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch events data for expiring events
|
// Fetch events data for expiring events
|
||||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||||
queryKey: ['admin-events-summary'],
|
queryKey: ['admin-events-summary'],
|
||||||
@@ -74,7 +83,7 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
return num.toString();
|
return num.toString();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Build statistics cards
|
// Build statistics cards - always show 8 cards in 2x4 grid
|
||||||
const stats: StatCard[] = [
|
const stats: StatCard[] = [
|
||||||
{
|
{
|
||||||
title: t('admin.activeEvents'),
|
title: t('admin.activeEvents'),
|
||||||
@@ -101,28 +110,34 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
icon: HardDrive,
|
icon: HardDrive,
|
||||||
color: 'text-purple-600',
|
color: 'text-purple-600',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.totalViews'),
|
||||||
|
value: formatNumber(dashboardStats?.totalViews || 0),
|
||||||
|
change: dashboardStats?.viewsTrend ? t('admin.percentFromLastWeek', { percent: `${dashboardStats.viewsTrend > 0 ? '+' : ''}${dashboardStats.viewsTrend}` }) : undefined,
|
||||||
|
icon: Eye,
|
||||||
|
color: 'text-indigo-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.downloads'),
|
||||||
|
value: formatNumber(dashboardStats?.totalDownloads || 0),
|
||||||
|
change: dashboardStats?.downloadsTrend ? t('admin.percentFromLastWeek', { percent: `${dashboardStats.downloadsTrend > 0 ? '+' : ''}${dashboardStats.downloadsTrend}` }) : undefined,
|
||||||
|
icon: Download,
|
||||||
|
color: 'text-pink-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.archivedEvents'),
|
||||||
|
value: dashboardStats?.archivedEvents || 0,
|
||||||
|
icon: Archive,
|
||||||
|
color: 'text-gray-600',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('admin.systemHealth'),
|
||||||
|
value: systemHealth ? t(`admin.health.${systemHealth.overall}`) : t('admin.health.checking'),
|
||||||
|
icon: Heart,
|
||||||
|
color: systemHealth?.overall === 'healthy' ? 'text-green-600' : systemHealth?.overall === 'warning' ? 'text-yellow-600' : 'text-red-600',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
// Add second row of stats if we have trend data
|
|
||||||
if (dashboardStats?.totalViews !== undefined) {
|
|
||||||
stats.push(
|
|
||||||
{
|
|
||||||
title: t('admin.totalViews'),
|
|
||||||
value: formatNumber(dashboardStats.totalViews),
|
|
||||||
change: dashboardStats.viewsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.viewsTrend}` }) : undefined,
|
|
||||||
icon: Eye,
|
|
||||||
color: 'text-indigo-600',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: t('admin.downloads'),
|
|
||||||
value: formatNumber(dashboardStats.totalDownloads),
|
|
||||||
change: dashboardStats.downloadsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.downloadsTrend}` }) : undefined,
|
|
||||||
icon: Download,
|
|
||||||
color: 'text-pink-600',
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{/* Page Header */}
|
{/* Page Header */}
|
||||||
@@ -243,12 +258,31 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
return colors[type] || 'bg-gray-500';
|
return colors[type] || 'bg-gray-500';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Format activity message with translations
|
||||||
|
const getActivityMessage = (): string => {
|
||||||
|
const translationKey = `admin.activities.${activity.type}`;
|
||||||
|
const params: Record<string, any> = {
|
||||||
|
eventName: activity.eventName || t('common.unknown'),
|
||||||
|
count: activity.metadata?.count || 0,
|
||||||
|
template: activity.metadata?.template_key || '',
|
||||||
|
categoryName: activity.metadata?.category_name || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// Check if translation exists
|
||||||
|
const translated = t(translationKey, params);
|
||||||
|
if (typeof translated === 'string') {
|
||||||
|
return translated;
|
||||||
|
}
|
||||||
|
// Fallback to unknown activity if translation not found
|
||||||
|
return t('admin.activities.unknown') as string;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div key={activity.id} className="flex items-start gap-3">
|
<div key={activity.id} className="flex items-start gap-3">
|
||||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${getActivityColor(activity.type)}`} />
|
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${getActivityColor(activity.type)}`} />
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm text-neutral-900 break-words">
|
<p className="text-sm text-neutral-900 break-words">
|
||||||
{adminService.formatActivityMessage(activity)}
|
{getActivityMessage()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-neutral-500">{activity.actorName}</p>
|
<p className="text-xs text-neutral-500">{activity.actorName}</p>
|
||||||
<p className="text-xs text-neutral-400 mt-1">
|
<p className="text-xs text-neutral-400 mt-1">
|
||||||
@@ -261,14 +295,6 @@ export const AdminDashboard: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{recentActivity && recentActivity.length > 5 && (
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/admin/activity')}
|
|
||||||
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
|
||||||
>
|
|
||||||
{t('admin.viewAllActivity')} →
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -270,21 +270,6 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
|
||||||
<label className="flex items-center gap-3 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={brandingSettings.watermark_enabled}
|
|
||||||
onChange={(e) => handleBrandingChange('watermark_enabled', 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.enableWatermarks')}</span>
|
|
||||||
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
|
|
||||||
</div>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
@@ -330,6 +315,21 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||||
|
<label className="flex items-center gap-3 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={brandingSettings.watermark_enabled}
|
||||||
|
onChange={(e) => handleBrandingChange('watermark_enabled', 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.enableWatermarks')}</span>
|
||||||
|
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Watermark Settings */}
|
{/* Watermark Settings */}
|
||||||
{brandingSettings.watermark_enabled && (
|
{brandingSettings.watermark_enabled && (
|
||||||
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
|
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
|
||||||
@@ -422,7 +422,15 @@ export const BrandingPage: React.FC = () => {
|
|||||||
step="10"
|
step="10"
|
||||||
value={brandingSettings.watermark_opacity || 50}
|
value={brandingSettings.watermark_opacity || 50}
|
||||||
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
|
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
|
||||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
|
className="w-full slider"
|
||||||
|
style={{
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
appearance: 'none',
|
||||||
|
height: '8px',
|
||||||
|
background: '#d4d4d4',
|
||||||
|
borderRadius: '4px',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||||
<span>10%</span>
|
<span>10%</span>
|
||||||
@@ -443,7 +451,15 @@ export const BrandingPage: React.FC = () => {
|
|||||||
step="5"
|
step="5"
|
||||||
value={brandingSettings.watermark_size || 15}
|
value={brandingSettings.watermark_size || 15}
|
||||||
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
|
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
|
||||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
|
className="w-full slider"
|
||||||
|
style={{
|
||||||
|
WebkitAppearance: 'none',
|
||||||
|
appearance: 'none',
|
||||||
|
height: '8px',
|
||||||
|
background: '#d4d4d4',
|
||||||
|
borderRadius: '4px',
|
||||||
|
outline: 'none'
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||||
<span>5%</span>
|
<span>5%</span>
|
||||||
|
|||||||
@@ -235,10 +235,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePresetChange = (presetName: string) => {
|
const handlePresetChange = (presetName: string) => {
|
||||||
setFormData(prev => ({
|
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||||
...prev,
|
if (preset) {
|
||||||
theme_preset: presetName
|
setFormData(prev => ({
|
||||||
}));
|
...prev,
|
||||||
|
theme_preset: presetName,
|
||||||
|
theme_config: preset.config
|
||||||
|
}));
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -383,7 +387,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
|||||||
onChange={handleThemeChange}
|
onChange={handleThemeChange}
|
||||||
presetName={formData.theme_preset}
|
presetName={formData.theme_preset}
|
||||||
onPresetChange={handlePresetChange}
|
onPresetChange={handlePresetChange}
|
||||||
isPreviewMode={false}
|
isPreviewMode={true}
|
||||||
showGalleryLayouts={true}
|
showGalleryLayouts={true}
|
||||||
hideActions={true}
|
hideActions={true}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -15,16 +15,14 @@ import {
|
|||||||
CheckCircle,
|
CheckCircle,
|
||||||
Upload,
|
Upload,
|
||||||
Image,
|
Image,
|
||||||
Key,
|
Key
|
||||||
Palette,
|
|
||||||
Settings
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays } from 'date-fns';
|
import { parseISO, differenceInDays } from 'date-fns';
|
||||||
import { toast } from 'react-toastify';
|
import { toast } from 'react-toastify';
|
||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
|
|
||||||
import { Button, Input, Card, Loading } from '../../components/common';
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeDisplay, ThemeCustomizerEnhanced, ThemeEditorModal, HeroPhotoSelector, PhotoUploadModal } from '../../components/admin';
|
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal } from '../../components/admin';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
@@ -60,10 +58,8 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
|
||||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||||
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
|
|
||||||
|
|
||||||
// Photo filters state
|
// Photo filters state
|
||||||
const [photoFilters, setPhotoFilters] = useState({
|
const [photoFilters, setPhotoFilters] = useState({
|
||||||
@@ -203,7 +199,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const handleSaveEdit = () => {
|
const handleSaveEdit = () => {
|
||||||
// Prepare color_theme - if we have a custom theme, serialize it
|
// Prepare color_theme - if we have a custom theme, serialize it
|
||||||
let themeToSave = editForm.color_theme;
|
let themeToSave = editForm.color_theme;
|
||||||
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
|
if (currentTheme && currentPresetName === 'custom') {
|
||||||
themeToSave = JSON.stringify(currentTheme);
|
themeToSave = JSON.stringify(currentTheme);
|
||||||
} else if (currentPresetName && currentPresetName !== 'custom') {
|
} else if (currentPresetName && currentPresetName !== 'custom') {
|
||||||
// Use preset name for non-custom themes
|
// Use preset name for non-custom themes
|
||||||
@@ -256,34 +252,6 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeModalSave = async (theme: ThemeConfig, presetName: string) => {
|
|
||||||
// Prepare theme for saving
|
|
||||||
let themeToSave: string;
|
|
||||||
if (presetName !== 'custom' && GALLERY_THEME_PRESETS[presetName]) {
|
|
||||||
// Save preset name for standard presets
|
|
||||||
themeToSave = presetName;
|
|
||||||
} else {
|
|
||||||
// Save full theme config for custom themes
|
|
||||||
themeToSave = JSON.stringify(theme);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Update the event with new theme
|
|
||||||
await eventsService.updateEvent(parseInt(id!), {
|
|
||||||
color_theme: themeToSave
|
|
||||||
});
|
|
||||||
|
|
||||||
// Invalidate queries to refresh the data
|
|
||||||
await queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
|
||||||
|
|
||||||
// Close modal and show success
|
|
||||||
setShowThemeEditorModal(false);
|
|
||||||
toast.success(t('toast.themeUpdated'));
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to save theme:', error);
|
|
||||||
toast.error(t('toast.saveError'));
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@@ -492,73 +460,6 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
|
||||||
{t('events.galleryTheme')}
|
|
||||||
</label>
|
|
||||||
{!showThemeCustomizer ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<select
|
|
||||||
value={currentPresetName}
|
|
||||||
onChange={(e) => {
|
|
||||||
const presetName = e.target.value;
|
|
||||||
setCurrentPresetName(presetName);
|
|
||||||
if (presetName !== 'custom') {
|
|
||||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
|
||||||
if (preset) {
|
|
||||||
setCurrentTheme(preset.config);
|
|
||||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
|
||||||
>
|
|
||||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
|
||||||
<option key={key} value={key}>
|
|
||||||
{preset.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
<option value="custom">{t('branding.customTheme')}</option>
|
|
||||||
</select>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
leftIcon={<Settings className="w-4 h-4" />}
|
|
||||||
onClick={() => setShowThemeCustomizer(true)}
|
|
||||||
className="w-full"
|
|
||||||
>
|
|
||||||
{t('branding.customizeTheme')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<span className="text-sm text-neutral-600">{t('branding.customizingTheme')}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setShowThemeCustomizer(false)}
|
|
||||||
>
|
|
||||||
{t('common.hide')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<ThemeCustomizerEnhanced
|
|
||||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
|
||||||
onChange={setCurrentTheme}
|
|
||||||
presetName={currentPresetName}
|
|
||||||
onPresetChange={(presetName) => {
|
|
||||||
setCurrentPresetName(presetName);
|
|
||||||
if (presetName !== 'custom') {
|
|
||||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
isPreviewMode={false}
|
|
||||||
showGalleryLayouts={true}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Hero Photo Selection */}
|
{/* Hero Photo Selection */}
|
||||||
<HeroPhotoSelector
|
<HeroPhotoSelector
|
||||||
photos={photos || []}
|
photos={photos || []}
|
||||||
@@ -822,28 +723,44 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Gallery Theme */}
|
{/* Theme & Style */}
|
||||||
<Card padding="md">
|
{isEditing && !event.is_archived && (
|
||||||
<div className="flex items-center justify-between mb-4">
|
<Card padding="md">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.themeAndStyle')}</h2>
|
||||||
{!event.is_archived && (
|
<ThemeCustomizerEnhanced
|
||||||
<Button
|
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||||
variant="outline"
|
onChange={(theme) => {
|
||||||
size="sm"
|
setCurrentTheme(theme);
|
||||||
leftIcon={<Palette className="w-4 h-4" />}
|
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
|
||||||
onClick={() => setShowThemeEditorModal(true)}
|
}}
|
||||||
>
|
presetName={currentPresetName}
|
||||||
{t('events.customizeTheme')}
|
onPresetChange={(presetName) => {
|
||||||
</Button>
|
setCurrentPresetName(presetName);
|
||||||
)}
|
if (presetName !== 'custom') {
|
||||||
</div>
|
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||||
|
if (preset) {
|
||||||
<ThemeDisplay
|
setCurrentTheme(preset.config);
|
||||||
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||||
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
}
|
||||||
showDetails={true}
|
}
|
||||||
/>
|
}}
|
||||||
</Card>
|
isPreviewMode={false}
|
||||||
|
showGalleryLayouts={true}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Theme Display (when not editing) */}
|
||||||
|
{!isEditing && !event.is_archived && (
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.galleryTheme')}</h2>
|
||||||
|
<ThemeDisplay
|
||||||
|
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
||||||
|
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
||||||
|
showDetails={true}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Archive Status */}
|
{/* Archive Status */}
|
||||||
{event.is_archived ? (
|
{event.is_archived ? (
|
||||||
@@ -995,16 +912,6 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Theme Editor Modal */}
|
|
||||||
{showThemeEditorModal && (
|
|
||||||
<ThemeEditorModal
|
|
||||||
isOpen={showThemeEditorModal}
|
|
||||||
onClose={() => setShowThemeEditorModal(false)}
|
|
||||||
onSave={handleThemeModalSave}
|
|
||||||
currentTheme={event.color_theme || 'default'}
|
|
||||||
eventName={event.event_name}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
export { AdminLoginPage } from './AdminLoginPage';
|
export { AdminLoginPage } from './AdminLoginPage';
|
||||||
export { AdminDashboard } from './AdminDashboard';
|
export { AdminDashboard } from './AdminDashboard';
|
||||||
export { EventsListPage } from './EventsListPage';
|
export { EventsListPage } from './EventsListPage';
|
||||||
export { CreateEventPageEnhanced as CreateEventPage } from './CreateEventPageEnhanced';
|
export { CreateEventPageEnhanced } from './CreateEventPageEnhanced';
|
||||||
export { EventDetailsPage } from './EventDetailsPage';
|
export { EventDetailsPage } from './EventDetailsPage';
|
||||||
export { EmailConfigPage } from './EmailConfigPage';
|
export { EmailConfigPage } from './EmailConfigPage';
|
||||||
export { ArchivesPage } from './ArchivesPage';
|
export { ArchivesPage } from './ArchivesPage';
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ export const LegalPage: React.FC = () => {
|
|||||||
// Update page title
|
// Update page title
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (page?.title) {
|
if (page?.title) {
|
||||||
document.title = `${page.title} - Wedding Photo Sharing`;
|
document.title = `${page.title} - PicPeak`;
|
||||||
}
|
}
|
||||||
}, [page?.title]);
|
}, [page?.title]);
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ export const LegalPage: React.FC = () => {
|
|||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-neutral-500 mt-4">
|
<p className="text-sm text-neutral-500 mt-4">
|
||||||
© 2024 Wedding Photo Sharing. All rights reserved.
|
© 2024 PicPeak. All rights reserved.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@@ -9,6 +9,29 @@ export interface DashboardStats {
|
|||||||
totalDownloads: number;
|
totalDownloads: number;
|
||||||
viewsTrend: number;
|
viewsTrend: number;
|
||||||
downloadsTrend: number;
|
downloadsTrend: number;
|
||||||
|
archivedEvents: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SystemHealth {
|
||||||
|
overall: 'healthy' | 'warning' | 'error';
|
||||||
|
services: {
|
||||||
|
database: 'healthy' | 'warning' | 'error';
|
||||||
|
email: 'healthy' | 'warning' | 'error';
|
||||||
|
storage: 'healthy' | 'warning' | 'error';
|
||||||
|
memory: 'healthy' | 'warning' | 'error';
|
||||||
|
};
|
||||||
|
details: {
|
||||||
|
emailQueue: {
|
||||||
|
pending: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
memory: {
|
||||||
|
total: number;
|
||||||
|
free: number;
|
||||||
|
used: number;
|
||||||
|
percentage: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Activity {
|
export interface Activity {
|
||||||
@@ -63,6 +86,12 @@ export const adminService = {
|
|||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// System health check
|
||||||
|
async getSystemHealth(): Promise<SystemHealth> {
|
||||||
|
const response = await api.get<SystemHealth>('/api/admin/dashboard/health');
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
// Format activity message
|
// Format activity message
|
||||||
formatActivityMessage(activity: Activity): string {
|
formatActivityMessage(activity: Activity): string {
|
||||||
const messages: Record<string, string> = {
|
const messages: Record<string, string> = {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { Photo } from '../types';
|
||||||
|
|
||||||
|
interface PhotoUrlOptions {
|
||||||
|
slug: string;
|
||||||
|
photo: Photo;
|
||||||
|
watermarkEnabled?: boolean;
|
||||||
|
token?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the appropriate URL for a photo, using watermarked endpoint if enabled
|
||||||
|
*/
|
||||||
|
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
|
||||||
|
if (watermarkEnabled && token) {
|
||||||
|
// Use the watermarked photo endpoint
|
||||||
|
return `/api/gallery/${slug}/photo/${photo.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use the static photo URL
|
||||||
|
return photo.url;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the download URL for a photo
|
||||||
|
*/
|
||||||
|
export function getPhotoDownloadUrl(slug: string, photoId: number): string {
|
||||||
|
return `/api/gallery/${slug}/download/${photoId}`;
|
||||||
|
}
|
||||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"name": "wedding-photo-sharing",
|
"name": "picpeak",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 853 KiB |
Reference in New Issue
Block a user