Compare commits

...

21 Commits

Author SHA1 Message Date
Paul Nothaft ed0fa3241b chore(main): release 3.85.0-beta.0 (#793)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 13:38:57 +00:00
Paul Nothaft 54676424f2 Merge pull request #788 from PicPeak/feat/slideshow-order-category
feat(slideshow): per-event play order + category filter (#202)
2026-07-10 15:35:45 +02:00
Paul Nothaft b41cb1586d chore(main): release 3.84.1-beta.0 (#792)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-10 12:21:09 +00:00
Paul Nothaft 1f3bc3c343 Merge pull request #791 from PicPeak/fix/docker-v-tag-via-ref
fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
2026-07-10 14:17:11 +02:00
Paul Nothaft 39db7bf6cb fix(ci): publish v-prefixed image tags via type=ref,event=tag (#668)
#783 added `type=semver,pattern=v{{version}}` to the merge-job metadata,
but metadata-action silently dropped it on prereleases — the 3.84.0-beta.0
build published only :3.84.0-beta.0 + :sha, not :v3.84.0-beta.0 (verified
in the merge-backend push log + GHCR: :v3.84.0-beta.0 → 404).

Replace the v{{version}}/v{{major}} semver patterns with type=ref,event=tag,
which emits the git-tag name verbatim (v3.45.0 / v3.84.0-beta.0) for both
stable and beta tags — exactly the string users pin (matches the GitHub
release). Applies to both backend + frontend merge metadata steps.

Takes effect on the next release build. The bare :3.84.0-beta.0 tags stay
(the {{version}} patterns are unchanged), so both forms resolve.
2026-07-10 14:13:17 +02:00
Paul Nothaft aeade94a35 Merge pull request #786 from PicPeak/release-please--branches--main
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
chore(main): release 3.84.0-beta.0
2026-07-10 13:04:13 +02:00
Paul Nothaft b768a53c5b feat(slideshow): per-event play order + category filter (#202)
The Live Slideshow already covers the core of #202 (fullscreen kiosk,
live-appending new uploads, timing/transitions/watermark, per-event
opt-in via the share link). This adds the two customization dimensions
the reporter also asked for:

- **Play order** (show_order): 'chronological' (upload order, default) or
  'random' — the client shuffles the initial set (Fisher-Yates) so
  live-appended uploads keep working.
- **Category filter** (show_category_id): restrict the slideshow to a
  single photo category (NULL = all photos, default). Enforced
  server-side on the slideshow /photos access and mirrored in the
  /session + /state photo_count, so the kiosk viewer can't widen the set.

Per-event enable/disable (default off) is unchanged — it's the existing
'Generate/Disable slideshow link' flow (no token = no slideshow).

- Migration 158: show_order (default 'chronological') + show_category_id.
- Admin: Play-order dropdown + category picker in the Live Slideshow card
  (picker hidden for events without categories); EN + DE i18n.
- Verified: migration (SQLite + PG); live API (category filter → 3/2/5
  photos + matching count; order propagates) and the running kiosk
  requests exactly the filtered set; tsc clean, 136 backend tests pass.
2026-07-10 10:46:54 +02:00
Paul Nothaft 03ded870bf chore(main): release 3.84.0-beta.0 2026-07-10 10:20:17 +02:00
Paul Nothaft df5aeaba41 Merge pull request #785 from PicPeak/docs/releasing-stable-version-alignment
docs(releasing): align stable version to main on promote (Option A)
2026-07-10 10:20:04 +02:00
Paul Nothaft 279e0472c7 Merge pull request #784 from PicPeak/feat/admin-github-repo-button
feat(admin): GitHub repo button in the sidebar footer (#778)
2026-07-10 10:19:49 +02:00
Paul Nothaft 2ee4146d9a Merge pull request #783 from PicPeak/fix/docker-versioned-tags-v-prefix
fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
2026-07-10 10:19:22 +02:00
Paul Nothaft 5dea0c9695 docs(releasing): align stable version to main on promote (Option A)
The two release-please tracks count independently — main bumps on every
merge, stable only on promotion — so they drifted far apart (main
v3.83.x-beta while stable sat at v3.45.0 for the same code). Document
the alignment convention: a promotion pins the stable version to main's
base version via a Release-As commit (new step 5 in the cut procedure),
so stable tracks main instead of lagging.

Also records the release-engineering note that release-please.yml must
keep target-branch: stable (the missing pin cut a bogus v2.7.0 once).
2026-07-10 10:03:30 +02:00
Paul Nothaft d3d7df46f2 feat(admin): GitHub repo button in the sidebar footer (#778)
Adds a subtle 'View PicPeak on GitHub' link in the admin sidebar footer
(next to the version/storage widgets), so admins can reach the repo —
star it, browse source, report an issue — from anywhere in the dashboard,
not just the setup screen.

- Centralizes the repo URL as `repoUrl` in utils/githubReleaseUrl.ts
  (githubReleaseUrl now derives from it) so the org URL lives in one place.
- target=_blank + rel=noopener noreferrer; EN + DE i18n
  (`admin.viewOnGithub`); dark-mode aware, matches the muted footer style.
2026-07-10 09:50:05 +02:00
Paul Nothaft 784d059c3d fix(ci): publish v-prefixed image tags so :vX.Y.Z resolves (#668)
docker/metadata-action's type=semver strips the leading 'v', so releases
published only :3.45.0 / :3.83.1-beta.0. But git tags + GitHub releases
are named v3.45.0, so anyone pinning ghcr.io/.../backend:v3.45.0 (the
obvious choice) hit 'manifest unknown' — exactly #664.

Add v-prefixed semver patterns (v{{version}}, v{{major}}.{{minor}},
v{{major}}) alongside the existing bare ones, for both backend and
frontend. Now both :v3.45.0 and :3.45.0 resolve.

Applies to future releases; the already-published v3.45.0 only has the
bare :3.45.0 tag (retagging past releases is out of scope).
2026-07-10 09:46:10 +02:00
Paul Nothaft dbe4b588eb chore(main): release 3.83.1-beta.0 (#776)
Build and Push Docker Images / build-backend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-backend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-backend (push) Blocked by required conditions
Build and Push Docker Images / build-frontend (linux/amd64, ubuntu-latest) (push) Waiting to run
Build and Push Docker Images / build-frontend (linux/arm64, ubuntu-24.04-arm) (push) Waiting to run
Build and Push Docker Images / merge-frontend (push) Blocked by required conditions
Build and Push Docker Images / summary (push) Blocked by required conditions
2026-07-09 11:17:23 +00:00
Paul Nothaft 274ef0cd73 Merge pull request #774 from PicPeak/fix/release-please-stable-target
fix(release): target stable in release-please + undo bogus 2.7.0 bump
2026-07-09 13:13:23 +02:00
Paul Nothaft 65ac6eddac fix(release): target stable in release-please.yml + undo the bogus 2.7.0 bump
The stable release-please workflow (release-please.yml, triggered on
push to stable) had no `target-branch`, so it defaulted to the repo
default branch (main) and computed the next version from main's stale
`.release-please-manifest.json` (2.6.1) — cutting a spurious **v2.7.0**
stable release (a version regression from 3.44.0) when #771 landed on
stable, and bumping main's package.json + manifest to 2.7.0.

- release-please.yml: add `target-branch: stable` so it releases from
  the stable branch (3.44.0 → 3.45.0), like release-please-beta.yml
  already pins `target-branch: main`.
- Restore main's version to 3.83.0-beta.0 (backend + frontend
  package.json), set `.release-please-manifest.json` to 3.44.0, and drop
  the bogus 2.7.0 CHANGELOG section.

The v2.7.0 tag/release is deleted separately; the real v3.45.0 stable is
cut by re-running release-please on the stable branch after this lands.
2026-07-09 11:39:16 +02:00
Paul Nothaft be710eb1de Merge pull request #773 from PicPeak/release-please--branches--main
chore(main): release 2.7.0
2026-07-08 21:14:07 +02:00
Paul Nothaft 58a86af868 chore(main): release 2.7.0 2026-07-08 20:46:56 +02:00
Paul Nothaft 1250306d11 Merge pull request #772 from PicPeak/ci/run-tests-on-stable
ci: run the Tests workflow on stable-targeted PRs
2026-07-08 20:42:01 +02:00
Paul Nothaft 80503c52b9 ci: run the Tests workflow on stable-targeted PRs
tests.yml (the backend/frontend Jest+Vitest jobs) only triggered on
main/beta, but those two jobs are required status checks on the stable
branch. A beta→stable promote PR therefore hung forever on
'Expected — Waiting for status to be reported' for backend/frontend,
while docker-build / install-smoke / schema-drift (already listing
stable) ran fine. Add stable to the push + pull_request filters so the
Tests suite runs on promote PRs too.
2026-07-08 20:29:19 +02:00
22 changed files with 283 additions and 24 deletions
+8
View File
@@ -282,6 +282,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),
@@ -499,6 +503,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),
+1
View File
@@ -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
+2 -2
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
{
".": "3.83.0-beta.0"
".": "3.85.0-beta.0"
}
+1 -3
View File
@@ -1,3 +1 @@
{
".": "2.6.1"
}
{".":"3.44.0"}
+43
View File
@@ -5,6 +5,49 @@ 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.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
View File
@@ -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,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));
}
}
};
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "picpeak-backend",
"version": "3.83.0-beta.0",
"version": "3.85.0-beta.0",
"description": "Backend for PicPeak event photo sharing platform",
"main": "server.js",
"scripts": {
@@ -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,
};
+24 -3
View File
@@ -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');
+18 -4
View File
@@ -243,8 +243,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 +252,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 +328,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 +363,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 +389,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 +433,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
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "picpeak-frontend",
"private": true,
"version": "3.83.0-beta.0",
"version": "3.85.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>
@@ -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">
+8
View File
@@ -2411,6 +2411,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 +3476,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",
+8
View File
@@ -1987,6 +1987,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",
@@ -3603,6 +3604,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",
+16 -2
View File
@@ -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';
// FisherYates 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]);
+2
View File
@@ -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);
@@ -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;
}
+6 -1
View File
@@ -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}`;