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 <noreply@anthropic.com>
This commit is contained in:
+4
-4
@@ -7,7 +7,7 @@ steps:
|
||||
- name: build-backend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
@@ -19,7 +19,7 @@ steps:
|
||||
- name: build-frontend
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- latest
|
||||
- ${DRONE_COMMIT_SHA:0:8}
|
||||
@@ -45,7 +45,7 @@ steps:
|
||||
- name: build-backend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-backend
|
||||
repo: registry.local.nothaft.cloud/picpeak-backend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- latest
|
||||
@@ -57,7 +57,7 @@ steps:
|
||||
- name: build-frontend-release
|
||||
image: plugins/docker
|
||||
settings:
|
||||
repo: registry.local.nothaft.cloud/wedding-photo-sharing-frontend
|
||||
repo: registry.local.nothaft.cloud/picpeak-frontend
|
||||
tags:
|
||||
- ${DRONE_TAG}
|
||||
- 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 "actions@github.com"
|
||||
|
||||
- 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
|
||||
# 1. Clone the repository
|
||||
git clone <your-repo-url>
|
||||
cd wedding-photo-sharing
|
||||
cd picpeak
|
||||
|
||||
# 2. Start everything
|
||||
./start-local.sh
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
# Wedding Photo Sharing Platform - Complete Setup Guide
|
||||
# PicPeak - Complete Setup Guide
|
||||
|
||||
## Repository Created Successfully! 🎉
|
||||
|
||||
Your wedding photo sharing platform repository has been created at:
|
||||
**https://gitea.nothaft.cloud/paul/wedding-photo-sharing**
|
||||
Your PicPeak repository has been created at:
|
||||
**https://gitea.nothaft.cloud/paul/picpeak**
|
||||
|
||||
## What's Been Created
|
||||
|
||||
@@ -26,8 +26,8 @@ I've uploaded the core files needed to run the application:
|
||||
|
||||
### 1. Clone the Repository
|
||||
```bash
|
||||
git clone https://gitea.local.nothaft.cloud/paul/wedding-photo-sharing.git
|
||||
cd wedding-photo-sharing
|
||||
git clone https://gitea.local.nothaft.cloud/paul/picpeak.git
|
||||
cd picpeak
|
||||
```
|
||||
|
||||
### 2. Run the Setup Script
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
module.exports = {
|
||||
apps: [{
|
||||
name: 'photo-sharing',
|
||||
name: 'picpeak',
|
||||
script: './server.js',
|
||||
instances: 'max',
|
||||
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",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.1",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^6.9.1",
|
||||
"react-i18next": "^15.6.0",
|
||||
@@ -38,7 +38,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"nodemon": "^2.0.22",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
},
|
||||
@@ -2646,50 +2646,20 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/concat-stream": {
|
||||
"version": "1.6.2",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz",
|
||||
"integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-2.0.0.tgz",
|
||||
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
|
||||
"engines": [
|
||||
"node >= 0.8"
|
||||
"node >= 6.0"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"buffer-from": "^1.0.0",
|
||||
"inherits": "^2.0.3",
|
||||
"readable-stream": "^2.2.2",
|
||||
"readable-stream": "^3.0.2",
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
|
||||
@@ -5974,22 +5944,21 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/multer": {
|
||||
"version": "1.4.5-lts.2",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.2.tgz",
|
||||
"integrity": "sha512-VzGiVigcG9zUAoCNU+xShztrlr1auZOlurXynNvO9GiWD1/mTBbUljOKY+qMeazBqXgRnjzeEgJI/wyjJUHg9A==",
|
||||
"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.",
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/multer/-/multer-2.0.1.tgz",
|
||||
"integrity": "sha512-Ug8bXeTIUlxurg8xLTEskKShvcKDZALo1THEX5E41pYCD2sCVub5/kIRIGqWNoqV6szyLyQKV6mD4QUrWE5GCQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"append-field": "^1.0.0",
|
||||
"busboy": "^1.0.0",
|
||||
"concat-stream": "^1.5.2",
|
||||
"mkdirp": "^0.5.4",
|
||||
"busboy": "^1.6.0",
|
||||
"concat-stream": "^2.0.0",
|
||||
"mkdirp": "^0.5.6",
|
||||
"object-assign": "^4.1.1",
|
||||
"type-is": "^1.6.4",
|
||||
"xtend": "^4.0.0"
|
||||
"type-is": "^1.6.18",
|
||||
"xtend": "^4.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6.0.0"
|
||||
"node": ">= 10.16.0"
|
||||
}
|
||||
},
|
||||
"node_modules/napi-build-utils": {
|
||||
@@ -6175,19 +6144,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemon": {
|
||||
"version": "2.0.22",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-2.0.22.tgz",
|
||||
"integrity": "sha512-B8YqaKMmyuCO7BowF1Z1/mkPqLk6cs/l63Ojtd6otKjMx47Dq1utxfRxcavH1I7VSaL8n5BUaoutadnsX3AAVQ==",
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.10.tgz",
|
||||
"integrity": "sha512-WDjw3pJ0/0jMFmyNDp3gvY2YizjLmmOUQo6DEBY+JgdvW/yQ9mEeSw6H5ythl5Ny2ytb7f9C2nIbjSxMNzbJXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chokidar": "^3.5.2",
|
||||
"debug": "^3.2.7",
|
||||
"debug": "^4",
|
||||
"ignore-by-default": "^1.0.1",
|
||||
"minimatch": "^3.1.2",
|
||||
"pstree.remy": "^1.1.8",
|
||||
"semver": "^5.7.1",
|
||||
"simple-update-notifier": "^1.0.7",
|
||||
"semver": "^7.5.3",
|
||||
"simple-update-notifier": "^2.0.0",
|
||||
"supports-color": "^5.5.0",
|
||||
"touch": "^3.1.0",
|
||||
"undefsafe": "^2.0.5"
|
||||
@@ -6196,23 +6165,13 @@
|
||||
"nodemon": "bin/nodemon.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
"node": ">=10"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz",
|
||||
@@ -6223,16 +6182,6 @@
|
||||
"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": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz",
|
||||
@@ -7453,26 +7402,16 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-update-notifier": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-1.1.0.tgz",
|
||||
"integrity": "sha512-VpsrsJSUcJEseSbMHkrsrAVSdvVS5I96Qo1QAQ4FxQ9wXFcB+pjj7FB7/us9+GcgfW4ziHtYMc1J0PLczb55mg==",
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/simple-update-notifier/-/simple-update-notifier-2.0.0.tgz",
|
||||
"integrity": "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"semver": "~7.0.0"
|
||||
"semver": "^7.5.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"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": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/sisteransi": {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "photo-sharing-backend",
|
||||
"name": "picpeak-backend",
|
||||
"version": "1.0.0",
|
||||
"description": "Backend for event photo sharing platform",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
@@ -29,7 +29,7 @@
|
||||
"joi": "^17.9.1",
|
||||
"jsonwebtoken": "^9.0.0",
|
||||
"knex": "^2.4.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"multer": "^2.0.1",
|
||||
"node-cron": "^3.0.2",
|
||||
"nodemailer": "^6.9.1",
|
||||
"react-i18next": "^15.6.0",
|
||||
@@ -41,7 +41,7 @@
|
||||
"devDependencies": {
|
||||
"eslint": "^8.40.0",
|
||||
"jest": "^29.5.0",
|
||||
"nodemon": "^2.0.22",
|
||||
"nodemon": "^3.1.10",
|
||||
"supertest": "^6.3.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
.count('id as count')
|
||||
.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)
|
||||
const previousViews = await db('access_logs')
|
||||
.where('action', 'view')
|
||||
@@ -78,7 +84,8 @@ router.get('/stats', adminAuth, async (req, res) => {
|
||||
totalViews: totalViews.count || 0,
|
||||
totalDownloads: totalDownloads.count || 0,
|
||||
viewsTrend: Math.round(viewsTrend * 10) / 10,
|
||||
downloadsTrend: Math.round(downloadsTrend * 10) / 10
|
||||
downloadsTrend: Math.round(downloadsTrend * 10) / 10,
|
||||
archivedEvents: archivedEvents.count || 0
|
||||
});
|
||||
} catch (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
|
||||
router.get('/analytics', adminAuth, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -65,9 +65,9 @@ router.post('/', adminAuth, [
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Calculate expiration date
|
||||
const expires_at = new Date();
|
||||
expires_at.setDate(expires_at.getDate() + expiration_days);
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -115,7 +115,8 @@ router.post('/', adminAuth, [
|
||||
event_date: await formatDate(event_date, emailLang),
|
||||
gallery_link: shareLink,
|
||||
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
|
||||
});
|
||||
|
||||
@@ -53,9 +53,9 @@ router.post('/', adminAuth, [
|
||||
// Hash password
|
||||
const password_hash = await bcrypt.hash(password, 10);
|
||||
|
||||
// Calculate expiration date
|
||||
const expires_at = new Date();
|
||||
expires_at.setDate(expires_at.getDate() + expiration_days);
|
||||
// Calculate expiration date (days after event date)
|
||||
const expires_at = new Date(event_date);
|
||||
expires_at.setDate(expires_at.getDate() + parseInt(expiration_days, 10));
|
||||
|
||||
// Create folder structure
|
||||
const storagePath = process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
@@ -86,7 +86,8 @@ router.post('/', adminAuth, [
|
||||
event_date: new Date(event_date).toLocaleDateString(),
|
||||
gallery_link: shareLink,
|
||||
gallery_password: password,
|
||||
expiry_date: expires_at.toLocaleDateString()
|
||||
expiry_date: expires_at.toLocaleDateString(),
|
||||
welcome_message: welcome_message || ''
|
||||
});
|
||||
|
||||
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
|
||||
router.get('/:slug/stats', verifyGalleryAccess, async (req, res) => {
|
||||
try {
|
||||
|
||||
@@ -93,6 +93,17 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3005';
|
||||
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
|
||||
Object.entries(variables).forEach(([key, value]) => {
|
||||
const regex = new RegExp(`{{${key}}}`, 'g');
|
||||
@@ -101,6 +112,9 @@ async function processTemplate(template, variables, language = 'en') {
|
||||
textBody = textBody.replace(regex, value || '');
|
||||
});
|
||||
|
||||
// Replace welcome message section placeholder
|
||||
htmlBody = htmlBody.replace(/{{welcome_message_section}}/g, welcomeMessageSection);
|
||||
|
||||
// Wrap HTML body in styled template
|
||||
const styledHtmlBody = `
|
||||
<!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
|
||||
|
||||
echo "========================================="
|
||||
echo "Wedding Photo Sharing Platform Setup"
|
||||
echo "PicPeak Platform Setup"
|
||||
echo "========================================="
|
||||
echo ""
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ YELLOW='\033[1;33m'
|
||||
BLUE='\033[0;34m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
echo -e "${GREEN}Photo Sharing Platform Deployment Script${NC}"
|
||||
echo -e "${GREEN}PicPeak Deployment Script${NC}"
|
||||
echo "========================================"
|
||||
|
||||
# Default values
|
||||
STACK_NAME="photo-sharing"
|
||||
STACK_NAME="picpeak"
|
||||
ENV_FILE="../../.env.production"
|
||||
REGISTRY_URL="${REGISTRY_URL:-}"
|
||||
VERSION="${VERSION:-latest}"
|
||||
@@ -42,7 +42,7 @@ while [[ $# -gt 0 ]]; do
|
||||
echo " --env FILE Path to environment file (default: ../../.env.production)"
|
||||
echo " --registry URL Docker registry URL"
|
||||
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
|
||||
;;
|
||||
*)
|
||||
|
||||
@@ -3,7 +3,7 @@ version: '3.8'
|
||||
|
||||
services:
|
||||
backend:
|
||||
image: photo-sharing-backend:latest
|
||||
image: picpeak-backend:latest
|
||||
build:
|
||||
context: ./backend
|
||||
dockerfile: Dockerfile
|
||||
@@ -27,10 +27,10 @@ services:
|
||||
- ./data:/app/data
|
||||
- ./logs:/app/logs
|
||||
networks:
|
||||
- photo-sharing
|
||||
- picpeak
|
||||
|
||||
frontend:
|
||||
image: photo-sharing-frontend:latest
|
||||
image: picpeak-frontend:latest
|
||||
build:
|
||||
context: ./frontend
|
||||
dockerfile: Dockerfile
|
||||
@@ -38,7 +38,7 @@ services:
|
||||
depends_on:
|
||||
- backend
|
||||
networks:
|
||||
- photo-sharing
|
||||
- picpeak
|
||||
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
- frontend
|
||||
- backend
|
||||
networks:
|
||||
- photo-sharing
|
||||
- picpeak
|
||||
command: "/bin/sh -c 'while :; do sleep 6h & wait $${!}; nginx -s reload; done & nginx -g \"daemon off;\"'"
|
||||
|
||||
certbot:
|
||||
@@ -72,11 +72,11 @@ services:
|
||||
environment:
|
||||
- POSTGRES_USER=${DB_USER:-photoapp}
|
||||
- POSTGRES_PASSWORD=${DB_PASSWORD}
|
||||
- POSTGRES_DB=${DB_NAME:-photo_sharing}
|
||||
- POSTGRES_DB=${DB_NAME:-picpeak}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- photo-sharing
|
||||
- picpeak
|
||||
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
@@ -88,10 +88,10 @@ services:
|
||||
depends_on:
|
||||
- db
|
||||
networks:
|
||||
- photo-sharing
|
||||
- picpeak
|
||||
|
||||
networks:
|
||||
photo-sharing:
|
||||
picpeak:
|
||||
driver: bridge
|
||||
|
||||
volumes:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "photo-sharing-frontend",
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
AdminLoginPage,
|
||||
AdminDashboard,
|
||||
EventsListPage,
|
||||
CreateEventPage,
|
||||
CreateEventPageEnhanced as CreateEventPage,
|
||||
EventDetailsPage,
|
||||
EmailConfigPage,
|
||||
ArchivesPage,
|
||||
|
||||
@@ -88,8 +88,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - PicPeak branding */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2">
|
||||
{/* Center - Logo and PicPeak text */}
|
||||
<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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,11 +49,10 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
}`}
|
||||
>
|
||||
<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">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="Camera" className="w-8 h-8 object-contain" />
|
||||
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card } from '../common';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||
|
||||
@@ -71,7 +70,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
|
||||
switch (activeLayout) {
|
||||
case 'grid': {
|
||||
const cols = theme.gallerySettings?.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
|
||||
return (
|
||||
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
|
||||
{mockPhotos.slice(0, 8).map((photo) => (
|
||||
@@ -170,7 +168,9 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
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>
|
||||
|
||||
{/* Preview Content */}
|
||||
|
||||
@@ -57,17 +57,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
// Create a new URL with auth header
|
||||
const fetchImage = async () => {
|
||||
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;
|
||||
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
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
|
||||
onDownloadAll?: () => void;
|
||||
isDownloading?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
menuButton?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -65,6 +67,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
@@ -106,8 +109,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
||||
<div className="container py-3">
|
||||
<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">
|
||||
{/* Menu button */}
|
||||
{menuButton && (
|
||||
<div className="flex-shrink-0">
|
||||
{menuButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
@@ -119,11 +129,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Menu button on mobile */}
|
||||
<div className="flex-shrink-0 sm:hidden">
|
||||
{headerExtra}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Event info */}
|
||||
@@ -154,10 +159,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* Menu button on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
{/* Extra header items (upload button, etc.) */}
|
||||
{headerExtra && (
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
@@ -214,7 +221,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
@@ -273,21 +281,27 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
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>
|
||||
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
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}
|
||||
</h1>
|
||||
|
||||
{/* Event Details */}
|
||||
{(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 && (
|
||||
<span className="flex items-center text-lg">
|
||||
<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 { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -48,9 +49,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const { watermarkEnabled } = useWatermarkSettings();
|
||||
|
||||
// Fetch photos
|
||||
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();
|
||||
|
||||
// Handle window resize
|
||||
@@ -88,18 +101,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settingsData && event) {
|
||||
if (settingsData && data?.event) {
|
||||
let themeToApply = null;
|
||||
const fullEvent = data.event; // Use the full event data from API
|
||||
|
||||
if (event.color_theme) {
|
||||
if (fullEvent.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (event.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(event.color_theme);
|
||||
if (fullEvent.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// 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) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
@@ -126,10 +140,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (event.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
|
||||
} else if (event.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
|
||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||
console.log('Setting hero photo ID in existing gallery settings:', fullEvent.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);
|
||||
}, 0);
|
||||
@@ -137,7 +153,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData, event, setTheme]); // Include all dependencies
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Calculate days until expiration
|
||||
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;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy]);
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]);
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadAllMutation.mutate(slug);
|
||||
@@ -307,7 +332,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
isMobile={isMobile}
|
||||
galleryLayout={theme.galleryLayout}
|
||||
allowUploads={event.allow_user_uploads}
|
||||
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
|
||||
onUploadClick={() => setShowUploadModal(true)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -320,23 +345,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
showDownloadAll={!showSidebar}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
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={(() => {
|
||||
const items = [];
|
||||
|
||||
if (showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="menu-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>
|
||||
);
|
||||
}
|
||||
console.log('Header extra - data loaded:', !!data);
|
||||
console.log('Header extra - allow uploads:', data?.event?.allow_user_uploads);
|
||||
console.log('Header extra - showSidebar:', showSidebar);
|
||||
console.log('Header extra - isMobile:', isMobile);
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
@@ -345,7 +371,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
// 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(
|
||||
<Button
|
||||
key="upload-button"
|
||||
@@ -360,7 +387,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
// Upload button for non-sidebar layouts
|
||||
if (event.allow_user_uploads && !showSidebar) {
|
||||
if (allowUploads && !showSidebar) {
|
||||
items.push(
|
||||
<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 */}
|
||||
@@ -420,10 +447,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
eventId={data?.event?.id || event?.id}
|
||||
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card, CardContent } from '../common';
|
||||
import { Button } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface UserPhotoUploadProps {
|
||||
@@ -114,7 +114,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
|
||||
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="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 */}
|
||||
<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>
|
||||
|
||||
@@ -31,24 +31,49 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
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(() => {
|
||||
if (photos.length > 0) {
|
||||
const heroId = gallerySettings.heroImageId;
|
||||
const hero = heroId ? photos.find(p => p.id === heroId) : photos[0];
|
||||
setHeroPhoto(hero || photos[0]);
|
||||
console.log('HeroGalleryLayout - heroImageId:', heroId, 'photos:', photos.length);
|
||||
|
||||
// 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;
|
||||
|
||||
const remainingPhotos = photos.filter(p => p.id !== heroPhoto.id);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative -mt-6">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
||||
<AuthenticatedImage
|
||||
@@ -67,17 +92,20 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
{/* Logo */}
|
||||
{eventLogo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo}
|
||||
alt="Event logo"
|
||||
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))' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${eventLogo}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto"
|
||||
style={{
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Title */}
|
||||
{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",
|
||||
"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.",
|
||||
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||
"extendSevenDays": "Um 7 Tage verlängern",
|
||||
"overview": "Übersicht",
|
||||
"photos": "Fotos",
|
||||
"categories": "Kategorien",
|
||||
@@ -266,7 +268,7 @@
|
||||
"colorTheme": "Farbthema",
|
||||
"galleryExpiration": "Galerie-Ablauf",
|
||||
"galleryExpiresIn": "Galerie läuft ab in",
|
||||
"daysAfterEvent": "Tage nach der Veranstaltung",
|
||||
"daysAfterEvent": "Tage nach dem Veranstaltungsdatum",
|
||||
"expiresOn": "Läuft ab am",
|
||||
"themeAndStyle": "Design & Stil",
|
||||
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
||||
@@ -279,6 +281,12 @@
|
||||
"selectCategory": "Wählen Sie eine Kategorie für Benutzer-Uploads",
|
||||
"uploadCategoryHelp": "Alle von Benutzern hochgeladenen Fotos werden dieser Kategorie hinzugefügt",
|
||||
"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...",
|
||||
"eventTypeWedding": "Hochzeit",
|
||||
"eventTypeBirthday": "Geburtstag",
|
||||
@@ -324,6 +332,8 @@
|
||||
"loadingEvents": "Veranstaltungen werden geladen...",
|
||||
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
|
||||
"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",
|
||||
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
|
||||
"searchEventsPlaceholder": "Veranstaltungen suchen...",
|
||||
@@ -458,6 +468,7 @@
|
||||
"titleFull": "Branding & Anpassung",
|
||||
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
|
||||
"loadingBranding": "Branding-Einstellungen werden geladen...",
|
||||
"themeAndStyle": "Theme & Stil",
|
||||
"companyInfo": "Unternehmensinformationen",
|
||||
"companyName": "Unternehmensname",
|
||||
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
|
||||
@@ -598,6 +609,14 @@
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"storagePercent": "{{percent}}% von {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archivierte Veranstaltungen",
|
||||
"systemHealth": "Systemstatus",
|
||||
"health": {
|
||||
"healthy": "Gesund",
|
||||
"warning": "Warnung",
|
||||
"error": "Fehler",
|
||||
"checking": "Prüfe..."
|
||||
},
|
||||
"notifications": "Benachrichtigungen",
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
@@ -664,7 +683,29 @@
|
||||
"viewAllActivity": "Alle Aktivitäten anzeigen",
|
||||
"quickActions": "Schnellaktionen",
|
||||
"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": {
|
||||
"notFound": "Nicht gefunden",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"create": "Create",
|
||||
"createEvent": "Create Event",
|
||||
"totalViews": "Total Views",
|
||||
"totalDownloads": "Total Downloads",
|
||||
@@ -198,7 +199,9 @@
|
||||
"hostEmailPlaceholder": "host@example.com",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"securityAndAccess": "Security & Access",
|
||||
"accessAndSecurity": "Access & Security",
|
||||
"enterPassword": "Enter password",
|
||||
"passwordPlaceholder": "Enter a secure password",
|
||||
"confirmPasswordPlaceholder": "Confirm password",
|
||||
"galleryExpiresOn": "Gallery will expire on {{date}}",
|
||||
"guestsWillReceiveWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||
@@ -279,13 +282,18 @@
|
||||
"confirmPassword": "Confirm Password",
|
||||
"showPasswords": "Show passwords",
|
||||
"gallerySettings": "Gallery Settings",
|
||||
"themeAndStyle": "Theme & Style",
|
||||
"colorTheme": "Color Theme",
|
||||
"galleryExpiration": "Gallery Expiration",
|
||||
"galleryExpiresIn": "Gallery Expires In",
|
||||
"daysAfterEvent": "days after event date",
|
||||
"expiresOn": "Expires on",
|
||||
"galleryWillExpireOn": "Gallery will expire on {{date}}",
|
||||
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||
"userUploads": "User Upload Settings",
|
||||
"allowUserUploads": "Allow guests to upload photos",
|
||||
"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",
|
||||
"selectCategory": "Select a category for user uploads",
|
||||
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
|
||||
@@ -513,6 +521,7 @@
|
||||
"titleFull": "Branding & Customization",
|
||||
"subtitle": "Customize the look and feel of your galleries",
|
||||
"loadingBranding": "Loading branding settings...",
|
||||
"themeAndStyle": "Theme & Style",
|
||||
"companyInfo": "Company Information",
|
||||
"companyName": "Company Name",
|
||||
"companyNameHelp": "Displayed in gallery headers and emails",
|
||||
@@ -653,6 +662,14 @@
|
||||
"totalPhotos": "Total Photos",
|
||||
"storagePercent": "{{percent}}% of {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archived Events",
|
||||
"systemHealth": "System Health",
|
||||
"health": {
|
||||
"healthy": "Healthy",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"noNotifications": "No new notifications",
|
||||
@@ -717,7 +734,29 @@
|
||||
"viewAllActivity": "View all activity",
|
||||
"quickActions": "Quick Actions",
|
||||
"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": {
|
||||
"notFound": "Not Found",
|
||||
|
||||
+75
-18
@@ -186,38 +186,95 @@
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom range slider styles */
|
||||
.slider {
|
||||
/* Custom range slider styles - Updated */
|
||||
input[type="range"].slider {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #d4d4d4 !important;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-track {
|
||||
@apply bg-neutral-200 h-2 rounded-lg;
|
||||
/* Webkit browsers like Chrome/Safari */
|
||||
input[type="range"].slider::-webkit-slider-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #d4d4d4 !important;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
@apply bg-neutral-200 h-2 rounded-lg;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
input[type="range"].slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all;
|
||||
margin-top: -6px;
|
||||
width: 20px;
|
||||
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 {
|
||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all border-0;
|
||||
input[type="range"].slider::-webkit-slider-thumb:hover {
|
||||
background: #4a6f4f;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider:hover::-webkit-slider-thumb {
|
||||
@apply bg-primary-700 scale-110;
|
||||
/* Firefox */
|
||||
input[type="range"].slider::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #d4d4d4 !important;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider:hover::-moz-range-thumb {
|
||||
@apply bg-primary-700 scale-110;
|
||||
input[type="range"].slider::-moz-range-thumb {
|
||||
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,
|
||||
Plus,
|
||||
HardDrive,
|
||||
Image
|
||||
Image,
|
||||
Archive,
|
||||
Heart
|
||||
} from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -44,6 +46,13 @@ export const AdminDashboard: React.FC = () => {
|
||||
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
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['admin-events-summary'],
|
||||
@@ -74,7 +83,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// Build statistics cards
|
||||
// Build statistics cards - always show 8 cards in 2x4 grid
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
title: t('admin.activeEvents'),
|
||||
@@ -101,28 +110,34 @@ export const AdminDashboard: React.FC = () => {
|
||||
icon: HardDrive,
|
||||
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 (
|
||||
<div>
|
||||
{/* Page Header */}
|
||||
@@ -243,12 +258,31 @@ export const AdminDashboard: React.FC = () => {
|
||||
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 (
|
||||
<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="flex-1 min-w-0">
|
||||
<p className="text-sm text-neutral-900 break-words">
|
||||
{adminService.formatActivityMessage(activity)}
|
||||
{getActivityMessage()}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{activity.actorName}</p>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
@@ -261,14 +295,6 @@ export const AdminDashboard: React.FC = () => {
|
||||
)}
|
||||
</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>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -270,21 +270,6 @@ export const BrandingPage: React.FC = () => {
|
||||
</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>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
@@ -330,6 +315,21 @@ export const BrandingPage: React.FC = () => {
|
||||
</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 */}
|
||||
{brandingSettings.watermark_enabled && (
|
||||
<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"
|
||||
value={brandingSettings.watermark_opacity || 50}
|
||||
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">
|
||||
<span>10%</span>
|
||||
@@ -443,7 +451,15 @@ export const BrandingPage: React.FC = () => {
|
||||
step="5"
|
||||
value={brandingSettings.watermark_size || 15}
|
||||
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">
|
||||
<span>5%</span>
|
||||
|
||||
@@ -235,10 +235,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
};
|
||||
|
||||
const handlePresetChange = (presetName: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
theme_preset: presetName
|
||||
}));
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
theme_preset: presetName,
|
||||
theme_config: preset.config
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -383,7 +387,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
onChange={handleThemeChange}
|
||||
presetName={formData.theme_preset}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={false}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
/>
|
||||
|
||||
@@ -15,16 +15,14 @@ import {
|
||||
CheckCircle,
|
||||
Upload,
|
||||
Image,
|
||||
Key,
|
||||
Palette,
|
||||
Settings
|
||||
Key
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
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 { eventsService } from '../../services/events.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 [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
|
||||
|
||||
// Photo filters state
|
||||
const [photoFilters, setPhotoFilters] = useState({
|
||||
@@ -203,7 +199,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const handleSaveEdit = () => {
|
||||
// Prepare color_theme - if we have a custom theme, serialize it
|
||||
let themeToSave = editForm.color_theme;
|
||||
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
|
||||
if (currentTheme && currentPresetName === 'custom') {
|
||||
themeToSave = JSON.stringify(currentTheme);
|
||||
} else if (currentPresetName && currentPresetName !== 'custom') {
|
||||
// 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 (
|
||||
<div>
|
||||
@@ -492,73 +460,6 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
</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 */}
|
||||
<HeroPhotoSelector
|
||||
photos={photos || []}
|
||||
@@ -822,28 +723,44 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Theme */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
|
||||
{!event.is_archived && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={() => setShowThemeEditorModal(true)}
|
||||
>
|
||||
{t('events.customizeTheme')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<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>
|
||||
{/* Theme & Style */}
|
||||
{isEditing && !event.is_archived && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.themeAndStyle')}</h2>
|
||||
<ThemeCustomizerEnhanced
|
||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||
onChange={(theme) => {
|
||||
setCurrentTheme(theme);
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
|
||||
}}
|
||||
presetName={currentPresetName}
|
||||
onPresetChange={(presetName) => {
|
||||
setCurrentPresetName(presetName);
|
||||
if (presetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setCurrentTheme(preset.config);
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
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 */}
|
||||
{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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { AdminLoginPage } from './AdminLoginPage';
|
||||
export { AdminDashboard } from './AdminDashboard';
|
||||
export { EventsListPage } from './EventsListPage';
|
||||
export { CreateEventPageEnhanced as CreateEventPage } from './CreateEventPageEnhanced';
|
||||
export { CreateEventPageEnhanced } from './CreateEventPageEnhanced';
|
||||
export { EventDetailsPage } from './EventDetailsPage';
|
||||
export { EmailConfigPage } from './EmailConfigPage';
|
||||
export { ArchivesPage } from './ArchivesPage';
|
||||
|
||||
@@ -46,7 +46,7 @@ export const LegalPage: React.FC = () => {
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
if (page?.title) {
|
||||
document.title = `${page.title} - Wedding Photo Sharing`;
|
||||
document.title = `${page.title} - PicPeak`;
|
||||
}
|
||||
}, [page?.title]);
|
||||
|
||||
@@ -129,7 +129,7 @@ export const LegalPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-4">
|
||||
© 2024 Wedding Photo Sharing. All rights reserved.
|
||||
© 2024 PicPeak. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -9,6 +9,29 @@ export interface DashboardStats {
|
||||
totalDownloads: number;
|
||||
viewsTrend: 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 {
|
||||
@@ -63,6 +86,12 @@ export const adminService = {
|
||||
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
|
||||
formatActivityMessage(activity: Activity): 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,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 853 KiB |
Reference in New Issue
Block a user