Added opaque join-token support across backend and frontend: new migration/model/service/endpoints, guest controllers now resolve tokens, and the demo seeder seeds a token. Tenant event details list/manage tokens with copy/revoke actions, and the guest PWA uses tokens end-to-end (routing, storage, uploads, achievements, etc.). Docs TODO updated to reflect completed steps.

This commit is contained in:
Codex Agent
2025-10-12 10:32:37 +02:00
parent d04e234ca0
commit 9394c3171e
73 changed files with 3277 additions and 911 deletions

View File

@@ -29,12 +29,12 @@ function TabLink({
}
export default function BottomNav() {
const { slug } = useParams();
const { token } = useParams();
const location = useLocation();
const { event } = useEventData();
if (!slug) return null; // Only show bottom nav within event context
const base = `/e/${encodeURIComponent(slug)}`;
if (!token) return null; // Only show bottom nav within event context
const base = `/e/${encodeURIComponent(token)}`;
const currentPath = location.pathname;
const locale = event?.default_locale || 'de';
@@ -57,7 +57,7 @@ export default function BottomNav() {
const t = translations[locale as keyof typeof translations] || translations.de;
// Improved active state logic
const isHomeActive = currentPath === base || currentPath === `/${slug}`;
const isHomeActive = currentPath === base || currentPath === `/${token}`;
const isTasksActive = currentPath.startsWith(`${base}/tasks`) || currentPath === `${base}/upload`;
const isAchievementsActive = currentPath.startsWith(`${base}/achievements`);
const isGalleryActive = currentPath.startsWith(`${base}/gallery`) || currentPath.startsWith(`${base}/photos`);

View File

@@ -16,7 +16,8 @@ interface EmotionPickerProps {
}
export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const navigate = useNavigate();
const [emotions, setEmotions] = useState<Emotion[]>([]);
const [loading, setLoading] = useState(true);
@@ -33,7 +34,7 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
];
useEffect(() => {
if (!slug) return;
if (!eventKey) return;
async function fetchEmotions() {
try {
@@ -41,7 +42,7 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
setError(null);
// Try API first
const response = await fetch(`/api/v1/events/${slug}/emotions`);
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/emotions`);
if (response.ok) {
const data = await response.json();
setEmotions(Array.isArray(data) ? data : fallbackEmotions);
@@ -60,14 +61,15 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
}
fetchEmotions();
}, [slug]);
}, [eventKey]);
const handleEmotionSelect = (emotion: Emotion) => {
if (onSelect) {
onSelect(emotion);
} else {
// Default: Navigate to tasks with emotion filter
navigate(`/e/${slug}/tasks?emotion=${emotion.slug}`);
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks?emotion=${emotion.slug}`);
}
};
@@ -139,11 +141,14 @@ export default function EmotionPicker({ onSelect }: EmotionPickerProps) {
<Button
variant="ghost"
className="w-full text-sm text-gray-600 dark:text-gray-300 hover:text-pink-600 hover:bg-pink-50 dark:hover:bg-gray-800 border-t border-gray-200 dark:border-gray-700 pt-3 mt-3"
onClick={() => navigate(`/e/${slug}/tasks`)}
onClick={() => {
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/tasks`);
}}
>
Überspringen und Aufgabe wählen
</Button>
</div>
</div>
);
}
}

View File

@@ -82,7 +82,7 @@ export default function GalleryPreview({ slug }: Props) {
My Photos
</button>
</div>
<Link to={`/e/${slug}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
<Link to={`/e/${encodeURIComponent(slug)}/gallery?mode=${mode}`} className="text-sm text-pink-600 hover:text-pink-700 font-medium">
Alle ansehen
</Link>
</div>
@@ -97,7 +97,7 @@ export default function GalleryPreview({ slug }: Props) {
)}
<div className="grid grid-cols-2 gap-3">
{items.map((p: any) => (
<Link key={p.id} to={`/e/${slug}/gallery?photoId=${p.id}`} className="block">
<Link key={p.id} to={`/e/${encodeURIComponent(slug)}/gallery?photoId=${p.id}`} className="block">
<div className="relative">
<img
src={p.thumbnail_path || p.file_path}
@@ -123,4 +123,3 @@ export default function GalleryPreview({ slug }: Props) {
</div>
);
}

View File

@@ -29,8 +29,8 @@ export default function Header({ slug, title = '' }: { slug?: string; title?: st
}
const { event, loading: eventLoading, error: eventError } = useEventData();
const stats = statsContext && statsContext.slug === slug ? statsContext : undefined;
const guestName = identity && identity.slug === slug && identity.hydrated && identity.name ? identity.name : null;
const stats = statsContext && statsContext.eventKey === slug ? statsContext : undefined;
const guestName = identity && identity.eventKey === slug && identity.hydrated && identity.name ? identity.name : null;
if (eventLoading) {
return (

View File

@@ -2,16 +2,17 @@ import React from 'react';
import { usePollStats } from '../polling/usePollStats';
type EventStatsContextValue = ReturnType<typeof usePollStats> & {
eventKey: string;
slug: string;
};
const EventStatsContext = React.createContext<EventStatsContextValue | undefined>(undefined);
export function EventStatsProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
const stats = usePollStats(slug);
export function EventStatsProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const stats = usePollStats(eventKey);
const value = React.useMemo<EventStatsContextValue>(
() => ({ slug, ...stats }),
[slug, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading]
() => ({ eventKey, slug: eventKey, ...stats }),
[eventKey, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading]
);
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
}

View File

@@ -1,7 +1,8 @@
import React from 'react';
type GuestIdentityContextValue = {
slug: string;
eventKey: string;
slug: string; // backward-compatible alias
name: string;
hydrated: boolean;
setName: (nextName: string) => void;
@@ -11,36 +12,36 @@ type GuestIdentityContextValue = {
const GuestIdentityContext = React.createContext<GuestIdentityContextValue | undefined>(undefined);
function storageKey(slug: string) {
return `guestName_${slug}`;
function storageKey(eventKey: string) {
return `guestName_${eventKey}`;
}
export function readGuestName(slug: string) {
if (!slug || typeof window === 'undefined') {
export function readGuestName(eventKey: string) {
if (!eventKey || typeof window === 'undefined') {
return '';
}
try {
return window.localStorage.getItem(storageKey(slug)) ?? '';
return window.localStorage.getItem(storageKey(eventKey)) ?? '';
} catch (error) {
console.warn('Failed to read guest name', error);
return '';
}
}
export function GuestIdentityProvider({ slug, children }: { slug: string; children: React.ReactNode }) {
export function GuestIdentityProvider({ eventKey, children }: { eventKey: string; children: React.ReactNode }) {
const [name, setNameState] = React.useState('');
const [hydrated, setHydrated] = React.useState(false);
const loadFromStorage = React.useCallback(() => {
if (!slug) {
if (!eventKey) {
setHydrated(true);
setNameState('');
return;
}
try {
const stored = window.localStorage.getItem(storageKey(slug));
const stored = window.localStorage.getItem(storageKey(eventKey));
setNameState(stored ?? '');
} catch (error) {
console.warn('Failed to read guest name from storage', error);
@@ -48,7 +49,7 @@ export function GuestIdentityProvider({ slug, children }: { slug: string; childr
} finally {
setHydrated(true);
}
}, [slug]);
}, [eventKey]);
React.useEffect(() => {
setHydrated(false);
@@ -61,36 +62,37 @@ export function GuestIdentityProvider({ slug, children }: { slug: string; childr
setNameState(trimmed);
try {
if (trimmed) {
window.localStorage.setItem(storageKey(slug), trimmed);
window.localStorage.setItem(storageKey(eventKey), trimmed);
} else {
window.localStorage.removeItem(storageKey(slug));
window.localStorage.removeItem(storageKey(eventKey));
}
} catch (error) {
console.warn('Failed to persist guest name', error);
}
},
[slug]
[eventKey]
);
const clearName = React.useCallback(() => {
setNameState('');
try {
window.localStorage.removeItem(storageKey(slug));
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear guest name', error);
}
}, [slug]);
}, [eventKey]);
const value = React.useMemo<GuestIdentityContextValue>(
() => ({
slug,
eventKey,
slug: eventKey,
name,
hydrated,
setName: persistName,
clearName,
reload: loadFromStorage,
}),
[slug, name, hydrated, persistName, clearName, loadFromStorage]
[eventKey, name, hydrated, persistName, clearName, loadFromStorage]
);
return <GuestIdentityContext.Provider value={value}>{children}</GuestIdentityContext.Provider>;

View File

@@ -3,14 +3,14 @@ import { useParams } from 'react-router-dom';
import { fetchEvent, EventData } from '../services/eventApi';
export function useEventData() {
const { slug } = useParams<{ slug: string }>();
const { token } = useParams<{ token: string }>();
const [event, setEvent] = useState<EventData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!slug) {
setError('No event slug provided');
if (!token) {
setError('No event token provided');
setLoading(false);
return;
}
@@ -19,7 +19,7 @@ export function useEventData() {
try {
setLoading(true);
setError(null);
const eventData = await fetchEvent(slug);
const eventData = await fetchEvent(token);
setEvent(eventData);
} catch (err) {
console.error('Failed to load event:', err);
@@ -30,11 +30,11 @@ export function useEventData() {
};
loadEvent();
}, [slug]);
}, [token]);
return {
event,
loading,
error,
};
}
}

View File

@@ -1,7 +1,7 @@
import React from 'react';
function storageKey(slug: string) {
return `guestTasks_${slug}`;
function storageKey(eventKey: string) {
return `guestTasks_${eventKey}`;
}
function parseStored(value: string | null) {
@@ -20,18 +20,18 @@ function parseStored(value: string | null) {
}
}
export function useGuestTaskProgress(slug: string | undefined) {
export function useGuestTaskProgress(eventKey: string | undefined) {
const [completed, setCompleted] = React.useState<number[]>([]);
const [hydrated, setHydrated] = React.useState(false);
React.useEffect(() => {
if (!slug) {
if (!eventKey) {
setCompleted([]);
setHydrated(true);
return;
}
try {
const stored = window.localStorage.getItem(storageKey(slug));
const stored = window.localStorage.getItem(storageKey(eventKey));
setCompleted(parseStored(stored));
} catch (error) {
console.warn('Failed to read task progress', error);
@@ -39,24 +39,24 @@ export function useGuestTaskProgress(slug: string | undefined) {
} finally {
setHydrated(true);
}
}, [slug]);
}, [eventKey]);
const persist = React.useCallback(
(next: number[]) => {
if (!slug) return;
if (!eventKey) return;
setCompleted(next);
try {
window.localStorage.setItem(storageKey(slug), JSON.stringify(next));
window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next));
} catch (error) {
console.warn('Failed to persist task progress', error);
}
},
[slug]
[eventKey]
);
const markCompleted = React.useCallback(
(taskId: number) => {
if (!slug || !Number.isInteger(taskId)) {
if (!eventKey || !Number.isInteger(taskId)) {
return;
}
setCompleted((prev) => {
@@ -65,25 +65,25 @@ export function useGuestTaskProgress(slug: string | undefined) {
}
const next = [...prev, taskId];
try {
window.localStorage.setItem(storageKey(slug), JSON.stringify(next));
window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next));
} catch (error) {
console.warn('Failed to persist task progress', error);
}
return next;
});
},
[slug]
[eventKey]
);
const clearProgress = React.useCallback(() => {
if (!slug) return;
if (!eventKey) return;
setCompleted([]);
try {
window.localStorage.removeItem(storageKey(slug));
window.localStorage.removeItem(storageKey(eventKey));
} catch (error) {
console.warn('Failed to clear task progress', error);
}
}, [slug]);
}, [eventKey]);
const isCompleted = React.useCallback(
(taskId: number | null | undefined) => {

View File

@@ -1,9 +1,9 @@
import React from 'react';
import React 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.tsx';
import { initializeTheme } from '@/hooks/use-appearance';
import { ToastProvider } from './components/ToastHost';
initializeTheme();
@@ -30,3 +30,4 @@ createRoot(rootEl).render(
</ToastProvider>
</React.StrictMode>
);

View File

@@ -292,7 +292,7 @@ function PersonalActions({ slug }: { slug: string }) {
}
export default function AchievementsPage() {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const identity = useGuestIdentity();
const [data, setData] = useState<AchievementsPayload | null>(null);
const [loading, setLoading] = useState(true);

View File

@@ -12,8 +12,8 @@ import PhotoLightbox from './PhotoLightbox';
import { fetchEvent, getEventPackage, fetchStats, type EventData, type EventPackage, type EventStats } from '../services/eventApi';
export default function GalleryPage() {
const { slug } = useParams();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug!);
const { token: slug } = useParams<{ token?: string }>();
const { photos, loading, newCount, acknowledgeNew } = usePollGalleryDelta(slug ?? '');
const [filter, setFilter] = React.useState<GalleryFilter>('latest');
const [currentPhotoIndex, setCurrentPhotoIndex] = React.useState<number | null>(null);
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
@@ -99,6 +99,10 @@ export default function GalleryPage() {
}
}
if (!slug) {
return <Page title="Galerie"><p>Event nicht gefunden.</p></Page>;
}
if (eventLoading) {
return <Page title="Galerie"><p>Lade Event-Info...</p></Page>;
}

View File

@@ -12,13 +12,13 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
import { Sparkles, UploadCloud, Images, CheckCircle2, Users, TimerReset } from 'lucide-react';
export default function HomePage() {
const { slug } = useParams<{ slug: string }>();
const { token } = useParams<{ token: string }>();
const { name, hydrated } = useGuestIdentity();
const stats = useEventStats();
const { event } = useEventData();
const { completedCount } = useGuestTaskProgress(slug);
const { completedCount } = useGuestTaskProgress(token);
if (!slug) return null;
if (!token) return null;
const displayName = hydrated && name ? name : 'Gast';
const latestUploadText = formatLatestUpload(stats.latestPhotoAt);
@@ -125,7 +125,7 @@ export default function HomePage() {
<EmotionPicker />
<GalleryPreview slug={slug} />
<GalleryPreview slug={token} />
</div>
);
}

View File

@@ -10,28 +10,58 @@ import { readGuestName } from '../context/GuestIdentityContext';
export default function LandingPage() {
const nav = useNavigate();
const [slug, setSlug] = useState('');
const [eventCode, setEventCode] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isScanning, setIsScanning] = useState(false);
const [scanner, setScanner] = useState<Html5Qrcode | null>(null);
async function join(eventSlug?: string) {
const s = (eventSlug ?? slug).trim();
if (!s) return;
function extractEventKey(raw: string): string {
const trimmed = raw.trim();
if (!trimmed) {
return '';
}
try {
const url = new URL(trimmed);
const inviteParam = url.searchParams.get('invite') ?? url.searchParams.get('token');
if (inviteParam) {
return inviteParam;
}
const segments = url.pathname.split('/').filter(Boolean);
const eventIndex = segments.findIndex((segment) => segment === 'e');
if (eventIndex >= 0 && segments.length > eventIndex + 1) {
return decodeURIComponent(segments[eventIndex + 1]);
}
if (segments.length > 0) {
return decodeURIComponent(segments[segments.length - 1]);
}
} catch {
// Not a URL, treat as raw code
}
return trimmed;
}
async function join(input?: string) {
const provided = input ?? eventCode;
const normalized = extractEventKey(provided);
if (!normalized) return;
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(s)}`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(normalized)}`);
if (!res.ok) {
setError('Event nicht gefunden oder geschlossen.');
return;
}
const storedName = readGuestName(s);
const data = await res.json();
const targetKey = data.join_token ?? data.slug ?? normalized;
const storedName = readGuestName(targetKey);
if (!storedName) {
nav(`/setup/${encodeURIComponent(s)}`);
nav(`/setup/${encodeURIComponent(targetKey)}`);
} else {
nav(`/e/${encodeURIComponent(s)}`);
nav(`/e/${encodeURIComponent(targetKey)}`);
}
} catch (e) {
console.error('Join request failed', e);
@@ -136,14 +166,14 @@ export default function LandingPage() {
<div className="space-y-2">
<Input
value={slug}
onChange={(event) => setSlug(event.target.value)}
value={eventCode}
onChange={(event) => setEventCode(event.target.value)}
placeholder="Event-Code eingeben"
disabled={loading}
/>
<Button
className="w-full bg-gradient-to-r from-pink-500 to-pink-600 text-white hover:from-pink-600 hover:to-pink-700"
disabled={loading || !slug.trim()}
disabled={loading || !eventCode.trim()}
onClick={() => join()}
>
{loading ? 'Pruefe...' : 'Event beitreten'}

View File

@@ -13,12 +13,13 @@ export default function LegalPage() {
if (!page) {
return;
}
const slug = page;
const controller = new AbortController();
async function loadLegal() {
try {
setLoading(true);
const res = await fetch(`/api/v1/legal/${encodeURIComponent(page)}?lang=de`, {
const res = await fetch(`/api/v1/legal/${encodeURIComponent(slug)}?lang=de`, {
headers: { 'Cache-Control': 'no-store' },
signal: controller.signal,
});
@@ -45,8 +46,10 @@ export default function LegalPage() {
return () => controller.abort();
}, [page]);
const fallbackTitle = page ? `Rechtliches: ${page}` : 'Rechtliche Informationen';
return (
<Page title={title || `Rechtliches: ${page}` }>
<Page title={title || fallbackTitle}>
{loading ? <p>Laedt...</p> : <LegalMarkdown markdown={body} />}
</Page>
);

View File

@@ -26,11 +26,11 @@ interface Props {
}
export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexChange, slug }: Props) {
const params = useParams();
const params = useParams<{ token?: string; photoId?: string }>();
const location = useLocation();
const navigate = useNavigate();
const photoId = params.photoId;
const eventSlug = params.slug || slug;
const eventSlug = params.token || slug;
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
const [loading, setLoading] = useState(true);
@@ -129,9 +129,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
};
// Load task info if photo has task_id and slug is available
// Load task info if photo has task_id and event key is available
React.useEffect(() => {
if (!photo?.task_id || !slug) {
if (!photo?.task_id || !eventSlug) {
setTask(null);
setTaskLoading(false);
return;
@@ -142,7 +142,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
(async () => {
setTaskLoading(true);
try {
const res = await fetch(`/api/v1/events/${slug}/tasks`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventSlug)}/tasks`);
if (res.ok) {
const tasks = await res.json();
const foundTask = tasks.find((t: any) => t.id === taskId);
@@ -173,7 +173,7 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
setTaskLoading(false);
}
})();
}, [photo?.task_id, slug]);
}, [photo?.task_id, eventSlug]);
async function onLike() {
if (liked || !photo) return;

View File

@@ -9,7 +9,7 @@ import { Label } from '@/components/ui/label';
import Header from '../components/Header';
export default function ProfileSetupPage() {
const { slug } = useParams<{ slug: string }>();
const { token } = useParams<{ token: string }>();
const nav = useNavigate();
const { event, loading, error } = useEventData();
const { name: storedName, setName: persistName, hydrated } = useGuestIdentity();
@@ -17,11 +17,11 @@ export default function ProfileSetupPage() {
const [submitting, setSubmitting] = useState(false);
useEffect(() => {
if (!slug) {
if (!token) {
nav('/');
return;
}
}, [slug, nav]);
}, [token, nav]);
useEffect(() => {
if (hydrated) {
@@ -34,14 +34,14 @@ export default function ProfileSetupPage() {
}
function submitName() {
if (!slug) return;
if (!token) return;
const trimmedName = name.trim();
if (!trimmedName) return;
setSubmitting(true);
try {
persistName(trimmedName);
nav(`/e/${slug}`);
nav(`/e/${token}`);
} catch (e) {
console.error('Fehler beim Speichern des Namens:', e);
setSubmitting(false);
@@ -67,7 +67,7 @@ export default function ProfileSetupPage() {
return (
<div className="min-h-screen bg-gradient-to-b from-pink-50 to-purple-50 flex flex-col">
<Header slug={slug!} />
<Header slug={token!} />
<div className="flex-1 flex flex-col justify-center items-center px-4 py-8">
<Card className="w-full max-w-md">
<CardHeader className="text-center space-y-2">

View File

@@ -28,11 +28,12 @@ const TASK_PROGRESS_TARGET = 5;
const TIMER_VIBRATION = [0, 60, 120, 60];
export default function TaskPickerPage() {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(slug);
const { completedCount, markCompleted, isCompleted } = useGuestTaskProgress(eventKey);
const [tasks, setTasks] = React.useState<Task[]>([]);
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
@@ -48,12 +49,12 @@ export default function TaskPickerPage() {
const initialEmotionRef = React.useRef(false);
const fetchTasks = React.useCallback(async () => {
if (!slug) return;
if (!eventKey) return;
setIsFetching(true);
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/tasks`);
const response = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!response.ok) throw new Error('Aufgaben konnten nicht geladen werden.');
const payload = await response.json();
if (Array.isArray(payload)) {
@@ -69,7 +70,7 @@ export default function TaskPickerPage() {
setIsFetching(false);
setLoading(false);
}
}, [slug]);
}, [eventKey]);
React.useEffect(() => {
fetchTasks();
@@ -206,8 +207,9 @@ export default function TaskPickerPage() {
};
const handleStartUpload = () => {
if (!currentTask || !slug) return;
navigate(`/e/${encodeURIComponent(slug)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
if (!currentTask || !eventKey) return;
if (!eventKey) return;
navigate(`/e/${encodeURIComponent(eventKey)}/upload?task=${currentTask.id}&emotion=${currentTask.emotion?.slug || ''}`);
};
const handleMarkCompleted = () => {

View File

@@ -55,7 +55,8 @@ const DEFAULT_PREFS: CameraPreferences = {
};
export default function UploadPage() {
const { slug } = useParams<{ slug: string }>();
const { token: slug } = useParams<{ token: string }>();
const eventKey = slug ?? '';
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { appearance } = useAppearance();
@@ -65,8 +66,8 @@ export default function UploadPage() {
const taskIdParam = searchParams.get('task');
const emotionSlug = searchParams.get('emotion') || '';
const primerStorageKey = slug ? `guestCameraPrimerDismissed_${slug}` : 'guestCameraPrimerDismissed';
const prefsStorageKey = slug ? `guestCameraPrefs_${slug}` : 'guestCameraPrefs';
const primerStorageKey = eventKey ? `guestCameraPrimerDismissed_${eventKey}` : 'guestCameraPrimerDismissed';
const prefsStorageKey = eventKey ? `guestCameraPrefs_${eventKey}` : 'guestCameraPrefs';
const supportsCamera = typeof navigator !== 'undefined' && !!navigator.mediaDevices?.getUserMedia;
@@ -148,7 +149,7 @@ export default function UploadPage() {
setLoadingTask(true);
setTaskError(null);
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug!)}/tasks`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/tasks`);
if (!res.ok) throw new Error('Tasks konnten nicht geladen werden');
const tasks = await res.json();
const found = Array.isArray(tasks) ? tasks.find((entry: any) => entry.id === taskId!) : null;
@@ -203,15 +204,15 @@ export default function UploadPage() {
return () => {
active = false;
};
}, [slug, taskId, emotionSlug]);
}, [eventKey, taskId, emotionSlug]);
// Check upload limits
useEffect(() => {
if (!slug || !task) return;
if (!eventKey || !task) return;
const checkLimits = async () => {
try {
const pkg = await getEventPackage(slug);
const pkg = await getEventPackage(eventKey);
setEventPackage(pkg);
if (pkg && pkg.used_photos >= pkg.package.max_photos) {
setCanUpload(false);
@@ -227,7 +228,7 @@ export default function UploadPage() {
};
checkLimits();
}, [slug, task]);
}, [eventKey, task]);
const stopStream = useCallback(() => {
if (streamRef.current) {
@@ -444,19 +445,19 @@ export default function UploadPage() {
const navigateAfterUpload = useCallback(
(photoId: number | undefined) => {
if (!slug || !task) return;
if (!eventKey || !task) return;
const params = new URLSearchParams();
params.set('uploaded', 'true');
if (task.id) params.set('task', String(task.id));
if (photoId) params.set('photo', String(photoId));
if (emotionSlug) params.set('emotion', emotionSlug);
navigate(`/e/${encodeURIComponent(slug!)}/gallery?${params.toString()}`);
navigate(`/e/${encodeURIComponent(eventKey)}/gallery?${params.toString()}`);
},
[emotionSlug, navigate, slug, task]
[emotionSlug, navigate, eventKey, task]
);
const handleUsePhoto = useCallback(async () => {
if (!slug || !reviewPhoto || !task || !canUpload) return;
if (!eventKey || !reviewPhoto || !task || !canUpload) return;
setMode('uploading');
setUploadProgress(5);
setUploadError(null);
@@ -470,7 +471,7 @@ export default function UploadPage() {
}, 400);
try {
const photoId = await uploadPhoto(slug, reviewPhoto.file, task.id, emotionSlug || undefined);
const photoId = await uploadPhoto(eventKey, reviewPhoto.file, task.id, emotionSlug || undefined);
setUploadProgress(100);
setStatusMessage('Upload abgeschlossen.');
markCompleted(task.id);
@@ -487,7 +488,7 @@ export default function UploadPage() {
}
setStatusMessage('');
}
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, slug, stopStream, task, canUpload]);
}, [emotionSlug, markCompleted, navigateAfterUpload, reviewPhoto, eventKey, stopStream, task, canUpload]);
const handleGalleryPick = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
if (!canUpload) return;
@@ -532,7 +533,7 @@ export default function UploadPage() {
if (!supportsCamera && !task) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="px-4 py-6">
<Alert>
<AlertDescription>
@@ -548,7 +549,7 @@ export default function UploadPage() {
if (loadingTask) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="px-4 py-6 flex flex-col items-center justify-center text-center">
<Loader2 className="h-10 w-10 animate-spin text-pink-500 mb-4" />
<p className="text-sm text-muted-foreground">Aufgabe und Kamera werden vorbereitet ...</p>
@@ -561,7 +562,7 @@ export default function UploadPage() {
if (!canUpload) {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="px-4 py-6">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
@@ -629,7 +630,7 @@ export default function UploadPage() {
return (
<div className="pb-16">
<Header slug={slug} title="Kamera" />
<Header slug={eventKey} title="Kamera" />
<main className="relative flex flex-col gap-4 pb-4">
<div className="absolute left-0 right-0 top-0" aria-hidden="true">
{renderPrimer()}

View File

@@ -13,6 +13,11 @@ export function usePollGalleryDelta(slug: string) {
);
async function fetchDelta() {
if (!slug) {
setLoading(false);
return;
}
try {
const qs = latestAt.current ? `?since=${encodeURIComponent(latestAt.current)}` : '';
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/photos${qs}`, {
@@ -85,6 +90,12 @@ export function usePollGalleryDelta(slug: string) {
}, []);
useEffect(() => {
if (!slug) {
setPhotos([]);
setLoading(false);
return;
}
setLoading(true);
latestAt.current = null;
setPhotos([]);

View File

@@ -12,7 +12,7 @@ type StatsResponse = {
latest_photo_at?: string;
};
export function usePollStats(slug: string | null | undefined) {
export function usePollStats(eventKey: string | null | undefined) {
const [data, setData] = useState<EventStats>({ onlineGuests: 0, tasksSolved: 0, latestPhotoAt: null });
const [loading, setLoading] = useState(true);
const timer = useRef<number | null>(null);
@@ -20,11 +20,11 @@ export function usePollStats(slug: string | null | undefined) {
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
);
const canPoll = Boolean(slug);
const canPoll = Boolean(eventKey);
async function fetchOnce(activeSlug: string) {
async function fetchOnce(activeKey: string) {
try {
const res = await fetch(`/api/v1/events/${encodeURIComponent(activeSlug)}/stats`, {
const res = await fetch(`/api/v1/events/${encodeURIComponent(activeKey)}/stats`, {
headers: { 'Cache-Control': 'no-store' },
});
if (res.status === 304) return;
@@ -52,16 +52,16 @@ export function usePollStats(slug: string | null | undefined) {
}
setLoading(true);
const activeSlug = String(slug);
fetchOnce(activeSlug);
const activeKey = String(eventKey);
fetchOnce(activeKey);
if (timer.current) window.clearInterval(timer.current);
if (visible) {
timer.current = window.setInterval(() => fetchOnce(activeSlug), 10_000);
timer.current = window.setInterval(() => fetchOnce(activeKey), 10_000);
}
return () => {
if (timer.current) window.clearInterval(timer.current);
};
}, [slug, visible, canPoll]);
}, [eventKey, visible, canPoll]);
return { ...data, loading };
}

View File

@@ -20,9 +20,9 @@ import LegalPage from './pages/LegalPage';
import NotFoundPage from './pages/NotFoundPage';
function HomeLayout() {
const { slug } = useParams();
const { token } = useParams();
if (!slug) {
if (!token) {
return (
<div className="pb-16">
<Header title="Event" />
@@ -35,10 +35,10 @@ function HomeLayout() {
}
return (
<GuestIdentityProvider slug={slug}>
<EventStatsProvider slug={slug}>
<GuestIdentityProvider eventKey={token}>
<EventStatsProvider eventKey={token}>
<div className="pb-16">
<Header slug={slug} />
<Header slug={token} />
<div className="px-4 py-3">
<Outlet />
</div>
@@ -52,14 +52,14 @@ function HomeLayout() {
export const router = createBrowserRouter([
{ path: '/event', element: <SimpleLayout title="Event"><LandingPage /></SimpleLayout> },
{
path: '/setup/:slug',
path: '/setup/:token',
element: <SetupLayout />,
children: [
{ index: true, element: <ProfileSetupPage /> },
],
},
{
path: '/e/:slug',
path: '/e/:token',
element: <HomeLayout />,
children: [
{ index: true, element: <HomePage /> },
@@ -79,13 +79,13 @@ export const router = createBrowserRouter([
]);
function SetupLayout() {
const { slug } = useParams<{ slug: string }>();
if (!slug) return null;
const { token } = useParams<{ token: string }>();
if (!token) return null;
return (
<GuestIdentityProvider slug={slug}>
<EventStatsProvider slug={slug}>
<GuestIdentityProvider eventKey={token}>
<EventStatsProvider eventKey={token}>
<div className="pb-0">
<Header slug={slug} />
<Header slug={token} />
<Outlet />
</div>
</EventStatsProvider>

View File

@@ -7,6 +7,7 @@ export interface EventData {
default_locale: string;
created_at: string;
updated_at: string;
join_token?: string | null;
type?: {
slug: string;
name: string;
@@ -33,14 +34,14 @@ export interface EventStats {
latestPhotoAt: string | null;
}
export async function fetchEvent(slug: string): Promise<EventData> {
const res = await fetch(`/api/v1/events/${slug}`);
export async function fetchEvent(eventKey: string): Promise<EventData> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}`);
if (!res.ok) throw new Error('Event fetch failed');
return await res.json();
}
export async function fetchStats(slug: string): Promise<EventStats> {
const res = await fetch(`/api/v1/events/${slug}/stats`, {
export async function fetchStats(eventKey: string): Promise<EventStats> {
const res = await fetch(`/api/v1/events/${encodeURIComponent(eventKey)}/stats`, {
headers: {
'X-Device-Id': getDeviceId(),
},
@@ -48,17 +49,17 @@ export async function fetchStats(slug: string): Promise<EventStats> {
if (!res.ok) throw new Error('Stats fetch failed');
const json = await res.json();
return {
onlineGuests: json.onlineGuests ?? 0,
tasksSolved: json.tasksSolved ?? 0,
latestPhotoAt: json.latestPhotoAt ?? null,
onlineGuests: json.online_guests ?? json.onlineGuests ?? 0,
tasksSolved: json.tasks_solved ?? json.tasksSolved ?? 0,
latestPhotoAt: json.latest_photo_at ?? json.latestPhotoAt ?? null,
};
}
export async function getEventPackage(slug: string): Promise<EventPackage | null> {
const res = await fetch(`/api/v1/events/${slug}/package`);
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/package`);
if (!res.ok) {
if (res.status === 404) return null;
throw new Error('Failed to load event package');
}
return await res.json();
}
}

View File

@@ -77,7 +77,7 @@ export async function uploadPhoto(slug: string, file: File, taskId?: number, emo
if (emotionSlug) formData.append('emotion_slug', emotionSlug);
formData.append('device_id', getDeviceId());
const res = await fetch(`/api/v1/events/${slug}/upload`, {
const res = await fetch(`/api/v1/events/${encodeURIComponent(slug)}/upload`, {
method: 'POST',
credentials: 'include',
body: formData,
@@ -99,4 +99,3 @@ export async function uploadPhoto(slug: string, file: File, taskId?: number, emo
const json = await res.json();
return json.photo_id ?? json.id ?? json.data?.id ?? 0;
}