refactor: rename project from wedding-photo-sharing to PicPeak
Create Release / check-version-change (push) Successful in 2m25s
Automatic Version Bump / version-bump (push) Failing after 8m2s
Create Release / create-release (push) Has been skipped

- 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:
2025-07-12 09:22:14 +02:00
parent d065132bb7
commit 288b0c25e6
42 changed files with 1116 additions and 422 deletions
+4 -4
View File
@@ -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
+108
View File
@@ -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
+107
View File
@@ -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
View File
@@ -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
View File
@@ -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 -1
View File
@@ -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>`
});
};
+28 -89
View File
@@ -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": {
+4 -4
View File
@@ -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"
}
}
+77 -1
View File
@@ -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 {
+5 -4
View File
@@ -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
});
+5 -4
View File
@@ -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({
+38
View File
@@ -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 {
+14
View File
@@ -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>
+48
View File
@@ -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
View File
@@ -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 ""
+3 -3
View File
@@ -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
;;
*)
+9 -9
View File
@@ -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 -1
View File
@@ -1,5 +1,5 @@
{
"name": "photo-sharing-frontend",
"name": "picpeak-frontend",
"private": true,
"version": "1.0.0",
"type": "module",
+1 -1
View File
@@ -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" />
+59 -32
View File
@@ -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 };
}
+43 -2
View File
@@ -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",
+40 -1
View File
@@ -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
View File
@@ -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;
}
}
+57 -31
View File
@@ -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>
+33 -17
View File
@@ -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}
/>
+41 -134
View File
@@ -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 -1
View File
@@ -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';
+2 -2
View File
@@ -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>
+29
View File
@@ -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> = {
+28
View File
@@ -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}`;
}
+1 -1
View File
@@ -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