diff --git a/.drone.yml b/.drone.yml index 75d350e..81f4508 100644 --- a/.drone.yml +++ b/.drone.yml @@ -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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8787bbd --- /dev/null +++ b/.github/workflows/release.yml @@ -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<> $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 \ No newline at end of file diff --git a/.github/workflows/version-bump.yml b/.github/workflows/version-bump.yml new file mode 100644 index 0000000..9f5aab1 --- /dev/null +++ b/.github/workflows/version-bump.yml @@ -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 \ No newline at end of file diff --git a/README-LOCAL.md b/README-LOCAL.md index 863e3c6..ff842cc 100644 --- a/README-LOCAL.md +++ b/README-LOCAL.md @@ -12,7 +12,7 @@ Get the photo sharing platform running locally in under 2 minutes! ```bash # 1. Clone the repository git clone -cd wedding-photo-sharing +cd picpeak # 2. Start everything ./start-local.sh diff --git a/SETUP_GUIDE.md b/SETUP_GUIDE.md index 16988ff..3bead60 100644 --- a/SETUP_GUIDE.md +++ b/SETUP_GUIDE.md @@ -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 diff --git a/backend/ecosystem.config.js b/backend/ecosystem.config.js index cbe6852..e95827e 100644 --- a/backend/ecosystem.config.js +++ b/backend/ecosystem.config.js @@ -1,6 +1,6 @@ module.exports = { apps: [{ - name: 'photo-sharing', + name: 'picpeak', script: './server.js', instances: 'max', exec_mode: 'cluster', diff --git a/backend/migrations/014_add_default_welcome_message.js b/backend/migrations/014_add_default_welcome_message.js new file mode 100644 index 0000000..37e4bc2 --- /dev/null +++ b/backend/migrations/014_add_default_welcome_message.js @@ -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: `

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:

+ +

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`, + body_html_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:

+ +

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.

+

Galerie anzeigen

`, + 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: `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created!

+

Gallery Details:

+ +

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`, + body_html_de: `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt!

+

Galerie-Details:

+ +

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.

+

Galerie anzeigen

` + }); +}; \ No newline at end of file diff --git a/backend/package-lock.json b/backend/package-lock.json index 32bc900..0143f6e 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -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": { diff --git a/backend/package.json b/backend/package.json index 4339757..1b7d9ca 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } } diff --git a/backend/src/routes/adminDashboard.js b/backend/src/routes/adminDashboard.js index d4dd87e..e67a505 100644 --- a/backend/src/routes/adminDashboard.js +++ b/backend/src/routes/adminDashboard.js @@ -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 { diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 5a83d4b..b63af97 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -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 }); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js index 788bb99..7a71857 100644 --- a/backend/src/routes/events.js +++ b/backend/src/routes/events.js @@ -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({ diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index ff91ac5..45376a4 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -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 { diff --git a/backend/src/services/emailProcessor.js b/backend/src/services/emailProcessor.js index b8df520..2af51ea 100644 --- a/backend/src/services/emailProcessor.js +++ b/backend/src/services/emailProcessor.js @@ -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 = ` +
+

${welcomeTitle}

+

${variables.welcome_message}

+
`; + } + // 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 = ` diff --git a/backend/update_email_template.js b/backend/update_email_template.js new file mode 100644 index 0000000..6441681 --- /dev/null +++ b/backend/update_email_template.js @@ -0,0 +1,48 @@ +const { db } = require('./src/database/db'); + +async function updateTemplate() { + try { + const englishBody = `

Gallery Successfully Created

+

Dear {{host_name}},

+

Your photo gallery "{{event_name}}" has been successfully created\!

+{{welcome_message_section}} +

Gallery Details:

+ +

Share this link and password with your guests so they can view and download photos.

+

View Gallery

`; + + const germanBody = `

Galerie erfolgreich erstellt

+

Liebe(r) {{host_name}},

+

Ihre Fotogalerie "{{event_name}}" wurde erfolgreich erstellt\!

+{{welcome_message_section}} +

Galerie-Details:

+ +

Teilen Sie diesen Link und das Passwort mit Ihren Gästen, damit sie Fotos ansehen und herunterladen können.

+

Galerie anzeigen

`; + + 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(); diff --git a/complete-setup.sh b/complete-setup.sh index 47b5ba7..57a2a77 100644 --- a/complete-setup.sh +++ b/complete-setup.sh @@ -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 "" diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh index f47d81b..799edfb 100755 --- a/deploy/scripts/deploy.sh +++ b/deploy/scripts/deploy.sh @@ -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 ;; *) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index da632e7..7af325f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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: diff --git a/frontend/package.json b/frontend/package.json index 8b5149d..b78cb9d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,5 +1,5 @@ { - "name": "photo-sharing-frontend", + "name": "picpeak-frontend", "private": true, "version": "1.0.0", "type": "module", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 810f500..f778c39 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -14,7 +14,7 @@ import { AdminLoginPage, AdminDashboard, EventsListPage, - CreateEventPage, + CreateEventPageEnhanced as CreateEventPage, EventDetailsPage, EmailConfigPage, ArchivesPage, diff --git a/frontend/src/components/admin/AdminHeader.tsx b/frontend/src/components/admin/AdminHeader.tsx index c919592..7f95d76 100644 --- a/frontend/src/components/admin/AdminHeader.tsx +++ b/frontend/src/components/admin/AdminHeader.tsx @@ -88,8 +88,9 @@ export const AdminHeader: React.FC = ({ onMenuClick }) => { - {/* Center - PicPeak branding */} -
+ {/* Center - Logo and PicPeak text */} +
+ PicPeak PicPeak
diff --git a/frontend/src/components/admin/AdminSidebar.tsx b/frontend/src/components/admin/AdminSidebar.tsx index 560eff4..4b1f136 100644 --- a/frontend/src/components/admin/AdminSidebar.tsx +++ b/frontend/src/components/admin/AdminSidebar.tsx @@ -49,11 +49,10 @@ export const AdminSidebar: React.FC = ({ isOpen, onClose }) = }`} >
- {/* Logo/Brand */} + {/* Brand */}
- Camera - {t('admin.title')} + {t('admin.title')}
+ ) : undefined} headerExtra={(() => { const items = []; - if (showSidebar) { - items.push( - - ); - } + 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 = ({ 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(
{/* Upload Modal */} - {showUploadModal && ( + {showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && ( { setShowUploadModal(false); // Refetch photos after upload diff --git a/frontend/src/components/gallery/UserPhotoUpload.tsx b/frontend/src/components/gallery/UserPhotoUpload.tsx index 4ad704d..e9dfbe6 100644 --- a/frontend/src/components/gallery/UserPhotoUpload.tsx +++ b/frontend/src/components/gallery/UserPhotoUpload.tsx @@ -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 = ({ return (
-
+
{/* Fixed Header */}

{t('upload.uploadPhotos')}

diff --git a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx index f236407..28fa8e6 100644 --- a/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx +++ b/frontend/src/components/gallery/layouts/HeroGalleryLayout.tsx @@ -31,24 +31,49 @@ export const HeroGalleryLayout: React.FC = ({ const { format } = useLocalizedDate(); const { theme } = useTheme(); const [heroPhoto, setHeroPhoto] = useState(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 ( -
+
{/* Hero Section */}
= ({ {/* Hero Content */}
- {/* Logo */} - {eventLogo && ( -
- Event logo -
- )} + {/* Logo - Show custom logo or fallback to PicPeak logo */} +
+ Event logo +
{/* Event Title */} {eventName && ( diff --git a/frontend/src/hooks/useWatermarkSettings.ts b/frontend/src/hooks/useWatermarkSettings.ts new file mode 100644 index 0000000..06528be --- /dev/null +++ b/frontend/src/hooks/useWatermarkSettings.ts @@ -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 }; +} \ No newline at end of file diff --git a/frontend/src/i18n/locales/de.json b/frontend/src/i18n/locales/de.json index 26f76e2..75965d6 100644 --- a/frontend/src/i18n/locales/de.json +++ b/frontend/src/i18n/locales/de.json @@ -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", diff --git a/frontend/src/i18n/locales/en.json b/frontend/src/i18n/locales/en.json index 466388b..3dc0e2a 100644 --- a/frontend/src/i18n/locales/en.json +++ b/frontend/src/i18n/locales/en.json @@ -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", diff --git a/frontend/src/index.css b/frontend/src/index.css index 077ba35..ede5f5b 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -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; } } diff --git a/frontend/src/pages/admin/AdminDashboard.tsx b/frontend/src/pages/admin/AdminDashboard.tsx index b40433f..209423a 100644 --- a/frontend/src/pages/admin/AdminDashboard.tsx +++ b/frontend/src/pages/admin/AdminDashboard.tsx @@ -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 (
{/* 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 = { + 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 (

- {adminService.formatActivityMessage(activity)} + {getActivityMessage()}

{activity.actorName}

@@ -261,14 +295,6 @@ export const AdminDashboard: React.FC = () => { )}

- {recentActivity && recentActivity.length > 5 && ( - - )}
diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index 5418a8c..1b0a03a 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -270,21 +270,6 @@ export const BrandingPage: React.FC = () => {
-
- -
-
+
+ +
+ {/* Watermark Settings */} {brandingSettings.watermark_enabled && (
@@ -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' + }} />
10% @@ -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' + }} />
5% diff --git a/frontend/src/pages/admin/CreateEventPageEnhanced.tsx b/frontend/src/pages/admin/CreateEventPageEnhanced.tsx index 463538a..3b45d17 100644 --- a/frontend/src/pages/admin/CreateEventPageEnhanced.tsx +++ b/frontend/src/pages/admin/CreateEventPageEnhanced.tsx @@ -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} /> diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 8eabbd6..e399025 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -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(null); const [currentPresetName, setCurrentPresetName] = useState('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 (
@@ -492,73 +460,6 @@ export const EventDetailsPage: React.FC = () => { />
-
- - {!showThemeCustomizer ? ( -
- - -
- ) : ( -
-
- {t('branding.customizingTheme')} - -
- { - setCurrentPresetName(presetName); - if (presetName !== 'custom') { - setEditForm(prev => ({ ...prev, color_theme: presetName })); - } - }} - isPreviewMode={false} - showGalleryLayouts={true} - /> -
- )} -
- {/* Hero Photo Selection */} {
- {/* Gallery Theme */} - -
-

{t('events.galleryTheme')}

- {!event.is_archived && ( - - )} -
- - -
+ {/* Theme & Style */} + {isEditing && !event.is_archived && ( + +

{t('branding.themeAndStyle')}

+ { + 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} + /> +
+ )} + + {/* Theme Display (when not editing) */} + {!isEditing && !event.is_archived && ( + +

{t('events.galleryTheme')}

+ +
+ )} {/* Archive Status */} {event.is_archived ? ( @@ -995,16 +912,6 @@ export const EventDetailsPage: React.FC = () => { /> )} - {/* Theme Editor Modal */} - {showThemeEditorModal && ( - setShowThemeEditorModal(false)} - onSave={handleThemeModalSave} - currentTheme={event.color_theme || 'default'} - eventName={event.event_name} - /> - )}
); }; diff --git a/frontend/src/pages/admin/index.ts b/frontend/src/pages/admin/index.ts index 9c82e4a..761e205 100644 --- a/frontend/src/pages/admin/index.ts +++ b/frontend/src/pages/admin/index.ts @@ -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'; diff --git a/frontend/src/pages/public/LegalPage.tsx b/frontend/src/pages/public/LegalPage.tsx index 59068ff..0e4c5bf 100644 --- a/frontend/src/pages/public/LegalPage.tsx +++ b/frontend/src/pages/public/LegalPage.tsx @@ -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 = () => {

- © 2024 Wedding Photo Sharing. All rights reserved. + © 2024 PicPeak. All rights reserved.

diff --git a/frontend/src/services/admin.service.ts b/frontend/src/services/admin.service.ts index b8b2935..9fe4910 100644 --- a/frontend/src/services/admin.service.ts +++ b/frontend/src/services/admin.service.ts @@ -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 { + const response = await api.get('/api/admin/dashboard/health'); + return response.data; + }, + // Format activity message formatActivityMessage(activity: Activity): string { const messages: Record = { diff --git a/frontend/src/utils/photoUrl.ts b/frontend/src/utils/photoUrl.ts new file mode 100644 index 0000000..01aba71 --- /dev/null +++ b/frontend/src/utils/photoUrl.ts @@ -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}`; +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index eed44d3..ddc5389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "wedding-photo-sharing", + "name": "picpeak", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/storage/uploads/logos/logo-1752236747132.png b/storage/uploads/logos/logo-1752236747132.png new file mode 100644 index 0000000..ed816b3 Binary files /dev/null and b/storage/uploads/logos/logo-1752236747132.png differ