Aufgabenkarten in der Gäste-pwa als swipe-barer Stapel umgesetzt. Sofortiges Freigeben von Foto-Uploads als Event-Einstellung implementiert.
This commit is contained in:
150
resources/js/guest/hooks/useDirectUpload.ts
Normal file
150
resources/js/guest/hooks/useDirectUpload.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { useCallback, useState } from 'react';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { uploadPhoto, type UploadError } from '../services/photosApi';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { resolveUploadErrorDialog, type UploadErrorDialog } from '../lib/uploadErrorDialog';
|
||||
|
||||
type DirectUploadResult = {
|
||||
success: boolean;
|
||||
photoId?: number;
|
||||
warning?: string | null;
|
||||
error?: string | null;
|
||||
dialog?: UploadErrorDialog | null;
|
||||
};
|
||||
|
||||
type UseDirectUploadOptions = {
|
||||
eventToken: string;
|
||||
taskId?: number | null;
|
||||
emotionSlug?: string;
|
||||
onCompleted?: (photoId: number) => void;
|
||||
};
|
||||
|
||||
export function useDirectUpload({ eventToken, taskId, emotionSlug, onCompleted }: UseDirectUploadOptions) {
|
||||
const { name } = useGuestIdentity();
|
||||
const { markCompleted } = useGuestTaskProgress(eventToken);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [warning, setWarning] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [errorDialog, setErrorDialog] = useState<UploadErrorDialog | null>(null);
|
||||
const [canUpload, setCanUpload] = useState(true);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setProgress(0);
|
||||
setWarning(null);
|
||||
setError(null);
|
||||
setErrorDialog(null);
|
||||
}, []);
|
||||
|
||||
const preparePhoto = useCallback(async (file: File) => {
|
||||
reset();
|
||||
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);
|
||||
setWarning(`Wir haben dein Foto verkleinert, damit der Upload schneller klappt. Eingespart: ${saved}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('Direct upload: optimization failed, using original', err);
|
||||
setWarning('Optimierung nicht möglich – wir laden das Original hoch.');
|
||||
}
|
||||
|
||||
if (prepared.size > 12_000_000) {
|
||||
setError('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||||
return { ok: false as const };
|
||||
}
|
||||
|
||||
return { ok: true as const, prepared };
|
||||
}, [reset]);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File): Promise<DirectUploadResult> => {
|
||||
if (!canUpload || uploading) return { success: false, warning, error };
|
||||
const preparedResult = await preparePhoto(file);
|
||||
if (!preparedResult.ok) {
|
||||
return { success: false, warning, error };
|
||||
}
|
||||
|
||||
const prepared = preparedResult.prepared;
|
||||
setUploading(true);
|
||||
setProgress(2);
|
||||
setError(null);
|
||||
setErrorDialog(null);
|
||||
|
||||
try {
|
||||
const photoId = await uploadPhoto(eventToken, prepared, taskId ?? undefined, emotionSlug || undefined, {
|
||||
maxRetries: 2,
|
||||
guestName: name || undefined,
|
||||
onProgress: (percent) => {
|
||||
setProgress(Math.max(10, Math.min(98, percent)));
|
||||
},
|
||||
onRetry: (attempt) => {
|
||||
setWarning(`Verbindung holperig – neuer Versuch (${attempt}).`);
|
||||
},
|
||||
});
|
||||
|
||||
setProgress(100);
|
||||
if (taskId) {
|
||||
markCompleted(taskId);
|
||||
}
|
||||
|
||||
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 (persistErr) {
|
||||
console.warn('Direct upload: persist my-photo-ids failed', persistErr);
|
||||
}
|
||||
|
||||
onCompleted?.(photoId);
|
||||
return { success: true, photoId, warning };
|
||||
} catch (err) {
|
||||
console.error('Direct upload failed', err);
|
||||
const uploadErr = err as UploadError;
|
||||
const meta = uploadErr.meta as Record<string, unknown> | undefined;
|
||||
const dialog = resolveUploadErrorDialog(uploadErr.code, meta, (v: string) => v);
|
||||
setErrorDialog(dialog);
|
||||
setError(dialog?.description ?? uploadErr.message ?? 'Upload fehlgeschlagen.');
|
||||
setWarning(null);
|
||||
|
||||
if (
|
||||
uploadErr.code === 'photo_limit_exceeded'
|
||||
|| uploadErr.code === 'upload_device_limit'
|
||||
|| uploadErr.code === 'event_package_missing'
|
||||
|| uploadErr.code === 'event_not_found'
|
||||
|| uploadErr.code === 'gallery_expired'
|
||||
) {
|
||||
setCanUpload(false);
|
||||
}
|
||||
|
||||
if (uploadErr.status === 422 || uploadErr.code === 'validation_error') {
|
||||
setWarning('Das Foto war zu groß. Bitte erneut versuchen – wir verkleinern es automatisch.');
|
||||
}
|
||||
|
||||
return { success: false, warning, error: dialog?.description ?? uploadErr.message, dialog };
|
||||
} finally {
|
||||
setUploading(false);
|
||||
setProgress((p) => (p === 100 ? p : 0));
|
||||
}
|
||||
},
|
||||
[canUpload, emotionSlug, eventToken, markCompleted, name, preparePhoto, taskId, uploading, warning, onCompleted]
|
||||
);
|
||||
|
||||
return {
|
||||
upload,
|
||||
uploading,
|
||||
progress,
|
||||
warning,
|
||||
error,
|
||||
errorDialog,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
128
resources/js/guest/lib/emotionTheme.ts
Normal file
128
resources/js/guest/lib/emotionTheme.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
export type EmotionTheme = {
|
||||
gradientClass: string;
|
||||
gradientBackground: string;
|
||||
suggestionGradient: string;
|
||||
suggestionBorder: string;
|
||||
};
|
||||
|
||||
export type EmotionIdentity = {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const themeFreude: EmotionTheme = {
|
||||
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
|
||||
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
|
||||
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
|
||||
};
|
||||
|
||||
const themeLiebe: EmotionTheme = {
|
||||
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
|
||||
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
|
||||
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
|
||||
};
|
||||
|
||||
const themeEkstase: EmotionTheme = {
|
||||
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
|
||||
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
|
||||
};
|
||||
|
||||
const themeEntspannt: EmotionTheme = {
|
||||
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
|
||||
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
|
||||
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
|
||||
};
|
||||
|
||||
const themeBesinnlich: EmotionTheme = {
|
||||
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
|
||||
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
|
||||
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
|
||||
};
|
||||
|
||||
const themeUeberraschung: EmotionTheme = {
|
||||
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
|
||||
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
|
||||
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
|
||||
};
|
||||
|
||||
const themeDefault: EmotionTheme = {
|
||||
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
|
||||
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
|
||||
};
|
||||
|
||||
const EMOTION_THEMES: Record<string, EmotionTheme> = {
|
||||
freude: themeFreude,
|
||||
happy: themeFreude,
|
||||
liebe: themeLiebe,
|
||||
romance: themeLiebe,
|
||||
romantik: themeLiebe,
|
||||
nostalgie: themeEntspannt,
|
||||
relaxed: themeEntspannt,
|
||||
ruehrung: themeBesinnlich,
|
||||
traurigkeit: themeBesinnlich,
|
||||
teamgeist: themeFreude,
|
||||
gemeinschaft: themeFreude,
|
||||
ueberraschung: themeUeberraschung,
|
||||
surprise: themeUeberraschung,
|
||||
ekstase: themeEkstase,
|
||||
excited: themeEkstase,
|
||||
besinnlichkeit: themeBesinnlich,
|
||||
sad: themeBesinnlich,
|
||||
default: themeDefault,
|
||||
};
|
||||
|
||||
const EMOTION_ICONS: Record<string, string> = {
|
||||
freude: '😊',
|
||||
happy: '😊',
|
||||
liebe: '❤️',
|
||||
romantik: '💞',
|
||||
nostalgie: '📼',
|
||||
ruehrung: '🥲',
|
||||
teamgeist: '🤝',
|
||||
ueberraschung: '😲',
|
||||
surprise: '😲',
|
||||
ekstase: '🤩',
|
||||
besinnlichkeit: '🕯️',
|
||||
};
|
||||
|
||||
function sluggify(value?: string | null): string {
|
||||
return (value ?? '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
|
||||
if (!identity) return 'default';
|
||||
const nameKey = sluggify(identity.name);
|
||||
if (nameKey && EMOTION_THEMES[nameKey]) {
|
||||
return nameKey;
|
||||
}
|
||||
const slugKey = sluggify(identity.slug);
|
||||
if (slugKey && EMOTION_THEMES[slugKey]) {
|
||||
return slugKey;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
export function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_THEMES[key] ?? themeDefault;
|
||||
}
|
||||
|
||||
export function getEmotionIcon(identity?: EmotionIdentity | null): string {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_ICONS[key] ?? '✨';
|
||||
}
|
||||
@@ -2,18 +2,28 @@
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import EmotionPicker from '../components/EmotionPicker';
|
||||
import GalleryPreview from '../components/GalleryPreview';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react';
|
||||
import { Sparkles, UploadCloud, X, RefreshCw, Timer } from 'lucide-react';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
import { animated, useSpring } from '@react-spring/web';
|
||||
import { useGesture } from '@use-gesture/react';
|
||||
import { Swiper, SwiperSlide } from 'swiper/react';
|
||||
import { EffectCards } from 'swiper/modules';
|
||||
import 'swiper/css';
|
||||
import 'swiper/css/effect-cards';
|
||||
import { getEmotionIcon, getEmotionTheme, type EmotionIdentity } from '../lib/emotionTheme';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
import { useDirectUpload } from '../hooks/useDirectUpload';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
|
||||
export default function HomePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -60,87 +70,200 @@ export default function HomePage() {
|
||||
const eventNameDisplay = event?.name ?? t('home.hero.defaultEventName');
|
||||
const accentColor = branding.primaryColor;
|
||||
const secondaryAccent = branding.secondaryColor;
|
||||
const uploadsRequireApproval =
|
||||
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
|
||||
|
||||
const [missionDeck, setMissionDeck] = React.useState<MissionPreview[]>([]);
|
||||
const [missionPool, setMissionPool] = React.useState<MissionPreview[]>([]);
|
||||
const [missionLoading, setMissionLoading] = React.useState(false);
|
||||
const missionPoolRef = React.useRef<MissionPreview[]>([]);
|
||||
|
||||
const drawRandom = React.useCallback((excludeIds: Set<number>) => {
|
||||
const pool = missionPoolRef.current.filter((item) => !excludeIds.has(item.id));
|
||||
if (!pool.length) return null;
|
||||
return pool[Math.floor(Math.random() * pool.length)];
|
||||
}, []);
|
||||
|
||||
const resetDeck = React.useCallback(() => {
|
||||
const pool = missionPoolRef.current;
|
||||
if (!pool.length) {
|
||||
setMissionDeck([]);
|
||||
return;
|
||||
}
|
||||
const shuffled = [...pool].sort(() => Math.random() - 0.5);
|
||||
setMissionDeck(shuffled.slice(0, 4));
|
||||
}, []);
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false);
|
||||
const [hasMore, setHasMore] = React.useState(true);
|
||||
const [page, setPage] = React.useState(1);
|
||||
const [swipeCount, setSwipeCount] = React.useState(0);
|
||||
const seenIdsRef = React.useRef<Set<number>>(new Set());
|
||||
const poolIndexRef = React.useRef(0);
|
||||
const sliderStateKey = token ? `missionSliderIndex:${token}` : null;
|
||||
const swiperRef = React.useRef<any>(null);
|
||||
|
||||
const advanceDeck = React.useCallback(() => {
|
||||
setMissionDeck((prev) => {
|
||||
if (!prev.length) return prev;
|
||||
const [, ...rest] = prev;
|
||||
const exclude = new Set(rest.map((r) => r.id));
|
||||
const nextCandidate = drawRandom(exclude);
|
||||
const replenished = nextCandidate ? [...rest, nextCandidate] : rest;
|
||||
return replenished;
|
||||
});
|
||||
}, [drawRandom]);
|
||||
if (swiperRef.current) {
|
||||
swiperRef.current.slideNext();
|
||||
}
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) return;
|
||||
let cancelled = false;
|
||||
async function loadMissions() {
|
||||
setMissionLoading(true);
|
||||
try {
|
||||
const safeToken = token ?? '';
|
||||
const response = await fetch(
|
||||
`/api/v1/events/${encodeURIComponent(safeToken)}/tasks?locale=${encodeURIComponent(locale)}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
},
|
||||
const normalizeTasks = React.useCallback(
|
||||
(tasks: Record<string, unknown>[]): MissionPreview[] =>
|
||||
tasks.map((task) => ({
|
||||
id: Number(task.id),
|
||||
title: typeof task.title === 'string' ? task.title : 'Mission',
|
||||
description: typeof task.description === 'string' ? task.description : '',
|
||||
duration: typeof task.duration === 'number' ? task.duration : 3,
|
||||
emotion: (task.emotion as EmotionIdentity) ?? null,
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
const mergeIntoPool = React.useCallback(
|
||||
(incoming: MissionPreview[]) => {
|
||||
const slugTitle = (title: string) =>
|
||||
title
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
setMissionPool((prev) => {
|
||||
const byId = new Map<number, MissionPreview>();
|
||||
const byTitle = new Map<string, MissionPreview>();
|
||||
|
||||
const addCandidate = (candidate: MissionPreview) => {
|
||||
if (byId.has(candidate.id)) return;
|
||||
const titleKey = slugTitle(candidate.title);
|
||||
if (byTitle.has(titleKey)) return;
|
||||
byId.set(candidate.id, candidate);
|
||||
byTitle.set(titleKey, candidate);
|
||||
};
|
||||
|
||||
prev.forEach(addCandidate);
|
||||
incoming.forEach(addCandidate);
|
||||
|
||||
return Array.from(byId.values());
|
||||
});
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const fetchTasksPage = React.useCallback(
|
||||
async (pageToFetch: number, isInitial: boolean = false) => {
|
||||
if (!token) return;
|
||||
if (isInitial) {
|
||||
setMissionLoading(true);
|
||||
}
|
||||
);
|
||||
setIsLoadingMore(true);
|
||||
try {
|
||||
const perPage = 20;
|
||||
const response = await fetch(
|
||||
`/api/v1/events/${encodeURIComponent(token)}/tasks?page=${pageToFetch}&per_page=${perPage}&locale=${encodeURIComponent(locale)}`,
|
||||
{
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||
const payload = await response.json();
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(payload) && payload.length) {
|
||||
missionPoolRef.current = payload.map((task: Record<string, unknown>) => ({
|
||||
id: Number(task.id),
|
||||
title: typeof task.title === 'string' ? task.title : 'Mission',
|
||||
description: typeof task.description === 'string' ? task.description : '',
|
||||
duration: typeof task.duration === 'number' ? task.duration : 3,
|
||||
emotion: task.emotion ?? null,
|
||||
}));
|
||||
resetDeck();
|
||||
|
||||
let items: Record<string, unknown>[] = [];
|
||||
let hasMoreFlag = false;
|
||||
let nextPage = pageToFetch + 1;
|
||||
|
||||
if (Array.isArray(payload)) {
|
||||
items = payload;
|
||||
hasMoreFlag = false;
|
||||
} else if (payload && Array.isArray(payload.tasks)) {
|
||||
items = payload.tasks;
|
||||
hasMoreFlag = false;
|
||||
} else if (payload && Array.isArray(payload.data)) {
|
||||
items = payload.data;
|
||||
if (payload.meta?.current_page && payload.meta?.last_page) {
|
||||
hasMoreFlag = payload.meta.current_page < payload.meta.last_page;
|
||||
nextPage = (payload.meta.current_page as number) + 1;
|
||||
} else if (payload.next_page_url !== undefined) {
|
||||
hasMoreFlag = Boolean(payload.next_page_url);
|
||||
} else {
|
||||
hasMoreFlag = items.length > 0;
|
||||
}
|
||||
} else {
|
||||
missionPoolRef.current = [];
|
||||
setMissionDeck([]);
|
||||
hasMoreFlag = false;
|
||||
}
|
||||
|
||||
const normalized = normalizeTasks(items);
|
||||
const deduped = normalized.filter((task) => {
|
||||
if (seenIdsRef.current.has(task.id)) return false;
|
||||
seenIdsRef.current.add(task.id);
|
||||
return true;
|
||||
});
|
||||
|
||||
if (deduped.length) {
|
||||
mergeIntoPool(deduped);
|
||||
}
|
||||
|
||||
setHasMore(hasMoreFlag);
|
||||
if (hasMoreFlag) {
|
||||
setPage(nextPage);
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.warn('Mission preview failed', err);
|
||||
missionPoolRef.current = [];
|
||||
setMissionDeck([]);
|
||||
}
|
||||
console.warn('Mission fetch failed', err);
|
||||
setHasMore(false);
|
||||
} finally {
|
||||
if (!cancelled) {
|
||||
setIsLoadingMore(false);
|
||||
if (isInitial) {
|
||||
setMissionLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[locale, normalizeTasks, token]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!token) return;
|
||||
seenIdsRef.current = new Set();
|
||||
setMissionDeck([]);
|
||||
setMissionPool([]);
|
||||
setPage(1);
|
||||
setHasMore(true);
|
||||
// restore persisted slider position for this event
|
||||
let restoredIndex = 0;
|
||||
if (sliderStateKey && typeof window !== 'undefined') {
|
||||
try {
|
||||
const stored = window.sessionStorage.getItem(sliderStateKey);
|
||||
if (stored) {
|
||||
const parsed = Number(stored);
|
||||
if (!Number.isNaN(parsed) && parsed >= 0) {
|
||||
restoredIndex = parsed;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
restoredIndex = 0;
|
||||
}
|
||||
}
|
||||
loadMissions();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [resetDeck, token, locale]);
|
||||
poolIndexRef.current = restoredIndex;
|
||||
fetchTasksPage(1, true);
|
||||
}, [fetchTasksPage, locale, sliderStateKey, token]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (missionPool.length === 0) return;
|
||||
if (poolIndexRef.current >= missionPool.length) {
|
||||
poolIndexRef.current = poolIndexRef.current % missionPool.length;
|
||||
}
|
||||
setMissionDeck(missionPool);
|
||||
}, [missionPool]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!swiperRef.current) return;
|
||||
if (!missionDeck.length) return;
|
||||
const target = poolIndexRef.current % missionDeck.length;
|
||||
const inst = swiperRef.current;
|
||||
if (typeof inst.slideToLoop === 'function') {
|
||||
inst.slideToLoop(target, 0);
|
||||
} else if (typeof inst.slideTo === 'function') {
|
||||
inst.slideTo(target, 0);
|
||||
}
|
||||
}, [missionDeck.length]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (missionLoading) return;
|
||||
if (!hasMore || isLoadingMore) return;
|
||||
// Prefetch when we are within 6 items of the end of the current pool
|
||||
const remaining = missionPool.length - poolIndexRef.current;
|
||||
if (remaining <= 6) {
|
||||
fetchTasksPage(page);
|
||||
}
|
||||
}, [fetchTasksPage, hasMore, isLoadingMore, missionLoading, missionPool.length, page]);
|
||||
|
||||
if (!token) {
|
||||
return null;
|
||||
@@ -189,9 +312,28 @@ export default function HomePage() {
|
||||
mission={missionDeck[0] ?? null}
|
||||
loading={missionLoading}
|
||||
onAdvance={advanceDeck}
|
||||
stack={missionDeck.slice(0, 3)}
|
||||
stack={missionDeck.slice(1)}
|
||||
initialIndex={poolIndexRef.current}
|
||||
onIndexChange={(idx, total) => {
|
||||
poolIndexRef.current = idx % Math.max(1, total);
|
||||
if (sliderStateKey && typeof window !== 'undefined') {
|
||||
try {
|
||||
window.sessionStorage.setItem(sliderStateKey, String(poolIndexRef.current));
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}
|
||||
}}
|
||||
swiperRef={swiperRef}
|
||||
/>
|
||||
<UploadActionCard
|
||||
token={token}
|
||||
accentColor={accentColor}
|
||||
secondaryAccent={secondaryAccent}
|
||||
radius={radius}
|
||||
bodyFont={bodyFont}
|
||||
requiresApproval={uploadsRequireApproval}
|
||||
/>
|
||||
<UploadActionCard token={token} accentColor={accentColor} secondaryAccent={secondaryAccent} radius={radius} bodyFont={bodyFont} />
|
||||
<EmotionActionCard />
|
||||
</div>
|
||||
</section>
|
||||
@@ -273,7 +415,7 @@ type MissionPreview = {
|
||||
title: string;
|
||||
description?: string;
|
||||
duration?: number;
|
||||
emotion?: { name?: string; slug?: string } | null;
|
||||
emotion?: EmotionIdentity | null;
|
||||
};
|
||||
|
||||
function MissionActionCard({
|
||||
@@ -282,231 +424,200 @@ function MissionActionCard({
|
||||
loading,
|
||||
onAdvance,
|
||||
stack,
|
||||
initialIndex,
|
||||
onIndexChange,
|
||||
swiperRef,
|
||||
}: {
|
||||
token: string;
|
||||
mission: MissionPreview | null;
|
||||
loading: boolean;
|
||||
onAdvance: () => void;
|
||||
stack: MissionPreview[];
|
||||
initialIndex: number;
|
||||
onIndexChange: (index: number, total: number) => void;
|
||||
swiperRef: React.MutableRefObject<any>;
|
||||
}) {
|
||||
const { branding } = useEventBranding();
|
||||
const radius = branding.buttons?.radius ?? 12;
|
||||
const primary = branding.buttons?.primary ?? branding.primaryColor;
|
||||
const secondary = branding.buttons?.secondary ?? branding.secondaryColor;
|
||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||
const headingFont = branding.typography?.heading ?? branding.fontFamily ?? undefined;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
const textColor = '#1f2937';
|
||||
const subTextColor = '#334155';
|
||||
const swipeThreshold = 120;
|
||||
const stackLayers = stack.slice(1, 4);
|
||||
const cards = mission ? [mission, ...stack] : stack;
|
||||
const shellRadius = `${radius + 10}px`;
|
||||
|
||||
const cardStyle: React.CSSProperties = {
|
||||
borderRadius: `${radius + 8}px`,
|
||||
backgroundColor: '#fcf7ef',
|
||||
backgroundImage: `linear-gradient(0deg, ${primary}33, ${primary}22), url(/patterns/rays-sunburst.svg)`,
|
||||
backgroundBlendMode: 'multiply, normal',
|
||||
backgroundSize: '330% 330%',
|
||||
backgroundPosition: 'center',
|
||||
backgroundRepeat: 'no-repeat',
|
||||
filter: 'contrast(1.12) saturate(1.1)',
|
||||
border: `1px solid ${primary}26`,
|
||||
boxShadow: `0 12px 28px ${primary}22, 0 2px 6px ${primary}1f`,
|
||||
fontFamily: bodyFont,
|
||||
position: 'relative',
|
||||
overflow: 'hidden',
|
||||
};
|
||||
const renderCardContent = (card: MissionPreview | null) => {
|
||||
const theme = getEmotionTheme(card?.emotion ?? null);
|
||||
const emotionIcon = getEmotionIcon(card?.emotion ?? null);
|
||||
const durationMinutes = card?.duration ?? 3;
|
||||
const progressValue = Math.min(100, Math.max(20, (durationMinutes / 8) * 100));
|
||||
const titleFont = headingFont ? { fontFamily: headingFont } : undefined;
|
||||
const gradientBackground = card ? theme.gradientBackground : `linear-gradient(135deg, ${primary}, ${secondary})`;
|
||||
|
||||
const [{ x, y, rotateZ, rotateY, rotateX, scale, opacity }, api] = useSpring(() => ({
|
||||
x: 0,
|
||||
y: 0,
|
||||
rotateZ: 0,
|
||||
rotateY: 0,
|
||||
rotateX: 0,
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
config: { tension: 320, friction: 26 },
|
||||
}));
|
||||
return (
|
||||
<div
|
||||
className="relative isolate overflow-hidden"
|
||||
style={{
|
||||
borderRadius: shellRadius,
|
||||
background: gradientBackground,
|
||||
boxShadow: `0 18px 50px ${primary}35, 0 6px 18px ${primary}22`,
|
||||
fontFamily: bodyFont,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-0 opacity-25"
|
||||
style={{
|
||||
backgroundImage:
|
||||
'linear-gradient(90deg, rgba(255,255,255,0.18) 1px, transparent 1px), linear-gradient(0deg, rgba(255,255,255,0.18) 1px, transparent 1px)',
|
||||
backgroundSize: '26px 26px',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute -left-12 -top-10 h-32 w-32 rounded-full bg-white/40 blur-3xl" />
|
||||
<div className="absolute -right-10 bottom-0 h-28 w-28 rounded-full bg-white/25 blur-3xl" />
|
||||
|
||||
React.useEffect(() => {
|
||||
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
|
||||
}, [mission?.id, api]);
|
||||
|
||||
const bind = useGesture(
|
||||
{
|
||||
onDrag: ({ active, movement: [mx, my], velocity: [vx], direction: [dx], cancel }) => {
|
||||
if (active && Math.abs(mx) > swipeThreshold) {
|
||||
cancel?.();
|
||||
api.start({
|
||||
x: dx > 0 ? 520 : -520,
|
||||
y: my,
|
||||
rotateZ: dx > 0 ? 12 : -12,
|
||||
rotateY: dx > 0 ? 18 : -18,
|
||||
rotateX: -my / 10,
|
||||
opacity: 0,
|
||||
scale: 1,
|
||||
immediate: false,
|
||||
onRest: () => {
|
||||
onAdvance();
|
||||
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, opacity: 1, scale: 1, immediate: false });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
api.start({
|
||||
x: mx,
|
||||
y: my,
|
||||
rotateZ: mx / 18,
|
||||
rotateY: mx / 28,
|
||||
rotateX: -my / 36,
|
||||
scale: active ? 1.02 : 1,
|
||||
opacity: 1,
|
||||
immediate: false,
|
||||
});
|
||||
},
|
||||
onDragEnd: () => {
|
||||
api.start({ x: 0, y: 0, rotateZ: 0, rotateY: 0, rotateX: 0, scale: 1, opacity: 1, immediate: false });
|
||||
},
|
||||
},
|
||||
{
|
||||
drag: {
|
||||
filterTaps: true,
|
||||
bounds: { left: -200, right: 200, top: -120, bottom: 120 },
|
||||
rubberband: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
return (
|
||||
<Card className="border-0 shadow-none bg-transparent" style={{ fontFamily: bodyFont }}>
|
||||
<CardContent className="px-0 py-0">
|
||||
<div className="relative min-h-[280px]">
|
||||
{stackLayers.map((item, index) => {
|
||||
const depth = index + 1;
|
||||
const scaleDown = 1 - depth * 0.03;
|
||||
const translateY = depth * 12;
|
||||
const fade = Math.max(0.25, 0.55 - depth * 0.08);
|
||||
return (
|
||||
<div
|
||||
key={item.id ?? index}
|
||||
className="absolute inset-0 pointer-events-none"
|
||||
style={{
|
||||
...cardStyle,
|
||||
transform: `translateY(${translateY}px) scale(${scaleDown})`,
|
||||
opacity: fade,
|
||||
filter: 'brightness(0.96) contrast(0.98)',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<animated.div
|
||||
className="relative overflow-hidden touch-pan-y"
|
||||
style={{
|
||||
...cardStyle,
|
||||
x,
|
||||
y,
|
||||
rotateZ,
|
||||
rotateY,
|
||||
rotateX,
|
||||
scale,
|
||||
opacity,
|
||||
transformOrigin: 'center center',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
{...bind()}
|
||||
>
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 h-12"
|
||||
style={{
|
||||
background: `linear-gradient(90deg, ${secondary}80, ${primary}cc)`,
|
||||
filter: 'saturate(1.05)',
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative z-10 flex flex-col gap-3 px-5 pb-4 pt-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex h-11 w-11 items-center justify-center rounded-2xl bg-white/90 text-foreground shadow-sm"
|
||||
style={{ borderRadius: `${radius - 2}px` }}
|
||||
<div className="relative z-10 m-3 rounded-2xl border border-white/35 bg-white/80 px-4 py-4 shadow-lg backdrop-blur-xl">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 rounded-full bg-white/60 blur-xl" />
|
||||
<Avatar className="relative h-12 w-12 border border-white/50 bg-white/80 shadow-md">
|
||||
<AvatarFallback className="text-xl">{emotionIcon}</AvatarFallback>
|
||||
</Avatar>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Badge
|
||||
className="border-0 text-[11px] font-semibold uppercase tracking-wide text-white shadow"
|
||||
style={{ backgroundImage: gradientBackground }}
|
||||
>
|
||||
<Sparkles className="h-5 w-5" aria-hidden />
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
className="text-xs font-semibold uppercase tracking-wide"
|
||||
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: subTextColor }}
|
||||
>
|
||||
Fotoaufgabe
|
||||
</p>
|
||||
<p className="text-sm" style={{ color: subTextColor }}>
|
||||
Wir haben schon etwas für dich vorbereitet.
|
||||
</p>
|
||||
{card?.emotion?.name ?? 'Fotoaufgabe'}
|
||||
</Badge>
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-600">
|
||||
<Sparkles className="h-4 w-4 text-amber-500" aria-hidden />
|
||||
<span>Deine nächste Foto-Aufgabe wartet</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mission ? (
|
||||
<div className="space-y-2">
|
||||
<div
|
||||
className="rounded-[14px] py-3 text-center"
|
||||
style={{
|
||||
background: 'rgba(255,255,255,0.6)',
|
||||
paddingLeft: '30px',
|
||||
paddingRight: '30px',
|
||||
}}
|
||||
>
|
||||
<p
|
||||
className="text-lg font-semibold leading-snug"
|
||||
style={{ ...(headingFont ? { fontFamily: headingFont } : {}), color: textColor }}
|
||||
>
|
||||
{mission.title}
|
||||
</p>
|
||||
{mission.description && (
|
||||
<p className="text-sm leading-relaxed" style={{ color: subTextColor }}>
|
||||
{mission.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-center" style={{ color: subTextColor }}>
|
||||
Ziehe deine erste Mission im Aufgaben-Tab oder wähle eine Stimmung.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-[2fr_1fr] gap-2 pt-1">
|
||||
<Button asChild className="w-full" style={{ borderRadius: `${radius}px` }}>
|
||||
<Link
|
||||
to={
|
||||
mission
|
||||
? `/e/${encodeURIComponent(token)}/upload?task=${mission.id}`
|
||||
: `/e/${encodeURIComponent(token)}/tasks`
|
||||
}
|
||||
>
|
||||
Aufgabe starten
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full"
|
||||
onClick={onShuffle}
|
||||
disabled={loading}
|
||||
style={{
|
||||
borderRadius: `${radius}px`,
|
||||
backgroundColor: secondary,
|
||||
color: '#ffffff',
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
|
||||
Andere Aufgabe
|
||||
</Button>
|
||||
<div className="flex items-center gap-1 rounded-full border border-white/70 bg-white/80 px-3 py-1 text-xs font-semibold text-slate-700 shadow-sm">
|
||||
<Timer className="mr-1 h-3.5 w-3.5 text-slate-500" aria-hidden />
|
||||
<span>ca. {durationMinutes} min</span>
|
||||
</div>
|
||||
</div>
|
||||
</animated.div>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
{card ? (
|
||||
<p
|
||||
className="text-2xl font-semibold leading-tight text-slate-900 line-clamp-2"
|
||||
style={{ ...titleFont, textShadow: '0 6px 18px rgba(15,23,42,0.28)' }}
|
||||
>
|
||||
{card.title}
|
||||
</p>
|
||||
) : loading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="mx-auto h-6 w-3/4" />
|
||||
<Skeleton className="mx-auto h-4 w-full" />
|
||||
<Skeleton className="mx-auto h-4 w-5/6" />
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-600">Ziehe deine erste Mission oder wähle eine Stimmung.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-2">
|
||||
<Progress
|
||||
value={card ? progressValue : 35}
|
||||
className="h-2 overflow-hidden bg-white/70"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wide text-slate-500">
|
||||
<span>Mission bereit</span>
|
||||
<span>{card ? 'Tippe zum Start' : 'Neue Mission lädt'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 grid grid-cols-[2fr_1fr] gap-2">
|
||||
<Button
|
||||
asChild
|
||||
className="w-full text-white shadow-lg"
|
||||
style={{
|
||||
borderRadius: `${radius}px`,
|
||||
background: `linear-gradient(120deg, ${primary}, ${secondary})`,
|
||||
boxShadow: `0 12px 28px ${primary}25`,
|
||||
}}
|
||||
>
|
||||
<Link
|
||||
to={
|
||||
card
|
||||
? `/e/${encodeURIComponent(token)}/upload?task=${card.id}`
|
||||
: `/e/${encodeURIComponent(token)}/tasks`
|
||||
}
|
||||
>
|
||||
Aufgabe starten
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
className="w-full border border-slate-200 bg-white/80 text-slate-800 shadow-sm backdrop-blur"
|
||||
onClick={onAdvance}
|
||||
disabled={loading}
|
||||
style={{
|
||||
borderRadius: `${radius}px`,
|
||||
}}
|
||||
>
|
||||
<RefreshCw className={`mr-2 h-4 w-4 ${loading ? 'animate-spin' : ''}`} aria-hidden />
|
||||
Andere
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const slides = cards.length ? cards : [mission ?? null];
|
||||
const initialSlide = Math.min(initialIndex, Math.max(0, slides.length - 1));
|
||||
|
||||
return (
|
||||
<Card className="border-0 bg-transparent shadow-none" style={{ fontFamily: bodyFont }}>
|
||||
<CardContent className="px-0 py-0">
|
||||
<div className="relative min-h-[280px] px-2">
|
||||
<Swiper
|
||||
effect="cards"
|
||||
modules={[EffectCards]}
|
||||
cardsEffect={{
|
||||
perSlideRotate: 2,
|
||||
perSlideOffset: 4,
|
||||
slideShadows: false,
|
||||
}}
|
||||
slidesPerView={1}
|
||||
grabCursor
|
||||
allowTouchMove
|
||||
loop={slides.length > 1}
|
||||
initialSlide={initialSlide}
|
||||
onSwiper={(instance) => {
|
||||
swiperRef.current = instance;
|
||||
if (initialSlide > 0) {
|
||||
instance.slideToLoop ? instance.slideToLoop(initialSlide, 0) : instance.slideTo(initialSlide, 0);
|
||||
}
|
||||
}}
|
||||
onSlideChange={(instance) => {
|
||||
const realIndex = typeof instance.realIndex === 'number' ? instance.realIndex : instance.activeIndex ?? 0;
|
||||
onIndexChange(realIndex, slides.length);
|
||||
}}
|
||||
className="!pb-2"
|
||||
style={{ paddingLeft: '0.25rem', paddingRight: '0.25rem' }}
|
||||
>
|
||||
{slides.map((card, index) => {
|
||||
const key = `card-${card?.id ?? 'x'}-${index}`;
|
||||
return (
|
||||
<SwiperSlide key={key} className="!h-auto">
|
||||
<div className="mx-auto w-full max-w-[calc(100vw-2rem)]">
|
||||
{renderCardContent(card)}
|
||||
</div>
|
||||
</SwiperSlide>
|
||||
);
|
||||
})}
|
||||
</Swiper>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
@@ -534,13 +645,58 @@ function UploadActionCard({
|
||||
secondaryAccent,
|
||||
radius,
|
||||
bodyFont,
|
||||
requiresApproval,
|
||||
}: {
|
||||
token: string;
|
||||
accentColor: string;
|
||||
secondaryAccent: string;
|
||||
radius: number;
|
||||
bodyFont?: string;
|
||||
requiresApproval: boolean;
|
||||
}) {
|
||||
const inputRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [busy, setBusy] = React.useState(false);
|
||||
const [message, setMessage] = React.useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { upload, uploading, error, warning, progress, reset } = useDirectUpload({
|
||||
eventToken: token,
|
||||
taskId: undefined,
|
||||
emotionSlug: undefined,
|
||||
onCompleted: () => {
|
||||
setMessage(null);
|
||||
navigate(`/e/${encodeURIComponent(token)}/gallery`);
|
||||
},
|
||||
});
|
||||
|
||||
const onPick = React.useCallback(
|
||||
async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
setBusy(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
await upload(file);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
},
|
||||
[upload]
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (error) {
|
||||
setMessage(error);
|
||||
} else if (warning) {
|
||||
setMessage(warning);
|
||||
} else {
|
||||
setMessage(null);
|
||||
}
|
||||
}, [error, warning]);
|
||||
|
||||
return (
|
||||
<Card
|
||||
className="overflow-hidden border border-muted/30 shadow-sm"
|
||||
@@ -550,28 +706,48 @@ function UploadActionCard({
|
||||
fontFamily: bodyFont,
|
||||
}}
|
||||
>
|
||||
<CardContent className="flex flex-col gap-1.5 py-[4px]">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-2xl p-3" style={{ background: `${accentColor}15` }}>
|
||||
<UploadCloud className="h-5 w-5" aria-hidden style={{ color: accentColor }} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-lg font-semibold text-foreground">Direkt hochladen</p>
|
||||
<p className="text-sm text-muted-foreground">Kamera öffnen oder ein Foto aus deiner Galerie wählen.</p>
|
||||
</div>
|
||||
</div>
|
||||
<CardContent className="flex flex-col gap-2 py-[4px]">
|
||||
<Button
|
||||
asChild
|
||||
className="text-white"
|
||||
type="button"
|
||||
className="text-white justify-center"
|
||||
style={{
|
||||
borderRadius: `${radius}px`,
|
||||
background: `linear-gradient(120deg, ${accentColor}, ${secondaryAccent})`,
|
||||
boxShadow: `0 12px 28px ${accentColor}25`,
|
||||
}}
|
||||
disabled={busy || uploading}
|
||||
onClick={() => {
|
||||
reset();
|
||||
setMessage(null);
|
||||
inputRef.current?.click();
|
||||
}}
|
||||
>
|
||||
<Link to={`/e/${encodeURIComponent(token)}/upload`}>Foto hochladen</Link>
|
||||
<span className="flex items-center gap-2">
|
||||
<UploadCloud className={`h-5 w-5 ${busy || uploading ? 'animate-pulse' : ''}`} aria-hidden />
|
||||
<span>{busy || uploading ? 'Lädt …' : 'Direkt hochladen'}</span>
|
||||
</span>
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">Offline möglich – wir laden später hoch.</p>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
capture="environment"
|
||||
className="hidden"
|
||||
onChange={onPick}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Kamera öffnen oder ein Foto aus deiner Galerie wählen. Offline möglich – wir laden später hoch.
|
||||
</p>
|
||||
{requiresApproval ? (
|
||||
<p className="text-xs font-medium text-amber-700 dark:text-amber-300">
|
||||
Deine Fotos werden kurz geprüft und erscheinen danach in der Galerie.
|
||||
</p>
|
||||
) : null}
|
||||
{message && (
|
||||
<p className="text-xs font-medium text-amber-600 dark:text-amber-400">
|
||||
{message} {progress > 0 && progress < 100 ? `(${Math.round(progress)}%)` : ''}
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useTranslation } from '../i18n/useTranslation';
|
||||
import { useToast } from '../components/ToastHost';
|
||||
import ShareSheet from '../components/ShareSheet';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
type Photo = {
|
||||
id: number;
|
||||
@@ -162,12 +163,20 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const tasks = (await res.json()) as Task[];
|
||||
const foundTask = tasks.find((t) => t.id === taskId);
|
||||
const payload = (await res.json()) as unknown;
|
||||
const tasks = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray((payload as any)?.data)
|
||||
? (payload as any).data
|
||||
: Array.isArray((payload as any)?.tasks)
|
||||
? (payload as any).tasks
|
||||
: [];
|
||||
const foundTask = (tasks as Task[]).find((t) => t.id === taskId);
|
||||
if (foundTask) {
|
||||
setTask({
|
||||
id: foundTask.id,
|
||||
|
||||
@@ -10,6 +10,13 @@ import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import {
|
||||
getEmotionIcon,
|
||||
getEmotionTheme,
|
||||
type EmotionIdentity,
|
||||
type EmotionTheme,
|
||||
} from '../lib/emotionTheme';
|
||||
import { getDeviceId } from '../lib/device';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -39,128 +46,6 @@ type EventPhoto = {
|
||||
|
||||
const SWIPE_THRESHOLD_PX = 40;
|
||||
const SIMILAR_PHOTO_LIMIT = 6;
|
||||
type EmotionTheme = {
|
||||
gradientClass: string;
|
||||
gradientBackground: string;
|
||||
suggestionGradient: string;
|
||||
suggestionBorder: string;
|
||||
};
|
||||
|
||||
type EmotionIdentity = {
|
||||
slug?: string | null;
|
||||
name?: string | null;
|
||||
};
|
||||
|
||||
const themeFreude: EmotionTheme = {
|
||||
gradientClass: 'from-amber-300 via-orange-400 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #fde68a, #fb923c, #fb7185)',
|
||||
suggestionGradient: 'from-amber-50 via-white to-orange-50 dark:from-amber-500/20 dark:via-gray-900 dark:to-orange-500/10',
|
||||
suggestionBorder: 'border-amber-200/60 dark:border-amber-400/30',
|
||||
};
|
||||
const themeLiebe: EmotionTheme = {
|
||||
gradientClass: 'from-rose-400 via-fuchsia-500 to-purple-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #fb7185, #ec4899, #9333ea)',
|
||||
suggestionGradient: 'from-rose-50 via-white to-fuchsia-50 dark:from-rose-500/20 dark:via-gray-900 dark:to-fuchsia-500/10',
|
||||
suggestionBorder: 'border-rose-200/60 dark:border-rose-400/30',
|
||||
};
|
||||
const themeEkstase: EmotionTheme = {
|
||||
gradientClass: 'from-fuchsia-400 via-purple-500 to-indigo-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #f472b6, #a855f7, #6366f1)',
|
||||
suggestionGradient: 'from-fuchsia-50 via-white to-indigo-50 dark:from-fuchsia-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-fuchsia-200/60 dark:border-fuchsia-400/30',
|
||||
};
|
||||
const themeEntspannt: EmotionTheme = {
|
||||
gradientClass: 'from-teal-300 via-emerald-400 to-cyan-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #5eead4, #34d399, #22d3ee)',
|
||||
suggestionGradient: 'from-teal-50 via-white to-emerald-50 dark:from-teal-500/20 dark:via-gray-900 dark:to-emerald-500/10',
|
||||
suggestionBorder: 'border-emerald-200/60 dark:border-emerald-400/30',
|
||||
};
|
||||
const themeBesinnlich: EmotionTheme = {
|
||||
gradientClass: 'from-slate-500 via-blue-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #64748b, #3b82f6, #4f46e5)',
|
||||
suggestionGradient: 'from-slate-50 via-white to-blue-50 dark:from-slate-600/20 dark:via-gray-900 dark:to-blue-500/10',
|
||||
suggestionBorder: 'border-slate-200/60 dark:border-slate-500/30',
|
||||
};
|
||||
const themeUeberraschung: EmotionTheme = {
|
||||
gradientClass: 'from-indigo-300 via-violet-500 to-rose-500',
|
||||
gradientBackground: 'linear-gradient(135deg, #a5b4fc, #a855f7, #fb7185)',
|
||||
suggestionGradient: 'from-indigo-50 via-white to-violet-50 dark:from-indigo-500/20 dark:via-gray-900 dark:to-violet-500/10',
|
||||
suggestionBorder: 'border-indigo-200/60 dark:border-indigo-400/30',
|
||||
};
|
||||
const themeDefault: EmotionTheme = {
|
||||
gradientClass: 'from-pink-500 via-purple-500 to-indigo-600',
|
||||
gradientBackground: 'linear-gradient(135deg, #ec4899, #a855f7, #4f46e5)',
|
||||
suggestionGradient: 'from-pink-50 via-white to-indigo-50 dark:from-pink-500/20 dark:via-gray-900 dark:to-indigo-500/10',
|
||||
suggestionBorder: 'border-pink-200/60 dark:border-pink-400/30',
|
||||
};
|
||||
|
||||
const EMOTION_THEMES: Record<string, EmotionTheme> = {
|
||||
freude: themeFreude,
|
||||
happy: themeFreude,
|
||||
liebe: themeLiebe,
|
||||
romance: themeLiebe,
|
||||
romantik: themeLiebe,
|
||||
nostalgie: themeEntspannt,
|
||||
relaxed: themeEntspannt,
|
||||
ruehrung: themeBesinnlich,
|
||||
traurigkeit: themeBesinnlich,
|
||||
teamgeist: themeFreude,
|
||||
gemeinschaft: themeFreude,
|
||||
ueberraschung: themeUeberraschung,
|
||||
surprise: themeUeberraschung,
|
||||
ekstase: themeEkstase,
|
||||
excited: themeEkstase,
|
||||
besinnlichkeit: themeBesinnlich,
|
||||
sad: themeBesinnlich,
|
||||
default: themeDefault,
|
||||
};
|
||||
|
||||
const EMOTION_ICONS: Record<string, string> = {
|
||||
freude: '😊',
|
||||
happy: '😊',
|
||||
liebe: '❤️',
|
||||
romantik: '💞',
|
||||
nostalgie: '📼',
|
||||
ruehrung: '🥲',
|
||||
teamgeist: '🤝',
|
||||
ueberraschung: '😲',
|
||||
surprise: '😲',
|
||||
ekstase: '🤩',
|
||||
besinnlichkeit: '🕯️',
|
||||
};
|
||||
|
||||
function sluggify(value?: string | null): string {
|
||||
return (value ?? '')
|
||||
.toString()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9]+/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function resolveEmotionKey(identity?: EmotionIdentity | null): string {
|
||||
if (!identity) return 'default';
|
||||
const nameKey = sluggify(identity.name);
|
||||
if (nameKey && EMOTION_THEMES[nameKey]) {
|
||||
return nameKey;
|
||||
}
|
||||
const slugKey = sluggify(identity.slug);
|
||||
if (slugKey && EMOTION_THEMES[slugKey]) {
|
||||
return slugKey;
|
||||
}
|
||||
return 'default';
|
||||
}
|
||||
|
||||
function getEmotionTheme(identity?: EmotionIdentity | null): EmotionTheme {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_THEMES[key] ?? themeDefault;
|
||||
}
|
||||
|
||||
function getEmotionIcon(identity?: EmotionIdentity | null): string {
|
||||
const key = resolveEmotionKey(identity);
|
||||
return EMOTION_ICONS[key] ?? '✨';
|
||||
}
|
||||
|
||||
export default function TaskPickerPage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
@@ -218,6 +103,7 @@ export default function TaskPickerPage() {
|
||||
const headers: HeadersInit = {
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
'X-Device-Id': getDeviceId(),
|
||||
};
|
||||
|
||||
if (cached?.etag) {
|
||||
@@ -235,14 +121,17 @@ export default function TaskPickerPage() {
|
||||
|
||||
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
|
||||
const payload = await response.json();
|
||||
if (Array.isArray(payload)) {
|
||||
const entry = { data: payload, etag: response.headers.get('ETag') };
|
||||
tasksCacheRef.current.set(cacheKey, entry);
|
||||
setTasks(payload);
|
||||
} else {
|
||||
tasksCacheRef.current.set(cacheKey, { data: [], etag: response.headers.get('ETag') });
|
||||
setTasks([]);
|
||||
}
|
||||
const taskList = Array.isArray(payload)
|
||||
? payload
|
||||
: Array.isArray(payload?.data)
|
||||
? payload.data
|
||||
: Array.isArray(payload?.tasks)
|
||||
? payload.tasks
|
||||
: [];
|
||||
|
||||
const entry = { data: taskList, etag: response.headers.get('ETag') };
|
||||
tasksCacheRef.current.set(cacheKey, entry);
|
||||
setTasks(taskList);
|
||||
} catch (err) {
|
||||
console.error('Failed to load tasks', err);
|
||||
setError(err instanceof Error ? err.message : 'Unbekannter Fehler');
|
||||
|
||||
@@ -39,6 +39,7 @@ import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { compressPhoto, formatBytes } from '../lib/image';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
|
||||
interface Task {
|
||||
id: number;
|
||||
@@ -120,10 +121,13 @@ export default function UploadPage() {
|
||||
const { t, locale } = useTranslation();
|
||||
const stats = useEventStats();
|
||||
const { branding } = useEventBranding();
|
||||
const { event } = useEventData();
|
||||
const radius = branding.buttons?.radius ?? 12;
|
||||
const buttonStyle = branding.buttons?.style ?? 'filled';
|
||||
const linkColor = branding.buttons?.linkColor ?? branding.secondaryColor;
|
||||
const bodyFont = branding.typography?.body ?? branding.fontFamily ?? undefined;
|
||||
const uploadsRequireApproval =
|
||||
(event?.guest_upload_visibility as 'immediate' | 'review' | undefined) !== 'immediate';
|
||||
|
||||
const taskIdParam = searchParams.get('task');
|
||||
const emotionSlug = searchParams.get('emotion') || '';
|
||||
@@ -253,12 +257,19 @@ const [canUpload, setCanUpload] = useState(true);
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'X-Locale': locale,
|
||||
'X-Device-Id': getDeviceId(),
|
||||
},
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
|
||||
const payload = (await res.json()) as unknown;
|
||||
const entries = Array.isArray(payload) ? payload.filter(isTaskPayload) : [];
|
||||
const entries = Array.isArray(payload)
|
||||
? payload.filter(isTaskPayload)
|
||||
: Array.isArray((payload as any)?.data)
|
||||
? (payload as any).data.filter(isTaskPayload)
|
||||
: Array.isArray((payload as any)?.tasks)
|
||||
? (payload as any).tasks.filter(isTaskPayload)
|
||||
: [];
|
||||
const found = entries.find((entry) => entry.id === currentTaskId) ?? null;
|
||||
|
||||
if (!active) return;
|
||||
@@ -1103,6 +1114,12 @@ const renderWithDialog = (content: ReactNode, wrapperClassName = 'space-y-6 pb-[
|
||||
>
|
||||
{taskFloatingCard}
|
||||
{heroOverlay}
|
||||
{uploadsRequireApproval ? (
|
||||
<div className="mx-4 rounded-xl border border-amber-300/70 bg-amber-50/80 p-3 text-amber-900 shadow-sm backdrop-blur dark:border-amber-400/40 dark:bg-amber-500/10 dark:text-amber-50">
|
||||
<p className="text-sm font-semibold">{t('upload.review.noticeTitle', 'Uploads werden geprüft')}</p>
|
||||
<p className="text-xs">{t('upload.review.noticeBody', 'Dein Foto erscheint, sobald es freigegeben wurde.')}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<section
|
||||
className="relative flex flex-col overflow-hidden border border-white/10 bg-black text-white shadow-2xl"
|
||||
style={{ borderRadius: radius }}
|
||||
|
||||
@@ -64,6 +64,7 @@ export interface EventData {
|
||||
icon: string | null;
|
||||
};
|
||||
branding?: EventBrandingPayload | null;
|
||||
guest_upload_visibility?: 'immediate' | 'review';
|
||||
}
|
||||
|
||||
export interface PackageData {
|
||||
@@ -265,6 +266,8 @@ export async function fetchEvent(eventKey: string): Promise<EventData> {
|
||||
default_locale: typeof json?.default_locale === 'string' && json.default_locale.trim() !== ''
|
||||
? json.default_locale
|
||||
: DEFAULT_LOCALE,
|
||||
guest_upload_visibility:
|
||||
json?.guest_upload_visibility === 'immediate' ? 'immediate' : 'review',
|
||||
};
|
||||
|
||||
if (json?.type) {
|
||||
|
||||
Reference in New Issue
Block a user