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:
@@ -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'
|
||||
}`}
|
||||
>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user