Update guest PWA v2 UI and likes
This commit is contained in:
@@ -28,6 +28,7 @@ use Filament\Schemas\Schema;
|
||||
use Filament\Tables;
|
||||
use Filament\Tables\Table;
|
||||
use Illuminate\Database\Eloquent\Collection;
|
||||
use Illuminate\Support\Arr;
|
||||
use UnitEnum;
|
||||
|
||||
class EventResource extends Resource
|
||||
@@ -264,6 +265,67 @@ class EventResource extends Resource
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
Actions\Action::make('set_demo_read_only')
|
||||
->label(__('admin.events.join_link.demo_read_only_action'))
|
||||
->icon('heroicon-o-lock-closed')
|
||||
->color('gray')
|
||||
->size('xs')
|
||||
->modalHeading(function (Actions\Action $action, Event $record): string {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return $token
|
||||
? __('admin.events.join_link.demo_read_only_heading', [
|
||||
'label' => $token->label ?: __('admin.events.join_link.token_default', ['id' => $token->id]),
|
||||
])
|
||||
: __('admin.events.join_link.demo_read_only_heading_fallback');
|
||||
})
|
||||
->schema([
|
||||
Toggle::make('demo_read_only')
|
||||
->label(__('admin.events.join_link.demo_read_only_label'))
|
||||
->helperText(__('admin.events.join_link.demo_read_only_help')),
|
||||
])
|
||||
->fillForm(function (Actions\Action $action, Event $record): array {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
return [
|
||||
'demo_read_only' => (bool) Arr::get($token?->metadata ?? [], 'demo_read_only', false),
|
||||
];
|
||||
})
|
||||
->action(function (array $data, Actions\Action $action, Event $record): void {
|
||||
$token = static::resolveJoinTokenFromAction($record, $action);
|
||||
|
||||
if (! $token) {
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.demo_read_only_missing'))
|
||||
->danger()
|
||||
->send();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$metadata = is_array($token->metadata) ? $token->metadata : [];
|
||||
$enabled = (bool) ($data['demo_read_only'] ?? false);
|
||||
|
||||
if ($enabled) {
|
||||
$metadata['demo_read_only'] = true;
|
||||
} else {
|
||||
unset($metadata['demo_read_only']);
|
||||
}
|
||||
|
||||
$token->metadata = empty($metadata) ? null : $metadata;
|
||||
$token->save();
|
||||
|
||||
app(SuperAdminAuditLogger::class)->recordModelMutation(
|
||||
'updated',
|
||||
$token,
|
||||
source: static::class
|
||||
);
|
||||
|
||||
Notification::make()
|
||||
->title(__('admin.events.join_link.demo_read_only_success'))
|
||||
->success()
|
||||
->send();
|
||||
}),
|
||||
])
|
||||
->modalContent(function (Actions\Action $action, $record) {
|
||||
$tokens = $record->joinTokens()
|
||||
@@ -335,6 +397,7 @@ class EventResource extends Resource
|
||||
'expires_at' => optional($token->expires_at)->toIso8601String(),
|
||||
'revoked_at' => optional($token->revoked_at)->toIso8601String(),
|
||||
'is_active' => $token->isActive(),
|
||||
'demo_read_only' => (bool) Arr::get($token->metadata ?? [], 'demo_read_only', false),
|
||||
'created_at' => optional($token->created_at)->toIso8601String(),
|
||||
'layouts' => $layouts,
|
||||
'layouts_url' => route('api.v1.tenant.events.join-tokens.layouts.index', [
|
||||
|
||||
@@ -2985,6 +2985,54 @@ class EventPublicController extends BaseController
|
||||
return response()->json(['liked' => true, 'likes_count' => $count]);
|
||||
}
|
||||
|
||||
public function unlike(Request $request, int $id)
|
||||
{
|
||||
$deviceId = (string) $request->header('X-Device-Id', 'anon');
|
||||
$deviceId = substr(preg_replace('/[^a-zA-Z0-9_-]/', '', $deviceId), 0, 64);
|
||||
if ($deviceId === '') {
|
||||
$deviceId = 'anon';
|
||||
}
|
||||
|
||||
$photo = DB::table('photos')
|
||||
->join('events', 'photos.event_id', '=', 'events.id')
|
||||
->where('photos.id', $id)
|
||||
->where('events.status', 'published')
|
||||
->first(['photos.id', 'photos.event_id']);
|
||||
if (! $photo) {
|
||||
return ApiError::response(
|
||||
'photo_not_found',
|
||||
'Photo Not Found',
|
||||
'Photo not found or event not public.',
|
||||
Response::HTTP_NOT_FOUND,
|
||||
['photo_id' => $id]
|
||||
);
|
||||
}
|
||||
|
||||
$exists = DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->exists();
|
||||
if (! $exists) {
|
||||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
||||
|
||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
||||
}
|
||||
|
||||
DB::beginTransaction();
|
||||
try {
|
||||
DB::table('photo_likes')->where('photo_id', $id)->where('guest_name', $deviceId)->delete();
|
||||
DB::table('photos')->where('id', $id)->update([
|
||||
'likes_count' => DB::raw('case when likes_count > 0 then likes_count - 1 else 0 end'),
|
||||
'updated_at' => now(),
|
||||
]);
|
||||
DB::commit();
|
||||
} catch (\Throwable $e) {
|
||||
DB::rollBack();
|
||||
Log::warning('unlike failed', ['error' => $e->getMessage()]);
|
||||
}
|
||||
|
||||
$count = (int) DB::table('photos')->where('id', $id)->value('likes_count');
|
||||
|
||||
return response()->json(['liked' => false, 'likes_count' => $count]);
|
||||
}
|
||||
|
||||
public function upload(Request $request, string $token)
|
||||
{
|
||||
$result = $this->resolvePublishedEvent($request, $token, ['id']);
|
||||
|
||||
@@ -81,6 +81,11 @@ class ContentSecurityPolicy
|
||||
'https:',
|
||||
];
|
||||
|
||||
$workerSources = [
|
||||
"'self'",
|
||||
'blob:',
|
||||
];
|
||||
|
||||
$paypalSources = [
|
||||
'https://www.paypal.com',
|
||||
'https://www.paypalobjects.com',
|
||||
@@ -153,6 +158,7 @@ class ContentSecurityPolicy
|
||||
'font-src' => array_unique($fontSources),
|
||||
'connect-src' => array_unique($connectSources),
|
||||
'media-src' => array_unique($mediaSources),
|
||||
'worker-src' => array_unique($workerSources),
|
||||
'frame-src' => array_unique($frameSources),
|
||||
'form-action' => ["'self'"],
|
||||
'base-uri' => ["'self'"],
|
||||
|
||||
@@ -30,7 +30,7 @@ vi.mock('@tamagui/sheet', () => {
|
||||
|
||||
vi.mock('react-router-dom', () => ({
|
||||
useNavigate: () => vi.fn(),
|
||||
useSearchParams: () => [new URLSearchParams()],
|
||||
useSearchParams: () => [new URLSearchParams(), vi.fn()],
|
||||
}));
|
||||
|
||||
vi.mock('lucide-react', () => ({
|
||||
@@ -48,9 +48,14 @@ vi.mock('lucide-react', () => ({
|
||||
Trophy: () => <span>trophy</span>,
|
||||
Play: () => <span>play</span>,
|
||||
Share2: () => <span>share</span>,
|
||||
MessageSquare: () => <span>message</span>,
|
||||
Copy: () => <span>copy</span>,
|
||||
ChevronLeft: () => <span>chevron-left</span>,
|
||||
ChevronRight: () => <span>chevron-right</span>,
|
||||
QrCode: () => <span>qr</span>,
|
||||
Link: () => <span>link</span>,
|
||||
Users: () => <span>users</span>,
|
||||
Heart: () => <span>heart</span>,
|
||||
}));
|
||||
|
||||
vi.mock('../components/AppShell', () => ({
|
||||
@@ -73,6 +78,10 @@ vi.mock('@/guest/services/pendingUploadsApi', () => ({
|
||||
|
||||
vi.mock('../services/photosApi', () => ({
|
||||
fetchGallery: vi.fn().mockResolvedValue({ data: [], next_cursor: null, latest_photo_at: null, notModified: false }),
|
||||
fetchPhoto: vi.fn().mockResolvedValue(null),
|
||||
likePhoto: vi.fn().mockResolvedValue(0),
|
||||
unlikePhoto: vi.fn().mockResolvedValue(0),
|
||||
createPhotoShareLink: vi.fn().mockResolvedValue({ url: null }),
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/usePollGalleryDelta', () => ({
|
||||
@@ -136,7 +145,7 @@ describe('Guest v2 screens copy', () => {
|
||||
</EventDataProvider>
|
||||
);
|
||||
|
||||
expect(screen.getByText('Gallery')).toBeInTheDocument();
|
||||
expect(screen.getByText('Neues Foto hochladen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders upload preview prompt', () => {
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import React from 'react';
|
||||
import { YStack } from '@tamagui/stacks';
|
||||
import { XStack, YStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { X } from 'lucide-react';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
|
||||
export type CompassAction = {
|
||||
key: string;
|
||||
@@ -40,6 +42,10 @@ export default function CompassHub({
|
||||
}: CompassHubProps) {
|
||||
const close = () => onOpenChange(false);
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||
const tileShadow = isDark
|
||||
? '0 10px 0 rgba(2, 6, 23, 0.55), 0 20px 24px rgba(2, 6, 23, 0.45)'
|
||||
: '0 10px 0 rgba(15, 23, 42, 0.18), 0 18px 22px rgba(15, 23, 42, 0.16)';
|
||||
const [visible, setVisible] = React.useState(open);
|
||||
const [closing, setClosing] = React.useState(false);
|
||||
|
||||
@@ -86,10 +92,11 @@ export default function CompassHub({
|
||||
justifyContent="center"
|
||||
pointerEvents="box-none"
|
||||
>
|
||||
<YStack alignItems="center" justifyContent="center" gap="$3" pointerEvents="auto">
|
||||
<Text fontSize="$5" fontFamily="$display" fontWeight="$8" color="$color">
|
||||
{title}
|
||||
</Text>
|
||||
<YStack
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
pointerEvents="auto"
|
||||
>
|
||||
<YStack
|
||||
key={closing ? 'compass-out' : 'compass-in'}
|
||||
width={280}
|
||||
@@ -97,6 +104,27 @@ export default function CompassHub({
|
||||
position="relative"
|
||||
className={closing ? 'guest-compass-flyout' : 'guest-compass-flyin'}
|
||||
>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
onPress={close}
|
||||
aria-label="Close compass"
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: -18,
|
||||
top: -18,
|
||||
boxShadow: tileShadow,
|
||||
transform: 'rotate(-6deg)',
|
||||
zIndex: 2,
|
||||
}}
|
||||
>
|
||||
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
{quadrants.map((action, index) => (
|
||||
<Button
|
||||
key={action.key}
|
||||
@@ -107,11 +135,14 @@ export default function CompassHub({
|
||||
width={120}
|
||||
height={120}
|
||||
borderRadius={24}
|
||||
backgroundColor="$surface"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
position="absolute"
|
||||
{...quadrantPositions[index]}
|
||||
style={{ boxShadow: tileShadow }}
|
||||
>
|
||||
<YStack alignItems="center" gap="$2">
|
||||
{action.icon}
|
||||
@@ -131,10 +162,17 @@ export default function CompassHub({
|
||||
height={90}
|
||||
borderRadius={45}
|
||||
backgroundColor="$primary"
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
position="absolute"
|
||||
top="50%"
|
||||
left="50%"
|
||||
style={{ transform: 'translate(-45px, -45px)' }}
|
||||
style={{
|
||||
transform: 'translate(-45px, -45px)',
|
||||
boxShadow: tileShadow,
|
||||
}}
|
||||
>
|
||||
<YStack alignItems="center" gap="$1">
|
||||
{centerAction.icon}
|
||||
@@ -144,9 +182,6 @@ export default function CompassHub({
|
||||
</YStack>
|
||||
</Button>
|
||||
</YStack>
|
||||
<Text fontSize="$2" color="$color" opacity={0.6}>
|
||||
Tap outside to close
|
||||
</Text>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
@@ -2,17 +2,21 @@ import React from 'react';
|
||||
import { YStack, XStack } from '@tamagui/stacks';
|
||||
import { SizableText as Text } from '@tamagui/text';
|
||||
import { Button } from '@tamagui/button';
|
||||
import { Camera, Image as ImageIcon, Filter } from 'lucide-react';
|
||||
import { Camera, ChevronLeft, ChevronRight, Heart, Share2, Sparkles, X } from 'lucide-react';
|
||||
import AppShell from '../components/AppShell';
|
||||
import PhotoFrameTile from '../components/PhotoFrameTile';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import { useEventData } from '../context/EventDataContext';
|
||||
import { fetchGallery } from '../services/photosApi';
|
||||
import { createPhotoShareLink, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
|
||||
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
|
||||
import { useGuestThemeVariant } from '../lib/guestTheme';
|
||||
import { useTranslation } from '@/guest/i18n/useTranslation';
|
||||
import { useLocale } from '@/guest/i18n/LocaleContext';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { buildEventPath } from '../lib/routes';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
import { usePollStats } from '../hooks/usePollStats';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
|
||||
type GalleryFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
|
||||
@@ -25,6 +29,12 @@ type GalleryTile = {
|
||||
sessionId?: string | null;
|
||||
};
|
||||
|
||||
type LightboxPhoto = {
|
||||
id: number;
|
||||
imageUrl: string;
|
||||
likes: number;
|
||||
};
|
||||
|
||||
function normalizeImageUrl(src?: string | null) {
|
||||
if (!src) {
|
||||
return '';
|
||||
@@ -43,20 +53,35 @@ function normalizeImageUrl(src?: string | null) {
|
||||
}
|
||||
|
||||
export default function GalleryScreen() {
|
||||
const { token } = useEventData();
|
||||
const { token, event } = useEventData();
|
||||
const { t } = useTranslation();
|
||||
const { locale } = useLocale();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||
const cardShadow = bentoSurface.shadow;
|
||||
const hardShadow = isDark
|
||||
? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)'
|
||||
: '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)';
|
||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const [photos, setPhotos] = React.useState<GalleryTile[]>([]);
|
||||
const [loading, setLoading] = React.useState(false);
|
||||
const { data: delta } = usePollGalleryDelta(token ?? null, { locale });
|
||||
const { stats } = usePollStats(token ?? null, 12000);
|
||||
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
|
||||
const uploadPath = React.useMemo(() => buildEventPath(token ?? null, '/upload'), [token]);
|
||||
const numberFormatter = React.useMemo(() => new Intl.NumberFormat(locale), [locale]);
|
||||
const [lightboxPhoto, setLightboxPhoto] = React.useState<LightboxPhoto | null>(null);
|
||||
const [lightboxLoading, setLightboxLoading] = React.useState(false);
|
||||
const [likesById, setLikesById] = React.useState<Record<number, number>>({});
|
||||
const [shareSheet, setShareSheet] = React.useState<{ url: string | null; loading: boolean }>({
|
||||
url: null,
|
||||
loading: false,
|
||||
});
|
||||
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
|
||||
const touchStartX = React.useRef<number | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) {
|
||||
@@ -142,6 +167,15 @@ export default function GalleryScreen() {
|
||||
const rightColumn = displayPhotos.filter((_, index) => index % 2 === 1);
|
||||
const isEmpty = !loading && displayPhotos.length === 0;
|
||||
const isSingle = !loading && displayPhotos.length === 1;
|
||||
const selectedPhotoId = Number(searchParams.get('photo') ?? 0);
|
||||
const lightboxIndex = React.useMemo(() => {
|
||||
if (!selectedPhotoId) {
|
||||
return -1;
|
||||
}
|
||||
return displayPhotos.findIndex((item) => item.id === selectedPhotoId);
|
||||
}, [displayPhotos, selectedPhotoId]);
|
||||
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
|
||||
const lightboxOpen = Boolean(selectedPhotoId);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
|
||||
@@ -164,10 +198,17 @@ export default function GalleryScreen() {
|
||||
const openLightbox = React.useCallback(
|
||||
(photoId: number) => {
|
||||
if (!token) return;
|
||||
navigate(buildEventPath(token, `/photo/${photoId}`));
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.set('photo', String(photoId));
|
||||
setSearchParams(next, { replace: false });
|
||||
},
|
||||
[navigate, token]
|
||||
[searchParams, setSearchParams, token]
|
||||
);
|
||||
const closeLightbox = React.useCallback(() => {
|
||||
const next = new URLSearchParams(searchParams);
|
||||
next.delete('photo');
|
||||
setSearchParams(next, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (delta.photos.length === 0) {
|
||||
@@ -208,73 +249,340 @@ export default function GalleryScreen() {
|
||||
});
|
||||
}, [delta.photos]);
|
||||
|
||||
const heroStatsLine = t(
|
||||
'galleryPage.hero.stats',
|
||||
{
|
||||
photoCount: numberFormatter.format(photos.length),
|
||||
likeCount: numberFormatter.format(stats.likesCount ?? 0),
|
||||
guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0),
|
||||
},
|
||||
`${numberFormatter.format(photos.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online`
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
setLikesById((prev) => {
|
||||
const next = { ...prev };
|
||||
for (const photo of photos) {
|
||||
if (next[photo.id] === undefined) {
|
||||
next[photo.id] = photo.likes;
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [photos]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lightboxOpen) {
|
||||
setLightboxPhoto(null);
|
||||
setLightboxLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const seed = lightboxSelected
|
||||
? { id: lightboxSelected.id, imageUrl: lightboxSelected.imageUrl, likes: lightboxSelected.likes }
|
||||
: null;
|
||||
if (seed) {
|
||||
setLightboxPhoto(seed);
|
||||
}
|
||||
|
||||
let active = true;
|
||||
setLightboxLoading(true);
|
||||
fetchPhoto(selectedPhotoId, locale)
|
||||
.then((photo) => {
|
||||
if (!active || !photo) return;
|
||||
const mapped = mapFullPhoto(photo as Record<string, unknown>);
|
||||
if (mapped) {
|
||||
setLightboxPhoto(mapped);
|
||||
setLikesById((prev) => ({ ...prev, [mapped.id]: mapped.likes }));
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Lightbox photo load failed', error);
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLightboxLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [lightboxOpen, lightboxSelected, locale, selectedPhotoId]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!lightboxOpen) {
|
||||
document.body.style.overflow = '';
|
||||
return;
|
||||
}
|
||||
document.body.style.overflow = 'hidden';
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
closeLightbox();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKey);
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
window.removeEventListener('keydown', handleKey);
|
||||
};
|
||||
}, [lightboxOpen]);
|
||||
|
||||
const goPrev = React.useCallback(() => {
|
||||
if (lightboxIndex <= 0) return;
|
||||
const prevId = displayPhotos[lightboxIndex - 1]?.id;
|
||||
if (prevId) {
|
||||
openLightbox(prevId);
|
||||
}
|
||||
}, [displayPhotos, lightboxIndex, openLightbox]);
|
||||
|
||||
const goNext = React.useCallback(() => {
|
||||
if (lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1) return;
|
||||
const nextId = displayPhotos[lightboxIndex + 1]?.id;
|
||||
if (nextId) {
|
||||
openLightbox(nextId);
|
||||
}
|
||||
}, [displayPhotos, lightboxIndex, openLightbox]);
|
||||
|
||||
const handleLike = React.useCallback(async () => {
|
||||
if (!lightboxPhoto) return;
|
||||
const isLiked = likedIds.has(lightboxPhoto.id);
|
||||
const current = likesById[lightboxPhoto.id] ?? lightboxPhoto.likes;
|
||||
const nextCount = Math.max(0, current + (isLiked ? -1 : 1));
|
||||
setLikedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isLiked) {
|
||||
next.delete(lightboxPhoto.id);
|
||||
} else {
|
||||
next.add(lightboxPhoto.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: nextCount }));
|
||||
try {
|
||||
const count = isLiked ? await unlikePhoto(lightboxPhoto.id) : await likePhoto(lightboxPhoto.id);
|
||||
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: count }));
|
||||
} catch (error) {
|
||||
console.error('Like failed', error);
|
||||
setLikedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (isLiked) {
|
||||
next.add(lightboxPhoto.id);
|
||||
} else {
|
||||
next.delete(lightboxPhoto.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setLikesById((prev) => ({ ...prev, [lightboxPhoto.id]: current }));
|
||||
}
|
||||
}, [lightboxPhoto, likedIds, likesById]);
|
||||
|
||||
const shareTitle = event?.name ?? t('share.title', 'Shared photo');
|
||||
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
|
||||
|
||||
const openShareSheet = React.useCallback(async () => {
|
||||
if (!lightboxPhoto || !token) return;
|
||||
setShareSheet({ url: null, loading: true });
|
||||
try {
|
||||
const payload = await createPhotoShareLink(token, lightboxPhoto.id);
|
||||
const url = payload?.url ?? null;
|
||||
setShareSheet({ url, loading: false });
|
||||
} catch (error) {
|
||||
console.error('Share failed', error);
|
||||
pushGuestToast({ text: t('share.error', 'Share failed'), type: 'error' });
|
||||
setShareSheet({ url: null, loading: false });
|
||||
}
|
||||
}, [lightboxPhoto, t, token]);
|
||||
|
||||
const closeShareSheet = React.useCallback(() => {
|
||||
setShareSheet({ url: null, loading: false });
|
||||
}, []);
|
||||
|
||||
const shareWhatsApp = React.useCallback(
|
||||
(url?: string | null) => {
|
||||
if (!url) return;
|
||||
const waUrl = `https://wa.me/?text=${encodeURIComponent(`${shareText} ${url}`)}`;
|
||||
window.open(waUrl, '_blank', 'noopener');
|
||||
closeShareSheet();
|
||||
},
|
||||
[closeShareSheet, shareText]
|
||||
);
|
||||
|
||||
const shareMessages = React.useCallback(
|
||||
(url?: string | null) => {
|
||||
if (!url) return;
|
||||
const smsUrl = `sms:?&body=${encodeURIComponent(`${shareText} ${url}`)}`;
|
||||
window.open(smsUrl, '_blank', 'noopener');
|
||||
closeShareSheet();
|
||||
},
|
||||
[closeShareSheet, shareText]
|
||||
);
|
||||
|
||||
const copyLink = React.useCallback(
|
||||
async (url?: string | null) => {
|
||||
if (!url) return;
|
||||
try {
|
||||
await navigator.clipboard?.writeText(url);
|
||||
pushGuestToast({ text: t('share.copySuccess', 'Link copied!') });
|
||||
} catch (error) {
|
||||
console.error('Copy failed', error);
|
||||
pushGuestToast({ text: t('share.copyError', 'Link could not be copied.'), type: 'error' });
|
||||
} finally {
|
||||
closeShareSheet();
|
||||
}
|
||||
},
|
||||
[closeShareSheet, t]
|
||||
);
|
||||
|
||||
const shareNative = React.useCallback(
|
||||
(url?: string | null) => {
|
||||
if (!url) return;
|
||||
const data: ShareData = {
|
||||
title: shareTitle,
|
||||
text: shareText,
|
||||
url,
|
||||
};
|
||||
if (navigator.share && (!navigator.canShare || navigator.canShare(data))) {
|
||||
navigator.share(data).catch(() => undefined);
|
||||
closeShareSheet();
|
||||
return;
|
||||
}
|
||||
void copyLink(url);
|
||||
},
|
||||
[closeShareSheet, copyLink, shareText, shareTitle]
|
||||
);
|
||||
|
||||
const handleTouchStart = (event: React.TouchEvent) => {
|
||||
touchStartX.current = event.touches[0]?.clientX ?? null;
|
||||
};
|
||||
|
||||
const handleTouchEnd = (event: React.TouchEvent) => {
|
||||
if (touchStartX.current === null) {
|
||||
return;
|
||||
}
|
||||
const endX = event.changedTouches[0]?.clientX ?? null;
|
||||
if (endX === null) {
|
||||
touchStartX.current = null;
|
||||
return;
|
||||
}
|
||||
const delta = endX - touchStartX.current;
|
||||
touchStartX.current = null;
|
||||
if (Math.abs(delta) < 60) {
|
||||
return;
|
||||
}
|
||||
if (delta > 0) {
|
||||
goPrev();
|
||||
return;
|
||||
}
|
||||
goNext();
|
||||
};
|
||||
|
||||
return (
|
||||
<AppShell>
|
||||
<YStack gap="$4">
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
padding="$2"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
gap="$3"
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$2"
|
||||
position="relative"
|
||||
overflow="hidden"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
backgroundImage: isDark
|
||||
? 'radial-gradient(120% 120% at 20% 20%, rgba(56, 189, 248, 0.18), transparent 55%), radial-gradient(120% 120% at 80% 15%, rgba(251, 113, 133, 0.18), transparent 60%)'
|
||||
: 'radial-gradient(130% 130% at 20% 20%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 25%, white), transparent 55%), radial-gradient(120% 120% at 80% 0%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 18%, white), transparent 60%)',
|
||||
boxShadow: hardShadow,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<ImageIcon size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
{t('galleryPage.title', 'Gallery')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.2}>
|
||||
{t('galleryPage.hero.label', 'Live-Galerie')}
|
||||
</Text>
|
||||
</XStack>
|
||||
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
|
||||
<Text fontSize="$6" fontFamily="$display" fontWeight="$8">
|
||||
{event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
|
||||
</Text>
|
||||
<Button
|
||||
size="$3"
|
||||
backgroundColor={mutedButton}
|
||||
backgroundColor="$primary"
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={() => navigate(uploadPath)}
|
||||
pressStyle={{ y: 2 }}
|
||||
style={{
|
||||
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
|
||||
}}
|
||||
>
|
||||
<Filter size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
{t('galleryPage.hero.upload', 'Neues Foto hochladen')}
|
||||
</Button>
|
||||
</XStack>
|
||||
<XStack gap="$2" flexWrap="wrap">
|
||||
{(
|
||||
[
|
||||
{ value: 'latest', label: t('galleryPage.filters.latest', 'Newest') },
|
||||
{ value: 'popular', label: t('galleryPage.filters.popular', 'Popular') },
|
||||
{ value: 'mine', label: t('galleryPage.filters.mine', 'My photos') },
|
||||
photos.some((photo) => photo.ingestSource === 'photobooth')
|
||||
? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') }
|
||||
: null,
|
||||
].filter(Boolean) as Array<{ value: GalleryFilter; label: string }>
|
||||
).map((chip) => (
|
||||
<Button
|
||||
key={chip.value}
|
||||
size="$3"
|
||||
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
|
||||
onPress={() => setFilter(chip.value)}
|
||||
>
|
||||
<Text fontSize="$2" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
{newUploads > 0 ? (
|
||||
<YStack
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
alignSelf="flex-start"
|
||||
>
|
||||
<Text fontSize="$1" fontWeight="$6">
|
||||
{t('galleryPage.feed.newUploads', { count: newUploads }, '{count} neue Uploads sind da.')}
|
||||
</Text>
|
||||
</YStack>
|
||||
) : null}
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{heroStatsLine}
|
||||
</Text>
|
||||
<YStack
|
||||
gap="$1"
|
||||
paddingTop="$1"
|
||||
borderTopWidth={1}
|
||||
borderTopColor={bentoSurface.borderColor}
|
||||
>
|
||||
<XStack gap="$1.5" flexWrap="wrap" justifyContent="center">
|
||||
{(
|
||||
[
|
||||
{ value: 'latest', label: t('galleryPage.filters.latest', 'Newest') },
|
||||
{ value: 'popular', label: t('galleryPage.filters.popular', 'Popular') },
|
||||
{ value: 'mine', label: t('galleryPage.filters.mine', 'My photos') },
|
||||
photos.some((photo) => photo.ingestSource === 'photobooth')
|
||||
? { value: 'photobooth', label: t('galleryPage.filters.photobooth', 'Photo booth') }
|
||||
: null,
|
||||
].filter(Boolean) as Array<{ value: GalleryFilter; label: string }>
|
||||
).map((chip) => (
|
||||
<Button
|
||||
key={chip.value}
|
||||
size="$2"
|
||||
backgroundColor={filter === chip.value ? '$primary' : mutedButton}
|
||||
borderRadius="$pill"
|
||||
borderWidth={1}
|
||||
borderColor={filter === chip.value ? '$primary' : mutedButtonBorder}
|
||||
onPress={() => setFilter(chip.value)}
|
||||
>
|
||||
<Text fontSize="$1" fontWeight="$6" color={filter === chip.value ? '#FFFFFF' : undefined}>
|
||||
{chip.label}
|
||||
</Text>
|
||||
</Button>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
|
||||
{isEmpty ? (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$3"
|
||||
alignItems="center"
|
||||
style={{
|
||||
@@ -312,18 +620,20 @@ export default function GalleryScreen() {
|
||||
) : isSingle ? (
|
||||
<YStack gap="$3">
|
||||
<Button unstyled onPress={() => openLightbox(displayPhotos[0].id)}>
|
||||
<PhotoFrameTile height={360} borderRadius="$card">
|
||||
<PhotoFrameTile height={360} borderRadius="$bento">
|
||||
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||
<img
|
||||
src={displayPhotos[0].imageUrl}
|
||||
alt={t('galleryPage.photo.alt', { id: displayPhotos[0].id }, 'Photo {id}')}
|
||||
alt={t('galleryPage.photo.alt', { id: displayPhotos[0].id, suffix: '' }, `Foto ${displayPhotos[0].id}`)}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</YStack>
|
||||
</PhotoFrameTile>
|
||||
</Button>
|
||||
<Button unstyled onPress={() => navigate(uploadPath)}>
|
||||
<PhotoFrameTile height={160} borderRadius="$card">
|
||||
<PhotoFrameTile height={160} borderRadius="$bento">
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" gap="$2" padding="$3">
|
||||
<YStack
|
||||
width={48}
|
||||
@@ -353,6 +663,7 @@ export default function GalleryScreen() {
|
||||
if (typeof tile === 'number') {
|
||||
return <PhotoFrameTile key={`left-${tile}`} height={140 + (index % 3) * 24} shimmer shimmerDelayMs={200 + index * 120} />;
|
||||
}
|
||||
const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`);
|
||||
return (
|
||||
<Button
|
||||
key={tile.id}
|
||||
@@ -363,8 +674,10 @@ export default function GalleryScreen() {
|
||||
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||
<img
|
||||
src={tile.imageUrl}
|
||||
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
alt={altText}
|
||||
loading={index < 4 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</YStack>
|
||||
</PhotoFrameTile>
|
||||
@@ -377,6 +690,7 @@ export default function GalleryScreen() {
|
||||
if (typeof tile === 'number') {
|
||||
return <PhotoFrameTile key={`right-${tile}`} height={120 + (index % 3) * 28} shimmer shimmerDelayMs={260 + index * 140} />;
|
||||
}
|
||||
const altText = t('galleryPage.photo.alt', { id: tile.id, suffix: '' }, `Foto ${tile.id}`);
|
||||
return (
|
||||
<Button
|
||||
key={tile.id}
|
||||
@@ -387,8 +701,10 @@ export default function GalleryScreen() {
|
||||
<YStack flex={1} width="100%" height="100%" alignItems="center" justifyContent="center">
|
||||
<img
|
||||
src={tile.imageUrl}
|
||||
alt={t('galleryPage.photo.alt', { id: tile.id }, 'Photo {id}')}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'contain' }}
|
||||
alt={altText}
|
||||
loading={index < 4 ? 'eager' : 'lazy'}
|
||||
decoding="async"
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
</YStack>
|
||||
</PhotoFrameTile>
|
||||
@@ -424,27 +740,254 @@ export default function GalleryScreen() {
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
</YStack>
|
||||
{lightboxOpen ? (
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
gap="$1"
|
||||
position="fixed"
|
||||
top={0}
|
||||
left={0}
|
||||
right={0}
|
||||
bottom={0}
|
||||
zIndex={2000}
|
||||
padding="$3"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
backgroundColor: isDark ? 'rgba(15, 23, 42, 0.75)' : 'rgba(15, 23, 42, 0.45)',
|
||||
backdropFilter: 'blur(12px)',
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('galleryPage.feed.title', 'Live feed')}
|
||||
</Text>
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{newUploads > 0
|
||||
? t('galleryPage.feed.newUploads', { count: newUploads }, '{count} new uploads just landed.')
|
||||
: t('galleryPage.feed.description', 'Updated every few seconds.')}
|
||||
</Text>
|
||||
<Button
|
||||
unstyled
|
||||
onPress={closeLightbox}
|
||||
style={{ position: 'absolute', inset: 0, zIndex: 0 }}
|
||||
aria-label={t('common.actions.close', 'Close')}
|
||||
/>
|
||||
<YStack
|
||||
width="100%"
|
||||
maxWidth={780}
|
||||
gap="$2"
|
||||
padding="$3"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
style={{ boxShadow: hardShadow, zIndex: 1 }}
|
||||
>
|
||||
<XStack alignItems="center" justifyContent="space-between">
|
||||
<YStack gap="$1">
|
||||
<Text fontSize="$1" color="$color" opacity={0.7} textTransform="uppercase" letterSpacing={1.1}>
|
||||
{t('galleryPage.hero.label', 'Live-Galerie')}
|
||||
</Text>
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
{event?.name ?? t('galleryPage.hero.eventFallback', 'Euer Event')}
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={closeLightbox}
|
||||
aria-label={t('common.actions.close', 'Close')}
|
||||
>
|
||||
<X size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</Button>
|
||||
</XStack>
|
||||
<YStack
|
||||
borderRadius="$bento"
|
||||
backgroundColor="$muted"
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
overflow="hidden"
|
||||
style={{ height: 'min(70vh, 520px)', boxShadow: cardShadow }}
|
||||
>
|
||||
<YStack flex={1} alignItems="center" justifyContent="center" padding="$2">
|
||||
{lightboxPhoto ? (
|
||||
<YStack
|
||||
flex={1}
|
||||
width="100%"
|
||||
height="100%"
|
||||
position="relative"
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
style={{ zIndex: 0 }}
|
||||
>
|
||||
<img
|
||||
src={lightboxPhoto.imageUrl}
|
||||
alt={t('galleryPage.photo.alt', { id: lightboxPhoto.id, suffix: '' }, `Foto ${lightboxPhoto.id}`)}
|
||||
style={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'contain',
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
zIndex: 0,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
unstyled
|
||||
onPress={handleLike}
|
||||
aria-label={t('galleryPage.photo.likeAria', 'Like')}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
right: 14,
|
||||
bottom: 14,
|
||||
zIndex: 10,
|
||||
}}
|
||||
>
|
||||
<YStack
|
||||
padding="$2.5"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={likedIds.has(lightboxPhoto.id) ? '#F43F5E' : mutedButtonBorder}
|
||||
alignItems="center"
|
||||
justifyContent="center"
|
||||
style={{
|
||||
boxShadow: isDark ? '0 8px 0 rgba(2, 6, 23, 0.55)' : '0 8px 0 rgba(15, 23, 42, 0.18)',
|
||||
}}
|
||||
>
|
||||
<Heart size={22} color={likedIds.has(lightboxPhoto.id) ? '#FFFFFF' : (isDark ? '#F8FAFF' : '#0F172A')} />
|
||||
</YStack>
|
||||
</Button>
|
||||
</YStack>
|
||||
) : (
|
||||
<Text fontSize="$2" color="$color" opacity={0.7}>
|
||||
{lightboxLoading ? t('galleryPage.loading', 'Loading…') : t('lightbox.errors.notFound', 'Photo not found')}
|
||||
</Text>
|
||||
)}
|
||||
</YStack>
|
||||
<XStack
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
padding="$2"
|
||||
borderTopWidth={1}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.35)' : 'rgba(255, 255, 255, 0.8)'}
|
||||
>
|
||||
<Text fontSize="$1" color="$color" opacity={0.7}>
|
||||
{lightboxIndex >= 0 ? `${lightboxIndex + 1} / ${displayPhotos.length}` : ''}
|
||||
</Text>
|
||||
<XStack
|
||||
gap="$1"
|
||||
padding="$1"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
alignItems="center"
|
||||
>
|
||||
<Button
|
||||
unstyled
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1.5"
|
||||
onPress={goPrev}
|
||||
disabled={lightboxIndex <= 0}
|
||||
opacity={lightboxIndex <= 0 ? 0.4 : 1}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<ChevronLeft size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$1" fontWeight="$6">
|
||||
{t('galleryPage.lightbox.prev', 'Prev')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
unstyled
|
||||
paddingHorizontal="$2.5"
|
||||
paddingVertical="$1.5"
|
||||
onPress={goNext}
|
||||
disabled={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1}
|
||||
opacity={lightboxIndex < 0 || lightboxIndex >= displayPhotos.length - 1 ? 0.4 : 1}
|
||||
>
|
||||
<XStack alignItems="center" gap="$1.5">
|
||||
<Text fontSize="$1" fontWeight="$6">
|
||||
{t('galleryPage.lightbox.next', 'Next')}
|
||||
</Text>
|
||||
<ChevronRight size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
<XStack alignItems="center" justifyContent="space-between" flexWrap="wrap" gap="$2">
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{lightboxPhoto
|
||||
? t(
|
||||
'galleryPage.lightbox.likes',
|
||||
{ count: likesById[lightboxPhoto.id] ?? lightboxPhoto.likes },
|
||||
'{count} likes'
|
||||
)
|
||||
: ''}
|
||||
</Text>
|
||||
<XStack
|
||||
gap="$1"
|
||||
padding="$1"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
alignItems="center"
|
||||
flexWrap="wrap"
|
||||
justifyContent="flex-end"
|
||||
>
|
||||
<Button unstyled onPress={openShareSheet} paddingHorizontal="$3" paddingVertical="$2">
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Share2 size={16} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{shareSheet.loading ? t('share.loading', 'Sharing...') : t('share.button', 'Share')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
</XStack>
|
||||
</XStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
</YStack>
|
||||
) : null}
|
||||
<ShareSheet
|
||||
open={shareSheet.loading || Boolean(shareSheet.url)}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
closeShareSheet();
|
||||
}
|
||||
}}
|
||||
photoId={lightboxPhoto?.id}
|
||||
eventName={event?.name ?? null}
|
||||
url={shareSheet.url}
|
||||
loading={shareSheet.loading}
|
||||
onShareNative={() => shareNative(shareSheet.url)}
|
||||
onShareWhatsApp={() => shareWhatsApp(shareSheet.url)}
|
||||
onShareMessages={() => shareMessages(shareSheet.url)}
|
||||
onCopyLink={() => copyLink(shareSheet.url)}
|
||||
/>
|
||||
</AppShell>
|
||||
);
|
||||
}
|
||||
|
||||
function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
|
||||
const id = Number(photo.id ?? 0);
|
||||
if (!id) return null;
|
||||
const imageUrl = normalizeImageUrl(
|
||||
(photo.full_url as string | null | undefined)
|
||||
?? (photo.file_path as string | null | undefined)
|
||||
?? (photo.thumbnail_url as string | null | undefined)
|
||||
?? (photo.thumbnail_path as string | null | undefined)
|
||||
?? (photo.url as string | null | undefined)
|
||||
?? (photo.image_url as string | null | undefined)
|
||||
);
|
||||
if (!imageUrl) return null;
|
||||
return {
|
||||
id,
|
||||
imageUrl,
|
||||
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -367,7 +367,7 @@ export default function TasksScreen() {
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Play size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
|
||||
<Text fontSize="$2" fontWeight="$7">
|
||||
{t('tasks.startTask', 'Start task')}
|
||||
{t('tasks.startTask', 'Aufgabe starten')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
|
||||
@@ -14,8 +14,8 @@ import { useGuestTaskProgress } from '@/guest/hooks/useGuestTaskProgress';
|
||||
import { fetchPendingUploadsSummary, type PendingUpload } from '@/guest/services/pendingUploadsApi';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '@/guest/lib/uploadErrorDialog';
|
||||
import { fetchTasks, type TaskItem } from '../services/tasksApi';
|
||||
import SurfaceCard from '../components/SurfaceCard';
|
||||
import { pushGuestToast } from '../lib/toast';
|
||||
import { getBentoSurfaceTokens } from '../lib/bento';
|
||||
|
||||
function getTaskValue(task: TaskItem, key: string): string | undefined {
|
||||
const value = task?.[key as keyof TaskItem];
|
||||
@@ -51,8 +51,12 @@ export default function UploadScreen() {
|
||||
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
|
||||
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
|
||||
const { isDark } = useGuestThemeVariant();
|
||||
const cardBorder = isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(15, 23, 42, 0.12)';
|
||||
const cardShadow = isDark ? '0 18px 40px rgba(2, 6, 23, 0.4)' : '0 16px 30px rgba(15, 23, 42, 0.12)';
|
||||
const bentoSurface = getBentoSurfaceTokens(isDark);
|
||||
const cardBorder = bentoSurface.borderColor;
|
||||
const cardShadow = bentoSurface.shadow;
|
||||
const hardShadow = isDark
|
||||
? '0 18px 0 rgba(2, 6, 23, 0.55), 0 32px 40px rgba(2, 6, 23, 0.55)'
|
||||
: '0 18px 0 rgba(15, 23, 42, 0.22), 0 30px 36px rgba(15, 23, 42, 0.2)';
|
||||
const iconColor = isDark ? '#F8FAFF' : '#0F172A';
|
||||
const mutedButton = isDark ? 'rgba(255, 255, 255, 0.08)' : 'rgba(15, 23, 42, 0.06)';
|
||||
const mutedButtonBorder = isDark ? 'rgba(255, 255, 255, 0.2)' : 'rgba(15, 23, 42, 0.12)';
|
||||
@@ -448,7 +452,19 @@ export default function UploadScreen() {
|
||||
<AppShell>
|
||||
<YStack gap="$4">
|
||||
{taskId ? (
|
||||
<SurfaceCard>
|
||||
<YStack
|
||||
padding="$3"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$2"
|
||||
style={{
|
||||
boxShadow: hardShadow,
|
||||
}}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Sparkles size={18} color={iconColor} />
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
@@ -475,27 +491,21 @@ export default function UploadScreen() {
|
||||
{taskError}
|
||||
</Text>
|
||||
) : null}
|
||||
</SurfaceCard>
|
||||
</YStack>
|
||||
) : null}
|
||||
<YStack
|
||||
borderRadius="$card"
|
||||
backgroundColor="$muted"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
overflow="hidden"
|
||||
style={{
|
||||
backgroundImage: isDark
|
||||
? 'linear-gradient(135deg, rgba(15, 23, 42, 0.7), rgba(8, 12, 24, 0.9)), radial-gradient(circle at 20% 20%, rgba(255, 79, 216, 0.2), transparent 50%)'
|
||||
: 'linear-gradient(135deg, rgba(255, 255, 255, 0.92), rgba(248, 250, 255, 0.82)), radial-gradient(circle at 20% 20%, color-mix(in oklab, var(--guest-primary, #FF5A5F) 16%, white), transparent 60%)',
|
||||
boxShadow: isExpanded
|
||||
? isDark
|
||||
? '0 28px 60px rgba(2, 6, 23, 0.55)'
|
||||
: '0 22px 44px rgba(15, 23, 42, 0.16)'
|
||||
: isDark
|
||||
? '0 22px 40px rgba(2, 6, 23, 0.5)'
|
||||
: '0 18px 32px rgba(15, 23, 42, 0.12)',
|
||||
borderRadius: isExpanded ? 28 : undefined,
|
||||
transition: 'box-shadow 360ms ease, border-radius 360ms ease',
|
||||
? 'radial-gradient(120% 120% at 12% 15%, rgba(56, 189, 248, 0.18), transparent 55%), radial-gradient(130% 130% at 88% 10%, rgba(251, 113, 133, 0.2), transparent 60%)'
|
||||
: 'radial-gradient(120% 120% at 12% 15%, color-mix(in oklab, var(--guest-primary, #0EA5E9) 18%, white), transparent 55%), radial-gradient(130% 130% at 88% 10%, color-mix(in oklab, var(--guest-secondary, #F43F5E) 18%, white), transparent 60%)',
|
||||
boxShadow: hardShadow,
|
||||
}}
|
||||
>
|
||||
<YStack
|
||||
@@ -697,61 +707,86 @@ export default function UploadScreen() {
|
||||
</YStack>
|
||||
) : null}
|
||||
</YStack>
|
||||
<XStack
|
||||
gap="$2"
|
||||
padding={isExpanded ? '$2' : '$3'}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
borderTopWidth={1}
|
||||
borderColor={cardBorder}
|
||||
backgroundColor={isDark ? 'rgba(10, 14, 28, 0.7)' : 'rgba(255, 255, 255, 0.75)'}
|
||||
>
|
||||
{cameraState === 'preview' ? null : (
|
||||
<>
|
||||
<Button
|
||||
size="$3"
|
||||
borderRadius="$pill"
|
||||
backgroundColor={mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={handlePick}
|
||||
paddingHorizontal="$4"
|
||||
gap="$2"
|
||||
alignSelf="center"
|
||||
flexShrink={0}
|
||||
justifyContent="center"
|
||||
>
|
||||
<Image size={16} color={iconColor} />
|
||||
<Text fontSize="$2" fontWeight="$6">
|
||||
{t('uploadV2.galleryCta', 'Upload from gallery')}
|
||||
</Text>
|
||||
</Button>
|
||||
{facingMode === 'user' ? (
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={mirror ? '$primary' : mutedButton}
|
||||
borderWidth={1}
|
||||
borderColor={mutedButtonBorder}
|
||||
onPress={() => setMirror((prev) => !prev)}
|
||||
>
|
||||
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</XStack>
|
||||
</YStack>
|
||||
{cameraState === 'preview' ? null : (
|
||||
<XStack gap="$2">
|
||||
<Button
|
||||
flex={1}
|
||||
height={64}
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
onPress={() => {
|
||||
if (cameraState === 'ready') {
|
||||
void handleCapture();
|
||||
return;
|
||||
}
|
||||
void startCamera();
|
||||
}}
|
||||
disabled={cameraState === 'starting' || cameraState === 'blocked' || cameraState === 'unsupported'}
|
||||
style={{ boxShadow: cardShadow }}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Camera size={18} color={iconColor} />
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{cameraState === 'ready'
|
||||
? t('upload.captureButton', 'Foto aufnehmen')
|
||||
: cameraState === 'starting'
|
||||
? t('upload.buttons.starting', 'Kamera startet…')
|
||||
: t('upload.buttons.startCamera', 'Kamera starten')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
<Button
|
||||
flex={1}
|
||||
height={64}
|
||||
borderRadius="$bento"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
onPress={handlePick}
|
||||
style={{ boxShadow: cardShadow }}
|
||||
>
|
||||
<XStack alignItems="center" gap="$2">
|
||||
<Image size={18} color={iconColor} />
|
||||
<Text fontSize="$3" fontWeight="$7">
|
||||
{t('uploadV2.galleryCta', 'Aus Galerie')}
|
||||
</Text>
|
||||
</XStack>
|
||||
</Button>
|
||||
{facingMode === 'user' ? (
|
||||
<Button
|
||||
size="$3"
|
||||
circular
|
||||
backgroundColor={mirror ? '$primary' : mutedButton}
|
||||
borderWidth={1}
|
||||
borderBottomWidth={3}
|
||||
borderColor={mutedButtonBorder}
|
||||
borderBottomColor={mutedButtonBorder}
|
||||
onPress={() => setMirror((prev) => !prev)}
|
||||
>
|
||||
<FlipHorizontal size={16} color={mirror ? '#FFFFFF' : iconColor} />
|
||||
</Button>
|
||||
) : null}
|
||||
</XStack>
|
||||
)}
|
||||
|
||||
<YStack
|
||||
padding="$4"
|
||||
borderRadius="$card"
|
||||
backgroundColor="$surface"
|
||||
borderRadius="$bentoLg"
|
||||
backgroundColor={bentoSurface.backgroundColor}
|
||||
borderWidth={1}
|
||||
borderColor={cardBorder}
|
||||
borderBottomWidth={3}
|
||||
borderColor={bentoSurface.borderColor}
|
||||
borderBottomColor={bentoSurface.borderBottomColor}
|
||||
gap="$2"
|
||||
style={{
|
||||
boxShadow: cardShadow,
|
||||
boxShadow: hardShadow,
|
||||
}}
|
||||
>
|
||||
<Text fontSize="$4" fontWeight="$7">
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fetchJson } from './apiClient';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
export { likePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi';
|
||||
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi';
|
||||
|
||||
export type GalleryPhoto = Record<string, unknown>;
|
||||
|
||||
|
||||
@@ -561,7 +561,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
subtitle: 'Tippe irgendwo, um zu fokussieren.',
|
||||
cta: 'Jetzt aufnehmen',
|
||||
},
|
||||
galleryCta: 'Direkt aus Deiner Galerie hochladen',
|
||||
galleryCta: 'Aus Galerie',
|
||||
tools: {
|
||||
grid: 'Raster',
|
||||
flash: 'Blitz',
|
||||
@@ -1471,7 +1471,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
|
||||
subtitle: 'Tap anywhere to focus.',
|
||||
cta: 'Capture now',
|
||||
},
|
||||
galleryCta: 'Upload directly from your gallery',
|
||||
galleryCta: 'From gallery',
|
||||
tools: {
|
||||
grid: 'Grid',
|
||||
flash: 'Flash',
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
// @ts-nocheck
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Page } from './_util';
|
||||
import { useNavigationType, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { Link, useNavigationType, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { usePollGalleryDelta } from '../polling/usePollGalleryDelta';
|
||||
import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
|
||||
import { Heart, Image as ImageIcon, ImagePlus, Share2, Users } from 'lucide-react';
|
||||
import { motion } from 'framer-motion';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
@@ -17,6 +17,7 @@ import { createPhotoShareLink } from '../services/photosApi';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
FADE_SCALE,
|
||||
FADE_UP,
|
||||
@@ -27,6 +28,7 @@ import {
|
||||
} from '../lib/motion';
|
||||
import PullToRefresh from '../components/PullToRefresh';
|
||||
import { triggerHaptic } from '../lib/haptics';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
|
||||
const allGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||
type GalleryPhoto = {
|
||||
@@ -67,6 +69,7 @@ export default function GalleryPage() {
|
||||
const navigationType = useNavigationType();
|
||||
const { t, locale } = useTranslation();
|
||||
const { branding } = useEventBranding();
|
||||
const stats = useEventStats();
|
||||
const { photos, loading, newCount, acknowledgeNew, refreshNow } = usePollGalleryDelta(token ?? '', locale);
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const photoIdParam = searchParams.get('photoId');
|
||||
@@ -304,6 +307,14 @@ export default function GalleryPage() {
|
||||
const badgeEmphasisClass = newCount > 0
|
||||
? 'border border-pink-200 bg-pink-500/15 text-pink-600'
|
||||
: 'border border-transparent bg-muted text-muted-foreground';
|
||||
const uploadUrl = token ? `/e/${encodeURIComponent(token)}/upload` : '/event';
|
||||
const heroStatsLine = t('galleryPage.hero.stats', {
|
||||
photoCount: numberFormatter.format(list.length),
|
||||
likeCount: numberFormatter.format(stats.likesCount ?? 0),
|
||||
guestCount: numberFormatter.format(stats.onlineGuests || stats.guestCount || 0),
|
||||
}, `${numberFormatter.format(list.length)} Fotos · ${numberFormatter.format(stats.likesCount ?? 0)} ❤️ · ${numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)} Gäste online`);
|
||||
const bentoShadow =
|
||||
'shadow-[10px_10px_0_rgba(15,23,42,0.85)] dark:shadow-[10px_10px_0_rgba(15,23,42,0.6)]';
|
||||
|
||||
return (
|
||||
<Page title="">
|
||||
@@ -315,45 +326,138 @@ export default function GalleryPage() {
|
||||
refreshingLabel={t('common.refreshing')}
|
||||
>
|
||||
<motion.div className="space-y-6 pb-24" {...containerMotion}>
|
||||
<motion.div className="space-y-2" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-pink-500/10 text-pink-500" style={{ borderRadius: radius }}>
|
||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h1 className="text-2xl font-semibold text-foreground" style={headingFont ? { fontFamily: headingFont } : undefined}>{t('galleryPage.title')}</h1>
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold ${badgeEmphasisClass}`}
|
||||
style={{ borderRadius: radius }}
|
||||
>
|
||||
{newPhotosBadgeText}
|
||||
<motion.div className="space-y-4" style={bodyFont ? { fontFamily: bodyFont } : undefined} {...fadeUpMotion}>
|
||||
<div className="grid gap-4 lg:grid-cols-[1.35fr_0.65fr]">
|
||||
<div
|
||||
className={cn(
|
||||
'relative overflow-hidden rounded-[28px] border-2 border-slate-900/80 bg-slate-950 text-white',
|
||||
bentoShadow
|
||||
)}
|
||||
style={{
|
||||
borderRadius: radius + 14,
|
||||
background: `radial-gradient(120% 120% at 90% 0%, ${branding.secondaryColor}55 0%, transparent 60%), linear-gradient(135deg, ${branding.primaryColor}, ${branding.secondaryColor})`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute inset-0 opacity-40 [background-image:radial-gradient(circle_at_20%_20%,rgba(255,255,255,0.45),transparent_45%),radial-gradient(circle_at_80%_20%,rgba(255,255,255,0.25),transparent_40%)]" />
|
||||
<div className="absolute inset-0 opacity-25 [background-image:linear-gradient(135deg,rgba(255,255,255,0.12)_12%,transparent_12%),linear-gradient(225deg,rgba(255,255,255,0.12)_12%,transparent_12%)] [background-size:16px_16px]" />
|
||||
<div className="relative z-10 flex h-full flex-col gap-4 p-6 sm:p-7">
|
||||
<div className="flex items-center gap-2 text-xs font-semibold uppercase tracking-[0.2em] text-white/80">
|
||||
<span className="flex h-9 w-9 items-center justify-center rounded-xl bg-white/15">
|
||||
<ImageIcon className="h-5 w-5" aria-hidden />
|
||||
</span>
|
||||
<span>{t('galleryPage.hero.label')}</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<h1
|
||||
className="text-3xl font-bold leading-tight sm:text-4xl"
|
||||
style={headingFont ? { fontFamily: headingFont } : undefined}
|
||||
>
|
||||
{event?.name ?? t('galleryPage.hero.eventFallback')}
|
||||
</h1>
|
||||
<p className="max-w-xl text-sm text-white/85 sm:text-base">
|
||||
{t('galleryPage.subtitle')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-white/90">
|
||||
<span className="rounded-full border border-white/30 bg-white/10 px-3 py-1">
|
||||
{heroStatsLine}
|
||||
</span>
|
||||
{newCount > 0 && (
|
||||
<span className="rounded-full border border-white/30 bg-black/30 px-3 py-1">
|
||||
{newPhotosBadgeText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 pt-1">
|
||||
<Button
|
||||
asChild
|
||||
size="lg"
|
||||
className="h-12 rounded-full bg-white text-slate-900 shadow-[6px_6px_0_rgba(15,23,42,0.7)] transition hover:-translate-y-0.5 hover:bg-white/90"
|
||||
>
|
||||
<Link to={uploadUrl} className="flex items-center gap-2">
|
||||
<ImagePlus className="h-5 w-5" />
|
||||
{t('galleryPage.hero.upload', 'Neues Foto hochladen')}
|
||||
</Link>
|
||||
</Button>
|
||||
{newCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeNew}
|
||||
className="rounded-full border border-white/30 bg-white/10 px-4 py-2 text-xs font-semibold uppercase tracking-wide text-white/90 transition hover:bg-white/20"
|
||||
>
|
||||
{t('galleryPage.badge.markSeen', 'Gesehen')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">{t('galleryPage.subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{newCount > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={acknowledgeNew}
|
||||
className="inline-flex items-center rounded-full px-2.5 py-0.5 text-[11px] font-semibold text-pink-600 transition hover:bg-pink-50"
|
||||
style={{ borderRadius: radius }}
|
||||
<div className="grid gap-4">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[26px] border-2 border-slate-900/80 bg-white/90 p-5 text-slate-900 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80 dark:text-white',
|
||||
)}
|
||||
style={{ borderRadius: radius + 10 }}
|
||||
>
|
||||
{t('galleryPage.badge.markSeen', 'Gesehen')}
|
||||
</button>
|
||||
)}
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-white/60">
|
||||
{t('galleryPage.feed.title', 'Live-Feed')}
|
||||
</p>
|
||||
<p className="mt-2 text-sm text-slate-700 dark:text-white/80">
|
||||
{t('galleryPage.feed.description', 'Alle paar Sekunden aktualisiert.')}
|
||||
</p>
|
||||
<div className="mt-4 flex items-center justify-between gap-2 rounded-2xl border border-slate-200 bg-white px-3 py-2 text-xs font-semibold text-slate-800 shadow-[4px_4px_0_rgba(15,23,42,0.8)] dark:border-white/10 dark:bg-slate-900 dark:text-white">
|
||||
<span>{t('galleryPage.feed.newUploads', '{count} neue Uploads sind da.').replace('{count}', `${newCount}`)}</span>
|
||||
<span className={cn('rounded-full px-2 py-0.5', badgeEmphasisClass)}>{newCount}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[22px] border-2 border-slate-900/80 bg-white/95 p-4 text-slate-900 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80 dark:text-white'
|
||||
)}
|
||||
style={{ borderRadius: radius + 8 }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-white/60">
|
||||
Likes
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-2xl font-bold">
|
||||
<Heart className="h-5 w-5 text-pink-500" />
|
||||
{numberFormatter.format(stats.likesCount ?? 0)}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[22px] border-2 border-slate-900/80 bg-white/95 p-4 text-slate-900 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80 dark:text-white'
|
||||
)}
|
||||
style={{ borderRadius: radius + 8 }}
|
||||
>
|
||||
<p className="text-[10px] font-semibold uppercase tracking-[0.2em] text-slate-500 dark:text-white/60">
|
||||
{t('galleryPage.hero.statsGuests', 'Gäste online')}
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-2 text-2xl font-bold">
|
||||
<Users className="h-5 w-5 text-slate-700 dark:text-white" />
|
||||
{numberFormatter.format(stats.onlineGuests || stats.guestCount || 0)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FiltersBar
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
className="mt-0"
|
||||
showPhotobooth={showPhotoboothFilter}
|
||||
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-[22px] border-2 border-slate-900/80 bg-white/95 p-2 shadow-[8px_8px_0_rgba(15,23,42,0.9)] dark:border-white/10 dark:bg-slate-950/80'
|
||||
)}
|
||||
style={{ borderRadius: radius + 8 }}
|
||||
>
|
||||
<FiltersBar
|
||||
value={filter}
|
||||
onChange={setFilter}
|
||||
className="mt-0"
|
||||
showPhotobooth={showPhotoboothFilter}
|
||||
styleOverride={{ borderRadius: radius, fontFamily: headingFont }}
|
||||
/>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{loading && (
|
||||
@@ -363,7 +467,7 @@ export default function GalleryPage() {
|
||||
)}
|
||||
|
||||
<motion.div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4" {...gridMotion}>
|
||||
{list.map((p: GalleryPhoto) => {
|
||||
{list.map((p: GalleryPhoto, idx: number) => {
|
||||
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' })
|
||||
@@ -399,11 +503,13 @@ export default function GalleryPage() {
|
||||
<img
|
||||
src={imageUrl}
|
||||
alt={altText}
|
||||
decoding="async"
|
||||
loading={idx < 6 ? 'eager' : 'lazy'}
|
||||
fetchPriority={idx < 6 ? 'high' : 'auto'}
|
||||
className="aspect-[3/4] 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-x-0 bottom-0 h-20 bg-gradient-to-t from-black/55 via-black/0 to-transparent" aria-hidden />
|
||||
</div>
|
||||
|
||||
82
resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx
Normal file
82
resources/js/guest/pages/__tests__/GalleryPageHero.test.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import React from 'react';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { MemoryRouter, Route, Routes } from 'react-router-dom';
|
||||
import GalleryPage from '../GalleryPage';
|
||||
import { LocaleProvider } from '../../i18n/LocaleContext';
|
||||
|
||||
vi.mock('../../polling/usePollGalleryDelta', () => ({
|
||||
usePollGalleryDelta: () => ({
|
||||
photos: [],
|
||||
loading: false,
|
||||
newCount: 0,
|
||||
acknowledgeNew: vi.fn(),
|
||||
refreshNow: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventBrandingContext', () => ({
|
||||
useEventBranding: () => ({
|
||||
branding: {
|
||||
primaryColor: '#FF5A5F',
|
||||
secondaryColor: '#FFF8F5',
|
||||
fontFamily: 'Space Grotesk, sans-serif',
|
||||
buttons: { radius: 12, style: 'filled', linkColor: '#FF5A5F' },
|
||||
typography: { heading: 'Space Grotesk, sans-serif', body: 'Space Grotesk, sans-serif' },
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../context/EventStatsContext', () => ({
|
||||
useEventStats: () => ({
|
||||
likesCount: 12,
|
||||
guestCount: 5,
|
||||
onlineGuests: 2,
|
||||
tasksSolved: 0,
|
||||
latestPhotoAt: null,
|
||||
loading: false,
|
||||
eventKey: 'demo',
|
||||
slug: 'demo',
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../../services/eventApi', () => ({
|
||||
fetchEvent: vi.fn().mockResolvedValue({ name: 'Demo Event' }),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ToastHost', () => ({
|
||||
useToast: () => ({ push: vi.fn() }),
|
||||
}));
|
||||
|
||||
vi.mock('../../components/ShareSheet', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../PhotoLightbox', () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/PullToRefresh', () => ({
|
||||
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||
}));
|
||||
|
||||
vi.mock('../../components/FiltersBar', () => ({
|
||||
default: () => <div data-testid="filters-bar" />,
|
||||
}));
|
||||
|
||||
describe('GalleryPage hero CTA', () => {
|
||||
it('links to the upload page', async () => {
|
||||
render(
|
||||
<LocaleProvider defaultLocale="de">
|
||||
<MemoryRouter initialEntries={['/e/demo/gallery']}>
|
||||
<Routes>
|
||||
<Route path="/e/:token/gallery" element={<GalleryPage />} />
|
||||
</Routes>
|
||||
</MemoryRouter>
|
||||
</LocaleProvider>
|
||||
);
|
||||
|
||||
const link = await screen.findByRole('link', { name: /neues foto hochladen/i });
|
||||
expect(link).toHaveAttribute('href', '/e/demo/upload');
|
||||
});
|
||||
});
|
||||
@@ -139,7 +139,6 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
setLoading(true);
|
||||
latestAt.current = null;
|
||||
etagRef.current = null;
|
||||
setPhotos([]);
|
||||
void fetchDelta();
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
// Poll less aggressively when hidden
|
||||
@@ -158,7 +157,6 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
latestAt.current = null;
|
||||
etagRef.current = null;
|
||||
setNewCount(0);
|
||||
setPhotos([]);
|
||||
await fetchDelta();
|
||||
}, [fetchDelta, token]);
|
||||
|
||||
|
||||
@@ -26,7 +26,6 @@ const TaskPickerPage = React.lazy(() => import('./pages/TaskPickerPage'));
|
||||
const TaskDetailPage = React.lazy(() => import('./pages/TaskDetailPage'));
|
||||
const UploadPage = React.lazy(() => import('./pages/UploadPage'));
|
||||
const UploadQueuePage = React.lazy(() => import('./pages/UploadQueuePage'));
|
||||
const GalleryPage = React.lazy(() => import('./pages/GalleryPage'));
|
||||
const PhotoLightbox = React.lazy(() => import('./pages/PhotoLightbox'));
|
||||
const AchievementsPage = React.lazy(() => import('./pages/AchievementsPage'));
|
||||
const SlideshowPage = React.lazy(() => import('./pages/SlideshowPage'));
|
||||
@@ -88,7 +87,6 @@ export const router = createBrowserRouter([
|
||||
{ path: 'tasks/:taskId', element: <TaskGuard><TaskDetailPage /></TaskGuard> },
|
||||
{ path: 'upload', element: <UploadPage /> },
|
||||
{ path: 'queue', element: <UploadQueuePage /> },
|
||||
{ path: 'gallery', element: <GalleryPage /> },
|
||||
{ path: 'photo/:photoId', element: <PhotoLightbox /> },
|
||||
{ path: 'achievements', element: <AchievementsPage /> },
|
||||
{ path: 'slideshow', element: <SlideshowPage /> },
|
||||
|
||||
@@ -52,6 +52,50 @@ export async function likePhoto(id: number): Promise<number> {
|
||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||
}
|
||||
|
||||
export async function unlikePhoto(id: number): Promise<number> {
|
||||
const headers = buildCsrfHeaders();
|
||||
|
||||
const res = await fetch(`/api/v1/photos/${id}/like`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch (error) {
|
||||
console.warn('Unlike photo: failed to parse error payload', error);
|
||||
}
|
||||
|
||||
if (res.status === 419) {
|
||||
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
|
||||
error.code = 'csrf_mismatch';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Unlike failed: ${res.status}`
|
||||
);
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'unlike_failed';
|
||||
error.status = res.status;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
const json = await res.json();
|
||||
return json.likes_count ?? json.data?.likes_count ?? 0;
|
||||
}
|
||||
|
||||
type UploadOptions = {
|
||||
guestName?: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
|
||||
@@ -417,6 +417,14 @@ return [
|
||||
'extend_expiry_missing' => 'Einladung nicht gefunden.',
|
||||
'extend_expiry_missing_date' => 'Bitte ein neues Ablaufdatum wählen.',
|
||||
'extend_expiry_success' => 'Ablauf der Einladung aktualisiert.',
|
||||
'demo_read_only_action' => 'Demo-Modus',
|
||||
'demo_read_only_label' => 'Nur-Lesen-Demo',
|
||||
'demo_read_only_help' => 'Uploads und Schreibaktionen für diesen Link deaktivieren.',
|
||||
'demo_read_only_heading' => 'Demo-Modus für :label',
|
||||
'demo_read_only_heading_fallback' => 'Demo-Modus für Einladung',
|
||||
'demo_read_only_missing' => 'Einladung nicht gefunden.',
|
||||
'demo_read_only_success' => 'Demo-Modus aktualisiert.',
|
||||
'demo_read_only_badge' => 'Demo (nur lesen)',
|
||||
],
|
||||
'analytics' => [
|
||||
'success_total' => 'Erfolgreiche Zugriffe',
|
||||
|
||||
@@ -413,6 +413,14 @@ return [
|
||||
'extend_expiry_missing' => 'Invitation not found.',
|
||||
'extend_expiry_missing_date' => 'Please select a new expiry.',
|
||||
'extend_expiry_success' => 'Invitation expiry updated.',
|
||||
'demo_read_only_action' => 'Demo mode',
|
||||
'demo_read_only_label' => 'Read-only demo',
|
||||
'demo_read_only_help' => 'Disable uploads and write actions for this invitation link.',
|
||||
'demo_read_only_heading' => 'Demo mode for :label',
|
||||
'demo_read_only_heading_fallback' => 'Demo mode for invitation',
|
||||
'demo_read_only_missing' => 'Invitation not found.',
|
||||
'demo_read_only_success' => 'Demo mode updated.',
|
||||
'demo_read_only_badge' => 'Demo (read-only)',
|
||||
'deprecated_notice' => 'Direct access via slug :slug has been retired. Share the invitations below or manage QR layouts in the admin app.',
|
||||
'open_admin' => 'Open admin app',
|
||||
],
|
||||
|
||||
@@ -60,6 +60,11 @@
|
||||
{{ __('admin.events.join_link.token_inactive') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
@if (!empty($token['demo_read_only']))
|
||||
<x-filament::badge color="warning" :icon="Heroicon::LockClosed">
|
||||
{{ __('admin.events.join_link.demo_read_only_badge') }}
|
||||
</x-filament::badge>
|
||||
@endif
|
||||
</x-slot>
|
||||
|
||||
<div {{ $stacked }}>
|
||||
@@ -78,6 +83,7 @@
|
||||
</x-filament::button>
|
||||
@if (isset($action))
|
||||
{{ $action->getModalAction('extend_join_token_expiry')(['token_id' => $token['id']]) }}
|
||||
{{ $action->getModalAction('set_demo_read_only')(['token_id' => $token['id']]) }}
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -193,6 +193,7 @@ 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::delete('/photos/{id}/like', [EventPublicController::class, 'unlike'])->name('photos.unlike');
|
||||
Route::post('/events/{token}/photos/{photo}/share', [EventPublicController::class, 'createShareLink'])
|
||||
->whereNumber('photo')
|
||||
->name('photos.share');
|
||||
|
||||
20
tests/Feature/ContentSecurityPolicyTest.php
Normal file
20
tests/Feature/ContentSecurityPolicyTest.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature;
|
||||
|
||||
use Tests\TestCase;
|
||||
|
||||
class ContentSecurityPolicyTest extends TestCase
|
||||
{
|
||||
public function test_guest_routes_include_worker_src_for_blob(): void
|
||||
{
|
||||
config(['app.debug' => false]);
|
||||
|
||||
$response = $this->get('/e/test/upload');
|
||||
|
||||
$csp = $response->headers->get('Content-Security-Policy');
|
||||
|
||||
$this->assertNotNull($csp);
|
||||
$this->assertStringContainsString("worker-src 'self' blob:", $csp);
|
||||
}
|
||||
}
|
||||
@@ -51,6 +51,52 @@ class EventJoinTokenExpiryActionTest extends TestCase
|
||||
);
|
||||
}
|
||||
|
||||
public function test_superadmin_can_toggle_demo_read_only_on_join_token(): void
|
||||
{
|
||||
$user = User::factory()->create(['role' => 'super_admin']);
|
||||
$event = Event::factory()->create([
|
||||
'date' => now()->addDays(10),
|
||||
]);
|
||||
|
||||
$token = $event->joinTokens()->latest('id')->first();
|
||||
|
||||
$this->bootSuperAdminPanel($user);
|
||||
|
||||
Livewire::test(ListEvents::class)
|
||||
->callAction(
|
||||
[
|
||||
TestAction::make('join_tokens')->table($event),
|
||||
TestAction::make('set_demo_read_only')
|
||||
->arguments(['token_id' => $token->id]),
|
||||
],
|
||||
[
|
||||
'demo_read_only' => true,
|
||||
]
|
||||
)
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token->refresh();
|
||||
|
||||
$this->assertTrue((bool) data_get($token->metadata, 'demo_read_only', false));
|
||||
|
||||
Livewire::test(ListEvents::class)
|
||||
->callAction(
|
||||
[
|
||||
TestAction::make('join_tokens')->table($event),
|
||||
TestAction::make('set_demo_read_only')
|
||||
->arguments(['token_id' => $token->id]),
|
||||
],
|
||||
[
|
||||
'demo_read_only' => false,
|
||||
]
|
||||
)
|
||||
->assertHasNoErrors();
|
||||
|
||||
$token->refresh();
|
||||
|
||||
$this->assertFalse((bool) data_get($token->metadata, 'demo_read_only', false));
|
||||
}
|
||||
|
||||
private function bootSuperAdminPanel(User $user): void
|
||||
{
|
||||
$panel = Filament::getPanel('superadmin');
|
||||
|
||||
@@ -368,6 +368,38 @@ class GuestJoinTokenFlowTest extends TestCase
|
||||
$this->assertEquals(1, $photo->fresh()->likes_count);
|
||||
}
|
||||
|
||||
public function test_guest_can_unlike_photo_after_liking(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
$token = $this->tokenService->createToken($event);
|
||||
|
||||
$photo = Photo::factory()->create([
|
||||
'event_id' => $event->id,
|
||||
'likes_count' => 0,
|
||||
]);
|
||||
|
||||
$this->getJson("/api/v1/events/{$token->token}");
|
||||
|
||||
$this->withHeader('X-Device-Id', 'device-like')
|
||||
->postJson("/api/v1/photos/{$photo->id}/like")
|
||||
->assertOk();
|
||||
|
||||
$response = $this->withHeader('X-Device-Id', 'device-like')
|
||||
->deleteJson("/api/v1/photos/{$photo->id}/like");
|
||||
|
||||
$response->assertOk()
|
||||
->assertJson([
|
||||
'liked' => false,
|
||||
]);
|
||||
|
||||
$this->assertDatabaseMissing('photo_likes', [
|
||||
'photo_id' => $photo->id,
|
||||
'guest_name' => 'device-like',
|
||||
]);
|
||||
|
||||
$this->assertEquals(0, $photo->fresh()->likes_count);
|
||||
}
|
||||
|
||||
public function test_guest_cannot_access_event_with_expired_token(): void
|
||||
{
|
||||
$event = $this->createPublishedEvent();
|
||||
|
||||
Reference in New Issue
Block a user