Refine guest gallery UI and add multi-photo upload flow
Some checks failed
linter / quality (push) Has been cancelled
tests / ci (push) Has been cancelled
tests / ui (push) Has been cancelled

This commit is contained in:
Codex Agent
2026-02-09 18:01:01 +01:00
parent e3bb1642db
commit 1f9a43806a
9 changed files with 369 additions and 159 deletions

View File

@@ -19,6 +19,12 @@ import { getBentoSurfaceTokens } from '../lib/bento';
import { buildEventPath } from '../lib/routes';
import { compressPhoto, formatBytes } from '@/shared/guest/lib/image';
type SelectedPreview = {
id: string;
file: File;
url: string;
};
function getTaskValue(task: TaskItem, key: string): string | undefined {
const value = task?.[key as keyof TaskItem];
if (typeof value === 'string' && value.trim() !== '') return value;
@@ -43,6 +49,7 @@ export default function UploadScreen() {
const videoRef = React.useRef<HTMLVideoElement | null>(null);
const streamRef = React.useRef<MediaStream | null>(null);
const mockPreviewTimerRef = React.useRef<number | null>(null);
const selectedPreviewsRef = React.useRef<SelectedPreview[]>([]);
const [uploading, setUploading] = React.useState<{ name: string; progress: number } | null>(null);
const [error, setError] = React.useState<string | null>(null);
const [uploadDialog, setUploadDialog] = React.useState<UploadErrorDialog | null>(null);
@@ -50,8 +57,7 @@ export default function UploadScreen() {
const [facingMode, setFacingMode] = React.useState<'user' | 'environment'>('environment');
const [mirror, setMirror] = React.useState(true);
const [flashPreferred, setFlashPreferred] = React.useState(false);
const [previewFile, setPreviewFile] = React.useState<File | null>(null);
const [previewUrl, setPreviewUrl] = React.useState<string | null>(null);
const [selectedPreviews, setSelectedPreviews] = React.useState<SelectedPreview[]>([]);
const { isDark } = useGuestThemeVariant();
const bentoSurface = getBentoSurfaceTokens(isDark);
const cardBorder = bentoSurface.borderColor;
@@ -68,6 +74,8 @@ export default function UploadScreen() {
const accessoryShadow = isDark ? '0 10px 20px rgba(2, 6, 23, 0.45)' : '0 8px 16px rgba(15, 23, 42, 0.14)';
const autoApprove = event?.guest_upload_visibility === 'immediate';
const isExpanded = cameraState === 'ready' || cameraState === 'starting' || cameraState === 'preview';
const selectedCount = selectedPreviews.length;
const previewUrl = selectedPreviews[0]?.url ?? null;
const queueCount = items.filter((item) => item.status !== 'done').length;
const sendingCount = items.filter((item) => item.status === 'uploading').length;
@@ -237,23 +245,43 @@ export default function UploadScreen() {
}
}, []);
const clearSelectedPreviews = React.useCallback(() => {
setSelectedPreviews((prev) => {
prev.forEach((item) => URL.revokeObjectURL(item.url));
return [];
});
}, []);
React.useEffect(() => {
selectedPreviewsRef.current = selectedPreviews;
}, [selectedPreviews]);
React.useEffect(() => {
return () => {
selectedPreviewsRef.current.forEach((item) => URL.revokeObjectURL(item.url));
};
}, []);
const uploadFiles = React.useCallback(
async (files: File[]) => {
if (!token || files.length === 0) return;
if (files.length === 0) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
if (!token || files.length === 0) {
return;
}
setError(null);
setUploadDialog(null);
let redirectPhotoId: number | null = null;
let hadNetworkQueue = false;
let uploadedCount = 0;
let queuedCount = 0;
let failedCount = 0;
for (const file of files) {
const preparedFile = await prepareUploadFile(file);
if (!navigator.onLine) {
await enqueueFile(preparedFile);
pushGuestToast({ text: t('uploadV2.toast.queued', 'Offline — added to upload queue.'), type: 'info' });
queuedCount += 1;
hadNetworkQueue = true;
continue;
}
@@ -272,8 +300,7 @@ export default function UploadScreen() {
if (autoApprove) {
void triggerConfetti();
}
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
void loadPending();
uploadedCount += 1;
persistMyPhotoId(photoId);
if (autoApprove && photoId) {
redirectPhotoId = photoId;
@@ -283,12 +310,49 @@ export default function UploadScreen() {
console.error('Upload failed, enqueueing', err);
setUploadDialog(resolveUploadErrorDialog(uploadErr?.code, uploadErr?.meta, t));
await enqueueFile(preparedFile);
queuedCount += 1;
if (uploadErr?.code === 'network_error') {
hadNetworkQueue = true;
} else {
failedCount += 1;
}
} finally {
setUploading(null);
}
}
if (autoApprove && redirectPhotoId) {
if (uploadedCount > 0) {
pushGuestToast({
text:
uploadedCount > 1
? t('upload.review.uploadedMany', { count: uploadedCount }, '{count} photos uploaded.')
: t('uploadV2.toast.uploaded', 'Upload complete.'),
type: 'success',
});
}
if (queuedCount > 0 && !hadNetworkQueue) {
pushGuestToast({
text: t('upload.review.queuedMany', { count: queuedCount }, '{count} photos were added to the queue.'),
type: 'info',
});
}
if (failedCount > 0) {
pushGuestToast({
text: t('upload.review.failedSome', { count: failedCount }, '{count} uploads failed and were queued for retry.'),
type: 'info',
});
}
void loadPending();
if (hadNetworkQueue) {
navigate('../queue?notice=network-retry');
return;
}
if (autoApprove && redirectPhotoId && files.length === 1) {
navigate(buildEventPath(token, `/gallery?photo=${redirectPhotoId}`));
}
},
@@ -311,20 +375,29 @@ export default function UploadScreen() {
async (fileList: FileList | null) => {
if (!fileList) return;
const files = Array.from(fileList).filter((file) => file.type.startsWith('image/'));
const next = files[0];
if (!next) {
if (files.length === 0) {
setError(t('uploadV2.errors.invalidFile', 'Please choose a photo file.'));
return;
}
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
const url = URL.createObjectURL(next);
setPreviewFile(next);
setPreviewUrl(url);
setError(null);
setUploadDialog(null);
setSelectedPreviews((prev) => {
const existingKeys = new Set(prev.map((item) => `${item.file.name}-${item.file.size}-${item.file.lastModified}`));
const additions = files
.filter((file) => {
const key = `${file.name}-${file.size}-${file.lastModified}`;
return !existingKeys.has(key);
})
.map((file) => ({
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
}));
return [...prev, ...additions];
});
setCameraState('preview');
},
[previewUrl, t]
[t]
);
const handlePick = React.useCallback(() => {
@@ -432,9 +505,14 @@ export default function UploadScreen() {
const blob = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.92));
if (!blob) return;
const file = new File([blob], `mock-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
setSelectedPreviews((prev) => [
...prev,
{
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
},
]);
stopCamera();
setCameraState('preview');
return;
@@ -472,9 +550,14 @@ export default function UploadScreen() {
return;
}
const file = new File([blob], `camera-${Date.now()}.jpg`, { type: blob.type });
const url = URL.createObjectURL(file);
setPreviewFile(file);
setPreviewUrl(url);
setSelectedPreviews((prev) => [
...prev,
{
id: `${file.name}-${file.size}-${file.lastModified}-${Math.random().toString(16).slice(2, 8)}`,
file,
url: URL.createObjectURL(file),
},
]);
stopCamera();
setCameraState('preview');
}, [cameraState, facingMode, mirror, mockPreviewEnabled, startCamera, stopCamera, t]);
@@ -491,35 +574,37 @@ export default function UploadScreen() {
}, [mockPreviewEnabled, startCamera]);
const handleRetake = React.useCallback(async () => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
clearSelectedPreviews();
if (cameraState === 'preview') {
await startCamera();
}
}, [cameraState, previewUrl, startCamera]);
}, [cameraState, clearSelectedPreviews, startCamera]);
const removeSelectedPreview = React.useCallback((id: string) => {
setSelectedPreviews((prev) => {
const item = prev.find((candidate) => candidate.id === id);
if (item) {
URL.revokeObjectURL(item.url);
}
const next = prev.filter((candidate) => candidate.id !== id);
if (next.length === 0) {
setCameraState('idle');
}
return next;
});
}, []);
const handleUseImage = React.useCallback(async () => {
if (!previewFile) return;
await uploadFiles([previewFile]);
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
if (selectedPreviews.length === 0) return;
await uploadFiles(selectedPreviews.map((item) => item.file));
clearSelectedPreviews();
setCameraState('idle');
}, [previewFile, previewUrl, uploadFiles]);
}, [clearSelectedPreviews, selectedPreviews, uploadFiles]);
const handleAbortCamera = React.useCallback(() => {
if (previewUrl) {
URL.revokeObjectURL(previewUrl);
}
setPreviewFile(null);
setPreviewUrl(null);
clearSelectedPreviews();
stopCamera();
}, [previewUrl, stopCamera]);
}, [clearSelectedPreviews, stopCamera]);
React.useEffect(() => {
return () => stopCamera();
@@ -717,51 +802,90 @@ export default function UploadScreen() {
</Button>
) : null}
{cameraState === 'preview' ? (
<XStack
position="absolute"
bottom="$4"
left="$4"
right="$4"
gap="$3"
zIndex={5}
>
<Button
flex={1}
height={76}
borderRadius={24}
backgroundColor="#F43F5E"
onPress={handleRetake}
alignItems="center"
justifyContent="center"
<YStack position="absolute" bottom="$4" left="$4" right="$4" gap="$2.5" zIndex={5}>
<XStack
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(159, 18, 57, 0.9), 0 26px 40px rgba(127, 29, 29, 0.35)',
overflowX: 'auto',
WebkitOverflowScrolling: 'touch',
}}
>
<X size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.retakeGallery', 'Ein anderes Foto auswählen')}
{selectedPreviews.map((item) => (
<YStack key={item.id} width={70} height={70} borderRadius={14} overflow="hidden" position="relative">
<img src={item.url} alt={item.file.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
<Button
size="$1"
circular
position="absolute"
top={4}
right={4}
backgroundColor="rgba(15, 23, 42, 0.72)"
onPress={() => removeSelectedPreview(item.id)}
aria-label={t('upload.review.removePhoto', 'Remove photo')}
>
<X size={12} color="#FFFFFF" />
</Button>
</YStack>
))}
</XStack>
<XStack alignItems="center" justifyContent="space-between">
<Text fontSize="$2" color="#FFFFFF" fontWeight="$7">
{t('upload.review.count', { count: selectedCount }, '{count} photos selected')}
</Text>
</Button>
<Button
flex={1}
height={76}
borderRadius={24}
backgroundColor="#22C55E"
onPress={handleUseImage}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(21, 128, 61, 0.9), 0 26px 40px rgba(22, 101, 52, 0.35)',
}}
>
<ArrowRight size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.keep', 'Foto verwenden')}
</Text>
</Button>
</XStack>
<Button
size="$2"
borderRadius="$pill"
backgroundColor="rgba(15, 23, 42, 0.62)"
borderWidth={1}
borderColor="rgba(255,255,255,0.35)"
onPress={handlePick}
>
<Text fontSize="$2" color="#FFFFFF" fontWeight="$7">
{t('upload.review.addMore', 'Add more')}
</Text>
</Button>
</XStack>
<XStack gap="$3">
<Button
flex={1}
height={76}
borderRadius={24}
backgroundColor="#F43F5E"
onPress={handleRetake}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(159, 18, 57, 0.9), 0 26px 40px rgba(127, 29, 29, 0.35)',
}}
>
<X size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{t('upload.review.clearSelection', 'Clear selection')}
</Text>
</Button>
<Button
flex={1}
height={76}
borderRadius={24}
backgroundColor="#22C55E"
onPress={handleUseImage}
alignItems="center"
justifyContent="center"
gap="$2"
style={{
boxShadow: '0 10px 0 rgba(21, 128, 61, 0.9), 0 26px 40px rgba(22, 101, 52, 0.35)',
}}
>
<ArrowRight size={22} color="#FFFFFF" />
<Text fontSize="$3" fontWeight="$7" color="#FFFFFF">
{selectedCount > 1
? t('upload.review.uploadMany', { count: selectedCount }, 'Upload {count} photos')
: t('upload.review.keep', 'Foto verwenden')}
</Text>
</Button>
</XStack>
</YStack>
) : null}
{cameraState !== 'ready' && cameraState !== 'preview' ? (
<YStack alignItems="center" gap="$2" padding="$4">