Aufgabenkarten in der Gäste-pwa als swipe-barer Stapel umgesetzt. Sofortiges Freigeben von Foto-Uploads als Event-Einstellung implementiert.

This commit is contained in:
Codex Agent
2025-12-16 15:30:52 +01:00
parent f2473c6f6d
commit 9e4e9a0d87
19 changed files with 22590 additions and 21687 deletions

View File

@@ -490,7 +490,7 @@ class EventPublicController extends BaseController
]) ])
->orderByRaw('COALESCE(event_task_collection.sort_order, event_task.sort_order, tasks.sort_order, 0)') ->orderByRaw('COALESCE(event_task_collection.sort_order, event_task.sort_order, tasks.sort_order, 0)')
->orderBy('tasks.sort_order') ->orderBy('tasks.sort_order')
->limit(20) ->distinct('tasks.id')
->get(); ->get();
$tasks = $rows->map(function ($row) use ($fallbacks) { $tasks = $rows->map(function ($row) use ($fallbacks) {
@@ -659,6 +659,22 @@ class EventPublicController extends BaseController
return $trimmed; return $trimmed;
} }
private function buildDeterministicSeed(?string $identifier): int
{
if ($identifier === null || trim($identifier) === '') {
return random_int(1, PHP_INT_MAX);
}
$hash = substr(sha1($identifier), 0, 8);
$seed = hexdec($hash);
if (! is_numeric($seed) || $seed <= 0) {
$seed = abs(crc32($identifier));
}
return max(1, (int) $seed);
}
private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array private function buildAchievementsPayload(int $eventId, ?string $guestIdentifier, array $fallbacks): array
{ {
$totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count(); $totalPhotos = (int) DB::table('photos')->where('event_id', $eventId)->count();
@@ -1757,6 +1773,7 @@ class EventPublicController extends BaseController
'join_token' => $joinToken?->token, 'join_token' => $joinToken?->token,
'photobooth_enabled' => (bool) $event->photobooth_enabled, 'photobooth_enabled' => (bool) $event->photobooth_enabled,
'branding' => $branding, 'branding' => $branding,
'guest_upload_visibility' => Arr::get($event->settings ?? [], 'guest_upload_visibility', 'review'),
])->header('Cache-Control', 'no-store'); ])->header('Cache-Control', 'no-store');
} }
@@ -2443,7 +2460,27 @@ class EventPublicController extends BaseController
}); });
$tasks = $cached['tasks']; $tasks = $cached['tasks'];
$etag = $cached['hash'] ?? sha1(json_encode($tasks)); $baseHash = $cached['hash'] ?? sha1(json_encode($tasks));
$page = max(1, (int) $request->query('page', 1));
$perPage = max(1, min(100, (int) $request->query('per_page', 20)));
// Shuffle per request for unpredictability; stable when seeded by guest/device or explicit seed.
$seedParam = $request->query('seed');
$guestIdentifier = $this->determineGuestIdentifier($request);
$seedValue = is_numeric($seedParam)
? (int) $seedParam
: $this->buildDeterministicSeed(($guestIdentifier ? $guestIdentifier.'|' : '').(string) $event->id);
$randomizer = new \Random\Randomizer(new \Random\Engine\Mt19937($seedValue));
$shuffled = $randomizer->shuffleArray($tasks);
$total = count($shuffled);
$offset = ($page - 1) * $perPage;
$data = array_slice($shuffled, $offset, $perPage);
$lastPage = (int) ceil($total / $perPage);
$hasMore = $page < $lastPage;
$etag = sha1($baseHash . ':' . $page . ':' . $perPage . ':' . $seedValue);
$reqEtag = $request->headers->get('If-None-Match'); $reqEtag = $request->headers->get('If-None-Match');
if ($reqEtag && $reqEtag === $etag) { if ($reqEtag && $reqEtag === $etag) {
@@ -2454,7 +2491,19 @@ class EventPublicController extends BaseController
->header('ETag', $etag); ->header('ETag', $etag);
} }
return response()->json($tasks) $payload = [
'data' => $data,
'meta' => [
'total' => $total,
'per_page' => $perPage,
'current_page' => $page,
'last_page' => $lastPage,
'has_more' => $hasMore,
'seed' => $seedValue,
],
];
return response()->json($payload)
->header('Cache-Control', 'public, max-age=120') ->header('Cache-Control', 'public, max-age=120')
->header('ETag', $etag) ->header('ETag', $etag)
->header('Vary', 'Accept-Language, X-Locale') ->header('Vary', 'Accept-Language, X-Locale')
@@ -2629,6 +2678,8 @@ class EventPublicController extends BaseController
'eventPackages.package', 'eventPackages.package',
'storageAssignments.storageTarget', 'storageAssignments.storageTarget',
])->findOrFail($eventId); ])->findOrFail($eventId);
$uploadVisibility = Arr::get($eventModel->settings ?? [], 'guest_upload_visibility', 'review');
$autoApproveUploads = $uploadVisibility === 'immediate';
$tenantModel = $eventModel->tenant; $tenantModel = $eventModel->tenant;
@@ -2744,7 +2795,7 @@ class EventPublicController extends BaseController
'thumbnail_path' => $thumbUrl, 'thumbnail_path' => $thumbUrl,
'likes_count' => 0, 'likes_count' => 0,
'ingest_source' => Photo::SOURCE_GUEST_PWA, 'ingest_source' => Photo::SOURCE_GUEST_PWA,
'status' => 'pending', 'status' => $autoApproveUploads ? 'approved' : 'pending',
// Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default // Handle emotion_id: prefer explicit ID, fallback to slug lookup, then default
'emotion_id' => $this->resolveEmotionId($validated, $eventId), 'emotion_id' => $this->resolveEmotionId($validated, $eventId),
@@ -2827,10 +2878,14 @@ class EventPublicController extends BaseController
$this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1); $this->packageUsageTracker->recordPhotoUsage($eventPackage, $previousUsed, 1);
} }
$message = $autoApproveUploads
? 'Photo uploaded and visible.'
: 'Photo uploaded and pending review.';
$response = response()->json([ $response = response()->json([
'id' => $photoId, 'id' => $photoId,
'status' => 'pending', 'status' => $autoApproveUploads ? 'approved' : 'pending',
'message' => 'Photo uploaded and pending review.', 'message' => $message,
], 201); ], 201);
$this->recordTokenEvent( $this->recordTokenEvent(

View File

@@ -45,6 +45,7 @@ class EventStoreRequest extends FormRequest
'settings.branding' => ['nullable', 'array'], 'settings.branding' => ['nullable', 'array'],
'settings.branding.*' => ['nullable'], 'settings.branding.*' => ['nullable'],
'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])], 'settings.engagement_mode' => ['nullable', Rule::in(['tasks', 'photo_only'])],
'settings.guest_upload_visibility' => ['nullable', Rule::in(['review', 'immediate'])],
]; ];
} }

View File

@@ -0,0 +1,148 @@
'use client';
import React from "react"
import { motion } from "framer-motion"
import { cn } from "@repo/shadcn-ui/lib/utils"
export interface BackgroundBeamsProps {
className?: string;
}
export const BackgroundBeams = React.memo(
({ className }: BackgroundBeamsProps) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
]
return (
<div
className={cn(
"absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] flex items-center justify-center",
className,
)}
>
<svg
className="z-0 h-full w-full pointer-events-none absolute"
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483M-30 -589C-30 -589 38 -184 502 -57C966 70 1034 475 1034 475M-23 -597C-23 -597 45 -192 509 -65C973 62 1041 467 1041 467M-16 -605C-16 -605 52 -200 516 -73C980 54 1048 459 1048 459M-9 -613C-9 -613 59 -208 523 -81C987 46 1055 451 1055 451M-2 -621C-2 -621 66 -216 530 -89C994 38 1062 443 1062 443M5 -629C5 -629 73 -224 537 -97C1001 30 1069 435 1069 435M12 -637C12 -637 80 -232 544 -105C1008 22 1076 427 1076 427M19 -645C19 -645 87 -240 551 -113C1015 14 1083 419 1083 419"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.05"
strokeWidth="0.5"
/>
{paths.map((path, index) => (
<motion.path
key={`path-${index}`}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeOpacity="0.4"
strokeWidth="0.5"
/>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: Math.random() * 10,
}}
>
<stop stopColor="#18CCFC" stopOpacity="0" />
<stop stopColor="#18CCFC" />
<stop offset="32.5%" stopColor="#6344F5" />
<stop offset="100%" stopColor="#AE48FF" stopOpacity="0" />
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--neutral-300)" />
<stop offset="0.243243" stopColor="var(--neutral-300)" />
<stop offset="0.43594" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
</div>
)
},
)
BackgroundBeams.displayName = "BackgroundBeams"

188
package-lock.json generated
View File

@@ -53,6 +53,7 @@
"embla-carousel-autoplay": "^8.6.0", "embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"fabric": "^6.9.0", "fabric": "^6.9.0",
"framer-motion": "^12.23.26",
"globals": "^15.15.0", "globals": "^15.15.0",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"i18next": "^25.7.2", "i18next": "^25.7.2",
@@ -67,6 +68,7 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.1", "react-i18next": "^16.4.1",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"swiper": "^12.0.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -11587,24 +11589,30 @@
} }
}, },
"node_modules/framer-motion": { "node_modules/framer-motion": {
"version": "6.5.1", "version": "12.23.26",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.26.tgz",
"integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==", "integrity": "sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@motionone/dom": "10.12.0", "motion-dom": "^12.23.23",
"framesync": "6.0.1", "motion-utils": "^12.23.6",
"hey-listen": "^1.0.8", "tslib": "^2.4.0"
"popmotion": "11.0.3",
"style-value-types": "5.0.0",
"tslib": "^2.1.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=16.8 || ^17.0.0 || ^18.0.0", "@emotion/is-prop-valid": "*",
"react-dom": ">=16.8 || ^17.0.0 || ^18.0.0" "react": "^18.0.0 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@emotion/is-prop-valid": {
"optional": true
},
"react": {
"optional": true
},
"react-dom": {
"optional": true
}
} }
}, },
"node_modules/framesync": { "node_modules/framesync": {
@@ -12291,25 +12299,29 @@
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/http-errors": { "node_modules/http-errors": {
"version": "2.0.0", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==", "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"depd": "2.0.0", "depd": "~2.0.0",
"inherits": "2.0.4", "inherits": "~2.0.4",
"setprototypeof": "1.2.0", "setprototypeof": "~1.2.0",
"statuses": "2.0.1", "statuses": "~2.0.2",
"toidentifier": "1.0.1" "toidentifier": "~1.0.1"
}, },
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
} }
}, },
"node_modules/http-errors/node_modules/statuses": { "node_modules/http-errors/node_modules/statuses": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">= 0.8" "node": ">= 0.8"
@@ -15026,6 +15038,42 @@
"react-native-reanimated": "*" "react-native-reanimated": "*"
} }
}, },
"node_modules/moti/node_modules/framer-motion": {
"version": "6.5.1",
"resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-6.5.1.tgz",
"integrity": "sha512-o1BGqqposwi7cgDrtg0dNONhkmPsUFDaLcKXigzuTFC5x58mE8iyTazxSudFzmT6MEyJKfjjU8ItoMe3W+3fiw==",
"license": "MIT",
"dependencies": {
"@motionone/dom": "10.12.0",
"framesync": "6.0.1",
"hey-listen": "^1.0.8",
"popmotion": "11.0.3",
"style-value-types": "5.0.0",
"tslib": "^2.1.0"
},
"optionalDependencies": {
"@emotion/is-prop-valid": "^0.8.2"
},
"peerDependencies": {
"react": ">=16.8 || ^17.0.0 || ^18.0.0",
"react-dom": ">=16.8 || ^17.0.0 || ^18.0.0"
}
},
"node_modules/motion-dom": {
"version": "12.23.23",
"resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz",
"integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==",
"license": "MIT",
"dependencies": {
"motion-utils": "^12.23.6"
}
},
"node_modules/motion-utils": {
"version": "12.23.6",
"resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz",
"integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==",
"license": "MIT"
},
"node_modules/ms": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -16329,37 +16377,6 @@
"node": ">= 0.10" "node": ">= 0.10"
} }
}, },
"node_modules/raw-body/node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/raw-body/node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/react": { "node_modules/react": {
"version": "19.2.1", "version": "19.2.1",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
@@ -17386,25 +17403,25 @@
} }
}, },
"node_modules/send": { "node_modules/send": {
"version": "0.19.0", "version": "0.19.2",
"resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
"integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"debug": "2.6.9", "debug": "2.6.9",
"depd": "2.0.0", "depd": "2.0.0",
"destroy": "1.2.0", "destroy": "1.2.0",
"encodeurl": "~1.0.2", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"etag": "~1.8.1", "etag": "~1.8.1",
"fresh": "0.5.2", "fresh": "~0.5.2",
"http-errors": "2.0.0", "http-errors": "~2.0.1",
"mime": "1.6.0", "mime": "1.6.0",
"ms": "2.1.3", "ms": "2.1.3",
"on-finished": "2.4.1", "on-finished": "~2.4.1",
"range-parser": "~1.2.1", "range-parser": "~1.2.1",
"statuses": "2.0.1" "statuses": "~2.0.2"
}, },
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
@@ -17427,6 +17444,16 @@
"license": "MIT", "license": "MIT",
"peer": true "peer": true
}, },
"node_modules/send/node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">= 0.8"
}
},
"node_modules/send/node_modules/on-finished": { "node_modules/send/node_modules/on-finished": {
"version": "2.4.1", "version": "2.4.1",
"resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
@@ -17441,9 +17468,9 @@
} }
}, },
"node_modules/send/node_modules/statuses": { "node_modules/send/node_modules/statuses": {
"version": "2.0.1", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==", "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"engines": { "engines": {
@@ -17461,16 +17488,16 @@
} }
}, },
"node_modules/serve-static": { "node_modules/serve-static": {
"version": "1.16.2", "version": "1.16.3",
"resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
"integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
"license": "MIT", "license": "MIT",
"peer": true, "peer": true,
"dependencies": { "dependencies": {
"encodeurl": "~2.0.0", "encodeurl": "~2.0.0",
"escape-html": "~1.0.3", "escape-html": "~1.0.3",
"parseurl": "~1.3.3", "parseurl": "~1.3.3",
"send": "0.19.0" "send": "~0.19.1"
}, },
"engines": { "engines": {
"node": ">= 0.8.0" "node": ">= 0.8.0"
@@ -18328,6 +18355,25 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/swiper": {
"version": "12.0.3",
"resolved": "https://registry.npmjs.org/swiper/-/swiper-12.0.3.tgz",
"integrity": "sha512-BHd6U1VPEIksrXlyXjMmRWO0onmdNPaTAFduzqR3pgjvi7KfmUCAm/0cj49u2D7B0zNjMw02TSeXfinC1hDCXg==",
"funding": [
{
"type": "patreon",
"url": "https://www.patreon.com/swiperjs"
},
{
"type": "open_collective",
"url": "http://opencollective.com/swiper"
}
],
"license": "MIT",
"engines": {
"node": ">= 4.7.0"
}
},
"node_modules/symbol-tree": { "node_modules/symbol-tree": {
"version": "3.2.4", "version": "3.2.4",
"resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz",

View File

@@ -90,6 +90,7 @@
"embla-carousel-autoplay": "^8.6.0", "embla-carousel-autoplay": "^8.6.0",
"embla-carousel-react": "^8.6.0", "embla-carousel-react": "^8.6.0",
"fabric": "^6.9.0", "fabric": "^6.9.0",
"framer-motion": "^12.23.26",
"globals": "^15.15.0", "globals": "^15.15.0",
"html5-qrcode": "^2.3.8", "html5-qrcode": "^2.3.8",
"i18next": "^25.7.2", "i18next": "^25.7.2",
@@ -104,6 +105,7 @@
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-i18next": "^16.4.1", "react-i18next": "^16.4.1",
"react-router-dom": "^7.10.1", "react-router-dom": "^7.10.1",
"swiper": "^12.0.3",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7",
@@ -118,5 +120,13 @@
}, },
"resolutions": { "resolutions": {
"react-native-web": "^0.19.12" "react-native-web": "^0.19.12"
},
"overrides": {
"react-tinder-card": {
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
}
}
} }
} }

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="100%" height="100%" fill="#e8e8f2"/><path fill="#ecc94b" d="m23.222 25.097-3.266-2.056-3.219 2.058.983-3.847-3.042-2.503 3.936-.18 1.52-3.668 1.342 3.578 3.846.312-2.996 2.505z"/><path fill="#f44034" d="m.133-5.1-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.06 3.264 2.057-.895-3.803L5.322-1.21l-3.845-.312zm40 0-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.059 3.264 2.057-.895-3.803 2.996-2.504-3.845-.312zm-40 40-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.059 3.264 2.057-.895-3.803 2.996-2.504-3.845-.312zm40 0-1.52 3.668-3.935.18 3.043 2.504-.985 3.848 3.221-2.059 3.264 2.057-.895-3.803 2.996-2.504-3.845-.312z"/></svg>

After

Width:  |  Height:  |  Size: 721 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="56.915" height="30"><rect width="100%" height="100%" fill="#e1e1ea"/><path fill="#ee2f69" d="M10.023 0c1.263 1.051 2.418 2.246 3.592 3.462 1.874 1.944 3.808 3.938 6.287 5.404-.94.552-1.8 1.18-2.606 1.856-.844-.785-1.66-1.625-2.452-2.444C11.22 4.525 7.476.646 0 .645v1.71c6.752.001 10.089 3.451 13.615 7.107.771.8 1.568 1.619 2.397 2.401a62 62 0 0 0-1.785 1.776C10.785 10.099 7.056 6.646 0 6.645v1.708c6.38.002 9.706 3.085 13.038 6.513a51 51 0 0 1-1.878 1.86C8.773 14.73 5.373 12.646 0 12.646v1.707c4.679.001 7.63 1.687 9.86 3.514-.97.793-2.009 1.5-3.173 2.066C4.652 19.07 2.46 18.646 0 18.646v1.706c1.494 0 2.872.171 4.17.512-1.24.332-2.61.517-4.17.517v1.71c7.477-.001 11.22-3.881 14.842-7.63 3.527-3.654 6.864-7.106 13.615-7.106s10.084 3.452 13.612 7.106c3.622 3.75 7.363 7.63 14.842 7.63h.004v-1.71h-.006c-1.56 0-2.932-.186-4.171-.517 1.294-.34 2.675-.512 4.17-.512h.007v-1.706h-.004c-2.466 0-4.654.427-6.686 1.287-1.164-.567-2.206-1.273-3.175-2.066 2.23-1.827 5.182-3.514 9.86-3.514h.005v-1.708h-.004c-5.375 0-8.777 2.084-11.16 4.081a50 50 0 0 1-1.88-1.86c3.33-3.425 6.657-6.513 13.04-6.513h.004V6.647h-.004c-7.052 0-10.785 3.449-14.23 6.99a54 54 0 0 0-1.786-1.774 73 73 0 0 0 2.397-2.4c3.528-3.658 6.864-7.108 13.619-7.108h.004V.645c-7.479 0-11.225 3.88-14.848 7.633-.793.819-1.606 1.66-2.45 2.444a19.4 19.4 0 0 0-2.612-1.86c2.482-1.461 4.415-3.46 6.293-5.404C44.472 2.243 45.628 1.051 46.89 0h-2.564a56 56 0 0 0-1.644 1.638A57 57 0 0 0 41.04 0h-2.563c1.058.878 2.037 1.854 3.017 2.865a57 57 0 0 1-1.877 1.864C37.23 2.732 33.83.647 28.457.647c-5.375 0-8.776 2.085-11.163 4.082a58 58 0 0 1-1.879-1.864c.98-1.01 1.957-1.988 3.016-2.865H15.87a56 56 0 0 0-1.642 1.638A58 58 0 0 0 12.583 0zm18.432 2.355c4.678 0 7.63 1.684 9.86 3.511-.967.79-2.003 1.49-3.167 2.061-1.871-.796-4.05-1.281-6.693-1.282-2.65 0-4.825.486-6.696 1.282-1.164-.567-2.198-1.272-3.165-2.057 2.23-1.83 5.18-3.515 9.861-3.515m.002 10.29c-7.479 0-11.224 3.879-14.847 7.628-2.134 2.213-4.16 4.306-6.916 5.651a15.8 15.8 0 0 0-3.792-1.063l-.134-.022q-.406-.061-.827-.101l-.143-.011a31 31 0 0 0-.703-.052l-.234-.009A17 17 0 0 0 0 24.644v1.708q.393.001.775.019l.211.01q.318.018.636.045c.041.004.089.005.13.009q.374.036.737.088.07.014.143.024.333.05.655.116l.083.014q.37.079.735.171.027.009.053.017.753.197 1.466.475h.007a13.4 13.4 0 0 1 1.789.847h.004c.864.484 1.71 1.079 2.591 1.813h2.568q-.072-.068-.141-.136c.833-.782 1.624-1.603 2.396-2.402 3.531-3.657 6.868-7.108 13.62-7.108 6.75 0 10.083 3.453 13.61 7.106a70 70 0 0 0 2.401 2.408q-.074.067-.141.132h2.562c2.534-2.11 5.516-3.646 10.02-3.646h.005v-1.71h-.002c-2.646 0-4.825.489-6.697 1.28-2.756-1.349-4.781-3.438-6.918-5.651-3.62-3.752-7.366-7.628-14.84-7.628zm-.002 1.708c6.751 0 10.084 3.453 13.616 7.107 1.875 1.942 3.806 3.94 6.288 5.405-.938.554-1.8 1.182-2.608 1.86-.847-.788-1.664-1.632-2.455-2.452-3.62-3.749-7.366-7.63-14.84-7.63-7.478 0-11.225 3.881-14.845 7.63a62 62 0 0 1-2.455 2.449 19.3 19.3 0 0 0-2.606-1.857c2.478-1.465 4.411-3.46 6.287-5.404 3.53-3.657 6.864-7.108 13.618-7.108m-.001 10.291c-5.953 0-9.538 2.46-12.581 5.356h2.556c2.534-2.11 5.52-3.648 10.027-3.648 4.504 0 7.485 1.538 10.018 3.648h2.56c-3.038-2.895-6.628-5.356-12.58-5.356"/></svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

View File

@@ -547,3 +547,17 @@ h4,
overscroll-behavior: contain; overscroll-behavior: contain;
background-color: #000; background-color: #000;
} }
@theme inline {
--animate-spin:
spin 2s linear infinite;
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -77,7 +77,10 @@ export type TenantEvent = {
active_invites_count?: number; active_invites_count?: number;
total_invites_count?: number; total_invites_count?: number;
engagement_mode?: 'tasks' | 'photo_only'; engagement_mode?: 'tasks' | 'photo_only';
settings?: Record<string, unknown> & { engagement_mode?: 'tasks' | 'photo_only' }; settings?: Record<string, unknown> & {
engagement_mode?: 'tasks' | 'photo_only';
guest_upload_visibility?: 'review' | 'immediate';
};
package?: { package?: {
id: number | string | null; id: number | string | null;
name: string | null; name: string | null;
@@ -1482,6 +1485,22 @@ export async function updatePhotoVisibility(slug: string, id: number, visible: b
return normalizePhoto(data.data); return normalizePhoto(data.data);
} }
export async function updatePhotoStatus(
slug: string,
id: number,
status: 'pending' | 'approved' | 'rejected'
): Promise<TenantPhoto> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/photos/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
});
const data = await jsonOrThrow<PhotoResponse>(response, 'Failed to update photo status');
return normalizePhoto(data.data);
}
export async function toggleEvent(slug: string): Promise<TenantEvent> { export async function toggleEvent(slug: string): Promise<TenantEvent> {
const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' }); const response = await authorizedFetch(`${eventEndpoint(slug)}/toggle`, { method: 'POST' });
const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event'); const data = await jsonOrThrow<{ message: string; data: JsonValue }>(response, 'Failed to toggle event');

View File

@@ -19,6 +19,7 @@ type FormState = {
description: string; description: string;
location: string; location: string;
published: boolean; published: boolean;
autoApproveUploads: boolean;
}; };
export default function MobileEventFormPage() { export default function MobileEventFormPage() {
@@ -35,6 +36,7 @@ export default function MobileEventFormPage() {
description: '', description: '',
location: '', location: '',
published: false, published: false,
autoApproveUploads: true,
}); });
const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]); const [eventTypes, setEventTypes] = React.useState<TenantEventType[]>([]);
const [typesLoading, setTypesLoading] = React.useState(false); const [typesLoading, setTypesLoading] = React.useState(false);
@@ -55,6 +57,8 @@ export default function MobileEventFormPage() {
description: typeof data.description === 'string' ? data.description : '', description: typeof data.description === 'string' ? data.description : '',
location: resolveLocation(data), location: resolveLocation(data),
published: data.status === 'published', published: data.status === 'published',
autoApproveUploads:
(data.settings?.guest_upload_visibility as string | undefined) === 'immediate',
}); });
setError(null); setError(null);
} catch (err) { } catch (err) {
@@ -95,7 +99,10 @@ export default function MobileEventFormPage() {
event_date: form.date || undefined, event_date: form.date || undefined,
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
status: form.published ? 'published' : 'draft', status: form.published ? 'published' : 'draft',
settings: { location: form.location }, settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
},
}); });
navigate(adminPath(`/mobile/events/${slug}`)); navigate(adminPath(`/mobile/events/${slug}`));
} else { } else {
@@ -105,7 +112,10 @@ export default function MobileEventFormPage() {
event_type_id: form.eventTypeId ?? undefined, event_type_id: form.eventTypeId ?? undefined,
event_date: form.date || undefined, event_date: form.date || undefined,
status: (form.published ? 'published' : 'draft') as const, status: (form.published ? 'published' : 'draft') as const,
settings: { location: form.location }, settings: {
location: form.location,
guest_upload_visibility: form.autoApproveUploads ? 'immediate' : 'review',
},
}; };
const { event } = await createEvent(payload as any); const { event } = await createEvent(payload as any);
navigate(adminPath(`/mobile/events/${event.slug}`)); navigate(adminPath(`/mobile/events/${event.slug}`));
@@ -217,6 +227,37 @@ export default function MobileEventFormPage() {
</XStack> </XStack>
<Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text> <Text fontSize="$xs" color="#6b7280">{t('eventForm.fields.publish.help', 'Enable if guests should see the event right away. You can change the status later.')}</Text>
</Field> </Field>
<Field label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}>
<XStack alignItems="center" space="$2">
<Switch
checked={form.autoApproveUploads}
onCheckedChange={(checked) =>
setForm((prev) => ({ ...prev, autoApproveUploads: Boolean(checked) }))
}
size="$3"
aria-label={t('eventForm.fields.uploadVisibility.label', 'Uploads visible immediately')}
>
<Switch.Thumb />
</Switch>
<Text fontSize="$sm" color="#111827">
{form.autoApproveUploads
? t('common:states.enabled', 'Enabled')
: t('common:states.disabled', 'Disabled')}
</Text>
</XStack>
<Text fontSize="$xs" color="#6b7280">
{form.autoApproveUploads
? t(
'eventForm.fields.uploadVisibility.helpOn',
'Neue Gast-Uploads erscheinen sofort in der Galerie (Security-Scan läuft im Hintergrund).',
)
: t(
'eventForm.fields.uploadVisibility.helpOff',
'Uploads werden zunächst geprüft und erscheinen nach Freigabe.',
)}
</Text>
</Field>
</MobileCard> </MobileCard>
<YStack space="$2"> <YStack space="$2">

View File

@@ -12,6 +12,7 @@ import {
updatePhotoVisibility, updatePhotoVisibility,
featurePhoto, featurePhoto,
unfeaturePhoto, unfeaturePhoto,
updatePhotoStatus,
TenantPhoto, TenantPhoto,
EventAddonCatalogItem, EventAddonCatalogItem,
createEventAddonCheckout, createEventAddonCheckout,
@@ -30,7 +31,7 @@ import { buildLimitWarnings } from '../lib/limitWarnings';
import { adminPath } from '../constants'; import { adminPath } from '../constants';
import { scopeDefaults, selectAddonKeyForScope } from './addons'; import { scopeDefaults, selectAddonKeyForScope } from './addons';
type FilterKey = 'all' | 'featured' | 'hidden'; type FilterKey = 'all' | 'featured' | 'hidden' | 'pending';
export default function MobileEventPhotosPage() { export default function MobileEventPhotosPage() {
const { slug: slugParam } = useParams<{ slug?: string }>(); const { slug: slugParam } = useParams<{ slug?: string }>();
@@ -92,12 +93,18 @@ export default function MobileEventPhotosPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
try { try {
const status =
filter === 'hidden' || onlyHidden
? 'hidden'
: filter === 'pending'
? 'pending'
: undefined;
const result = await getEventPhotos(slug, { const result = await getEventPhotos(slug, {
page, page,
perPage: 20, perPage: 20,
sort: 'desc', sort: 'desc',
featured: filter === 'featured' || onlyFeatured, featured: filter === 'featured' || onlyFeatured,
status: filter === 'hidden' || onlyHidden ? 'hidden' : undefined, status,
search: search || undefined, search: search || undefined,
}); });
setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos])); setPhotos((prev) => (page === 1 ? result.photos : [...prev, ...result.photos]));
@@ -184,6 +191,24 @@ export default function MobileEventPhotosPage() {
} }
} }
async function approvePhoto(photo: TenantPhoto) {
if (!slug) return;
setBusyId(photo.id);
try {
const updated = await updatePhotoStatus(slug, photo.id, 'approved');
setPhotos((prev) => prev.map((p) => (p.id === photo.id ? updated : p)));
setLightbox((prev) => (prev && prev.id === photo.id ? updated : prev));
toast.success(t('mobilePhotos.approveSuccess', 'Photo approved'));
} catch (err) {
if (!isAuthError(err)) {
setError(getApiErrorMessage(err, t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.')));
toast.error(t('mobilePhotos.approveFailed', 'Freigabe fehlgeschlagen.'));
}
} finally {
setBusyId(null);
}
}
return ( return (
<MobileShell <MobileShell
activeTab="uploads" activeTab="uploads"
@@ -219,8 +244,8 @@ export default function MobileEventPhotosPage() {
style={{ ...baseInputStyle, marginBottom: 12 }} style={{ ...baseInputStyle, marginBottom: 12 }}
/> />
<XStack space="$2"> <XStack space="$2" flexWrap="wrap">
{(['all', 'featured', 'hidden'] as FilterKey[]).map((key) => ( {(['all', 'featured', 'pending', 'hidden'] as FilterKey[]).map((key) => (
<Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}> <Pressable key={key} onPress={() => setFilter(key)} style={{ flex: 1 }}>
<MobileCard <MobileCard
backgroundColor={filter === key ? infoBg : surface} backgroundColor={filter === key ? infoBg : surface}
@@ -232,6 +257,8 @@ export default function MobileEventPhotosPage() {
? t('common.all', 'All') ? t('common.all', 'All')
: key === 'featured' : key === 'featured'
? t('photos.filters.featured', 'Featured') ? t('photos.filters.featured', 'Featured')
: key === 'pending'
? t('photos.filters.pending', 'Pending')
: t('photos.filters.hidden', 'Hidden')} : t('photos.filters.hidden', 'Hidden')}
</Text> </Text>
</MobileCard> </MobileCard>
@@ -283,6 +310,9 @@ export default function MobileEventPhotosPage() {
/> />
<XStack position="absolute" top={6} left={6} space="$1"> <XStack position="absolute" top={6} left={6} space="$1">
{photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null} {photo.is_featured ? <PillBadge tone="warning">{t('photos.filters.featured', 'Featured')}</PillBadge> : null}
{photo.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null} {photo.status === 'hidden' ? <PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge> : null}
</XStack> </XStack>
</YStack> </YStack>
@@ -317,8 +347,25 @@ export default function MobileEventPhotosPage() {
<XStack space="$2" alignItems="center"> <XStack space="$2" alignItems="center">
<PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge> <PillBadge tone="muted">{lightbox.uploader_name || t('events.members.roles.guest', 'Guest')}</PillBadge>
<PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge> <PillBadge tone="muted"> {lightbox.likes_count ?? 0}</PillBadge>
{lightbox.status === 'pending' ? (
<PillBadge tone="warning">{t('photos.filters.pending', 'Pending')}</PillBadge>
) : null}
{lightbox.status === 'hidden' ? (
<PillBadge tone="muted">{t('photos.filters.hidden', 'Hidden')}</PillBadge>
) : null}
</XStack> </XStack>
<XStack space="$2" flexWrap="wrap"> <XStack space="$2" flexWrap="wrap">
{lightbox.status === 'pending' ? (
<CTAButton
label={
busyId === lightbox.id
? t('common.processing', '...')
: t('photos.actions.approve', 'Approve')
}
onPress={() => approvePhoto(lightbox)}
style={{ flex: 1, minWidth: 140 }}
/>
) : null}
<CTAButton <CTAButton
label={ label={
busyId === lightbox.id busyId === lightbox.id

View File

@@ -0,0 +1,148 @@
'use client';
import React from "react"
import { motion } from "framer-motion"
import { cn } from "@/lib/utils"
export interface BackgroundBeamsProps {
className?: string;
}
export const BackgroundBeams = React.memo(
({ className }: BackgroundBeamsProps) => {
const paths = [
"M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875",
"M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867",
"M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859",
"M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851",
"M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843",
"M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835",
"M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827",
"M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819",
"M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811",
"M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803",
"M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795",
"M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787",
"M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779",
"M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771",
"M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763",
"M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755",
"M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747",
"M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739",
"M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731",
"M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723",
"M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715",
"M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707",
"M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699",
"M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691",
"M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683",
"M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675",
"M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667",
"M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659",
"M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651",
"M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643",
"M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635",
"M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627",
"M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619",
"M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611",
"M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603",
"M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595",
"M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587",
"M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579",
"M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571",
"M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563",
"M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555",
"M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547",
"M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539",
"M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531",
"M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523",
"M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515",
"M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507",
"M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499",
"M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491",
"M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483",
]
return (
<div
className={cn(
"absolute h-full w-full inset-0 [mask-size:40px] [mask-repeat:no-repeat] flex items-center justify-center",
className,
)}
>
<svg
className="z-0 h-full w-full pointer-events-none absolute"
width="100%"
height="100%"
viewBox="0 0 696 316"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M-380 -189C-380 -189 -312 216 152 343C616 470 684 875 684 875M-373 -197C-373 -197 -305 208 159 335C623 462 691 867 691 867M-366 -205C-366 -205 -298 200 166 327C630 454 698 859 698 859M-359 -213C-359 -213 -291 192 173 319C637 446 705 851 705 851M-352 -221C-352 -221 -284 184 180 311C644 438 712 843 712 843M-345 -229C-345 -229 -277 176 187 303C651 430 719 835 719 835M-338 -237C-338 -237 -270 168 194 295C658 422 726 827 726 827M-331 -245C-331 -245 -263 160 201 287C665 414 733 819 733 819M-324 -253C-324 -253 -256 152 208 279C672 406 740 811 740 811M-317 -261C-317 -261 -249 144 215 271C679 398 747 803 747 803M-310 -269C-310 -269 -242 136 222 263C686 390 754 795 754 795M-303 -277C-303 -277 -235 128 229 255C693 382 761 787 761 787M-296 -285C-296 -285 -228 120 236 247C700 374 768 779 768 779M-289 -293C-289 -293 -221 112 243 239C707 366 775 771 775 771M-282 -301C-282 -301 -214 104 250 231C714 358 782 763 782 763M-275 -309C-275 -309 -207 96 257 223C721 350 789 755 789 755M-268 -317C-268 -317 -200 88 264 215C728 342 796 747 796 747M-261 -325C-261 -325 -193 80 271 207C735 334 803 739 803 739M-254 -333C-254 -333 -186 72 278 199C742 326 810 731 810 731M-247 -341C-247 -341 -179 64 285 191C749 318 817 723 817 723M-240 -349C-240 -349 -172 56 292 183C756 310 824 715 824 715M-233 -357C-233 -357 -165 48 299 175C763 302 831 707 831 707M-226 -365C-226 -365 -158 40 306 167C770 294 838 699 838 699M-219 -373C-219 -373 -151 32 313 159C777 286 845 691 845 691M-212 -381C-212 -381 -144 24 320 151C784 278 852 683 852 683M-205 -389C-205 -389 -137 16 327 143C791 270 859 675 859 675M-198 -397C-198 -397 -130 8 334 135C798 262 866 667 866 667M-191 -405C-191 -405 -123 0 341 127C805 254 873 659 873 659M-184 -413C-184 -413 -116 -8 348 119C812 246 880 651 880 651M-177 -421C-177 -421 -109 -16 355 111C819 238 887 643 887 643M-170 -429C-170 -429 -102 -24 362 103C826 230 894 635 894 635M-163 -437C-163 -437 -95 -32 369 95C833 222 901 627 901 627M-156 -445C-156 -445 -88 -40 376 87C840 214 908 619 908 619M-149 -453C-149 -453 -81 -48 383 79C847 206 915 611 915 611M-142 -461C-142 -461 -74 -56 390 71C854 198 922 603 922 603M-135 -469C-135 -469 -67 -64 397 63C861 190 929 595 929 595M-128 -477C-128 -477 -60 -72 404 55C868 182 936 587 936 587M-121 -485C-121 -485 -53 -80 411 47C875 174 943 579 943 579M-114 -493C-114 -493 -46 -88 418 39C882 166 950 571 950 571M-107 -501C-107 -501 -39 -96 425 31C889 158 957 563 957 563M-100 -509C-100 -509 -32 -104 432 23C896 150 964 555 964 555M-93 -517C-93 -517 -25 -112 439 15C903 142 971 547 971 547M-86 -525C-86 -525 -18 -120 446 7C910 134 978 539 978 539M-79 -533C-79 -533 -11 -128 453 -1C917 126 985 531 985 531M-72 -541C-72 -541 -4 -136 460 -9C924 118 992 523 992 523M-65 -549C-65 -549 3 -144 467 -17C931 110 999 515 999 515M-58 -557C-58 -557 10 -152 474 -25C938 102 1006 507 1006 507M-51 -565C-51 -565 17 -160 481 -33C945 94 1013 499 1013 499M-44 -573C-44 -573 24 -168 488 -41C952 86 1020 491 1020 491M-37 -581C-37 -581 31 -176 495 -49C959 78 1027 483 1027 483"
stroke="url(#paint0_radial_242_278)"
strokeOpacity="0.05"
strokeWidth="0.5"
/>
{paths.map((path, index) => (
<motion.path
key={`path-${index}`}
d={path}
stroke={`url(#linearGradient-${index})`}
strokeOpacity="0.4"
strokeWidth="0.5"
/>
))}
<defs>
{paths.map((path, index) => (
<motion.linearGradient
id={`linearGradient-${index}`}
key={`gradient-${index}`}
initial={{
x1: "0%",
x2: "0%",
y1: "0%",
y2: "0%",
}}
animate={{
x1: ["0%", "100%"],
x2: ["0%", "95%"],
y1: ["0%", "100%"],
y2: ["0%", `${93 + Math.random() * 8}%`],
}}
transition={{
duration: Math.random() * 10 + 10,
ease: "easeInOut",
repeat: Infinity,
delay: Math.random() * 10,
}}
>
<stop stopColor="var(--beam-primary, #18CCFC)" stopOpacity="0" />
<stop stopColor="var(--beam-primary, #18CCFC)" />
<stop offset="32.5%" stopColor="var(--beam-secondary, #6344F5)" />
<stop offset="100%" stopColor="var(--beam-secondary, #AE48FF)" stopOpacity="0" />
</motion.linearGradient>
))}
<radialGradient
id="paint0_radial_242_278"
cx="0"
cy="0"
r="1"
gradientUnits="userSpaceOnUse"
gradientTransform="translate(352 34) rotate(90) scale(555 1560.62)"
>
<stop offset="0.0666667" stopColor="var(--neutral-300)" />
<stop offset="0.243243" stopColor="var(--neutral-300)" />
<stop offset="0.43594" stopColor="white" stopOpacity="0" />
</radialGradient>
</defs>
</svg>
</div>
)
},
)
BackgroundBeams.displayName = "BackgroundBeams"

View File

@@ -0,0 +1,150 @@
import { useCallback, useState } from 'react';
import { compressPhoto, formatBytes } from '../lib/image';
import { uploadPhoto, type UploadError } from '../services/photosApi';
import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
type DirectUploadResult = {
success: boolean;
photoId?: number;
warning?: string | null;
error?: string | null;
dialog?: UploadErrorDialog | null;
};
type UseDirectUploadOptions = {
eventToken: string;
taskId?: number | null;
emotionSlug?: string;
onCompleted?: (photoId: number) => void;
};
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
const { name } = useGuestIdentity();
const { markCompleted } = useGuestTaskProgress(eventToken);
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [warning, setWarning] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
const [canUpload, setCanUpload] = useState(true);
const reset = useCallback(() => {
setProgress(0);
setWarning(null);
setError(null);
setErrorDialog(null);
}, []);
const preparePhoto = useCallback(async (file: File) => {
reset();
let prepared = file;
try {
prepared = await compressPhoto(file, {
maxEdge: 2400,
targetBytes: 4_000_000,
qualityStart: 0.82,
});
if (prepared.size < file.size - 50_000) {
const saved = formatBytes(file.size - prepared.size);
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
}
} catch (err) {
console.warn('Direct upload: optimization failed, using original', err);
setWarning('Optimierung nicht möglich wir laden das Original hoch.');
}
if (prepared.size > 12_000_000) {
setError('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
return { ok: false as const };
}
return { ok: true as const, prepared };
}, [reset]);
const upload = useCallback(
async (file: File): Promise<DirectUploadResult> => {
if (!canUpload || uploading) return { success: false, warning, error };
const preparedResult = await preparePhoto(file);
if (!preparedResult.ok) {
return { success: false, warning, error };
}
const prepared = preparedResult.prepared;
setUploading(true);
setProgress(2);
setError(null);
setErrorDialog(null);
try {
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
maxRetries: 2,
guestName: name || undefined,
onProgress: (percent) => {
setProgress(Math.max(10, Math.min(98, percent)));
},
onRetry: (attempt) => {
setWarning(`Verbindung holperig neuer Versuch (${attempt}).`);
},
});
setProgress(100);
if (taskId) {
markCompleted(taskId);
}
try {
const raw = localStorage.getItem('my-photo-ids');
const arr: number[] = raw ? JSON.parse(raw) : [];
if (photoId && !arr.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
}
} catch (persistErr) {
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
}
onCompleted?.(photoId);
return { success: true, photoId, warning };
} catch (err) {
console.error('Direct upload failed', err);
const uploadErr = err as UploadError;
const meta = uploadErr.meta as Record<string, unknown> | undefined;
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
setErrorDialog(dialog);
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
setWarning(null);
if (
uploadErr.code === 'photo_limit_exceeded'
|| uploadErr.code === 'upload_device_limit'
|| uploadErr.code === 'event_package_missing'
|| uploadErr.code === 'event_not_found'
|| uploadErr.code === 'gallery_expired'
) {
setCanUpload(false);
}
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
setWarning('Das Foto war zu groß. Bitte erneut versuchen wir verkleinern es automatisch.');
}
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
} finally {
setUploading(false);
setProgress((p) => (p === 100 ? p : 0));
}
},
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
);
return {
upload,
uploading,
progress,
warning,
error,
errorDialog,
reset,
};
}

View File

@@ -0,0 +1,128 @@
export type EmotionTheme = {
gradientClass: string;
gradientBackground: string;
suggestionGradient: string;
suggestionBorder: string;
};
export type EmotionIdentity = {
slug?: string | null;
name?: string | null;
};
const themeFreude: EmotionTheme = {
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
};
const themeLiebe: EmotionTheme = {
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
};
const themeEkstase: EmotionTheme = {
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
};
const themeEntspannt: EmotionTheme = {
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
};
const themeBesinnlich: EmotionTheme = {
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
};
const themeUeberraschung: EmotionTheme = {
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
};
const themeDefault: EmotionTheme = {
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
};
const EMOTION_THEMES: Record<string, EmotionTheme> = {
freude: themeFreude,
happy: themeFreude,
liebe: themeLiebe,
romance: themeLiebe,
romantik: themeLiebe,
nostalgie: themeEntspannt,
relaxed: themeEntspannt,
ruehrung: themeBesinnlich,
traurigkeit: themeBesinnlich,
teamgeist: themeFreude,
gemeinschaft: themeFreude,
ueberraschung: themeUeberraschung,
surprise: themeUeberraschung,
ekstase: themeEkstase,
excited: themeEkstase,
besinnlichkeit: themeBesinnlich,
sad: themeBesinnlich,
default: themeDefault,
};
const EMOTION_ICONS: Record<string, string> = {
freude: '😊',
happy: '😊',
liebe: '❤️',
romantik: '💞',
nostalgie: '📼',
ruehrung: '🥲',
teamgeist: '🤝',
ueberraschung: '😲',
surprise: '😲',
ekstase: '🤩',
besinnlichkeit: '🕯️',
};
function sluggify(value?: string | null): string {
return (value ?? '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
if (!identity) return 'default';
const nameKey = sluggify(identity.name);
if (nameKey && EMOTION_THEMES[nameKey]) {
return nameKey;
}
const slugKey = sluggify(identity.slug);
if (slugKey && EMOTION_THEMES[slugKey]) {
return slugKey;
}
return 'default';
}
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
const key = resolveEmotionKey(identity);
return EMOTION_THEMES[key] ?? themeDefault;
}
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
const key = resolveEmotionKey(identity);
return EMOTION_ICONS[key] ?? '✨';
}

View File

@@ -2,18 +2,28 @@
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
import { Progress } from '@/components/ui/progress';
import { Skeleton } from '@/components/ui/skeleton';
import { Separator } from '@/components/ui/separator'; import { Separator } from '@/components/ui/separator';
import EmotionPicker from '../components/EmotionPicker'; import EmotionPicker from '../components/EmotionPicker';
import GalleryPreview from '../components/GalleryPreview'; import GalleryPreview from '../components/GalleryPreview';
import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData'; import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress'; import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react'; import { Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding'; import type { EventBranding } from '../types/event-branding';
import { animated, useSpring } from '@react-spring/web'; import { Swiper, SwiperSlide } from 'swiper/react';
import { useGesture } from '@use-gesture/react'; import { EffectCards } from 'swiper/modules';
import 'swiper/css';
import 'swiper/css/effect-cards';
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
import { useDirectUpload } from '../hooks/useDirectUpload';
import { useNavigate } from 'react-router-dom';
export default function HomePage() { export default function HomePage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -60,87 +70,200 @@ export default function HomePage() {
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName'); const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
const accentColor = branding.primaryColor; const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor; const secondaryAccent = branding.secondaryColor;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]); const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
const [missionLoading, setMissionLoading] = React.useState(false); const [missionLoading, setMissionLoading] = React.useState(false);
const missionPoolRef = React.useRef<MissionPreview[]>([]); const [isLoadingMore, setIsLoadingMore] = React.useState(false);
const [hasMore, setHasMore] = React.useState(true);
const drawRandom = React.useCallback((excludeIds: Set<number>) => { const [page, setPage] = React.useState(1);
const pool = missionPoolRef.current.filter((item) => !excludeIds.has(item.id)); const [swipeCount, setSwipeCount] = React.useState(0);
if (!pool.length) return null; const seenIdsRef = React.useRef<Set<number>>(new Set());
return pool[Math.floor(Math.random() * pool.length)]; const poolIndexRef = React.useRef(0);
}, []); const sliderStateKey = token ? `missionSliderIndex:${token}` : null;
const swiperRef = React.useRef<any>(null);
const resetDeck = React.useCallback(() => {
const pool = missionPoolRef.current;
if (!pool.length) {
setMissionDeck([]);
return;
}
const shuffled = [...pool].sort(() => Math.random() - 0.5);
setMissionDeck(shuffled.slice(0, 4));
}, []);
const advanceDeck = React.useCallback(() => { const advanceDeck = React.useCallback(() => {
setMissionDeck((prev) => { if (swiperRef.current) {
if (!prev.length) return prev; swiperRef.current.slideNext();
const [, ...rest] = prev; }
const exclude = new Set(rest.map((r) => r.id)); }, []);
const nextCandidate = drawRandom(exclude);
const replenished = nextCandidate ? [...rest, nextCandidate] : rest;
return replenished;
});
}, [drawRandom]);
React.useEffect(() => { const normalizeTasks = React.useCallback(
(tasks: Record<string, unknown>[]): MissionPreview[] =>
tasks.map((task) => ({
id: Number(task.id),
title: typeof task.title === 'string' ? task.title : 'Mission',
description: typeof task.description === 'string' ? task.description : '',
duration: typeof task.duration === 'number' ? task.duration : 3,
emotion: (task.emotion as EmotionIdentity) ?? null,
})),
[]
);
const mergeIntoPool = React.useCallback(
(incoming: MissionPreview[]) => {
const slugTitle = (title: string) =>
title
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, ' ')
.replace(/\s+/g, ' ')
.trim();
setMissionPool((prev) => {
const byId = new Map<number, MissionPreview>();
const byTitle = new Map<string, MissionPreview>();
const addCandidate = (candidate: MissionPreview) => {
if (byId.has(candidate.id)) return;
const titleKey = slugTitle(candidate.title);
if (byTitle.has(titleKey)) return;
byId.set(candidate.id, candidate);
byTitle.set(titleKey, candidate);
};
prev.forEach(addCandidate);
incoming.forEach(addCandidate);
return Array.from(byId.values());
});
},
[]
);
const fetchTasksPage = React.useCallback(
async (pageToFetch: number, isInitial: boolean = false) => {
if (!token) return; if (!token) return;
let cancelled = false; if (isInitial) {
async function loadMissions() {
setMissionLoading(true); setMissionLoading(true);
}
setIsLoadingMore(true);
try { try {
const safeToken = token ?? ''; const perPage = 20;
const response = await fetch( const response = await fetch(
`/api/v1/events/${encodeURIComponent(safeToken)}/tasks?locale=${encodeURIComponent(locale)}`, `/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`,
{ {
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'X-Locale': locale, 'X-Locale': locale,
'X-Device-Id': getDeviceId(),
}, },
} }
); );
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json(); const payload = await response.json();
if (cancelled) return;
if (Array.isArray(payload) && payload.length) { let items: Record<string, unknown>[] = [];
missionPoolRef.current = payload.map((task: Record<string, unknown>) => ({ let hasMoreFlag = false;
id: Number(task.id), let nextPage = pageToFetch + 1;
title: typeof task.title === 'string' ? task.title : 'Mission',
description: typeof task.description === 'string' ? task.description : '', if (Array.isArray(payload)) {
duration: typeof task.duration === 'number' ? task.duration : 3, items = payload;
emotion: task.emotion ?? null, hasMoreFlag = false;
})); } else if (payload && Array.isArray(payload.tasks)) {
resetDeck(); items = payload.tasks;
hasMoreFlag = false;
} else if (payload && Array.isArray(payload.data)) {
items = payload.data;
if (payload.meta?.current_page && payload.meta?.last_page) {
hasMoreFlag = payload.meta.current_page < payload.meta.last_page;
nextPage = (payload.meta.current_page as number) + 1;
} else if (payload.next_page_url !== undefined) {
hasMoreFlag = Boolean(payload.next_page_url);
} else { } else {
missionPoolRef.current = []; hasMoreFlag = items.length > 0;
setMissionDeck([]); }
} else {
hasMoreFlag = false;
}
const normalized = normalizeTasks(items);
const deduped = normalized.filter((task) => {
if (seenIdsRef.current.has(task.id)) return false;
seenIdsRef.current.add(task.id);
return true;
});
if (deduped.length) {
mergeIntoPool(deduped);
}
setHasMore(hasMoreFlag);
if (hasMoreFlag) {
setPage(nextPage);
} }
} catch (err) { } catch (err) {
if (!cancelled) { console.warn('Mission fetch failed', err);
console.warn('Mission preview failed', err); setHasMore(false);
missionPoolRef.current = [];
setMissionDeck([]);
}
} finally { } finally {
if (!cancelled) { setIsLoadingMore(false);
if (isInitial) {
setMissionLoading(false); setMissionLoading(false);
} }
} }
},
[locale, normalizeTasks, token]
);
React.useEffect(() => {
if (!token) return;
seenIdsRef.current = new Set();
setMissionDeck([]);
setMissionPool([]);
setPage(1);
setHasMore(true);
// restore persisted slider position for this event
let restoredIndex = 0;
if (sliderStateKey && typeof window !== 'undefined') {
try {
const stored = window.sessionStorage.getItem(sliderStateKey);
if (stored) {
const parsed = Number(stored);
if (!Number.isNaN(parsed) && parsed >= 0) {
restoredIndex = parsed;
} }
loadMissions(); }
return () => { } catch {
cancelled = true; restoredIndex = 0;
}; }
}, [resetDeck, token, locale]); }
poolIndexRef.current = restoredIndex;
fetchTasksPage(1, true);
}, [fetchTasksPage, locale, sliderStateKey, token]);
React.useEffect(() => {
if (missionPool.length === 0) return;
if (poolIndexRef.current >= missionPool.length) {
poolIndexRef.current = poolIndexRef.current % missionPool.length;
}
setMissionDeck(missionPool);
}, [missionPool]);
React.useEffect(() => {
if (!swiperRef.current) return;
if (!missionDeck.length) return;
const target = poolIndexRef.current % missionDeck.length;
const inst = swiperRef.current;
if (typeof inst.slideToLoop === 'function') {
inst.slideToLoop(target, 0);
} else if (typeof inst.slideTo === 'function') {
inst.slideTo(target, 0);
}
}, [missionDeck.length]);
React.useEffect(() => {
if (missionLoading) return;
if (!hasMore || isLoadingMore) return;
// Prefetch when we are within 6 items of the end of the current pool
const remaining = missionPool.length - poolIndexRef.current;
if (remaining <= 6) {
fetchTasksPage(page);
}
}, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]);
if (!token) { if (!token) {
return null; return null;
@@ -189,9 +312,28 @@ export default function HomePage() {
mission={missionDeck[0] ?? null} mission={missionDeck[0] ?? null}
loading={missionLoading} loading={missionLoading}
onAdvance={advanceDeck} onAdvance={advanceDeck}
stack={missionDeck.slice(0, 3)} stack={missionDeck.slice(1)}
initialIndex={poolIndexRef.current}
onIndexChange={(idx, total) => {
poolIndexRef.current = idx % Math.max(1, total);
if (sliderStateKey && typeof window !== 'undefined') {
try {
window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current));
} catch {
// ignore storage errors
}
}
}}
swiperRef={swiperRef}
/>
<UploadActionCard
token={token}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
radius={radius}
bodyFont={bodyFont}
requiresApproval={uploadsRequireApproval}
/> />
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
<EmotionActionCard /> <EmotionActionCard />
</div> </div>
</section> </section>
@@ -273,7 +415,7 @@ type MissionPreview = {
title: string; title: string;
description?: string; description?: string;
duration?: number; duration?: number;
emotion?: { name?: string; slug?: string } | null; emotion?: EmotionIdentity | null;
}; };
function MissionActionCard({ function MissionActionCard({
@@ -282,207 +424,130 @@ function MissionActionCard({
loading, loading,
onAdvance, onAdvance,
stack, stack,
initialIndex,
onIndexChange,
swiperRef,
}: { }: {
token: string; token: string;
mission: MissionPreview | null; mission: MissionPreview | null;
loading: boolean; loading: boolean;
onAdvance: () => void; onAdvance: () => void;
stack: MissionPreview[]; stack: MissionPreview[];
initialIndex: number;
onIndexChange: (index: number, total: number) => void;
swiperRef: React.MutableRefObject<any>;
}) { }) {
const { branding } = useEventBranding(); const { branding } = useEventBranding();
const radius = branding.buttons?.radius ?? 12; const radius = branding.buttons?.radius ?? 12;
const primary = branding.buttons?.primary ?? branding.primaryColor; const primary = branding.buttons?.primary ?? branding.primaryColor;
const secondary = branding.buttons?.secondary ?? branding.secondaryColor; const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
const buttonStyle = branding.buttons?.style ?? 'filled';
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined; const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const textColor = '#1f2937'; const cards = mission ? [mission, ...stack] : stack;
const subTextColor = '#334155'; const shellRadius = `${radius + 10}px`;
const swipeThreshold = 120;
const stackLayers = stack.slice(1, 4);
const cardStyle: React.CSSProperties = { const renderCardContent = (card: MissionPreview | null) => {
borderRadius: `${radius + 8}px`, const theme = getEmotionTheme(card?.emotion ?? null);
backgroundColor: '#fcf7ef', const emotionIcon = getEmotionIcon(card?.emotion ?? null);
backgroundImage: `linear-gradient(0deg, ${primary}33, ${primary}22), url(/patterns/rays-sunburst.svg)`, const durationMinutes = card?.duration ?? 3;
backgroundBlendMode: 'multiply, normal', const progressValue = Math.min(100, Math.max(20, (durationMinutes / 8) * 100));
backgroundSize: '330% 330%', const titleFont = headingFont ? { fontFamily: headingFont } : undefined;
backgroundPosition: 'center', const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`;
backgroundRepeat: 'no-repeat',
filter: 'contrast(1.12) saturate(1.1)', return (
border: `1px solid ${primary}26`, <div
boxShadow: `0 12px 28px ${primary}22, 0 2px 6px ${primary}1f`, className="relative isolate overflow-hidden"
style={{
borderRadius: shellRadius,
background: gradientBackground,
boxShadow: `0 18px 50px ${primary}35, 0 6px 18px ${primary}22`,
fontFamily: bodyFont, fontFamily: bodyFont,
position: 'relative',
overflow: 'hidden',
};
const [{ x, y, rotateZ, rotateY, rotateX, scale, opacity }, api] = useSpring(() => ({
x: 0,
y: 0,
rotateZ: 0,
rotateY: 0,
rotateX: 0,
scale: 1,
opacity: 1,
config: { tension: 320, friction: 26 },
}));
React.useEffect(() => {
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
}, [mission?.id, api]);
const bind = useGesture(
{
onDrag: ({ active, movement: [mx, my], velocity: [vx], direction: [dx], cancel }) => {
if (active && Math.abs(mx) > swipeThreshold) {
cancel?.();
api.start({
x: dx > 0 ? 520 : -520,
y: my,
rotateZ: dx > 0 ? 12 : -12,
rotateY: dx > 0 ? 18 : -18,
rotateX: -my / 10,
opacity: 0,
scale: 1,
immediate: false,
onRest: () => {
onAdvance();
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, opacity: 1, scale: 1, immediate: false });
},
});
return;
}
api.start({
x: mx,
y: my,
rotateZ: mx / 18,
rotateY: mx / 28,
rotateX: -my / 36,
scale: active ? 1.02 : 1,
opacity: 1,
immediate: false,
});
},
onDragEnd: () => {
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
},
},
{
drag: {
filterTaps: true,
bounds: { left: -200, right: 200, top: -120, bottom: 120 },
rubberband: true,
},
}
);
return (
<Card className="border-0 shadow-none bg-transparent" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[280px]">
{stackLayers.map((item, index) => {
const depth = index + 1;
const scaleDown = 1 - depth * 0.03;
const translateY = depth * 12;
const fade = Math.max(0.25, 0.55 - depth * 0.08);
return (
<div
key={item.id ?? index}
className="absolute inset-0 pointer-events-none"
style={{
...cardStyle,
transform: `translateY(${translateY}px) scale(${scaleDown})`,
opacity: fade,
filter: 'brightness(0.96) contrast(0.98)',
}} }}
aria-hidden
/>
);
})}
<animated.div
className="relative overflow-hidden touch-pan-y"
style={{
...cardStyle,
x,
y,
rotateZ,
rotateY,
rotateX,
scale,
opacity,
transformOrigin: 'center center',
willChange: 'transform',
}}
{...bind()}
> >
<div <div
className="absolute inset-x-0 top-0 h-12" className="absolute inset-0 opacity-25"
style={{ style={{
background: `linear-gradient(90deg, ${secondary}80, ${primary}cc)`, backgroundImage:
filter: 'saturate(1.05)', 'linear-gradient(90deg, rgba(255,255,255,0.18) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,0.18) 1px, transparent 1px)',
backgroundSize: '26px 26px',
}} }}
aria-hidden
/> />
<div className="relative z-10 flex flex-col gap-3 px-5 pb-4 pt-1"> <div className="absolute -left-12 -top-10 h-32 w-32 rounded-full bg-white/40 blur-3xl" />
<div className="flex items-center justify-between"> <div className="absolute -right-10 bottom-0 h-28 w-28 rounded-full bg-white/25 blur-3xl" />
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div <div className="relative">
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/90 text-foreground shadow-sm" <div className="absolute inset-0 rounded-full bg-white/60 blur-xl" />
style={{ borderRadius: `${radius - 2}px` }} <Avatar className="relative h-12 w-12 border border-white/50 bg-white/80 shadow-md">
> <AvatarFallback className="text-xl">{emotionIcon}</AvatarFallback>
<Sparkles className="h-5 w-5" aria-hidden /> </Avatar>
</div> </div>
<div> <div className="space-y-1">
<p <Badge
className="text-xs font-semibold uppercase tracking-wide" className="border-0 text-[11px] font-semibold uppercase tracking-wide text-white shadow"
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: subTextColor }} style={{ backgroundImage: gradientBackground }}
> >
Fotoaufgabe {card?.emotion?.name ?? 'Fotoaufgabe'}
</p> </Badge>
<p className="text-sm" style={{ color: subTextColor }}> <div className="flex items-center gap-2 text-xs font-medium text-slate-600">
Wir haben schon etwas für dich vorbereitet. <Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
</p> <span>Deine nächste Foto-Aufgabe wartet</span>
</div> </div>
</div> </div>
</div> </div>
{mission ? ( <div className="flex items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm">
<div className="space-y-2"> <Timer className="mr-1 h-3.5 w-3.5 text-slate-500" aria-hidden />
<div <span>ca. {durationMinutes} min</span>
className="rounded-[14px] py-3 text-center"
style={{
background: 'rgba(255,255,255,0.6)',
paddingLeft: '30px',
paddingRight: '30px',
}}
>
<p
className="text-lg font-semibold leading-snug"
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: textColor }}
>
{mission.title}
</p>
{mission.description && (
<p className="text-sm leading-relaxed" style={{ color: subTextColor }}>
{mission.description}
</p>
)}
</div> </div>
</div> </div>
<div className="mt-4 text-center">
{card ? (
<p
className="text-2xl font-semibold leading-tight text-slate-900 line-clamp-2"
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
>
{card.title}
</p>
) : loading ? (
<div className="space-y-2">
<Skeleton className="mx-auto h-6 w-3/4" />
<Skeleton className="mx-auto h-4 w-full" />
<Skeleton className="mx-auto h-4 w-5/6" />
</div>
) : ( ) : (
<p className="text-sm text-center" style={{ color: subTextColor }}> <p className="text-sm text-slate-600">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
Ziehe deine erste Mission im Aufgaben-Tab oder wähle eine Stimmung.
</p>
)} )}
</div>
<div className="grid grid-cols-[2fr_1fr] gap-2 pt-1"> <div className="mt-4 space-y-2">
<Button asChild className="w-full" style={{ borderRadius: `${radius}px` }}> <Progress
value={card ? progressValue : 35}
className="h-2 overflow-hidden bg-white/70"
/>
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wide text-slate-500">
<span>Mission bereit</span>
<span>{card ? 'Tippe zum Start' : 'Neue Mission lädt'}</span>
</div>
</div>
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
<Button
asChild
className="w-full text-white shadow-lg"
style={{
borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
boxShadow: `0 12px 28px ${primary}25`,
}}
>
<Link <Link
to={ to={
mission card
? `/e/${encodeURIComponent(token)}/upload?task=${mission.id}` ? `/e/${encodeURIComponent(token)}/upload?task=${card.id}`
: `/e/${encodeURIComponent(token)}/tasks` : `/e/${encodeURIComponent(token)}/tasks`
} }
> >
@@ -492,21 +557,67 @@ function MissionActionCard({
<Button <Button
type="button" type="button"
variant="secondary" variant="secondary"
className="w-full" className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur"
onClick={onShuffle} onClick={onAdvance}
disabled={loading} disabled={loading}
style={{ style={{
borderRadius: `${radius}px`, borderRadius: `${radius}px`,
backgroundColor: secondary,
color: '#ffffff',
}} }}
> >
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden /> <RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
Andere Aufgabe Andere
</Button> </Button>
</div> </div>
</div> </div>
</animated.div> </div>
);
};
const slides = cards.length ? cards : [mission ?? null];
const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1));
return (
<Card className="border-0 bg-transparent shadow-none" style={{ fontFamily: bodyFont }}>
<CardContent className="px-0 py-0">
<div className="relative min-h-[280px] px-2">
<Swiper
effect="cards"
modules={[EffectCards]}
cardsEffect={{
perSlideRotate: 2,
perSlideOffset: 4,
slideShadows: false,
}}
slidesPerView={1}
grabCursor
allowTouchMove
loop={slides.length > 1}
initialSlide={initialSlide}
onSwiper={(instance) => {
swiperRef.current = instance;
if (initialSlide > 0) {
instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0);
}
}}
onSlideChange={(instance) => {
const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0;
onIndexChange(realIndex, slides.length);
}}
className="!pb-2"
style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }}
>
{slides.map((card, index) => {
const key = `card-${card?.id ?? 'x'}-${index}`;
return (
<SwiperSlide key={key} className="!h-auto">
<div className="mx-auto w-full max-w-[calc(100vw-2rem)]">
{renderCardContent(card)}
</div>
</SwiperSlide>
);
})}
</Swiper>
</div>
</CardContent> </CardContent>
</Card> </Card>
); );
@@ -534,13 +645,58 @@ function UploadActionCard({
secondaryAccent, secondaryAccent,
radius, radius,
bodyFont, bodyFont,
requiresApproval,
}: { }: {
token: string; token: string;
accentColor: string; accentColor: string;
secondaryAccent: string; secondaryAccent: string;
radius: number; radius: number;
bodyFont?: string; bodyFont?: string;
requiresApproval: boolean;
}) { }) {
const inputRef = React.useRef<HTMLInputElement | null>(null);
const [busy, setBusy] = React.useState(false);
const [message, setMessage] = React.useState<string | null>(null);
const navigate = useNavigate();
const { upload, uploading, error, warning, progress, reset } = useDirectUpload({
eventToken: token,
taskId: undefined,
emotionSlug: undefined,
onCompleted: () => {
setMessage(null);
navigate(`/e/${encodeURIComponent(token)}/gallery`);
},
});
const onPick = React.useCallback(
async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setBusy(true);
setMessage(null);
try {
await upload(file);
} finally {
setBusy(false);
}
if (inputRef.current) {
inputRef.current.value = '';
}
},
[upload]
);
React.useEffect(() => {
if (error) {
setMessage(error);
} else if (warning) {
setMessage(warning);
} else {
setMessage(null);
}
}, [error, warning]);
return ( return (
<Card <Card
className="overflow-hidden border border-muted/30 shadow-sm" className="overflow-hidden border border-muted/30 shadow-sm"
@@ -550,28 +706,48 @@ function UploadActionCard({
fontFamily: bodyFont, fontFamily: bodyFont,
}} }}
> >
<CardContent className="flex flex-col gap-1.5 py-[4px]"> <CardContent className="flex flex-col gap-2 py-[4px]">
<div className="flex items-center gap-3">
<div className="rounded-2xl p-3" style={{ background: `${accentColor}15` }}>
<UploadCloud className="h-5 w-5" aria-hidden style={{ color: accentColor }} />
</div>
<div>
<p className="text-lg font-semibold text-foreground">Direkt hochladen</p>
<p className="text-sm text-muted-foreground">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
</div>
</div>
<Button <Button
asChild type="button"
className="text-white" className="text-white justify-center"
style={{ style={{
borderRadius: `${radius}px`, borderRadius: `${radius}px`,
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`, background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
boxShadow: `0 12px 28px ${accentColor}25`, boxShadow: `0 12px 28px ${accentColor}25`,
}} }}
disabled={busy || uploading}
onClick={() => {
reset();
setMessage(null);
inputRef.current?.click();
}}
> >
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link> <span className="flex items-center gap-2">
<UploadCloud className={`h-5 w-5 ${busy || uploading ? 'animate-pulse' : ''}`} aria-hidden />
<span>{busy || uploading ? 'Lädt …' : 'Direkt hochladen'}</span>
</span>
</Button> </Button>
<p className="text-xs text-muted-foreground">Offline möglich wir laden später hoch.</p> <input
ref={inputRef}
type="file"
accept="image/*"
capture="environment"
className="hidden"
onChange={onPick}
/>
<p className="text-xs text-muted-foreground">
Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich wir laden später hoch.
</p>
{requiresApproval ? (
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie.
</p>
) : null}
{message && (
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">
{message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''}
</p>
)}
</CardContent> </CardContent>
</Card> </Card>
); );

View File

@@ -8,6 +8,7 @@ import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost'; import { useToast } from '../components/ToastHost';
import ShareSheet from '../components/ShareSheet'; import ShareSheet from '../components/ShareSheet';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import { getDeviceId } from '../lib/device';
type Photo = { type Photo = {
id: number; id: number;
@@ -162,12 +163,20 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'X-Locale': locale, 'X-Locale': locale,
'X-Device-Id': getDeviceId(),
}, },
} }
); );
if (res.ok) { if (res.ok) {
const tasks = (await res.json()) as Task[]; const payload = (await res.json()) as unknown;
const foundTask = tasks.find((t) => t.id === taskId); const tasks = Array.isArray(payload)
? payload
: Array.isArray((payload as any)?.data)
? (payload as any).data
: Array.isArray((payload as any)?.tasks)
? (payload as any).tasks
: [];
const foundTask = (tasks as Task[]).find((t) => t.id === taskId);
if (foundTask) { if (foundTask) {
setTask({ setTask({
id: foundTask.id, id: foundTask.id,

View File

@@ -10,6 +10,13 @@ import { cn } from '@/lib/utils';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation'; import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import {
getEmotionIcon,
getEmotionTheme,
type EmotionIdentity,
type EmotionTheme,
} from '../lib/emotionTheme';
import { getDeviceId } from '../lib/device';
interface Task { interface Task {
id: number; id: number;
@@ -39,128 +46,6 @@ type EventPhoto = {
const SWIPE_THRESHOLD_PX = 40; const SWIPE_THRESHOLD_PX = 40;
const SIMILAR_PHOTO_LIMIT = 6; const SIMILAR_PHOTO_LIMIT = 6;
type EmotionTheme = {
gradientClass: string;
gradientBackground: string;
suggestionGradient: string;
suggestionBorder: string;
};
type EmotionIdentity = {
slug?: string | null;
name?: string | null;
};
const themeFreude: EmotionTheme = {
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
};
const themeLiebe: EmotionTheme = {
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
};
const themeEkstase: EmotionTheme = {
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
};
const themeEntspannt: EmotionTheme = {
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
};
const themeBesinnlich: EmotionTheme = {
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
};
const themeUeberraschung: EmotionTheme = {
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
};
const themeDefault: EmotionTheme = {
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
};
const EMOTION_THEMES: Record<string, EmotionTheme> = {
freude: themeFreude,
happy: themeFreude,
liebe: themeLiebe,
romance: themeLiebe,
romantik: themeLiebe,
nostalgie: themeEntspannt,
relaxed: themeEntspannt,
ruehrung: themeBesinnlich,
traurigkeit: themeBesinnlich,
teamgeist: themeFreude,
gemeinschaft: themeFreude,
ueberraschung: themeUeberraschung,
surprise: themeUeberraschung,
ekstase: themeEkstase,
excited: themeEkstase,
besinnlichkeit: themeBesinnlich,
sad: themeBesinnlich,
default: themeDefault,
};
const EMOTION_ICONS: Record<string, string> = {
freude: '😊',
happy: '😊',
liebe: '❤️',
romantik: '💞',
nostalgie: '📼',
ruehrung: '🥲',
teamgeist: '🤝',
ueberraschung: '😲',
surprise: '😲',
ekstase: '🤩',
besinnlichkeit: '🕯️',
};
function sluggify(value?: string | null): string {
return (value ?? '')
.toString()
.toLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/[^a-z0-9]+/g, '')
.trim();
}
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
if (!identity) return 'default';
const nameKey = sluggify(identity.name);
if (nameKey && EMOTION_THEMES[nameKey]) {
return nameKey;
}
const slugKey = sluggify(identity.slug);
if (slugKey && EMOTION_THEMES[slugKey]) {
return slugKey;
}
return 'default';
}
function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
const key = resolveEmotionKey(identity);
return EMOTION_THEMES[key] ?? themeDefault;
}
function getEmotionIcon(identity?: EmotionIdentity | null): string {
const key = resolveEmotionKey(identity);
return EMOTION_ICONS[key] ?? '✨';
}
export default function TaskPickerPage() { export default function TaskPickerPage() {
const { token } = useParams<{ token: string }>(); const { token } = useParams<{ token: string }>();
@@ -218,6 +103,7 @@ export default function TaskPickerPage() {
const headers: HeadersInit = { const headers: HeadersInit = {
Accept: 'application/json', Accept: 'application/json',
'X-Locale': locale, 'X-Locale': locale,
'X-Device-Id': getDeviceId(),
}; };
if (cached?.etag) { if (cached?.etag) {
@@ -235,14 +121,17 @@ export default function TaskPickerPage() {
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.'); if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json(); const payload = await response.json();
if (Array.isArray(payload)) { const taskList = Array.isArray(payload)
const entry = { data: payload, etag: response.headers.get('ETag') }; ? payload
: Array.isArray(payload?.data)
? payload.data
: Array.isArray(payload?.tasks)
? payload.tasks
: [];
const entry = { data: taskList, etag: response.headers.get('ETag') };
tasksCacheRef.current.set(cacheKey, entry); tasksCacheRef.current.set(cacheKey, entry);
setTasks(payload); setTasks(taskList);
} else {
tasksCacheRef.current.set(cacheKey, { data: [], etag: response.headers.get('ETag') });
setTasks([]);
}
} catch (err) { } catch (err) {
console.error('Failed to load tasks', err); console.error('Failed to load tasks', err);
setError(err instanceof Error ? err.message : 'Unbekannter Fehler'); setError(err instanceof Error ? err.message : 'Unbekannter Fehler');

View File

@@ -39,6 +39,7 @@ import { useEventStats } from '../context/EventStatsContext';
import { useEventBranding } from '../context/EventBrandingContext'; import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image'; import { compressPhoto, formatBytes } from '../lib/image';
import { useGuestIdentity } from '../context/GuestIdentityContext'; import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventData } from '../hooks/useEventData';
interface Task { interface Task {
id: number; id: number;
@@ -120,10 +121,13 @@ export default function UploadPage() {
const { t, locale } = useTranslation(); const { t, locale } = useTranslation();
const stats = useEventStats(); const stats = useEventStats();
const { branding } = useEventBranding(); const { branding } = useEventBranding();
const { event } = useEventData();
const radius = branding.buttons?.radius ?? 12; const radius = branding.buttons?.radius ?? 12;
const buttonStyle = branding.buttons?.style ?? 'filled'; const buttonStyle = branding.buttons?.style ?? 'filled';
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor; const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined; const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
const uploadsRequireApproval =
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
const taskIdParam = searchParams.get('task'); const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || ''; const emotionSlug = searchParams.get('emotion') || '';
@@ -253,12 +257,19 @@ const [canUpload, setCanUpload] = useState(true);
headers: { headers: {
Accept: 'application/json', Accept: 'application/json',
'X-Locale': locale, 'X-Locale': locale,
'X-Device-Id': getDeviceId(),
}, },
} }
); );
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden'); if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const payload = (await res.json()) as unknown; const payload = (await res.json()) as unknown;
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : []; const entries = Array.isArray(payload)
? payload.filter(isTaskPayload)
: Array.isArray((payload as any)?.data)
? (payload as any).data.filter(isTaskPayload)
: Array.isArray((payload as any)?.tasks)
? (payload as any).tasks.filter(isTaskPayload)
: [];
const found = entries.find((entry) => entry.id === currentTaskId) ?? null; const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
if (!active) return; if (!active) return;
@@ -1103,6 +1114,12 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
> >
{taskFloatingCard} {taskFloatingCard}
{heroOverlay} {heroOverlay}
{uploadsRequireApproval ? (
<div className="mx-4 rounded-xl border border-amber-300/70 bg-amber-50/80 p-3 text-amber-900 shadow-sm backdrop-blur dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
<p className="text-sm font-semibold">{t('upload.review.noticeTitle', 'Uploads werden geprüft')}</p>
<p className="text-xs">{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}</p>
</div>
) : null}
<section <section
className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl" className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
style={{ borderRadius: radius }} style={{ borderRadius: radius }}

View File

@@ -64,6 +64,7 @@ export interface EventData {
icon: string | null; icon: string | null;
}; };
branding?: EventBrandingPayload | null; branding?: EventBrandingPayload | null;
guest_upload_visibility?: 'immediate' | 'review';
} }
export interface PackageData { export interface PackageData {
@@ -265,6 +266,8 @@ export async function fetchEvent(eventKey: string): Promise<EventData> {
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== '' default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
? json.default_locale ? json.default_locale
: DEFAULT_LOCALE, : DEFAULT_LOCALE,
guest_upload_visibility:
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
}; };
if (json?.type) { if (json?.type) {