Compare commits

..

7 Commits

Author SHA1 Message Date
Codex Agent
a9fa1546f7 bd sync: 2026-01-23 09:20:33 2026-01-23 09:20:34 +01:00
Codex Agent
7c6eee187c bd sync: 2026-01-23 08:56:22 2026-01-23 08:56:23 +01:00
Codex Agent
fbd46b8e5c bd sync: 2026-01-21 12:55:26 2026-01-21 12:55:26 +01:00
Codex Agent
886b336a08 bd sync: 2026-01-19 18:50:20 2026-01-19 18:50:20 +01:00
Codex Agent
02237735ec bd sync: 2026-01-18 11:02:27 2026-01-18 11:02:28 +01:00
Codex Agent
5e420a0dd8 bd sync: 2026-01-15 19:54:28 2026-01-15 19:54:28 +01:00
Codex Agent
2a55ae934f bd sync: 2026-01-13 11:04:44 2026-01-13 11:04:44 +01:00
73 changed files with 706 additions and 3575 deletions

6
.beads/.gitignore vendored
View File

@@ -11,12 +11,6 @@ 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,10 +17,12 @@
{"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"}]}
@@ -36,6 +38,7 @@
{"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)"}
@@ -48,6 +51,7 @@
{"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)"}
@@ -74,6 +78,7 @@
{"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"}
@@ -135,6 +140,7 @@
{"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-6yz
fotospiel-app-29r

View File

@@ -4,7 +4,6 @@ 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;
@@ -34,8 +33,7 @@ class PhotoboothConnectController extends Controller
return response()->json([
'data' => [
'event_name' => $this->resolveEventName($event),
'upload_url' => route('api.v1.photobooth.upload'),
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'username' => $setting->username,
'password' => $setting->password,
'expires_at' => optional($setting->expires_at)->toIso8601String(),
@@ -44,27 +42,4 @@ 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,17 +3,12 @@
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
{
@@ -74,39 +69,6 @@ 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([
@@ -130,30 +92,4 @@ 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

@@ -1,18 +0,0 @@
<?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.upload') : null,
'upload_url' => $isSparkbooth ? route('api.v1.photobooth.sparkbooth.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.upload'),
'upload_url' => route('api.v1.photobooth.sparkbooth.upload'),
'response_format' => ($eventSetting?->metadata ?? [])['sparkbooth_response_format'] ?? config('photobooth.sparkbooth.response_format', 'json'),
'metrics' => $sparkMetrics,
],

View File

@@ -1,50 +0,0 @@
<?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,8 +77,6 @@ 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,85 +6,5 @@
<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.

Before

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 B

View File

@@ -4,27 +4,13 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="520" d:DesignHeight="360"
x:Class="PhotoboothUploader.MainWindow"
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>
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" />
<Grid Grid.Row="1" ColumnDefinitions="*,16,*">
<StackPanel Grid.Column="0" Spacing="16" MaxWidth="420">
<Border Padding="14" Classes="card">
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<StackPanel Spacing="6">
<TextBlock Text="Schritte" FontWeight="SemiBold" />
<TextBlock x:Name="StepCodeText" Text="1. Code eingeben" />
@@ -33,112 +19,34 @@
</StackPanel>
</Border>
<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>
<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" />
<Border Padding="14" Classes="card">
<StackPanel Spacing="8">
<StackPanel Spacing="6">
<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" />
<TextBlock x:Name="FolderText" Text="Noch nicht ausgewählt." TextWrapping="Wrap" />
<Button x:Name="PickFolderButton" Content="Ordner auswählen" Click="PickFolderButton_Click" IsEnabled="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="16" MaxWidth="380" Margin="0,6,0,0">
<Border Padding="14" Classes="card accent">
<StackPanel Grid.Column="2" Spacing="12" MaxWidth="380">
<Border Background="#1F000000" Padding="12" CornerRadius="8">
<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="#14FFFFFF" Padding="10" CornerRadius="8" Margin="0,0,0,8">
<Border Background="#14000000" Padding="8" CornerRadius="6" Margin="0,0,0,6">
<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}" />
@@ -148,13 +56,8 @@
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<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" />
<Button x:Name="RetryFailedButton" Content="Fehlgeschlagene erneut senden" Click="RetryFailedButton_Click" IsEnabled="False" />
</StackPanel>
</StackPanel>
</Border>
</StackPanel>
</Grid>
</Grid>
</Window>

View File

@@ -13,9 +13,6 @@ 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

@@ -1,26 +0,0 @@
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,29 +1,11 @@
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,7 +4,6 @@
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ApplicationManifest>app.manifest</ApplicationManifest>
<ApplicationIcon>Assets\app.ico</ApplicationIcon>
<AvaloniaUseCompiledBindingsByDefault>false</AvaloniaUseCompiledBindingsByDefault>
</PropertyGroup>
@@ -19,10 +18,4 @@
<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,7 +1,5 @@
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;
@@ -12,111 +10,41 @@ 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, string userAgent)
public PhotoboothConnectClient(string baseUrl)
{
_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 request = new { code };
var response = await _httpClient.PostAsJsonAsync("/api/v1/photobooth/connect", new { code }, cancellationToken);
var payload = await response.Content.ReadFromJsonAsync<PhotoboothConnectResponse>(_jsonOptions, cancellationToken);
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)
if (payload is null)
{
return new PhotoboothConnectResponse
{
Message = message,
Message = response.ReasonPhrase ?? "Verbindung fehlgeschlagen.",
};
}
if (!response.IsSuccessStatusCode)
{
return new PhotoboothConnectResponse
{
Message = payload.Message ?? "Verbindung fehlgeschlagen.",
};
}
return payload;
}
}

View File

@@ -14,7 +14,6 @@ public sealed class SettingsStore
};
public string SettingsPath { get; }
public string LogPath { get; }
public SettingsStore()
{
@@ -25,7 +24,6 @@ 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,6 +1,5 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
@@ -13,38 +12,21 @@ 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, string> onFailure)
Action<string> onFailure)
{
Stop();
_cts = new CancellationTokenSource();
var workerCount = GetWorkerCount(settings);
for (var i = 0; i < workerCount; i++)
{
_workers.Add(Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token)));
}
_ = Task.Run(() => WorkerAsync(settings, onQueued, onUploading, onSuccess, onFailure, _cts.Token));
}
public void Stop()
@@ -52,7 +34,6 @@ public sealed class UploadService
_cts?.Cancel();
_cts = null;
_pending.Clear();
_workers.Clear();
}
public void Enqueue(string path, Action<string> onQueued)
@@ -71,7 +52,7 @@ public sealed class UploadService
Action<string> onQueued,
Action<string> onUploading,
Action<string> onSuccess,
Action<string, string> onFailure,
Action<string> onFailure,
CancellationToken token)
{
if (string.IsNullOrWhiteSpace(settings.UploadUrl))
@@ -80,9 +61,6 @@ 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))
{
@@ -91,72 +69,58 @@ public sealed class UploadService
try
{
onUploading(path);
var error = await UploadWithRetryAsync(client, settings, path, token);
if (error is null)
{
await WaitForFileReadyAsync(path, token);
await UploadAsync(client, settings, path, token);
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<string?> UploadWithRetryAsync(
HttpClient client,
PhotoboothSettings settings,
string path,
CancellationToken token)
private static async Task WaitForFileReadyAsync(string path, CancellationToken token)
{
for (var attempt = 0; attempt <= MaxRetries; attempt++)
{
var attemptError = await UploadOnceAsync(client, settings, path, token);
if (attemptError.Success)
{
return null;
}
var lastSize = -1L;
if (!attemptError.Retryable || attempt >= MaxRetries)
for (var attempts = 0; attempts < 10; attempts++)
{
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);
}
token.ThrowIfCancellationRequested();
if (!File.Exists(path))
{
return UploadAttempt.Fail("Datei nicht gefunden.", retryable: false);
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;
}
using var content = new MultipartFormDataContent();
@@ -181,61 +145,8 @@ 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);
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.";
response.EnsureSuccessStatusCode();
}
private static string ResolveContentType(string path)
@@ -247,51 +158,4 @@ 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,8 +24,7 @@
"simplesoftwareio/simple-qrcode": "^4.2",
"spatie/laravel-translatable": "^6.11",
"staudenmeir/belongs-to-through": "^2.17",
"stripe/stripe-php": "*",
"symfony/yaml": "^7.0"
"stripe/stripe-php": "*"
},
"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": "5e1d60e650853d6113b01e1adaf49d65",
"content-hash": "c1a772e5fe6f8d5c92fdbbea232f9f78",
"packages": [
{
"name": "anourvalar/eloquent-serialize",
@@ -10043,82 +10043,6 @@
],
"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",
@@ -12928,6 +12852,82 @@
],
"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,24 +106,6 @@ 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:
@@ -358,7 +340,6 @@ volumes:
external: true
name: fotospiel-${APP_ENV:-prod}-storage
app-bootstrap-cache:
nuget-cache:
photobooth-import:
photobooth-ftp-auth:
mysql-data:

View File

@@ -53,23 +53,6 @@ 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
@@ -137,7 +120,6 @@ ensure_helper_scripts
prepare_storage
refresh_config_cache
wait_for_dependencies
ensure_help_cache
cd "$APP_TARGET"
exec "$@"

View File

@@ -20,12 +20,6 @@ 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/upload`
- Endpoint: `POST /api/v1/photobooth/sparkbooth/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/upload \
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/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/upload \
Example cURL (request XML response):
```bash
curl -X POST https://app.example.com/api/v1/photobooth/upload \
curl -X POST https://app.example.com/api/v1/photobooth/sparkbooth/upload \
-F "media=@/path/to/photo.jpg" \
-F "username=PB123" \
-F "password=SECRET" \

View File

@@ -65,25 +65,6 @@ 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,25 +65,6 @@ 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,11 +218,6 @@ export type PhotoboothStatus = {
metrics?: PhotoboothStatusMetrics | null;
};
export type PhotoboothConnectCode = {
code: string;
expires_at: string | null;
};
export type EventAddonCheckout = {
addon_key: string;
quantity?: number;
@@ -2046,35 +2041,6 @@ 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,7 +13,6 @@ 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,23 +1168,15 @@
"mode": "Modus"
},
"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."
"title": "Photobooth-Typ auswählen",
"description": "Wähle zwischen klassischem FTP und Sparkbooth HTTP-Upload. Umschalten generiert neue Zugangsdaten.",
"active": "Aktuell: {{mode}}"
},
"credentials": {
"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.",
"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).",
"host": "Host",
"port": "Port",
"username": "Benutzername",
@@ -1193,44 +1185,6 @@
"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",
@@ -1248,9 +1202,9 @@
"title": "Setup-Checkliste",
"description": "Durchlaufe die Schritte, bevor du Gästen Zugang gibst.",
"enable": "Zugang aktivieren",
"enableCopy": "Aktiviere die Verbindung für die Uploader-App.",
"enableCopy": "Aktiviere den FTP-Account für eure Photobooth-Software.",
"share": "Zugang teilen",
"shareCopy": "Übergib URL, Benutzername & Passwort an den Betreiber.",
"shareCopy": "Übergib Host, Benutzer & Passwort an den Betreiber.",
"monitor": "Uploads beobachten",
"monitorCopy": "Verfolge Uploads & Limits direkt im Dashboard."
},
@@ -1477,7 +1431,7 @@
"photobooth": {
"title": "Fotobox-Uploads",
"titleForEvent": "Fotobox-Uploads verwalten",
"subtitle": "Erstelle Zugang für die Uploader-App und behalte Limits im Blick.",
"subtitle": "Erstelle FTP-Zugänge für Photobooth-Software und behalte Limits im Blick.",
"actions": {
"backToEvent": "Zur Detailansicht",
"allEvents": "Zur Eventliste"
@@ -2392,7 +2346,7 @@
"mobileProfile": {
"title": "Profil",
"settings": "Einstellungen",
"account": "Account bearbeiten",
"account": "Account & Sicherheit",
"language": "Sprache",
"languageDe": "Deutsch",
"languageEn": "Englisch",

View File

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

View File

@@ -881,23 +881,15 @@
"mode": "Mode"
},
"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."
"title": "Choose your photobooth type",
"description": "Pick classic FTP or Sparkbooth HTTP upload. Switching regenerates credentials.",
"active": "Current: {{mode}}"
},
"credentials": {
"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.",
"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).",
"host": "Host",
"port": "Port",
"username": "Username",
@@ -906,44 +898,6 @@
"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",
@@ -961,9 +915,9 @@
"title": "Setup checklist",
"description": "Complete each step before guests upload.",
"enable": "Activate access",
"enableCopy": "Enable the uploader app connection for this event.",
"enableCopy": "Enable the FTP account in your photobooth software.",
"share": "Share credentials",
"shareCopy": "Share URL, username, and password with the operator.",
"shareCopy": "Hand over host, user, and password to the operator.",
"monitor": "Monitor uploads",
"monitorCopy": "Watch uploads & limits in the dashboard."
},
@@ -1474,7 +1428,7 @@
"photobooth": {
"title": "Photobooth uploads",
"titleForEvent": "Manage photobooth uploads",
"subtitle": "Create uploader access for photobooth apps and keep limits in sight.",
"subtitle": "Create FTP access for photobooth software and keep limits in sight.",
"actions": {
"backToEvent": "Back to detail view",
"allEvents": "Back to event list"
@@ -2396,7 +2350,7 @@
"mobileProfile": {
"title": "Profile",
"settings": "Settings",
"account": "Edit account",
"account": "Account & security",
"language": "Language",
"languageDe": "Deutsch",
"languageEn": "English",

View File

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

View File

@@ -48,29 +48,6 @@ 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,9 +1,10 @@
import React from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3, Mail, Download } from 'lucide-react';
import { RefreshCcw, PlugZap, RefreshCw, ShieldCheck, Copy, Power, Clock3 } 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';
@@ -13,14 +14,12 @@ import {
enableEventPhotobooth,
disableEventPhotobooth,
rotateEventPhotobooth,
createEventPhotoboothConnectCode,
sendEventPhotoboothUploaderEmail,
PhotoboothStatus,
TenantEvent,
} from '../api';
import { isAuthError } from '../auth/tokens';
import { getApiErrorMessage } from '../lib/apiError';
import { formatEventDate, formatEventDateTime } from '../lib/events';
import { formatEventDate } from '../lib/events';
import toast from 'react-hot-toast';
import { adminPath } from '../constants';
import { useBackNavigation } from './hooks/useBackNavigation';
@@ -35,14 +34,10 @@ 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';
@@ -55,6 +50,7 @@ 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.')));
@@ -68,14 +64,20 @@ export default function MobileEventPhotoboothPage() {
void load();
}, [load]);
React.useEffect(() => {
if (status?.mode) {
setSelectedMode(status.mode);
}
}, [status?.mode]);
const handleEnable = async () => {
const handleEnable = async (mode?: 'ftp' | 'sparkbooth') => {
if (!slug) return;
const nextMode = 'sparkbooth';
const nextMode = mode ?? selectedMode ?? status?.mode ?? 'ftp';
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)) {
@@ -88,11 +90,12 @@ export default function MobileEventPhotoboothPage() {
const handleDisable = async () => {
if (!slug) return;
const mode = 'sparkbooth';
const mode = status?.mode ?? selectedMode ?? 'ftp';
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)) {
@@ -105,11 +108,12 @@ export default function MobileEventPhotoboothPage() {
const handleRotate = async () => {
if (!slug) return;
const mode = 'sparkbooth';
const mode = selectedMode ?? status?.mode ?? 'ftp';
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)) {
@@ -120,57 +124,39 @@ export default function MobileEventPhotoboothPage() {
}
};
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 activeMode = selectedMode ?? status?.mode ?? 'ftp';
const isSpark = activeMode === 'sparkbooth';
const spark = status?.sparkbooth ?? null;
const metrics = spark?.metrics ?? null;
const expiresAt = spark?.expires_at ?? status?.expires_at;
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 lastUploadAt = metrics?.last_upload_at;
const uploads24h = metrics?.uploads_24h ?? metrics?.uploads_today;
const uploadsTotal = metrics?.uploads_total;
const uploadUrl = spark?.upload_url ?? status?.upload_url;
const username = spark?.username ?? status?.username ?? null;
const password = spark?.password ?? status?.password ?? null;
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 modeLabel = t('photobooth.mode.uploader', 'Uploader App (HTTP)');
const modeLabel =
activeMode === 'sparkbooth'
? t('photobooth.credentials.sparkboothTitle', 'Sparkbooth / HTTP')
: t('photobooth.credentials.heading', 'FTP (Classic)');
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"
@@ -199,159 +185,143 @@ export default function MobileEventPhotoboothPage() {
) : (
<YStack space="$2">
<MobileCard space="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.activate.title', '1. Photobooth aktivieren')}
<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')}
</Text>
<Text fontSize="$xs" color={muted}>
{t('photobooth.steps.activate.description', 'Schalte den Upload-Zugang fuer dieses Event frei.')}
{t('photobooth.credentials.description', 'Share these credentials with your photobooth software.')}
</Text>
</YStack>
<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>
<Text fontSize="$xs" color={muted}>
{t('photobooth.mode.active', 'Current: {{mode}}', { mode: modeLabel })}
</Text>
</XStack>
<XStack space="$2" marginTop="$2">
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable())}
tone={isActive ? 'ghost' : 'primary'}
iconLeft={isActive ? <Power size={14} color={text} /> : <PlugZap size={14} color={surface} />}
</YStack>
<YStack alignItems="flex-end" space="$2">
<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}
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}
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')}
</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="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.download.title', '2. Uploader App herunterladen')}
<MobileCard space="$2">
<Text fontSize="$sm" fontWeight="700" color={text}>
{t('photobooth.selector.title', 'Choose adapter')}
</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.'
'photobooth.selector.description',
'FTP (Classic) works with most booths. Sparkbooth uses HTTP POST without FTP.'
)}
</Text>
</YStack>
<XStack space="$2" marginTop="$2" flexWrap="wrap">
<XStack space="$2" marginTop="$2" flexWrap="nowrap">
<XStack flex={1} minWidth={0}>
<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}
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 space="$2" marginTop="$2">
<XStack flex={1} minWidth={0}>
<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}
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="$3">
<YStack space="$1">
<Text fontSize="$sm" fontWeight="800" color={text}>
{t('photobooth.steps.access.title', '3. Verbindungscode erstellen')}
<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>
<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}
/>
{!isSpark && ftp?.require_ftps ? <PillBadge tone="warning">{t('photobooth.credentials.ftps', 'FTPS required')}</PillBadge> : null}
</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">
{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 />
</YStack>
<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.hidden', 'Credentials are hidden. Tap to show them.')}
{t('photobooth.credentials.ftpsHint', 'Use FTPS if required; uploads go into the target folder for this event.')}
</Text>
</>
)}
<Text fontSize="$xs" color={muted}>
{t('photobooth.uploader.hint', 'POST with media file or base64 "media" field; app uses these credentials.')}
</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}>
<CTAButton
label={isActive ? t('photobooth.actions.disable', 'Disable uploads') : t('photobooth.actions.enable', 'Enable uploads')}
onPress={() => (isActive ? handleDisable() : handleEnable(selectedMode))}
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 }}
/>
</XStack>
</XStack>
</MobileCard>
<MobileCard space="$2">

View File

@@ -1,302 +0,0 @@
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, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants';
import { adminPath, ADMIN_DATA_EXPORTS_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(ADMIN_PROFILE_ACCOUNT_PATH)}>
<Pressable onPress={() => navigate(adminPath('/mobile/profile/security'))}>
<ListItem
hoverTheme
pressTheme
@@ -93,7 +93,7 @@ export default function MobileProfilePage() {
paddingHorizontal="$3"
title={
<Text fontSize="$sm" color={textColor}>
{t('mobileProfile.account', 'Account bearbeiten')}
{t('mobileProfile.account', 'Account & security')}
</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, ADMIN_PROFILE_ACCOUNT_PATH } from '../constants';
import { adminPath, ADMIN_HOME_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(ADMIN_PROFILE_ACCOUNT_PATH)} />
<CTAButton label={t('settings.profile.actions.openProfile', 'Profil bearbeiten')} onPress={() => navigate(adminPath('/mobile/profile'))} />
<CTAButton label={t('settings.session.logout', 'Abmelden')} tone="ghost" onPress={() => logout({ redirect: adminPath('/logout') })} />
</XStack>
</MobileCard>

View File

@@ -1,141 +0,0 @@
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,7 +35,6 @@ 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'));
@@ -213,7 +212,6 @@ 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,36 +37,27 @@ export default function FiltersBar({
return (
<div
className={cn(
'flex overflow-x-auto px-1 pb-2 text-xs font-semibold [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
style={styleOverride}
>
<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">
{filters.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => onChange(filter.value)}
className={cn(
'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',
'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',
)}
>
{React.cloneElement(filter.icon as React.ReactElement, { className: 'h-3.5 w-3.5' })}
<span className="whitespace-nowrap">{t(filter.labelKey)}</span>
{filter.icon}
{t(filter.labelKey)}
</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 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) => {
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
{filters.map((filter) => {
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(
'inline-flex items-center rounded-full px-3 py-1.5 transition',
'px-4 py-1 transition',
isActive
? 'bg-pink-500 text-white shadow'
: 'text-muted-foreground hover:bg-pink-50 hover:text-pink-600',
? 'text-white'
: 'bg-[var(--guest-surface)] text-foreground dark:bg-slate-950/70 dark:text-slate-100',
)}
>
<span className="whitespace-nowrap">{filter.label}</span>
{filter.label}
</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,29 +147,37 @@ export default function GalleryPreview({ token }: Props) {
</div>
)}
<div className="grid gap-3 grid-cols-2 md:grid-cols-3">
<div className="grid gap-2 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 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 }}
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`,
}}
>
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="aspect-[3/4] w-full object-cover transition duration-300 group-hover:scale-105"
className="h-40 w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy"
/>
<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}>
<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}>
{p.title || getPhotoTitle(p)}
</p>
<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 />
<div className="flex items-center gap-1 text-xs text-foreground/80">
<Heart className="h-4 w-4" style={{ color: branding.primaryColor }} aria-hidden />
{p.likes_count ?? 0}
</div>
</div>

View File

@@ -188,6 +188,13 @@ 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,
@@ -252,6 +259,15 @@ 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, y: 8 },
center: { opacity: 1, y: 0 },
exit: { opacity: 0, y: -8 },
enter: { opacity: 0, scale: 0.985 },
center: { opacity: 1, scale: 1 },
exit: { opacity: 0, scale: 0.985 },
};
const transition = kind === 'tab'
? { duration: 0.22, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.28, ease: [0.25, 0.8, 0.25, 1] };
? { duration: 0.18, ease: [0.22, 0.61, 0.36, 1] }
: { duration: 0.24, ease: [0.25, 0.8, 0.25, 1] };
return (
<AnimatePresence initial={false} mode="wait">
<AnimatePresence initial={false}>
<motion.div
key={location.pathname}
custom={{ direction }}

View File

@@ -1,5 +1,5 @@
import React from "react";
import { Link, useLocation, useParams } from 'react-router-dom';
import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
@@ -23,7 +23,6 @@ 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' },
@@ -54,15 +53,12 @@ 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 helpSlug = getHelpSlugForPathname(location.pathname);
const helpBase = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
const helpHref = helpSlug ? `${helpBase}/${helpSlug}` : helpBase;
const helpHref = params?.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
React.useEffect(() => {
if (open && identity?.hydrated) {

View File

@@ -746,8 +746,6 @@ 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',
},
@@ -1483,8 +1481,6 @@ 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

@@ -1,13 +0,0 @@
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

@@ -1,32 +0,0 @@
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,47 +1,41 @@
import { getMotionContainerPropsForNavigation, getMotionItemPropsForNavigation, STAGGER_FAST, FADE_UP } from '../motion';
import { describe, expect, it, vi } from 'vitest';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../motion';
describe('getMotionContainerPropsForNavigation', () => {
it('returns initial hidden for POP navigation', () => {
expect(getMotionContainerPropsForNavigation(true, STAGGER_FAST, 'POP')).toEqual({
variants: STAGGER_FAST,
initial: 'hidden',
animate: 'show',
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,
});
});
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({});
it('exposes distinct base variants', () => {
expect(FADE_UP).not.toBe(FADE_SCALE);
});
});

View File

@@ -1,22 +0,0 @@
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

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

View File

@@ -1,44 +0,0 @@
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,31 +56,3 @@ 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

@@ -1,18 +0,0 @@
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, useNavigationType, useParams } from 'react-router-dom';
import { Link, 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, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
const GENERIC_ERROR = 'GENERIC_ERROR';
@@ -343,7 +343,6 @@ 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();
@@ -394,7 +393,7 @@ export default function AchievementsPage() {
const hasPersonal = Boolean(data?.personal);
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
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 { useNavigationType, useParams, useSearchParams } from 'react-router-dom';
import { 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,19 +12,11 @@ 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,
getMotionContainerPropsForNavigation,
getMotionItemPropsForNavigation,
prefersReducedMotion,
} from '../lib/motion';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics';
@@ -64,7 +56,6 @@ 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);
@@ -77,10 +68,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 = 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 containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);
const gridMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const [filter, setFilterState] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -97,7 +88,10 @@ export default function GalleryPage() {
});
const typedPhotos = photos as GalleryPhoto[];
const showPhotoboothFilter = React.useMemo(() => shouldShowPhotoboothFilter(event), [event]);
const showPhotoboothFilter = React.useMemo(
() => Boolean(event?.photobooth_enabled) || typedPhotos.some((p) => p.ingest_source === 'photobooth'),
[event?.photobooth_enabled, typedPhotos],
);
const allowedGalleryFilters = React.useMemo<GalleryFilter[]>(
() => (showPhotoboothFilter ? allGalleryFilters : ['latest', 'popular', 'mine']),
[showPhotoboothFilter],
@@ -307,62 +301,54 @@ 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-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">
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...containerMotion}>
<motion.div className="flex items-center gap-3" {...fadeUpMotion}>
<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>
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{newPhotosBadgeText}
</span>
</div>
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
</div>
</div>
{newCount > 0 && (
{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"
className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold transition ${badgeEmphasisClass}`}
style={{ borderRadius: radius }}
>
{t('galleryPage.badge.markSeen', 'Gesehen')}
{newPhotosBadgeText}
</button>
) : (
<span className={`inline-flex items-center rounded-full px-3 py-1 text-xs font-semibold ${badgeEmphasisClass}`} style={{ borderRadius: radius }}>
{newPhotosBadgeText}
</span>
)}
</div>
</motion.div>
</motion.div>
<motion.div {...fadeUpMotion}>
<FiltersBar
value={filter}
onChange={setFilter}
className="mt-0"
className="mt-2"
showPhotobooth={showPhotoboothFilter}
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
/>
</motion.div>
{loading && (
<motion.p className="px-1" {...fadeUpMotion}>
<motion.p className="px-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
{t('galleryPage.loading', 'Lade…')}
</motion.p>
)}
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
<motion.div className="grid grid-cols-2 gap-2 px-2 pb-16 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
@@ -391,11 +377,10 @@ export default function GalleryPage() {
openPhoto();
}
}}
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"
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"
style={{ borderRadius: radius }}
{...fadeScaleMotion}
>
<div className="relative">
<img
src={imageUrl}
alt={altText}
@@ -405,38 +390,15 @@ export default function GalleryPage() {
}}
loading="lazy"
/>
<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 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}
>
{localizedTaskTitle}
</p>
)}
<div className="flex items-center justify-between gap-2 text-[11px] text-muted-foreground">
<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="truncate">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
<span className="ml-3 truncate text-right">{p.uploader_name || t('galleryPage.photo.anonymous', 'Gast')}</span>
</div>
<div className="flex items-center justify-between gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
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',
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 }}
>
<Heart className={`h-3.5 w-3.5 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
</div>
<div className="absolute right-3 top-3 z-10 flex items-center gap-2">
<button
type="button"
onClick={(e) => {
@@ -444,17 +406,41 @@ export default function GalleryPage() {
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'
'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 }}
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,
}}
>
<Share2 className="h-3.5 w-3.5" aria-hidden />
{t('galleryPage.photo.shareLabel', 'Teilen')}
<Share2 className="h-4 w-4" aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
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'
)}
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,
}}
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
</div>
</div>
</motion.div>
);
@@ -462,7 +448,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-white shadow-sm ring-1 ring-black/5 dark:bg-slate-950 dark:ring-white/10"
className="relative overflow-hidden border border-muted/40 bg-[var(--guest-surface,#f7f7f7)] shadow-sm"
style={{ borderRadius: radius }}
{...fadeScaleMotion}
>
@@ -475,9 +461,7 @@ 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 { ArrowLeft, Loader2 } from 'lucide-react';
import { Loader2 } from 'lucide-react';
import { Page } from './_util';
import { useLocale } from '../i18n/LocaleContext';
import { useTranslation } from '../i18n/useTranslation';
@@ -37,9 +37,7 @@ export default function HelpArticlePage() {
loadArticle();
}, [loadArticle]);
const title = state === 'loading'
? t('help.article.loadingTitle')
: (article?.title ?? t('help.article.unavailable'));
const title = article?.title ?? t('help.article.unavailable');
return (
<Page title={title}>
@@ -50,30 +48,17 @@ export default function HelpArticlePage() {
refreshingLabel={t('common.refreshing')}
>
<div className="mb-4">
<Button variant="outline" size="sm" className="rounded-full border-border/60 bg-background/70 px-3" asChild>
<Button variant="ghost" size="sm" 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="rounded-2xl border border-border/60 bg-card/70 p-5">
<div className="flex items-center gap-3 text-sm text-muted-foreground">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
<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>
{t('common.actions.loading')}
</div>
)}
@@ -92,6 +77,11 @@ 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,7 +18,6 @@ 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) => {
@@ -38,24 +37,6 @@ 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;
@@ -104,7 +85,7 @@ export default function HelpCenterPage() {
)}
</Button>
</div>
{showOfflineBadge && (
{servedFromCache && (
<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, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
import { useNavigate, 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,10 +18,9 @@ import {
type EmotionTheme,
} from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerPropsForNavigation, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import { FADE_SCALE, FADE_UP, STAGGER_FAST, getMotionContainerProps, getMotionItemProps, prefersReducedMotion } from '../lib/motion';
import PullToRefresh from '../components/PullToRefresh';
import { triggerHaptic } from '../lib/haptics';
import { dedupeTasksById } from '../lib/taskUtils';
interface Task {
id: number;
@@ -56,7 +55,6 @@ 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();
@@ -135,10 +133,9 @@ export default function TaskPickerPage() {
? payload.tasks
: [];
const uniqueTasks = dedupeTasksById(taskList);
const entry = { data: uniqueTasks, etag: response.headers.get('ETag') };
const entry = { data: taskList, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry);
setTasks(uniqueTasks);
setTasks(taskList);
} catch (err) {
console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
@@ -372,7 +369,7 @@ export default function TaskPickerPage() {
);
const toggleValue = selectedEmotion === 'all' ? 'none' : 'recent';
const motionEnabled = !prefersReducedMotion();
const containerMotion = getMotionContainerPropsForNavigation(motionEnabled, STAGGER_FAST, navigationType);
const containerMotion = getMotionContainerProps(motionEnabled, STAGGER_FAST);
const fadeUpMotion = getMotionItemProps(motionEnabled, FADE_UP);
const fadeScaleMotion = getMotionItemProps(motionEnabled, FADE_SCALE);

View File

@@ -1,49 +0,0 @@
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, useLocation, useParams, Link, Navigate } from 'react-router-dom';
import { createBrowserRouter, useParams, Link, Navigate } from 'react-router-dom';
import Header from './components/Header';
import BottomNav from './components/BottomNav';
import RouteTransition from './components/RouteTransition';
@@ -101,7 +101,6 @@ 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 />;
@@ -119,9 +118,6 @@ 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}>
@@ -129,7 +125,7 @@ function EventBoundary({ token }: { token: string }) {
<NotificationCenterProvider eventToken={token}>
<div className="pb-16">
<Header eventToken={token} />
<div className={contentPaddingClass}>
<div className="px-4 py-3">
<RouteTransition />
</div>
<BottomNav />

View File

@@ -128,25 +128,6 @@ 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,25 +127,6 @@ 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

@@ -1,66 +0,0 @@
@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/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.upload');
Route::post('/photobooth/sparkbooth/upload', [SparkboothUploadController::class, 'store'])
->name('photobooth.sparkbooth.upload');
Route::post('/photobooth/connect', [PhotoboothConnectController::class, 'store'])
->middleware('throttle:photobooth-connect')
->name('photobooth.connect');
@@ -270,8 +270,6 @@ 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

@@ -1,69 +0,0 @@
#!/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

@@ -1,24 +0,0 @@
<?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,7 +39,6 @@ class PhotoboothConnectCodeTest extends TenantTestCase
{
$event = Event::factory()->for($this->tenant)->create([
'slug' => 'connect-code-redeem',
'name' => 'Winterhochzeit',
]);
EventPhotoboothSetting::factory()
@@ -60,7 +59,6 @@ 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

@@ -1,28 +0,0 @@
<?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);
}
}