Compare commits

..

55 Commits

Author SHA1 Message Date
Codex Agent
33e46b448d Match gallery preview filters and tiles to gallery
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 16:07:29 +01:00
Codex Agent
289ef70e53 Remove gallery route padding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:58:40 +01:00
Codex Agent
d0559bf8c9 Align gallery layout with achievements structure
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:50:48 +01:00
Codex Agent
0ef4b32bf6 Match gallery layout to achievements spacing
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:44:32 +01:00
Codex Agent
3612c97e86 Tighten gallery spacing and add filter dividers
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 15:08:28 +01:00
Codex Agent
c0510581c6 Tighten gallery filters and badge placement
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 14:04:31 +01:00
Codex Agent
1ffd3e3b9d Fix gallery section closing tag
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:56:00 +01:00
Codex Agent
e05ee3b186 Unify gallery header and grid section
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 13:53:35 +01:00
Codex Agent
cf7b2e563a Unify gallery layout and reduce image overlays
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 12:40:55 +01:00
Codex Agent
719afb6920 Refresh gallery layout and tile styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:48:40 +01:00
Codex Agent
83c58358a1 Show photobooth filter only when enabled
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:45:29 +01:00
Codex Agent
2b888078a0 Modernize gallery UI and fix nav motion
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:42:12 +01:00
Codex Agent
2f584162d6 Avoid hidden gallery content on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:36:02 +01:00
Codex Agent
0833ea6b36 Skip hidden initial motion on achievements tab nav
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:32:54 +01:00
Codex Agent
5bdc15d399 Tune guest route transition animations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:30:03 +01:00
Codex Agent
693540f609 Avoid task page hidden animation on tab navigation
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:25:43 +01:00
Codex Agent
c0193c9581 Deduplicate guest tasks list and restore header icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 11:17:35 +01:00
Codex Agent
03c7b20cae Improve guest help routing and loading
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 09:00:12 +01:00
Codex Agent
3a78c4f2c0 Ensure help sync creates cache directory
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-14 08:17:49 +01:00
Codex Agent
fa333deed9 Ensure storage subdirs exist on boot 2026-01-13 22:49:47 +01:00
Codex Agent
a733df6221 Add symfony/yaml for help sync
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:38:09 +01:00
Codex Agent
5ee1baa7e2 Fix forwarded host/port for signed URLs
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 22:30:10 +01:00
Codex Agent
2f19752199 chore: sync beads
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:33:26 +01:00
Codex Agent
7dd7ec14a4 chore: sync beads 2026-01-13 21:32:39 +01:00
Codex Agent
d9568be579 Fix proxy headers and help sync boot
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 21:31:46 +01:00
Codex Agent
9cf6e9d94d Add photobooth email translations
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:40:00 +01:00
Codex Agent
a23ce0c86f Set locale on photobooth mail
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 16:24:29 +01:00
Codex Agent
9efea136bd Normalize photobooth mail locale
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:37:26 +01:00
Codex Agent
7a6f489b8b Add tenant admin account edit page
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 15:09:25 +01:00
Codex Agent
cc11e024f0 Add photobooth folder presets
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 12:00:39 +01:00
Codex Agent
2089251a92 Extend uploader profiles, filters, and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:26:04 +01:00
Codex Agent
53094b8d36 Add filters, throttling, and connection test
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:15:57 +01:00
Codex Agent
0c33c1ddc1 Persist upload queue and uploaded cache
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:12:26 +01:00
Codex Agent
ce0b7c951a Update beads issues for uploader epic
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 11:08:39 +01:00
Codex Agent
fbbbbdac4c Add upload retries and richer errors 2026-01-13 11:08:26 +01:00
Codex Agent
94d0713ec0 Add manual uploader credentials fields
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:56:33 +01:00
Codex Agent
3e36354916 Restructure photobooth page flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 10:52:50 +01:00
Codex Agent
24a1319cc2 Add photobooth uploader download email
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 09:59:39 +01:00
Codex Agent
b1250c6246 Collapse photobooth credentials
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:59:24 +01:00
Codex Agent
fd7a3c846a Add uploader downloads for Windows macOS Linux
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:49:08 +01:00
Codex Agent
1ca7545f86 Add photobooth uploader build service
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:37:26 +01:00
Codex Agent
9f4a202d2b Add Windows app icon
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-13 08:12:11 +01:00
Codex Agent
fe0525e678 Fix uploader header layout
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:26:17 +01:00
Codex Agent
d62efdb55c Refresh uploader UI styling
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:15:55 +01:00
Codex Agent
be722f6e37 Remember uploader window size
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 21:03:27 +01:00
Codex Agent
898ac9ff0e Add uploader advanced settings and live status
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:50:39 +01:00
Codex Agent
c8d1ac7971 Improve uploader client connection and diagnostics
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:40:40 +01:00
Codex Agent
3ee23f3a66 Add uploader branding
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:28:49 +01:00
Codex Agent
993c351832 Remove response format from uploader UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:22:45 +01:00
Codex Agent
2444a62a4d Show connect code expiry time
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:14:42 +01:00
Codex Agent
e52720a3cb Rename photobooth upload endpoint
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 20:05:09 +01:00
Codex Agent
93bed358ba Remove sparkbooth option from photobooth UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 19:50:30 +01:00
Codex Agent
a16bd9c498 Relabel photobooth uploader mode
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 18:46:41 +01:00
Codex Agent
e32b1fa45a Add photobooth connect code UI
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:59:35 +01:00
Codex Agent
6edc890e01 Configure beads sync branch and ignore artifacts
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled
2026-01-12 17:46:39 +01:00
73 changed files with 3573 additions and 704 deletions

6
.beads/.gitignore vendored
View File

@@ -11,6 +11,12 @@ daemon.log
daemon.pid
bd.sock
sync-state.json
.sync.lock
last-touched
sync_base.jsonl
.sync.lock
last-touched
sync_base.jsonl
# Local version tracking (prevents upgrade notification spam after git ops)
.local_version

View File

@@ -42,7 +42,7 @@
# This setting persists across clones (unlike database config which is gitignored).
# Can also use BEADS_SYNC_BRANCH env var for local override.
# If not set, bd sync will require you to run 'bd config set sync.branch <branch>'.
# sync-branch: "beads-sync"
sync-branch: "beads-sync"
# Multi-repo configuration (experimental - bd-307)
# Allows hydrating from multiple repositories and routing writes to the correct JSONL

View File

@@ -17,12 +17,10 @@
{"id":"fotospiel-app-38f","title":"Paddle catalog sync: surface last sync error/log context in admin","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:59:14.865414785+01:00","created_by":"soeren","updated_at":"2026-01-02T21:16:09.109922491+01:00","closed_at":"2026-01-02T21:16:09.109922491+01:00","close_reason":"Completed"}
{"id":"fotospiel-app-3ut","title":"SEC-API-03 Synthetic monitoring + alert config","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:46.793875724+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:46.793875724+01:00"}
{"id":"fotospiel-app-3xa","title":"Security review: event admin code audit (policies, PKCE, file handling)","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:05:20.115675149+01:00","created_by":"soeren","updated_at":"2026-01-01T16:05:20.115675149+01:00"}
{"id":"fotospiel-app-43mp","title":"Help-System für Event Admin PWA planen","notes":"Context help links wired into priority admin pages.","status":"in_progress","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-23T08:21:47.812129626+01:00","created_by":"Codex Agent","updated_at":"2026-01-23T09:19:45.828239299+01:00"}
{"id":"fotospiel-app-4ar","title":"SEC-BILL-03 Failed capture notifications + ledger hook","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:33.266516715+01:00","created_by":"soeren","updated_at":"2026-01-01T15:54:33.266516715+01:00"}
{"id":"fotospiel-app-4en","title":"Add translations for Mobile Package Shop","description":"The new MobilePackageShopPage.tsx uses translation keys like 'shop.title', 'shop.legal.agb', etc. Ensure these are added to the management.json files for de and en.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T18:05:50.469751088+01:00","created_by":"soeren","updated_at":"2026-01-06T18:14:19.984343737+01:00","closed_at":"2026-01-06T18:14:19.984346372+01:00"}
{"id":"fotospiel-app-4i4","title":"Security review: map roles/data","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:58.370301875+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:03.997327414+01:00","closed_at":"2026-01-01T16:03:03.997327414+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-4zu","title":"SEC-IO-02 Refresh-token management UI + audit logs","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:51:50.24186222+01:00","created_by":"soeren","updated_at":"2026-01-04T16:10:39.752587431+01:00","closed_at":"2026-01-04T16:10:39.752587431+01:00","close_reason":"Obsolete: authentication now uses Sanctum PATs; OAuth/refresh-token tables removed and no refresh-token flow remains. See docs/archive/prp/13-backend-authentication.md and docs/archive/prp/marketing-checkout-payment-architecture.md."}
{"id":"fotospiel-app-4zy","title":"Refine Dashboard Translations","description":"Fix missing translations in the modern dashboard UI and use proper i18n keys for stats and status labels.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T16:35:14.464529363+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T16:35:14.464529363+01:00"}
{"id":"fotospiel-app-539","title":"Live Show: public player view with effects engine","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:11:36.821959901+01:00","created_by":"soeren","updated_at":"2026-01-05T18:30:13.318396255+01:00","closed_at":"2026-01-05T18:30:13.318396255+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-qne","type":"blocks","created_at":"2026-01-05T11:12:58.721858159+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-6zc","type":"blocks","created_at":"2026-01-05T11:13:07.289796993+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:42.719445471+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.2","title":"Live Show player shell + routing + data layer","description":"Add /show/{token} route + guest player page shell, Live Show API client, SSE/polling subscription and state model.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:41.587003393+01:00","created_by":"soeren","updated_at":"2026-01-05T16:44:39.577762479+01:00","closed_at":"2026-01-05T16:44:39.577762479+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.2","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:41.641767879+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-539.3","title":"Live Show playback engine (queue, pacing, layouts)","description":"Implement player playback scheduler, queue management, and layout rendering for single/split/grid.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-05T15:57:56.531080931+01:00","created_by":"soeren","updated_at":"2026-01-05T17:40:45.929168571+01:00","closed_at":"2026-01-05T17:40:45.929168571+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539","type":"parent-child","created_at":"2026-01-05T15:57:56.631147026+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-539.3","depends_on_id":"fotospiel-app-539.2","type":"blocks","created_at":"2026-01-05T15:57:56.655278463+01:00","created_by":"soeren"}]}
@@ -38,7 +36,6 @@
{"id":"fotospiel-app-5ie","title":"Help docs: Live Show how-to + recommended hardware (DE/EN)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-05T11:12:05.973844187+01:00","created_by":"soeren","updated_at":"2026-01-05T19:42:44.39939087+01:00","closed_at":"2026-01-05T19:42:44.39939087+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:13:54.925412888+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-5ie","depends_on_id":"fotospiel-app-539","type":"blocks","created_at":"2026-01-05T11:14:03.257649076+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-5iy","title":"Security review: confirm env/header defaults","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:03:20.808188183+01:00","created_by":"soeren","updated_at":"2026-01-01T16:03:26.388002115+01:00","closed_at":"2026-01-01T16:03:26.388002115+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5s3","title":"Localized SEO: canonical/hreflang tags + localized navigation","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:02:03.909947355+01:00","created_by":"soeren","updated_at":"2026-01-01T16:02:09.550647107+01:00","closed_at":"2026-01-01T16:02:09.550647107+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-5veo","title":"Investigate vite build timeout","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-21T12:49:14.166622473+01:00","created_by":"Codex Agent","updated_at":"2026-01-21T12:49:14.166622473+01:00"}
{"id":"fotospiel-app-5zl","title":"Ensure checkout step 3 requires login for Paddle checkout","description":"Problem: Paddle checkout on step 3 fails when user is not logged in. Step 3 must enforce authentication before initializing Paddle checkout.\\n\\nSuggestions:\\n- Protect step 3 route/controller with auth middleware and redirect to login with intended return URL.\\n- Gate step 3 UI/CTA on auth state; show inline login prompt and disable Paddle until authenticated.\\n- Require auth in backend endpoint that creates Paddle transaction/session; return 401 and send user to login.\\n- Optionally preflight at end of step 2 to prompt login before advancing.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-04T12:31:43.215017311+01:00","created_by":"soeren","updated_at":"2026-01-04T12:42:45.088723058+01:00","closed_at":"2026-01-04T12:42:45.088723058+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-64l","title":"SEC-FE-01 CSP nonce/hashing rollout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:54:47.607047443+01:00","created_by":"soeren","updated_at":"2026-01-01T15:55:56.477104351+01:00","closed_at":"2026-01-01T15:55:56.477104351+01:00","close_reason":"Completed in codebase (verified) - duplicate of fotospiel-app-zli"}
{"id":"fotospiel-app-6dp","title":"Coupon ops enhancements (redemption service, preview endpoint, widget, export)","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:09:09.275919717+01:00","created_by":"soeren","updated_at":"2026-01-01T16:09:14.882264149+01:00","closed_at":"2026-01-01T16:09:14.882264149+01:00","close_reason":"Completed in codebase (verified)"}
@@ -51,7 +48,6 @@
{"id":"fotospiel-app-7uu","title":"Uploader: improve file readiness detection","description":"Part of epic fotospiel-app-5aa. Use size + last-write stabilization to avoid partial uploads.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:54.142231578+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:54.142231578+01:00"}
{"id":"fotospiel-app-7x1","title":"Uploader: response format manual override","description":"Part of epic fotospiel-app-5aa. Allow manual response format override when connect code doesn't set it.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:54.824613016+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:54.824613016+01:00"}
{"id":"fotospiel-app-83q","title":"Implement Advanced Analytics","description":"Full plan: Phase 1 (MVP) includes Activity Timeline, Top Contributors, and Task Stats. Phase 2 includes Engagement Funnel, Vibe Check, and PDF Export. See chat history for details.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-06T15:40:08.826105426+01:00","created_by":"soeren","updated_at":"2026-01-06T16:15:17.722450844+01:00","closed_at":"2026-01-06T16:15:17.722455019+01:00"}
{"id":"fotospiel-app-8iw","title":"Modernize Tenant Admin PWA UI","status":"open","priority":1,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-17T14:36:39.802617182+01:00","created_by":"Codex Agent","updated_at":"2026-01-17T14:36:39.802617182+01:00"}
{"id":"fotospiel-app-8ui","title":"Uploader: persist queue across restarts","description":"Part of epic fotospiel-app-5aa. Persist pending upload queue to disk (settings or local DB) so restarts don't lose files.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:01:42.213478619+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:01:42.213478619+01:00"}
{"id":"fotospiel-app-95m","title":"Paddle migration: admin catalog sync UI for packages","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:57:49.790409261+01:00","created_by":"soeren","updated_at":"2026-01-01T15:57:55.418180246+01:00","closed_at":"2026-01-01T15:57:55.418180246+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-99d","title":"Paddle migration: marketing checkout uses Paddle-hosted checkout","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:58:12.298063897+01:00","created_by":"soeren","updated_at":"2026-01-01T15:58:17.968032021+01:00","closed_at":"2026-01-01T15:58:17.968032021+01:00","close_reason":"Completed in codebase (verified)"}
@@ -78,7 +74,6 @@
{"id":"fotospiel-app-cwq","title":"Integrations health: unified Paddle/RevenueCat/webhook status dashboard","description":"Add a superadmin integrations health dashboard for Paddle/RevenueCat/webhooks.\nScope: show latest webhook processing status/lag, recent failures, retry backlog, and config presence (env set) without exposing secrets.\nInclude per-provider status badges and time-window filters, plus links to related logs/actions.\n","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T17:34:20.84661157+01:00","created_by":"soeren","updated_at":"2026-01-02T18:33:07.133704488+01:00","closed_at":"2026-01-02T18:33:07.133704488+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-d39","title":"Superadmin control surface spec and access matrix","description":"Define the minimal superadmin control surface, permissions, and mapping to tenant/guest responsibilities. Document scope and non-goals.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T14:16:06.994379577+01:00","updated_at":"2026-01-01T14:20:43.080701114+01:00","closed_at":"2026-01-01T14:20:43.080701114+01:00"}
{"id":"fotospiel-app-dar","title":"Uploader: retry policy for failed uploads","description":"Part of epic fotospiel-app-5aa. Auto-retry with backoff and retry limit before marking failed.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:03:00.808893045+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:03:00.808893045+01:00"}
{"id":"fotospiel-app-de7","title":"Re-run admin Playwright tests with valid E2E credentials","status":"open","priority":3,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-15T19:53:26.674926731+01:00","created_by":"Codex Agent","updated_at":"2026-01-15T19:53:26.674926731+01:00"}
{"id":"fotospiel-app-dl5","title":"SEC-API-01 Signed URL middleware + asset migration","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:52:24.24098702+01:00","created_by":"soeren","updated_at":"2026-01-01T15:52:29.8793891+01:00","closed_at":"2026-01-01T15:52:29.8793891+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dm4","title":"SEC-BILL-01 Checkout session linkage + idempotency locks","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T15:53:26.350238207+01:00","created_by":"soeren","updated_at":"2026-01-01T15:53:31.997737421+01:00","closed_at":"2026-01-01T15:53:31.997737421+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-dmb","title":"Security review checklist: Event Admin dynamic tests","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:46.359468828+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:46.359468828+01:00"}
@@ -140,7 +135,6 @@
{"id":"fotospiel-app-sbs","title":"Compliance tools: data export + retention overrides","description":"GDPR-compliant export requests and retention override workflows for tenants/events.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-01-01T14:20:16.530289009+01:00","updated_at":"2026-01-02T20:13:31.704875591+01:00","closed_at":"2026-01-02T20:13:31.704875591+01:00","close_reason":"Closed"}
{"id":"fotospiel-app-sdg","title":"Uploader: watch include/exclude patterns","description":"Part of epic fotospiel-app-5aa. Configurable file patterns (ignore tmp/preview) for watcher.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:02:17.188267106+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:02:17.188267106+01:00"}
{"id":"fotospiel-app-sju","title":"Live Show link sharing + QR in admin","description":"Expose Live Show link in Event Admin with copy/share/open actions and embedded QR (use simplesoftwareio/simple-qrcode, no external service). Add API endpoints for link fetch/rotate, admin UI card with rotate confirmation, and tests.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-05T20:00:25.427132538+01:00","created_by":"soeren","updated_at":"2026-01-05T20:00:25.427132538+01:00"}
{"id":"fotospiel-app-spq8","title":"Eslint fails due to existing repo violations","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-19T18:49:19.208323875+01:00","created_by":"Codex Agent","updated_at":"2026-01-19T18:49:19.208323875+01:00"}
{"id":"fotospiel-app-swb","title":"Security review: replace public asset URLs with signed routes","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-01T16:04:05.610098299+01:00","created_by":"soeren","updated_at":"2026-01-01T16:04:11.215921463+01:00","closed_at":"2026-01-01T16:04:11.215921463+01:00","close_reason":"Completed in codebase (verified)"}
{"id":"fotospiel-app-t1k","title":"Live Show: data model \u0026 status workflow (pending/approved/ready)","acceptance_criteria":"- DB migrations add event token + photo live fields + indexes\\n- Token generation supports rotation (no expiry)\\n- Photo live workflow methods set timestamps/reviewer consistently\\n- Feature test covers token + workflow","notes":"Implemented Live Show data model: events.live_show_token + live_show_token_rotated_at; photos.live_status + timestamps/reviewer/rejection fields + indexes. Added PhotoLiveStatus enum and Photo workflow methods (markLivePending/approveForLiveShow/rejectForLiveShow). Added Event helpers (ensureLiveShowToken/rotateLiveShowToken). Tests: tests/Feature/LiveShowDataModelTest.php.","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-01-05T11:10:56.560421826+01:00","created_by":"soeren","updated_at":"2026-01-05T12:22:51.967913423+01:00","closed_at":"2026-01-05T12:22:51.967913423+01:00","close_reason":"Closed","dependencies":[{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-vro","type":"blocks","created_at":"2026-01-05T11:12:20.345646244+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-h5d","type":"blocks","created_at":"2026-01-05T11:44:12.439413712+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1eu","type":"blocks","created_at":"2026-01-05T11:44:22.588642567+01:00","created_by":"soeren"},{"issue_id":"fotospiel-app-t1k","depends_on_id":"fotospiel-app-1we","type":"blocks","created_at":"2026-01-05T11:44:31.775634827+01:00","created_by":"soeren"}]}
{"id":"fotospiel-app-t2s","title":"Uploader: multiple event profiles","description":"Part of epic fotospiel-app-5aa. Save multiple event profiles and allow quick switching.","status":"open","priority":2,"issue_type":"task","owner":"codex-agent@example.com","created_at":"2026-01-13T11:04:18.20222112+01:00","created_by":"Codex Agent","updated_at":"2026-01-13T11:04:18.20222112+01:00"}

View File

@@ -1 +1 @@
fotospiel-app-29r
fotospiel-app-6yz

View File

@@ -4,6 +4,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothConnectRedeemRequest;
use App\Models\Event;
use App\Services\Photobooth\PhotoboothConnectCodeService;
use Illuminate\Http\JsonResponse;
@@ -33,7 +34,8 @@ class PhotoboothConnectController extends Controller
return response()->json([
'data' => [
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'event_name' => $this->resolveEventName($event),
'upload_url' => route('api.v1.photobooth.upload'),
'username' => $setting->username,
'password' => $setting->password,
'expires_at' => optional($setting->expires_at)->toIso8601String(),
@@ -42,4 +44,27 @@ class PhotoboothConnectController extends Controller
],
]);
}
private function resolveEventName(?Event $event): ?string
{
if (! $event) {
return null;
}
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: null;
}
}

View File

@@ -3,12 +3,17 @@
namespace App\Http\Controllers\Api\Tenant;
use App\Http\Controllers\Controller;
use App\Http\Requests\Photobooth\PhotoboothSendUploaderDownloadRequest;
use App\Http\Resources\Tenant\PhotoboothStatusResource;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event;
use App\Models\PhotoboothSetting;
use App\Services\Photobooth\PhotoboothProvisioner;
use App\Support\LocaleConfig;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Validation\ValidationException;
class PhotoboothController extends Controller
{
@@ -69,6 +74,39 @@ class PhotoboothController extends Controller
]);
}
public function sendUploaderDownloadEmail(PhotoboothSendUploaderDownloadRequest $request, Event $event): JsonResponse
{
$this->assertEventBelongsToTenant($request, $event);
$user = $request->user();
if (! $user || ! $user->email) {
throw ValidationException::withMessages([
'email' => __('No email address is configured for this account.'),
]);
}
$locale = LocaleConfig::canonicalize($user->preferred_locale ?: app()->getLocale());
$eventName = $this->resolveEventName($event, $locale);
$recipientName = $user->fullName ?? $user->name ?? $user->email;
$mail = (new PhotoboothUploaderDownload(
recipientName: $recipientName,
eventName: $eventName,
links: [
'windows' => url('/downloads/PhotoboothUploader-win-x64.exe'),
'macos' => url('/downloads/PhotoboothUploader-macos-x64'),
'linux' => url('/downloads/PhotoboothUploader-linux-x64'),
],
))->locale($locale);
Mail::to($user->email)->queue($mail);
return response()->json([
'message' => __('Download links sent via email.'),
]);
}
protected function resource(Event $event): PhotoboothStatusResource
{
return PhotoboothStatusResource::make([
@@ -92,4 +130,30 @@ class PhotoboothController extends Controller
return in_array($mode, ['sparkbooth', 'ftp'], true) ? $mode : 'ftp';
}
protected function resolveEventName(Event $event, ?string $locale = null): string
{
$name = $event->name;
if (is_string($name) && trim($name) !== '') {
return $name;
}
if (is_array($name)) {
$locale = $locale ?: app()->getLocale();
$localized = $name[$locale] ?? null;
if (is_string($localized) && trim($localized) !== '') {
return $localized;
}
foreach ($name as $value) {
if (is_string($value) && trim($value) !== '') {
return $value;
}
}
}
return $event->slug ?: __('emails.photobooth_uploader.event_fallback');
}
}

View File

@@ -0,0 +1,18 @@
<?php
namespace App\Http\Requests\Photobooth;
use Illuminate\Foundation\Http\FormRequest;
class PhotoboothSendUploaderDownloadRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [];
}
}

View File

@@ -47,7 +47,7 @@ class PhotoboothStatusResource extends JsonResource
'password' => $password,
'path' => $eventSetting?->path,
'ftp_url' => $isSparkbooth ? null : $this->buildFtpUrl($eventSetting, $settings, $password),
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.upload') : null,
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.upload') : null,
'expires_at' => optional($activeExpires)->toIso8601String(),
'rate_limit_per_minute' => (int) $settings->rate_limit_per_minute,
'ftp' => [
@@ -62,7 +62,7 @@ class PhotoboothStatusResource extends JsonResource
'username' => $mode === 'sparkbooth' ? $eventSetting?->username : null,
'password' => $mode === 'sparkbooth' ? $password : null,
'expires_at' => $mode === 'sparkbooth' ? optional($eventSetting?->expires_at)->toIso8601String() : null,
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'upload_url' => route('api.v1.photobooth.upload'),
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
'metrics' => $sparkMetrics,
],

View File

@@ -0,0 +1,50 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class PhotoboothUploaderDownload extends Mailable implements ShouldQueue
{
use Queueable, SerializesModels;
/**
* @param array{windows:string, macos:string, linux:string} $links
*/
public function __construct(
public string $recipientName,
public string $eventName,
public array $links,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: __('emails.photobooth_uploader.subject', [
'event' => $this->eventName,
]),
);
}
public function content(): Content
{
return new Content(
view: 'emails.photobooth-uploader-download',
with: [
'recipientName' => $this->recipientName,
'eventName' => $this->eventName,
'links' => $this->links,
],
);
}
public function attachments(): array
{
return [];
}
}

View File

@@ -77,6 +77,8 @@ class HelpSyncService
foreach ($articles->groupBy(fn ($article) => $article['audience'].'::'.$article['locale']) as $key => $group) {
[$audience, $locale] = explode('::', $key);
$path = sprintf('%s/%s/%s/articles.json', $compiledPath, $audience, $locale);
$directory = sprintf('%s/%s/%s', $compiledPath, $audience, $locale);
Storage::disk($disk)->makeDirectory($directory);
Storage::disk($disk)->put($path, $group->values()->toJson(JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE));
Cache::forget($this->cacheKey($audience, $locale));
$written[$audience][$locale] = $group->count();

View File

@@ -6,5 +6,85 @@
<Application.Styles>
<FluentTheme />
<Style>
<Style.Resources>
<Color x:Key="BrandRose">#FFB6C1</Color>
<Color x:Key="BrandRoseStrong">#FF69B4</Color>
<Color x:Key="BrandRoseSoft">#FFE5EC</Color>
<Color x:Key="BrandGold">#FFD700</Color>
<Color x:Key="BrandSky">#87CEEB</Color>
<Color x:Key="BrandSkySoft">#E0F5FF</Color>
<Color x:Key="BrandNavy">#0F4C75</Color>
<Color x:Key="BrandSlate">#1F2937</Color>
<Color x:Key="BrandCream">#FFF8F5</Color>
<SolidColorBrush x:Key="TextPrimaryBrush" Color="{DynamicResource BrandSlate}" />
<SolidColorBrush x:Key="TextMutedBrush" Color="#6B7280" />
<SolidColorBrush x:Key="CardBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
<SolidColorBrush x:Key="CardBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="AccentBackgroundBrush" Color="{DynamicResource BrandSkySoft}" />
<SolidColorBrush x:Key="InputBorderBrush" Color="{DynamicResource BrandRoseSoft}" />
<SolidColorBrush x:Key="InputBackgroundBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="PrimaryButtonBrush" Color="{DynamicResource BrandRoseStrong}" />
<SolidColorBrush x:Key="PrimaryButtonTextBrush" Color="#FFFFFF" />
<SolidColorBrush x:Key="SecondaryButtonBrush" Color="{DynamicResource BrandSky}" />
<SolidColorBrush x:Key="SecondaryButtonTextBrush" Color="{DynamicResource BrandNavy}" />
<LinearGradientBrush x:Key="WindowBackgroundBrush" StartPoint="0,0" EndPoint="1,1">
<GradientStop Color="{DynamicResource BrandCream}" Offset="0" />
<GradientStop Color="{DynamicResource BrandRoseSoft}" Offset="0.5" />
<GradientStop Color="{DynamicResource BrandSkySoft}" Offset="1" />
</LinearGradientBrush>
</Style.Resources>
</Style>
<Style Selector="Window">
<Setter Property="Background" Value="{DynamicResource WindowBackgroundBrush}" />
<Setter Property="FontFamily" Value="Inter" />
<Setter Property="Foreground" Value="{DynamicResource TextPrimaryBrush}" />
</Style>
<Style Selector="TextBlock.title">
<Setter Property="FontSize" Value="20" />
<Setter Property="FontWeight" Value="SemiBold" />
</Style>
<Style Selector="TextBlock.subtitle">
<Setter Property="FontSize" Value="12" />
<Setter Property="Foreground" Value="{DynamicResource TextMutedBrush}" />
</Style>
<Style Selector="Border.card">
<Setter Property="Background" Value="{DynamicResource CardBackgroundBrush}" />
<Setter Property="BorderBrush" Value="{DynamicResource CardBorderBrush}" />
<Setter Property="BorderThickness" Value="1" />
<Setter Property="CornerRadius" Value="12" />
</Style>
<Style Selector="Border.card.accent">
<Setter Property="Background" Value="{DynamicResource AccentBackgroundBrush}" />
</Style>
<Style Selector="TextBox">
<Setter Property="BorderBrush" Value="{DynamicResource InputBorderBrush}" />
<Setter Property="Background" Value="{DynamicResource InputBackgroundBrush}" />
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="10,8" />
</Style>
<Style Selector="Button">
<Setter Property="CornerRadius" Value="8" />
<Setter Property="Padding" Value="12,8" />
</Style>
<Style Selector="Button.primary">
<Setter Property="Background" Value="{DynamicResource PrimaryButtonBrush}" />
<Setter Property="Foreground" Value="{DynamicResource PrimaryButtonTextBrush}" />
</Style>
<Style Selector="Button.secondary">
<Setter Property="Background" Value="{DynamicResource SecondaryButtonBrush}" />
<Setter Property="Foreground" Value="{DynamicResource SecondaryButtonTextBrush}" />
</Style>
</Application.Styles>
</Application>

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 B

View File

@@ -4,13 +4,27 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
x:Class="PhotoboothUploader.MainWindow"
Width="520" Height="360"
Title="Fotospiel Photobooth Uploader">
<Grid Margin="24" ColumnDefinitions="*,8,*">
<StackPanel Grid.Column="0" Spacing="12" MaxWidth="420">
<TextBlock Text="Fotospiel Photobooth Uploader" FontSize="20" FontWeight="SemiBold" />
Width="560" Height="420"
MinWidth="520" MinHeight="400"
Title="Die Fotospiel.App - Photobooth Uploader">
<Grid Margin="24,32,24,24" RowDefinitions="Auto,*">
<StackPanel Grid.Row="0" Orientation="Horizontal" Spacing="12" VerticalAlignment="Center">
<Border Width="40" Height="40" Classes="card accent" VerticalAlignment="Center" HorizontalAlignment="Left">
<Image Source="avares://PhotoboothUploader/Assets/logo.png" Width="28" Height="28" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Border>
<StackPanel Spacing="2">
<TextBlock x:Name="TitleText"
Text="Die Fotospiel.App - Photobooth Uploader"
Classes="title"
PointerPressed="TitleText_PointerPressed" />
<TextBlock Text="Sicherer Upload der Fotobox-Fotos ins Event." Classes="subtitle" />
</StackPanel>
</StackPanel>
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
<Border Padding="14" Classes="card">
<StackPanel Spacing="6">
<TextBlock Text="Schritte" FontWeight="SemiBold" />
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
@@ -19,34 +33,112 @@
</StackPanel>
</Border>
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" />
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" />
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" />
<StackPanel Spacing="6">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" />
<Border Padding="14" Classes="card">
<StackPanel Spacing="10">
<TextBlock Text="Verbindungscode" FontWeight="SemiBold" />
<TextBlock Text="Gib den 6-stelligen Verbindungscode ein." TextWrapping="Wrap" Classes="subtitle" />
<TextBox x:Name="CodeBox" MaxLength="6" Watermark="123456" TextChanged="CodeBox_TextChanged" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="ConnectButton" Content="Verbinden" Click="ConnectButton_Click" Classes="primary" />
<Button x:Name="ReconnectButton" Content="Erneut verbinden" Click="ReconnectButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Upload-Ordner" FontWeight="SemiBold" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" Classes="subtitle" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="DslrBoothPresetButton" Content="DSLrBooth" Click="DslrBoothPresetButton_Click" Classes="secondary" IsVisible="False" />
<Button x:Name="SparkboothPresetButton" Content="Sparkbooth" Click="SparkboothPresetButton_Click" Classes="secondary" IsVisible="False" />
</StackPanel>
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="False" Classes="primary" />
<Button x:Name="TestUploadButton" Content="Test-Upload senden" Click="TestUploadButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
<ToggleSwitch x:Name="QuietToggle" Content="Ruhiger Modus (nur Fehler anzeigen)" />
<Border x:Name="AdvancedPanel" Padding="12" Classes="card accent" IsVisible="False">
<StackPanel Spacing="6">
<TextBlock Text="Erweiterte Einstellungen" FontWeight="SemiBold" />
<ToggleSwitch x:Name="SettingsUnlockToggle" Content="Einstellungen entsperren" Checked="SettingsUnlockToggle_Changed" Unchecked="SettingsUnlockToggle_Changed" />
<TextBlock Text="Profile" />
<ComboBox x:Name="ProfilesBox" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="LoadProfileButton" Content="Profil laden" Click="LoadProfileButton_Click" Classes="secondary" />
<Button x:Name="SaveProfileButton" Content="Profil speichern" Click="SaveProfileButton_Click" Classes="secondary" />
</StackPanel>
<TextBlock Text="Basis-URL" />
<TextBox x:Name="BaseUrlBox" Watermark="https://fotospiel.app" />
<TextBlock Text="Max. parallele Uploads" />
<TextBox x:Name="MaxUploadsBox" Watermark="2" />
<TextBlock Text="Upload-Tempo" />
<ComboBox x:Name="UploadTempoBox" SelectedIndex="1">
<ComboBoxItem Content="Schnell (ohne Pause)" />
<ComboBoxItem Content="Normal" />
<ComboBoxItem Content="Sanft (schont Netzwerk)" />
</ComboBox>
<TextBlock Text="Nur diese Dateien (optional)" />
<TextBox x:Name="IncludePatternsBox" Watermark="*.jpg;*.jpeg;*.png" />
<TextBlock Text="Dateien ausschliessen (optional)" />
<TextBox x:Name="ExcludePatternsBox" Watermark="*_preview*;*.tmp" />
<TextBlock Text="Antwort-Format (optional)" />
<ComboBox x:Name="ResponseFormatBox" SelectedIndex="0">
<ComboBoxItem Content="Auto" />
<ComboBoxItem Content="JSON" />
<ComboBoxItem Content="XML" />
</ComboBox>
<TextBlock Text="Manuelle Zugangsdaten (optional)" FontWeight="SemiBold" Margin="0,8,0,0" />
<TextBlock Text="Diese Felder ueberschreiben den Verbindungscode." Classes="subtitle" TextWrapping="Wrap" />
<TextBlock Text="Upload-URL" />
<TextBox x:Name="ManualUploadUrlBox" Watermark="https://fotospiel.app/api/v1/photobooth/upload" />
<TextBlock Text="Benutzername" />
<TextBox x:Name="ManualUsernameBox" />
<TextBlock Text="Passwort" />
<TextBox x:Name="ManualPasswordBox" PasswordChar="•" />
<Button x:Name="TestConnectionButton" Content="Verbindung testen" Click="TestConnectionButton_Click" Classes="secondary" />
<Button x:Name="SaveAdvancedButton" Content="Speichern" Click="SaveAdvancedButton_Click" Classes="primary" />
</StackPanel>
</Border>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="12" MaxWidth="380">
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<StackPanel Grid.Column="2" Spacing="16" MaxWidth="380" Margin="0,6,0,0">
<Border Padding="14" Classes="card accent">
<StackPanel Spacing="6">
<TextBlock Text="Status" FontWeight="SemiBold" />
<TextBlock x:Name="StatusText" Text="Nicht verbunden." TextWrapping="Wrap" />
<TextBlock x:Name="LastUploadText" Text="Letzter Upload: —" />
<TextBlock x:Name="QueueStatusText" Text="Warteschlange: 0 · Läuft: 0 · Fehlgeschlagen: 0" />
<TextBlock x:Name="LiveStatusText" Text="Live: —" />
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="6">
<TextBlock Text="Details" FontWeight="SemiBold" />
<TextBlock x:Name="EventNameText" Text="Event: —" TextWrapping="Wrap" />
<TextBlock x:Name="BaseUrlText" Text="Basis-URL: —" TextWrapping="Wrap" />
<TextBlock x:Name="VersionText" Text="App-Version: —" />
<TextBlock x:Name="ConnectExpiryText" Text="Verbindungscode: —" TextWrapping="Wrap" />
<TextBlock x:Name="FolderHealthText" Text="Ordner: —" TextWrapping="Wrap" />
<TextBlock x:Name="DiskFreeText" Text="Freier Speicher: —" TextWrapping="Wrap" />
<TextBlock x:Name="LastSeenText" Text="Letzte Datei: —" TextWrapping="Wrap" />
<TextBlock x:Name="LastErrorText" Text="Letzter Fehler: —" TextWrapping="Wrap" />
<Button x:Name="LogCopyButton" Content="Log kopieren" Click="LogCopyButton_Click" Classes="secondary" />
</StackPanel>
</Border>
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<TextBlock Text="Letzte Uploads" FontWeight="SemiBold" />
<ItemsControl x:Name="RecentUploadsList" ItemsSource="{Binding RecentUploads}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
<Border Background="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto">
<TextBlock Grid.Column="0" Grid.Row="0" Text="{Binding FileName}" />
<TextBlock Grid.Column="1" Grid.Row="0" Text="{Binding StatusLabel}" />
@@ -56,8 +148,13 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
<StackPanel Orientation="Horizontal" Spacing="8">
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" Classes="secondary" />
<Button x:Name="ClearFailedButton" Content="Fehlerliste leeren" Click="ClearFailedButton_Click" IsEnabled="False" Classes="secondary" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Grid>
</Window>

View File

@@ -13,6 +13,9 @@ public sealed class PhotoboothConnectResponse
public sealed class PhotoboothConnectPayload
{
[JsonPropertyName("event_name")]
public string? EventName { get; set; }
[JsonPropertyName("upload_url")]
public string? UploadUrl { get; set; }

View File

@@ -0,0 +1,26 @@
using System;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothProfile
{
public string? Label { get; set; }
public string? EventName { get; set; }
public string? BaseUrl { get; set; }
public string? UploadUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; }
public string? IncludePatterns { get; set; }
public string? ExcludePatterns { get; set; }
public int MaxConcurrentUploads { get; set; } = 2;
public int UploadDelayMs { get; set; } = 500;
public string DisplayName
=> !string.IsNullOrWhiteSpace(Label)
? Label
: !string.IsNullOrWhiteSpace(EventName)
? EventName
: UploadUrl ?? BaseUrl ?? "Profil";
}

View File

@@ -1,11 +1,29 @@
using System;
using System.Collections.Generic;
namespace PhotoboothUploader.Models;
public sealed class PhotoboothSettings
{
public string? BaseUrl { get; set; }
public string? EventName { get; set; }
public string? UploadUrl { get; set; }
public string? Username { get; set; }
public string? Password { get; set; }
public string? ResponseFormat { get; set; }
public string? WatchFolder { get; set; }
public string? IncludePatterns { get; set; }
public string? ExcludePatterns { get; set; }
public List<string> PendingUploads { get; set; } = new();
public Dictionary<string, string> UploadedFiles { get; set; } = new(StringComparer.OrdinalIgnoreCase);
public List<PhotoboothProfile> Profiles { get; set; } = new();
public string? ConnectExpiresAt { get; set; }
public string? LastSeenFile { get; set; }
public string? LastSeenAt { get; set; }
public string? LastError { get; set; }
public string? LastErrorAt { get; set; }
public int MaxConcurrentUploads { get; set; } = 2;
public int UploadDelayMs { get; set; } = 500;
public double WindowWidth { get; set; }
public double WindowHeight { get; set; }
}

View File

@@ -4,6 +4,7 @@
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
@@ -18,4 +19,10 @@
<PrivateAssets Condition="'$(Configuration)' != 'Debug'">All</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Include="Assets\app.ico" />
<AvaloniaResource Include="Assets\logo.png" />
<AvaloniaResource Include="Assets\sample-upload.png" />
</ItemGroup>
</Project>

View File

@@ -1,5 +1,7 @@
using System;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using System.Threading;
@@ -10,41 +12,111 @@ namespace PhotoboothUploader.Services;
public sealed class PhotoboothConnectClient
{
private const int MaxRetries = 2;
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(10);
private readonly HttpClient _httpClient;
private readonly JsonSerializerOptions _jsonOptions = new()
{
PropertyNameCaseInsensitive = true,
};
public PhotoboothConnectClient(string baseUrl)
public PhotoboothConnectClient(string baseUrl, string userAgent)
{
_httpClient = new HttpClient
{
BaseAddress = new Uri(baseUrl),
Timeout = DefaultTimeout,
};
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd(userAgent);
}
public async Task<PhotoboothConnectResponse> RedeemAsync(string code, CancellationToken cancellationToken = default)
{
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
var request = new { code };
if (payload is null)
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
try
{
using var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", request, cancellationToken);
var payload = await ReadPayloadAsync(response, cancellationToken);
if (response.IsSuccessStatusCode)
{
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
if (response.StatusCode is HttpStatusCode.UnprocessableEntity or HttpStatusCode.Conflict or HttpStatusCode.Unauthorized)
{
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
if (attempt < MaxRetries && IsTransientStatus(response.StatusCode))
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return payload ?? Fail(response.ReasonPhrase ?? "Verbindung fehlgeschlagen.");
}
catch (TaskCanceledException) when (!cancellationToken.IsCancellationRequested)
{
if (attempt < MaxRetries)
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return Fail("Zeitüberschreitung bei der Verbindung.");
}
catch (HttpRequestException)
{
if (attempt < MaxRetries)
{
await Task.Delay(GetRetryDelay(attempt), cancellationToken);
continue;
}
return Fail("Netzwerkfehler. Bitte Verbindung prüfen.");
}
catch (JsonException)
{
return Fail("Serverantwort konnte nicht gelesen werden.");
}
}
return Fail("Verbindung fehlgeschlagen.");
}
private async Task<PhotoboothConnectResponse?> ReadPayloadAsync(HttpResponseMessage response, CancellationToken cancellationToken)
{
if (response.Content.Headers.ContentLength == 0)
{
return null;
}
return await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
}
private static bool IsTransientStatus(HttpStatusCode statusCode)
{
return statusCode is HttpStatusCode.RequestTimeout or HttpStatusCode.TooManyRequests
or HttpStatusCode.BadGateway or HttpStatusCode.ServiceUnavailable or HttpStatusCode.GatewayTimeout
or HttpStatusCode.InternalServerError;
}
private static TimeSpan GetRetryDelay(int attempt)
{
return TimeSpan.FromMilliseconds(500 * (attempt + 1));
}
private static PhotoboothConnectResponse Fail(string message)
{
return new PhotoboothConnectResponse
{
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
Message = message,
};
}
if (!response.IsSuccessStatusCode)
{
return new PhotoboothConnectResponse
{
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
};
}
return payload;
}
}

View File

@@ -14,6 +14,7 @@ public sealed class SettingsStore
};
public string SettingsPath { get; }
public string LogPath { get; }
public SettingsStore()
{
@@ -24,6 +25,7 @@ public sealed class SettingsStore
Directory.CreateDirectory(basePath);
SettingsPath = Path.Combine(basePath, "settings.json");
LogPath = Path.Combine(basePath, "uploader.log");
}
public PhotoboothSettings Load()

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -12,21 +13,38 @@ namespace PhotoboothUploader.Services;
public sealed class UploadService
{
private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(20);
private static readonly TimeSpan RetryBaseDelay = TimeSpan.FromSeconds(2);
private const int MaxRetries = 2;
private readonly Channel<string> _queue = Channel.CreateUnbounded<string>();
private readonly ConcurrentDictionary<string, byte> _pending = new(StringComparer.OrdinalIgnoreCase);
private string _userAgent = "FotospielPhotoboothUploader";
private CancellationTokenSource? _cts;
private readonly List<Task> _workers = new();
public void Configure(string userAgent)
{
if (!string.IsNullOrWhiteSpace(userAgent))
{
_userAgent = userAgent;
}
}
public void Start(
PhotoboothSettings settings,
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string> onFailure)
Action<string, string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
_ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
var workerCount = GetWorkerCount(settings);
for (var i = 0; i < workerCount; i++)
{
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
}
}
public void Stop()
@@ -34,6 +52,7 @@ public sealed class UploadService
_cts?.Cancel();
_cts = null;
_pending.Clear();
_workers.Clear();
}
public void Enqueue(string path, Action<string> onQueued)
@@ -52,7 +71,7 @@ public sealed class UploadService
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string> onFailure,
Action<string, string> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
@@ -61,6 +80,9 @@ public sealed class UploadService
}
using var client = new HttpClient();
client.Timeout = DefaultTimeout;
client.DefaultRequestHeaders.UserAgent.ParseAdd(_userAgent);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
while (await _queue.Reader.WaitToReadAsync(token))
{
@@ -69,58 +91,72 @@ public sealed class UploadService
try
{
onUploading(path);
await WaitForFileReadyAsync(path, token);
await UploadAsync(client, settings, path, token);
var error = await UploadWithRetryAsync(client, settings, path, token);
if (error is null)
{
onSuccess(path);
}
else
{
onFailure(path, error);
}
}
catch (OperationCanceledException)
{
return;
}
catch
{
onFailure(path);
}
finally
{
_pending.TryRemove(path, out _);
if (settings.UploadDelayMs > 0)
{
await Task.Delay(settings.UploadDelayMs, token);
}
}
}
}
}
private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
private static async Task<string?> UploadWithRetryAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
var lastSize = -1L;
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
var attemptError = await UploadOnceAsync(client, settings, path, token);
if (attemptError.Success)
{
return null;
}
for (var attempts = 0; attempts < 10; attempts++)
if (!attemptError.Retryable || attempt >= MaxRetries)
{
token.ThrowIfCancellationRequested();
return attemptError.Error ?? "Upload fehlgeschlagen.";
}
await Task.Delay(GetRetryDelay(attempt), token);
}
return "Upload fehlgeschlagen.";
}
private static async Task<UploadAttempt> UploadOnceAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
{
var readyError = await WaitForFileReadyAsync(path, token);
if (readyError is not null)
{
return UploadAttempt.Fail(readyError, retryable: false);
}
if (!File.Exists(path))
{
await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return;
}
lastSize = size;
await Task.Delay(700, token);
}
}
private static async Task UploadAsync(HttpClient client, PhotoboothSettings settings, string path, CancellationToken token)
{
if (!File.Exists(path))
{
return;
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
}
using var content = new MultipartFormDataContent();
@@ -145,8 +181,61 @@ public sealed class UploadService
fileContent.Headers.ContentType = new MediaTypeHeaderValue(ResolveContentType(path));
content.Add(fileContent, "media", Path.GetFileName(path));
try
{
var response = await client.PostAsync(settings.UploadUrl, content, token);
response.EnsureSuccessStatusCode();
if (response.IsSuccessStatusCode)
{
return UploadAttempt.Ok();
}
var body = await ReadResponseBodyAsync(response, token);
var status = $"{(int)response.StatusCode} {response.ReasonPhrase}".Trim();
var message = string.IsNullOrWhiteSpace(body) ? status : $"{status} {body}";
return UploadAttempt.Fail(message, IsRetryableStatus(response.StatusCode));
}
catch (TaskCanceledException) when (!token.IsCancellationRequested)
{
return UploadAttempt.Fail("Zeitüberschreitung beim Upload.", retryable: true);
}
catch (HttpRequestException)
{
return UploadAttempt.Fail("Netzwerkfehler beim Upload.", retryable: true);
}
catch (IOException)
{
return UploadAttempt.Fail("Datei konnte nicht gelesen werden.", retryable: false);
}
}
private static async Task<string?> WaitForFileReadyAsync(string path, CancellationToken token)
{
var lastSize = -1L;
for (var attempts = 0; attempts < 10; attempts++)
{
token.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
await Task.Delay(500, token);
continue;
}
var info = new FileInfo(path);
var size = info.Length;
if (size > 0 && size == lastSize)
{
return null;
}
lastSize = size;
await Task.Delay(700, token);
}
return "Datei ist noch in Bearbeitung.";
}
private static string ResolveContentType(string path)
@@ -158,4 +247,51 @@ public sealed class UploadService
_ => "image/jpeg",
};
}
private static bool IsRetryableStatus(System.Net.HttpStatusCode statusCode)
{
var numeric = (int)statusCode;
return numeric >= 500 || statusCode is System.Net.HttpStatusCode.RequestTimeout or System.Net.HttpStatusCode.TooManyRequests;
}
private static TimeSpan GetRetryDelay(int attempt)
{
var jitter = TimeSpan.FromMilliseconds(Random.Shared.Next(100, 350));
return TimeSpan.FromMilliseconds(RetryBaseDelay.TotalMilliseconds * Math.Pow(2, attempt)) + jitter;
}
private static async Task<string?> ReadResponseBodyAsync(HttpResponseMessage response, CancellationToken token)
{
if (response.Content is null)
{
return null;
}
var body = await response.Content.ReadAsStringAsync(token);
if (string.IsNullOrWhiteSpace(body))
{
return null;
}
body = body.Trim();
return body.Length > 200 ? body[..200] + "…" : body;
}
private static int GetWorkerCount(PhotoboothSettings settings)
{
var count = settings.MaxConcurrentUploads;
if (count < 1)
{
return 1;
}
return count > 5 ? 5 : count;
}
private readonly record struct UploadAttempt(bool Success, bool Retryable, string? Error)
{
public static UploadAttempt Ok() => new(true, false, null);
public static UploadAttempt Fail(string error, bool retryable) => new(false, retryable, error);
}
}

View File

@@ -24,7 +24,8 @@
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17",
"stripe/stripe-php": "*"
"stripe/stripe-php": "*",
"symfony/yaml": "^7.0"
},
"require-dev": {
"fakerphp/faker": "^1.23",

154
composer.lock generated
View File

@@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
"content-hash": "5e1d60e650853d6113b01e1adaf49d65",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -10043,6 +10043,82 @@
],
"time": "2025-10-27T20:36:44+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-04T18:11:45+00:00"
},
{
"name": "tijsverkoyen/css-to-inline-styles",
"version": "v2.3.0",
@@ -12852,82 +12928,6 @@
],
"time": "2024-10-20T05:08:20+00:00"
},
{
"name": "symfony/yaml",
"version": "v7.4.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/24dd4de28d2e3988b311751ac49e684d783e2345",
"reference": "24dd4de28d2e3988b311751ac49e684d783e2345",
"shasum": ""
},
"require": {
"php": ">=8.2",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/polyfill-ctype": "^1.8"
},
"conflict": {
"symfony/console": "<6.4"
},
"require-dev": {
"symfony/console": "^6.4|^7.0|^8.0"
},
"bin": [
"Resources/bin/yaml-lint"
],
"type": "library",
"autoload": {
"psr-4": {
"Symfony\\Component\\Yaml\\": ""
},
"exclude-from-classmap": [
"/Tests/"
]
},
"notification-url": "https://packagist.org/downloads/",
"license": [
"MIT"
],
"authors": [
{
"name": "Fabien Potencier",
"email": "fabien@symfony.com"
},
{
"name": "Symfony Community",
"homepage": "https://symfony.com/contributors"
}
],
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.1"
},
"funding": [
{
"url": "https://symfony.com/sponsor",
"type": "custom"
},
{
"url": "https://github.com/fabpot",
"type": "github"
},
{
"url": "https://github.com/nicolas-grekas",
"type": "github"
},
{
"url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
"type": "tidelift"
}
],
"time": "2025-12-04T18:11:45+00:00"
},
{
"name": "theseer/tokenizer",
"version": "1.3.1",

View File

@@ -106,6 +106,24 @@ services:
condition: service_healthy
restart: "no"
photobooth-uploader-build:
image: mcr.microsoft.com/dotnet/sdk:10.0
working_dir: /var/www/html
command:
- bash
- -lc
- /var/www/html/scripts/build-photobooth-uploader.sh
environment:
DOTNET_CLI_TELEMETRY_OPTOUT: "1"
NUGET_PACKAGES: /root/.nuget/packages
volumes:
- app-code:/var/www/html
- nuget-cache:/root/.nuget/packages
depends_on:
app:
condition: service_healthy
restart: "no"
help-sync:
image: ${APP_IMAGE_REPO:-fotospiel-app}:${APP_IMAGE_TAG:-latest}
env_file:
@@ -340,6 +358,7 @@ volumes:
external: true
name: fotospiel-${APP_ENV:-prod}-storage
app-bootstrap-cache:
nuget-cache:
photobooth-import:
photobooth-ftp-auth:
mysql-data:

View File

@@ -53,6 +53,23 @@ refresh_config_cache() {
php artisan view:clear >/dev/null 2>&1 || true
}
ensure_help_cache() {
cd "$APP_TARGET"
if [[ "${HELP_SYNC_ON_BOOT:-auto}" == "0" ]]; then
return
fi
if [[ "${HELP_SYNC_ON_BOOT:-auto}" == "1" ]]; then
php artisan help:sync >/dev/null 2>&1 || true
return
fi
if ! compgen -G "$APP_TARGET/storage/app/help/*/*/articles.json" > /dev/null; then
php artisan help:sync >/dev/null 2>&1 || true
fi
}
wait_for_service() {
local name="$1" host="$2" port="$3" timeout="$4"
local start
@@ -120,6 +137,7 @@ ensure_helper_scripts
prepare_storage
refresh_config_cache
wait_for_dependencies
ensure_help_cache
cd "$APP_TARGET"
exec "$@"

View File

@@ -20,6 +20,12 @@ server {
fastcgi_pass app:9000;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTP_X_FORWARDED_PROTO $http_x_forwarded_proto;
fastcgi_param HTTP_X_FORWARDED_HOST $http_x_forwarded_host;
fastcgi_param HTTP_X_FORWARDED_FOR $proxy_add_x_forwarded_for;
fastcgi_param HTTP_HOST $host;
fastcgi_param HTTP_X_FORWARDED_PORT $server_port;
fastcgi_param HTTPS $http_x_forwarded_proto;
fastcgi_buffer_size 32k;
fastcgi_buffers 8 16k;
}

View File

@@ -84,7 +84,7 @@ php artisan photobooth:ingest --event=123 --max-files=20
Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
- Endpoint: `POST /api/v1/photobooth/sparkbooth/upload`
- Endpoint: `POST /api/v1/photobooth/upload`
- Auth: per-event username/password (set in Event Admin → Fotobox-Uploads; switch mode to “Sparkbooth”).
- Body (multipart/form-data): `media` (file or base64), `username`, `password`, optionally `name`, `email`, `message`.
- Response:
@@ -99,7 +99,7 @@ Use this when Sparkbooth runs in “Custom Upload” mode instead of FTP.
Example cURL (JSON response):
```bash
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
curl -X POST https://app.example.com/api/v1/photobooth/upload \
-F "media=@/path/to/photo.jpg" \
-F "username=PB123" \
-F "password=SECRET" \
@@ -109,7 +109,7 @@ curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
Example cURL (request XML response):
```bash
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
curl -X POST https://app.example.com/api/v1/photobooth/upload \
-F "media=@/path/to/photo.jpg" \
-F "username=PB123" \
-F "password=SECRET" \

View File

@@ -65,6 +65,25 @@ return [
'benefit4' => 'Unterstuetzung, wenn du sie brauchst',
'footer' => 'Brauchst du Hilfe? Antworte einfach auf diese E-Mail.',
],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App fuer :event',
'preheader' => 'Download-Links fuer die Fotospiel Photobooth Uploader App.',
'hero_title' => 'Hallo :name,',
'hero_subtitle' => 'Deine Uploader App fuer :event ist bereit.',
'body' => 'Hier findest du die Download-Links fuer die Fotospiel Photobooth Uploader App. Installiere die passende Version auf dem Photobooth-PC, bevor dein Event startet.',
'downloads_title' => 'Download-Links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download fuer Windows',
'cta_macos' => 'Download fuer macOS',
'cta_linux' => 'Download fuer Linux',
'credentials_hint' => 'Die Zugangsdaten bleiben im Admin-Dashboard. Erstelle einen Verbindungscode, sobald du die App koppeln moechtest.',
'footer' => 'Fragen? Antworte einfach auf diese E-Mail.',
'event_fallback' => 'dein Event',
],
'package_limits' => [
'package_fallback' => 'Paket',
'team_fallback' => 'dein Team',

View File

@@ -65,6 +65,25 @@ return [
'benefit4' => 'Friendly support whenever you need help',
'footer' => 'Need help? Reply to this email.',
],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App for :event',
'preheader' => 'Download links for the Fotospiel Photobooth Uploader.',
'hero_title' => 'Hi :name,',
'hero_subtitle' => 'Your uploader app for :event is ready.',
'body' => 'Here are the download links for the Fotospiel Photobooth Uploader. Install the right version on the photobooth PC before your event starts.',
'downloads_title' => 'Download links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download for Windows',
'cta_macos' => 'Download for macOS',
'cta_linux' => 'Download for Linux',
'credentials_hint' => 'Connection credentials stay in the admin dashboard. Generate a connect code when you are ready to pair the app.',
'footer' => 'Questions? Reply to this email and we will help.',
'event_fallback' => 'your event',
],
'package_limits' => [
'package_fallback' => 'package',
'team_fallback' => 'your team',

View File

@@ -218,6 +218,11 @@ export type PhotoboothStatus = {
metrics?: PhotoboothStatusMetrics | null;
};
export type PhotoboothConnectCode = {
code: string;
expires_at: string | null;
};
export type EventAddonCheckout = {
addon_key: string;
quantity?: number;
@@ -2041,6 +2046,35 @@ export async function disableEventPhotobooth(slug: string, options?: { mode?: 'f
);
}
export async function createEventPhotoboothConnectCode(
slug: string,
options?: { expires_in_minutes?: number }
): Promise<PhotoboothConnectCode> {
const body = options ? JSON.stringify(options) : undefined;
const headers = body ? { 'Content-Type': 'application/json' } : undefined;
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/connect-codes`, {
method: 'POST',
body,
headers,
});
const data = await jsonOrThrow<{ data?: JsonValue }>(response, 'Failed to create photobooth connect code');
const record = (data.data ?? {}) as Record<string, JsonValue>;
return {
code: typeof record.code === 'string' ? record.code : '',
expires_at: typeof record.expires_at === 'string' ? record.expires_at : null,
};
}
export async function sendEventPhotoboothUploaderEmail(slug: string): Promise<void> {
const response = await authorizedFetch(`${photoboothEndpoint(slug)}/uploader-email`, {
method: 'POST',
});
await jsonOrThrow<{ message?: string }>(response, 'Failed to send photobooth uploader email');
}
export async function submitTenantFeedback(payload: {
category: string;
sentiment?: 'positive' | 'neutral' | 'negative';

View File

@@ -13,6 +13,7 @@ export const ADMIN_AUTH_CALLBACK_PATH = adminPath('/auth/callback');
export const ADMIN_EVENTS_PATH = adminPath('/mobile/events');
export const ADMIN_SETTINGS_PATH = adminPath('/mobile/settings');
export const ADMIN_PROFILE_PATH = adminPath('/mobile/profile');
export const ADMIN_PROFILE_ACCOUNT_PATH = adminPath('/mobile/profile/account');
export const ADMIN_FAQ_PATH = adminPath('/mobile/help');
export const ADMIN_BILLING_PATH = adminPath('/mobile/billing');
export const ADMIN_PACKAGE_SHOP_PATH = adminPath('/mobile/billing/shop');

View File

@@ -1168,15 +1168,23 @@
"mode": "Modus"
},
"mode": {
"title": "Photobooth-Typ auswählen",
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
"active": "Aktuell: {{mode}}"
"title": "Uploader-Verbindung",
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads. Beim Zurücksetzen werden neue Zugangsdaten generiert.",
"active": "Aktuell: {{mode}}",
"uploader": "Uploader-App (HTTP)"
},
"selector": {
"title": "Verbindung",
"description": "Nutze die Fotospiel-Uploader-App für HTTP-Uploads."
},
"credentials": {
"heading": "FTP-Zugangsdaten",
"description": "Teile die Zugangsdaten mit eurer Photobooth-Software.",
"sparkboothTitle": "Sparkbooth-Upload (HTTP)",
"sparkboothDescription": "Trage URL, Benutzername und Passwort in Sparkbooth ein. Antworten sind JSON (optional XML).",
"heading": "Zugangsdaten für die Uploader-App",
"description": "Teile die Zugangsdaten mit der Fotospiel-Uploader-App.",
"uploaderTitle": "Uploader-App (HTTP)",
"uploaderDescription": "Trage URL, Benutzername und Passwort in die Fotospiel-Uploader-App ein. Antworten sind JSON (optional XML).",
"show": "Zugangsdaten anzeigen",
"hide": "Zugangsdaten verbergen",
"hidden": "Zugangsdaten verborgen. Tippe zum Anzeigen.",
"host": "Host",
"port": "Port",
"username": "Benutzername",
@@ -1185,6 +1193,44 @@
"postUrl": "Upload-URL",
"responseFormat": "Antwort-Format"
},
"connectCode": {
"label": "Verbindungscode",
"description": "Erstelle einen 6-stelligen Code für die Uploader-App.",
"expires": "Läuft ab: {{date}}",
"actions": {
"generate": "Verbindungscode erstellen",
"generated": "Verbindungscode erstellt"
},
"errors": {
"failed": "Verbindungscode konnte nicht erstellt werden."
}
},
"uploader": {
"hint": "POST mit Mediendatei oder base64-Feld \"media\"; die App nutzt diese Zugangsdaten."
},
"steps": {
"activate": {
"title": "1. Photobooth aktivieren",
"description": "Schalte den Upload-Zugang fuer dieses Event frei."
},
"download": {
"title": "2. Uploader App herunterladen"
},
"access": {
"title": "3. Verbindungscode erstellen",
"description": "Der Code verbindet die App sicher mit deinem Event."
}
},
"uploaderDownload": {
"title": "Fotospiel Uploader App",
"description": "Die Fotospiel Uploader App wird benötigt, damit Uploads stabil laufen, die Zugangsdaten geschützt bleiben und keine Dateien verloren gehen.",
"emailAction": "Download-Links per E-Mail senden",
"emailSuccess": "Download-Links wurden per E-Mail gesendet.",
"emailFailed": "E-Mail konnte nicht gesendet werden.",
"actionWindows": "Uploader herunterladen (Windows)",
"actionMac": "Uploader herunterladen (macOS)",
"actionLinux": "Uploader herunterladen (Linux)"
},
"actions": {
"enable": "Photobooth aktivieren",
"disable": "Deaktivieren",
@@ -1202,9 +1248,9 @@
"title": "Setup-Checkliste",
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
"enable": "Zugang aktivieren",
"enableCopy": "Aktiviere den FTP-Account für eure Photobooth-Software.",
"enableCopy": "Aktiviere die Verbindung für die Uploader-App.",
"share": "Zugang teilen",
"shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.",
"shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.",
"monitor": "Uploads beobachten",
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
},
@@ -1431,7 +1477,7 @@
"photobooth": {
"title": "Fotobox-Uploads",
"titleForEvent": "Fotobox-Uploads verwalten",
"subtitle": "Erstelle FTP-Zugänge für Photobooth-Software und behalte Limits im Blick.",
"subtitle": "Erstelle Zugang für die Uploader-App und behalte Limits im Blick.",
"actions": {
"backToEvent": "Zur Detailansicht",
"allEvents": "Zur Eventliste"
@@ -2346,7 +2392,7 @@
"mobileProfile": {
"title": "Profil",
"settings": "Einstellungen",
"account": "Account & Sicherheit",
"account": "Account bearbeiten",
"language": "Sprache",
"languageDe": "Deutsch",
"languageEn": "Englisch",

View File

@@ -2,6 +2,7 @@
"profile": {
"title": "Profil",
"subtitle": "Verwalte deine Kontodaten und Zugangsdaten.",
"loading": "Lädt ...",
"sections": {
"account": {
"heading": "Account-Informationen",

View File

@@ -881,15 +881,23 @@
"mode": "Mode"
},
"mode": {
"title": "Choose your photobooth type",
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
"active": "Current: {{mode}}"
"title": "Uploader connection",
"description": "Use the Fotospiel uploader app for live HTTP uploads. Rotating access regenerates credentials.",
"active": "Current: {{mode}}",
"uploader": "Uploader App (HTTP)"
},
"selector": {
"title": "Connection",
"description": "Use the Fotospiel uploader app for HTTP uploads."
},
"credentials": {
"heading": "FTP credentials",
"description": "Share these credentials with your photobooth software.",
"sparkboothTitle": "Sparkbooth upload (HTTP)",
"sparkboothDescription": "Enter URL, username and password in Sparkbooth. Responses default to JSON (XML optional).",
"heading": "Uploader app credentials",
"description": "Share these credentials with the Fotospiel uploader app.",
"uploaderTitle": "Uploader App (HTTP)",
"uploaderDescription": "Enter URL, username and password in the Fotospiel uploader app. Responses default to JSON (XML optional).",
"show": "Show credentials",
"hide": "Hide credentials",
"hidden": "Credentials are hidden. Tap to show them.",
"host": "Host",
"port": "Port",
"username": "Username",
@@ -898,6 +906,44 @@
"postUrl": "Upload URL",
"responseFormat": "Response format"
},
"connectCode": {
"label": "Connect code",
"description": "Create a 6-digit code for the uploader app.",
"expires": "Expires: {{date}}",
"actions": {
"generate": "Generate connect code",
"generated": "Connect code created"
},
"errors": {
"failed": "Connect code could not be created."
}
},
"uploader": {
"hint": "POST with media file or base64 \"media\" field; app uses these credentials."
},
"steps": {
"activate": {
"title": "1. Activate photobooth",
"description": "Enable upload access for this event."
},
"download": {
"title": "2. Download uploader app"
},
"access": {
"title": "3. Generate connect code",
"description": "The code securely pairs the app with your event."
}
},
"uploaderDownload": {
"title": "Fotospiel Uploader App",
"description": "The Fotospiel Uploader App is required so uploads stay stable, credentials remain protected, and no files are lost.",
"emailAction": "Send download links by email",
"emailSuccess": "Download links were sent by email.",
"emailFailed": "Email could not be sent.",
"actionWindows": "Download uploader (Windows)",
"actionMac": "Download uploader (macOS)",
"actionLinux": "Download uploader (Linux)"
},
"actions": {
"enable": "Activate photobooth",
"disable": "Disable",
@@ -915,9 +961,9 @@
"title": "Setup checklist",
"description": "Complete each step before guests upload.",
"enable": "Activate access",
"enableCopy": "Enable the FTP account in your photobooth software.",
"enableCopy": "Enable the uploader app connection for this event.",
"share": "Share credentials",
"shareCopy": "Hand over host, user, and password to the operator.",
"shareCopy": "Share URL, username, and password with the operator.",
"monitor": "Monitor uploads",
"monitorCopy": "Watch uploads & limits in the dashboard."
},
@@ -1428,7 +1474,7 @@
"photobooth": {
"title": "Photobooth uploads",
"titleForEvent": "Manage photobooth uploads",
"subtitle": "Create FTP access for photobooth software and keep limits in sight.",
"subtitle": "Create uploader access for photobooth apps and keep limits in sight.",
"actions": {
"backToEvent": "Back to detail view",
"allEvents": "Back to event list"
@@ -2350,7 +2396,7 @@
"mobileProfile": {
"title": "Profile",
"settings": "Settings",
"account": "Account & security",
"account": "Edit account",
"language": "Language",
"languageDe": "Deutsch",
"languageEn": "English",

View File

@@ -2,6 +2,7 @@
"profile": {
"title": "Profile",
"subtitle": "Manage your account details and credentials.",
"loading": "Loading ...",
"sections": {
"account": {
"heading": "Account information",

View File

@@ -48,6 +48,29 @@ export function formatEventDate(value?: string | null, locale = 'de-DE'): string
}
}
export function formatEventDateTime(value?: string | null, locale = 'de-DE'): string | null {
if (!value) {
return null;
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return null;
}
try {
return new Intl.DateTimeFormat(locale, {
day: '2-digit',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
}).format(date);
} catch {
return date.toISOString().slice(0, 16).replace('T', ' ');
}
}
export function resolveEngagementMode(event?: TenantEvent | null): 'tasks' | 'photo_only' | null {
if (!event) {
return null;

View File

@@ -1,10 +1,9 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } from 'lucide-react';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3, Mail, Download } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Switch } from '@tamagui/switch';
import { Pressable } from '@tamagui/react-native-web-lite';
import { MobileShell, HeaderActionButton } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge, SkeletonCard } from './components/Primitives';
@@ -14,12 +13,14 @@ import {
enableEventPhotobooth,
disableEventPhotobooth,
rotateEventPhotobooth,
createEventPhotoboothConnectCode,
sendEventPhotoboothUploaderEmail,
PhotoboothStatus,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate } from '../lib/events';
import { formatEventDate, formatEventDateTime } from '../lib/events';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
@@ -34,10 +35,14 @@ export default function MobileEventPhotoboothPage() {
const [event, setEvent] = React.useState<TenantEvent | null>(null);
const [status, setStatus] = React.useState<PhotoboothStatus | null>(null);
const [selectedMode, setSelectedMode] = React.useState<'ftp' | 'sparkbooth'>('ftp');
const [loading, setLoading] = React.useState(true);
const [updating, setUpdating] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const [connectCode, setConnectCode] = React.useState<string | null>(null);
const [connectExpiresAt, setConnectExpiresAt] = React.useState<string | null>(null);
const [connectLoading, setConnectLoading] = React.useState(false);
const [sendingEmail, setSendingEmail] = React.useState(false);
const [showCredentials, setShowCredentials] = React.useState(false);
const back = useBackNavigation(slug ? adminPath(`/mobile/events/${slug}`) : adminPath('/mobile/events'));
const locale = i18n.language?.startsWith('en') ? 'en-GB' : 'de-DE';
@@ -50,7 +55,6 @@ export default function MobileEventPhotoboothPage() {
const [eventData, statusData] = await Promise.all([getEvent(slug), getEventPhotoboothStatus(slug)]);
setEvent(eventData);
setStatus(statusData);
setSelectedMode(statusData.mode ?? 'ftp');
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('management.photobooth.errors.loadFailed', 'Photobooth-Link konnte nicht geladen werden.')));
@@ -64,20 +68,14 @@ export default function MobileEventPhotoboothPage() {
void load();
}, [load]);
React.useEffect(() => {
if (status?.mode) {
setSelectedMode(status.mode);
}
}, [status?.mode]);
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
const handleEnable = async () => {
if (!slug) return;
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
const nextMode = 'sparkbooth';
setUpdating(true);
try {
const result = await enableEventPhotobooth(slug, { mode: nextMode });
setStatus(result);
setSelectedMode(result.mode ?? nextMode);
toast.success(t('management.photobooth.actions.enable', 'Zugang aktiviert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -90,12 +88,11 @@ export default function MobileEventPhotoboothPage() {
const handleDisable = async () => {
if (!slug) return;
const mode = status?.mode ?? selectedMode ?? 'ftp';
const mode = 'sparkbooth';
setUpdating(true);
try {
const result = await disableEventPhotobooth(slug, { mode });
setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.actions.disable', 'Zugang deaktiviert'));
} catch (err) {
if (!isAuthError(err)) {
@@ -108,12 +105,11 @@ export default function MobileEventPhotoboothPage() {
const handleRotate = async () => {
if (!slug) return;
const mode = selectedMode ?? status?.mode ?? 'ftp';
const mode = 'sparkbooth';
setUpdating(true);
try {
const result = await rotateEventPhotobooth(slug, { mode });
setStatus(result);
setSelectedMode(result.mode ?? mode);
toast.success(t('management.photobooth.presets.actions.rotate', 'Zugang zurückgesetzt'));
} catch (err) {
if (!isAuthError(err)) {
@@ -124,39 +120,57 @@ export default function MobileEventPhotoboothPage() {
}
};
const activeMode = selectedMode ?? status?.mode ?? 'ftp';
const isSpark = activeMode === 'sparkbooth';
const handleGenerateConnectCode = async () => {
if (!slug) return;
setConnectLoading(true);
try {
const result = await createEventPhotoboothConnectCode(slug);
setConnectCode(result.code || null);
setConnectExpiresAt(result.expires_at ?? null);
toast.success(t('photobooth.connectCode.actions.generated', 'Verbindungscode erstellt'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.connectCode.errors.failed', 'Verbindungscode konnte nicht erstellt werden.'))
);
}
} finally {
setConnectLoading(false);
}
};
const handleSendDownloadEmail = async () => {
if (!slug) return;
setSendingEmail(true);
try {
await sendEventPhotoboothUploaderEmail(slug);
toast.success(t('photobooth.uploaderDownload.emailSuccess', 'Download-Links wurden per E-Mail gesendet.'));
} catch (err) {
if (!isAuthError(err)) {
toast.error(
getApiErrorMessage(err, t('photobooth.uploaderDownload.emailFailed', 'E-Mail konnte nicht gesendet werden.'))
);
}
} finally {
setSendingEmail(false);
}
};
const spark = status?.sparkbooth ?? null;
const ftp = status?.ftp ?? null;
const metrics = isSpark ? spark?.metrics ?? null : status?.metrics ?? null;
const expiresAt = isSpark ? spark?.expires_at ?? status?.expires_at : status?.expires_at ?? spark?.expires_at;
const metrics = spark?.metrics ?? null;
const expiresAt = spark?.expires_at ?? status?.expires_at;
const lastUploadAt = metrics?.last_upload_at;
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
const uploadsTotal = metrics?.uploads_total;
const connectionPath = status?.path ?? '—';
const ftpUrl = status?.ftp_url ?? '—';
const uploadUrl = isSpark ? spark?.upload_url ?? status?.upload_url : null;
const responseFormat = spark?.response_format ?? 'json';
const username = isSpark ? spark?.username ?? status?.username : status?.username ?? spark?.username ?? null;
const password = isSpark ? spark?.password ?? status?.password : status?.password ?? spark?.password ?? null;
const uploadUrl = spark?.upload_url ?? status?.upload_url;
const username = spark?.username ?? status?.username ?? null;
const password = spark?.password ?? status?.password ?? null;
const modeLabel =
activeMode === 'sparkbooth'
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
: t('photobooth.credentials.heading', 'FTP (Classic)');
const modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)');
const isActive = Boolean(status?.enabled);
const title = t('photobooth.title', 'Photobooth');
const handleToggle = (checked: boolean) => {
if (!slug || updating) return;
if (checked) {
void handleEnable(status?.mode ?? 'ftp');
} else {
void handleDisable();
}
};
return (
<MobileShell
activeTab="home"
@@ -185,145 +199,161 @@ export default function MobileEventPhotoboothPage() {
) : (
<YStack space="$2">
<MobileCard space="$3">
<XStack justifyContent="space-between" alignItems="center" space="$3" flexWrap="wrap">
<YStack space="$1" flex={1} minWidth={0}>
<Text fontSize="$md" fontWeight="800" color={text}>
{t('photobooth.title', 'Photobooth')}
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
</Text>
</YStack>
<YStack alignItems="flex-end" space="$2">
<XStack alignItems="center" justifyContent="space-between" space="$3" flexWrap="wrap">
<PillBadge tone={isActive ? 'success' : 'warning'}>
{isActive ? t('photobooth.status.badgeActive', 'ACTIVE') : t('photobooth.status.badgeInactive', 'INACTIVE')}
</PillBadge>
<XStack alignItems="center" space="$2">
<Text fontSize="$xs" color={muted}>
{isActive ? t('common.enabled', 'Enabled') : t('common.disabled', 'Disabled')}
</Text>
<Switch
size="$4"
checked={isActive}
disabled={updating}
onCheckedChange={handleToggle}
aria-label={t('photobooth.actions.toggle', 'Toggle photobooth access')}
>
<Switch.Thumb />
</Switch>
</XStack>
</YStack>
</XStack>
<YStack space="$1" marginTop="$2">
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xs" color={muted}>
{t('photobooth.stats.lastUpload', 'Last upload')}
</Text>
<Text fontSize="$xs" fontWeight="700" color={text}>
{lastUploadAt ? formatEventDate(lastUploadAt, locale) : t('photobooth.status.never', 'Never')}
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text>
</XStack>
<XStack justifyContent="space-between" alignItems="center">
<Text fontSize="$xs" color={muted}>
{t('photobooth.status.expires', 'Access expires')}
</Text>
<Text fontSize="$xs" fontWeight="700" color={text}>
{expiresAt ? formatEventDate(expiresAt, locale) : '—'}
</Text>
</XStack>
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.selector.title', 'Choose adapter')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'photobooth.selector.description',
'FTP (Classic) works with most booths. Sparkbooth uses HTTP POST without FTP.'
)}
</Text>
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
<XStack flex={1} minWidth={0}>
<CTAButton
label={t('photobooth.mode.ftp', 'FTP (Classic)')}
tone={activeMode === 'ftp' ? 'primary' : 'ghost'}
onPress={() => setSelectedMode('ftp')}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
<XStack flex={1} minWidth={0}>
<CTAButton
label={t('photobooth.mode.sparkbooth', 'Sparkbooth (HTTP POST)')}
tone={activeMode === 'sparkbooth' ? 'primary' : 'ghost'}
onPress={() => setSelectedMode('sparkbooth')}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
</XStack>
</MobileCard>
<MobileCard space="$2">
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$sm" fontWeight="700" color={text}>
{isSpark ? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth upload (HTTP)') : t('photobooth.credentials.heading', 'FTP credentials')}
</Text>
{!isSpark && ftp?.require_ftps ? <PillBadge tone="warning">{t('photobooth.credentials.ftps', 'FTPS required')}</PillBadge> : null}
</XStack>
<YStack space="$1">
{isSpark ? (
<>
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
<CredentialRow label={t('photobooth.sparkbooth.format', 'Response format')} value={responseFormat.toUpperCase()} border={border} />
<Text fontSize="$xs" color={muted}>
{t('photobooth.sparkbooth.hint', 'POST with media file or base64 "media" field; username/password required.')}
</Text>
</>
) : (
<>
<CredentialRow label={t('photobooth.credentials.host', 'Host')} value={ftp?.host ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.port', 'Port')} value={String(ftp?.port ?? '—')} border={border} />
<CredentialRow label={t('photobooth.credentials.path', 'Target folder')} value={connectionPath} border={border} />
<CredentialRow label={t('photobooth.credentials.postUrl', 'FTP URL')} value={ftpUrl} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
<Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
</Text>
</>
)}
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<XStack flex={1} minWidth={0}>
<CTAButton
label={updating ? t('common.processing', '...') : t('photobooth.actions.rotate', 'Regenerate access')}
onPress={() => handleRotate()}
iconLeft={<RefreshCw size={14} color={surface} />}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
/>
</XStack>
<XStack flex={1} minWidth={0}>
<XStack space="$2" marginTop="$2">
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
onPress={() => (isActive ? handleDisable() : handleEnable())}
tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
disabled={updating}
style={{ width: '100%', paddingHorizontal: 10, paddingVertical: 10 }}
fullWidth={false}
/>
{isActive ? (
<CTAButton
label={t('photobooth.actions.rotate', 'Regenerate access')}
onPress={() => handleRotate()}
tone="ghost"
iconLeft={<RefreshCw size={14} color={text} />}
disabled={updating}
fullWidth={false}
/>
) : null}
</XStack>
</MobileCard>
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
</Text>
<Text fontSize="$xs" color={muted}>
{t(
'photobooth.uploaderDownload.description',
'Die Fotospiel Uploader App ist verpflichtend, damit Uploads stabil laufen, die Zugangsdaten geschuetzt bleiben und keine Dateien verloren gehen.'
)}
</Text>
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<CTAButton
label={t('photobooth.uploaderDownload.actionWindows', 'Uploader herunterladen (Windows)')}
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-win-x64.exe', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
iconLeft={<Download size={14} color={surface} />}
fullWidth={false}
/>
<CTAButton
label={t('photobooth.uploaderDownload.actionMac', 'Uploader herunterladen (macOS)')}
tone="ghost"
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-macos-x64', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
fullWidth={false}
/>
<CTAButton
label={t('photobooth.uploaderDownload.actionLinux', 'Uploader herunterladen (Linux)')}
tone="ghost"
onPress={() => {
const url = new URL('/downloads/PhotoboothUploader-linux-x64', window.location.origin).toString();
window.open(url, '_blank', 'noopener,noreferrer');
}}
fullWidth={false}
/>
</XStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={
sendingEmail
? t('common.processing', '...')
: t('photobooth.uploaderDownload.emailAction', 'Download-Links per E-Mail senden')
}
tone="ghost"
onPress={handleSendDownloadEmail}
iconLeft={<Mail size={14} color={text} />}
disabled={sendingEmail}
fullWidth={false}
/>
</XStack>
</MobileCard>
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.steps.access.description', 'Der Code verbindet die App sicher mit deinem Event.')}
</Text>
</YStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={
connectLoading
? t('common.processing', '...')
: t('photobooth.connectCode.actions.generate', 'Generate connect code')
}
onPress={handleGenerateConnectCode}
iconLeft={<PlugZap size={14} color={surface} />}
disabled={!isActive || updating || connectLoading}
fullWidth={false}
/>
<CTAButton
label={
showCredentials
? t('photobooth.credentials.hide', 'Hide credentials')
: t('photobooth.credentials.show', 'Show credentials')
}
tone="ghost"
onPress={() => setShowCredentials((current) => !current)}
fullWidth={false}
/>
</XStack>
<YStack space="$2" marginTop="$2">
{connectCode ? (
<CredentialRow label={t('photobooth.connectCode.label', 'Connect code')} value={connectCode} border={border} />
) : null}
{connectExpiresAt ? (
<Text fontSize="$xs" color={muted}>
{t('photobooth.connectCode.expires', 'Expires: {{date}}', {
date: formatEventDateTime(connectExpiresAt, locale),
})}
</Text>
) : null}
{showCredentials ? (
<YStack space="$1">
<CredentialRow label={t('photobooth.credentials.postUrl', 'Upload URL')} value={uploadUrl ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.username', 'Username')} value={username ?? '—'} border={border} />
<CredentialRow label={t('photobooth.credentials.password', 'Password')} value={password ?? '—'} border={border} masked />
</YStack>
) : (
<Text fontSize="$xs" color={muted}>
{t('photobooth.credentials.hidden', 'Credentials are hidden. Tap to show them.')}
</Text>
)}
<Text fontSize="$xs" color={muted}>
{t('photobooth.uploader.hint', 'POST with media file or base64 "media" field; app uses these credentials.')}
</Text>
</YStack>
</MobileCard>
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.status.heading', 'Status')}

View File

@@ -0,0 +1,302 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { CheckCircle2, Lock, MailWarning, User } from 'lucide-react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import toast from 'react-hot-toast';
import { MobileShell } from './components/MobileShell';
import { MobileCard, CTAButton, PillBadge } from './components/Primitives';
import { MobileField, MobileInput, MobileSelect } from './components/FormControls';
import { fetchTenantProfile, updateTenantProfile, type TenantAccountProfile } from '../api';
import { getApiErrorMessage, getApiValidationMessage } from '../lib/apiError';
import { ADMIN_PROFILE_PATH } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
import { useAdminTheme } from './theme';
import i18n from '../i18n';
type ProfileFormState = {
name: string;
email: string;
preferredLocale: string;
currentPassword: string;
password: string;
passwordConfirmation: string;
};
const LOCALE_OPTIONS = [
{ value: '', labelKey: 'profile.locale.auto', fallback: 'Automatisch' },
{ value: 'de', label: 'Deutsch' },
{ value: 'en', label: 'English' },
];
export default function MobileProfileAccountPage() {
const { t } = useTranslation('settings');
const { text, muted, danger, subtle, primary, accentSoft } = useAdminTheme();
const back = useBackNavigation(ADMIN_PROFILE_PATH);
const [profile, setProfile] = React.useState<TenantAccountProfile | null>(null);
const [form, setForm] = React.useState<ProfileFormState>({
name: '',
email: '',
preferredLocale: '',
currentPassword: '',
password: '',
passwordConfirmation: '',
});
const [loading, setLoading] = React.useState(true);
const [savingAccount, setSavingAccount] = React.useState(false);
const [savingPassword, setSavingPassword] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const loadErrorMessage = t('profile.errors.load', 'Profil konnte nicht geladen werden.');
const dateFormatter = React.useMemo(
() =>
new Intl.DateTimeFormat(i18n.language || 'de', {
day: '2-digit',
month: 'long',
year: 'numeric',
}),
[i18n.language],
);
React.useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await fetchTenantProfile();
setProfile(data);
setForm((prev) => ({
...prev,
name: data.name ?? '',
email: data.email ?? '',
preferredLocale: data.preferred_locale ?? '',
}));
setError(null);
} catch (err) {
setError(getApiErrorMessage(err, loadErrorMessage));
} finally {
setLoading(false);
}
})();
}, []);
const verifiedAt = profile?.email_verified_at ? new Date(profile.email_verified_at) : null;
const verifiedDate = verifiedAt ? dateFormatter.format(verifiedAt) : null;
const emailStatusLabel = profile?.email_verified
? t('profile.status.emailVerified', 'E-Mail bestätigt')
: t('profile.status.emailNotVerified', 'Bestätigung erforderlich');
const emailHint = profile?.email_verified
? t('profile.status.verifiedHint', 'Bestätigt am {{date}}.', { date: verifiedDate ?? '' })
: t('profile.status.unverifiedHint', 'Bei Änderung der E-Mail senden wir dir automatisch eine neue Bestätigung.');
const buildPayload = (includePassword: boolean) => ({
name: form.name.trim(),
email: form.email.trim(),
preferred_locale: form.preferredLocale ? form.preferredLocale : null,
...(includePassword
? {
current_password: form.currentPassword,
password: form.password,
password_confirmation: form.passwordConfirmation,
}
: {}),
});
const handleAccountSave = async () => {
setSavingAccount(true);
try {
const updated = await updateTenantProfile(buildPayload(false));
setProfile(updated);
setError(null);
toast.success(t('profile.toasts.updated', 'Profil wurde aktualisiert.'));
} catch (err) {
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
setError(message);
toast.error(message);
} finally {
setSavingAccount(false);
}
};
const handlePasswordSave = async () => {
setSavingPassword(true);
try {
const updated = await updateTenantProfile(buildPayload(true));
setProfile(updated);
setError(null);
setForm((prev) => ({
...prev,
currentPassword: '',
password: '',
passwordConfirmation: '',
}));
toast.success(t('profile.toasts.passwordChanged', 'Passwort wurde aktualisiert.'));
} catch (err) {
const message = getApiValidationMessage(err, t('profile.errors.update', 'Profil konnte nicht aktualisiert werden.'));
setError(message);
toast.error(message);
} finally {
setSavingPassword(false);
}
};
const passwordReady =
form.currentPassword.trim().length > 0 &&
form.password.trim().length > 0 &&
form.passwordConfirmation.trim().length > 0;
return (
<MobileShell
activeTab="profile"
title={t('profile.title', 'Profil')}
onBack={back}
>
{error ? (
<MobileCard>
<Text fontWeight="700" color={danger}>
{error}
</Text>
</MobileCard>
) : null}
<MobileCard space="$3">
<XStack alignItems="center" space="$3">
<XStack
width={48}
height={48}
borderRadius={16}
alignItems="center"
justifyContent="center"
backgroundColor={accentSoft}
>
<User size={20} color={primary} />
</XStack>
<YStack space="$1">
<Text fontSize="$md" fontWeight="800" color={text}>
{form.name || profile?.email || t('profile.title', 'Profil')}
</Text>
<Text fontSize="$sm" color={muted}>
{form.email || profile?.email || '—'}
</Text>
</YStack>
</XStack>
<XStack alignItems="center" space="$2" flexWrap="wrap">
{profile?.email_verified ? (
<CheckCircle2 size={14} color={subtle} />
) : (
<MailWarning size={14} color={subtle} />
)}
<PillBadge tone={profile?.email_verified ? 'success' : 'warning'}>
{emailStatusLabel}
</PillBadge>
<Text fontSize="$xs" color={muted}>
{emailHint}
</Text>
</XStack>
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<User size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.account.heading', 'Account-Informationen')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.account.description', 'Passe Anzeigename, E-Mail-Adresse und Sprache der Admin-Oberfläche an.')}
</Text>
{loading ? (
<Text fontSize="$sm" color={muted}>
{t('profile.loading', 'Lädt ...')}
</Text>
) : (
<YStack space="$3">
<MobileField label={t('profile.fields.name', 'Anzeigename')}>
<MobileInput
value={form.name}
onChange={(event) => setForm((prev) => ({ ...prev, name: event.target.value }))}
placeholder={t('profile.placeholders.name', 'z. B. Hochzeitsplanung Schmidt')}
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.email', 'E-Mail-Adresse')}>
<MobileInput
value={form.email}
onChange={(event) => setForm((prev) => ({ ...prev, email: event.target.value }))}
placeholder="mail@beispiel.de"
type="email"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.locale', 'Bevorzugte Sprache')}>
<MobileSelect
value={form.preferredLocale}
onChange={(event) => setForm((prev) => ({ ...prev, preferredLocale: event.target.value }))}
>
{LOCALE_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label ?? t(option.labelKey, option.fallback)}
</option>
))}
</MobileSelect>
</MobileField>
<CTAButton
label={t('profile.actions.save', 'Speichern')}
onPress={handleAccountSave}
disabled={savingAccount || loading}
loading={savingAccount}
/>
</YStack>
)}
</MobileCard>
<MobileCard space="$3">
<XStack alignItems="center" space="$2">
<Lock size={16} color={text} />
<Text fontSize="$md" fontWeight="800" color={text}>
{t('profile.sections.password.heading', 'Passwort ändern')}
</Text>
</XStack>
<Text fontSize="$sm" color={muted}>
{t('profile.sections.password.description', 'Wähle ein sicheres Passwort, um deinen Admin-Zugang zu schützen.')}
</Text>
<YStack space="$3">
<MobileField label={t('profile.fields.currentPassword', 'Aktuelles Passwort')}>
<MobileInput
value={form.currentPassword}
onChange={(event) => setForm((prev) => ({ ...prev, currentPassword: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.newPassword', 'Neues Passwort')} hint={t('profile.sections.password.hint', 'Nutze mindestens 8 Zeichen und kombiniere Buchstaben sowie Zahlen für mehr Sicherheit.')}>
<MobileInput
value={form.password}
onChange={(event) => setForm((prev) => ({ ...prev, password: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<MobileField label={t('profile.fields.passwordConfirmation', 'Passwort bestätigen')}>
<MobileInput
value={form.passwordConfirmation}
onChange={(event) => setForm((prev) => ({ ...prev, passwordConfirmation: event.target.value }))}
placeholder="••••••••"
type="password"
hasError={false}
/>
</MobileField>
<CTAButton
label={t('profile.actions.updatePassword', 'Passwort aktualisieren')}
onPress={handlePasswordSave}
disabled={!passwordReady || savingPassword || loading}
loading={savingPassword}
tone="ghost"
/>
</YStack>
</MobileCard>
</MobileShell>
);
}

View File

@@ -12,7 +12,7 @@ import { MobileCard, CTAButton } from './components/Primitives';
import { MobileSelect } from './components/FormControls';
import { useAuth } from '../auth/context';
import { fetchTenantProfile } from '../api';
import { adminPath, ADMIN_DATA_EXPORTS_PATH } from '../constants';
import { adminPath, ADMIN_DATA_EXPORTS_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants';
import i18n from '../i18n';
import { useAppearance } from '@/hooks/use-appearance';
import { useBackNavigation } from './hooks/useBackNavigation';
@@ -85,7 +85,7 @@ export default function MobileProfilePage() {
<YStack space="$4">
<YGroup {...({ borderRadius: "$4", borderWidth: 1, borderColor: borderColor, overflow: "hidden" } as any)}>
<YGroup.Item>
<Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
<Pressable onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)}>
<ListItem
hoverTheme
pressTheme
@@ -93,7 +93,7 @@ export default function MobileProfilePage() {
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.account', 'Account & security')}
{t('mobileProfile.account', 'Account bearbeiten')}
</Text>
}
iconAfter={<Settings size={18} color={subtle} />}

View File

@@ -16,7 +16,7 @@ import {
NotificationPreferences,
} from '../api';
import { getApiErrorMessage } from '../lib/apiError';
import { adminPath, ADMIN_HOME_PATH } from '../constants';
import { adminPath, ADMIN_HOME_PATH, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants';
import { useAdminPushSubscription } from './hooks/useAdminPushSubscription';
import { useDevicePermissions } from './hooks/useDevicePermissions';
import { type PermissionStatus, type StorageStatus } from './lib/devicePermissions';
@@ -224,7 +224,7 @@ export default function MobileSettingsPage() {
<PillBadge tone="muted">{t('mobileSettings.tenantBadge', 'Tenant #{{id}}', { id: user.tenant_id })}</PillBadge>
) : null}
<XStack space="$2">
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(adminPath('/mobile/profile'))} />
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(ADMIN_PROFILE_ACCOUNT_PATH)} />
<CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} />
</XStack>
</MobileCard>

View File

@@ -0,0 +1,141 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
const backMock = vi.fn();
vi.mock('../hooks/useBackNavigation', () => ({
useBackNavigation: () => backMock,
}));
vi.mock('../../api', () => ({
fetchTenantProfile: vi.fn(),
updateTenantProfile: vi.fn(),
}));
vi.mock('react-hot-toast', () => ({
default: {
error: vi.fn(),
success: vi.fn(),
},
}));
vi.mock('../components/MobileShell', () => ({
MobileShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('../components/Primitives', () => ({
MobileCard: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
PillBadge: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
CTAButton: ({
label,
onPress,
disabled,
}: {
label: string;
onPress?: () => void;
disabled?: boolean;
}) => (
<button type="button" onClick={disabled ? undefined : onPress} disabled={disabled}>
{label}
</button>
),
}));
vi.mock('../components/FormControls', () => ({
MobileField: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
MobileInput: ({ hasError, compact, ...props }: React.InputHTMLAttributes<HTMLInputElement> & { hasError?: boolean; compact?: boolean }) => (
<input {...props} />
),
MobileSelect: ({ children, ...props }: { children: React.ReactNode }) => <select {...props}>{children}</select>,
}));
vi.mock('@tamagui/stacks', () => ({
YStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
XStack: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock('@tamagui/text', () => ({
SizableText: ({ children }: { children: React.ReactNode }) => <span>{children}</span>,
}));
vi.mock('../theme', () => ({
useAdminTheme: () => ({
text: '#111827',
muted: '#6b7280',
subtle: '#94a3b8',
danger: '#b91c1c',
border: '#e5e7eb',
surface: '#ffffff',
primary: '#ff5a5f',
accentSoft: '#fde7ea',
}),
}));
import { fetchTenantProfile, updateTenantProfile } from '../../api';
import MobileProfileAccountPage from '../ProfileAccountPage';
const profileFixture = {
id: 1,
name: 'Test Admin',
email: 'admin@example.com',
preferred_locale: null,
email_verified: true,
email_verified_at: '2024-01-02T00:00:00.000Z',
};
describe('MobileProfileAccountPage', () => {
it('submits account updates with name, email, and locale', async () => {
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);
await act(async () => {
render(<MobileProfileAccountPage />);
});
await screen.findByDisplayValue('Test Admin');
await act(async () => {
fireEvent.click(screen.getByText('profile.actions.save'));
});
expect(updateTenantProfile).toHaveBeenCalledWith({
name: 'Test Admin',
email: 'admin@example.com',
preferred_locale: null,
});
});
it('submits password updates when all password fields are provided', async () => {
vi.mocked(fetchTenantProfile).mockResolvedValue(profileFixture);
vi.mocked(updateTenantProfile).mockResolvedValue(profileFixture);
await act(async () => {
render(<MobileProfileAccountPage />);
});
await screen.findByDisplayValue('Test Admin');
const passwordInputs = screen.getAllByPlaceholderText('••••••••');
await act(async () => {
fireEvent.change(passwordInputs[0], { target: { value: 'old-pass' } });
fireEvent.change(passwordInputs[1], { target: { value: 'new-pass-123' } });
fireEvent.change(passwordInputs[2], { target: { value: 'new-pass-123' } });
});
await waitFor(() => {
expect(screen.getByText('profile.actions.updatePassword')).not.toBeDisabled();
});
await act(async () => {
fireEvent.click(screen.getByText('profile.actions.updatePassword'));
});
expect(updateTenantProfile).toHaveBeenCalledWith({
name: 'Test Admin',
email: 'admin@example.com',
preferred_locale: null,
current_password: 'old-pass',
password: 'new-pass-123',
password_confirmation: 'new-pass-123',
});
});
});

View File

@@ -35,6 +35,7 @@ const MobileEventRecapPage = React.lazy(() => import('./mobile/EventRecapPage'))
const MobileEventAnalyticsPage = React.lazy(() => import('./mobile/EventAnalyticsPage'));
const MobileNotificationsPage = React.lazy(() => import('./mobile/NotificationsPage'));
const MobileProfilePage = React.lazy(() => import('./mobile/ProfilePage'));
const MobileProfileAccountPage = React.lazy(() => import('./mobile/ProfileAccountPage'));
const MobileBillingPage = React.lazy(() => import('./mobile/BillingPage'));
const MobilePackageShopPage = React.lazy(() => import('./mobile/PackageShopPage'));
const MobileSettingsPage = React.lazy(() => import('./mobile/SettingsPage'));
@@ -212,6 +213,7 @@ export const router = createBrowserRouter([
{ path: 'mobile/notifications', element: <MobileNotificationsPage /> },
{ path: 'mobile/notifications/:notificationId', element: <MobileNotificationsPage /> },
{ path: 'mobile/profile', element: <RequireAdminAccess><MobileProfilePage /></RequireAdminAccess> },
{ path: 'mobile/profile/account', element: <RequireAdminAccess><MobileProfileAccountPage /></RequireAdminAccess> },
{ path: 'mobile/billing', element: <RequireAdminAccess><MobileBillingPage /></RequireAdminAccess> },
{ path: 'mobile/billing/shop', element: <RequireAdminAccess><MobilePackageShopPage /></RequireAdminAccess> },
{ path: 'mobile/settings', element: <RequireAdminAccess><MobileSettingsPage /></RequireAdminAccess> },

View File

@@ -37,27 +37,36 @@ export default function FiltersBar({
return (
<div
className={cn(
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
style={styleOverride}
>
{filters.map((filter) => (
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter, index) => {
const isActive = value === filter.value;
return (
<div key={filter.value} className="flex items-center">
<button
key={filter.value}
type="button"
onClick={() => onChange(filter.value)}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-4 py-2 transition',
value === filter.value
? 'border-pink-500 bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow'
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200',
'inline-flex items-center gap-1 rounded-full px-3 py-1.5 transition',
isActive
? 'bg-pink-500 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
)}
>
{filter.icon}
{t(filter.labelKey)}
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })}
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
</button>
))}
{index < filters.length - 1 && (
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
)}
</div>
);
})}
</div>
</div>
);
}

View File

@@ -111,33 +111,33 @@ export default function GalleryPreview({ token }: Props) {
</Link>
</div>
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
{filters.map((filter) => {
<div className="flex overflow-x-auto pb-1 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none]">
<div className="inline-flex items-center rounded-full border border-border/70 bg-white/80 p-1 shadow-sm backdrop-blur dark:border-white/10 dark:bg-slate-950/70">
{filters.map((filter, index) => {
const isActive = mode === filter.value;
return (
<div key={filter.value} className="flex items-center">
<button
key={filter.value}
type="button"
onClick={() => setMode(filter.value)}
style={{
borderRadius: radius,
border: isActive ? `1px solid ${branding.primaryColor}` : `1px solid ${branding.primaryColor}22`,
background: isActive ? branding.primaryColor : undefined,
boxShadow: isActive ? `0 8px 18px ${branding.primaryColor}33` : 'none',
}}
className={cn(
'px-4 py-1 transition',
'inline-flex items-center rounded-full px-3 py-1.5 transition',
isActive
? 'text-white'
: 'bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70 dark:text-slate-100',
? 'bg-pink-500 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
)}
>
{filter.label}
<span className="whitespace-nowrap">{filter.label}</span>
</button>
{index < filters.length - 1 && (
<span className="mx-1 h-4 w-px bg-border/60 dark:bg-white/10" aria-hidden />
)}
</div>
);
})}
</div>
</div>
{loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && (
@@ -147,37 +147,29 @@ export default function GalleryPreview({ token }: Props) {
</div>
)}
<div className="grid gap-2 grid-cols-2 md:grid-cols-3">
<div className="grid gap-3 grid-cols-2 md:grid-cols-3">
{items.map((p: PreviewPhoto) => (
<Link
key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
className="group relative block overflow-hidden bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70"
style={{
borderRadius: radius,
border: `1px solid ${branding.primaryColor}22`,
boxShadow: `0 12px 26px ${branding.primaryColor}22`,
}}
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
>
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="h-40 w-full object-cover transition duration-300 group-hover:scale-105"
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy"
/>
<div
className="absolute inset-0"
style={{
background: `linear-gradient(180deg, transparent 50%, ${branding.primaryColor}33 100%)`,
}}
aria-hidden
/>
<div className="absolute bottom-0 left-0 right-0 space-y-1 p-3">
<p className="text-sm font-semibold leading-tight line-clamp-2" style={headingFont ? { fontFamily: headingFont } : undefined}>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-black/50 via-black/0 to-transparent" aria-hidden />
</div>
<div className="space-y-2 px-3 pb-3 pt-3">
<p className="text-sm font-semibold leading-tight line-clamp-2 text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>
{p.title || getPhotoTitle(p)}
</p>
<div className="flex items-center gap-1 text-xs text-foreground/80">
<Heart className="h-4 w-4" style={{ color: branding.primaryColor }} aria-hidden />
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Heart className="h-3.5 w-3.5 text-pink-500" aria-hidden />
{p.likes_count ?? 0}
</div>
</div>

View File

@@ -188,13 +188,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const headerFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const basePath = eventToken ? `/e/${encodeURIComponent(eventToken)}` : '';
const showGalleryHelp = Boolean(
basePath
&& (location.pathname.startsWith(`${basePath}/gallery`) || location.pathname.startsWith(`${basePath}/photo`))
);
const galleryHelpHref = basePath ? `${basePath}/help/gallery-and-sharing` : '/help/gallery-and-sharing';
const headerStyle: React.CSSProperties = {
background: `linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
color: headerTextColor,
@@ -259,15 +252,6 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
t={t}
/>
)}
{showGalleryHelp && (
<Link
to={galleryHelpHref}
className="rounded-full bg-white/15 p-2 text-white transition hover:bg-white/30"
aria-label={t('header.helpGallery')}
>
<LifeBuoy className="h-5 w-5" aria-hidden />
</Link>
)}
<AppearanceToggleDropdown />
<SettingsSheet />
</div>

View File

@@ -72,17 +72,17 @@ export default function RouteTransition({ children }: { children?: React.ReactNo
};
const tabVariants = {
enter: { opacity: 0, scale: 0.985 },
center: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.985 },
enter: { opacity: 0, y: 8 },
center: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
};
const transition = kind === 'tab'
? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
return (
<AnimatePresence initial={false}>
<AnimatePresence initial={false} mode="wait">
<motion.div
key={location.pathname}
custom={{ direction }}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Link, useParams } from 'react-router-dom';
import { Link, useLocation, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -23,6 +23,7 @@ import { useTranslation } from '../i18n/useTranslation';
import type { LocaleCode } from '../i18n/messages';
import { useHapticsPreference } from '../hooks/useHapticsPreference';
import { triggerHaptic } from '../lib/haptics';
import { getHelpSlugForPathname } from '../lib/helpRouting';
const legalPages = [
{ slug: 'impressum', translationKey: 'settings.legal.section.impressum' },
@@ -53,12 +54,15 @@ export function SettingsSheet() {
const localeContext = useLocale();
const { t } = useTranslation();
const params = useParams<{ token?: string }>();
const location = useLocation();
const [nameDraft, setNameDraft] = React.useState(identity?.name ?? '');
const [nameStatus, setNameStatus] = React.useState<NameStatus>('idle');
const [savingName, setSavingName] = React.useState(false);
const isLegal = view.mode === 'legal';
const legalDocument = useLegalDocument(isLegal ? view.slug : null, localeContext.locale);
const helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpSlug = getHelpSlugForPathname(location.pathname);
const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase;
React.useEffect(() => {
if (open && identity?.hydrated) {

View File

@@ -746,6 +746,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
back: 'Zurück zur Übersicht',
updated: 'Aktualisiert am {date}',
relatedTitle: 'Verwandte Artikel',
loadingTitle: 'Artikel wird geladen',
loadingDescription: 'Wir holen die neuesten Infos für dich.',
unavailable: 'Dieser Artikel ist nicht verfügbar.',
reload: 'Neu laden',
},
@@ -1481,6 +1483,8 @@ export const messages: Record<LocaleCode, NestedMessages> = {
back: 'Back to overview',
updated: 'Updated on {date}',
relatedTitle: 'Related articles',
loadingTitle: 'Loading article',
loadingDescription: 'Fetching the latest details for you.',
unavailable: 'This article is unavailable.',
reload: 'Reload',
},

View File

@@ -0,0 +1,13 @@
import { shouldShowPhotoboothFilter } from '../galleryFilters';
describe('shouldShowPhotoboothFilter', () => {
it('returns true when photobooth is enabled', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: true } as any)).toBe(true);
});
it('returns false when photobooth is disabled or missing', () => {
expect(shouldShowPhotoboothFilter({ photobooth_enabled: false } as any)).toBe(false);
expect(shouldShowPhotoboothFilter(null)).toBe(false);
expect(shouldShowPhotoboothFilter(undefined)).toBe(false);
});
});

View File

@@ -0,0 +1,32 @@
import { describe, expect, it } from 'vitest';
import { getHelpSlugForPathname } from '../helpRouting';
describe('getHelpSlugForPathname', () => {
it('returns a getting-started slug for home paths', () => {
expect(getHelpSlugForPathname('/')).toBe('getting-started');
expect(getHelpSlugForPathname('/e/demo')).toBe('getting-started');
});
it('returns null for help pages', () => {
expect(getHelpSlugForPathname('/help')).toBeNull();
expect(getHelpSlugForPathname('/help/gallery-and-sharing')).toBeNull();
expect(getHelpSlugForPathname('/e/demo/help/gallery-and-sharing')).toBeNull();
});
it('maps gallery related pages', () => {
expect(getHelpSlugForPathname('/e/demo/gallery')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/photo/123')).toBe('gallery-and-sharing');
expect(getHelpSlugForPathname('/e/demo/slideshow')).toBe('gallery-and-sharing');
});
it('maps upload related pages', () => {
expect(getHelpSlugForPathname('/e/demo/upload')).toBe('uploading-photos');
expect(getHelpSlugForPathname('/e/demo/queue')).toBe('upload-troubleshooting');
});
it('maps tasks and achievements', () => {
expect(getHelpSlugForPathname('/e/demo/tasks')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/tasks/12')).toBe('tasks-and-missions');
expect(getHelpSlugForPathname('/e/demo/achievements')).toBe('achievements-and-badges');
});
});

View File

@@ -1,41 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../motion';
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
describe('motion helpers', () => {
it('returns disabled props when motion is off', () => {
const props = getMotionContainerProps(false, STAGGER_FAST);
expect(props.initial).toBe(false);
});
it('returns variants when motion is on', () => {
const containerProps = getMotionContainerProps(true, STAGGER_FAST);
const itemProps = getMotionItemProps(true, FADE_UP);
expect(containerProps.initial).toBe('hidden');
expect(containerProps.animate).toBe('show');
expect(itemProps.variants).toBe(FADE_UP);
});
it('detects reduced motion preference safely', () => {
const original = window.matchMedia;
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: undefined,
});
expect(prefersReducedMotion()).toBe(false);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: vi.fn().mockReturnValue({ matches: true }),
});
expect(prefersReducedMotion()).toBe(true);
Object.defineProperty(window, 'matchMedia', {
configurable: true,
value: original,
describe('getMotionContainerPropsForNavigation', () => {
it('returns initial hidden for POP navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
variants: STAGGER_FAST,
initial: 'hidden',
animate: 'show',
});
});
it('exposes distinct base variants', () => {
expect(FADE_UP).not.toBe(FADE_SCALE);
it('skips initial animation for PUSH navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'PUSH')).toEqual({
variants: STAGGER_FAST,
initial: false,
animate: 'show',
});
});
it('disables motion when not enabled', () => {
expect(getMotionContainerPropsForNavigation(false, STAGGER_FAST, 'POP')).toEqual({
initial: false,
});
});
});
describe('getMotionItemPropsForNavigation', () => {
it('returns animate props for POP navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'POP')).toEqual({
variants: FADE_UP,
initial: 'hidden',
animate: 'show',
});
});
it('skips initial animation for PUSH navigation', () => {
expect(getMotionItemPropsForNavigation(true, FADE_UP, 'PUSH')).toEqual({
variants: FADE_UP,
initial: false,
animate: 'show',
});
});
it('returns empty props when motion disabled', () => {
expect(getMotionItemPropsForNavigation(false, FADE_UP, 'POP')).toEqual({});
});
});

View File

@@ -0,0 +1,22 @@
import { dedupeTasksById } from '../taskUtils';
describe('dedupeTasksById', () => {
it('returns empty array for empty input', () => {
expect(dedupeTasksById([])).toEqual([]);
});
it('keeps the first occurrence and preserves order', () => {
const tasks = [
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 1, title: 'A-dup' },
{ id: 3, title: 'C' },
];
expect(dedupeTasksById(tasks)).toEqual([
{ id: 1, title: 'A' },
{ id: 2, title: 'B' },
{ id: 3, title: 'C' },
]);
});
});

View File

@@ -0,0 +1,5 @@
import type { EventData } from '../services/eventApi';
export function shouldShowPhotoboothFilter(event?: EventData | null): boolean {
return Boolean(event?.photobooth_enabled);
}

View File

@@ -0,0 +1,44 @@
export function getHelpSlugForPathname(pathname: string): string | null {
if (!pathname) {
return null;
}
const normalized = pathname
.replace(/^\/e\/[^/]+/, '')
.replace(/\/+$/g, '')
.toLowerCase();
if (!normalized || normalized === '/') {
return 'getting-started';
}
if (normalized.startsWith('/help')) {
return null;
}
if (normalized.startsWith('/gallery') || normalized.startsWith('/photo') || normalized.startsWith('/slideshow')) {
return 'gallery-and-sharing';
}
if (normalized.startsWith('/upload')) {
return 'uploading-photos';
}
if (normalized.startsWith('/queue')) {
return 'upload-troubleshooting';
}
if (normalized.startsWith('/tasks')) {
return 'tasks-and-missions';
}
if (normalized.startsWith('/achievements')) {
return 'achievements-and-badges';
}
if (normalized.startsWith('/settings')) {
return 'settings-and-cache';
}
return 'how-fotospiel-works';
}

View File

@@ -56,3 +56,31 @@ export function getMotionContainerProps(enabled: boolean, variants: Variants) {
export function getMotionItemProps(enabled: boolean, variants: Variants) {
return enabled ? { variants } : {};
}
export function getMotionContainerPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return { initial: false } as const;
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}
export function getMotionItemPropsForNavigation(
enabled: boolean,
variants: Variants,
navigationType: 'POP' | 'PUSH' | 'REPLACE'
) {
if (!enabled) {
return {};
}
const initial = navigationType === 'POP' ? 'hidden' : false;
return { variants, initial, animate: 'show' } as const;
}

View File

@@ -0,0 +1,18 @@
export type TaskIdentity = {
id: number;
};
export function dedupeTasksById<T extends TaskIdentity>(tasks: T[]): T[] {
const seen = new Set<number>();
const unique: T[] = [];
tasks.forEach((task) => {
if (seen.has(task.id)) {
return;
}
seen.add(task.id);
unique.push(task);
});
return unique;
}

View File

@@ -1,5 +1,5 @@
import React, { useEffect, useMemo, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { Link, useNavigationType, useParams } from 'react-router-dom';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { cn } from '@/lib/utils';
import { Button } from '@/components/ui/button';
@@ -24,7 +24,7 @@ import type { LocaleCode } from '../i18n/messages';
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { useEventData } from '../hooks/useEventData';
import { isTaskModeEnabled } from '../lib/engagement';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
const GENERIC_ERROR = 'GENERIC_ERROR';
@@ -343,6 +343,7 @@ function PersonalActions({ token, t, tasksEnabled }: PersonalActionsProps) {
export default function AchievementsPage() {
const { token } = useParams<{ token: string }>();
const navigationType = useNavigationType();
const identity = useGuestIdentity();
const { t, locale } = useTranslation();
const { event } = useEventData();
@@ -393,7 +394,7 @@ export default function AchievementsPage() {
const hasPersonal = Boolean(data?.personal);
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const tabMotion = motionEnabled

View File

@@ -1,7 +1,7 @@
// @ts-nocheck
import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useParams, useSearchParams } from 'react-router-dom';
import { useNavigationType, useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
@@ -12,11 +12,19 @@ import { fetchEvent, type EventData } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost';
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
import { shouldShowPhotoboothFilter } from '../lib/galleryFilters';
import { createPhotoShareLink } from '../services/photosApi';
import { cn } from '@/lib/utils';
import { useEventBranding } from '../context/EventBrandingContext';
import ShareSheet from '../components/ShareSheet';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import {
FADE_SCALE,
FADE_UP,
STAGGER_FAST,
getMotionContainerPropsForNavigation,
getMotionItemPropsForNavigation,
prefersReducedMotion,
} from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics';
@@ -56,6 +64,7 @@ const normalizeImageUrl = (src?: string | null) => {
export default function GalleryPage() {
const { token } = useParams<{ token?: string }>();
const navigationType = useNavigationType();
const { t, locale } = useTranslation();
const { branding } = useEventBranding();
const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale);
@@ -68,10 +77,10 @@ export default function GalleryPage() {
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const gridMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const fadeUpMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_UP, navigationType);
const fadeScaleMotion = getMotionItemPropsForNavigation(motionEnabled, FADE_SCALE, navigationType);
const gridMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -88,10 +97,7 @@ export default function GalleryPage() {
});
const typedPhotos = photos as GalleryPhoto[];
const showPhotoboothFilter = React.useMemo(
() => Boolean(event?.photobooth_enabled) || typedPhotos.some((p) => p.ingest_source === 'photobooth'),
[event?.photobooth_enabled, typedPhotos],
);
const showPhotoboothFilter = React.useMemo(() => shouldShowPhotoboothFilter(event), [event]);
const allowedGalleryFilters = React.useMemo<GalleryFilter[]>(
() => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']),
[showPhotoboothFilter],
@@ -301,54 +307,62 @@ export default function GalleryPage() {
return (
<Page title="">
<div className="relative">
<PullToRefresh
onRefresh={handleRefresh}
pullLabel={t('common.pullToRefresh')}
releaseLabel={t('common.releaseToRefresh')}
refreshingLabel={t('common.refreshing')}
>
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.div className="flex items-center gap-3" {...fadeUpMotion}>
<motion.div className="space-y-6 pb-24" {...containerMotion}>
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
<ImageIcon className="h-5 w-5" aria-hidden />
</div>
<div>
<div className="flex flex-wrap items-center gap-2">
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div>
{newCount > 0 ? (
<button
type="button"
onClick={acknowledgeNew}
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{newPhotosBadgeText}
</button>
) : (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
{newPhotosBadgeText}
</span>
)}
</motion.div>
</motion.div>
</div>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div>
</div>
{newCount > 0 && (
<button
type="button"
onClick={acknowledgeNew}
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold text-pink-600 transition hover:bg-pink-50"
style={{ borderRadius: radius }}
>
{t('galleryPage.badge.markSeen', 'Gesehen')}
</button>
)}
</div>
<motion.div {...fadeUpMotion}>
<FiltersBar
value={filter}
onChange={setFilter}
className="mt-2"
className="mt-0"
showPhotobooth={showPhotoboothFilter}
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/>
</motion.div>
{loading && (
<motion.p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
<motion.p className="px-1" {...fadeUpMotion}>
{t('galleryPage.loading', 'Lade…')}
</motion.p>
)}
<motion.div className="grid grid-cols-2 gap-2 px-2 pb-16 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
{list.map((p: GalleryPhoto) => {
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
const createdLabel = p.created_at
@@ -377,10 +391,11 @@ export default function GalleryPage() {
openPhoto();
}
}}
className="group relative overflow-hidden border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
className="group flex flex-col overflow-hidden border border-border/60 bg-white shadow-sm ring-1 ring-black/5 transition duration-300 hover:-translate-y-0.5 hover:shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400 dark:border-white/10 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
{...fadeScaleMotion}
>
<div className="relative">
<img
src={imageUrl}
alt={altText}
@@ -390,36 +405,22 @@ export default function GalleryPage() {
}}
loading="lazy"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/85 via-black/20 to-transparent" aria-hidden />
<div className="absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{localizedTaskTitle && <p className="text-sm font-medium leading-tight line-clamp-2 text-white" style={headingFont ? { fontFamily: headingFont } : undefined}>{localizedTaskTitle}</p>}
<div className="flex items-center justify-between text-xs text-white/90" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
<span className="truncate">{createdLabel}</span>
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/0 to-transparent" aria-hidden />
</div>
</div>
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShare(p);
}}
className={cn(
'flex h-9 w-9 items-center justify-center border text-white transition backdrop-blur',
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/10'
)}
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
disabled={shareTargetId === p.id}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
<div className="space-y-2 px-3 pb-3 pt-3" style={bodyFont ? { fontFamily: bodyFont } : undefined}>
{localizedTaskTitle && (
<p
className="text-sm font-semibold leading-tight line-clamp-2 text-foreground"
style={headingFont ? { fontFamily: headingFont } : undefined}
>
<Share2 className="h-4 w-4" aria-hidden />
</button>
{localizedTaskTitle}
</p>
)}
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<span className="truncate">{createdLabel}</span>
<span className="truncate">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div>
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={(e) => {
@@ -427,20 +428,33 @@ export default function GalleryPage() {
onLike(p.id);
}}
className={cn(
'flex items-center gap-1 px-3 py-1 text-sm font-medium transition backdrop-blur',
liked.has(p.id) ? 'text-pink-300' : 'text-white'
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
liked.has(p.id) ? 'border-pink-200 bg-pink-50 text-pink-600' : 'hover:bg-muted/40'
)}
aria-label={t('galleryPage.photo.likeAria', 'Foto liken')}
style={{
borderRadius: radius,
background: buttonStyle === 'outline' ? 'transparent' : '#00000066',
border: buttonStyle === 'outline' ? `1px solid ${linkColor}` : '1px solid rgba(255,255,255,0.4)',
color: buttonStyle === 'outline' ? linkColor : undefined,
}}
style={{ borderRadius: radius }}
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
<Heart className={`h-3.5 w-3.5 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShare(p);
}}
className={cn(
'inline-flex items-center gap-1 rounded-full border border-border/60 px-3 py-1 text-xs font-semibold text-foreground transition',
shareTargetId === p.id ? 'opacity-60' : 'hover:bg-muted/40'
)}
aria-label={t('galleryPage.photo.shareAria', 'Foto teilen')}
disabled={shareTargetId === p.id}
style={{ borderRadius: radius }}
>
<Share2 className="h-3.5 w-3.5" aria-hidden />
{t('galleryPage.photo.shareLabel', 'Teilen')}
</button>
</div>
</div>
</motion.div>
);
@@ -448,7 +462,7 @@ export default function GalleryPage() {
{list.length === 0 && Array.from({ length: 6 }).map((_, idx) => (
<motion.div
key={`placeholder-${idx}`}
className="relative overflow-hidden border border-muted/40 bg-[var(--guest-surface,#f7f7f7)] shadow-sm"
className="relative overflow-hidden border border-muted/40 bg-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10"
style={{ borderRadius: radius }}
{...fadeScaleMotion}
>
@@ -461,7 +475,9 @@ export default function GalleryPage() {
</motion.div>
))}
</motion.div>
</motion.div>
</PullToRefresh>
</div>
{currentPhotoIndex !== null && list.length > 0 && (
<PhotoLightbox
photos={list}

View File

@@ -1,7 +1,7 @@
import React from 'react';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { ArrowLeft, Loader2 } from 'lucide-react';
import { Page } from './_util';
import { useLocale } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation';
@@ -37,7 +37,9 @@ export default function HelpArticlePage() {
loadArticle();
}, [loadArticle]);
const title = article?.title ?? t('help.article.unavailable');
const title = state === 'loading'
? t('help.article.loadingTitle')
: (article?.title ?? t('help.article.unavailable'));
return (
<Page title={title}>
@@ -48,17 +50,30 @@ export default function HelpArticlePage() {
refreshingLabel={t('common.refreshing')}
>
<div className="mb-4">
<Button variant="ghost" size="sm" asChild>
<Button variant="outline" size="sm" className="rounded-full border-border/60 bg-background/70 px-3" asChild>
<Link to={basePath}>
<span className="inline-flex items-center gap-2">
<ArrowLeft className="h-4 w-4" aria-hidden />
{t('help.article.back')}
</span>
</Link>
</Button>
</div>
{state === 'loading' && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<div className="rounded-2xl border border-border/60 bg-card/70 p-5">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
{t('common.actions.loading')}
<div>
<div className="font-medium text-foreground">{t('help.article.loadingTitle')}</div>
<div className="text-xs text-muted-foreground">{t('help.article.loadingDescription')}</div>
</div>
</div>
<div className="mt-4 space-y-2 animate-pulse">
<div className="h-3 w-2/3 rounded-full bg-muted/60" />
<div className="h-3 w-5/6 rounded-full bg-muted/60" />
<div className="h-3 w-1/2 rounded-full bg-muted/60" />
</div>
</div>
)}
@@ -77,11 +92,6 @@ export default function HelpArticlePage() {
{article.updated_at && (
<div>{t('help.article.updated', { date: formatDate(article.updated_at, locale) })}</div>
)}
<Button variant="ghost" size="sm" asChild>
<Link to={basePath}>
{t('help.article.back')}
</Link>
</Button>
</div>
<div className="overflow-x-auto">
<div

View File

@@ -18,6 +18,7 @@ export default function HelpCenterPage() {
const [query, setQuery] = React.useState('');
const [state, setState] = React.useState<'idle' | 'loading' | 'ready' | 'error'>('loading');
const [servedFromCache, setServedFromCache] = React.useState(false);
const [isOnline, setIsOnline] = React.useState(() => (typeof navigator !== 'undefined' ? navigator.onLine : true));
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const loadArticles = React.useCallback(async (forceRefresh = false) => {
@@ -37,6 +38,24 @@ export default function HelpCenterPage() {
loadArticles();
}, [loadArticles]);
React.useEffect(() => {
if (typeof window === 'undefined') {
return;
}
const handleOnline = () => setIsOnline(true);
const handleOffline = () => setIsOnline(false);
window.addEventListener('online', handleOnline);
window.addEventListener('offline', handleOffline);
return () => {
window.removeEventListener('online', handleOnline);
window.removeEventListener('offline', handleOffline);
};
}, []);
const showOfflineBadge = servedFromCache && !isOnline;
const filteredArticles = React.useMemo(() => {
if (!query.trim()) {
return articles;
@@ -85,7 +104,7 @@ export default function HelpCenterPage() {
)}
</Button>
</div>
{servedFromCache && (
{showOfflineBadge && (
<div className="flex items-center gap-2 rounded-lg bg-amber-50/70 px-3 py-2 text-xs text-amber-900 dark:bg-amber-400/10 dark:text-amber-200">
<Badge variant="secondary" className="bg-amber-200/80 text-amber-900 dark:bg-amber-500/40 dark:text-amber-100">
{t('help.center.offlineBadge')}

View File

@@ -1,5 +1,5 @@
import React from 'react';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { useNavigate, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Sparkles, RefreshCw, Smile, Camera, Timer as TimerIcon, Heart, ChevronRight, CheckCircle2 } from 'lucide-react';
@@ -18,9 +18,10 @@ import {
type EmotionTheme,
} from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics';
import { dedupeTasksById } from '../lib/taskUtils';
interface Task {
id: number;
@@ -55,6 +56,7 @@ export default function TaskPickerPage() {
const { token } = useParams<{ token: string }>();
const eventKey = token ?? '';
const navigate = useNavigate();
const navigationType = useNavigationType();
const [searchParams, setSearchParams] = useSearchParams();
const { branding } = useEventBranding();
const { t, locale } = useTranslation();
@@ -133,9 +135,10 @@ export default function TaskPickerPage() {
? payload.tasks
: [];
const entry = { data: taskList, etag: response.headers.get('ETag') };
const uniqueTasks = dedupeTasksById(taskList);
const entry = { data: uniqueTasks, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry);
setTasks(taskList);
setTasks(uniqueTasks);
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
@@ -369,7 +372,7 @@ export default function TaskPickerPage() {
);
const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent';
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { describe, expect, it, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import { MemoryRouter, Route, Routes } from 'react-router-dom';
import HelpArticlePage from '../HelpArticlePage';
import type { HelpArticleDetail } from '../../services/helpApi';
vi.mock('../../i18n/LocaleContext', () => ({
useLocale: () => ({ locale: 'de' }),
}));
vi.mock('../../i18n/useTranslation', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
vi.mock('../../services/helpApi', () => ({
getHelpArticle: vi.fn(),
}));
const { getHelpArticle } = await import('../../services/helpApi');
describe('HelpArticlePage', () => {
it('renders a single back button after loading', async () => {
const article: HelpArticleDetail = {
slug: 'gallery-and-sharing',
title: 'Galerie & Teilen',
summary: 'Kurzfassung',
body_html: '<p>Inhalt</p>',
};
(getHelpArticle as ReturnType<typeof vi.fn>).mockResolvedValue({ article, servedFromCache: false });
render(
<MemoryRouter initialEntries={['/e/demo/help/gallery-and-sharing']}>
<Routes>
<Route path="/e/:token/help/:slug" element={<HelpArticlePage />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.getByText('Galerie & Teilen')).toBeInTheDocument();
});
expect(screen.getAllByText('help.article.back')).toHaveLength(1);
});
});

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { createBrowserRouter, useParams, Link, Navigate } from 'react-router-dom';
import { createBrowserRouter, useLocation, useParams, Link, Navigate } from 'react-router-dom';
import Header from './components/Header';
import BottomNav from './components/BottomNav';
import RouteTransition from './components/RouteTransition';
@@ -101,6 +101,7 @@ export const router = createBrowserRouter([
function EventBoundary({ token }: { token: string }) {
const identity = useOptionalGuestIdentity();
const { event, status, error, errorCode } = useEventData();
const location = useLocation();
if (status === 'loading') {
return <EventLoadingView />;
@@ -118,6 +119,9 @@ function EventBoundary({ token }: { token: string }) {
const localeStorageKey = `guestLocale_event_${event.id ?? token}`;
const branding = mapEventBranding(event.branding ?? (event as any)?.settings?.branding ?? null);
const isGalleryRoute = /^\/e\/[^/]+\/gallery(?:\/|$)/.test(location.pathname);
const contentPaddingClass = isGalleryRoute ? 'px-0 py-0' : 'px-4 py-3';
return (
<LocaleProvider defaultLocale={eventLocale} storageKey={localeStorageKey}>
<EventBrandingProvider branding={branding}>
@@ -125,7 +129,7 @@ function EventBoundary({ token }: { token: string }) {
<NotificationCenterProvider eventToken={token}>
<div className="pb-16">
<Header eventToken={token} />
<div className="px-4 py-3">
<div className={contentPaddingClass}>
<RouteTransition />
</div>
<BottomNav />

View File

@@ -128,6 +128,25 @@ return [
'benefit4' => 'Support durch das Die Fotospiel.App Team',
'footer' => 'Wir helfen Ihnen gern weiter, falls Fragen offen sind.',
],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App für :event',
'preheader' => 'Download-Links für die Fotospiel Photobooth Uploader App.',
'hero_title' => 'Hallo :name,',
'hero_subtitle' => 'Ihre Uploader App für :event ist bereit.',
'body' => 'Hier finden Sie die Download-Links für die Fotospiel Photobooth Uploader App. Installieren Sie die passende Version auf dem Photobooth-PC, bevor Ihr Event startet.',
'downloads_title' => 'Download-Links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download für Windows',
'cta_macos' => 'Download für macOS',
'cta_linux' => 'Download für Linux',
'credentials_hint' => 'Die Zugangsdaten bleiben im Admin-Dashboard. Erstellen Sie einen Verbindungscode, sobald Sie die App koppeln möchten.',
'footer' => 'Wenn Sie Fragen haben, antworten Sie einfach auf diese E-Mail.',
'event_fallback' => 'Ihr Event',
],
'contact' => [
'subject' => 'Neue Kontakt-Anfrage',

View File

@@ -127,6 +127,25 @@ return [
'benefit4' => 'Support from the Die Fotospiel.App team',
'footer' => 'Let us know if you need anything.',
],
'photobooth_uploader' => [
'subject' => 'Fotospiel Uploader App for :event',
'preheader' => 'Download links for the Fotospiel Photobooth Uploader.',
'hero_title' => 'Hi :name,',
'hero_subtitle' => 'Your uploader app for :event is ready.',
'body' => 'Here are the download links for the Fotospiel Photobooth Uploader. Install the right version on the photobooth PC before your event starts.',
'downloads_title' => 'Download links',
'downloads' => [
'windows' => 'Windows (x64)',
'macos' => 'macOS (x64)',
'linux' => 'Linux (x64)',
],
'cta_windows' => 'Download for Windows',
'cta_macos' => 'Download for macOS',
'cta_linux' => 'Download for Linux',
'credentials_hint' => 'Connection credentials stay in the admin dashboard. Generate a connect code when you are ready to pair the app.',
'footer' => 'Questions? Reply to this email and we will help.',
'event_fallback' => 'your event',
],
'contact' => [
'subject' => 'New Contact Request',

View File

@@ -0,0 +1,66 @@
@extends('emails.partials.layout')
@section('title', __('emails.photobooth_uploader.subject', ['event' => $eventName]))
@section('preheader', __('emails.photobooth_uploader.preheader', ['event' => $eventName]))
@section('hero_title', __('emails.photobooth_uploader.hero_title', ['name' => $recipientName]))
@section('hero_subtitle', __('emails.photobooth_uploader.hero_subtitle', ['event' => $eventName]))
@section('content')
<p style="margin:0 0 16px; font-size:15px; color:#1f2937;">
{{ __('emails.photobooth_uploader.body', ['event' => $eventName]) }}
</p>
<p style="margin:0 0 12px; font-size:14px; color:#6b7280;">
{{ __('emails.photobooth_uploader.downloads_title') }}
</p>
<table role="presentation" width="100%" cellspacing="0" cellpadding="0" style="margin-bottom:12px;">
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.windows') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['windows'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['windows'] }}
</a>
</td>
</tr>
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.macos') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['macos'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['macos'] }}
</a>
</td>
</tr>
<tr>
<td style="padding:4px 0; font-size:14px; color:#1f2937;">
<strong>{{ __('emails.photobooth_uploader.downloads.linux') }}</strong>
</td>
<td align="right" style="padding:4px 0; font-size:14px;">
<a href="{{ $links['linux'] }}" style="color:#1d4ed8; text-decoration:none;">
{{ $links['linux'] }}
</a>
</td>
</tr>
</table>
<p style="margin:0; font-size:14px; color:#6b7280;">
{{ __('emails.photobooth_uploader.credentials_hint') }}
</p>
@endsection
@section('cta')
<a href="{{ $links['windows'] }}" style="display:inline-block; background-color:#111827; color:#ffffff; text-decoration:none; padding:12px 20px; border-radius:999px; font-weight:600; font-size:14px; margin-right:8px;">
{{ __('emails.photobooth_uploader.cta_windows') }}
</a>
<a href="{{ $links['macos'] }}" style="display:inline-block; background-color:#f3f4f6; color:#111827; text-decoration:none; padding:12px 18px; border-radius:999px; font-weight:600; font-size:14px; margin-right:8px;">
{{ __('emails.photobooth_uploader.cta_macos') }}
</a>
<a href="{{ $links['linux'] }}" style="display:inline-block; background-color:#f3f4f6; color:#111827; text-decoration:none; padding:12px 18px; border-radius:999px; font-weight:600; font-size:14px;">
{{ __('emails.photobooth_uploader.cta_linux') }}
</a>
@endsection
@section('footer')
{!! __('emails.photobooth_uploader.footer') !!}
@endsection

View File

@@ -153,8 +153,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
->middleware('signed')
->name('gallery.photos.asset');
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.sparkbooth.upload');
Route::post('/photobooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.upload');
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
->middleware('throttle:photobooth-connect')
->name('photobooth.connect');
@@ -270,6 +270,8 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::post('/disable', [PhotoboothController::class, 'disable'])->name('tenant.events.photobooth.disable');
Route::post('/connect-codes', [PhotoboothConnectCodeController::class, 'store'])
->name('tenant.events.photobooth.connect-codes.store');
Route::post('/uploader-email', [PhotoboothController::class, 'sendUploaderDownloadEmail'])
->name('tenant.events.photobooth.uploader-email');
});
Route::get('members', [EventMemberController::class, 'index'])

View File

@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
WORKDIR=${WORKDIR:-/var/www/html}
SRC_DIR="${WORKDIR}/clients/photobooth-uploader/PhotoboothUploader"
OUT_DIR="${WORKDIR}/public/downloads"
WIN_FILE="${OUT_DIR}/PhotoboothUploader-win-x64.exe"
MAC_FILE="${OUT_DIR}/PhotoboothUploader-macos-x64"
LINUX_FILE="${OUT_DIR}/PhotoboothUploader-linux-x64"
STAMP_FILE="${OUT_DIR}/photobooth-uploader.hash"
if [[ ! -d "$SRC_DIR" ]]; then
echo "[photobooth-uploader] Source directory not found: ${SRC_DIR}"
exit 0
fi
mkdir -p "$OUT_DIR"
compute_hash() {
find "$SRC_DIR" -type f \
-not -path "*/bin/*" \
-not -path "*/obj/*" \
-print \
| LC_ALL=C sort \
| xargs sha256sum \
| sha256sum \
| awk '{print $1}'
}
HASH=$(compute_hash)
if [[ -f "$WIN_FILE" && -f "$MAC_FILE" && -f "$LINUX_FILE" && -f "$STAMP_FILE" ]]; then
CURRENT_HASH=$(cat "$STAMP_FILE" || true)
if [[ "$CURRENT_HASH" == "$HASH" ]]; then
echo "[photobooth-uploader] Up to date, skipping publish."
exit 0
fi
fi
publish_target() {
local rid="$1"
local output_file="$2"
local temp_dir
temp_dir=$(mktemp -d)
dotnet publish "${SRC_DIR}/PhotoboothUploader.csproj" \
-c Release \
-r "$rid" \
--self-contained true \
/p:PublishSingleFile=true \
/p:IncludeNativeLibrariesForSelfExtract=true \
-o "$temp_dir"
if [[ "$rid" == "win-x64" ]]; then
mv -f "$temp_dir/PhotoboothUploader.exe" "$output_file"
else
mv -f "$temp_dir/PhotoboothUploader" "$output_file"
fi
rm -rf "$temp_dir"
}
echo "[photobooth-uploader] Publishing uploader binaries..."
publish_target "win-x64" "$WIN_FILE"
publish_target "osx-x64" "$MAC_FILE"
publish_target "linux-x64" "$LINUX_FILE"
echo "$HASH" > "$STAMP_FILE"
echo "[photobooth-uploader] Published to ${OUT_DIR}"

View File

@@ -0,0 +1,24 @@
<?php
namespace Tests\Feature\Help;
use App\Services\Help\HelpSyncService;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Storage;
use Tests\TestCase;
class HelpSyncServiceTest extends TestCase
{
public function test_help_sync_writes_compiled_articles(): void
{
Storage::fake('local');
Config::set('help.disk', 'local');
$service = $this->app->make(HelpSyncService::class);
$result = $service->sync();
$this->assertNotEmpty($result);
Storage::disk('local')->assertExists('help/guest/en/articles.json');
Storage::disk('local')->assertExists('help/guest/de/articles.json');
}
}

View File

@@ -39,6 +39,7 @@ class PhotoboothConnectCodeTest extends TenantTestCase
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-redeem',
'name' => 'Winterhochzeit',
]);
EventPhotoboothSetting::factory()
@@ -59,6 +60,7 @@ class PhotoboothConnectCodeTest extends TenantTestCase
]);
$redeem->assertOk()
->assertJsonPath('data.event_name', 'Winterhochzeit')
->assertJsonPath('data.upload_url', fn ($value) => is_string($value) && $value !== '')
->assertJsonPath('data.username', 'pbconnect')
->assertJsonPath('data.password', 'SECRET12');

View File

@@ -0,0 +1,28 @@
<?php
namespace Tests\Feature\Photobooth;
use App\Mail\PhotoboothUploaderDownload;
use App\Models\Event;
use Illuminate\Support\Facades\Mail;
use PHPUnit\Framework\Attributes\Test;
use Tests\Feature\Tenant\TenantTestCase;
class PhotoboothUploaderDownloadEmailTest extends TenantTestCase
{
#[Test]
public function it_sends_the_photobooth_uploader_download_email(): void
{
Mail::fake();
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'photobooth-email',
]);
$response = $this->authenticatedRequest('POST', "/api/v1/tenant/events/{$event->slug}/photobooth/uploader-email");
$response->assertOk();
Mail::assertQueued(PhotoboothUploaderDownload::class);
}
}