Enable guest photo deletion and ownership flags
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-05 22:05:10 +01:00
parent c6aaf859f5
commit 18b4f36fcf
10 changed files with 455 additions and 14 deletions

View File

@@ -58,6 +58,7 @@ vi.mock('../services/photosApi', () => ({
likePhoto: vi.fn(),
unlikePhoto: vi.fn(),
createPhotoShareLink: vi.fn(),
deletePhoto: vi.fn(),
}));
vi.mock('../components/AppShell', () => ({
@@ -103,6 +104,7 @@ vi.mock('lucide-react', () => ({
Loader2: () => <span>loader</span>,
Download: () => <span>download</span>,
X: () => <span>x</span>,
Trash2: () => <span>trash</span>,
}));
import GalleryScreen from '../screens/GalleryScreen';

View File

@@ -2,12 +2,12 @@ import React from 'react';
import { YStack, XStack } from '@tamagui/stacks';
import { SizableText as Text } from '@tamagui/text';
import { Button } from '@tamagui/button';
import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, X } from 'lucide-react';
import { Camera, ChevronLeft, ChevronRight, Download, Heart, Loader2, Share2, Sparkles, Trash2, X } from 'lucide-react';
import AppShell from '../components/AppShell';
import PhotoFrameTile from '../components/PhotoFrameTile';
import ShareSheet from '../components/ShareSheet';
import { useEventData } from '../context/EventDataContext';
import { createPhotoShareLink, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
import { createPhotoShareLink, deletePhoto, fetchGallery, fetchPhoto, likePhoto, unlikePhoto } from '../services/photosApi';
import { usePollGalleryDelta } from '../hooks/usePollGalleryDelta';
import { useGuestThemeVariant } from '../lib/guestTheme';
import { useTranslation } from '@/guest/i18n/useTranslation';
@@ -27,6 +27,7 @@ type GalleryTile = {
createdAt?: string | null;
ingestSource?: string | null;
sessionId?: string | null;
isMine?: boolean;
taskId?: number | null;
taskLabel?: string | null;
emotion?: {
@@ -40,6 +41,7 @@ type LightboxPhoto = {
id: number;
imageUrl: string;
likes: number;
isMine?: boolean;
taskId?: number | null;
taskLabel?: string | null;
emotion?: {
@@ -66,6 +68,20 @@ function normalizeImageUrl(src?: string | null) {
return `/${cleanPath}`.replace(/\/+/g, '/');
}
function readMyPhotoIds(): Set<number> {
try {
const raw = localStorage.getItem('my-photo-ids');
const parsed = raw ? JSON.parse(raw) : [];
if (!Array.isArray(parsed)) {
return new Set();
}
const ids = parsed.map((value) => Number(value)).filter((value) => Number.isFinite(value) && value > 0);
return new Set(ids);
} catch {
return new Set();
}
}
export default function GalleryScreen() {
const { token, event } = useEventData();
const { t } = useTranslation();
@@ -95,6 +111,8 @@ export default function GalleryScreen() {
url: null,
loading: false,
});
const [deleteConfirmOpen, setDeleteConfirmOpen] = React.useState(false);
const [deleteBusy, setDeleteBusy] = React.useState(false);
const [likedIds, setLikedIds] = React.useState<Set<number>>(new Set());
const touchStartX = React.useRef<number | null>(null);
const fallbackAttemptedRef = React.useRef(false);
@@ -153,6 +171,8 @@ export default function GalleryScreen() {
? record.task_label
: null;
const rawEmotion = (record.emotion as Record<string, unknown> | null) ?? null;
const rawIsMine = record.is_mine ?? record.isMine;
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
const emotionName =
typeof rawEmotion?.name === 'string'
? rawEmotion.name
@@ -185,6 +205,7 @@ export default function GalleryScreen() {
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
isMine,
taskId,
taskLabel,
emotion,
@@ -215,13 +236,10 @@ export default function GalleryScreen() {
photosRef.current = photos;
}, [photos]);
const myPhotoIds = React.useMemo(() => {
try {
const raw = localStorage.getItem('my-photo-ids');
return new Set<number>(raw ? JSON.parse(raw) : []);
} catch {
return new Set<number>();
}
const [myPhotoIds, setMyPhotoIds] = React.useState<Set<number>>(() => readMyPhotoIds());
React.useEffect(() => {
setMyPhotoIds(readMyPhotoIds());
}, [token]);
const filteredPhotos = React.useMemo(() => {
@@ -229,7 +247,7 @@ export default function GalleryScreen() {
if (filter === 'popular') {
list.sort((a, b) => (b.likes ?? 0) - (a.likes ?? 0));
} else if (filter === 'mine') {
list = list.filter((photo) => myPhotoIds.has(photo.id));
list = list.filter((photo) => Boolean(photo.isMine) || myPhotoIds.has(photo.id));
} else if (filter === 'photobooth') {
list = list.filter((photo) => photo.ingestSource === 'photobooth');
list.sort((a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime());
@@ -253,6 +271,7 @@ export default function GalleryScreen() {
}, [displayPhotos, selectedPhotoId]);
const lightboxSelected = lightboxIndex >= 0 ? displayPhotos[lightboxIndex] : null;
const lightboxOpen = Boolean(selectedPhotoId);
const canDelete = Boolean(lightboxPhoto && (lightboxPhoto.isMine || myPhotoIds.has(lightboxPhoto.id)));
React.useEffect(() => {
if (filter === 'photobooth' && !photos.some((photo) => photo.ingestSource === 'photobooth')) {
@@ -304,6 +323,24 @@ export default function GalleryScreen() {
setSearchParams(next, { replace: true });
}, [searchParams, setSearchParams]);
const removeMyPhotoId = React.useCallback((photoId: number) => {
setMyPhotoIds((prev) => {
const next = new Set(prev);
next.delete(photoId);
return next;
});
try {
const raw = localStorage.getItem('my-photo-ids');
const parsed = raw ? JSON.parse(raw) : [];
if (Array.isArray(parsed)) {
const next = parsed.filter((value) => Number(value) !== photoId);
localStorage.setItem('my-photo-ids', JSON.stringify(next));
}
} catch (error) {
console.warn('Failed to update my-photo-ids', error);
}
}, []);
React.useEffect(() => {
if (delta.photos.length === 0) {
return;
@@ -326,6 +363,8 @@ export default function GalleryScreen() {
if (!id || !imageUrl || existing.has(id)) {
return null;
}
const rawIsMine = record.is_mine ?? record.isMine;
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
return {
id,
imageUrl,
@@ -333,6 +372,7 @@ export default function GalleryScreen() {
createdAt: typeof record.created_at === 'string' ? record.created_at : null,
ingestSource: typeof record.ingest_source === 'string' ? record.ingest_source : null,
sessionId: typeof record.session_id === 'string' ? record.session_id : null,
isMine,
} satisfies GalleryTile;
})
.filter(Boolean) as GalleryTile[];
@@ -371,6 +411,8 @@ export default function GalleryScreen() {
setLightboxLoading(false);
setLightboxError(null);
pendingNotFoundRef.current = false;
setDeleteConfirmOpen(false);
setDeleteBusy(false);
return;
}
@@ -379,6 +421,7 @@ export default function GalleryScreen() {
id: lightboxSelected.id,
imageUrl: lightboxSelected.imageUrl,
likes: lightboxSelected.likes,
isMine: lightboxSelected.isMine,
taskId: lightboxSelected.taskId ?? null,
taskLabel: lightboxSelected.taskLabel ?? null,
emotion: lightboxSelected.emotion ?? null,
@@ -584,6 +627,40 @@ export default function GalleryScreen() {
}
}, [lightboxPhoto, likedIds, likesById]);
const handleDelete = React.useCallback(async () => {
if (!lightboxPhoto || !token || deleteBusy) return;
setDeleteBusy(true);
try {
await deletePhoto(token, lightboxPhoto.id);
setPhotos((prev) => prev.filter((photo) => photo.id !== lightboxPhoto.id));
setLikesById((prev) => {
const next = { ...prev };
delete next[lightboxPhoto.id];
return next;
});
setLikedIds((prev) => {
const next = new Set(prev);
next.delete(lightboxPhoto.id);
return next;
});
removeMyPhotoId(lightboxPhoto.id);
setDeleteConfirmOpen(false);
setDeleteBusy(false);
closeLightbox();
pushGuestToast({
text: t('galleryPage.lightbox.deletedToast', 'Photo deleted.'),
type: 'success',
});
} catch (error) {
console.error('Failed to delete photo', error);
setDeleteBusy(false);
pushGuestToast({
text: t('galleryPage.lightbox.deleteFailed', 'Photo could not be deleted.'),
type: 'error',
});
}
}, [closeLightbox, deleteBusy, lightboxPhoto, removeMyPhotoId, t, token]);
const shareTitle = event?.name ?? t('share.title', 'Shared photo');
const shareText = t('share.shareText', 'Check out this moment on Fotospiel.');
@@ -991,6 +1068,7 @@ export default function GalleryScreen() {
borderRadius="$bentoLg"
backgroundColor={isDark ? 'rgba(15, 23, 42, 0.45)' : 'rgba(255, 255, 255, 0.65)'}
overflow="hidden"
position="relative"
style={{
height: 'min(76vh, 560px)',
boxShadow: hardShadow,
@@ -1213,6 +1291,18 @@ export default function GalleryScreen() {
<Download size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
{lightboxPhoto && canDelete ? (
<Button
unstyled
paddingHorizontal="$2"
paddingVertical="$1.5"
onPress={() => setDeleteConfirmOpen(true)}
disabled={deleteBusy}
aria-label={t('galleryPage.lightbox.deleteAria', 'Delete photo')}
>
<Trash2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
) : null}
<Button unstyled paddingHorizontal="$2" paddingVertical="$1.5" onPress={openShareSheet}>
<Share2 size={14} color={isDark ? '#F8FAFF' : '#0F172A'} />
</Button>
@@ -1244,6 +1334,73 @@ export default function GalleryScreen() {
</Button>
</XStack>
</XStack>
{deleteConfirmOpen ? (
<YStack
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
alignItems="center"
justifyContent="center"
padding="$4"
backgroundColor={isDark ? 'rgba(2, 6, 23, 0.7)' : 'rgba(15, 23, 42, 0.4)'}
style={{ backdropFilter: 'blur(6px)', zIndex: 6 }}
>
<YStack
width="100%"
maxWidth={360}
padding="$4"
gap="$3"
borderRadius="$card"
backgroundColor="$surface"
borderWidth={1}
borderColor={mutedButtonBorder}
style={{ boxShadow: cardShadow }}
>
<YStack gap="$1">
<Text fontSize="$4" fontWeight="$7">
{t('galleryPage.lightbox.deleteTitle', 'Foto löschen?')}
</Text>
<Text fontSize="$2" color="$color" opacity={0.7}>
{t(
'galleryPage.lightbox.deleteDescription',
'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.'
)}
</Text>
</YStack>
<XStack gap="$2" justifyContent="flex-end">
<Button
backgroundColor={mutedButton}
borderWidth={1}
borderColor={mutedButtonBorder}
onPress={() => setDeleteConfirmOpen(false)}
disabled={deleteBusy}
>
<Text fontSize="$2" fontWeight="$6">
{t('common.actions.cancel', 'Cancel')}
</Text>
</Button>
<Button
backgroundColor="#EF4444"
borderWidth={1}
borderColor="#EF4444"
onPress={handleDelete}
disabled={deleteBusy}
>
<XStack alignItems="center" gap="$1.5">
{deleteBusy ? <Loader2 size={14} className="animate-spin" color="#FFFFFF" /> : null}
<Text fontSize="$2" fontWeight="$7" color="#FFFFFF">
{deleteBusy
? t('galleryPage.lightbox.deleting', 'Wird gelöscht…')
: t('galleryPage.lightbox.deleteConfirm', 'Foto löschen')}
</Text>
</XStack>
</Button>
</XStack>
</YStack>
</YStack>
) : null}
<ShareSheet
open={shareSheet.loading || Boolean(shareSheet.url)}
onOpenChange={(open) => {
@@ -1319,10 +1476,13 @@ function mapFullPhoto(photo: Record<string, unknown>): LightboxPhoto | null {
const emotion = emotionName || emotionIcon || emotionColor
? { name: emotionName, icon: emotionIcon, color: emotionColor }
: null;
const rawIsMine = photo.is_mine ?? photo.isMine;
const isMine = rawIsMine === true || rawIsMine === 1 || rawIsMine === '1';
return {
id,
imageUrl,
likes: typeof photo.likes_count === 'number' ? photo.likes_count : 0,
isMine,
taskId,
taskLabel,
emotion,

View File

@@ -223,6 +223,20 @@ export default function UploadScreen() {
[optimizeMaxEdge, optimizeTargetBytes, t]
);
const persistMyPhotoId = React.useCallback((photoId: number) => {
if (!photoId) return;
try {
const raw = localStorage.getItem('my-photo-ids');
const parsed = raw ? JSON.parse(raw) : [];
const list = Array.isArray(parsed) ? parsed.filter((value) => Number.isFinite(Number(value))) : [];
if (!list.includes(photoId)) {
localStorage.setItem('my-photo-ids', JSON.stringify([photoId, ...list]));
}
} catch (error) {
console.warn('Failed to persist my-photo-ids', error);
}
}, []);
const uploadFiles = React.useCallback(
async (files: File[]) => {
if (!token || files.length === 0) return;
@@ -260,6 +274,7 @@ export default function UploadScreen() {
}
pushGuestToast({ text: t('uploadV2.toast.uploaded', 'Upload complete.'), type: 'success' });
void loadPending();
persistMyPhotoId(photoId);
if (autoApprove && photoId) {
redirectPhotoId = photoId;
}

View File

@@ -1,6 +1,6 @@
import { fetchJson } from './apiClient';
import { getDeviceId } from '../lib/device';
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto } from '@/guest/services/photosApi';
export { likePhoto, unlikePhoto, createPhotoShareLink, uploadPhoto, deletePhoto } from '@/guest/services/photosApi';
export type GalleryPhoto = Record<string, unknown>;

View File

@@ -27,6 +27,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
close: 'Schließen',
back: 'Zurück',
loading: 'Lädt...',
cancel: 'Abbrechen',
},
},
consent: {
@@ -502,6 +503,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
prev: 'Zurück',
next: 'Weiter',
likes: '{count} Likes',
deleteTitle: 'Foto löschen?',
deleteDescription: 'Das Foto wird aus der Galerie entfernt und kann nicht wiederhergestellt werden.',
deleteConfirm: 'Foto löschen',
deleteAria: 'Foto löschen',
deleting: 'Wird gelöscht…',
deletedToast: 'Foto gelöscht.',
deleteFailed: 'Foto konnte nicht gelöscht werden.',
},
},
share: {
@@ -945,6 +953,7 @@ export const messages: Record<LocaleCode, NestedMessages> = {
close: 'Close',
back: 'Back',
loading: 'Loading...',
cancel: 'Cancel',
},
},
consent: {
@@ -1420,6 +1429,13 @@ export const messages: Record<LocaleCode, NestedMessages> = {
prev: 'Prev',
next: 'Next',
likes: '{count} likes',
deleteTitle: 'Delete this photo?',
deleteDescription: 'This photo will be removed from the gallery and cannot be restored.',
deleteConfirm: 'Delete photo',
deleteAria: 'Delete photo',
deleting: 'Deleting…',
deletedToast: 'Photo deleted.',
deleteFailed: 'Photo could not be deleted.',
},
},
share: {

View File

@@ -96,6 +96,48 @@ export async function unlikePhoto(id: number): Promise<number> {
return json.likes_count ?? json.data?.likes_count ?? 0;
}
export async function deletePhoto(eventToken: string, id: number): Promise<void> {
const headers = buildCsrfHeaders();
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/photos/${id}`;
const res = await fetch(url, {
method: 'DELETE',
credentials: 'include',
headers: {
...headers,
'Content-Type': 'application/json',
},
});
if (!res.ok) {
let payload: unknown = null;
try {
payload = await res.clone().json();
} catch (error) {
console.warn('Delete photo: failed to parse error payload', error);
}
if (res.status === 419) {
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
error.code = 'csrf_mismatch';
error.status = res.status;
throw error;
}
const error: UploadError = new Error(
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Delete failed: ${res.status}`
);
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'delete_failed';
error.status = res.status;
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
if (meta) {
error.meta = meta;
}
throw error;
}
}
type UploadOptions = {
guestName?: string;
onProgress?: (percent: number) => void;