@@ -225,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
diff --git a/resources/js/guest/i18n/messages.ts b/resources/js/guest/i18n/messages.ts
index e492384..bc3e384 100644
--- a/resources/js/guest/i18n/messages.ts
+++ b/resources/js/guest/i18n/messages.ts
@@ -342,7 +342,6 @@ export const messages: Record = {
button: 'Teilen',
copyLink: 'Link kopieren',
copySuccess: 'Link kopiert!',
- copyError: 'Link konnte nicht kopiert werden.',
manualPrompt: 'Link kopieren',
openEvent: 'Event öffnen',
loading: 'Moment wird geladen...',
@@ -986,7 +985,6 @@ export const messages: Record = {
defaultEvent: 'A special moment',
button: 'Share',
copyLink: 'Copy link',
- copySuccess: 'Link copied!',
copyError: 'Link could not be copied.',
manualPrompt: 'Copy link',
openEvent: 'Open event',
diff --git a/resources/js/guest/main.tsx b/resources/js/guest/main.tsx
index c454861..32770a0 100644
--- a/resources/js/guest/main.tsx
+++ b/resources/js/guest/main.tsx
@@ -1,11 +1,7 @@
import React, { Suspense } from 'react';
import { createRoot } from 'react-dom/client';
-import { RouterProvider } from 'react-router-dom';
-import { router } from './router';
import '../../css/app.css';
import { initializeTheme } from '@/hooks/use-appearance';
-import { ToastProvider } from './components/ToastHost';
-import { LocaleProvider } from './i18n/LocaleContext';
import { enableGuestDemoMode, shouldEnableGuestDemoMode } from './demo/demoMode';
initializeTheme();
@@ -13,35 +9,71 @@ if (shouldEnableGuestDemoMode()) {
enableGuestDemoMode();
}
const rootEl = document.getElementById('root')!;
+const isShareRoute = typeof window !== 'undefined' && window.location.pathname.startsWith('/share/');
-// Register a minimal service worker for background sync (best-effort)
-if ('serviceWorker' in navigator) {
- navigator.serviceWorker.register('/guest-sw.js').catch(() => {});
- navigator.serviceWorker.addEventListener('message', (evt) => {
- if (evt.data?.type === 'sync-queue') {
- import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
- }
- });
- // Also attempt to process queue on load and when going online
- import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
- window.addEventListener('online', () => {
+const shareRoot = async () => {
+ const { SharedPhotoStandalone } = await import('./pages/SharedPhotoPage');
+ createRoot(rootEl).render(
+
+
+
+ );
+};
+
+const appRoot = async () => {
+ const { RouterProvider } = await import('react-router-dom');
+ const { router } = await import('./router');
+ const { ToastProvider } = await import('./components/ToastHost');
+ const { LocaleProvider } = await import('./i18n/LocaleContext');
+
+ // Register a minimal service worker for background sync (best-effort)
+ if ('serviceWorker' in navigator) {
+ navigator.serviceWorker.register('/guest-sw.js').catch(() => {});
+ navigator.serviceWorker.addEventListener('message', (evt) => {
+ if (evt.data?.type === 'sync-queue') {
+ import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
+ }
+ });
+ // Also attempt to process queue on load and when going online
import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
+ window.addEventListener('online', () => {
+ import('./queue/queue').then((m) => m.processQueue().catch(() => {}));
+ });
+ }
+
+ createRoot(rootEl).render(
+
+
+
+
+ Erlebnisse werden geladen …
+
+ )}
+ >
+
+
+
+
+
+ );
+};
+
+if (isShareRoute) {
+ shareRoot().catch(() => {
+ createRoot(rootEl).render(
+
+ Dieses Foto kann gerade nicht geladen werden.
+
+ );
+ });
+} else {
+ appRoot().catch(() => {
+ createRoot(rootEl).render(
+
+ Erlebnisse können nicht geladen werden.
+
+ );
});
}
-createRoot(rootEl).render(
-
-
-
-
- Erlebnisse werden geladen …
-
- )}
- >
-
-
-
-
-
-);
diff --git a/resources/js/guest/pages/SharedPhotoPage.tsx b/resources/js/guest/pages/SharedPhotoPage.tsx
index ea15a43..93bb020 100644
--- a/resources/js/guest/pages/SharedPhotoPage.tsx
+++ b/resources/js/guest/pages/SharedPhotoPage.tsx
@@ -1,11 +1,8 @@
// @ts-nocheck
import React from 'react';
-import { useParams, Link } from 'react-router-dom';
-import { Button } from '@/components/ui/button';
+import { useParams } from 'react-router-dom';
import { fetchPhotoShare } from '../services/photosApi';
-import { useTranslation } from '../i18n/useTranslation';
-import { useToast } from '../components/ToastHost';
-import { Loader2 } from 'lucide-react';
+import { Loader2, AlertCircle } from 'lucide-react';
interface ShareResponse {
slug: string;
@@ -14,16 +11,29 @@ interface ShareResponse {
id: number;
title?: string;
likes_count?: number;
- emotion?: { name?: string; emoji?: string } | null;
+ emotion?: { name?: string; emoji?: string | null } | null;
+ created_at?: string | null;
image_urls: { full: string; thumbnail: string };
};
event?: { id: number; name?: string | null } | null;
}
+type ShareProps = { slug: string | undefined };
+
+export function SharedPhotoStandalone() {
+ const slug = React.useMemo(() => {
+ const parts = window.location.pathname.split('/').filter(Boolean);
+ return parts.length >= 2 ? parts[1] : undefined;
+ }, []);
+ return
;
+}
+
export default function SharedPhotoPage() {
const { slug } = useParams<{ slug: string }>();
- const { t } = useTranslation();
- const toast = useToast();
+ return
;
+}
+
+function SharedPhotoView({ slug }: ShareProps) {
const [state, setState] = React.useState<{
loading: boolean;
error: string | null;
@@ -36,34 +46,22 @@ export default function SharedPhotoPage() {
setState({ loading: true, error: null, data: null });
fetchPhotoShare(slug)
- .then((data) => {
- if (!active) return;
- setState({ loading: false, error: null, data });
- })
+ .then((data) => { if (active) setState({ loading: false, error: null, data }); })
.catch((error: unknown) => {
if (!active) return;
- setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null });
+ setState({ loading: false, error: 'Dieses Foto ist nicht mehr verfügbar.', 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]);
+ }, [slug]);
if (state.loading) {
return (
-
{t('share.loading', 'Moment wird geladen...')}
+
Moment wird geladen …
);
}
@@ -71,29 +69,30 @@ export default function SharedPhotoPage() {
if (state.error || !state.data) {
return (
-
{t('share.expiredTitle', 'Link abgelaufen')}
-
{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}
-
- {t('share.openEvent', 'Event öffnen')}
-
+
+
+ {state.error ?? 'Dieses Foto ist nicht mehr verfügbar.'}
+
);
}
const { data } = state;
+ const chips = buildChips(data);
return (
-
-
-
-
{t('share.title', 'Geteiltes Foto')}
-
{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}
- {data.photo.title && (
-
{data.photo.title}
- )}
+
+
+
+
Geteiltes Foto
+
{data.event?.name ?? 'Ein besonderer Moment'}
+ {data.photo.title &&
{data.photo.title}
}
-
+
- {data.photo.emotion && (
-
- {data.photo.emotion.emoji} {data.photo.emotion.name}
-
+ {chips.length > 0 && (
+
+ {chips.map((chip) => (
+
+ {chip.icon ? {chip.icon} : null}
+ {chip.label}
+ {chip.value}
+
+ ))}
+
)}
-
-
-
- {t('share.copyLink', 'Link kopieren')}
-
-
- {t('share.openEvent', 'Event öffnen')}
-
-
);
}
+
+function buildChips(data: ShareResponse): { id: string; label: string; value: string; icon?: string }[] {
+ const list: { id: string; label: string; value: string; icon?: string }[] = [];
+ if (data.photo.emotion?.name) {
+ list.push({ id: 'emotion', label: 'Emotion', value: data.photo.emotion.name, icon: data.photo.emotion.emoji ?? '★' });
+ }
+ if (data.photo.title) {
+ list.push({ id: 'task', label: 'Aufgabe', value: data.photo.title });
+ }
+ if (data.photo.created_at) {
+ const date = formatDate(data.photo.created_at);
+ list.push({ id: 'date', label: 'Aufgenommen', value: date });
+ }
+ return list;
+}
+
+function formatDate(value: string): string {
+ const parsed = new Date(value);
+ if (Number.isNaN(parsed.getTime())) return '';
+ return parsed.toLocaleDateString(undefined, { day: '2-digit', month: 'short', year: 'numeric' });
+}
diff --git a/resources/js/guest/pages/UploadPage.tsx b/resources/js/guest/pages/UploadPage.tsx
index edbd8b9..035cace 100644
--- a/resources/js/guest/pages/UploadPage.tsx
+++ b/resources/js/guest/pages/UploadPage.tsx
@@ -27,6 +27,8 @@ import {
Sparkles,
Zap,
ZapOff,
+ Maximize2,
+ Minimize2,
} from 'lucide-react';
import { getEventPackage, type EventPackage } from '../services/eventApi';
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
@@ -35,6 +37,7 @@ import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadE
import { useEventStats } from '../context/EventStatsContext';
import { useEventBranding } from '../context/EventBrandingContext';
import { compressPhoto, formatBytes } from '../lib/image';
+import { useGuestIdentity } from '../context/GuestIdentityContext';
interface Task {
id: number;
@@ -112,6 +115,7 @@ export default function UploadPage() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { markCompleted } = useGuestTaskProgress(token);
+ const identity = useGuestIdentity();
const { t, locale } = useTranslation();
const stats = useEventStats();
const { branding } = useEventBranding();
@@ -143,6 +147,7 @@ export default function UploadPage() {
const [uploadProgress, setUploadProgress] = useState(0);
const [uploadError, setUploadError] = useState
(null);
const [uploadWarning, setUploadWarning] = useState(null);
+const [immersiveMode, setImmersiveMode] = useState(false);
const [errorDialog, setErrorDialog] = useState(null);
const [taskDetailsExpanded, setTaskDetailsExpanded] = useState(false);
@@ -155,6 +160,19 @@ const [canUpload, setCanUpload] = useState(true);
[eventPackage?.limits, t]
);
+ useEffect(() => {
+ if (typeof document === 'undefined') return undefined;
+ const className = 'guest-immersive';
+ if (immersiveMode) {
+ document.body.classList.add(className);
+ } else {
+ document.body.classList.remove(className);
+ }
+ return () => {
+ document.body.classList.remove(className);
+ };
+ }, [immersiveMode]);
+
const [showPrimer, setShowPrimer] = useState(() => {
if (typeof window === 'undefined') return false;
return window.localStorage.getItem(primerStorageKey) !== '1';
@@ -374,7 +392,7 @@ const [canUpload, setCanUpload] = useState(true);
return;
}
- if (!task || mode === 'uploading') return;
+ if (mode === 'uploading') return;
try {
setPermissionState('prompt');
@@ -401,15 +419,15 @@ const [canUpload, setCanUpload] = useState(true);
setPermissionMessage(t('upload.cameraError.explanation'));
}
}
- }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, task, t]);
+ }, [attachStreamToVideo, createConstraint, mode, preferences.facingMode, stopStream, supportsCamera, t]);
useEffect(() => {
- if (!task || loadingTask) return;
+ if (loadingTask) return;
startCamera();
return () => {
stopStream();
};
- }, [task, loadingTask, startCamera, stopStream, preferences.facingMode]);
+ }, [loadingTask, startCamera, stopStream, preferences.facingMode]);
// Countdown live region updates
useEffect(() => {
@@ -601,8 +619,9 @@ const [canUpload, setCanUpload] = useState(true);
}
try {
- const photoId = await uploadPhoto(eventKey, fileForUpload, task.id, emotionSlug || undefined, {
+ const photoId = await uploadPhoto(eventKey, fileForUpload, task?.id, emotionSlug || undefined, {
maxRetries: 2,
+ guestName: identity.name || undefined,
onProgress: (percent) => {
setUploadProgress(Math.max(15, Math.min(98, percent)));
setStatusMessage(t('upload.status.uploading'));
@@ -616,7 +635,18 @@ const [canUpload, setCanUpload] = useState(true);
});
setUploadProgress(100);
setStatusMessage(t('upload.status.completed'));
- markCompleted(task.id);
+ if (task?.id) {
+ markCompleted(task.id);
+ }
+ try {
+ const raw = localStorage.getItem('my-photo-ids');
+ const arr: number[] = raw ? JSON.parse(raw) : [];
+ if (photoId && !arr.includes(photoId)) {
+ localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...arr]));
+ }
+ } catch (error) {
+ console.warn('Failed to persist my-photo-ids', error);
+ }
stopStream();
navigateAfterUpload(photoId);
} catch (error: unknown) {
@@ -648,22 +678,49 @@ const [canUpload, setCanUpload] = useState(true);
} finally {
setStatusMessage('');
}
- }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t]);
+ }, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload, t, identity.name]);
- const handleGalleryPick = useCallback((event: React.ChangeEvent) => {
+ const handleGalleryPick = useCallback(async (event: React.ChangeEvent) => {
if (!canUpload) return;
const file = event.target.files?.[0];
if (!file) return;
setUploadError(null);
- const reader = new FileReader();
- reader.onload = () => {
- setReviewPhoto({ dataUrl: reader.result as string, file });
- setMode('review');
- };
- reader.onerror = () => {
- setUploadError(t('upload.galleryPickError'));
- };
- reader.readAsDataURL(file);
+ setUploadWarning(null);
+ setStatusMessage(t('upload.status.optimizing', 'Foto wird optimiert…'));
+
+ let prepared = file;
+ try {
+ prepared = await compressPhoto(file, {
+ maxEdge: 2400,
+ targetBytes: 4_000_000,
+ qualityStart: 0.82,
+ });
+ if (prepared.size < file.size - 50_000) {
+ const saved = formatBytes(file.size - prepared.size);
+ setUploadWarning(
+ t('upload.optimizedNotice', 'Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: {saved}')
+ .replace('{saved}', saved)
+ );
+ }
+ } catch (error) {
+ console.warn('Gallery image optimization failed, falling back to original', error);
+ setUploadWarning(t('upload.optimizedFallback', 'Optimierung nicht möglich – wir laden das Original hoch.'));
+ }
+
+ if (prepared.size > 12_000_000) {
+ setStatusMessage('');
+ setUploadError(
+ t('upload.errors.tooLargeHint', 'Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.')
+ );
+ event.target.value = '';
+ return;
+ }
+
+ const dataUrl = await readAsDataUrl(prepared);
+ setReviewPhoto({ dataUrl, file: prepared });
+ setMode('review');
+ setStatusMessage('');
+ event.target.value = '';
}, [canUpload, t]);
const emotionLabel = useMemo(() => {
@@ -1119,6 +1176,20 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
{preferences.flashPreferred ? : }
{t('upload.controls.toggleFlash')}
+ setImmersiveMode((prev) => !prev)}
+ >
+ {immersiveMode ? : }
+ {immersiveMode
+ ? t('upload.controls.exitFullscreen', 'Menü einblenden')
+ : t('upload.controls.enterFullscreen', 'Vollbild')}
+
@@ -1200,6 +1271,15 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
);
}
+function readAsDataUrl(file: File): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(reader.result as string);
+ reader.onerror = () => reject(reader.error ?? new Error('Failed to read file'));
+ reader.readAsDataURL(file);
+ });
+}
+
function formatRelativeTimeLabel(value: string | null | undefined, t: TranslateFn): string {
if (!value) {
return t('upload.hud.relative.now');
diff --git a/resources/js/guest/services/photosApi.ts b/resources/js/guest/services/photosApi.ts
index cc3f35f..59449a2 100644
--- a/resources/js/guest/services/photosApi.ts
+++ b/resources/js/guest/services/photosApi.ts
@@ -95,6 +95,7 @@ export async function likePhoto(id: number): Promise {
}
type UploadOptions = {
+ guestName?: string;
onProgress?: (percent: number) => void;
signal?: AbortSignal;
maxRetries?: number;
@@ -112,6 +113,7 @@ export async function uploadPhoto(
formData.append('photo', file, file.name || `photo-${Date.now()}.jpg`);
if (taskId) formData.append('task_id', taskId.toString());
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
+ if (options.guestName) formData.append('guest_name', options.guestName);
formData.append('device_id', getDeviceId());
const maxRetries = options.maxRetries ?? 2;
diff --git a/resources/js/i18n.ts b/resources/js/i18n.ts
index 8872200..2d0c25c 100644
--- a/resources/js/i18n.ts
+++ b/resources/js/i18n.ts
@@ -30,10 +30,6 @@ i18n
backend: {
loadPath: '/lang/{{lng}}/{{ns}}.json',
},
- detection: {
- order: [],
- caches: ['localStorage'],
- },
react: {
useSuspense: false,
},