behoben: ohne aufgabe kann die kamera nicht gestartet werden (offensichtlich kein fehler mit browserzugriff auf kamera!)

platz zu begrenzt im aufnahmemodus - vollbildmodus möglich? Menü und Kopfleiste ausblenden?
	Bild aus eigener galerie auswählen - Upload schlägt fehl (zu groß? evtl fehlende Rechte - aber browser hat rechte auf bilder und dateien!)
	hochgeladene bilder tauchen in der galerie nicht beim filter "Meine Bilder" auf - fotos werden auch nicht gezählt in den stats und achievements zeigen keinen fortschriftt.
	geteilte fotos: ruft man den Link auf, bekommt man die meldung "Link abgelaufen"
	der im startbildschirm gewählte name mit Umlauten (Sören) ist nach erneutem aufruf der pwa ohne umlaut (Sren).
Aufgabenseite verbessert (Zwischenstand)
This commit is contained in:
Codex Agent
2025-12-04 11:58:07 +01:00
parent 899e742c38
commit c73a3163c0
15 changed files with 776 additions and 610 deletions

View File

@@ -83,7 +83,7 @@ export default function BottomNav() {
return (
<div
className={`fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
className={`guest-bottom-nav fixed inset-x-0 bottom-0 z-30 border-t border-white/20 bg-gradient-to-t from-black/40 via-black/20 to-transparent px-4 shadow-xl backdrop-blur-2xl transition-all duration-200 dark:border-white/10 dark:from-gray-950/90 dark:via-gray-900/70 dark:to-gray-900/35 ${
compact ? 'pb-1 pt-1 translate-y-3' : 'pb-3 pt-2'
}`}
>

View File

@@ -175,7 +175,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
const guestName = identity?.name && identity?.hydrated ? identity.name : null;
return (
<div
className="sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
className="guest-header sticky top-0 z-20 flex items-center justify-between border-b bg-white/70 px-4 py-2 backdrop-blur dark:bg-black/40"
style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}
>
<div className="flex flex-col">
@@ -207,7 +207,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
if (status === 'loading') {
return (
<div className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
<div className="guest-header sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-2 text-white shadow-sm backdrop-blur" style={headerStyle}>
<div className="font-semibold" style={branding.fontFamily ? { fontFamily: branding.fontFamily } : undefined}>{t('header.loading')}</div>
<div className="flex items-center gap-2">
<AppearanceToggleDropdown />
@@ -225,7 +225,7 @@ export default function Header({ eventToken, title = '' }: { eventToken?: string
statsContext && statsContext.eventKey === eventToken ? statsContext : undefined;
return (
<div
className="sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
className="guest-header sticky top-0 z-20 flex items-center justify-between border-b border-white/10 px-4 py-3 text-white shadow-sm backdrop-blur"
style={headerStyle}
>
<div className="flex items-center gap-3">

View File

@@ -342,7 +342,6 @@ export const messages: Record<LocaleCode, NestedMessages> = {
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<LocaleCode, NestedMessages> = {
defaultEvent: 'A special moment',
button: 'Share',
copyLink: 'Copy link',
copySuccess: 'Link copied!',
copyError: 'Link could not be copied.',
manualPrompt: 'Copy link',
openEvent: 'Open event',

View File

@@ -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(
<React.StrictMode>
<SharedPhotoStandalone />
</React.StrictMode>
);
};
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(
<React.StrictMode>
<LocaleProvider>
<ToastProvider>
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Erlebnisse werden geladen
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</React.StrictMode>
);
};
if (isShareRoute) {
shareRoot().catch(() => {
createRoot(rootEl).render(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Dieses Foto kann gerade nicht geladen werden.
</div>
);
});
} else {
appRoot().catch(() => {
createRoot(rootEl).render(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Erlebnisse können nicht geladen werden.
</div>
);
});
}
createRoot(rootEl).render(
<React.StrictMode>
<LocaleProvider>
<ToastProvider>
<Suspense
fallback={(
<div className="flex min-h-screen items-center justify-center text-sm text-muted-foreground">
Erlebnisse werden geladen
</div>
)}
>
<RouterProvider router={router} />
</Suspense>
</ToastProvider>
</LocaleProvider>
</React.StrictMode>
);

View File

@@ -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 <SharedPhotoView slug={slug} />;
}
export default function SharedPhotoPage() {
const { slug } = useParams<{ slug: string }>();
const { t } = useTranslation();
const toast = useToast();
return <SharedPhotoView slug={slug} />;
}
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 (
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-gradient-to-br from-pink-50 to-white px-4 text-center">
<Loader2 className="h-8 w-8 animate-spin text-pink-500" aria-hidden />
<p className="text-sm text-muted-foreground">{t('share.loading', 'Moment wird geladen...')}</p>
<p className="text-sm text-muted-foreground">Moment wird geladen </p>
</div>
);
}
@@ -71,29 +69,30 @@ export default function SharedPhotoPage() {
if (state.error || !state.data) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gradient-to-br from-pink-50 to-white px-6 text-center">
<p className="text-lg font-semibold text-foreground">{t('share.expiredTitle', 'Link abgelaufen')}</p>
<p className="text-sm text-muted-foreground max-w-md">{state.error ?? t('share.expiredDescription', 'Dieses Foto ist nicht mehr verfügbar.')}</p>
<Button asChild>
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
</Button>
<div className="flex items-center gap-2 text-rose-600">
<AlertCircle className="h-5 w-5" />
<p className="text-lg font-semibold text-foreground">Link abgelaufen</p>
</div>
<p className="text-sm text-muted-foreground max-w-md">
{state.error ?? 'Dieses Foto ist nicht mehr verfügbar.'}
</p>
</div>
);
}
const { data } = state;
const chips = buildChips(data);
return (
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-8">
<div className="mx-auto flex max-w-2xl flex-col gap-5">
<div className="rounded-3xl border border-white/60 bg-white/80 p-6 text-center shadow">
<p className="text-xs uppercase tracking-wide text-muted-foreground">{t('share.title', 'Geteiltes Foto')}</p>
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? t('share.defaultEvent', 'Ein besonderer Moment')}</h1>
{data.photo.title && (
<p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>
)}
<div className="min-h-screen bg-gradient-to-b from-white via-pink-50 to-white px-4 py-10">
<div className="mx-auto flex w-full max-w-xl flex-col gap-6">
<div className="rounded-3xl border border-white/60 bg-white/90 p-5 text-center shadow-sm">
<p className="text-[11px] uppercase tracking-[0.35em] text-muted-foreground">Geteiltes Foto</p>
<h1 className="mt-2 text-2xl font-semibold text-foreground">{data.event?.name ?? 'Ein besonderer Moment'}</h1>
{data.photo.title && <p className="mt-1 text-sm text-muted-foreground">{data.photo.title}</p>}
</div>
<div className="overflow-hidden rounded-[32px] border border-white/60 bg-black">
<div className="overflow-hidden rounded-[28px] border border-white/60 bg-black shadow-lg">
<img
src={data.photo.image_urls.full}
alt={data.photo.title ?? 'Foto'}
@@ -102,21 +101,42 @@ export default function SharedPhotoPage() {
/>
</div>
{data.photo.emotion && (
<p className="text-center text-sm text-muted-foreground">
{data.photo.emotion.emoji} {data.photo.emotion.name}
</p>
{chips.length > 0 && (
<div className="flex flex-wrap justify-center gap-2">
{chips.map((chip) => (
<span
key={chip.id}
className="inline-flex items-center gap-1 rounded-full border border-slate-200/80 bg-white/90 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm"
>
{chip.icon ? <span aria-hidden className="text-sm">{chip.icon}</span> : null}
<span className="text-[11px] uppercase tracking-wide opacity-70">{chip.label}</span>
<span className="text-[12px]">{chip.value}</span>
</span>
))}
</div>
)}
<div className="flex flex-col gap-3 sm:flex-row sm:justify-center">
<Button variant="secondary" onClick={handleCopy}>
{t('share.copyLink', 'Link kopieren')}
</Button>
<Button asChild>
<Link to="/event">{t('share.openEvent', 'Event öffnen')}</Link>
</Button>
</div>
</div>
</div>
);
}
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' });
}

View File

@@ -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<string | null>(null);
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
const [immersiveMode, setImmersiveMode] = useState(false);
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(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<boolean>(() => {
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<HTMLInputElement>) => {
const handleGalleryPick = useCallback(async (event: React.ChangeEvent<HTMLInputElement>) => {
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 ? <Zap className="mr-1 h-3.5 w-3.5 text-yellow-300" /> : <ZapOff className="mr-1 h-3.5 w-3.5" />}
{t('upload.controls.toggleFlash')}
</Button>
<Button
size="sm"
variant={immersiveMode ? 'default' : 'secondary'}
className={cn(
'rounded-full border-white/30 bg-white/10 px-4 py-1 text-xs',
immersiveMode && 'bg-white text-black'
)}
onClick={() => setImmersiveMode((prev) => !prev)}
>
{immersiveMode ? <Minimize2 className="mr-1 h-3.5 w-3.5" /> : <Maximize2 className="mr-1 h-3.5 w-3.5" />}
{immersiveMode
? t('upload.controls.exitFullscreen', 'Menü einblenden')
: t('upload.controls.enterFullscreen', 'Vollbild')}
</Button>
</div>
<div className="flex items-center justify-center gap-6">
@@ -1200,6 +1271,15 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
);
}
function readAsDataUrl(file: File): Promise<string> {
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');

View File

@@ -95,6 +95,7 @@ export async function likePhoto(id: number): Promise<number> {
}
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;