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