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:
@@ -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`);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 = () => {
|
||||
|
||||
@@ -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()}
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user