Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f0cdcddb92 | |||
| 0751a08aa6 | |||
| d64eef8abf | |||
| 109aba8598 | |||
| b9283386a5 | |||
| 5da1c3a12f | |||
| 93301002ba | |||
| f8ba669716 | |||
| 00fff24a1c | |||
| 7eb6357b4a | |||
| aab9e1a937 | |||
| ffd4a7eee6 | |||
| 1476884dd0 | |||
| e3d597b89a | |||
| ea9caa9c5d | |||
| d51112e761 | |||
| a4b4485d32 | |||
| 8d0a946478 | |||
| 4698402b54 | |||
| ed0fa3241b | |||
| 54676424f2 | |||
| b41cb1586d | |||
| 1f3bc3c343 | |||
| 39db7bf6cb | |||
| aeade94a35 | |||
| b768a53c5b | |||
| 1f19fbb1b2 | |||
| 03ded870bf | |||
| df5aeaba41 | |||
| 279e0472c7 | |||
| 2ee4146d9a | |||
| 5dea0c9695 | |||
| d3d7df46f2 | |||
| 784d059c3d | |||
| dbe4b588eb | |||
| 274ef0cd73 | |||
| 65ac6eddac | |||
| be710eb1de | |||
| 58a86af868 | |||
| 1250306d11 | |||
| 80503c52b9 |
@@ -95,6 +95,15 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
@@ -233,6 +242,15 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -266,11 +284,24 @@ jobs:
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Backend
|
||||
id: meta-backend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
# GHCR always; Docker Hub (picpeak/backend) added on the canonical repo so
|
||||
# the same tag scheme is mirrored to both registries. metadata-action drops
|
||||
# the blank second line on forks → GHCR-only there.
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/backend' || '' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Backend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform backend service
|
||||
@@ -282,6 +313,10 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
|
||||
# so users can pin the same string as the GitHub release. metadata-action's
|
||||
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
@@ -298,10 +333,15 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest
|
||||
- name: Inspect manifest (GHCR)
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
- name: Inspect manifest (Docker Hub)
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect docker.io/picpeak/backend:${{ steps.meta-backend.outputs.version }}
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Frontend: per-arch build, then merge into a multi-arch manifest
|
||||
# -----------------------------------------------------------------------------
|
||||
@@ -331,6 +371,15 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Prepare platform pair
|
||||
run: |
|
||||
@@ -450,6 +499,15 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Download digest artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
@@ -483,11 +541,24 @@ jobs:
|
||||
echo "is_prerelease=false" >> $GITHUB_OUTPUT
|
||||
fi
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: docker.io
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata for Frontend
|
||||
id: meta-frontend
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
# GHCR always; Docker Hub (picpeak/frontend) added on the canonical repo so
|
||||
# the same tag scheme is mirrored to both registries. metadata-action drops
|
||||
# the blank second line on forks → GHCR-only there.
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}
|
||||
${{ env.DOCKERHUB_ENABLED == 'true' && 'docker.io/picpeak/frontend' || '' }}
|
||||
labels: |
|
||||
org.opencontainers.image.title=PicPeak Frontend
|
||||
org.opencontainers.image.description=PicPeak photo sharing platform frontend application
|
||||
@@ -499,6 +570,10 @@ jobs:
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
type=semver,pattern={{major}},enable=${{ steps.context.outputs.is_prerelease == 'false' }}
|
||||
# #668/#783: publish the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0)
|
||||
# so users can pin the same string as the GitHub release. metadata-action's
|
||||
# `pattern=v{{version}}` silently dropped it on prereleases, so use type=ref.
|
||||
type=ref,event=tag
|
||||
type=sha,format=short
|
||||
# `:latest` + `:stable` follow the stable channel (the `stable` branch +
|
||||
# stable release tags). The default branch is now `main` (active dev),
|
||||
@@ -515,10 +590,15 @@ jobs:
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf "${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}@sha256:%s " *)
|
||||
|
||||
- name: Inspect manifest
|
||||
- name: Inspect manifest (GHCR)
|
||||
run: |
|
||||
docker buildx imagetools inspect ${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
- name: Inspect manifest (Docker Hub)
|
||||
if: env.DOCKERHUB_ENABLED == 'true'
|
||||
run: |
|
||||
docker buildx imagetools inspect docker.io/picpeak/frontend:${{ steps.meta-frontend.outputs.version }}
|
||||
|
||||
summary:
|
||||
needs: [build-backend, merge-backend, build-frontend, merge-frontend]
|
||||
if: always()
|
||||
@@ -532,6 +612,15 @@ jobs:
|
||||
repo_lc="${GITHUB_REPOSITORY,,}"
|
||||
echo "BACKEND_IMAGE_NAME=${repo_lc}/backend" >> "$GITHUB_ENV"
|
||||
echo "FRONTEND_IMAGE_NAME=${repo_lc}/frontend" >> "$GITHUB_ENV"
|
||||
# Mirror manifests to Docker Hub (picpeak/{backend,frontend}) only on the
|
||||
# canonical org repo, where the DOCKERHUB_* secrets live. Forks (and any
|
||||
# other owner) fall back to GHCR-only — the Docker Hub image line and login
|
||||
# are gated on this flag so their builds keep working unchanged.
|
||||
if [[ "$GITHUB_REPOSITORY" == "PicPeak/picpeak" ]]; then
|
||||
echo "DOCKERHUB_ENABLED=true" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "DOCKERHUB_ENABLED=false" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Build Summary
|
||||
run: |
|
||||
@@ -570,6 +659,10 @@ jobs:
|
||||
echo "### 📦 Images" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Backend: \`${{ env.REGISTRY }}/${{ env.BACKEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend: \`${{ env.REGISTRY }}/${{ env.FRONTEND_IMAGE_NAME }}\`" >> $GITHUB_STEP_SUMMARY
|
||||
if [[ "$DOCKERHUB_ENABLED" == "true" ]]; then
|
||||
echo "- Backend (Docker Hub): \`docker.io/picpeak/backend\`" >> $GITHUB_STEP_SUMMARY
|
||||
echo "- Frontend (Docker Hub): \`docker.io/picpeak/frontend\`" >> $GITHUB_STEP_SUMMARY
|
||||
fi
|
||||
|
||||
echo "" >> $GITHUB_STEP_SUMMARY
|
||||
echo "### 🏗️ Architectures" >> $GITHUB_STEP_SUMMARY
|
||||
|
||||
@@ -25,6 +25,7 @@ jobs:
|
||||
token: ${{ secrets.RELEASE_PLEASE_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
config-file: release-please-config.json
|
||||
manifest-file: .release-please-manifest.json
|
||||
target-branch: stable
|
||||
|
||||
# Auto-approve + auto-merge the open stable release PR. See the beta
|
||||
# workflow for the full rationale. Skipped on the release-cutting run and
|
||||
|
||||
@@ -17,9 +17,9 @@ name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, beta]
|
||||
branches: [main, beta, stable]
|
||||
pull_request:
|
||||
branches: [main, beta]
|
||||
branches: [main, beta, stable]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
{
|
||||
".": "3.83.0-beta.0"
|
||||
".": "3.88.0-beta.0"
|
||||
}
|
||||
|
||||
@@ -1,3 +1 @@
|
||||
{
|
||||
".": "2.6.1"
|
||||
}
|
||||
{".":"3.44.0"}
|
||||
|
||||
@@ -5,6 +5,87 @@ All notable changes to PicPeak will be documented in this file.
|
||||
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
## [3.88.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.87.0-beta.0...v3.88.0-beta.0) (2026-07-15)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **setup:** event-types step in first-run wizard + un-hardcode event type dependencies ([109aba8](https://github.com/PicPeak/picpeak/commit/109aba859820bf80440d056baf183ecf2657fee3))
|
||||
* **setup:** event-types step in first-run wizard + un-hardcode event type deps ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([7eb6357](https://github.com/PicPeak/picpeak/commit/7eb6357b4a9bf3914674a63afa386a5fcf8c2161))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **event-types:** harden setup window + catalog validation (codex review) ([f8ba669](https://github.com/PicPeak/picpeak/commit/f8ba6697163b4d9aa0fa0014cb5b0810371c04ae))
|
||||
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([d64eef8](https://github.com/PicPeak/picpeak/commit/d64eef8abf2915230b3cdd38a3bbb8af1a12c6d2))
|
||||
* **event-types:** un-hardcode event type dependencies in v1 API and CRM ([#800](https://github.com/PicPeak/picpeak/issues/800)) ([5da1c3a](https://github.com/PicPeak/picpeak/commit/5da1c3a12f603a230091426b1d7be0eac83da22c))
|
||||
* **gallery:** show feedback filter chips on desktop for galleries without categories ([0751a08](https://github.com/PicPeak/picpeak/commit/0751a08aa661a430c1609cd8c118347291cbaa14))
|
||||
* **gallery:** show feedback filter chips on desktop for galleries without categories ([#802](https://github.com/PicPeak/picpeak/issues/802)) ([b928338](https://github.com/PicPeak/picpeak/commit/b9283386a57431ac8bd395347f9acb9bbdf82e8e))
|
||||
|
||||
## [3.87.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.86.0-beta.0...v3.87.0-beta.0) (2026-07-11)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **invoices:** configurable VAT note under MwSt. line + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([ffd4a7e](https://github.com/PicPeak/picpeak/commit/ffd4a7eee64b6418df1c9cc6843d86dc0f41d2ec))
|
||||
* **invoices:** configurable VAT/free-text note + fix multi-page page-number overlap ([#794](https://github.com/PicPeak/picpeak/issues/794)) ([1476884](https://github.com/PicPeak/picpeak/commit/1476884dd04202f5f18d50d458b6176b0535c71b))
|
||||
|
||||
## [3.86.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.85.0-beta.0...v3.86.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([d51112e](https://github.com/PicPeak/picpeak/commit/d51112e761d2fd83f1939841fbf4c05e625fc34d))
|
||||
* **categories:** per-event category ordering — global default + override ([#782](https://github.com/PicPeak/picpeak/issues/782)) ([4698402](https://github.com/PicPeak/picpeak/commit/4698402b5493cfbdb1e2b6d81c6f58829e17a703))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **categories:** address PR [#790](https://github.com/PicPeak/picpeak/issues/790) review — event ownership, migration renumber, nits ([a4b4485](https://github.com/PicPeak/picpeak/commit/a4b4485d322514690c5400ca7ab9a91bc25c3e48))
|
||||
|
||||
## [3.85.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.1-beta.0...v3.85.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **slideshow:** per-event play order + category filter ([#202](https://github.com/PicPeak/picpeak/issues/202)) ([5467642](https://github.com/PicPeak/picpeak/commit/54676424f2f7ed50e74cb8e144cbdaa5a96e65c3))
|
||||
|
||||
## [3.84.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.84.0-beta.0...v3.84.1-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([1f3bc3c](https://github.com/PicPeak/picpeak/commit/1f3bc3c3430414b5b6cb2141d887a8b5855a04af))
|
||||
* **ci:** publish v-prefixed image tags via type=ref,event=tag ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([39db7bf](https://github.com/PicPeak/picpeak/commit/39db7bf6cb5c39fcdf71c875a4aaf704f34447fa))
|
||||
|
||||
## [3.84.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.1-beta.0...v3.84.0-beta.0) (2026-07-10)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([279e047](https://github.com/PicPeak/picpeak/commit/279e0472c71c6a37ba091a9c7a31f5571c0a8df6))
|
||||
* **admin:** GitHub repo button in the sidebar footer ([#778](https://github.com/PicPeak/picpeak/issues/778)) ([d3d7df4](https://github.com/PicPeak/picpeak/commit/d3d7df46f214028ba89063079d356bc0430083f5))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([2ee4146](https://github.com/PicPeak/picpeak/commit/2ee4146d9a6fd026e7b7be3ba774de9a0cf6e96a))
|
||||
* **ci:** publish v-prefixed image tags so :vX.Y.Z resolves ([#668](https://github.com/PicPeak/picpeak/issues/668)) ([784d059](https://github.com/PicPeak/picpeak/commit/784d059c3da5b36e2b6794ebf2e34bc15c8a9824))
|
||||
|
||||
|
||||
### Documentation
|
||||
|
||||
* **releasing:** align stable version to main on promote (Option A) ([df5aeab](https://github.com/PicPeak/picpeak/commit/df5aeaba416726cc0123f32ddf88e4a30dc28908))
|
||||
* **releasing:** align stable version to main on promote (Option A) ([5dea0c9](https://github.com/PicPeak/picpeak/commit/5dea0c969558f50833973ff742257780f5842612))
|
||||
|
||||
## [3.83.1-beta.0](https://github.com/PicPeak/picpeak/compare/v3.83.0-beta.0...v3.83.1-beta.0) (2026-07-09)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **release:** target stable in release-please + undo bogus 2.7.0 bump ([274ef0c](https://github.com/PicPeak/picpeak/commit/274ef0cd731765b057a5d62d5f41c14cb3a1564b))
|
||||
* **release:** target stable in release-please.yml + undo the bogus 2.7.0 bump ([65ac6ed](https://github.com/PicPeak/picpeak/commit/65ac6eddacb79857e9a9651d3c869e7bfdd92887))
|
||||
|
||||
## [3.83.0-beta.0](https://github.com/PicPeak/picpeak/compare/v3.82.6-beta.0...v3.83.0-beta.0) (2026-07-08)
|
||||
|
||||
|
||||
|
||||
+18
-4
@@ -52,13 +52,19 @@ The actual mechanics, in order:
|
||||
- **`.release-please-manifest.json`** — keep `stable`'s; release-please owns this file.
|
||||
- Any other auto-merged file — spot-check that the auto-merge produced something sensible, especially for security-sensitive files (`backend/src/middleware/`, `backend/src/utils/tokenUtils.js`).
|
||||
|
||||
5. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
|
||||
5. **Pin the stable version to match `main` (number alignment — see Versioning).** Determine `X.Y.Z` = the `main` tip's **base** version (its `vX.Y.Z-beta.N`, dropping the `-beta.N` suffix), and add an empty commit on the release branch:
|
||||
```bash
|
||||
git commit --allow-empty -m "chore: release X.Y.Z" -m "Release-As: X.Y.Z"
|
||||
```
|
||||
The `Release-As:` footer forces release-please to cut exactly `X.Y.Z`. Without it, release-please computes the next MINOR from the *previous stable* tag (e.g. `3.45.0` → `3.46.0`) while `main` is already at `3.84.x`, so the stable number drifts ever further behind for the same code.
|
||||
|
||||
6. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
6. **Wait for CI on the PR.** All ten checks (the original eight plus `merge-backend` and `merge-frontend`) must be green. If anything fails, fix on the release branch (NOT on `main` — `main` has already moved on).
|
||||
|
||||
7. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
|
||||
7. **Merge.** Standard merge commit, not squash — the PR's history (the individual feature commits) carries forward into `stable`'s log.
|
||||
|
||||
8. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
|
||||
8. **release-please picks it up.** Within minutes, release-please will open a new `chore(stable): release X.Y.Z` PR proposing the stable release. Review the auto-generated CHANGELOG.md entries for accuracy, edit if needed, and merge. That merge creates the `vX.Y.Z` git tag, publishes Docker images on the `:stable` and `:latest` tags, and creates the GitHub Release page.
|
||||
|
||||
9. **Close the loop.** Bulk-close any `bug` issues that were fixed-but-not-closed and now appear in the released changelog. Reference the merge commit so reporters know which version contains the fix.
|
||||
|
||||
## Hotfix path (backport to current stable)
|
||||
|
||||
@@ -83,6 +89,14 @@ PicPeak follows [Semantic Versioning](https://semver.org/) with one project-spec
|
||||
|
||||
release-please derives all of this from conventional commit prefixes (`feat:`, `fix:`, `BREAKING CHANGE:`, etc.) automatically.
|
||||
|
||||
### Stable ↔ pre-release number alignment
|
||||
|
||||
The two channels run **independent** release-please counters: `main` bumps on every merge (racing ahead), while `stable` only bumps on a promotion. Left to itself, `stable` computes each promotion as the next MINOR from the *previous stable tag*, so the two drift far apart — e.g. `main` at `v3.83.x-beta.0` while `stable` sat at `v3.45.0` for the **same code**, which reads as "stable is 38 versions behind" when it isn't.
|
||||
|
||||
To keep the numbers legible, **a promotion sets the stable version to the current `main` base version** (the `X.Y.Z` of the `main` tip's `vX.Y.Z-beta.N`, minus the suffix). Promoting a `main` at `v3.84.2-beta.0` therefore cuts stable `v3.84.2`, and the stable number tracks `main` instead of lagging. This is forced with the `Release-As:` commit in step 5 of the cut procedure — the one-time catch-up jump (e.g. `3.45.0 → 3.84.x`) is expected and happens only on the first aligned promotion.
|
||||
|
||||
> **Release-engineering note (2026-07):** `release-please.yml` (the stable workflow) *must* keep `target-branch: stable`. Without it, release-please defaults to the repo's default branch (`main`), reads `main`'s stale `.release-please-manifest.json`, and cuts a wrong/regressed version — this is what produced a bogus `v2.7.0` once. If a promotion ever yields an unexpected version, check that first.
|
||||
|
||||
## Things that don't go through this process
|
||||
|
||||
- **Documentation-only changes** can land on either `stable` or `main` directly (no release cut needed); release-please will pick them up on the next regular release.
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
/**
|
||||
* Layered per-event category ordering (#782).
|
||||
*
|
||||
* Two ordering layers, resolved per event:
|
||||
* - GLOBAL default — photo_categories.display_order (migration 159),
|
||||
* set via POST /reorder-global; applies everywhere.
|
||||
* - PER-EVENT override — event_category_order (migration 160), set via
|
||||
* POST /reorder; overrides the default for one gallery.
|
||||
* - DELETE /reorder/:eventId clears an event's override.
|
||||
*
|
||||
* Verified against a real SQLite DB with the full core-migration set applied.
|
||||
*/
|
||||
const request = require('supertest');
|
||||
const {
|
||||
bootCrmDb, seedMinimal, assignAdminRole, mintAdminToken, buildRouteApp,
|
||||
} = require('./helpers/crmDb');
|
||||
|
||||
jest.setTimeout(30000);
|
||||
|
||||
describe('category ordering (#782)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let token;
|
||||
let app;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
const { adminId } = await seedMinimal(db);
|
||||
await assignAdminRole(db, adminId, 'super_admin');
|
||||
token = mintAdminToken(adminId);
|
||||
app = buildRouteApp('/api/admin/categories', require('../../src/routes/adminCategories'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => { if (cleanup) await cleanup(); });
|
||||
|
||||
const auth = (r) => r.set('Authorization', `Bearer ${token}`);
|
||||
|
||||
async function insertEvent(slug) {
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug, share_link: slug,
|
||||
event_name: slug, event_date: '2026-01-01',
|
||||
});
|
||||
return (await db('events').where({ slug }).first()).id;
|
||||
}
|
||||
|
||||
async function insertCat(name, { is_global = false, event_id = null, display_order = 0 } = {}) {
|
||||
const res = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: name.toLowerCase().replace(/\s+/g, '-'),
|
||||
is_global: is_global ? 1 : 0,
|
||||
event_id,
|
||||
display_order,
|
||||
}).returning('id');
|
||||
return res[0]?.id ?? res[0];
|
||||
}
|
||||
|
||||
const getEvent = (eventId) => auth(request(app).get(`/api/admin/categories/event/${eventId}`)).expect(200);
|
||||
|
||||
describe('migration 159 backfill', () => {
|
||||
it('seeds display_order from alphabetical order, scoped per event', async () => {
|
||||
const eventId = await insertEvent('backfill-ev');
|
||||
await insertCat('Reception', { event_id: eventId });
|
||||
await insertCat('Ceremony', { event_id: eventId });
|
||||
await insertCat('Pre-Ceremony', { event_id: eventId });
|
||||
|
||||
// Re-run the migration: addColumn is guarded (no-op); the backfill loop
|
||||
// re-runs and assigns per-scope alphabetical order — what an upgrade does.
|
||||
await require('../../migrations/core/159_add_category_display_order').up(db);
|
||||
|
||||
const evCats = await db('photo_categories').where({ event_id: eventId }).orderBy('display_order', 'asc');
|
||||
expect(evCats.map((c) => c.name)).toEqual(['Ceremony', 'Pre-Ceremony', 'Reception']);
|
||||
expect(evCats.map((c) => c.display_order)).toEqual([1, 2, 3]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('global default order (POST /reorder-global)', () => {
|
||||
it('reverses the global order and every non-customised event follows it', async () => {
|
||||
const before = (await auth(request(app).get('/api/admin/categories/global')).expect(200)).body;
|
||||
expect(before.length).toBeGreaterThan(1);
|
||||
const reversedIds = before.map((c) => c.id).reverse();
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder-global'))
|
||||
.send({ orderedIds: reversedIds })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(reversedIds);
|
||||
|
||||
// A fresh event (no override) shows globals in the new global order.
|
||||
const eventId = await insertEvent('follows-global');
|
||||
const globalsInEvent = (await getEvent(eventId)).body.filter((c) => c.is_global).map((c) => c.id);
|
||||
expect(globalsInEvent).toEqual(reversedIds);
|
||||
});
|
||||
});
|
||||
|
||||
describe('per-event override (POST /reorder)', () => {
|
||||
it('pins a custom order for one event without affecting another', async () => {
|
||||
const eventA = await insertEvent('override-a');
|
||||
const eventB = await insertEvent('override-b');
|
||||
const a1 = await insertCat('A-Ceremony', { event_id: eventA });
|
||||
const a2 = await insertCat('A-Reception', { event_id: eventA });
|
||||
|
||||
// Current resolved list for A (globals + A's two categories).
|
||||
const listA = (await getEvent(eventA)).body;
|
||||
// Put A-Reception first, then A-Ceremony, then the globals in their order.
|
||||
const globalsA = listA.filter((c) => c.is_global).map((c) => c.id);
|
||||
const desired = [a2, a1, ...globalsA];
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventA, orderedIds: desired })
|
||||
.expect(200);
|
||||
expect(res.body.map((c) => c.id)).toEqual(desired);
|
||||
// override_position is set on every row for a customised event.
|
||||
expect(res.body.every((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
// Event B is untouched — no override, follows the global default.
|
||||
const listB = (await getEvent(eventB)).body;
|
||||
expect(listB.every((c) => c.override_position == null)).toBe(true);
|
||||
});
|
||||
|
||||
it('accepts global ids but rejects another event’s category', async () => {
|
||||
const eventId = await insertEvent('scope-ev');
|
||||
const own = await insertCat('Own', { event_id: eventId });
|
||||
const global = (await db('photo_categories').where('is_global', 1).first()).id;
|
||||
const foreign = await insertCat('Foreign', { event_id: await insertEvent('other-ev') });
|
||||
|
||||
// A global id is allowed (globals can be arranged per event).
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, global] })
|
||||
.expect(200);
|
||||
|
||||
// A foreign event's category is out of scope.
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [own, foreign] })
|
||||
.expect(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reset (DELETE /reorder/:eventId)', () => {
|
||||
it('clears the override and reverts to the global default', async () => {
|
||||
const eventId = await insertEvent('reset-ev');
|
||||
const c1 = await insertCat('R-One', { event_id: eventId });
|
||||
const list = (await getEvent(eventId)).body;
|
||||
const globals = list.filter((c) => c.is_global).map((c) => c.id);
|
||||
|
||||
await auth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: eventId, orderedIds: [c1, ...globals] })
|
||||
.expect(200);
|
||||
expect((await getEvent(eventId)).body.some((c) => c.override_position != null)).toBe(true);
|
||||
|
||||
const res = await auth(request(app).delete(`/api/admin/categories/reorder/${eventId}`)).expect(200);
|
||||
expect(res.body.every((c) => c.override_position == null)).toBe(true);
|
||||
expect(await db('event_category_order').where({ event_id: eventId }).first()).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('event ownership (PR #790 review)', () => {
|
||||
let limitedToken;
|
||||
let foreignEventId;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = require('bcrypt');
|
||||
// A non-super_admin role that DOES hold settings.view + settings.edit —
|
||||
// the exact case the review flagged (settings.edit is grantable).
|
||||
const roleRes = await db('roles').insert({ name: 'gallery-mgr', display_name: 'Gallery Mgr' }).returning('id');
|
||||
const roleId = roleRes[0]?.id ?? roleRes[0];
|
||||
const permIds = await db('permissions').whereIn('name', ['settings.view', 'settings.edit']).pluck('id');
|
||||
await db('role_permissions').insert(permIds.map((permission_id) => ({ role_id: roleId, permission_id })));
|
||||
|
||||
const a2 = await db('admin_users').insert({
|
||||
username: 'limited', email: 'limited@example.com',
|
||||
password_hash: await bcrypt.hash('x', 4), role_id: roleId,
|
||||
must_change_password: false, created_at: new Date(),
|
||||
}).returning('id');
|
||||
limitedToken = mintAdminToken(a2[0]?.id ?? a2[0]);
|
||||
|
||||
// An event owned by a DIFFERENT admin (the seeded super_admin).
|
||||
const owner = (await db('admin_users').where({ username: 'tester' }).first()).id;
|
||||
await db('events').insert({
|
||||
event_type: 'wedding', password_hash: 'x',
|
||||
expires_at: new Date(Date.now() + 9e9).toISOString(),
|
||||
is_active: true, is_archived: false, slug: 'owned-ev', share_link: 'owned-ev',
|
||||
event_name: 'Owned', event_date: '2026-01-01', created_by: owner,
|
||||
});
|
||||
foreignEventId = (await db('events').where({ slug: 'owned-ev' }).first()).id;
|
||||
});
|
||||
|
||||
const limitedAuth = (r) => r.set('Authorization', `Bearer ${limitedToken}`);
|
||||
|
||||
it('blocks a non-owner from reading, reordering or resetting another event', async () => {
|
||||
await limitedAuth(request(app).get(`/api/admin/categories/event/${foreignEventId}`)).expect(403);
|
||||
await limitedAuth(request(app).post('/api/admin/categories/reorder'))
|
||||
.send({ event_id: foreignEventId, orderedIds: [1] }).expect(403);
|
||||
await limitedAuth(request(app).delete(`/api/admin/categories/reorder/${foreignEventId}`)).expect(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST / (create) appends to the end of its scope', () => {
|
||||
it('assigns display_order = max + 1 within the event', async () => {
|
||||
const eventId = await insertEvent('append-ev');
|
||||
await insertCat('First', { event_id: eventId, display_order: 1 });
|
||||
await insertCat('Second', { event_id: eventId, display_order: 2 });
|
||||
|
||||
const res = await auth(request(app).post('/api/admin/categories'))
|
||||
.send({ name: 'Third', is_global: false, event_id: eventId })
|
||||
.expect(200);
|
||||
|
||||
expect(res.body.display_order).toBe(3);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
/**
|
||||
* Catalog-driven event-type defaults (#800 follow-up).
|
||||
*
|
||||
* The contract→event conversion used to hardcode `event_type: 'wedding'` and
|
||||
* the v1 API validated against a fixed whitelist. Both now follow the live
|
||||
* event_types catalog; these tests pin the shared resolver.
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('resolveDefaultEventType follows the catalog', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so the service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it("prefers the 'other' catch-all while it is active", async () => {
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
});
|
||||
|
||||
it('falls over to the first active type when other is deactivated', async () => {
|
||||
const other = await db('event_types').where({ slug_prefix: 'other' }).first();
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 0 });
|
||||
|
||||
const resolved = await eventTypeService.resolveDefaultEventType();
|
||||
expect(resolved).not.toBe('other');
|
||||
expect(await db('event_types').where({ slug_prefix: resolved }).first()).toBeTruthy();
|
||||
|
||||
await db('event_types').where({ id: other.id }).update({ is_active: 1 });
|
||||
});
|
||||
|
||||
it("returns the literal 'other' only for an empty catalog", async () => {
|
||||
const rows = await db('event_types').select('*');
|
||||
await db('event_types').del();
|
||||
|
||||
expect(await eventTypeService.resolveDefaultEventType()).toBe('other');
|
||||
|
||||
await db('event_types').insert(rows);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Setup-window event type deletion (#800).
|
||||
*
|
||||
* The first-run setup wizard may delete the seeded SYSTEM event types —
|
||||
* but ONLY while the `setup_wizard_completed` flag is unset (migration 161
|
||||
* seeds it false on a fresh install, true when an admin already exists).
|
||||
* These tests pin the whole contract:
|
||||
*
|
||||
* - fresh install → flag false → system types deletable (in-use checks
|
||||
* still apply), and the per-type reminder template goes with the type
|
||||
* - reminder-template self-heal does NOT resurrect templates for slugs
|
||||
* that no longer exist in the catalog
|
||||
* - after markSetupWizardCompleted() → system deletion is refused again
|
||||
*/
|
||||
|
||||
const { bootCrmDb } = require('./helpers/crmDb');
|
||||
|
||||
describe('event type deletion during the setup window (#800)', () => {
|
||||
let db;
|
||||
let cleanup;
|
||||
let eventTypeService;
|
||||
let setupService;
|
||||
let ensureEventReminderTemplatesSeeded;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ db, cleanup } = await bootCrmDb());
|
||||
// Require AFTER bootCrmDb so every service shares this db instance
|
||||
// (see crmDb.js — a second knex pool on one SQLite file deadlocks).
|
||||
eventTypeService = require('../../src/services/eventTypeService');
|
||||
setupService = require('../../src/services/setupService');
|
||||
({ ensureEventReminderTemplatesSeeded } = require('../../src/services/eventReminderTemplates'));
|
||||
}, 120000);
|
||||
|
||||
afterAll(async () => {
|
||||
if (cleanup) await cleanup();
|
||||
});
|
||||
|
||||
it('migration 161 seeds the flag false on a fresh (admin-less) install', async () => {
|
||||
const row = await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).first();
|
||||
expect(row).toBeTruthy();
|
||||
expect(JSON.parse(row.setting_value)).toBe(false);
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(false);
|
||||
});
|
||||
|
||||
it('refuses to delete a system type that events already use, even in the window', async () => {
|
||||
const corporate = await db('event_types').where({ slug_prefix: 'corporate' }).first();
|
||||
await db('events').insert({
|
||||
slug: 'corporate-test-2026-01-01',
|
||||
event_name: 'Test',
|
||||
event_type: 'corporate',
|
||||
event_date: '2026-01-01',
|
||||
host_email: 'host@example.com',
|
||||
admin_email: 'admin@example.com',
|
||||
password_hash: 'x',
|
||||
share_link: 'share-corporate-test',
|
||||
expires_at: new Date(Date.now() + 86400000),
|
||||
});
|
||||
|
||||
await expect(eventTypeService.deleteEventType(corporate.id))
|
||||
.rejects.toMatchObject({ code: 'IN_USE' });
|
||||
});
|
||||
|
||||
it('deletes an unused system type in the window, taking its reminder template along', async () => {
|
||||
// Seed the per-type reminder templates first so there is something to clean up.
|
||||
await ensureEventReminderTemplatesSeeded(db);
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeTruthy();
|
||||
|
||||
const wedding = await db('event_types').where({ slug_prefix: 'wedding' }).first();
|
||||
expect(wedding.is_system).toBeTruthy();
|
||||
|
||||
const result = await eventTypeService.deleteEventType(wedding.id);
|
||||
expect(result.success).toBe(true);
|
||||
|
||||
expect(await db('event_types').where({ slug_prefix: 'wedding' }).first()).toBeFalsy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
|
||||
// The deleted slug must NOT stay creatable through the legacy fallback —
|
||||
// the live catalog is authoritative while it has rows.
|
||||
expect(await eventTypeService.isValidEventType('wedding')).toBe(false);
|
||||
expect(await eventTypeService.isValidEventType('birthday')).toBe(true);
|
||||
});
|
||||
|
||||
it('does not resurrect reminder templates for deleted types on the next self-heal pass', async () => {
|
||||
// The seeder caches success per process — reset the module to force a
|
||||
// genuine second pass, exactly what a backend restart would run.
|
||||
jest.resetModules();
|
||||
const fresh = require('../../src/services/eventReminderTemplates');
|
||||
await fresh.ensureEventReminderTemplatesSeeded(db);
|
||||
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_wedding' }).first()).toBeFalsy();
|
||||
// Types still in the catalog keep their templates.
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_birthday' }).first()).toBeTruthy();
|
||||
expect(await db('email_templates').where({ template_key: 'event_reminder_default' }).first()).toBeTruthy();
|
||||
});
|
||||
|
||||
it('re-locks system types once the wizard is marked complete', async () => {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
|
||||
const birthday = await db('event_types').where({ slug_prefix: 'birthday' }).first();
|
||||
await expect(eventTypeService.deleteEventType(birthday.id))
|
||||
.rejects.toMatchObject({ code: 'SYSTEM_TYPE' });
|
||||
|
||||
// Custom (non-system) types remain deletable as before.
|
||||
const custom = await eventTypeService.createEventType({ name: 'Family', slug_prefix: 'family' });
|
||||
const result = await eventTypeService.deleteEventType(custom.id);
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('fails closed when the completion marker row is missing', async () => {
|
||||
// A portable-backup restore can replace app_settings with a set that
|
||||
// predates migration 161 (which will not rerun) — absence must mean
|
||||
// "configured instance", never an open deletion window.
|
||||
await db('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
expect(await setupService.isSetupWizardCompleted()).toBe(true);
|
||||
await setupService.markSetupWizardCompleted();
|
||||
});
|
||||
|
||||
it('refuses to delete the last remaining event type', async () => {
|
||||
// Reduce the catalog to a single custom type via direct db writes (the
|
||||
// service paths are already covered above), then hit the guard.
|
||||
const solo = await eventTypeService.createEventType({ name: 'Solo', slug_prefix: 'solo' });
|
||||
await db('events').del();
|
||||
await db('event_types').whereNot('id', solo.id).del();
|
||||
|
||||
await expect(eventTypeService.deleteEventType(solo.id))
|
||||
.rejects.toMatchObject({ code: 'LAST_TYPE' });
|
||||
|
||||
// Deactivating it would empty the ACTIVE catalog just the same.
|
||||
await expect(eventTypeService.updateEventType(solo.id, { is_active: false }))
|
||||
.rejects.toMatchObject({ code: 'LAST_ACTIVE' });
|
||||
});
|
||||
});
|
||||
@@ -234,3 +234,66 @@ describe('renderInvoiceToBuffer — Storno branch', () => {
|
||||
expect(stornoBuf.length).toBeLessThan(invoiceBuf.length);
|
||||
});
|
||||
});
|
||||
|
||||
// VAT free-text note (#794) + multi-page page-number placement. Same
|
||||
// constraint as the Storno tests: PDFKit Flate-compresses content streams,
|
||||
// so we can't grep the note text — but the page-TREE objects are NOT
|
||||
// compressed, so `/Type /Page` (not `/Pages`) is countable to assert
|
||||
// pagination, and a byte-size delta proves the note actually rendered.
|
||||
describe('renderInvoiceToBuffer — VAT note + multi-page footer (#794)', () => {
|
||||
function baseCtx(overrides = {}) {
|
||||
return {
|
||||
locale: 'de', currency: 'CHF',
|
||||
issuer: { companyName: 'AcmeCo' },
|
||||
recipient: {
|
||||
companyName: 'KundenCo', addressLine1: 'Strasse 1',
|
||||
city: 'Bern', postalCode: '3000',
|
||||
},
|
||||
lineItems: [{
|
||||
quantity: 1, description: 'Photo session',
|
||||
unitPriceMinor: 30000, lineTotalMinor: 30000,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}],
|
||||
totals: {
|
||||
netAmountMinor: 30000, vatRate: 0, vatAmountMinor: 0,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 30000,
|
||||
},
|
||||
doc: { invoiceNumber: 'R-2026-0042', issueDate: '2026-04-12' },
|
||||
qrFormat: 'none',
|
||||
paymentTerm: { netDays: 30 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
const pageCount = (buf) => (buf.toString('latin1').match(/\/Type\s*\/Page(?![s])/g) || []).length;
|
||||
const VAT_NOTE = 'Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).';
|
||||
|
||||
it('renders the VAT note on a single-page invoice (adds content, valid PDF)', async () => {
|
||||
const withNote = await pdfService.renderInvoiceToBuffer(baseCtx({ vatNote: VAT_NOTE }));
|
||||
const without = await pdfService.renderInvoiceToBuffer(baseCtx());
|
||||
expect(withNote.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
expect(pageCount(withNote)).toBe(1);
|
||||
expect(withNote.length).toBeGreaterThan(without.length);
|
||||
});
|
||||
|
||||
it('paginates a long invoice (with the note) across multiple pages without a stray blank page', async () => {
|
||||
const manyItems = Array.from({ length: 60 }, (_, i) => ({
|
||||
quantity: 1, description: `Position ${i + 1} — fotografische Leistung`,
|
||||
unitPriceMinor: 3225, lineTotalMinor: 3225,
|
||||
parentLineItemId: null, parentPosition: null,
|
||||
}));
|
||||
const buf = await pdfService.renderInvoiceToBuffer(baseCtx({
|
||||
lineItems: manyItems,
|
||||
totals: {
|
||||
netAmountMinor: 193500, vatRate: 0, vatAmountMinor: 0,
|
||||
shippingAmountMinor: 0, totalAmountMinor: 193500,
|
||||
},
|
||||
vatNote: VAT_NOTE,
|
||||
}));
|
||||
expect(buf.slice(0, 4).toString('ascii')).toBe('%PDF');
|
||||
const pages = pageCount(buf);
|
||||
expect(pages).toBeGreaterThanOrEqual(2);
|
||||
// 60 short rows fit in 2–3 pages; a stray blank page (the old margin
|
||||
// bug) or a runaway loop would blow past this.
|
||||
expect(pages).toBeLessThanOrEqual(3);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Migration 158: per-event slideshow ordering + category filter (#202).
|
||||
*
|
||||
* - `show_order` — 'chronological' (default, upload order) | 'random'
|
||||
* (client-side shuffle). Lets the Live Slideshow play
|
||||
* photos in a varied order during an event.
|
||||
* - `show_category_id`— optional FK into `photo_categories`. When set, the
|
||||
* slideshow only shows photos in that category (NULL =
|
||||
* all visible photos, the existing behaviour).
|
||||
*
|
||||
* Both additive + guarded. Defaults preserve today's behaviour (chronological,
|
||||
* all photos), so existing slideshows are unchanged.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
const hasOrder = await knex.schema.hasColumn('events', 'show_order');
|
||||
if (!hasOrder) {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.string('show_order', 20).defaultTo('chronological');
|
||||
});
|
||||
}
|
||||
const hasCat = await knex.schema.hasColumn('events', 'show_category_id');
|
||||
if (!hasCat) {
|
||||
await knex.schema.alterTable('events', (t) => {
|
||||
t.integer('show_category_id').nullable();
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
for (const col of ['show_order', 'show_category_id']) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
if (await knex.schema.hasColumn('events', col)) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
await knex.schema.alterTable('events', (t) => t.dropColumn(col));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Migration 159: per-event category ordering (#782).
|
||||
*
|
||||
* Adds a `display_order` integer to `photo_categories` so photographers can
|
||||
* arrange an event's categories in the flow of the day (Pre-Ceremony →
|
||||
* Ceremony → Reception …) instead of the hard-coded A–Z order. Mirrors the
|
||||
* `display_order` column + reorder pattern already used by `event_types`.
|
||||
*
|
||||
* Preserve existing galleries: backfill `display_order` from the CURRENT
|
||||
* (alphabetical) order, scoped — globals numbered together, event-specific
|
||||
* numbered per event — so nothing reshuffles on upgrade. A custom order is
|
||||
* opt-in via the admin reorder controls. See feedback: migrations should pin
|
||||
* previously-implicit defaults onto existing rows.
|
||||
*
|
||||
* Backfill runs in JS (not a SQL window function) to stay portable across
|
||||
* SQLite (dev) and Postgres (prod).
|
||||
*
|
||||
* Additive + hasColumn-guarded.
|
||||
*/
|
||||
async function addColumn(knex, table, column, builder) {
|
||||
if (!(await knex.schema.hasColumn(table, column))) {
|
||||
await knex.schema.alterTable(table, builder);
|
||||
}
|
||||
}
|
||||
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('photo_categories'))) return;
|
||||
|
||||
await addColumn(knex, 'photo_categories', 'display_order', (t) => {
|
||||
t.integer('display_order').notNullable().defaultTo(0);
|
||||
t.index('display_order');
|
||||
});
|
||||
|
||||
// Backfill from the current alphabetical order, per scope, so existing
|
||||
// galleries render exactly as before until an admin reorders.
|
||||
const cats = await knex('photo_categories')
|
||||
.select('id', 'name', 'is_global', 'event_id')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const counters = {};
|
||||
for (const c of cats) {
|
||||
const scope = c.is_global ? 'global' : `event:${c.event_id}`;
|
||||
counters[scope] = (counters[scope] || 0) + 1;
|
||||
await knex('photo_categories')
|
||||
.where('id', c.id)
|
||||
.update({ display_order: counters[scope] });
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('photo_categories'))) return;
|
||||
if (await knex.schema.hasColumn('photo_categories', 'display_order')) {
|
||||
await knex.schema.alterTable('photo_categories', (t) =>
|
||||
t.dropColumn('display_order')
|
||||
);
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Migration 160: per-event category order override (#782).
|
||||
*
|
||||
* Builds on migration 159 (photo_categories.display_order = the GLOBAL default
|
||||
* order) by adding a per-event OVERRIDE layer. Global categories are shared
|
||||
* across every event, so a single display_order can only express one order for
|
||||
* them. This table lets a single gallery arrange its categories — globals AND
|
||||
* event-specific, interleaved into the flow of the day — independently of the
|
||||
* global default.
|
||||
*
|
||||
* Resolution (see adminCategories / gallery):
|
||||
* 1. if the event has override rows -> use override.position;
|
||||
* 2. else fall back to photo_categories.display_order (the global default);
|
||||
* 3. else name.
|
||||
*
|
||||
* An event is either "using the default" (no rows here) or "customised" (a row
|
||||
* per category it shows). No backfill: every existing event starts on the
|
||||
* default order, so nothing reshuffles — a custom order is opt-in per event.
|
||||
*
|
||||
* Additive + hasTable-guarded.
|
||||
*/
|
||||
exports.up = async function (knex) {
|
||||
if (!(await knex.schema.hasTable('photo_categories'))) return;
|
||||
if (await knex.schema.hasTable('event_category_order')) return;
|
||||
|
||||
await knex.schema.createTable('event_category_order', (t) => {
|
||||
t.increments('id').primary();
|
||||
t.integer('event_id').notNullable()
|
||||
.references('id').inTable('events').onDelete('CASCADE');
|
||||
t.integer('category_id').notNullable()
|
||||
.references('id').inTable('photo_categories').onDelete('CASCADE');
|
||||
t.integer('position').notNullable().defaultTo(0);
|
||||
t.timestamp('created_at').defaultTo(knex.fn.now());
|
||||
|
||||
// At most one position per (event, category).
|
||||
t.unique(['event_id', 'category_id']);
|
||||
// Ordered reads are always scoped to one event.
|
||||
t.index(['event_id', 'position']);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function (knex) {
|
||||
if (await knex.schema.hasTable('event_category_order')) {
|
||||
await knex.schema.dropTable('event_category_order');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Migration 161: `setup_wizard_completed` app setting (#800).
|
||||
*
|
||||
* The setup wizard gains an event-types step that may rename or DELETE the
|
||||
* seeded system event types. That is only safe on a pristine install, so the
|
||||
* backend gates system-type deletion on this flag being unset (plus zero
|
||||
* usage — see eventTypeService.deleteEventType).
|
||||
*
|
||||
* Backfill rule: any install that already has an admin account predates the
|
||||
* wizard step (or already finished the wizard), so it is marked completed
|
||||
* here — the deletion window never opens on existing setups. A genuinely
|
||||
* fresh install runs this migration BEFORE its first admin is created, so
|
||||
* the flag starts false and the wizard's finish call flips it to true.
|
||||
*
|
||||
* Idempotent: skips when the key already exists. Values are JSON-stringified
|
||||
* to match getAppSetting's JSON.parse on read.
|
||||
*/
|
||||
exports.up = async function up(knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
|
||||
const existing = await knex('app_settings')
|
||||
.where({ setting_key: 'setup_wizard_completed' })
|
||||
.first();
|
||||
if (existing) return;
|
||||
|
||||
let hasAdmin = false;
|
||||
if (await knex.schema.hasTable('admin_users')) {
|
||||
const row = await knex('admin_users').count({ c: '*' }).first();
|
||||
hasAdmin = Number(row?.c || 0) > 0;
|
||||
}
|
||||
|
||||
await knex('app_settings').insert({
|
||||
setting_key: 'setup_wizard_completed',
|
||||
setting_value: JSON.stringify(hasAdmin),
|
||||
setting_type: 'boolean',
|
||||
updated_at: new Date(),
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = async function down(knex) {
|
||||
if (!(await knex.schema.hasTable('app_settings'))) return;
|
||||
await knex('app_settings').where({ setting_key: 'setup_wizard_completed' }).del();
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "picpeak-backend",
|
||||
"version": "3.83.0-beta.0",
|
||||
"version": "3.88.0-beta.0",
|
||||
"description": "Backend for PicPeak event photo sharing platform",
|
||||
"main": "server.js",
|
||||
"scripts": {
|
||||
|
||||
@@ -4,6 +4,8 @@ const { db, logActivity } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const logger = require('../utils/logger');
|
||||
const router = express.Router();
|
||||
|
||||
@@ -12,8 +14,9 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
try {
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching categories:', error);
|
||||
@@ -21,19 +24,12 @@ router.get('/global', adminAuth, requirePermission('settings.view'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Get categories for a specific event (global + event-specific)
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), async (req, res) => {
|
||||
// Get categories for a specific event (global + event-specific), resolved to
|
||||
// the event's effective order: per-event override, else global default, else
|
||||
// name (#782). Each row carries `override_position` (null when not customised).
|
||||
router.get('/event/:eventId', adminAuth, requirePermission('settings.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId } = req.params;
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true))
|
||||
.orWhere('event_id', eventId);
|
||||
})
|
||||
.orderBy('is_global', 'desc')
|
||||
.orderBy('name', 'asc');
|
||||
|
||||
const categories = await getEventCategoriesOrdered(req.params.eventId);
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error fetching event categories:', error);
|
||||
@@ -81,12 +77,27 @@ router.post('/', adminAuth, requirePermission('settings.edit'), [
|
||||
return res.status(400).json({ error: 'Category with this slug already exists' });
|
||||
}
|
||||
|
||||
// Append to the end of its scope so a new category doesn't jump to the
|
||||
// top of an admin-defined order (#782).
|
||||
const maxRow = await db('photo_categories')
|
||||
.where(function() {
|
||||
if (is_global) {
|
||||
this.where('is_global', formatBoolean(true));
|
||||
} else {
|
||||
this.where('event_id', event_id);
|
||||
}
|
||||
})
|
||||
.max('display_order as maxOrder')
|
||||
.first();
|
||||
const nextOrder = (maxRow?.maxOrder || 0) + 1;
|
||||
|
||||
// Create category
|
||||
const insertResult = await db('photo_categories').insert({
|
||||
name,
|
||||
slug: categorySlug,
|
||||
is_global,
|
||||
event_id: is_global ? null : event_id
|
||||
event_id: is_global ? null : event_id,
|
||||
display_order: nextOrder
|
||||
}).returning('id');
|
||||
|
||||
const categoryId = insertResult[0]?.id || insertResult[0];
|
||||
@@ -254,4 +265,133 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
}
|
||||
});
|
||||
|
||||
// Set a per-event category order override (#782). The client sends the full
|
||||
// ordered id list for THIS event — globals + event-specific, interleaved — and
|
||||
// we replace the event's override rows in one transaction. This overrides the
|
||||
// global default order for this gallery only.
|
||||
router.post('/reorder', adminAuth, requirePermission('settings.edit'), [
|
||||
body('event_id').isInt().withMessage('event_id must be an integer'),
|
||||
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const eventId = parseInt(req.body.event_id, 10);
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
// Event ownership (event_id comes from the body, so requireEventOwnership —
|
||||
// which reads req.params — can't be used here). Mirror it: super_admins
|
||||
// bypass; other admins may only reorder events they own (ownerless
|
||||
// legacy/system events allowed).
|
||||
if (req.admin.roleName !== 'super_admin') {
|
||||
const event = await db('events').where('id', eventId).first();
|
||||
if (!event) {
|
||||
return res.status(404).json({ error: 'Event not found' });
|
||||
}
|
||||
if (event.created_by && event.created_by !== req.admin.id) {
|
||||
return res.status(403).json({ error: 'Access denied' });
|
||||
}
|
||||
}
|
||||
|
||||
// Every id must be a category available to this event: a shared global OR
|
||||
// one of the event's own categories. Anything else is out of scope.
|
||||
const available = await db('photo_categories')
|
||||
.where(function() {
|
||||
this.where('is_global', formatBoolean(true)).orWhere('event_id', eventId);
|
||||
})
|
||||
.pluck('id');
|
||||
const availableSet = new Set(available);
|
||||
const invalid = orderedIds.filter((id) => !availableSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more categories are not available for this event' });
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_category_order').where('event_id', eventId).del();
|
||||
await trx('event_category_order').insert(
|
||||
orderedIds.map((id, i) => ({ event_id: eventId, category_id: id, position: i + 1 }))
|
||||
);
|
||||
});
|
||||
|
||||
// Log activity after commit (avoids a SQLite in-transaction global write).
|
||||
await logActivity('event_category_order_set',
|
||||
{ eventId, count: orderedIds.length },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(await getEventCategoriesOrdered(eventId));
|
||||
} catch (error) {
|
||||
logger.error('Error reordering categories:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder categories' });
|
||||
}
|
||||
});
|
||||
|
||||
// Clear an event's override — revert this gallery to the global default order.
|
||||
router.delete('/reorder/:eventId', adminAuth, requirePermission('settings.edit'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const eventId = parseInt(req.params.eventId, 10);
|
||||
await db('event_category_order').where('event_id', eventId).del();
|
||||
|
||||
await logActivity('event_category_order_reset',
|
||||
{ eventId },
|
||||
eventId,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
res.json(await getEventCategoriesOrdered(eventId));
|
||||
} catch (error) {
|
||||
logger.error('Error resetting category order:', error);
|
||||
res.status(500).json({ error: 'Failed to reset category order' });
|
||||
}
|
||||
});
|
||||
|
||||
// Set the GLOBAL default order for shared (global) categories (#782). Applies
|
||||
// to every gallery that hasn't set its own override. Rewrites display_order.
|
||||
router.post('/reorder-global', adminAuth, requirePermission('settings.edit'), [
|
||||
body('orderedIds').isArray({ min: 1 }).withMessage('orderedIds must be a non-empty array'),
|
||||
body('orderedIds.*').isInt().withMessage('Each id must be an integer')
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
if (!errors.isEmpty()) {
|
||||
return res.status(400).json({ errors: errors.array() });
|
||||
}
|
||||
|
||||
const orderedIds = req.body.orderedIds.map((id) => parseInt(id, 10));
|
||||
|
||||
const globals = await db('photo_categories').where('is_global', formatBoolean(true)).pluck('id');
|
||||
const globalsSet = new Set(globals);
|
||||
const invalid = orderedIds.filter((id) => !globalsSet.has(id));
|
||||
if (invalid.length > 0) {
|
||||
return res.status(400).json({ error: 'One or more categories are not global' });
|
||||
}
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
for (let i = 0; i < orderedIds.length; i += 1) {
|
||||
await trx('photo_categories').where('id', orderedIds[i]).update({ display_order: i + 1 });
|
||||
}
|
||||
});
|
||||
|
||||
await logActivity('global_category_order_set',
|
||||
{ count: orderedIds.length },
|
||||
null,
|
||||
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||
);
|
||||
|
||||
const categories = await db('photo_categories')
|
||||
.where('is_global', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.orderBy('name', 'asc');
|
||||
res.json(categories);
|
||||
} catch (error) {
|
||||
logger.error('Error reordering global categories:', error);
|
||||
res.status(500).json({ error: 'Failed to reorder global categories' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -178,7 +178,7 @@ router.put('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX') {
|
||||
if (error.code === 'DUPLICATE_SLUG_PREFIX' || error.code === 'LAST_ACTIVE') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
@@ -216,7 +216,7 @@ router.delete('/:id', adminAuth, requirePermission('settings.edit'), [
|
||||
if (error.code === 'NOT_FOUND') {
|
||||
return res.status(404).json({ error: error.message });
|
||||
}
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE') {
|
||||
if (error.code === 'SYSTEM_TYPE' || error.code === 'IN_USE' || error.code === 'LAST_TYPE') {
|
||||
return res.status(400).json({ error: error.message });
|
||||
}
|
||||
|
||||
|
||||
@@ -307,6 +307,9 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
const SLIDESHOW_TRANSITIONS = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
// Allowed per-slide color filters.
|
||||
const SLIDESHOW_COLORFILTERS = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
// Allowed slideshow play orders (#202). 'chronological' = upload order,
|
||||
// 'random' = client-side shuffle.
|
||||
const SLIDESHOW_ORDERS = ['chronological', 'random'];
|
||||
module.exports = {
|
||||
validateHeroImageAnchor,
|
||||
getStoragePath,
|
||||
@@ -321,6 +324,7 @@ module.exports = {
|
||||
mapEventForApi,
|
||||
hasCustomerContactColumns,
|
||||
deleteEventCascade,
|
||||
SLIDESHOW_ORDERS,
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
};
|
||||
|
||||
@@ -13,7 +13,7 @@ const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { requireEventOwnership } = require('../../middleware/ownership');
|
||||
const { requireFeatureFlag } = require('../../middleware/requireFeatureFlag');
|
||||
const { getFrontendBaseUrl } = require('../../utils/frontendUrl');
|
||||
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS } = require('./helpers');
|
||||
const { SLIDESHOW_TRANSITIONS, SLIDESHOW_COLORFILTERS, SLIDESHOW_ORDERS } = require('./helpers');
|
||||
|
||||
// The watermark LOOK (source/position/opacity/style/size) is global-only
|
||||
// (app_settings, Settings → Slideshow); events only carry the show_watermark
|
||||
@@ -105,7 +105,9 @@ module.exports = (router) => {
|
||||
body('show_transition').optional().isIn(SLIDESHOW_TRANSITIONS),
|
||||
body('show_transition_ms').optional().isInt({ min: 100, max: 5000 }),
|
||||
body('show_watermark').optional({ nullable: true }),
|
||||
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS)
|
||||
body('show_colorfilter').optional().isIn(SLIDESHOW_COLORFILTERS),
|
||||
body('show_order').optional().isIn(SLIDESHOW_ORDERS),
|
||||
body('show_category_id').optional({ nullable: true }).isInt({ min: 1 })
|
||||
], async (req, res) => {
|
||||
try {
|
||||
const errors = validationResult(req);
|
||||
@@ -130,6 +132,23 @@ module.exports = (router) => {
|
||||
: formatBoolean(parseBooleanInput(req.body.show_watermark, false));
|
||||
}
|
||||
if (req.body.show_colorfilter !== undefined) updates.show_colorfilter = req.body.show_colorfilter;
|
||||
if (req.body.show_order !== undefined) updates.show_order = req.body.show_order;
|
||||
// Category filter (#202). null clears it (all photos). A non-null id must
|
||||
// belong to this event or be a global category — otherwise ignore it so a
|
||||
// stale/foreign id can't leak another event's category selection.
|
||||
if (req.body.show_category_id !== undefined) {
|
||||
if (req.body.show_category_id === null) {
|
||||
updates.show_category_id = null;
|
||||
} else {
|
||||
const catId = parseInt(req.body.show_category_id, 10);
|
||||
const cat = await db('photo_categories')
|
||||
.where({ id: catId })
|
||||
.where(function () { this.where('event_id', event.id).orWhere('is_global', formatBoolean(true)); })
|
||||
.first();
|
||||
if (!cat) return res.status(400).json({ error: 'Category does not belong to this event' });
|
||||
updates.show_category_id = catId;
|
||||
}
|
||||
}
|
||||
|
||||
// Knex throws on an empty update; only write if something changed.
|
||||
if (Object.keys(updates).length > 0) {
|
||||
@@ -141,7 +160,9 @@ module.exports = (router) => {
|
||||
show_transition: updates.show_transition ?? event.show_transition ?? 'crossfade',
|
||||
show_transition_ms: updates.show_transition_ms ?? event.show_transition_ms ?? 800,
|
||||
show_watermark: updates.show_watermark ?? event.show_watermark ?? null,
|
||||
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none'
|
||||
show_colorfilter: updates.show_colorfilter ?? event.show_colorfilter ?? 'none',
|
||||
show_order: updates.show_order ?? event.show_order ?? 'chronological',
|
||||
show_category_id: 'show_category_id' in updates ? updates.show_category_id : (event.show_category_id ?? null)
|
||||
});
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to update slideshow settings');
|
||||
|
||||
@@ -31,6 +31,19 @@ const watermarkGeneratorService = require('../services/watermarkGeneratorService
|
||||
|
||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||
|
||||
// Reserved first-run bootstrap keys — never writable through the generic
|
||||
// settings upserts in this file: setup_wizard_completed is a one-way marker
|
||||
// (#800; writing false would reopen system-event-type deletion) and
|
||||
// setup_token is the first-run bootstrap secret. Every handler that loops
|
||||
// arbitrary request keys into app_settings must strip these first.
|
||||
const RESERVED_SETTING_KEYS = ['setup_wizard_completed', 'setup_token'];
|
||||
const stripReservedSettingKeys = (settings) => {
|
||||
for (const key of RESERVED_SETTING_KEYS) {
|
||||
delete settings[key];
|
||||
}
|
||||
return settings;
|
||||
};
|
||||
|
||||
// Configure multer for logo uploads
|
||||
const storage = multer.diskStorage({
|
||||
destination: async (req, file, cb) => {
|
||||
@@ -906,7 +919,7 @@ router.put('/theme', adminAuth, requirePermission('settings.edit'), async (req,
|
||||
// Update general settings
|
||||
router.put('/general', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = { ...req.body };
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
let uploadLimitTouched = false;
|
||||
|
||||
const publicSiteKeysTouched = Object.keys(settings).some((key) => key.startsWith('general_public_site_'));
|
||||
@@ -1017,7 +1030,7 @@ router.put('/general', adminAuth, requirePermission('settings.edit'), async (req
|
||||
// Update security settings
|
||||
router.put('/security', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
|
||||
// Update or insert each setting
|
||||
for (const [key, value] of Object.entries(settings)) {
|
||||
@@ -1055,7 +1068,7 @@ router.put('/security', adminAuth, requirePermission('settings.edit'), async (re
|
||||
// Update analytics settings
|
||||
router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
|
||||
// Validate the provider switch (#663 Phase 1). Reject unknown values
|
||||
// so the dashboard route's factory doesn't have to defensively guard.
|
||||
@@ -1110,7 +1123,7 @@ router.put('/analytics', adminAuth, requirePermission('settings.edit'), async (r
|
||||
// Update SEO settings
|
||||
router.put('/seo', adminAuth, requirePermission('settings.edit'), async (req, res) => {
|
||||
try {
|
||||
const settings = req.body;
|
||||
const settings = stripReservedSettingKeys({ ...req.body });
|
||||
|
||||
// Validate seo_blocked_ai_agents is an array of strings
|
||||
if (settings.seo_blocked_ai_agents !== undefined) {
|
||||
|
||||
@@ -45,6 +45,17 @@ const router = express.Router();
|
||||
async function completeAdminLogin(req, res, admin, ipAddress, userAgent, lockoutKey) {
|
||||
await trackSuccessfulLogin(lockoutKey, ipAddress, userAgent);
|
||||
|
||||
// A normal login means the first-run wizard is over — the wizard never hits
|
||||
// this route (setup sets its cookie directly). Close the system-event-type
|
||||
// deletion window durably even when the wizard was abandoned mid-way (#800).
|
||||
// Best-effort: a failure here must never block a login.
|
||||
try {
|
||||
const setupService = require('../services/setupService');
|
||||
if (!(await setupService.isSetupWizardCompleted())) {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
}
|
||||
} catch (_) { /* best-effort */ }
|
||||
|
||||
await db('admin_users').where('id', admin.id).update({
|
||||
last_login: new Date(),
|
||||
last_login_ip: ipAddress
|
||||
|
||||
@@ -25,6 +25,7 @@ const { generateGuestIdentifier } = require('../middleware/feedbackRateLimit');
|
||||
const secureImageService = require('../services/secureImageService');
|
||||
const logger = require('../utils/logger');
|
||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||
const { getEventCategoriesOrdered } = require('../utils/categoryOrder');
|
||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||
const { handleAsync, errorResponse } = require('../utils/routeHelpers');
|
||||
const { NotFoundError } = require('../utils/errors');
|
||||
@@ -243,8 +244,8 @@ router.get('/:slug/info', async (req, res) => {
|
||||
|
||||
// Photos a slideshow may display: published, finished, non-hidden. Mirrors the
|
||||
// guest filter in GET /:slug/photos so the live count matches the rendered set.
|
||||
function slideshowPhotosQuery(eventId) {
|
||||
return db('photos')
|
||||
function slideshowPhotosQuery(eventId, categoryId = null) {
|
||||
const q = db('photos')
|
||||
.where('photos.event_id', eventId)
|
||||
.where(function() {
|
||||
this.where('photos.processing_status', 'complete').orWhereNull('photos.processing_status');
|
||||
@@ -252,6 +253,10 @@ function slideshowPhotosQuery(eventId) {
|
||||
.where(function() {
|
||||
this.where('photos.visibility', 'visible').orWhereNull('photos.visibility');
|
||||
});
|
||||
// Category filter (#202) — keep the /session + /state count in sync with the
|
||||
// photos the kiosk actually renders.
|
||||
if (categoryId) q.where('photos.category_id', categoryId);
|
||||
return q;
|
||||
}
|
||||
|
||||
// Resolve an active slideshow by slug + token. Returns the event row, or null
|
||||
@@ -324,6 +329,9 @@ async function slideshowSettings(event) {
|
||||
transition: event.show_transition || 'crossfade',
|
||||
transition_ms: event.show_transition_ms || 800,
|
||||
colorfilter: event.show_colorfilter || 'none',
|
||||
// Play order (#202): 'chronological' | 'random'. The client shuffles when
|
||||
// 'random' so live-appended uploads keep working.
|
||||
order: event.show_order || 'chronological',
|
||||
fit: g.fit,
|
||||
watermark,
|
||||
};
|
||||
@@ -356,7 +364,7 @@ router.get('/:slug/show/:token/session', handleAsync(async (req, res) => {
|
||||
// here so the kiosk's image requests are authorized with zero extra wiring.
|
||||
setGalleryAuthCookies(res, sessionToken, event.slug);
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
|
||||
res.json({
|
||||
token: sessionToken,
|
||||
@@ -382,7 +390,7 @@ router.get('/:slug/show/:token/state', handleAsync(async (req, res) => {
|
||||
throw new NotFoundError('Slideshow');
|
||||
}
|
||||
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id).count('* as count');
|
||||
const [{ count }] = await slideshowPhotosQuery(event.id, event.show_category_id).count('* as count');
|
||||
|
||||
res.json({
|
||||
...(await slideshowSettings(event)),
|
||||
@@ -426,6 +434,13 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
});
|
||||
}
|
||||
|
||||
// Live Slideshow category filter (#202). Enforced server-side so the kiosk
|
||||
// viewer can't widen the set: when the event pins show_category_id, the
|
||||
// slideshow only sees that category. NULL = all photos (unchanged).
|
||||
if (req.accessLevel === 'slideshow' && req.event.show_category_id) {
|
||||
photosQuery = photosQuery.where('photos.category_id', req.event.show_category_id);
|
||||
}
|
||||
|
||||
// Apply sort option
|
||||
if (sort === 'capture_date') {
|
||||
// Sort by capture date, falling back to uploaded_at if capture date is null
|
||||
@@ -573,10 +588,12 @@ router.get('/:slug/photos', verifyGalleryAccess, resolveGuest, async (req, res)
|
||||
// Fetch category details from photo_categories table
|
||||
let categories = [];
|
||||
if (usedCategoryIds.length > 0) {
|
||||
const categoryDetails = await db('photo_categories')
|
||||
.whereIn('id', usedCategoryIds)
|
||||
.select('id', 'name', 'slug', 'is_global', 'hero_photo_id', 'allow_downloads')
|
||||
.orderBy('name', 'asc');
|
||||
// Resolved category order (#782): per-event override, else global
|
||||
// default, else name — restricted to categories that have photos.
|
||||
const categoryDetails = await getEventCategoriesOrdered(req.event.id, {
|
||||
onlyIds: usedCategoryIds,
|
||||
select: ['c.id', 'c.name', 'c.slug', 'c.is_global', 'c.hero_photo_id', 'c.allow_downloads'],
|
||||
});
|
||||
|
||||
categories = categoryDetails.map(cat => ({
|
||||
id: cat.id,
|
||||
|
||||
@@ -10,6 +10,7 @@ const { body, validationResult } = require('express-validator');
|
||||
const setupService = require('../services/setupService');
|
||||
const { getClientIp } = require('../utils/requestIp');
|
||||
const { setAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const logger = require('../utils/logger');
|
||||
|
||||
const router = express.Router();
|
||||
@@ -79,4 +80,19 @@ router.post('/admin', [
|
||||
}
|
||||
});
|
||||
|
||||
// Wizard finish marker — unlike the endpoints above this one runs AFTER the
|
||||
// admin exists (the wizard is authenticated from the account step onward), so
|
||||
// it takes the normal admin auth. One-way: while the flag is unset the seeded
|
||||
// SYSTEM event types may be deleted from the wizard's event-types step; once
|
||||
// set they are permanently protected (#800).
|
||||
router.post('/complete', adminAuth, async (req, res) => {
|
||||
try {
|
||||
await setupService.markSetupWizardCompleted();
|
||||
res.json({ completed: true });
|
||||
} catch (err) {
|
||||
logger.error('[setup] markSetupWizardCompleted failed', { error: err.message });
|
||||
res.status(500).json({ error: 'Failed to mark setup complete' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
@@ -80,7 +80,16 @@ jest.mock('../../../services/webhookService', () => ({
|
||||
buildEventSubject: jest.fn().mockReturnValue({}),
|
||||
}));
|
||||
|
||||
// event_type is validated against the live event_types catalog (#800) —
|
||||
// that lookup would consume the first queued db() chain and shift the
|
||||
// call sequence these tests pin. Stub it valid; the invalid path has its
|
||||
// own test below.
|
||||
jest.mock('../../../services/eventTypeService', () => ({
|
||||
isValidEventType: jest.fn().mockResolvedValue(true),
|
||||
}));
|
||||
|
||||
const { db } = require('../../../database/db');
|
||||
const { isValidEventType } = require('../../../services/eventTypeService');
|
||||
const eventsRouter = require('../events');
|
||||
|
||||
const buildApp = () => {
|
||||
@@ -231,4 +240,16 @@ describe('v1 POST /events — issue #550 (color_theme + feedback row)', () => {
|
||||
.send({ ...BASE_BODY, feedback_enabled: 'maybe' })
|
||||
.expect(400);
|
||||
});
|
||||
|
||||
it('rejects an event_type unknown to the catalog with 400 (#800)', async () => {
|
||||
isValidEventType.mockResolvedValueOnce(false);
|
||||
const res = await request(buildApp())
|
||||
.post('/events')
|
||||
.send({ ...BASE_BODY, event_type: 'nope' })
|
||||
.expect(400);
|
||||
|
||||
expect(isValidEventType).toHaveBeenCalledWith('nope');
|
||||
expect(JSON.stringify(res.body.errors)).toContain('event_type');
|
||||
expect(db).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,7 @@ const logger = require('../../utils/logger');
|
||||
const { slugify } = require('../../utils/slug');
|
||||
const { formatBoolean } = require('../../utils/dbCompat');
|
||||
const { parseBooleanInput } = require('../../utils/parsers');
|
||||
const { isValidEventType } = require('../../services/eventTypeService');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
@@ -80,7 +81,7 @@ const photoUpload = multer({
|
||||
* event_name: { type: string }
|
||||
* event_type:
|
||||
* type: string
|
||||
* enum: [wedding, birthday, corporate, other, family]
|
||||
* description: "Slug of an active event type from the catalog (Settings → Event Types). Defaults on a fresh install: wedding, birthday, corporate, other. GET /api/v1/event-types lists the live values."
|
||||
* event_date: { type: string, format: date, nullable: true }
|
||||
* customer_name: { type: string, nullable: true }
|
||||
* customer_email: { type: string, format: email, nullable: true }
|
||||
@@ -117,7 +118,14 @@ router.post(
|
||||
requireApiScope('admin'),
|
||||
[
|
||||
body('event_name').isString().trim().notEmpty(),
|
||||
body('event_type').isIn(['wedding', 'birthday', 'corporate', 'other', 'family']),
|
||||
// Validate against the live event_types catalog (admins can rename/delete
|
||||
// the defaults and add custom types), not a hardcoded whitelist (#800).
|
||||
body('event_type').isString().trim().notEmpty().bail().custom(async (value) => {
|
||||
if (!(await isValidEventType(value))) {
|
||||
throw new Error('Unknown event type — must match an active event type slug');
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
body('event_date').optional({ nullable: true, checkFalsy: true }).isISO8601(),
|
||||
body('customer_name').optional({ nullable: true }).isString(),
|
||||
body('customer_email').optional({ nullable: true, checkFalsy: true }).isEmail(),
|
||||
@@ -447,6 +455,48 @@ router.get(
|
||||
}
|
||||
);
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GET /event-types — read (catalog discovery for event creation, #800)
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* @openapi
|
||||
* /event-types:
|
||||
* get:
|
||||
* tags: [Events]
|
||||
* summary: List active event types
|
||||
* description: The slugs accepted as `event_type` when creating events. The catalog is admin-customizable (Settings → Event Types), so integrations should discover values here instead of hardcoding them.
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: Active event types
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* properties:
|
||||
* eventTypes:
|
||||
* type: array
|
||||
* items:
|
||||
* type: object
|
||||
* properties:
|
||||
* slug_prefix: { type: string }
|
||||
* name: { type: string }
|
||||
* emoji: { type: string }
|
||||
*/
|
||||
router.get('/event-types', apiTokenAuth, requireApiScope('read'), async (req, res) => {
|
||||
try {
|
||||
const types = await db('event_types')
|
||||
.where('is_active', formatBoolean(true))
|
||||
.orderBy('display_order', 'asc')
|
||||
.select('slug_prefix', 'name', 'emoji');
|
||||
res.json({ eventTypes: types });
|
||||
} catch (error) {
|
||||
logger.error('v1 GET /event-types failed', { error: error.message });
|
||||
res.status(500).json({ error: 'Failed to list event types' });
|
||||
}
|
||||
});
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
// GET /events/:id — read
|
||||
// ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,6 +11,7 @@ const businessProfileService = require('../businessProfileService');
|
||||
const { ensureSystemBlocksSeeded } = require('../contractBlocksService');
|
||||
const { ensureInt } = require('../../utils/numericHelpers');
|
||||
const { adminActor, ensureCustomerActive, nextContractNumber } = require('./helpers');
|
||||
const { resolveDefaultEventType } = require('../eventTypeService');
|
||||
|
||||
|
||||
/**
|
||||
@@ -221,6 +222,12 @@ async function convertToEvent(contractId, adminId) {
|
||||
const placeholderHash = crypto.randomBytes(32).toString('hex');
|
||||
const shareToken = crypto.randomBytes(32).toString('hex');
|
||||
|
||||
// Event type: the configurable org default, else the resolved catch-all —
|
||||
// same chain as quoteService.convertToEvent. Never a hardcoded slug: the
|
||||
// admin may have renamed or deleted 'wedding' (#800).
|
||||
const eventType = (await getAppSetting('crm_default_event_type'))
|
||||
|| (await resolveDefaultEventType());
|
||||
|
||||
const eventCols = await db('events').columnInfo();
|
||||
const candidate = {
|
||||
slug: `contract-${contract.contract_number.toLowerCase()}-${crypto.randomBytes(3).toString('hex')}`,
|
||||
@@ -236,7 +243,7 @@ async function convertToEvent(contractId, adminId) {
|
||||
customer_email: customerEmail,
|
||||
customer_phone: customer.phone,
|
||||
admin_email: adminEmail,
|
||||
event_type: 'wedding',
|
||||
event_type: eventType,
|
||||
password_hash: placeholderHash,
|
||||
share_link: shareToken,
|
||||
share_token: shareToken,
|
||||
|
||||
@@ -266,7 +266,22 @@ async function ensureEventReminderTemplatesSeeded(db, logger) {
|
||||
}
|
||||
};
|
||||
|
||||
// Per-type templates are only seeded for slugs that still exist in the
|
||||
// event_types catalog — the setup wizard (and admins) can delete the
|
||||
// seeded defaults, and re-inserting event_reminder_<slug> for a removed
|
||||
// type would resurrect an orphan on every boot (#800). The catch-all
|
||||
// event_reminder_default is always seeded.
|
||||
let existingSlugs = null;
|
||||
if (await db.schema.hasTable('event_types')) {
|
||||
const rows = await db('event_types').select('slug_prefix');
|
||||
existingSlugs = new Set(rows.map((r) => r.slug_prefix));
|
||||
}
|
||||
|
||||
for (const [templateKey, def] of Object.entries(EVENT_REMINDER_TEMPLATES)) {
|
||||
const typeSlug = templateKey.replace(/^event_reminder_/, '');
|
||||
if (typeSlug !== 'default' && existingSlugs && !existingSlugs.has(typeSlug)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
let existing = await db('email_templates').where({ template_key: templateKey }).first();
|
||||
|
||||
|
||||
@@ -67,13 +67,20 @@ const getEventTypeBySlugPrefix = async (slugPrefix) => {
|
||||
const isValidEventType = async (slugPrefix) => {
|
||||
const normalized = slugPrefix.toLowerCase();
|
||||
|
||||
// Check in database
|
||||
// The live catalog is authoritative: a row decides by its active flag, and
|
||||
// a slug the admin deleted (setup wizard, #800) or deactivated must NOT
|
||||
// sneak back in through the legacy list below.
|
||||
const eventType = await getEventTypeBySlugPrefix(normalized);
|
||||
if (eventType && eventType.is_active) {
|
||||
return true;
|
||||
if (eventType) {
|
||||
return Boolean(eventType.is_active);
|
||||
}
|
||||
const anyType = await db('event_types').first('id');
|
||||
if (anyType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Legacy fallback: Accept old hardcoded values for backward compatibility
|
||||
// Legacy fallback: only for a degenerate install with an EMPTY catalog
|
||||
// (pre-catalog schema drift) — accept the old hardcoded values.
|
||||
const legacyTypes = ['wedding', 'birthday', 'corporate', 'other'];
|
||||
return legacyTypes.includes(normalized);
|
||||
};
|
||||
@@ -198,6 +205,19 @@ const updateEventType = async (id, updates) => {
|
||||
}
|
||||
|
||||
if (updates.is_active !== undefined) {
|
||||
// Deactivating the last active type would empty the ACTIVE catalog and
|
||||
// brick event creation (unknown slugs are rejected since #800).
|
||||
if (updates.is_active === false && eventType.is_active) {
|
||||
const otherActive = await db('event_types')
|
||||
.whereNot('id', id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.first('id');
|
||||
if (!otherActive) {
|
||||
const error = new Error('Cannot deactivate the last active event type — activate another one first.');
|
||||
error.code = 'LAST_ACTIVE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
updateData.is_active = formatBoolean(updates.is_active);
|
||||
}
|
||||
|
||||
@@ -250,6 +270,12 @@ const updateEventType = async (id, updates) => {
|
||||
|
||||
/**
|
||||
* Delete an event type
|
||||
*
|
||||
* System types are protected — EXCEPT during the first-run setup wizard
|
||||
* (setup_wizard_completed flag unset, see setupService), where the admin may
|
||||
* replace the seeded defaults before anything references them (#800). The
|
||||
* in-use checks below still apply in that window as defense in depth.
|
||||
*
|
||||
* @param {number} id - Event type ID
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
@@ -261,11 +287,17 @@ const deleteEventType = async (id) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Prevent deletion of system types
|
||||
// Prevent deletion of system types once the setup wizard has completed.
|
||||
if (eventType.is_system) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
// Lazy require: keeps the module graph flat (setupService has no
|
||||
// dependency back on this service, but the require is only needed on
|
||||
// this rare path).
|
||||
const { isSetupWizardCompleted } = require('./setupService');
|
||||
if (await isSetupWizardCompleted()) {
|
||||
const error = new Error('Cannot delete system event types. You can deactivate them instead.');
|
||||
error.code = 'SYSTEM_TYPE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if any events use this type
|
||||
@@ -280,7 +312,62 @@ const deleteEventType = async (id) => {
|
||||
throw error;
|
||||
}
|
||||
|
||||
await db('event_types').where('id', id).del();
|
||||
// Never delete the last remaining type — and never delete the last ACTIVE
|
||||
// one either: event creation and the quote/contract default-type resolution
|
||||
// both need at least one active catalog entry.
|
||||
const remaining = await db('event_types').whereNot('id', id).count('id as count').first();
|
||||
if (!remaining || parseInt(remaining.count) === 0) {
|
||||
const error = new Error('Cannot delete the last event type — at least one must remain.');
|
||||
error.code = 'LAST_TYPE';
|
||||
throw error;
|
||||
}
|
||||
if (eventType.is_active) {
|
||||
const remainingActive = await db('event_types')
|
||||
.whereNot('id', id)
|
||||
.where('is_active', formatBoolean(true))
|
||||
.count('id as count')
|
||||
.first();
|
||||
if (!remainingActive || parseInt(remainingActive.count) === 0) {
|
||||
const error = new Error('Cannot delete the last active event type — activate another one first.');
|
||||
error.code = 'LAST_TYPE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Quotes carry event_type too (migration 146) — a dangling slug there would
|
||||
// corrupt the quote→event conversion default chain.
|
||||
if (await hasColumnCached('quotes', 'event_type')) {
|
||||
const quotesUsingType = await db('quotes')
|
||||
.where('event_type', eventType.slug_prefix)
|
||||
.count('id as count')
|
||||
.first();
|
||||
if (quotesUsingType && parseInt(quotesUsingType.count) > 0) {
|
||||
const error = new Error(`Cannot delete: ${quotesUsingType.count} quotes are using this type. Deactivate it instead.`);
|
||||
error.code = 'IN_USE';
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve schema lookups BEFORE opening the transaction — a global-db read
|
||||
// inside a SQLite transaction (single connection) deadlocks. Same pattern
|
||||
// as the rename cascade in updateEventType above.
|
||||
const hasTranslations = await db.schema.hasTable('email_template_translations');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
await trx('event_types').where('id', id).del();
|
||||
// Drop the per-type reminder template with the type, or it lingers as an
|
||||
// orphan (invisible in the Reminder Emails tab, which derives its rows
|
||||
// from the live catalog).
|
||||
const tpl = await trx('email_templates')
|
||||
.where({ template_key: `event_reminder_${eventType.slug_prefix}` })
|
||||
.first('id');
|
||||
if (tpl) {
|
||||
if (hasTranslations) {
|
||||
await trx('email_template_translations').where({ template_id: tpl.id }).del();
|
||||
}
|
||||
await trx('email_templates').where({ id: tpl.id }).del();
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, deleted: eventType };
|
||||
};
|
||||
@@ -341,6 +428,28 @@ const getEventTypeForSlug = async (eventTypeIdentifier) => {
|
||||
return { slug_prefix: 'event', theme_preset: 'default', emoji: '📷' };
|
||||
};
|
||||
|
||||
/**
|
||||
* Resolve the fallback event type for document→event conversions (quotes,
|
||||
* contracts) when the source carries none. Never hardcodes a specific slug
|
||||
* (any of them, incl. 'other', can be disabled by the admin): prefer the
|
||||
* generic 'other' catch-all when it's active, else the first active type by
|
||||
* display order, and only fall back to the literal 'other' if the catalog is
|
||||
* somehow empty/unreadable.
|
||||
* @param {Object} [conn] - Optional knex connection/transaction
|
||||
* @returns {Promise<string>} - slug_prefix to use
|
||||
*/
|
||||
const resolveDefaultEventType = async (conn) => {
|
||||
const q = conn || db;
|
||||
try {
|
||||
const other = await q('event_types').where({ slug_prefix: 'other', is_active: formatBoolean(true) }).first('slug_prefix');
|
||||
if (other) return 'other';
|
||||
const firstActive = await q('event_types').where({ is_active: formatBoolean(true) }).orderBy('display_order', 'asc').first('slug_prefix');
|
||||
return firstActive?.slug_prefix || 'other';
|
||||
} catch (_) {
|
||||
return 'other';
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getAllEventTypes,
|
||||
getActiveEventTypes,
|
||||
@@ -352,5 +461,6 @@ module.exports = {
|
||||
updateEventType,
|
||||
deleteEventType,
|
||||
reorderEventTypes,
|
||||
getEventTypeForSlug
|
||||
getEventTypeForSlug,
|
||||
resolveDefaultEventType
|
||||
};
|
||||
|
||||
@@ -174,6 +174,14 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
? 0
|
||||
: ensureInt(invoice.net_amount_minor) - displayedNetMinor;
|
||||
|
||||
// Optional free-text VAT / legal note printed directly under the MwSt. line
|
||||
// on the invoice PDF (#794). Configured globally in Settings → CRM → Invoices.
|
||||
// Data-driven: the admin types the exact wording (e.g. the Austrian
|
||||
// Kleinunternehmer statement, § 6 Abs. 1 Z 27 UStG 1994), so no jurisdiction
|
||||
// is hardcoded. Empty/whitespace → null (row omitted).
|
||||
const vatNoteRaw = await getAppSetting('crm_invoices_vat_note_text');
|
||||
const vatNote = typeof vatNoteRaw === 'string' && vatNoteRaw.trim() ? vatNoteRaw.trim() : null;
|
||||
|
||||
return {
|
||||
locale: invoice.language || profile?.default_locale || 'de',
|
||||
currency: invoice.currency,
|
||||
@@ -189,6 +197,8 @@ async function buildInvoiceRenderContext(invoice, lineItems) {
|
||||
iban: bank.iban, bic: bank.bic, currency: bank.currency,
|
||||
} : null,
|
||||
paymentTerm,
|
||||
// Free-text VAT/legal note (#794) — rendered under the MwSt. line by drawTotals.
|
||||
vatNote,
|
||||
lineItems: lineItems.map((li) => ({
|
||||
quantity: li.quantity,
|
||||
description: li.description,
|
||||
|
||||
@@ -851,6 +851,18 @@ function drawTotals(doc, ctx, x, y, width) {
|
||||
doc.text(formatMinor(totals.vatAmountMinor, currency, intlLocale), valueX, y, { width: valueCol, align: 'right' });
|
||||
y = doc.y + 4;
|
||||
|
||||
// Free-text VAT / legal note (#794) — printed directly under the MwSt. line
|
||||
// (Benedikt's requested spot). The admin sets the exact wording in
|
||||
// Settings → CRM → Invoices (e.g. the Austrian Kleinunternehmer statement).
|
||||
// Optional; wraps across the totals column. Font size is restored to the row
|
||||
// scale so the Mahngebühr / Rundung / grand-total rows below are unaffected.
|
||||
if (ctx.vatNote) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#555');
|
||||
doc.text(ctx.vatNote, labelX, y, { width: right - labelX });
|
||||
doc.fillColor('#000').fontSize(10);
|
||||
y = doc.y + 4;
|
||||
}
|
||||
|
||||
// Mahngebühr row — only rendered when a late fee has been added
|
||||
// (second reminder onwards). Sits between VAT and the grand-total
|
||||
// divider so the customer sees a clear "VAT + late fee → Total"
|
||||
@@ -1670,7 +1682,16 @@ function renderDocument(type, context) {
|
||||
// VAT + middle divider + Total)
|
||||
const FOOTER_RESERVE = 30;
|
||||
const PAYMENT_BLOCK_HEIGHT = ctx.paymentTerm ? 80 : 50;
|
||||
const TOTALS_BLOCK_HEIGHT = 90;
|
||||
let TOTALS_BLOCK_HEIGHT = 90;
|
||||
// A free-text VAT note (#794) adds a wrapped row under the MwSt. line —
|
||||
// grow the reserved totals height by its measured height so a long note
|
||||
// can't push the grand total / payment block into the footer.
|
||||
if (ctx.vatNote) {
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8);
|
||||
const noteWidth = PAGE.contentWidth - ((PAGE.contentWidth - 20) / 2 + 20);
|
||||
TOTALS_BLOCK_HEIGHT += doc.heightOfString(ctx.vatNote, { width: noteWidth }) + 4;
|
||||
doc.fontSize(10);
|
||||
}
|
||||
const desiredPaymentY = PAGE.height - PAGE.marginBottom - FOOTER_RESERVE - PAYMENT_BLOCK_HEIGHT;
|
||||
const desiredTotalsY = desiredPaymentY - 12 - TOTALS_BLOCK_HEIGHT;
|
||||
|
||||
@@ -1746,14 +1767,23 @@ function renderDocument(type, context) {
|
||||
// grey line in the bottom corner) is negligible.
|
||||
for (let i = 0; i < total; i++) {
|
||||
doc.switchToPage(range.start + i);
|
||||
// Drop this page's bottom margin to 0 so writing the label INTO the
|
||||
// margin band (below the content area the line-item table fills) can't
|
||||
// trigger PDFKit's auto-page-break. Previously the label sat at
|
||||
// marginBottom-12 — INSIDE the content area — so on a full multi-page
|
||||
// invoice the table's last row overlapped the "Seite X von Y" stamp
|
||||
// (#794). The page is already fully laid out (buffered), so zeroing the
|
||||
// margin here is safe.
|
||||
doc.page.margins.bottom = 0;
|
||||
doc.font(doc._fonts ? doc._fonts.body : FONT_BODY).fontSize(8).fillColor('#888');
|
||||
const label = t(ctx.locale, 'page_of', {
|
||||
current: i + 1,
|
||||
total,
|
||||
});
|
||||
// Bottom-right corner, just above the bottom margin so
|
||||
// it doesn't trigger PDFKit's auto-paging.
|
||||
const labelY = doc.page.height - PAGE.marginBottom - 12;
|
||||
// Bottom-right corner, INSIDE the bottom margin (below the content
|
||||
// edge the table fills), so a full continuation page's last row can't
|
||||
// overlap it.
|
||||
const labelY = doc.page.height - PAGE.marginBottom + 8;
|
||||
const labelW = 120;
|
||||
const labelX = doc.page.width - PAGE.marginRight - labelW;
|
||||
doc.text(label, labelX, labelY, {
|
||||
@@ -1793,6 +1823,8 @@ function normaliseContext(type, ctx) {
|
||||
totals: ctx.totals || {},
|
||||
doc: ctx.doc || {},
|
||||
qrFormat: ctx.qrFormat || 'none',
|
||||
// Free-text VAT/legal note printed under the MwSt. line on invoices (#794).
|
||||
vatNote: (typeof ctx.vatNote === 'string' && ctx.vatNote.trim()) ? ctx.vatNote.trim() : null,
|
||||
// Date-format config from the `general_date_format` app setting.
|
||||
// Shape: `{ format: 'DD.MM.YYYY' | 'DD/MM/YYYY' | 'MM/DD/YYYY' |
|
||||
// 'YYYY-MM-DD', locale?: string }`. The service layer hydrates
|
||||
|
||||
@@ -34,6 +34,7 @@ const { cleanNetMinor } = require('../utils/invoiceRounding');
|
||||
const { AppError } = require('../utils/errors');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { nextDocumentNumber } = require('../utils/documentSequences');
|
||||
const { resolveDefaultEventType } = require('./eventTypeService');
|
||||
const { formatShortDate } = require('../utils/dateFormatter');
|
||||
const businessProfileService = require('./businessProfileService');
|
||||
const { buildIssuerBlock, buildRecipientBlock } = require('./_renderContext');
|
||||
@@ -308,25 +309,6 @@ async function nextQuoteNumber(trx) {
|
||||
return nextDocumentNumber('quote', 'crm_quotes_number_format', 'Q-{YEAR}-{SEQ:04d}', trx);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the fallback event type for a quote→event conversion when the quote
|
||||
* itself carries none. Never hardcodes a specific slug (any of them, incl.
|
||||
* 'other', can be disabled by the admin): prefer the generic 'other' catch-all
|
||||
* when it's active, else the first active type by display order, and only fall
|
||||
* back to the literal 'other' if the catalog is somehow empty/unreadable.
|
||||
*/
|
||||
async function resolveDefaultEventType(conn) {
|
||||
const q = conn || db;
|
||||
try {
|
||||
const other = await q('event_types').where({ slug_prefix: 'other', is_active: true }).first('slug_prefix');
|
||||
if (other) return 'other';
|
||||
const firstActive = await q('event_types').where({ is_active: true }).orderBy('display_order', 'asc').first('slug_prefix');
|
||||
return firstActive?.slug_prefix || 'other';
|
||||
} catch (_) {
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
function ensureCustomerFeatureEnabled(customer, feature) {
|
||||
// Global toggle (`customer_feature_quotes_enabled` / `..._bills_enabled`)
|
||||
// is checked at the route layer (feature flag); here we only enforce
|
||||
|
||||
@@ -20,6 +20,26 @@ const { formatBoolean } = require('../utils/dbCompat');
|
||||
// is permanently closed once setup is done — safe even on a public IP.
|
||||
const SETUP_TOKEN_KEY = 'setup_token';
|
||||
|
||||
// One-way flag flipped when the setup wizard finishes (migration 161 marks it
|
||||
// completed on installs that predate the wizard's event-types step). While it
|
||||
// is unset — i.e. only during the first-run wizard — the seeded SYSTEM event
|
||||
// types may be deleted (eventTypeService.deleteEventType), because nothing
|
||||
// can reference them yet. Once true, system types are permanently protected.
|
||||
const SETUP_WIZARD_COMPLETED_KEY = 'setup_wizard_completed';
|
||||
|
||||
async function isSetupWizardCompleted() {
|
||||
// Fail closed: only an explicit stored `false` (seeded by migration 161 on
|
||||
// a fresh, admin-less install) opens the deletion window. A missing row —
|
||||
// e.g. app_settings replaced by a portable-backup restore that predates the
|
||||
// migration, which will not rerun — means a configured instance, not a
|
||||
// first run.
|
||||
return (await getAppSetting(SETUP_WIZARD_COMPLETED_KEY)) !== false;
|
||||
}
|
||||
|
||||
async function markSetupWizardCompleted() {
|
||||
await upsertAppSetting(SETUP_WIZARD_COMPLETED_KEY, JSON.stringify(true), 'boolean');
|
||||
}
|
||||
|
||||
async function noAdminExists() {
|
||||
const row = await db('admin_users').count({ c: '*' }).first();
|
||||
return Number(row?.c || 0) === 0;
|
||||
@@ -173,4 +193,11 @@ async function createInitialAdmin({ token, email, password, ip }) {
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { getSetupStatus, ensureSetupToken, verifySetupToken, createInitialAdmin };
|
||||
module.exports = {
|
||||
getSetupStatus,
|
||||
ensureSetupToken,
|
||||
verifySetupToken,
|
||||
createInitialAdmin,
|
||||
isSetupWizardCompleted,
|
||||
markSetupWizardCompleted,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* Category order resolution (#782).
|
||||
*
|
||||
* Resolves an event's categories into their effective display order, layering:
|
||||
* 1. per-event override — event_category_order.position, when the event has
|
||||
* been customised;
|
||||
* 2. the global default — photo_categories.display_order (migration 159);
|
||||
* 3. name.
|
||||
*
|
||||
* Globals and event-specific categories are ordered together so a custom order
|
||||
* can interleave them into the flow of the day. Shared by the admin event view
|
||||
* and the public gallery so the two never diverge.
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('./dbCompat');
|
||||
const { hasColumnCached } = require('./schemaCache');
|
||||
|
||||
/**
|
||||
* @param {number|string} eventId
|
||||
* @param {object} [opts]
|
||||
* @param {number[]|null} [opts.onlyIds] restrict to these category ids (the
|
||||
* public gallery only shows categories that actually have photos).
|
||||
* @param {string[]|null} [opts.select] qualified columns to select (default
|
||||
* `c.*`). Always aliased to the `photo_categories as c` table.
|
||||
* @returns rows with an added `override_position` (null when not customised).
|
||||
*/
|
||||
async function getEventCategoriesOrdered(eventId, { onlyIds = null, select = null } = {}) {
|
||||
const eid = parseInt(eventId, 10);
|
||||
|
||||
const base = db('photo_categories as c').where(function () {
|
||||
this.where('c.is_global', formatBoolean(true)).orWhere('c.event_id', eid);
|
||||
});
|
||||
if (onlyIds) base.whereIn('c.id', onlyIds);
|
||||
|
||||
// Fail safe: if the override table isn't present yet (half-applied migration),
|
||||
// fall back to the global-default order so the public gallery never 500s.
|
||||
const overrideReady = await hasColumnCached('event_category_order', 'position');
|
||||
if (!overrideReady) {
|
||||
return base
|
||||
.select(select || 'c.*')
|
||||
.orderBy('c.is_global', 'desc')
|
||||
.orderBy('c.display_order', 'asc')
|
||||
.orderBy('c.name', 'asc');
|
||||
}
|
||||
|
||||
const cols = select ? [...select] : ['c.*'];
|
||||
cols.push('o.position as override_position');
|
||||
|
||||
return base
|
||||
.leftJoin('event_category_order as o', function () {
|
||||
this.on('o.category_id', 'c.id').andOnVal('o.event_id', '=', eid);
|
||||
})
|
||||
.select(cols)
|
||||
// Overridden categories first (in their pinned order), then the rest by the
|
||||
// global default. CASE keeps NULL-ordering portable across SQLite + Postgres.
|
||||
.orderByRaw('CASE WHEN o.position IS NULL THEN 1 ELSE 0 END ASC')
|
||||
.orderBy('o.position', 'asc')
|
||||
.orderBy('c.is_global', 'desc')
|
||||
.orderBy('c.display_order', 'asc')
|
||||
.orderBy('c.name', 'asc');
|
||||
}
|
||||
|
||||
module.exports = { getEventCategoriesOrdered };
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "3.83.0-beta.0",
|
||||
"version": "3.88.0-beta.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -15,11 +15,13 @@ import {
|
||||
Workflow,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
Github,
|
||||
} from 'lucide-react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { VersionInfo } from './VersionInfo';
|
||||
import { repoUrl } from '../../utils/githubReleaseUrl';
|
||||
import { usePermissions } from '../../contexts/PermissionsContext';
|
||||
import { useAdminDarkMode } from '../../contexts/AdminDarkModeContext';
|
||||
import { useFeatureFlags, type FeatureKey } from '../../contexts/FeatureFlagsContext';
|
||||
@@ -324,6 +326,20 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose, col
|
||||
|
||||
{/* Storage Info */}
|
||||
<StorageInfo />
|
||||
|
||||
{/* Link to the project on GitHub (#778). Subtle footer row so
|
||||
admins can reach the repo — star, source, report an issue —
|
||||
from anywhere in the dashboard, not just the setup screen. */}
|
||||
<a
|
||||
href={repoUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mx-4 mb-3 flex items-center gap-2 text-xs text-neutral-500 dark:text-neutral-400 hover:text-neutral-700 dark:hover:text-neutral-200 transition-colors"
|
||||
title={t('admin.viewOnGithub', 'View PicPeak on GitHub')}
|
||||
>
|
||||
<Github className="w-3.5 h-3.5" />
|
||||
<span>{t('admin.viewOnGithub', 'View PicPeak on GitHub')}</span>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, Edit2, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Plus, Edit2, Trash2, Loader2, ArrowUp, ArrowDown } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { Button } from '../common';
|
||||
@@ -13,12 +13,36 @@ export const CategoryManager: React.FC = () => {
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [editingName, setEditingName] = useState('');
|
||||
|
||||
// Fetch global categories
|
||||
// Fetch global categories (ordered by the global default display_order)
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['global-categories'],
|
||||
queryFn: categoriesService.getGlobalCategories,
|
||||
});
|
||||
|
||||
// Local copy so the up/down reorder buttons feel instant; resynced when the
|
||||
// query data changes.
|
||||
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
|
||||
useEffect(() => {
|
||||
setOrdered(categories);
|
||||
}, [categories]);
|
||||
|
||||
// Set the GLOBAL default order (#782). Applies to every gallery that hasn't
|
||||
// set its own per-event override.
|
||||
const reorderMutation = useMutationWithToast({
|
||||
mutationFn: (orderedIds: number[]) => categoriesService.reorderGlobalCategories(orderedIds),
|
||||
invalidateKeys: [['global-categories']],
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
const handleMove = (index: number, dir: -1 | 1) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setOrdered(next); // optimistic
|
||||
reorderMutation.mutate(next.map((c) => c.id));
|
||||
};
|
||||
|
||||
// Create category mutation
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
@@ -144,12 +168,12 @@ export const CategoryManager: React.FC = () => {
|
||||
|
||||
{/* Categories list */}
|
||||
<div className="space-y-2">
|
||||
{categories.length === 0 ? (
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-neutral-500 dark:text-neutral-400 text-center py-8">
|
||||
{t('categories.noCategoriesYet')}
|
||||
</p>
|
||||
) : (
|
||||
categories.map((category) => (
|
||||
ordered.map((category, index) => (
|
||||
<div
|
||||
key={category.id}
|
||||
className="flex items-center justify-between p-3 bg-white dark:bg-neutral-800 rounded-lg border border-neutral-200 dark:border-neutral-700 hover:border-neutral-300 dark:hover:border-neutral-600 transition-colors"
|
||||
@@ -189,9 +213,33 @@ export const CategoryManager: React.FC = () => {
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div>
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400">/{category.slug}</p>
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
{/* Global default order (#782). The gallery uses this order
|
||||
unless a specific event overrides it. */}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<button
|
||||
onClick={() => handleMove(index, -1)}
|
||||
disabled={index === 0 || reorderMutation.isPending}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveUp', 'Move up')}
|
||||
aria-label={t('categories.moveUp', 'Move up')}
|
||||
>
|
||||
<ArrowUp className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(index, 1)}
|
||||
disabled={index === ordered.length - 1 || reorderMutation.isPending}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveDown', 'Move down')}
|
||||
aria-label={t('categories.moveDown', 'Move down')}
|
||||
>
|
||||
<ArrowDown className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-w-0">
|
||||
<p className="font-medium text-neutral-900 dark:text-neutral-100 truncate">{category.name}</p>
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 truncate">/{category.slug}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Image as ImageIcon, Check, Download, DownloadCloud, ArrowUp, ArrowDown, RotateCcw } from 'lucide-react';
|
||||
import { categoriesService, type PhotoCategory } from '../../services/categories.service';
|
||||
import { photosService } from '../../services/photos.service';
|
||||
import { Button, Card, AuthenticatedImage } from '../common';
|
||||
@@ -17,7 +17,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
const [heroPickerCategoryId, setHeroPickerCategoryId] = useState<number | null>(null);
|
||||
|
||||
// Fetch categories for this event
|
||||
// Fetch this event's categories (globals + event-specific), already resolved
|
||||
// to the event's effective order by the backend (#782).
|
||||
const { data: categories = [], isLoading } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
@@ -30,17 +31,21 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
enabled: heroPickerCategoryId !== null,
|
||||
});
|
||||
|
||||
// Filter to show only event-specific categories
|
||||
const eventCategories = categories.filter(cat => !cat.is_global);
|
||||
// Combined list (globals + event-specific) in the resolved order, kept in
|
||||
// local state so the up/down reorder buttons feel instant; resynced whenever
|
||||
// the query data changes (e.g. after a reorder or reset persists).
|
||||
const [ordered, setOrdered] = useState<PhotoCategory[]>(categories);
|
||||
useEffect(() => {
|
||||
setOrdered(categories);
|
||||
}, [categories]);
|
||||
|
||||
// Create category mutation
|
||||
// The event is "customised" when it has its own per-event override.
|
||||
const isCustomised = ordered.some((c) => c.override_position != null);
|
||||
|
||||
// Create category mutation (always event-specific)
|
||||
const createMutation = useMutationWithToast({
|
||||
mutationFn: (name: string) =>
|
||||
categoriesService.createCategory({
|
||||
name,
|
||||
is_global: false,
|
||||
event_id: eventId
|
||||
}),
|
||||
categoriesService.createCategory({ name, is_global: false, event_id: eventId }),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.categoryCreatedSuccess'),
|
||||
onSuccess: () => {
|
||||
@@ -71,9 +76,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToSetCoverPhoto'),
|
||||
});
|
||||
|
||||
// Toggle per-category download permission (#640). The backend AND's this
|
||||
// with the event-level `allow_downloads`, so disabling at either level
|
||||
// blocks downloads for this category's photos.
|
||||
// Toggle per-category download permission (#640). Event-specific only.
|
||||
const downloadToggleMutation = useMutationWithToast({
|
||||
mutationFn: ({ category, allow }: { category: PhotoCategory; allow: boolean }) =>
|
||||
categoriesService.updateCategory(category.id, category.name, { allow_downloads: allow }),
|
||||
@@ -85,6 +88,32 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
errorMessage: t('categories.failedToToggleDownloads', 'Failed to update download permission'),
|
||||
});
|
||||
|
||||
// Per-event order override (#782). Sends the full ordered id list; the backend
|
||||
// pins it for this gallery only. Up/down buttons match the invoice line-item
|
||||
// convention (no drag-and-drop dependency).
|
||||
const reorderMutation = useMutationWithToast({
|
||||
mutationFn: (orderedIds: number[]) => categoriesService.reorderCategories(eventId, orderedIds),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
// Revert this gallery to the global default order.
|
||||
const resetMutation = useMutationWithToast({
|
||||
mutationFn: () => categoriesService.resetEventOrder(eventId),
|
||||
invalidateKeys: [['event-categories', eventId]],
|
||||
successMessage: t('categories.orderReset', 'Reverted to the default order'),
|
||||
errorMessage: t('categories.failedToReorder', 'Failed to update category order'),
|
||||
});
|
||||
|
||||
const handleMove = (index: number, dir: -1 | 1) => {
|
||||
const target = index + dir;
|
||||
if (target < 0 || target >= ordered.length) return;
|
||||
const next = [...ordered];
|
||||
[next[index], next[target]] = [next[target], next[index]];
|
||||
setOrdered(next); // optimistic — instant feedback
|
||||
reorderMutation.mutate(next.map((c) => c.id));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (newCategoryName.trim()) {
|
||||
createMutation.mutate(newCategoryName.trim());
|
||||
@@ -105,6 +134,8 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
heroMutation.mutate({ categoryId, photoId: null });
|
||||
};
|
||||
|
||||
const busy = reorderMutation.isPending || resetMutation.isPending;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center py-4">
|
||||
@@ -115,23 +146,38 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.eventSpecificCategories')}</h3>
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex justify-between items-center gap-2">
|
||||
<h3 className="text-sm font-medium text-neutral-700 dark:text-neutral-300">{t('categories.galleryOrder', 'Gallery order')}</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
{isCustomised && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => resetMutation.mutate()}
|
||||
disabled={busy}
|
||||
leftIcon={<RotateCcw className="w-3 h-3" />}
|
||||
>
|
||||
{t('categories.resetToDefault', 'Reset to default')}
|
||||
</Button>
|
||||
)}
|
||||
{!addingModal.isOpen && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={addingModal.open}
|
||||
leftIcon={<Plus className="w-3 h-3" />}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint about hero photo fallback */}
|
||||
{/* Explain the two ordering layers */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
{isCustomised
|
||||
? t('categories.orderCustomisedHint', 'This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).')
|
||||
: t('categories.orderDefaultHint', 'Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).')}
|
||||
</p>
|
||||
|
||||
{/* Add new category form */}
|
||||
@@ -152,11 +198,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
onClick={handleCreate}
|
||||
disabled={!newCategoryName.trim() || createMutation.isPending}
|
||||
>
|
||||
{createMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
t('common.add')
|
||||
)}
|
||||
{createMutation.isPending ? <Loader2 className="w-3 h-3 animate-spin" /> : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -171,14 +213,14 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Event categories list */}
|
||||
{eventCategories.length === 0 ? (
|
||||
{/* Combined, reorderable category list (globals + event-specific) */}
|
||||
{ordered.length === 0 ? (
|
||||
<p className="text-sm text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.noEventSpecificCategories')}
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{eventCategories.map((category) => {
|
||||
{ordered.map((category, index) => {
|
||||
const heroPhoto = category.hero_photo_id
|
||||
? photos.find(p => p.id === category.hero_photo_id)
|
||||
: null;
|
||||
@@ -187,7 +229,30 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
key={category.id}
|
||||
className="flex items-center justify-between px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
{/* Reorder controls (#782). The gallery renders categories in
|
||||
this order; changes here override the global default for
|
||||
this event only. */}
|
||||
<div className="flex flex-col -space-y-1">
|
||||
<button
|
||||
onClick={() => handleMove(index, -1)}
|
||||
disabled={index === 0 || busy}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveUp', 'Move up')}
|
||||
aria-label={t('categories.moveUp', 'Move up')}
|
||||
>
|
||||
<ArrowUp className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleMove(index, 1)}
|
||||
disabled={index === ordered.length - 1 || busy}
|
||||
className="p-0.5 text-neutral-400 dark:text-neutral-500 hover:text-accent-dark disabled:opacity-30 disabled:hover:text-neutral-400 transition-colors"
|
||||
title={t('categories.moveDown', 'Move down')}
|
||||
aria-label={t('categories.moveDown', 'Move down')}
|
||||
>
|
||||
<ArrowDown className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Hero photo thumbnail */}
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(category.id)}
|
||||
@@ -207,49 +272,56 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-700 dark:text-neutral-300 truncate">{category.name}</span>
|
||||
{category.is_global && (
|
||||
<span className="flex-shrink-0 text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-neutral-200 dark:bg-neutral-700 text-neutral-500 dark:text-neutral-400">
|
||||
{t('categories.sharedBadge', 'Shared')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{/* Per-category downloads toggle (#640). Green DownloadCloud
|
||||
icon when on, struck-through outline when off. The
|
||||
event-level `allow_downloads` AND's with this — if the
|
||||
whole event has downloads off, this toggle is cosmetic. */}
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
{/* Download toggle + delete apply to event-specific categories
|
||||
only. Global categories are managed in Settings. */}
|
||||
{!category.is_global && (
|
||||
<>
|
||||
<button
|
||||
onClick={() => downloadToggleMutation.mutate({
|
||||
category,
|
||||
allow: category.allow_downloads === false,
|
||||
})}
|
||||
className={`p-1 transition-colors ${
|
||||
category.allow_downloads === false
|
||||
? 'text-neutral-400 dark:text-neutral-500 hover:text-green-600 dark:hover:text-green-400'
|
||||
: 'text-green-600 dark:text-green-400 hover:text-neutral-400'
|
||||
}`}
|
||||
title={
|
||||
category.allow_downloads === false
|
||||
? t('categories.enableDownloadsTitle', 'Click to enable downloads for this category')
|
||||
: t('categories.disableDownloadsTitle', 'Click to disable downloads for this category')
|
||||
}
|
||||
disabled={downloadToggleMutation.isPending}
|
||||
>
|
||||
{downloadToggleMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : category.allow_downloads === false ? (
|
||||
<Download className="w-3 h-3" />
|
||||
) : (
|
||||
<DownloadCloud className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(category)}
|
||||
className="p-1 text-neutral-400 dark:text-neutral-500 hover:text-red-600 dark:hover:text-red-400 transition-colors"
|
||||
title={t('categories.deleteCategoryTitle')}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<Loader2 className="w-3 h-3 animate-spin" />
|
||||
) : (
|
||||
<X className="w-3 h-3" />
|
||||
)}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -257,41 +329,10 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Show available global categories */}
|
||||
<div className="mt-4 pt-3 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<p className="text-xs font-medium text-neutral-500 dark:text-neutral-400 mb-2">{t('categories.globalCategoriesAlwaysAvailable')}</p>
|
||||
<div className="space-y-2">
|
||||
{categories
|
||||
.filter(cat => cat.is_global)
|
||||
.map(cat => {
|
||||
const heroPhoto = cat.hero_photo_id
|
||||
? photos.find(p => p.id === cat.hero_photo_id)
|
||||
: null;
|
||||
return (
|
||||
<div key={cat.id} className="flex items-center gap-3 px-3 py-2 bg-neutral-50 dark:bg-neutral-800 rounded-md">
|
||||
<button
|
||||
onClick={() => setHeroPickerCategoryId(cat.id)}
|
||||
className="flex-shrink-0 w-10 h-10 rounded border border-neutral-200 dark:border-neutral-700 overflow-hidden bg-neutral-100 dark:bg-neutral-700 hover:border-accent-dark transition-colors flex items-center justify-center"
|
||||
title={t('categories.setCoverPhoto')}
|
||||
>
|
||||
{heroPhoto ? (
|
||||
<AuthenticatedImage
|
||||
src={heroPhoto.thumbnail_url || heroPhoto.url}
|
||||
alt={cat.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : cat.hero_photo_id ? (
|
||||
<ImageIcon className="w-4 h-4 text-accent" />
|
||||
) : (
|
||||
<ImageIcon className="w-4 h-4 text-neutral-300" />
|
||||
)}
|
||||
</button>
|
||||
<span className="text-sm text-neutral-600 dark:text-neutral-400">{cat.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
{/* Hint about hero photo fallback */}
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400 italic">
|
||||
{t('categories.categoryHeroHint')}
|
||||
</p>
|
||||
|
||||
{/* Hero Photo Picker Modal */}
|
||||
{heroPickerCategoryId !== null && (
|
||||
@@ -317,7 +358,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
) : (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
||||
{photos.map((photo) => {
|
||||
const currentCategory = categories.find(c => c.id === heroPickerCategoryId);
|
||||
const currentCategory = ordered.find(c => c.id === heroPickerCategoryId);
|
||||
const isSelected = photo.id === currentCategory?.hero_photo_id;
|
||||
return (
|
||||
<div
|
||||
@@ -337,7 +378,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
/>
|
||||
</div>
|
||||
{isSelected && (
|
||||
<div className="absolute top-2 right-2 bg-accent-dark/150 text-white rounded-full p-1">
|
||||
<div className="absolute top-2 right-2 bg-accent-dark text-white rounded-full p-1">
|
||||
<Check className="w-4 h-4" />
|
||||
</div>
|
||||
)}
|
||||
@@ -352,7 +393,7 @@ export const EventCategoryManager: React.FC<EventCategoryManagerProps> = ({ even
|
||||
</div>
|
||||
|
||||
<div className="p-6 border-t border-neutral-200 dark:border-neutral-700 flex justify-between gap-3">
|
||||
{categories.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
{ordered.find(c => c.id === heroPickerCategoryId)?.hero_photo_id && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleRemoveHeroPhoto(heroPickerCategoryId)}
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
|
||||
import { Button, Input, Loading } from '../common';
|
||||
import { eventTypesService, EventType } from '../../services/eventTypes.service';
|
||||
|
||||
interface Props {
|
||||
onDone: () => void;
|
||||
}
|
||||
|
||||
// One editable row of the wizard's event-type list. Existing rows carry the
|
||||
// catalog id; rows added in the wizard have no id until Continue POSTs them.
|
||||
interface RowState {
|
||||
id?: number;
|
||||
name: string;
|
||||
slug_prefix: string;
|
||||
emoji: string;
|
||||
}
|
||||
|
||||
const normalizeSlug = (value: string) => value.toLowerCase().replace(/[^a-z0-9-]/g, '-');
|
||||
|
||||
// First-run event-types step (#800). Shown once, during the setup wizard —
|
||||
// the only window in which the seeded SYSTEM types may be deleted (nothing
|
||||
// references them yet; the backend re-locks them when the wizard finishes).
|
||||
// Deliberately lean: name + URL prefix only. Icons, themes and ordering are
|
||||
// tunable later in Settings → Event Types.
|
||||
export const SetupEventTypesStep: React.FC<Props> = ({ onDone }) => {
|
||||
const { t } = useTranslation();
|
||||
const [rows, setRows] = useState<RowState[] | null>(null);
|
||||
const [original, setOriginal] = useState<Map<number, EventType>>(new Map());
|
||||
const [deletedIds, setDeletedIds] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { isLoading, isError } = useQuery({
|
||||
queryKey: ['setup-event-types'],
|
||||
queryFn: async () => {
|
||||
const types = await eventTypesService.getEventTypes();
|
||||
// Initialize once — a re-run (remount) must not clobber in-progress edits.
|
||||
setOriginal((prev) => (prev.size > 0 ? prev : new Map(types.map((et) => [et.id, et]))));
|
||||
setRows((prev) => prev ?? types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
|
||||
return types;
|
||||
},
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const setRow = (index: number, patch: Partial<RowState>) => {
|
||||
setRows((prev) => (prev ? prev.map((r, i) => (i === index ? { ...r, ...patch } : r)) : prev));
|
||||
};
|
||||
|
||||
const removeRow = (index: number) => {
|
||||
// No side effects inside the setRows updater — StrictMode double-invokes
|
||||
// updaters, which would enqueue the same id twice (one DELETE 404s and
|
||||
// shows a false "could not save" warning).
|
||||
const row = rows?.[index];
|
||||
if (!row) return;
|
||||
if (row.id !== undefined) {
|
||||
setDeletedIds((ids) => (ids.includes(row.id!) ? ids : [...ids, row.id!]));
|
||||
}
|
||||
setRows((prev) => (prev ? prev.filter((_, i) => i !== index) : prev));
|
||||
};
|
||||
|
||||
const addRow = () => {
|
||||
setRows((prev) => (prev ? [...prev, { name: '', slug_prefix: '', emoji: '📷' }] : prev));
|
||||
};
|
||||
|
||||
// Apply the diff, then advance. Ordering matters twice over: deletes first
|
||||
// frees a default's slug for a rename/re-create ("replace Wedding with my
|
||||
// own 'wedding'"), but deleting everything BEFORE a replacement exists could
|
||||
// empty the catalog if the creation then fails. So: when at least one
|
||||
// existing row is kept the catalog can never go empty → delete first; when
|
||||
// the user replaces ALL types → create first and only delete once at least
|
||||
// one replacement actually persisted. (The backend additionally refuses
|
||||
// deleting the last remaining type.) Best-effort like the other wizard
|
||||
// steps — a partial failure warns but never traps the user; everything here
|
||||
// is editable later in Settings → Event Types.
|
||||
const handleContinue = async () => {
|
||||
if (!rows) return;
|
||||
const kept = rows.filter((r) => r.name.trim() && r.slug_prefix.trim());
|
||||
if (kept.length === 0) {
|
||||
toast.error(t('setup.eventTypes.atLeastOne'));
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
let failures = 0;
|
||||
let deleteFailures = 0;
|
||||
let createdOk = 0;
|
||||
|
||||
const applyDeletes = async () => {
|
||||
for (const id of deletedIds) {
|
||||
try {
|
||||
await eventTypesService.deleteEventType(id);
|
||||
} catch {
|
||||
deleteFailures += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
const applyCreatesAndUpdates = async () => {
|
||||
for (const row of kept) {
|
||||
try {
|
||||
if (row.id !== undefined) {
|
||||
const before = original.get(row.id);
|
||||
const updates: { name?: string; slug_prefix?: string } = {};
|
||||
if (before && row.name.trim() !== before.name) updates.name = row.name.trim();
|
||||
if (before && row.slug_prefix !== before.slug_prefix) updates.slug_prefix = row.slug_prefix;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await eventTypesService.updateEventType(row.id, updates);
|
||||
}
|
||||
} else {
|
||||
await eventTypesService.createEventType({
|
||||
name: row.name.trim(),
|
||||
slug_prefix: row.slug_prefix,
|
||||
emoji: row.emoji,
|
||||
});
|
||||
createdOk += 1;
|
||||
}
|
||||
} catch {
|
||||
failures += 1;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const keptExisting = kept.filter((r) => r.id !== undefined).length;
|
||||
if (keptExisting > 0) {
|
||||
await applyDeletes();
|
||||
await applyCreatesAndUpdates();
|
||||
} else {
|
||||
await applyCreatesAndUpdates();
|
||||
if (deletedIds.length > 0 && createdOk === 0) {
|
||||
// Every replacement failed — deleting now would empty the catalog.
|
||||
// Keep the seeded types and stay on the step.
|
||||
setSaving(false);
|
||||
toast.error(t('setup.eventTypes.atLeastOne'));
|
||||
return;
|
||||
}
|
||||
await applyDeletes();
|
||||
}
|
||||
|
||||
// A failed DELETE must not slip past this step: system types are only
|
||||
// deletable inside this window, so once the wizard finishes the request
|
||||
// can never be retried. Reload the live catalog and stay for a retry.
|
||||
if (deleteFailures > 0) {
|
||||
try {
|
||||
const types = await eventTypesService.getEventTypes();
|
||||
setOriginal(new Map(types.map((et) => [et.id, et])));
|
||||
setRows(types.map((et) => ({ id: et.id, name: et.name, slug_prefix: et.slug_prefix, emoji: et.emoji })));
|
||||
} catch { /* keep the local rows if the reload fails */ }
|
||||
setDeletedIds([]);
|
||||
setSaving(false);
|
||||
toast.error(t('setup.eventTypes.deleteFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(false);
|
||||
if (failures > 0) toast.warn(t('setup.eventTypes.saveFailed'));
|
||||
onDone();
|
||||
};
|
||||
|
||||
if (isLoading || rows === null) {
|
||||
return isError ? (
|
||||
// Catalog unreadable — don't trap the user; the defaults stay seeded and
|
||||
// remain editable later in Settings → Event Types.
|
||||
<div className="space-y-6">
|
||||
<p className="text-sm text-neutral-600">{t('setup.eventTypes.loadFailed')}</p>
|
||||
<Button type="button" variant="primary" size="lg" className="w-full" onClick={onDone}>
|
||||
{t('setup.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Loading />
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<p className="rounded-lg bg-neutral-50 border border-neutral-200 px-3 py-2 text-xs text-neutral-600">
|
||||
{t('setup.eventTypes.intro')}
|
||||
</p>
|
||||
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, index) => (
|
||||
<div key={row.id ?? `new-${index}`} className="flex items-center gap-2">
|
||||
<span className="w-8 text-center text-xl flex-shrink-0" aria-hidden="true">{row.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(e) => setRow(index, { name: e.target.value })}
|
||||
placeholder={t('setup.eventTypes.namePlaceholder')}
|
||||
aria-label={t('setup.eventTypes.nameLabel')}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-32 flex-shrink-0">
|
||||
<Input
|
||||
value={row.slug_prefix}
|
||||
onChange={(e) => setRow(index, { slug_prefix: normalizeSlug(e.target.value) })}
|
||||
placeholder={t('setup.eventTypes.slugPlaceholder')}
|
||||
aria-label={t('setup.eventTypes.slugLabel')}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeRow(index)}
|
||||
className="flex-shrink-0 p-2 rounded-lg text-neutral-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||
aria-label={t('common.delete', 'Delete')}
|
||||
title={t('common.delete', 'Delete')}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={addRow}
|
||||
className="w-full rounded-lg border border-dashed border-neutral-300 p-3 text-left hover:bg-neutral-50 transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4 text-neutral-500" />
|
||||
<span className="text-sm font-medium text-neutral-800">{t('setup.eventTypes.add')}</span>
|
||||
</button>
|
||||
|
||||
<p className="text-xs text-neutral-500">{t('setup.eventTypes.hint')}</p>
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
size="lg"
|
||||
isLoading={saving}
|
||||
className="w-full"
|
||||
onClick={handleContinue}
|
||||
>
|
||||
{t('setup.continue')}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
SetupEventTypesStep.displayName = 'SetupEventTypesStep';
|
||||
@@ -16,11 +16,13 @@
|
||||
* POST .../slideshow/{generate,disable}.
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { MonitorPlay, Copy, CheckCircle, RotateCw, Trash2, Save } from 'lucide-react';
|
||||
import { Button, Card } from '../common';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { DEFAULT_SLIDESHOW_STYLE, type SlideshowStyle } from '../../services/slideshow.service';
|
||||
import { SlideshowStyleFields } from './SlideshowStyleFields';
|
||||
|
||||
@@ -35,6 +37,8 @@ export interface SlideshowSettingsCardProps {
|
||||
show_transition_ms?: number;
|
||||
show_watermark?: boolean | null;
|
||||
show_colorfilter?: string;
|
||||
show_order?: string;
|
||||
show_category_id?: number | null;
|
||||
};
|
||||
onChanged?: () => void;
|
||||
}
|
||||
@@ -52,6 +56,8 @@ function styleFromInitial(initial: SlideshowSettingsCardProps['initial']): Slide
|
||||
transition_ms: initial.show_transition_ms ?? DEFAULT_SLIDESHOW_STYLE.transition_ms,
|
||||
watermark: watermarkMode(initial.show_watermark),
|
||||
colorfilter: (initial.show_colorfilter as SlideshowStyle['colorfilter']) ?? DEFAULT_SLIDESHOW_STYLE.colorfilter,
|
||||
order: (initial.show_order as SlideshowStyle['order']) ?? DEFAULT_SLIDESHOW_STYLE.order,
|
||||
category_id: initial.show_category_id ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -68,6 +74,14 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
||||
|
||||
const link = token ? `${window.location.origin}/gallery/${slug}/show/${token}` : '';
|
||||
|
||||
// Event categories for the slideshow content filter (#202). Global + this
|
||||
// event's own categories; empty for events without any → picker hides.
|
||||
const { data: categories = [] } = useQuery({
|
||||
queryKey: ['event-categories', eventId],
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const generate = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
@@ -129,6 +143,8 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
||||
// is global-only (Settings → Slideshow); we only send the mode here.
|
||||
show_watermark: style.watermark === 'inherit' ? null : style.watermark === 'on',
|
||||
show_colorfilter: style.colorfilter,
|
||||
show_order: style.order,
|
||||
show_category_id: style.category_id,
|
||||
});
|
||||
toast.success(t('slideshow.settingsSaved', 'Slideshow settings saved'));
|
||||
onChanged?.();
|
||||
@@ -208,7 +224,7 @@ export const SlideshowSettingsCard: React.FC<SlideshowSettingsCardProps> = ({
|
||||
|
||||
{/* Live style settings */}
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
<SlideshowStyleFields value={style} onChange={setStyle} />
|
||||
<SlideshowStyleFields value={style} onChange={setStyle} categories={categories} />
|
||||
</div>
|
||||
<p className="text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('slideshow.liveHint', 'Changes apply to a running slideshow within a few seconds — no need to regenerate the link.')}
|
||||
|
||||
@@ -15,12 +15,17 @@ import {
|
||||
SLIDESHOW_TRANSITIONS,
|
||||
SLIDESHOW_COLORFILTERS,
|
||||
SLIDESHOW_WATERMARK_MODES,
|
||||
SLIDESHOW_ORDERS,
|
||||
type SlideshowStyle,
|
||||
} from '../../services/slideshow.service';
|
||||
import type { PhotoCategory } from '../../services/categories.service';
|
||||
|
||||
export interface SlideshowStyleFieldsProps {
|
||||
value: SlideshowStyle;
|
||||
onChange: (next: SlideshowStyle) => void;
|
||||
/** Event categories for the content filter (#202). Omitted/empty → the
|
||||
* category picker is hidden (e.g. events without any categories). */
|
||||
categories?: PhotoCategory[];
|
||||
}
|
||||
|
||||
const inputClass =
|
||||
@@ -29,7 +34,7 @@ const labelClass = 'block text-sm font-medium text-neutral-700 dark:text-neutral
|
||||
|
||||
const titleCase = (s: string) => s.charAt(0).toUpperCase() + s.slice(1);
|
||||
|
||||
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange }) => {
|
||||
export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ value, onChange, categories = [] }) => {
|
||||
const { t } = useTranslation();
|
||||
const set = (patch: Partial<SlideshowStyle>) => onChange({ ...value, ...patch });
|
||||
|
||||
@@ -92,6 +97,39 @@ export const SlideshowStyleFields: React.FC<SlideshowStyleFieldsProps> = ({ valu
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Play order + content filter (#202) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.orderLabel', 'Play order')}</label>
|
||||
<select
|
||||
value={value.order}
|
||||
onChange={(e) => set({ order: e.target.value as SlideshowStyle['order'] })}
|
||||
className={inputClass}
|
||||
>
|
||||
{SLIDESHOW_ORDERS.map((o) => (
|
||||
<option key={o} value={o}>
|
||||
{t(`slideshow.order.${o}`, o === 'random' ? 'Random (shuffle)' : 'Chronological')}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{categories.length > 0 && (
|
||||
<div>
|
||||
<label className={labelClass}>{t('slideshow.categoryLabel', 'Show only category')}</label>
|
||||
<select
|
||||
value={value.category_id ?? ''}
|
||||
onChange={(e) => set({ category_id: e.target.value === '' ? null : parseInt(e.target.value, 10) })}
|
||||
className={inputClass}
|
||||
>
|
||||
<option value="">{t('slideshow.categoryAll', 'All photos')}</option>
|
||||
{categories.map((c) => (
|
||||
<option key={c.id} value={c.id}>{c.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Watermark — MODE only. The look (logo/position/opacity/style/size)
|
||||
lives in Settings → Slideshow, so it isn't duplicated here. */}
|
||||
<div className="pt-2 border-t border-neutral-200 dark:border-neutral-700">
|
||||
|
||||
@@ -154,39 +154,45 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
|
||||
{/* Category and Feedback Filters */}
|
||||
<div className="space-y-3">
|
||||
{/* Categories Row */}
|
||||
{categories && categories.length > 0 && (
|
||||
{/* Categories + desktop feedback row. Rendered whenever EITHER part
|
||||
has content: the desktop feedback chips must not depend on the
|
||||
(optional) categories existing, or category-less galleries show
|
||||
no feedback filter at all on desktop (#802 — the lg:hidden
|
||||
fallback block below only covers mobile/tablet). */}
|
||||
{((categories && categories.length > 0) || (feedbackEnabled && !!onFilterChange)) && (
|
||||
<div className="flex items-start lg:items-center justify-between flex-col lg:flex-row gap-3">
|
||||
{/* Categories: keep in a horizontal scroll container */}
|
||||
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
|
||||
<div className="flex items-center gap-2 min-w-max">
|
||||
<Button
|
||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
|
||||
</Button>
|
||||
{categories.map((category) => {
|
||||
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
|
||||
if (categoryPhotoCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{category.name} ({categoryPhotoCount})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
{categories && categories.length > 0 && (
|
||||
<div className="w-full overflow-x-auto pb-2 lg:pb-0">
|
||||
<div className="flex items-center gap-2 min-w-max">
|
||||
<Button
|
||||
variant={selectedCategoryId === null ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(null)}
|
||||
leftIcon={<Grid className="w-3 h-3 md:w-4 md:h-4" />}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{showMediaFilter ? t('gallery.allMedia', 'All media') : t('gallery.allPhotos')} ({photos.length})
|
||||
</Button>
|
||||
{categories.map((category) => {
|
||||
const categoryPhotoCount = photos.filter(p => p.category_id === category.id).length;
|
||||
if (categoryPhotoCount === 0) return null;
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={category.id}
|
||||
variant={selectedCategoryId === category.id ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onCategoryChange(category.id)}
|
||||
className="text-xs md:text-sm whitespace-nowrap flex-shrink-0"
|
||||
>
|
||||
{category.name} ({categoryPhotoCount})
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Desktop: compact horizontal feedback filter with headline (icons only) */}
|
||||
{feedbackEnabled && onFilterChange && (
|
||||
@@ -244,7 +250,10 @@ export const PhotoFilterBar: React.FC<PhotoFilterBarProps> = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto">
|
||||
{/* Without categories this row only carries desktop content (the
|
||||
chips are lg-only; mobile has its own block below), so hide
|
||||
the count below lg to keep the mobile layout unchanged. */}
|
||||
<p className={`text-xs md:text-sm text-muted-theme flex-shrink-0 ml-auto ${categories && categories.length > 0 ? '' : 'hidden lg:block'}`}>
|
||||
{photoCount} {t('common.media', 'media')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Regression coverage for #802: the desktop feedback-filter chips
|
||||
* (All / Likes / Saved / Rated / Commented) were nested inside the
|
||||
* categories row, so a gallery WITHOUT photo categories (the default)
|
||||
* rendered no feedback filter at all on desktop — the standalone
|
||||
* fallback block is lg:hidden (mobile/tablet only). These tests pin
|
||||
* that both chip groups exist in the DOM regardless of categories.
|
||||
*/
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { PhotoFilterBar } from '../PhotoFilterBar';
|
||||
|
||||
vi.mock('react-i18next', async () => {
|
||||
const actual = await vi.importActual<typeof import('react-i18next')>('react-i18next');
|
||||
return {
|
||||
...actual,
|
||||
useTranslation: () => ({
|
||||
t: (_key: string, fallback?: unknown) =>
|
||||
typeof fallback === 'string' ? fallback : _key,
|
||||
i18n: { language: 'en' }
|
||||
})
|
||||
};
|
||||
});
|
||||
|
||||
const baseProps = {
|
||||
categories: [] as Array<{ id: number; name: string; slug: string }>,
|
||||
photos: [] as never[],
|
||||
selectedCategoryId: null,
|
||||
onCategoryChange: vi.fn(),
|
||||
searchTerm: '',
|
||||
onSearchChange: vi.fn(),
|
||||
sortBy: 'date' as const,
|
||||
onSortChange: vi.fn(),
|
||||
photoCount: 0,
|
||||
};
|
||||
|
||||
describe('PhotoFilterBar feedback chips (#802)', () => {
|
||||
it('renders both chip groups (desktop lg:flex + mobile lg:hidden) with NO categories', () => {
|
||||
render(
|
||||
<PhotoFilterBar
|
||||
{...baseProps}
|
||||
feedbackEnabled
|
||||
currentFilter="all"
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
// Two groups: the desktop row variant and the mobile fallback. Before
|
||||
// the fix, only the mobile one rendered when categories were empty,
|
||||
// leaving desktop with no feedback filter at all.
|
||||
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('still renders both chip groups when categories exist', () => {
|
||||
render(
|
||||
<PhotoFilterBar
|
||||
{...baseProps}
|
||||
categories={[{ id: 1, name: 'Ceremony', slug: 'ceremony' }]}
|
||||
photos={[{ id: 1, category_id: 1 } as never]}
|
||||
feedbackEnabled
|
||||
currentFilter="all"
|
||||
onFilterChange={vi.fn()}
|
||||
/>
|
||||
);
|
||||
expect(screen.getAllByText('Feedback Filter')).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('renders no chips when feedback is disabled', () => {
|
||||
render(<PhotoFilterBar {...baseProps} />);
|
||||
expect(screen.queryByText('Feedback Filter')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -947,6 +947,15 @@
|
||||
"coverPhotoRemoved": "Titelbild entfernt",
|
||||
"failedToSetCoverPhoto": "Titelbild konnte nicht festgelegt werden",
|
||||
"categoryHeroHint": "Wenn kein Titelbild für eine Kategorie festgelegt ist, wird das Standard-Hero-Foto verwendet.",
|
||||
"moveUp": "Nach oben",
|
||||
"moveDown": "Nach unten",
|
||||
"failedToReorder": "Kategorie-Reihenfolge konnte nicht aktualisiert werden",
|
||||
"galleryOrder": "Galerie-Reihenfolge",
|
||||
"resetToDefault": "Auf Standard zurücksetzen",
|
||||
"orderReset": "Auf Standardreihenfolge zurückgesetzt",
|
||||
"orderCustomisedHint": "Diese Galerie verwendet eine eigene Reihenfolge. Zurücksetzen, um der globalen Standardreihenfolge zu folgen (Einstellungen → Fotokategorien).",
|
||||
"orderDefaultHint": "Mit den Pfeilen die Reihenfolge für diese Galerie festlegen. Andernfalls gilt die globale Standardreihenfolge (Einstellungen → Fotokategorien).",
|
||||
"sharedBadge": "Geteilt",
|
||||
"downloadsEnabled": "Downloads für diese Kategorie aktiviert",
|
||||
"downloadsDisabled": "Downloads für diese Kategorie deaktiviert",
|
||||
"enableDownloadsTitle": "Klicken zum Aktivieren der Downloads für diese Kategorie",
|
||||
@@ -2411,6 +2420,7 @@
|
||||
"channelBeta": "Beta",
|
||||
"beta": "BETA",
|
||||
"viewReleaseNotes": "Versionshinweise anzeigen",
|
||||
"viewOnGithub": "PicPeak auf GitHub ansehen",
|
||||
"updateAvailableShort": "v{{version}} verfügbar",
|
||||
"upToDate": "Alles aktuell",
|
||||
"updateNow": "Jetzt aktualisieren",
|
||||
@@ -3475,6 +3485,13 @@
|
||||
"cool": "Kühl",
|
||||
"vignette": "Vignette"
|
||||
},
|
||||
"orderLabel": "Reihenfolge",
|
||||
"order": {
|
||||
"chronological": "Chronologisch",
|
||||
"random": "Zufällig (mischen)"
|
||||
},
|
||||
"categoryLabel": "Nur Kategorie zeigen",
|
||||
"categoryAll": "Alle Fotos",
|
||||
"watermarkToggle": "Logo-Wasserzeichen anzeigen",
|
||||
"watermarkDescription": "Blendet ein weißes, halbtransparentes Logo in einer Ecke ein (wie ein Senderlogo im TV).",
|
||||
"watermarkSourceLabel": "Logo",
|
||||
@@ -3594,6 +3611,20 @@
|
||||
"finish": "Einrichtung abschließen",
|
||||
"saveFailed": "Einige Einstellungen konnten nicht gespeichert werden — Sie können sie in den Einstellungen abschließen."
|
||||
},
|
||||
"eventTypes": {
|
||||
"subtitle": "Welche Veranstaltungen fotografieren Sie?",
|
||||
"intro": "Veranstaltungsarten ordnen Ihre Galerien — jede Art erhält ein eigenes URL-Präfix und Standard-Theme. Die Vorschläge unten sind nur ein Startpunkt: Benennen Sie sie um, entfernen Sie Unnötiges oder fügen Sie eigene hinzu. Nur jetzt können die mitgelieferten Arten gelöscht werden; später lassen sie sich nur umbenennen oder deaktivieren.",
|
||||
"nameLabel": "Anzeigename",
|
||||
"namePlaceholder": "z.B. Familienshooting",
|
||||
"slugLabel": "URL-Präfix",
|
||||
"slugPlaceholder": "z.B. familie",
|
||||
"add": "Veranstaltungsart hinzufügen",
|
||||
"hint": "Das URL-Präfix erscheint in Galerie-Links (z.B. familie-mueller-2025-06-01). Symbole, Themes und Reihenfolge können Sie später unter Einstellungen → Veranstaltungsarten anpassen.",
|
||||
"atLeastOne": "Behalten Sie mindestens eine Veranstaltungsart — jede Galerie braucht eine.",
|
||||
"loadFailed": "Veranstaltungsarten konnten nicht geladen werden — Sie können sie später unter Einstellungen → Veranstaltungsarten anpassen.",
|
||||
"saveFailed": "Einige Änderungen konnten nicht gespeichert werden — Sie können sie unter Einstellungen → Veranstaltungsarten abschließen.",
|
||||
"deleteFailed": "Eine Löschung ist fehlgeschlagen — die Liste wurde neu geladen. Mitgelieferte Arten können nur hier gelöscht werden; versuchen Sie es erneut oder fahren Sie mit ihnen fort."
|
||||
},
|
||||
"community": {
|
||||
"subtitle": "Alles bereit",
|
||||
"mission": "PicPeak gibt es, damit Fotografinnen und Fotografen ihre Galerien und Kundendaten selbst besitzen — auf dem eigenen Server, ohne monatliche SaaS-Gebühren. Danke, dass du es ausprobierst.",
|
||||
@@ -5216,6 +5247,11 @@
|
||||
"crm_invoices_late_fee_label": {
|
||||
"label": "Bezeichnung Mahngebühr"
|
||||
},
|
||||
"crm_invoices_vat_note_text": {
|
||||
"label": "MwSt.- / Freitext-Hinweis auf Rechnungen",
|
||||
"placeholder": "z. B. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
|
||||
"help": "Wird direkt unter der MwSt.-Zeile auf jeder Rechnungs-PDF gedruckt. Leer lassen zum Ausblenden. Bitte den genauen Wortlaut mit deinem Steuerberater abstimmen."
|
||||
},
|
||||
"crm_invoices_skonto_percent_default": {
|
||||
"label": "Standard-Skonto %"
|
||||
},
|
||||
|
||||
@@ -494,6 +494,15 @@
|
||||
"coverPhotoRemoved": "Cover photo removed",
|
||||
"failedToSetCoverPhoto": "Failed to set cover photo",
|
||||
"categoryHeroHint": "If no cover photo is set for a category, the default hero photo will be used.",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"failedToReorder": "Failed to update category order",
|
||||
"galleryOrder": "Gallery order",
|
||||
"resetToDefault": "Reset to default",
|
||||
"orderReset": "Reverted to the default order",
|
||||
"orderCustomisedHint": "This gallery uses a custom order. Reset to follow the global default (Settings → Photo Categories).",
|
||||
"orderDefaultHint": "Use the arrows to set the order for this gallery. Otherwise it follows the global default (Settings → Photo Categories).",
|
||||
"sharedBadge": "Shared",
|
||||
"downloadsEnabled": "Downloads enabled for this category",
|
||||
"downloadsDisabled": "Downloads disabled for this category",
|
||||
"enableDownloadsTitle": "Click to enable downloads for this category",
|
||||
@@ -1987,6 +1996,7 @@
|
||||
"channelBeta": "Beta",
|
||||
"beta": "BETA",
|
||||
"viewReleaseNotes": "View Release Notes",
|
||||
"viewOnGithub": "View PicPeak on GitHub",
|
||||
"updateAvailableShort": "v{{version}} available",
|
||||
"upToDate": "You're up to date",
|
||||
"updateNow": "Update Now",
|
||||
@@ -3490,6 +3500,20 @@
|
||||
"finish": "Finish setup",
|
||||
"saveFailed": "Some settings could not be saved — you can finish them in Settings."
|
||||
},
|
||||
"eventTypes": {
|
||||
"subtitle": "Which events do you photograph?",
|
||||
"intro": "Event types organize your galleries — each one gets its own URL prefix and default theme. The suggestions below are just a starting point: rename them, remove what you don't need, or add your own. This is the only time the built-in types can be deleted; later they can only be renamed or deactivated.",
|
||||
"nameLabel": "Display name",
|
||||
"namePlaceholder": "e.g. Family Shoot",
|
||||
"slugLabel": "URL prefix",
|
||||
"slugPlaceholder": "e.g. family",
|
||||
"add": "Add event type",
|
||||
"hint": "The URL prefix appears in gallery links (e.g. family-smith-2025-06-01). Icons, themes and order can be tuned later in Settings → Event Types.",
|
||||
"atLeastOne": "Keep at least one event type — every gallery needs one.",
|
||||
"loadFailed": "Could not load the event types — you can adjust them later in Settings → Event Types.",
|
||||
"saveFailed": "Some event type changes could not be saved — you can finish them in Settings → Event Types.",
|
||||
"deleteFailed": "A deletion failed — the list has been reloaded. Built-in types can only be deleted here, so retry or continue with them kept."
|
||||
},
|
||||
"community": {
|
||||
"subtitle": "You're all set",
|
||||
"mission": "PicPeak exists so photographers can own their galleries and client data — on their own server, without monthly SaaS fees. Thanks for giving it a try.",
|
||||
@@ -3603,6 +3627,13 @@
|
||||
"cool": "Cool",
|
||||
"vignette": "Vignette"
|
||||
},
|
||||
"orderLabel": "Play order",
|
||||
"order": {
|
||||
"chronological": "Chronological",
|
||||
"random": "Random (shuffle)"
|
||||
},
|
||||
"categoryLabel": "Show only category",
|
||||
"categoryAll": "All photos",
|
||||
"watermarkToggle": "Show logo watermark",
|
||||
"watermarkDescription": "Overlay a white, semi-transparent logo in a corner (like a TV station ident).",
|
||||
"watermarkSourceLabel": "Logo",
|
||||
@@ -5214,6 +5245,11 @@
|
||||
"crm_invoices_late_fee_label": {
|
||||
"label": "Late fee label"
|
||||
},
|
||||
"crm_invoices_vat_note_text": {
|
||||
"label": "VAT / free-text note on invoices",
|
||||
"placeholder": "e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).",
|
||||
"help": "Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor."
|
||||
},
|
||||
"crm_invoices_skonto_percent_default": {
|
||||
"label": "Skonto rate (default %)"
|
||||
},
|
||||
|
||||
@@ -11,6 +11,7 @@ import { setupService } from '../services/setup.service';
|
||||
import { featureFlagsService, type FeatureFlags, type FeatureKey } from '../services/featureFlags.service';
|
||||
import { PicpeakRestoreCard } from '../components/admin/PicpeakBackupCard';
|
||||
import { SetupConfigStep } from '../components/admin/SetupConfigStep';
|
||||
import { SetupEventTypesStep } from '../components/admin/SetupEventTypesStep';
|
||||
import { resolveLoginLogoClasses } from '../utils/loginLogoSize';
|
||||
import type { AdminUser } from '../types';
|
||||
|
||||
@@ -67,7 +68,7 @@ export const SetupPage: React.FC = () => {
|
||||
staleTime: Infinity,
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'restore' | 'config' | 'community'>('token');
|
||||
const [step, setStep] = useState<'token' | 'account' | 'usage' | 'eventTypes' | 'restore' | 'config' | 'community'>('token');
|
||||
const [form, setForm] = useState({ token: '', email: '', password: '', confirm: '' });
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
@@ -241,19 +242,25 @@ export const SetupPage: React.FC = () => {
|
||||
toast.warn(t('setup.featuresSaveFailed'));
|
||||
} finally {
|
||||
setIsSavingFeatures(false);
|
||||
// If the chosen features need config the wizard can collect (invoicing,
|
||||
// email), go to the config step; otherwise enter the app.
|
||||
const needsConfig =
|
||||
selectedFeatures.has('bills') ||
|
||||
selectedFeatures.has('reminderEmails') ||
|
||||
selectedFeatures.has('incomingMail') ||
|
||||
selectedFeatures.has('whatsapp');
|
||||
// Both the config branch and the no-config path end on the final
|
||||
// community/thank-you step (#732), whose Finish button enters the app.
|
||||
setStep(needsConfig ? 'config' : 'community');
|
||||
// Event types come next (#800) — the wizard is the one window in which
|
||||
// the seeded defaults can be freely renamed or deleted, because nothing
|
||||
// (events, quotes, reminder mails) references them yet.
|
||||
setStep('eventTypes');
|
||||
}
|
||||
};
|
||||
|
||||
// After the event-types step: if the chosen features need config the wizard
|
||||
// can collect (invoicing, email), go to the config step; otherwise skip to
|
||||
// the final community/thank-you step (#732), whose Finish enters the app.
|
||||
const continueAfterEventTypes = () => {
|
||||
const needsConfig =
|
||||
selectedFeatures.has('bills') ||
|
||||
selectedFeatures.has('reminderEmails') ||
|
||||
selectedFeatures.has('incomingMail') ||
|
||||
selectedFeatures.has('whatsapp');
|
||||
setStep(needsConfig ? 'config' : 'community');
|
||||
};
|
||||
|
||||
const stepNumber = step === 'token' ? 1 : step === 'account' ? 2 : 3;
|
||||
|
||||
return (
|
||||
@@ -278,13 +285,15 @@ export const SetupPage: React.FC = () => {
|
||||
? t('setup.tokenStepSubtitle')
|
||||
: step === 'account'
|
||||
? t('setup.accountStepSubtitle')
|
||||
: step === 'restore'
|
||||
? t('setup.restoreStepSubtitle')
|
||||
: step === 'config'
|
||||
? t('setup.config.subtitle')
|
||||
: step === 'community'
|
||||
? t('setup.community.subtitle')
|
||||
: t('setup.usageSubtitle')}
|
||||
: step === 'eventTypes'
|
||||
? t('setup.eventTypes.subtitle')
|
||||
: step === 'restore'
|
||||
? t('setup.restoreStepSubtitle')
|
||||
: step === 'config'
|
||||
? t('setup.config.subtitle')
|
||||
: step === 'community'
|
||||
? t('setup.community.subtitle')
|
||||
: t('setup.usageSubtitle')}
|
||||
</p>
|
||||
{(step === 'token' || step === 'account' || step === 'usage') && (
|
||||
<p className="mt-3 text-xs font-medium tracking-wide uppercase" style={{ color: '#171717', opacity: 0.5 }}>
|
||||
@@ -509,6 +518,8 @@ export const SetupPage: React.FC = () => {
|
||||
{t('setup.back')}
|
||||
</Button>
|
||||
</div>
|
||||
) : step === 'eventTypes' ? (
|
||||
<SetupEventTypesStep onDone={continueAfterEventTypes} />
|
||||
) : step === 'config' ? (
|
||||
<SetupConfigStep
|
||||
selectedFeatures={selectedFeatures}
|
||||
@@ -546,7 +557,14 @@ export const SetupPage: React.FC = () => {
|
||||
variant="primary"
|
||||
size="lg"
|
||||
className="w-full"
|
||||
onClick={() => navigate('/admin/dashboard', { replace: true })}
|
||||
onClick={async () => {
|
||||
// One-way marker: re-locks the seeded system event types
|
||||
// (#800). Best-effort — a failure must not trap the user on
|
||||
// the thank-you screen, and the flag re-arms nothing risky
|
||||
// (the delete window also requires zero usage server-side).
|
||||
try { await setupService.completeSetup(); } catch { /* best-effort */ }
|
||||
navigate('/admin/dashboard', { replace: true });
|
||||
}}
|
||||
rightIcon={<ArrowRight className="w-4 h-4" />}
|
||||
>
|
||||
{t('setup.community.finish')}
|
||||
|
||||
@@ -182,6 +182,18 @@ export const CreateEventPage: React.FC = () => {
|
||||
[eventTypes]
|
||||
);
|
||||
|
||||
// The hardcoded initial form value ('wedding') may not exist in the live
|
||||
// catalog — the setup wizard can rename or delete the defaults (#800), and
|
||||
// the backend now rejects unknown slugs. Snap to the first active type; a
|
||||
// user-picked value is always in the list, so this never fights the user.
|
||||
useEffect(() => {
|
||||
if (!availableEventTypes.length) return;
|
||||
if (!availableEventTypes.some(t => t.value === formData.event_type)) {
|
||||
setFormData(prev => ({ ...prev, event_type: availableEventTypes[0].value }));
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [availableEventTypes, formData.event_type]);
|
||||
|
||||
// Fetch default settings
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
|
||||
@@ -29,6 +29,8 @@ const SETTING_KEYS = [
|
||||
'crm_quotes_tos_url',
|
||||
'crm_invoices_qr_enabled',
|
||||
'crm_invoice_round_total',
|
||||
// Free-text VAT/legal note printed under the MwSt. line on invoice PDFs (#794).
|
||||
'crm_invoices_vat_note_text',
|
||||
'crm_invoices_reminders_enabled',
|
||||
'crm_invoices_reminder_first_days',
|
||||
'crm_invoices_reminder_second_days',
|
||||
@@ -249,6 +251,25 @@ export const CrmSettingsPage: React.FC = () => {
|
||||
{checkbox('crm_invoices_qr_enabled', 'Render payment QR on invoice PDFs')}
|
||||
{checkbox('crm_invoice_round_total', 'Reconcile sub-cent rounding to a clean total (adds a "Rundung" row when per-line rounding drifts from qty × rate)')}
|
||||
|
||||
{/* Free-text VAT / legal note (#794) — printed directly under the MwSt.
|
||||
line on every invoice PDF. Data-driven: the admin types the exact
|
||||
wording (Austrian Kleinunternehmer, German §19, reverse-charge, …). */}
|
||||
<div className="mt-3">
|
||||
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-1">
|
||||
{t('crmSettings.crm_invoices_vat_note_text.label', 'VAT / free-text note on invoices')}
|
||||
</label>
|
||||
<textarea
|
||||
value={values.crm_invoices_vat_note_text ?? ''}
|
||||
onChange={(e) => setVal('crm_invoices_vat_note_text', e.target.value)}
|
||||
rows={2}
|
||||
placeholder={t('crmSettings.crm_invoices_vat_note_text.placeholder', 'e.g. Gemäß § 6 Abs. 1 Z 27 UStG 1994 wird keine Umsatzsteuer berechnet (Kleinunternehmer).') as string}
|
||||
className="w-full px-3 py-2 rounded-lg border border-neutral-300 dark:border-neutral-600 bg-white dark:bg-neutral-900 text-neutral-900 dark:text-neutral-100"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-neutral-500 dark:text-neutral-400">
|
||||
{t('crmSettings.crm_invoices_vat_note_text.help', 'Printed directly under the MwSt. line on every invoice PDF. Leave empty to hide. Please confirm the exact wording with your tax advisor.')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Reminder TIMING: owned by the Invoice dunning workflow when the
|
||||
engine is live (callout); otherwise the legacy schedule controls. The
|
||||
late-fee math below is configured here in both cases — it's the fee
|
||||
|
||||
@@ -13,6 +13,7 @@ const DEFAULT_SETTINGS: SlideshowSettings = {
|
||||
transition: 'crossfade',
|
||||
transition_ms: 800,
|
||||
colorfilter: 'none',
|
||||
order: 'chronological',
|
||||
fit: 'cover',
|
||||
watermark: null,
|
||||
};
|
||||
@@ -65,6 +66,17 @@ function watermarkCorner(position: string): React.CSSProperties {
|
||||
|
||||
type Phase = 'splash' | 'running' | 'ended';
|
||||
|
||||
// Fisher–Yates shuffle for the 'random' play order (#202). Used once on the
|
||||
// initial photo set; live-appended uploads keep landing at the end.
|
||||
function shuffle<T>(arr: T[]): T[] {
|
||||
const a = [...arr];
|
||||
for (let i = a.length - 1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random() * (i + 1));
|
||||
[a[i], a[j]] = [a[j], a[i]];
|
||||
}
|
||||
return a;
|
||||
}
|
||||
|
||||
export function SlideshowPage() {
|
||||
const { slug = '', token = '' } = useParams<{ slug: string; token: string }>();
|
||||
const { t } = useTranslation();
|
||||
@@ -159,13 +171,15 @@ export function SlideshowPage() {
|
||||
storeGalleryToken(slug, session.token);
|
||||
setActiveGallerySlug(slug);
|
||||
setEventName(session.event.event_name || '');
|
||||
setSettings(session.settings || DEFAULT_SETTINGS);
|
||||
const settings = session.settings || DEFAULT_SETTINGS;
|
||||
setSettings(settings);
|
||||
|
||||
// Load the list and DECODE the first slide (and the next) before we flip
|
||||
// to running, so playback starts on an already-rasterised image instead
|
||||
// of struggling on the first transition.
|
||||
const data = await galleryService.getGalleryPhotos(slug);
|
||||
const list = data.photos || [];
|
||||
// 'random' shuffles the initial set once; new uploads still append (#202).
|
||||
const list = settings.order === 'random' ? shuffle(data.photos || []) : (data.photos || []);
|
||||
setPhotos(list);
|
||||
await preloadDecode(list[0]);
|
||||
void preloadDecode(list[1]);
|
||||
|
||||
@@ -10,6 +10,12 @@ export interface PhotoCategory {
|
||||
// Per-category download permission (#640). Defaults true (server-side) so
|
||||
// categories created before migration 135 keep working.
|
||||
allow_downloads?: boolean;
|
||||
// Global default sort order (#782). Backfilled from the previous alphabetical
|
||||
// order on migration, so existing galleries don't reshuffle.
|
||||
display_order?: number;
|
||||
// Per-event override position (#782). Non-null on the /event/:id response when
|
||||
// this gallery has customised its order; null means it follows the default.
|
||||
override_position?: number | null;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -61,5 +67,31 @@ export const categoriesService = {
|
||||
// Delete a category
|
||||
async deleteCategory(id: number): Promise<void> {
|
||||
await api.delete(`/admin/categories/${id}`);
|
||||
},
|
||||
|
||||
// Set a per-event order override (#782). Sends the full ordered id list for
|
||||
// this event — globals + event-specific — and returns the resolved order.
|
||||
// Overrides the global default for this gallery only.
|
||||
async reorderCategories(eventId: number, orderedIds: number[]): Promise<PhotoCategory[]> {
|
||||
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder', {
|
||||
event_id: eventId,
|
||||
orderedIds
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Clear an event's override — revert this gallery to the global default order.
|
||||
async resetEventOrder(eventId: number): Promise<PhotoCategory[]> {
|
||||
const response = await api.delete<PhotoCategory[]>(`/admin/categories/reorder/${eventId}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Set the GLOBAL default order for shared categories (#782). Applies to every
|
||||
// gallery that hasn't set its own override.
|
||||
async reorderGlobalCategories(orderedIds: number[]): Promise<PhotoCategory[]> {
|
||||
const response = await api.post<PhotoCategory[]>('/admin/categories/reorder-global', {
|
||||
orderedIds
|
||||
});
|
||||
return response.data;
|
||||
}
|
||||
};
|
||||
@@ -158,6 +158,8 @@ export const eventsService = {
|
||||
show_transition_ms?: number;
|
||||
show_watermark?: boolean | null;
|
||||
show_colorfilter?: string;
|
||||
show_order?: string;
|
||||
show_category_id?: number | null;
|
||||
}
|
||||
): Promise<Record<string, unknown>> {
|
||||
const response = await api.patch(`/admin/events/${id}/slideshow`, settings);
|
||||
|
||||
@@ -38,4 +38,11 @@ export const setupService = {
|
||||
const response = await api.post<{ user: SetupAdminUser }>('/setup/admin', input);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// One-way wizard-finish marker (authenticated — runs after the admin
|
||||
// exists). While unset, the wizard's event-types step may delete the
|
||||
// seeded system types; afterwards they are permanently protected.
|
||||
async completeSetup(): Promise<void> {
|
||||
await api.post('/setup/complete');
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,9 @@ export const SLIDESHOW_WATERMARK_STYLES: SlideshowWatermarkStyle[] = ['white', '
|
||||
// (admin Settings → Slideshow); 'on'/'off' = explicit override.
|
||||
export type SlideshowWatermarkMode = 'inherit' | 'on' | 'off';
|
||||
export const SLIDESHOW_WATERMARK_MODES: SlideshowWatermarkMode[] = ['inherit', 'on', 'off'];
|
||||
// Play order (#202): 'chronological' = upload order; 'random' = client shuffle.
|
||||
export type SlideshowOrder = 'chronological' | 'random';
|
||||
export const SLIDESHOW_ORDERS: SlideshowOrder[] = ['chronological', 'random'];
|
||||
|
||||
export const SLIDESHOW_TRANSITIONS: SlideshowTransition[] = ['crossfade', 'cut', 'slide', 'kenburns', 'dipwhite', 'dipblack'];
|
||||
export const SLIDESHOW_COLORFILTERS: SlideshowColorFilter[] = ['none', 'bw', 'sepia', 'warm', 'cool', 'vignette'];
|
||||
@@ -37,6 +40,9 @@ export interface SlideshowStyle {
|
||||
transition_ms: number;
|
||||
watermark: SlideshowWatermarkMode;
|
||||
colorfilter: SlideshowColorFilter;
|
||||
// Play order + optional category filter (#202). category_id null = all photos.
|
||||
order: SlideshowOrder;
|
||||
category_id: number | null;
|
||||
}
|
||||
|
||||
export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
||||
@@ -45,6 +51,8 @@ export const DEFAULT_SLIDESHOW_STYLE: SlideshowStyle = {
|
||||
transition_ms: 800,
|
||||
watermark: 'inherit',
|
||||
colorfilter: 'none',
|
||||
order: 'chronological',
|
||||
category_id: null,
|
||||
};
|
||||
|
||||
// Global slideshow defaults (admin Settings → Slideshow). The single source of
|
||||
@@ -81,6 +89,10 @@ export interface SlideshowSettings {
|
||||
transition: SlideshowTransition;
|
||||
transition_ms: number;
|
||||
colorfilter: SlideshowColorFilter;
|
||||
// Play order the kiosk applies (#202): 'random' shuffles client-side so
|
||||
// live-appended uploads keep working. The category filter is enforced
|
||||
// server-side, so it isn't echoed here.
|
||||
order: SlideshowOrder;
|
||||
fit: SlideshowFit;
|
||||
watermark: SlideshowWatermark | null;
|
||||
}
|
||||
|
||||
@@ -10,5 +10,10 @@
|
||||
* notes for the running version (#566) and by the update-available
|
||||
* indicator to link to the upgrade target's notes.
|
||||
*/
|
||||
/** Repository home on GitHub. Single source of truth for the org URL so
|
||||
* links (release notes, the admin "view on GitHub" button, #778) don't
|
||||
* each hardcode it. */
|
||||
export const repoUrl = 'https://github.com/PicPeak/picpeak';
|
||||
|
||||
export const githubReleaseUrl = (version: string): string =>
|
||||
`https://github.com/PicPeak/picpeak/releases/tag/v${version}`;
|
||||
`${repoUrl}/releases/tag/v${version}`;
|
||||
|
||||
Reference in New Issue
Block a user