reworked the guest pwa, modernized start and gallery page. added share link functionality.

This commit is contained in:
Codex Agent
2025-11-10 22:25:25 +01:00
parent 1e8810ca51
commit 1cec116933
22 changed files with 1208 additions and 476 deletions

View File

@@ -6,6 +6,7 @@ use App\Models\Event;
use App\Models\EventJoinToken;
use App\Models\EventMediaAsset;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Services\Analytics\JoinTokenAnalyticsRecorder;
use App\Services\EventJoinTokenService;
use App\Services\Packages\PackageLimitEvaluator;
@@ -585,6 +586,18 @@ class EventPublicController extends BaseController
);
}
private function makeShareAssetUrl(PhotoShareLink $shareLink, string $variant): string
{
return URL::temporarySignedRoute(
'api.v1.photo-shares.asset',
now()->addSeconds(self::SIGNED_URL_TTL_SECONDS),
[
'slug' => $shareLink->slug,
'variant' => $variant,
]
);
}
public function gallery(Request $request, string $token)
{
$locale = $request->query('locale', app()->getLocale());
@@ -677,6 +690,170 @@ class EventPublicController extends BaseController
]);
}
public function createShareLink(Request $request, string $token, Photo $photo)
{
$resolved = $this->resolvePublishedEvent($request, $token, ['id']);
if ($resolved instanceof JsonResponse) {
return $resolved;
}
/** @var array{0: object{id:int}} $resolved */
[$eventRecord] = $resolved;
if ((int) $photo->event_id !== (int) $eventRecord->id) {
return ApiError::response(
'photo_not_shareable',
'Photo Not Shareable',
'The selected photo cannot be shared at this time.',
Response::HTTP_NOT_FOUND,
[
'photo_id' => $photo->id,
'event_id' => $eventRecord->id,
]
);
}
$deviceId = trim((string) ($request->header('X-Device-Id') ?? $request->input('device_id', '')));
$ttlHours = max(1, (int) config('share-links.ttl_hours', 48));
$existing = PhotoShareLink::query()
->where('photo_id', $photo->id)
->when($deviceId !== '', fn ($query) => $query->where('created_by_device_id', $deviceId))
->where('expires_at', '>', now())
->latest('id')
->first();
if ($existing && ! $existing->isExpired()) {
$shareLink = $existing;
} else {
$shareLink = PhotoShareLink::create([
'photo_id' => $photo->id,
'slug' => PhotoShareLink::generateSlug(),
'expires_at' => now()->addHours($ttlHours),
'created_by_device_id' => $deviceId ?: null,
'created_ip' => $request->ip(),
]);
}
return response()->json([
'slug' => $shareLink->slug,
'expires_at' => $shareLink->expires_at?->toIso8601String(),
'url' => url("/share/{$shareLink->slug}"),
])->header('Cache-Control', 'no-store');
}
public function shareLink(Request $request, string $slug)
{
$shareLink = PhotoShareLink::with(['photo.event', 'photo.emotion', 'photo.task'])
->where('slug', $slug)
->first();
if (! $shareLink || $shareLink->isExpired()) {
return ApiError::response(
'share_link_expired',
'Link Expired',
'This shared photo link is no longer available.',
Response::HTTP_GONE,
['slug' => $slug]
);
}
$shareLink->forceFill(['last_accessed_at' => now()])->save();
$photo = $shareLink->photo;
$event = $photo->event;
if (! $event || $photo->status !== 'approved') {
return ApiError::response(
'photo_not_shareable',
'Photo Not Shareable',
'The shared photo is no longer available.',
Response::HTTP_NOT_FOUND,
['slug' => $slug]
);
}
$taskTitle = null;
if ($photo->task) {
$taskTitle = $this->translateLocalized($photo->task->title, app()->getLocale(), '');
if ($taskTitle === '') {
$taskTitle = null;
}
}
$photoResource = [
'id' => $photo->id,
'title' => $taskTitle,
'emotion' => $photo->emotion ? [
'name' => $photo->emotion->name,
'emoji' => $photo->emotion->emoji,
] : null,
'likes_count' => $photo->likes()->count(),
'image_urls' => [
'thumbnail' => $this->makeShareAssetUrl($shareLink, 'thumbnail'),
'full' => $this->makeShareAssetUrl($shareLink, 'full'),
],
];
return response()->json([
'slug' => $shareLink->slug,
'expires_at' => $shareLink->expires_at?->toIso8601String(),
'photo' => $photoResource,
'event' => $event ? [
'id' => $event->id,
'name' => $event->name,
'city' => $event->city,
] : null,
])->header('Cache-Control', 'no-store');
}
public function shareLinkAsset(Request $request, string $slug, string $variant)
{
if (! in_array($variant, ['thumbnail', 'full'], true)) {
return ApiError::response(
'invalid_variant',
'Invalid Variant',
'The requested asset variant is not supported.',
Response::HTTP_BAD_REQUEST
);
}
$shareLink = PhotoShareLink::with(['photo.mediaAsset', 'photo.event.tenant'])
->where('slug', $slug)
->first();
if (! $shareLink || $shareLink->isExpired()) {
return ApiError::response(
'share_link_expired',
'Link Expired',
'This shared photo link is no longer available.',
Response::HTTP_GONE,
['slug' => $slug]
);
}
$photo = $shareLink->photo;
$event = $photo->event;
if (! $event || $photo->status !== 'approved') {
return ApiError::response(
'photo_not_shareable',
'Photo Not Shareable',
'The shared photo is no longer available.',
Response::HTTP_NOT_FOUND,
['slug' => $slug]
);
}
$variantPreference = $variant === 'thumbnail'
? ['thumbnail', 'original']
: ['original'];
return $this->streamGalleryPhoto($event, $photo, $variantPreference, 'inline');
}
public function galleryPhotoAsset(Request $request, string $token, int $photo, string $variant)
{
$resolved = $this->resolveGalleryEvent($request, $token);

View File

@@ -76,6 +76,11 @@ class Photo extends Model
return $this->hasMany(PhotoLike::class);
}
public function shareLinks(): HasMany
{
return $this->hasMany(PhotoShareLink::class);
}
public static function supportsFilenameColumn(): bool
{
return static::hasColumn('filename');

View File

@@ -0,0 +1,51 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Str;
class PhotoShareLink extends Model
{
use HasFactory;
protected $fillable = [
'photo_id',
'slug',
'expires_at',
'created_by_device_id',
'created_ip',
'last_accessed_at',
];
protected $casts = [
'expires_at' => 'datetime',
'last_accessed_at' => 'datetime',
];
protected static function booted(): void
{
static::creating(function (PhotoShareLink $link) {
if (! $link->slug) {
$link->slug = static::generateSlug();
}
});
}
public static function generateSlug(): string
{
return Str::lower(Str::random(40));
}
public function photo(): BelongsTo
{
return $this->belongsTo(Photo::class);
}
public function isExpired(): bool
{
return $this->expires_at !== null && $this->expires_at->isPast();
}
}

5
config/share-links.php Normal file
View File

@@ -0,0 +1,5 @@
<?php
return [
'ttl_hours' => env('PHOTO_SHARE_LINK_TTL_HOURS', 48),
];

View File

@@ -0,0 +1,29 @@
<?php
namespace Database\Factories;
use App\Models\Photo;
use Illuminate\Database\Eloquent\Factories\Factory;
use Illuminate\Support\Str;
/**
* @extends \Illuminate\Database\Eloquent\Factories\Factory<\App\Models\PhotoShareLink>
*/
class PhotoShareLinkFactory extends Factory
{
/**
* Define the model's default state.
*
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'photo_id' => Photo::factory(),
'slug' => Str::lower(Str::random(32)),
'expires_at' => now()->addDay(),
'created_by_device_id' => $this->faker->unique()->uuid(),
'created_ip' => $this->faker->ipv4(),
];
}
}

View File

@@ -0,0 +1,33 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('photo_share_links', function (Blueprint $table) {
$table->id();
$table->foreignId('photo_id')->constrained()->cascadeOnDelete();
$table->string('slug', 64)->unique();
$table->string('created_by_device_id')->nullable();
$table->string('created_ip', 45)->nullable();
$table->timestamp('expires_at');
$table->timestamp('last_accessed_at')->nullable();
$table->timestamps();
});
}
/**
* Reverse the migrations.
*/
public function down(): void
{
Schema::dropIfExists('photo_share_links');
}
};

View File

@@ -1,6 +1,6 @@
import React from 'react';
import { NavLink, useParams, useLocation } from 'react-router-dom';
import { CheckSquare, GalleryHorizontal, Home, Trophy } from 'lucide-react';
import { NavLink, useParams, useLocation, Link } from 'react-router-dom';
import { CheckSquare, GalleryHorizontal, Home, Trophy, Camera } from 'lucide-react';
import { useEventData } from '../hooks/useEventData';
import { useTranslation } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
@@ -58,40 +58,61 @@ export default function BottomNav() {
tasks: t('navigation.tasks'),
achievements: t('navigation.achievements'),
gallery: t('navigation.gallery'),
upload: t('home.actions.items.upload.label'),
};
const isHomeActive = currentPath === base || currentPath === `/${token}`;
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
const isTasksActive = currentPath.startsWith(`${base}/tasks`);
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);
const isUploadActive = currentPath.startsWith(`${base}/upload`);
return (
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/30 via-black/10 to-transparent px-3 py-2 shadow-xl backdrop-blur-xl dark:border-white/10 dark:from-gray-950/85 dark:via-gray-900/70 dark:to-gray-900/35 dark:backdrop-blur-2xl dark:shadow-[0_18px_60px_rgba(15,15,30,0.6)]">
<div className="mx-auto flex max-w-sm items-center justify-around gap-2">
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<Home className="h-5 w-5" aria-hidden />
<span>{labels.home}</span>
</div>
</TabLink>
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
</div>
</TabLink>
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<Trophy className="h-5 w-5" aria-hidden />
<span>{labels.achievements}</span>
</div>
</TabLink>
<TabLink to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<GalleryHorizontal className="h-5 w-5" aria-hidden />
<span>{labels.gallery}</span>
</div>
</TabLink>
<div className="fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 pb-3 pt-2 shadow-xl backdrop-blur-2xl dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35">
<div className="mx-auto flex max-w-lg items-center gap-3">
<div className="flex flex-1 justify-evenly gap-2">
<TabLink to={`${base}`} isActive={isHomeActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<Home className="h-5 w-5" aria-hidden />
<span>{labels.home}</span>
</div>
</TabLink>
<TabLink to={`${base}/tasks`} isActive={isTasksActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<CheckSquare className="h-5 w-5" aria-hidden />
<span>{labels.tasks}</span>
</div>
</TabLink>
</div>
<Link
to={`${base}/upload`}
aria-label={labels.upload}
className={`relative flex h-16 w-16 items-center justify-center rounded-full text-white shadow-2xl transition ${
isUploadActive ? 'scale-105' : 'hover:scale-105'
}`}
style={{
background: `radial-gradient(circle at 20% 20%, ${branding.secondaryColor}, ${branding.primaryColor})`,
boxShadow: `0 20px 35px ${branding.primaryColor}44`,
}}
>
<Camera className="h-6 w-6" aria-hidden />
</Link>
<div className="flex flex-1 justify-evenly gap-2">
<TabLink to={`${base}/achievements`} isActive={isAchievementsActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<Trophy className="h-5 w-5" aria-hidden />
<span>{labels.achievements}</span>
</div>
</TabLink>
<TabLink to={`${base}/gallery`} isActive={isGalleryActive} accentColor={branding.primaryColor}>
<div className="flex flex-col items-center gap-1">
<GalleryHorizontal className="h-5 w-5" aria-hidden />
<span>{labels.gallery}</span>
</div>
</TabLink>
</div>
</div>
</div>
);

View File

@@ -73,30 +73,17 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
}
};
if (loading) {
return (
<div className="mb-6 p-4 text-center">
<div className="text-sm text-muted-foreground">Lade Emotionen...</div>
const content = (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-base font-semibold">
Wie fühlst du dich?
<span className="ml-2 text-xs text-muted-foreground">(optional)</span>
</h3>
{loading && <span className="text-xs text-muted-foreground">Lade Emotionen</span>}
</div>
);
}
if (error) {
return (
<div className="mb-6 p-4 bg-red-50 border border-red-200 rounded-lg">
<div className="text-sm text-red-700">{error}</div>
</div>
);
}
return (
<div className="mb-6">
<h3 className="text-lg font-semibold mb-4 flex items-center gap-2">
Wie fühlst du dich?
<span className="text-xs text-muted-foreground">(optional)</span>
</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<div className="flex gap-3 overflow-x-auto pb-2 [-ms-overflow-style:none] [scrollbar-width:none]" aria-label="Emotions">
{emotions.map((emotion) => {
// Localize name and description if they are JSON
const localize = (value: string | object, defaultValue: string = ''): string => {
@@ -113,25 +100,26 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
const localizedName = localize(emotion.name, emotion.name);
const localizedDescription = localize(emotion.description || '', '');
return (
<Button
<button
key={emotion.id}
variant="outline"
className="w-full justify-start h-16 p-3 bg-pink-50 dark:bg-gray-800/50 hover:bg-pink-100 dark:hover:bg-gray-700/50 border-pink-200 dark:border-gray-600 rounded-xl text-left shadow-sm dark:text-white"
type="button"
onClick={() => handleEmotionSelect(emotion)}
className="group flex min-w-[180px] flex-col gap-2 rounded-2xl border border-white/40 bg-white/80 px-4 py-3 text-left shadow-sm transition hover:-translate-y-0.5 hover:border-pink-200 hover:shadow-md dark:border-gray-700/60 dark:bg-gray-900/70"
>
<div className="flex items-center gap-3">
<span className="text-2xl">{emotion.emoji}</span>
<span className="text-2xl" aria-hidden>
{emotion.emoji}
</span>
<div className="flex-1 min-w-0">
<div className="font-medium text-sm truncate">{localizedName}</div>
<div className="font-medium text-sm text-foreground line-clamp-1">{localizedName}</div>
{localizedDescription && (
<div className="text-xs text-muted-foreground truncate">{localizedDescription}</div>
<div className="text-xs text-muted-foreground line-clamp-1">{localizedDescription}</div>
)}
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground ml-auto" />
<ChevronRight className="h-4 w-4 text-muted-foreground opacity-0 transition group-hover:opacity-100" />
</div>
</Button>
</button>
);
})}
</div>
@@ -151,4 +139,18 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
</div>
</div>
);
if (error) {
return (
<div className="rounded-3xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
{error}
</div>
);
}
return (
<div className="rounded-3xl border border-muted/40 bg-gradient-to-br from-white to-white/70 p-4 shadow-sm backdrop-blur">
{content}
</div>
);
}

View File

@@ -1,17 +1,40 @@
import React from 'react';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import { cn } from '@/lib/utils';
import { Sparkles, Flame, UserRound, Camera } from 'lucide-react';
export type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
export default function FiltersBar({ value, onChange }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void }) {
const filterConfig: Array<{ value: GalleryFilter; label: string; icon: React.ReactNode }> = [
{ value: 'latest', label: 'Neueste', icon: <Sparkles className="h-4 w-4" aria-hidden /> },
{ value: 'popular', label: 'Beliebt', icon: <Flame className="h-4 w-4" aria-hidden /> },
{ value: 'mine', label: 'Meine', icon: <UserRound className="h-4 w-4" aria-hidden /> },
{ value: 'photobooth', label: 'Fotobox', icon: <Camera className="h-4 w-4" aria-hidden /> },
];
export default function FiltersBar({ value, onChange, className }: { value: GalleryFilter; onChange: (v: GalleryFilter) => void; className?: string }) {
return (
<div className="mb-3 flex items-center justify-between">
<ToggleGroup type="single" value={value} onValueChange={(v) => v && onChange(v as GalleryFilter)}>
<ToggleGroupItem value="latest">Neueste</ToggleGroupItem>
<ToggleGroupItem value="popular">Beliebt</ToggleGroupItem>
<ToggleGroupItem value="mine">Meine</ToggleGroupItem>
<ToggleGroupItem value="photobooth">Fotobox</ToggleGroupItem>
</ToggleGroup>
<div
className={cn(
'flex gap-2 overflow-x-auto px-4 pb-2 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none] [&::-webkit-scrollbar]:hidden',
className,
)}
>
{filterConfig.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => onChange(filter.value)}
className={cn(
'inline-flex items-center gap-2 rounded-full border px-4 py-2 transition',
value === filter.value
? 'border-pink-500 bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow'
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200',
)}
>
{filter.icon}
{filter.label}
</button>
))}
</div>
);
}

View File

@@ -3,6 +3,7 @@ import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import { getDeviceId } from '../lib/device';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Heart } from 'lucide-react';
type Props = { token: string };
@@ -50,56 +51,45 @@ export default function GalleryPreview({ token }: Props) {
return 'Abendstimmung';
}
const filters: { value: PreviewFilter; label: string }[] = [
{ value: 'latest', label: 'Newest' },
{ value: 'popular', label: 'Popular' },
{ value: 'mine', label: 'My Photos' },
{ value: 'photobooth', label: 'Fotobox' },
];
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="inline-flex rounded-full bg-white/80 backdrop-blur-sm border border-pink-200 p-1 shadow-sm">
<button
onClick={() => setMode('latest')}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
mode === 'latest'
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
}`}
>
Newest
</button>
<button
onClick={() => setMode('popular')}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
mode === 'popular'
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
}`}
>
Popular
</button>
<button
onClick={() => setMode('mine')}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
mode === 'mine'
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
}`}
>
My Photos
</button>
<button
onClick={() => setMode('photobooth')}
className={`px-4 py-2 text-sm font-medium rounded-full transition-all duration-200 ${
mode === 'photobooth'
? 'bg-gradient-to-r from-pink-500 to-pink-600 text-white shadow-lg scale-105'
: 'text-gray-600 hover:bg-pink-50 hover:text-pink-700'
}`}
>
Fotobox
</button>
<section className="space-y-4">
<div className="flex items-center justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
<h3 className="text-lg font-semibold text-foreground">Alle Uploads auf einen Blick</h3>
</div>
<Link to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
<Link
to={`/e/${encodeURIComponent(token)}/gallery?mode=${mode}`}
className="text-sm font-semibold text-pink-600 hover:text-pink-700"
>
Alle ansehen
</Link>
</div>
<div className="flex gap-2 overflow-x-auto pb-1 text-sm font-medium [-ms-overflow-style:none] [scrollbar-width:none]">
{filters.map((filter) => (
<button
key={filter.value}
type="button"
onClick={() => setMode(filter.value)}
className={`rounded-full border px-4 py-1 transition ${
mode === filter.value
? 'border-pink-500 bg-pink-500 text-white shadow'
: 'border-transparent bg-white/70 text-muted-foreground hover:border-pink-200'
}`}
>
{filter.label}
</button>
))}
</div>
{loading && <p className="text-sm text-muted-foreground">Lädt</p>}
{!loading && items.length === 0 && (
<Card>
@@ -108,31 +98,31 @@ export default function GalleryPreview({ token }: Props) {
</CardContent>
</Card>
)}
<div className="grid grid-cols-2 gap-3">
<div className="grid gap-3 sm:grid-cols-2">
{items.map((p: any) => (
<Link key={p.id} to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`} className="block">
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="aspect-square w-full rounded-xl object-cover shadow-lg hover:shadow-xl transition-shadow duration-200"
loading="lazy"
/>
{/* Photo Title */}
<div className="mt-2">
<div className="text-xs font-medium text-gray-900 line-clamp-2 bg-white/80 px-2 py-1 rounded-md">
{p.title || getPhotoTitle(p)}
</div>
{p.likes_count > 0 && (
<div className="mt-1 flex items-center gap-1 text-xs text-pink-600">
{p.likes_count}
</div>
)}
<Link
key={p.id}
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
className="group relative block overflow-hidden rounded-3xl border border-white/30 bg-gray-900 text-white shadow-lg"
>
<img
src={p.thumbnail_path || p.file_path}
alt={p.title || 'Foto'}
className="h-48 w-full object-cover transition duration-300 group-hover:scale-105"
loading="lazy"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
<div className="absolute bottom-0 left-0 right-0 p-4">
<p className="text-sm font-semibold leading-tight line-clamp-2">{p.title || getPhotoTitle(p)}</p>
<div className="mt-2 flex items-center gap-1 text-xs text-white/80">
<Heart className="h-4 w-4 fill-current" aria-hidden />
{p.likes_count ?? 0}
</div>
</div>
</Link>
))}
</div>
</div>
</section>
);
}

View File

@@ -209,6 +209,21 @@ export const messages: Record<LocaleCode, NestedMessages> = {
emptyDescription: 'Sobald Fotos freigegeben sind, erscheinen sie hier.',
lightboxGuestFallback: 'Gast',
},
share: {
title: 'Geteiltes Foto',
defaultEvent: 'Ein besonderer Moment',
button: 'Teilen',
copyLink: 'Link kopieren',
copySuccess: 'Link kopiert!',
copyError: 'Link konnte nicht kopiert werden.',
manualPrompt: 'Link kopieren',
openEvent: 'Event öffnen',
loading: 'Moment wird geladen...',
expiredTitle: 'Link abgelaufen',
expiredDescription: 'Dieser Link ist nicht mehr verfügbar.',
shareText: 'Schau dir diesen Moment bei Fotospiel an.',
error: 'Teilen fehlgeschlagen',
},
uploadQueue: {
title: 'Uploads',
description: 'Warteschlange mit Fortschritt und erneuten Versuchen; Hintergrund-Sync umschalten.',
@@ -662,6 +677,21 @@ export const messages: Record<LocaleCode, NestedMessages> = {
emptyDescription: 'Once photos are approved they will appear here.',
lightboxGuestFallback: 'Guest',
},
share: {
title: 'Shared photo',
defaultEvent: 'A special moment',
button: 'Share',
copyLink: 'Copy link',
copySuccess: 'Link copied!',
copyError: 'Link could not be copied.',
manualPrompt: 'Copy link',
openEvent: 'Open event',
loading: 'Loading moment...',
expiredTitle: 'Link expired',
expiredDescription: 'This link is no longer available.',
shareText: 'Check out this moment on Fotospiel.',
error: 'Share failed',
},
uploadQueue: {
title: 'Uploads',
description: 'Queue with progress/retry and background sync toggle.',

View File

@@ -0,0 +1,59 @@
import { createPhotoShareLink } from '../services/photosApi';
type ShareOptions = {
token: string;
photoId: number;
title?: string;
text?: string;
};
async function copyToClipboard(text: string): Promise<boolean> {
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return true;
}
} catch {
// ignore and fallback
}
try {
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
return true;
} catch {
return false;
}
}
export async function sharePhotoLink(options: ShareOptions): Promise<{ url: string; method: 'native' | 'clipboard' | 'manual' }>
{
const payload = await createPhotoShareLink(options.token, options.photoId);
const shareData: ShareData = {
title: options.title ?? 'Fotospiel Moment',
text: options.text ?? '',
url: payload.url,
};
if (navigator.share && (!navigator.canShare || navigator.canShare(shareData))) {
try {
await navigator.share(shareData);
return { url: payload.url, method: 'native' };
} catch (error: any) {
if (error?.name === 'AbortError') {
return { url: payload.url, method: 'native' };
}
// fall through to clipboard
}
}
if (await copyToClipboard(payload.url)) {
return { url: payload.url, method: 'clipboard' };
}
return { url: payload.url, method: 'manual' };
}

View File

@@ -43,10 +43,6 @@ function formatRelativeTime(input: string): string {
return `vor ${days} Tagen`;
}
function badgeVariant(earned: boolean): string {
return earned ? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/30' : 'bg-muted text-muted-foreground';
}
function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string; icon: React.ElementType; entries: LeaderboardEntry[]; emptyCopy: string }) {
return (
<Card>
@@ -83,6 +79,17 @@ function Leaderboard({ title, icon: Icon, entries, emptyCopy }: { title: string;
);
}
function progressMeta(badge: AchievementBadge) {
const target = badge.target ?? 0;
const progress = badge.progress ?? 0;
const ratio = target > 0 ? Math.min(1, progress / target) : 0;
return {
progress,
target,
ratio: badge.earned ? 1 : ratio,
};
}
function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
if (badges.length === 0) {
return (
@@ -104,21 +111,43 @@ function BadgesGrid({ badges }: { badges: AchievementBadge[] }) {
<CardTitle>Badges</CardTitle>
<CardDescription>Dein Fortschritt bei den verfügbaren Erfolgen.</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{badges.map((badge) => (
<div key={badge.id} className={cn('rounded-xl border px-4 py-3', badgeVariant(badge.earned))}>
<div className="flex items-center justify-between gap-2">
<div>
<p className="text-sm font-semibold">{badge.title}</p>
<p className="text-xs text-muted-foreground">{badge.description}</p>
<CardContent className="grid grid-cols-2 gap-3 sm:grid-cols-3">
{badges.map((badge) => {
const { ratio } = progressMeta(badge);
const percentage = Math.round(ratio * 100);
return (
<div
key={badge.id}
className={cn(
'relative overflow-hidden rounded-2xl border px-4 py-3 shadow-sm transition',
badge.earned
? 'border-emerald-400/40 bg-gradient-to-br from-emerald-500/20 via-emerald-500/5 to-white text-emerald-900'
: 'border-border/60 bg-white/80',
)}
>
<div className="flex items-start justify-between gap-2">
<div>
<p className="text-sm font-semibold text-foreground">{badge.title}</p>
<p className="text-xs text-muted-foreground line-clamp-2">{badge.description}</p>
</div>
<span className="rounded-full bg-white/30 p-2 text-pink-500">
<Award className="h-4 w-4" />
</span>
</div>
<div className="mt-3">
<div className="h-2 w-full overflow-hidden rounded-full bg-muted/40">
<div
className={cn('h-2 rounded-full transition-all', badge.earned ? 'bg-emerald-500' : 'bg-pink-500')}
style={{ width: `${Math.max(8, percentage)}%` }}
/>
</div>
<p className="mt-1 text-xs text-muted-foreground">
{badge.earned ? 'Freigeschaltet 🎉' : `Fortschritt: ${badge.progress}/${badge.target}`}
</p>
</div>
<Award className="h-5 w-5" />
</div>
<div className="mt-2 text-xs text-muted-foreground">
{badge.earned ? 'Abgeschlossen' : `Fortschritt: ${badge.progress}/${badge.target}`}
</div>
</div>
))}
);
})}
</CardContent>
</Card>
);

View File

@@ -2,22 +2,38 @@ import React, { useEffect, useState } from 'react';
import { Page } from './_util';
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Alert, AlertDescription } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
import { Heart, Users, Image as ImageIcon, Camera, Package as PackageIcon, AlertTriangle } from 'lucide-react';
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
import { fetchEvent, fetchStats, type EventData, type EventStats } from '../services/eventApi';
import { useTranslation } from '../i18n/useTranslation';
import { sharePhotoLink } from '../lib/sharePhoto';
import { useToast } from '../components/ToastHost';
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
const parseGalleryFilter = (value: string | null): GalleryFilter =>
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
const normalizeImageUrl = (src?: string | null) => {
if (!src) {
return '';
}
if (/^https?:/i.test(src)) {
return src;
}
let cleanPath = src.replace(/^\/+/g, '').replace(/\/+/g, '/');
if (!cleanPath.startsWith('storage/')) {
cleanPath = `storage/${cleanPath}`;
}
return `/${cleanPath}`.replace(/\/+/g, '/');
};
export default function GalleryPage() {
const { token } = useParams<{ token?: string }>();
const navigate = useNavigate();
@@ -30,11 +46,11 @@ export default function GalleryPage() {
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
const [event, setEvent] = useState<EventData | null>(null);
const [eventPackage, setEventPackage] = useState<EventPackage | null>(null);
const [stats, setStats] = useState<EventStats | null>(null);
const [eventLoading, setEventLoading] = useState(true);
const { t } = useTranslation();
const locale = typeof window !== 'undefined' ? window.navigator.language : 'de-DE';
const toast = useToast();
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
useEffect(() => {
setFilterState(parseGalleryFilter(modeParam));
@@ -64,13 +80,11 @@ export default function GalleryPage() {
const loadEventData = async () => {
try {
setEventLoading(true);
const [eventData, packageData, statsData] = await Promise.all([
const [eventData, statsData] = await Promise.all([
fetchEvent(token),
getEventPackage(token),
fetchStats(token),
]);
setEvent(eventData);
setEventPackage(packageData);
setStats(statsData);
} catch (err) {
console.error('Failed to load event data', err);
@@ -106,67 +120,10 @@ export default function GalleryPage() {
const [liked, setLiked] = React.useState<Set<number>>(new Set());
const [counts, setCounts] = React.useState<Record<number, number>>({});
const photoLimits = eventPackage?.limits?.photos ?? null;
const guestLimits = eventPackage?.limits?.guests ?? null;
const galleryLimits = eventPackage?.limits?.gallery ?? null;
const galleryCountdown = React.useMemo(() => {
if (!galleryLimits || galleryLimits.state !== 'expired') {
return null;
}
return {
tone: 'danger' as const,
label: t('galleryCountdown.expired'),
description: t('galleryCountdown.expiredDescription'),
cta: null,
};
}, [galleryLimits, t]);
const handleCountdownCta = React.useCallback(() => {
if (!galleryCountdown?.cta || !token) {
return;
}
if (galleryCountdown.cta.type === 'upload') {
navigate(`/e/${encodeURIComponent(token)}/upload`);
}
}, [galleryCountdown?.cta, navigate, token]);
const packageWarnings = React.useMemo(() => {
const warnings: { id: string; tone: 'warning' | 'danger'; message: string }[] = [];
if (photoLimits?.state === 'limit_reached' && typeof photoLimits.limit === 'number') {
warnings.push({
id: 'photos-blocked',
tone: 'danger',
message: t('upload.limitReached')
.replace('{used}', `${photoLimits.used}`)
.replace('{max}', `${photoLimits.limit}`),
});
}
if (galleryLimits?.state === 'expired') {
warnings.push({
id: 'gallery-expired',
tone: 'danger',
message: t('upload.errors.galleryExpired'),
});
}
return warnings;
}, [photoLimits, galleryLimits, t]);
const formatDate = React.useCallback((value: string | null) => {
if (!value) return null;
const date = new Date(value);
if (Number.isNaN(date.getTime())) return null;
try {
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: 'short', year: 'numeric' }).format(date);
} catch {
return date.toISOString().slice(0, 10);
}
}, [locale]);
const totalLikes = React.useMemo(
() => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0),
[photos],
);
async function onLike(id: number) {
if (liked.has(id)) return;
@@ -185,6 +142,30 @@ export default function GalleryPage() {
}
}
async function onShare(photo: any) {
if (!token) return;
setShareTargetId(photo.id);
try {
const result = await sharePhotoLink({
token,
photoId: photo.id,
title: photo.task_title ?? event?.name ?? t('share.title', 'Geteiltes Foto'),
text: t('share.shareText', { event: event?.name ?? 'Fotospiel' }),
});
if (result.method === 'clipboard') {
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} else if (result.method === 'manual') {
window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url);
}
} catch (error) {
console.error('share failed', error);
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
} finally {
setShareTargetId(null);
}
}
if (!token) {
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
}
@@ -195,183 +176,109 @@ export default function GalleryPage() {
return (
<Page title="Galerie">
<Card className="mx-4 mb-4">
<CardHeader>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<CardTitle className="flex flex-wrap items-center gap-2">
<ImageIcon className="h-6 w-6" />
<span>Galerie: {event?.name || 'Event'}</span>
{galleryCountdown && (
<Badge
variant="secondary"
className={galleryCountdown.tone === 'danger'
? 'border-rose-200 bg-rose-100 text-rose-700'
: 'border-amber-200 bg-amber-100 text-amber-700'}
>
{galleryCountdown.label}
</Badge>
<section className="space-y-4 px-4">
<div className="rounded-[32px] border border-white/40 bg-gradient-to-br from-pink-50 via-white to-white p-6 shadow-sm">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<p className="text-xs uppercase tracking-wide text-muted-foreground">Live-Galerie</p>
<div className="mt-1 flex items-center gap-2 text-2xl font-semibold text-foreground">
<ImageIcon className="h-6 w-6 text-pink-500" aria-hidden />
<span>{event?.name ?? 'Event'}</span>
</div>
<p className="mt-2 text-sm text-muted-foreground">
{photos.length} Fotos · {totalLikes} · {stats?.onlineGuests ?? 0} Gäste online
</p>
</div>
<div className="flex flex-col items-start gap-2 sm:items-end">
{newCount > 0 && (
<Button size="sm" className="rounded-full" onClick={acknowledgeNew}>
{newCount} neue Fotos ansehen
</Button>
)}
</CardTitle>
{galleryCountdown?.cta && (
<Button
variant="outline"
size="sm"
variant={galleryCountdown.tone === 'danger' ? 'destructive' : 'outline'}
onClick={handleCountdownCta}
disabled={!token}
onClick={() => navigate(`/e/${encodeURIComponent(token)}/upload`)}
>
{galleryCountdown.cta.label}
Neues Foto hochladen
</Button>
)}
</div>
{galleryCountdown && (
<CardDescription className={galleryCountdown.tone === 'danger' ? 'text-rose-600' : 'text-amber-600'}>
{galleryCountdown.description}
</CardDescription>
)}
</CardHeader>
<CardContent className="space-y-4">
{packageWarnings.length > 0 && (
<div className="space-y-2">
{packageWarnings.map((warning) => (
<Alert
key={warning.id}
variant={warning.tone === 'danger' ? 'destructive' : 'default'}
className={warning.tone === 'warning' ? 'border-amber-400/40 bg-amber-50 text-amber-900' : undefined}
>
<AlertDescription className="flex items-center gap-2 text-sm">
<AlertTriangle className="h-4 w-4" />
{warning.message}
</AlertDescription>
</Alert>
))}
</div>
)}
</div>
</div>
</section>
<div className="grid grid-cols-2 gap-4 md:grid-cols-4">
<div className="text-center">
<Users className="h-8 w-8 mx-auto mb-2 text-blue-500" />
<p className="font-semibold">Online Gäste</p>
<p className="text-2xl">{stats?.onlineGuests || 0}</p>
</div>
<div className="text-center">
<Heart className="h-8 w-8 mx-auto mb-2 text-red-500" />
<p className="font-semibold">Gesamt Likes</p>
<p className="text-2xl">{photos.reduce((sum, p) => sum + ((p as any).likes_count || 0), 0)}</p>
</div>
<div className="text-center">
<Camera className="h-8 w-8 mx-auto mb-2 text-green-500" />
<p className="font-semibold">Gesamt Fotos</p>
<p className="text-2xl">{photos.length}</p>
</div>
{eventPackage && (
<div className="rounded-2xl border border-gray-200 bg-white/70 p-4 text-center">
<PackageIcon className="mx-auto mb-2 h-8 w-8 text-purple-500" />
<p className="font-semibold">Package</p>
<p className="text-sm text-gray-600">{eventPackage.package?.name ?? '—'}</p>
{photoLimits?.limit ? (
<>
<div className="mt-3 h-2 w-full overflow-hidden rounded-full bg-gray-200">
<div
className={`h-2 rounded-full ${photoLimits.state === 'limit_reached' ? 'bg-red-500' : photoLimits.state === 'warning' ? 'bg-amber-500' : 'bg-blue-600'}`}
style={{ width: `${Math.min(100, Math.max(6, Math.round((photoLimits.used / photoLimits.limit) * 100))) }%` }}
/>
</div>
<p className="mt-2 text-xs text-gray-600">
{photoLimits.used} / {photoLimits.limit} Fotos
</p>
</>
) : (
<p className="mt-2 text-xs text-gray-600">{t('upload.limitUnlimited')}</p>
)}
{guestLimits?.limit ? (
<p className="mt-2 text-xs text-gray-500">
Gäste: {guestLimits.used} / {guestLimits.limit}
</p>
) : null}
{galleryLimits?.expires_at ? (
<p className="mt-2 text-xs text-gray-500">
Galerie bis {formatDate(galleryLimits.expires_at)}
</p>
) : null}
</div>
)}
</div>
</CardContent>
</Card>
<FiltersBar value={filter} onChange={setFilter} />
{newCount > 0 && (
<Alert className="mb-3 mx-4">
<AlertDescription>
{newCount} neue Fotos verfügbar.{' '}
<Button variant="link" className="px-1" onClick={acknowledgeNew}>Aktualisieren</Button>
</AlertDescription>
</Alert>
)}
{loading && <p className="mx-4">Lade</p>}
<div className="grid grid-cols-2 gap-2 sm:grid-cols-3 px-4">
<FiltersBar value={filter} onChange={setFilter} className="mt-2" />
{loading && <p className="px-4">Lade</p>}
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
{list.map((p: any) => {
// Debug: Log image URLs
const imgSrc = p.thumbnail_path || p.file_path;
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
const createdLabel = p.created_at
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
: t('gallery.justNow', 'Gerade eben');
const likeCount = counts[p.id] ?? (p.likes_count || 0);
// Normalize image URL
let imageUrl = imgSrc;
let cleanPath = '';
if (imageUrl) {
// Remove leading/trailing slashes for processing
cleanPath = imageUrl.replace(/^\/+|\/+$/g, '');
// Check if path already contains storage prefix
if (cleanPath.startsWith('storage/')) {
// Already has storage prefix, just ensure it starts with /
imageUrl = `/${cleanPath}`;
} else {
// Add storage prefix
imageUrl = `/storage/${cleanPath}`;
}
// Remove double slashes
imageUrl = imageUrl.replace(/\/+/g, '/');
}
// Production: avoid heavy console logging for each image
const openPhoto = () => {
const index = list.findIndex((photo: any) => photo.id === p.id);
setCurrentPhotoIndex(index >= 0 ? index : null);
};
return (
<Card key={p.id} className="relative overflow-hidden">
<CardContent className="p-0">
<div
onClick={() => {
const index = list.findIndex(photo => photo.id === p.id);
setCurrentPhotoIndex(index >= 0 ? index : null);
}}
className="cursor-pointer"
>
<img
src={imageUrl}
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
className="aspect-square w-full object-cover bg-gray-200"
onError={(e) => {
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
loading="lazy"
/>
<div
key={p.id}
role="button"
tabIndex={0}
onClick={openPhoto}
onKeyDown={(e) => {
if (e.key === 'Enter') {
openPhoto();
}
}}
className="group relative overflow-hidden rounded-[28px] border border-white/20 bg-gray-950 text-white shadow-lg focus:outline-none focus-visible:ring-2 focus-visible:ring-pink-400"
>
<img
src={imageUrl}
alt={`Foto ${p.id}${p.task_title ? ` - ${p.task_title}` : ''}`}
className="h-64 w-full object-cover transition duration-500 group-hover:scale-105"
onError={(e) => {
(e.target as HTMLImageElement).src = 'data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMTAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj48cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjRjNGNEY2Ii8+PHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPk5vIEltYWdlPC90ZXh0Pjwvc3ZnPg==';
}}
loading="lazy"
/>
<div className="pointer-events-none absolute inset-0 bg-gradient-to-t from-black/80 via-black/10 to-transparent" aria-hidden />
<div className="pointer-events-none absolute inset-x-0 bottom-0 space-y-2 px-4 pb-4">
{p.task_title && <p className="text-sm font-medium leading-tight line-clamp-2">{p.task_title}</p>}
<div className="flex items-center justify-between text-xs text-white/80">
<span>{createdLabel}</span>
<span>{p.uploader_name || 'Gast'}</span>
</div>
</CardContent>
{p.task_title && (
<div className="px-2 pb-2 text-center">
<p className="text-xs text-gray-700 truncate bg-white/80 py-1 rounded-sm">{p.task_title}</p>
</div>
)}
<div className="absolute bottom-1 right-1 flex items-center gap-1 rounded-full bg-black/50 px-2 py-1 text-white">
<button onClick={(e) => { e.preventDefault(); e.stopPropagation(); onLike(p.id); }} className={`inline-flex items-center ${liked.has(p.id) ? 'text-red-400' : ''}`} aria-label="Like">
<Heart className="h-4 w-4" />
</button>
<span className="text-xs">{counts[p.id] ?? (p.likes_count || 0)}</span>
</div>
</Card>
<div className="absolute bottom-3 right-3 flex items-center gap-2">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onShare(p);
}}
className={`flex h-9 w-9 items-center justify-center rounded-full border border-white/30 bg-white/10 transition ${shareTargetId === p.id ? 'opacity-60' : 'hover:bg-white/20'}`}
aria-label={t('share.button', 'Teilen')}
disabled={shareTargetId === p.id}
>
<Share2 className="h-4 w-4" aria-hidden />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
onLike(p.id);
}}
className={`flex items-center gap-1 rounded-full border border-white/30 bg-white/10 px-3 py-1 text-sm font-medium transition ${liked.has(p.id) ? 'text-pink-300' : 'text-white'}`}
aria-label="Foto liken"
>
<Heart className={`h-4 w-4 ${liked.has(p.id) ? 'fill-current' : ''}`} aria-hidden />
{likeCount}
</button>
</div>
</div>
);
})}
</div>

View File

@@ -9,7 +9,7 @@ import { useGuestIdentity } from '../context/GuestIdentityContext';
import { useEventStats } from '../context/EventStatsContext';
import { useEventData } from '../hooks/useEventData';
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X } from 'lucide-react';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset, X, Camera, ArrowUpRight } from 'lucide-react';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
import { useEventBranding } from '../context/EventBrandingContext';
import type { EventBranding } from '../types/event-branding';
@@ -67,20 +67,47 @@ export default function HomePage() {
const accentColor = branding.primaryColor;
const secondaryAccent = branding.secondaryColor;
const primaryActions = React.useMemo(
const statItems = React.useMemo(
() => [
{
icon: <Users className="h-4 w-4" aria-hidden />,
label: t('home.stats.online'),
value: `${stats.onlineGuests}`,
},
{
icon: <Sparkles className="h-4 w-4" aria-hidden />,
label: t('home.stats.tasksSolved'),
value: `${stats.tasksSolved}`,
},
{
icon: <TimerReset className="h-4 w-4" aria-hidden />,
label: t('home.stats.lastUpload'),
value: latestUploadText,
},
{
icon: <CheckCircle2 className="h-4 w-4" aria-hidden />,
label: t('home.stats.completedTasks'),
value: `${completedCount}`,
},
],
[completedCount, latestUploadText, stats.onlineGuests, stats.tasksSolved, t],
);
const quickActions = React.useMemo(
() => [
{
to: 'upload',
label: t('home.actions.items.upload.label'),
description: t('home.actions.items.upload.description'),
icon: <Camera className="h-5 w-5" aria-hidden />,
highlight: true,
},
{
to: 'tasks',
label: t('home.actions.items.tasks.label'),
description: t('home.actions.items.tasks.description'),
icon: <Sparkles className="h-5 w-5" aria-hidden />,
},
{
to: 'upload',
label: t('home.actions.items.upload.label'),
description: t('home.actions.items.upload.description'),
icon: <UploadCloud className="h-5 w-5" aria-hidden />,
},
{
to: 'gallery',
label: t('home.actions.items.gallery.label'),
@@ -105,7 +132,7 @@ export default function HomePage() {
}
return (
<div className="space-y-6 pb-24">
<div className="space-y-6 pb-32">
{heroVisible && (
<HeroCard
name={displayName}
@@ -114,73 +141,36 @@ export default function HomePage() {
t={t}
branding={branding}
onDismiss={dismissHero}
ctaLabel={t('home.actions.items.tasks.label')}
ctaHref={`/e/${encodeURIComponent(token)}/tasks`}
/>
)}
<Card style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<CardContent className="grid grid-cols-1 gap-4 py-4 sm:grid-cols-4">
<StatTile
icon={<Users className="h-4 w-4" aria-hidden />}
label={t('home.stats.online')}
value={`${stats.onlineGuests}`}
accentColor={accentColor}
/>
<StatTile
icon={<Sparkles className="h-4 w-4" aria-hidden />}
label={t('home.stats.tasksSolved')}
value={`${stats.tasksSolved}`}
accentColor={accentColor}
/>
<StatTile
icon={<TimerReset className="h-4 w-4" aria-hidden />}
label={t('home.stats.lastUpload')}
value={latestUploadText}
accentColor={accentColor}
/>
<StatTile
icon={<CheckCircle2 className="h-4 w-4" aria-hidden />}
label={t('home.stats.completedTasks')}
value={`${completedCount}`}
accentColor={accentColor}
/>
</CardContent>
</Card>
<StatsRibbon items={statItems} accentColor={accentColor} fontFamily={branding.fontFamily} />
<section className="space-y-3">
<div className="flex items-center justify-between" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
{t('home.actions.title')}
</h2>
<span className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</span>
<section className="space-y-4" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>
<div className="flex items-center justify-between">
<div>
<h2 className="text-base font-semibold text-foreground">{t('home.actions.title')}</h2>
<p className="text-xs text-muted-foreground">{t('home.actions.subtitle')}</p>
</div>
<ArrowUpRight className="h-5 w-5 text-muted-foreground" aria-hidden />
</div>
<div className="grid gap-3 sm:grid-cols-2">
{primaryActions.map((action) => (
<Link to={action.to} key={action.to} className="block touch-manipulation">
<Card className="transition-all hover:shadow-lg">
<CardContent className="flex items-center gap-3 py-4">
<div
className="flex h-10 w-10 items-center justify-center rounded-lg shadow-sm"
style={{
backgroundColor: `${secondaryAccent}1a`,
color: secondaryAccent,
}}
>
{action.icon}
</div>
<div className="flex flex-col">
<span className="text-base font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{action.label}</span>
<span className="text-sm text-muted-foreground">{action.description}</span>
</div>
</CardContent>
</Card>
</Link>
<div className="grid gap-3 sm:grid-cols-3">
{quickActions.map((action) => (
<QuickActionCard
key={action.to}
action={action}
accentColor={accentColor}
secondaryAccent={secondaryAccent}
/>
))}
</div>
<Button
variant="outline"
asChild
className="w-full touch-manipulation"
style={{ borderColor: `${accentColor}33` }}
className="w-full touch-manipulation border-dashed"
style={{ borderColor: `${accentColor}44` }}
>
<Link to="queue">{t('home.actions.queueButton')}</Link>
</Button>
@@ -217,6 +207,8 @@ function HeroCard({
t,
branding,
onDismiss,
ctaLabel,
ctaHref,
}: {
name: string;
eventName: string;
@@ -224,6 +216,8 @@ function HeroCard({
t: TranslateFn;
branding: EventBranding;
onDismiss: () => void;
ctaLabel?: string;
ctaHref?: string;
}) {
const heroTitle = t('home.hero.title').replace('{name}', name);
const heroDescription = t('home.hero.description').replace('{eventName}', eventName);
@@ -249,33 +243,114 @@ function HeroCard({
<X className="h-4 w-4" aria-hidden />
<span className="sr-only">{t('common.actions.close')}</span>
</Button>
<CardHeader className="space-y-2 pr-10">
<CardHeader className="space-y-4 pr-10">
<CardDescription className="text-sm text-white/80">{t('home.hero.subtitle')}</CardDescription>
<CardTitle className="text-2xl font-bold leading-snug">{heroTitle}</CardTitle>
<p className="text-sm text-white/85">{heroDescription}</p>
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
<div className="flex flex-wrap items-center gap-3">
<p className="text-sm font-medium text-white/90">{progressMessage}</p>
{ctaHref && ctaLabel && (
<Button
variant="secondary"
size="sm"
asChild
className="rounded-full bg-white/15 px-4 py-1 text-xs font-semibold uppercase tracking-wide text-white hover:bg-white/25"
>
<Link to={ctaHref}>{ctaLabel}</Link>
</Button>
)}
</div>
</CardHeader>
</Card>
);
}
function StatTile({ icon, label, value, accentColor }: { icon: React.ReactNode; label: string; value: string; accentColor: string }) {
function StatsRibbon({
items,
accentColor,
fontFamily,
}: {
items: { icon: React.ReactNode; label: string; value: string }[];
accentColor: string;
fontFamily?: string | null;
}) {
return (
<div className="flex items-center gap-3 rounded-lg border bg-muted/30 px-3 py-2">
<div className="overflow-hidden rounded-3xl border border-muted/40 bg-white/70 shadow-sm backdrop-blur">
<div
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow-sm"
style={{ color: accentColor }}
className="flex gap-3 overflow-x-auto px-4 py-3 [scrollbar-width:none] sm:grid sm:grid-cols-4 sm:overflow-visible"
style={fontFamily ? { fontFamily } : undefined}
>
{icon}
</div>
<div className="flex flex-col">
<span className="text-xs uppercase tracking-wide text-muted-foreground">{label}</span>
<span className="text-lg font-semibold text-foreground">{value}</span>
{items.map((item) => (
<div
key={item.label}
className="flex min-w-[150px] flex-1 items-center gap-3 rounded-2xl border border-transparent bg-white/60 px-3 py-2 shadow-sm transition hover:-translate-y-0.5 hover:border-white"
>
<div
className="flex h-10 w-10 items-center justify-center rounded-full bg-white shadow"
style={{ color: accentColor }}
>
{item.icon}
</div>
<div className="flex flex-col">
<span className="text-[11px] uppercase tracking-wide text-muted-foreground">{item.label}</span>
<span className="text-lg font-semibold text-foreground">{item.value}</span>
</div>
</div>
))}
</div>
</div>
);
}
function QuickActionCard({
action,
accentColor,
secondaryAccent,
}: {
action: { to: string; label: string; description: string; icon: React.ReactNode; highlight?: boolean };
accentColor: string;
secondaryAccent: string;
}) {
const highlightStyle = action.highlight
? {
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
color: '#fff',
}
: undefined;
return (
<Link to={action.to} className="group block">
<Card
className="relative overflow-hidden border-0 shadow-sm transition-all group-hover:shadow-lg"
style={highlightStyle}
>
<CardContent className="flex items-start gap-4 py-4">
<div
className={`flex h-11 w-11 items-center justify-center rounded-2xl shadow-sm ${
action.highlight ? 'bg-white/15 text-white' : 'bg-pink-50'
}`}
style={!action.highlight ? { color: secondaryAccent } : undefined}
>
{action.icon}
</div>
<div className="flex flex-1 flex-col">
<span className={`text-base font-semibold ${action.highlight ? 'text-white' : 'text-foreground'}`}>
{action.label}
</span>
<span className={`text-sm ${action.highlight ? 'text-white/80' : 'text-muted-foreground'}`}>
{action.description}
</span>
</div>
<ArrowUpRight
className={`h-4 w-4 transition ${action.highlight ? 'text-white/70 group-hover:translate-x-0.5 group-hover:-translate-y-0.5' : 'text-muted-foreground group-hover:text-foreground'}`}
aria-hidden
/>
</CardContent>
</Card>
</Link>
);
}
function formatLatestUpload(isoDate: string | null, t: TranslateFn) {
if (!isoDate) {
return t('home.latestUpload.none');

View File

@@ -2,9 +2,11 @@ import React, { useState, useEffect } from 'react';
import { useParams, useLocation, useNavigate } from 'react-router-dom';
import { Dialog, DialogContent } from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Heart, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { Heart, ChevronLeft, ChevronRight, X, Share2 } from 'lucide-react';
import { likePhoto } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation';
import { sharePhotoLink } from '../lib/sharePhoto';
import { useToast } from '../components/ToastHost';
type Photo = {
id: number;
@@ -33,6 +35,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const photoId = params.photoId;
const eventToken = params.token || token;
const { t } = useTranslation();
const toast = useToast();
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [loading, setLoading] = useState(true);
@@ -41,6 +44,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
const [taskLoading, setTaskLoading] = useState(false);
const [likes, setLikes] = useState<number>(0);
const [liked, setLiked] = useState(false);
const [shareLoading, setShareLoading] = useState(false);
// Determine mode and photo
const isStandalone = !photos || photos.length === 0;
@@ -197,6 +201,30 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
}
}
async function onShare() {
if (!photo || !eventToken) return;
setShareLoading(true);
try {
const result = await sharePhotoLink({
token: eventToken,
photoId: photo.id,
title: photo.task_title ?? task?.title ?? t('share.title', 'Geteiltes Foto'),
text: t('share.shareText', { event: '' }),
});
if (result.method === 'clipboard') {
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} else if (result.method === 'manual') {
window.prompt(t('share.manualPrompt', 'Link kopieren'), result.url);
}
} catch (error) {
console.error('share failed', error);
toast.push({ text: t('share.error', 'Teilen fehlgeschlagen'), type: 'error' });
} finally {
setShareLoading(false);
}
}
function onOpenChange(open: boolean) {
if (!open) handleClose();
}
@@ -216,6 +244,15 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
<Heart className={`mr-1 h-4 w-4 ${liked ? 'fill-red-400 text-red-400' : ''}`} />
{likes}
</Button>
<Button
variant="secondary"
size="sm"
onClick={onShare}
disabled={shareLoading || !eventToken || !photo}
>
<Share2 className="mr-1 h-4 w-4" />
{t('share.button', 'Teilen')}
</Button>
</div>
<div className="flex items-center gap-1">
{currentIndexVal > 0 && (

View File

@@ -0,0 +1,121 @@
import React from 'react';
import { useParams, Link } from 'react-router-dom';
import { Button } from '@/components/ui/button';
import { fetchPhotoShare } from '../services/photosApi';
import { useTranslation } from '../i18n/useTranslation';
import { useToast } from '../components/ToastHost';
import { Loader2 } from 'lucide-react';
interface ShareResponse {
slug: string;
expires_at?: string;
photo: {
id: number;
title?: string;
likes_count?: number;
emotion?: { name?: string; emoji?: string } | null;
image_urls: { full: string; thumbnail: string };
};
event?: { id: number; name?: string | null } | null;
}
export default function SharedPhotoPage() {
const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation();
const toast = useToast();
const [state, setState] = React.useState<{
loading: boolean;
error: string | null;
data: ShareResponse | null;
}>({ loading: true, error: null, data: null });
React.useEffect(() => {
let active = true;
if (!slug) return;
setState({ loading: true, error: null, data: null });
fetchPhotoShare(slug)
.then((data) => {
if (!active) return;
setState({ loading: false, error: null, data });
})
.catch((error: any) => {
if (!active) return;
setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null });
});
return () => {
active = false;
};
}, [slug, t]);
const handleCopy = React.useCallback(async () => {
try {
await navigator.clipboard.writeText(window.location.href);
toast.push({ text: t('share.copySuccess', 'Link kopiert!') });
} catch {
toast.push({ text: t('share.copyError', 'Link konnte nicht kopiert werden.'), type: 'error' });
}
}, [toast, t]);
if (state.loading) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-gradient-to-br from-pink-50 to-white px-4 text-center">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" aria-hidden />
<p className="text-sm text-muted-foreground">{t('share.loading', 'Moment wird geladen...')}</p>
</div>
);
}
if (state.error || !state.data) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gradient-to-br from-pink-50 to-white px-6 text-center">
<p className="text-lg font-semibold text-foreground">{t('share.expiredTitle', 'Link abgelaufen')}</p>
<p className="text-sm text-muted-foreground max-w-md">{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}</p>
<Button asChild>
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
</Button>
</div>
);
}
const { data } = state;
return (
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-8">
<div className="mx-auto flex max-w-2xl flex-col gap-5">
<div className="rounded-3xl border border-white/60 bg-white/80 p-6 text-center shadow">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</p>
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}</h1>
{data.photo.title && (
<p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>
)}
</div>
<div className="overflow-hidden rounded-[32px] border border-white/60 bg-black">
<img
src={data.photo.image_urls.full}
alt={data.photo.title ?? 'Foto'}
className="h-full w-full object-cover"
loading="lazy"
/>
</div>
{data.photo.emotion && (
<p className="text-center text-sm text-muted-foreground">
{data.photo.emotion.emoji} {data.photo.emotion.name}
</p>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Button variant="secondary" onClick={handleCopy}>
{t('share.copyLink', 'Link kopieren')}
</Button>
<Button asChild>
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
</Button>
</div>
</div>
</div>
);
}

View File

@@ -30,6 +30,7 @@ const LegalPage = React.lazy(() => import('./pages/LegalPage'));
const HelpCenterPage = React.lazy(() => import('./pages/HelpCenterPage'));
const HelpArticlePage = React.lazy(() => import('./pages/HelpArticlePage'));
const PublicGalleryPage = React.lazy(() => import('./pages/PublicGalleryPage'));
const SharedPhotoPage = React.lazy(() => import('./pages/SharedPhotoPage'));
const NotFoundPage = React.lazy(() => import('./pages/NotFoundPage'));
function HomeLayout() {
@@ -56,6 +57,7 @@ function HomeLayout() {
export const router = createBrowserRouter([
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout> },
{ path: '/share/:slug', element: <SharedPhotoPage /> },
{
path: '/setup/:token',
element: <SetupLayout />,

View File

@@ -134,3 +134,43 @@ export async function uploadPhoto(eventToken: string, file: File, taskId?: numbe
const json = await res.json();
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
}
export async function createPhotoShareLink(eventToken: string, photoId: number): Promise<{ slug: string; url: string; expires_at?: string }> {
const headers = getCsrfHeaders();
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventToken)}/photos/${photoId}/share`, {
method: 'POST',
credentials: 'include',
headers,
});
if (!res.ok) {
let payload: any = null;
try {
payload = await res.clone().json();
} catch {}
const error: UploadError = new Error(payload?.error?.message ?? 'Share link creation failed');
error.code = payload?.error?.code ?? 'share_failed';
error.status = res.status;
throw error;
}
return res.json();
}
export async function fetchPhotoShare(slug: string) {
const res = await fetch(`/api/v1/photo-shares/${encodeURIComponent(slug)}`, {
headers: { Accept: 'application/json' },
});
if (!res.ok) {
const payload = await res.json().catch(() => null);
const error: UploadError = new Error(payload?.error?.message ?? 'Share link unavailable');
error.code = payload?.error?.code ?? 'share_unavailable';
error.status = res.status;
throw error;
}
return res.json();
}

View File

@@ -1,8 +1,8 @@
<?php
use App\Http\Controllers\Api\EventPublicController;
use App\Http\Controllers\Api\LegalController;
use App\Http\Controllers\Api\HelpController;
use App\Http\Controllers\Api\LegalController;
use App\Http\Controllers\Api\Marketing\CouponPreviewController;
use App\Http\Controllers\Api\PackageController;
use App\Http\Controllers\Api\Tenant\DashboardController;
@@ -73,6 +73,13 @@ Route::prefix('v1')->name('api.v1.')->group(function () {
Route::get('/events/{token}/photos', [EventPublicController::class, 'photos'])->name('events.photos');
Route::get('/photos/{id}', [EventPublicController::class, 'photo'])->name('photos.show');
Route::post('/photos/{id}/like', [EventPublicController::class, 'like'])->name('photos.like');
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
->whereNumber('photo')
->name('photos.share');
Route::get('/photo-shares/{slug}', [EventPublicController::class, 'shareLink'])->name('photo-shares.show');
Route::get('/photo-shares/{slug}/asset/{variant}', [EventPublicController::class, 'shareLinkAsset'])
->middleware('signed')
->name('photo-shares.asset');
Route::post('/events/{token}/upload', [EventPublicController::class, 'upload'])->name('events.upload');
Route::get('/gallery/{token}', [EventPublicController::class, 'gallery'])->name('gallery.show');

View File

@@ -276,6 +276,9 @@ Route::view('/e/{token}/{path?}', 'guest')
->where('token', '.*')
->where('path', '.*')
->name('guest.event');
Route::view('/share/{slug}', 'guest')
->where('slug', '[A-Za-z0-9]+')
->name('guest.share');
Route::middleware('auth')->group(function () {
Route::get('/tenant/events/{event}/photos/archive', EventPhotoArchiveController::class)
->name('tenant.events.photos.archive');

View File

@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature\Api;
use App\Models\Event;
use App\Models\Photo;
use App\Models\PhotoShareLink;
use App\Models\Task;
use App\Models\Tenant;
use App\Services\EventJoinTokenService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Config;
use Tests\TestCase;
class PhotoShareLinkTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Config::set('share-links.ttl_hours', 2);
}
public function test_guest_can_create_share_link(): void
{
$tenant = Tenant::factory()->create();
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
$photo = Photo::factory()->for($event)->create([
'status' => 'approved',
]);
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
$response = $this->withHeaders([
'X-Device-Id' => 'device-share',
'Accept' => 'application/json',
])->postJson("/api/v1/events/{$token}/photos/{$photo->id}/share");
$response->assertOk();
$response->assertJsonStructure(['slug', 'url', 'expires_at']);
$this->assertDatabaseHas('photo_share_links', [
'photo_id' => $photo->id,
'slug' => $response->json('slug'),
]);
}
public function test_share_link_cannot_be_created_for_unrelated_photo(): void
{
$event = Event::factory()->create(['status' => 'published']);
$otherEvent = Event::factory()->create(['status' => 'published']);
$photo = Photo::factory()->for($otherEvent)->create(['status' => 'approved']);
$token = app(EventJoinTokenService::class)->createToken($event)->plain_token;
$response = $this->withHeaders([
'X-Device-Id' => 'device-share',
'Accept' => 'application/json',
])->postJson("/api/v1/events/{$token}/photos/{$photo->id}/share");
$response->assertNotFound();
}
public function test_share_payload_exposes_public_photo_data(): void
{
$tenant = Tenant::factory()->create();
$event = Event::factory()->for($tenant)->create(['status' => 'published']);
$task = Task::factory()->for($tenant)->create();
$photo = Photo::factory()->for($event)->create([
'status' => 'approved',
'task_id' => $task->id,
]);
$share = PhotoShareLink::factory()->for($photo)->create([
'expires_at' => now()->addDay(),
]);
$response = $this->getJson("/api/v1/photo-shares/{$share->slug}");
$response->assertOk();
$response->assertJsonPath('photo.id', $photo->id);
$response->assertJsonPath('photo.image_urls.full', fn ($value) => str_contains($value, '/photo-shares/'));
$response->assertJsonPath('event.id', $event->id);
}
}