typescript-typenfehler behoben.. npm run lint läuft nun fehlerfrei durch.
This commit is contained in:
@@ -37,14 +37,17 @@ export default function EmotionPicker({
|
||||
const { locale } = useTranslation();
|
||||
|
||||
// Fallback emotions (when API not available yet)
|
||||
const fallbackEmotions: Emotion[] = [
|
||||
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
|
||||
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
|
||||
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
|
||||
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
|
||||
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
|
||||
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
|
||||
];
|
||||
const fallbackEmotions = React.useMemo<Emotion[]>(
|
||||
() => [
|
||||
{ id: 1, slug: 'happy', name: 'Glücklich', emoji: '😊' },
|
||||
{ id: 2, slug: 'love', name: 'Verliebt', emoji: '❤️' },
|
||||
{ id: 3, slug: 'excited', name: 'Aufgeregt', emoji: '🎉' },
|
||||
{ id: 4, slug: 'relaxed', name: 'Entspannt', emoji: '😌' },
|
||||
{ id: 5, slug: 'sad', name: 'Traurig', emoji: '😢' },
|
||||
{ id: 6, slug: 'surprised', name: 'Überrascht', emoji: '😲' },
|
||||
],
|
||||
[]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!eventKey) return;
|
||||
@@ -79,7 +82,7 @@ export default function EmotionPicker({
|
||||
}
|
||||
|
||||
fetchEmotions();
|
||||
}, [eventKey, locale]);
|
||||
}, [eventKey, locale, fallbackEmotions]);
|
||||
|
||||
const handleEmotionSelect = (emotion: Emotion) => {
|
||||
if (onSelect) {
|
||||
|
||||
@@ -9,6 +9,20 @@ import { useTranslation } from '../i18n/useTranslation';
|
||||
type Props = { token: string };
|
||||
|
||||
type PreviewFilter = 'latest' | 'popular' | 'mine' | 'photobooth';
|
||||
type PreviewPhoto = {
|
||||
id: number;
|
||||
session_id?: string | null;
|
||||
ingest_source?: string | null;
|
||||
likes_count?: number | null;
|
||||
created_at?: string | null;
|
||||
task_id?: number | null;
|
||||
task_title?: string | null;
|
||||
emotion_id?: number | null;
|
||||
emotion_name?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
file_path?: string | null;
|
||||
title?: string | null;
|
||||
};
|
||||
|
||||
export default function GalleryPreview({ token }: Props) {
|
||||
const { locale } = useTranslation();
|
||||
@@ -16,28 +30,29 @@ export default function GalleryPreview({ token }: Props) {
|
||||
const [mode, setMode] = React.useState<PreviewFilter>('latest');
|
||||
|
||||
const items = React.useMemo(() => {
|
||||
let arr = photos.slice();
|
||||
const typed = photos as PreviewPhoto[];
|
||||
let arr = typed.slice();
|
||||
|
||||
// MyPhotos filter (requires session_id matching)
|
||||
if (mode === 'mine') {
|
||||
const deviceId = getDeviceId();
|
||||
arr = arr.filter((photo: any) => photo.session_id === deviceId);
|
||||
arr = arr.filter((photo) => photo.session_id === deviceId);
|
||||
} else if (mode === 'photobooth') {
|
||||
arr = arr.filter((photo: any) => photo.ingest_source === 'photobooth');
|
||||
arr = arr.filter((photo) => photo.ingest_source === 'photobooth');
|
||||
}
|
||||
|
||||
// Sorting
|
||||
if (mode === 'popular') {
|
||||
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
} else {
|
||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
}
|
||||
|
||||
return arr.slice(0, 4); // 2x2 = 4 items
|
||||
}, [photos, mode]);
|
||||
|
||||
// Helper function to generate photo title (must be before return)
|
||||
function getPhotoTitle(photo: any): string {
|
||||
function getPhotoTitle(photo: PreviewPhoto): string {
|
||||
if (photo.task_id) {
|
||||
return `Task: ${photo.task_title || 'Unbekannte Aufgabe'}`;
|
||||
}
|
||||
@@ -102,7 +117,7 @@ export default function GalleryPreview({ token }: Props) {
|
||||
)}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{items.map((p: any) => (
|
||||
{items.map((p: PreviewPhoto) => (
|
||||
<Link
|
||||
key={p.id}
|
||||
to={`/e/${encodeURIComponent(token)}/gallery?photoId=${p.id}`}
|
||||
|
||||
@@ -11,7 +11,7 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
setTimeout(() => setList((arr) => arr.filter((x) => x.id !== id)), 3000);
|
||||
}, []);
|
||||
React.useEffect(() => {
|
||||
const onEvt = (e: any) => push(e.detail);
|
||||
const onEvt = (e: CustomEvent<Omit<Toast, 'id'>>) => push(e.detail);
|
||||
window.addEventListener('guest-toast', onEvt);
|
||||
return () => window.removeEventListener('guest-toast', onEvt);
|
||||
}, [push]);
|
||||
|
||||
@@ -12,7 +12,7 @@ export function EventStatsProvider({ eventKey, children }: { eventKey: string; c
|
||||
const stats = usePollStats(eventKey);
|
||||
const value = React.useMemo<EventStatsContextValue>(
|
||||
() => ({ eventKey, slug: eventKey, ...stats }),
|
||||
[eventKey, stats.onlineGuests, stats.tasksSolved, stats.latestPhotoAt, stats.loading]
|
||||
[eventKey, stats]
|
||||
);
|
||||
return <EventStatsContext.Provider value={value}>{children}</EventStatsContext.Provider>;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,13 @@ let enabled = false;
|
||||
let originalFetch: typeof window.fetch | null = null;
|
||||
const likeState = new Map<number, number>();
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__FOTOSPIEL_DEMO__?: boolean;
|
||||
__FOTOSPIEL_DEMO_ACTIVE__?: boolean;
|
||||
}
|
||||
}
|
||||
|
||||
export function shouldEnableGuestDemoMode(): boolean {
|
||||
if (typeof window === 'undefined') {
|
||||
return false;
|
||||
@@ -16,7 +23,7 @@ export function shouldEnableGuestDemoMode(): boolean {
|
||||
if (params.get('demo') === '1') {
|
||||
return true;
|
||||
}
|
||||
if ((window as any).__FOTOSPIEL_DEMO__ === true) {
|
||||
if (window.__FOTOSPIEL_DEMO__ === true) {
|
||||
return true;
|
||||
}
|
||||
const attr = document.documentElement?.dataset?.guestDemo;
|
||||
@@ -42,7 +49,7 @@ export function enableGuestDemoMode(config: DemoConfig = { fixtures: demoFixture
|
||||
};
|
||||
|
||||
enabled = true;
|
||||
(window as any).__FOTOSPIEL_DEMO_ACTIVE__ = true;
|
||||
window.__FOTOSPIEL_DEMO_ACTIVE__ = true;
|
||||
notifyDemoToast();
|
||||
}
|
||||
|
||||
|
||||
@@ -43,19 +43,6 @@ export function useGuestTaskProgress(eventKey: string | undefined) {
|
||||
}
|
||||
}, [eventKey]);
|
||||
|
||||
const persist = React.useCallback(
|
||||
(next: number[]) => {
|
||||
if (!eventKey) return;
|
||||
setCompleted(next);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey(eventKey), JSON.stringify(next));
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist task progress', error);
|
||||
}
|
||||
},
|
||||
[eventKey]
|
||||
);
|
||||
|
||||
const markCompleted = React.useCallback(
|
||||
(taskId: number) => {
|
||||
if (!eventKey || !Number.isInteger(taskId)) {
|
||||
|
||||
@@ -15,7 +15,7 @@ export async function compressPhoto(
|
||||
const canvas = createCanvas(width, height);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) throw new Error('Canvas unsupported');
|
||||
ctx.drawImage(img as any, 0, 0, width, height);
|
||||
ctx.drawImage(img, 0, 0, width, height);
|
||||
|
||||
// Iteratively lower quality to fit target size
|
||||
let quality = qualityStart;
|
||||
@@ -58,14 +58,20 @@ function createCanvas(w: number, h: number): HTMLCanvasElement {
|
||||
}
|
||||
|
||||
function toBlob(canvas: HTMLCanvasElement, type: string, quality: number): Promise<Blob | null> {
|
||||
return new Promise((resolve) => canvas.toBlob(resolve, type, quality));
|
||||
return new Promise(resolve => canvas.toBlob(resolve, type, quality));
|
||||
}
|
||||
|
||||
async function loadImageBitmap(file: File): Promise<HTMLImageElement | ImageBitmap> {
|
||||
async function loadImageBitmap(file: File): Promise<CanvasImageSource> {
|
||||
const canBitmap = 'createImageBitmap' in window;
|
||||
|
||||
if (canBitmap) {
|
||||
try { return await (createImageBitmap as any)(file); } catch {}
|
||||
try {
|
||||
return await createImageBitmap(file);
|
||||
} catch (error) {
|
||||
console.warn('Falling back to HTML image decode', error);
|
||||
}
|
||||
}
|
||||
|
||||
return await loadHtmlImage(file);
|
||||
}
|
||||
|
||||
@@ -88,4 +94,3 @@ export function formatBytes(bytes: number) {
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,8 +43,8 @@ export async function sharePhotoLink(options: ShareOptions): Promise<{ url: stri
|
||||
try {
|
||||
await navigator.share(shareData);
|
||||
return { url: payload.url, method: 'native' };
|
||||
} catch (error: any) {
|
||||
if (error?.name === 'AbortError') {
|
||||
} catch (error: unknown) {
|
||||
if (error && typeof error === 'object' && 'name' in error && (error as { name?: string }).name === 'AbortError') {
|
||||
return { url: payload.url, method: 'native' };
|
||||
}
|
||||
// fall through to clipboard
|
||||
|
||||
@@ -6,13 +6,28 @@ import FiltersBar, { type GalleryFilter } from '../components/FiltersBar';
|
||||
import { Heart, Image as ImageIcon, Share2 } from 'lucide-react';
|
||||
import { likePhoto } from '../services/photosApi';
|
||||
import PhotoLightbox from './PhotoLightbox';
|
||||
import { fetchEvent, fetchStats, type EventData, type EventStats } from '../services/eventApi';
|
||||
import { fetchEvent, type EventData } from '../services/eventApi';
|
||||
import { useTranslation } from '../i18n/useTranslation';
|
||||
import { sharePhotoLink } from '../lib/sharePhoto';
|
||||
import { useToast } from '../components/ToastHost';
|
||||
import { localizeTaskLabel } from '../lib/localizeTaskLabel';
|
||||
|
||||
const allowedGalleryFilters: GalleryFilter[] = ['latest', 'popular', 'mine', 'photobooth'];
|
||||
type GalleryPhoto = {
|
||||
id: number;
|
||||
likes_count?: number | null;
|
||||
created_at?: string | null;
|
||||
ingest_source?: string | null;
|
||||
session_id?: string | null;
|
||||
task_id?: number | null;
|
||||
task_title?: string | null;
|
||||
emotion_id?: number | null;
|
||||
emotion_name?: string | null;
|
||||
thumbnail_path?: string | null;
|
||||
file_path?: string | null;
|
||||
title?: string | null;
|
||||
uploader_name?: string | null;
|
||||
};
|
||||
|
||||
const parseGalleryFilter = (value: string | null): GalleryFilter =>
|
||||
allowedGalleryFilters.includes(value as GalleryFilter) ? (value as GalleryFilter) : 'latest';
|
||||
@@ -46,7 +61,6 @@ export default function GalleryPage() {
|
||||
const [hasOpenedPhoto, setHasOpenedPhoto] = useState(false);
|
||||
|
||||
const [event, setEvent] = useState<EventData | null>(null);
|
||||
const [stats, setStats] = useState<EventStats | null>(null);
|
||||
const [eventLoading, setEventLoading] = useState(true);
|
||||
const toast = useToast();
|
||||
const [shareTargetId, setShareTargetId] = React.useState<number | null>(null);
|
||||
@@ -62,16 +76,18 @@ export default function GalleryPage() {
|
||||
params.set('mode', next);
|
||||
setSearchParams(params, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
const typedPhotos = photos as GalleryPhoto[];
|
||||
|
||||
// Auto-open lightbox if photoId in query params
|
||||
useEffect(() => {
|
||||
if (photoIdParam && photos.length > 0 && currentPhotoIndex === null && !hasOpenedPhoto) {
|
||||
const index = photos.findIndex((photo: any) => photo.id === parseInt(photoIdParam, 10));
|
||||
const index = typedPhotos.findIndex((photo) => photo.id === parseInt(photoIdParam, 10));
|
||||
if (index !== -1) {
|
||||
setCurrentPhotoIndex(index);
|
||||
setHasOpenedPhoto(true);
|
||||
}
|
||||
}
|
||||
}, [photos, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
|
||||
}, [typedPhotos, photos.length, photoIdParam, currentPhotoIndex, hasOpenedPhoto]);
|
||||
|
||||
// Load event and package info
|
||||
useEffect(() => {
|
||||
@@ -80,12 +96,8 @@ export default function GalleryPage() {
|
||||
const loadEventData = async () => {
|
||||
try {
|
||||
setEventLoading(true);
|
||||
const [eventData, statsData] = await Promise.all([
|
||||
fetchEvent(token),
|
||||
fetchStats(token),
|
||||
]);
|
||||
const eventData = await fetchEvent(token);
|
||||
setEvent(eventData);
|
||||
setStats(statsData);
|
||||
} catch (err) {
|
||||
console.error('Failed to load event data', err);
|
||||
} finally {
|
||||
@@ -104,27 +116,22 @@ export default function GalleryPage() {
|
||||
}, []);
|
||||
|
||||
const list = React.useMemo(() => {
|
||||
let arr = photos.slice();
|
||||
let arr = typedPhotos.slice();
|
||||
if (filter === 'popular') {
|
||||
arr.sort((a: any, b: any) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
arr.sort((a, b) => (b.likes_count ?? 0) - (a.likes_count ?? 0));
|
||||
} else if (filter === 'mine') {
|
||||
arr = arr.filter((p: any) => myPhotoIds.has(p.id));
|
||||
arr = arr.filter((p) => myPhotoIds.has(p.id));
|
||||
} else if (filter === 'photobooth') {
|
||||
arr = arr.filter((p: any) => p.ingest_source === 'photobooth');
|
||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
arr = arr.filter((p) => p.ingest_source === 'photobooth');
|
||||
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
} else {
|
||||
arr.sort((a: any, b: any) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
arr.sort((a, b) => new Date(b.created_at ?? 0).getTime() - new Date(a.created_at ?? 0).getTime());
|
||||
}
|
||||
return arr;
|
||||
}, [photos, filter, myPhotoIds]);
|
||||
}, [typedPhotos, filter, myPhotoIds]);
|
||||
const [liked, setLiked] = React.useState<Set<number>>(new Set());
|
||||
const [counts, setCounts] = React.useState<Record<number, number>>({});
|
||||
|
||||
const totalLikes = React.useMemo(
|
||||
() => photos.reduce((sum, photo: any) => sum + (photo.likes_count ?? 0), 0),
|
||||
[photos],
|
||||
);
|
||||
|
||||
async function onLike(id: number) {
|
||||
if (liked.has(id)) return;
|
||||
setLiked(new Set(liked).add(id));
|
||||
@@ -136,13 +143,16 @@ export default function GalleryPage() {
|
||||
const raw = localStorage.getItem('liked-photo-ids');
|
||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||
if (!arr.includes(id)) localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, id]));
|
||||
} catch {}
|
||||
} catch {
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist liked-photo-ids', error);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Like failed', error);
|
||||
const s = new Set(liked); s.delete(id); setLiked(s);
|
||||
}
|
||||
}
|
||||
|
||||
async function onShare(photo: any) {
|
||||
async function onShare(photo: GalleryPhoto) {
|
||||
if (!token) return;
|
||||
setShareTargetId(photo.id);
|
||||
try {
|
||||
@@ -223,7 +233,7 @@ export default function GalleryPage() {
|
||||
<FiltersBar value={filter} onChange={setFilter} className="mt-2" />
|
||||
{loading && <p className="px-4">{t('galleryPage.loading', 'Lade…')}</p>}
|
||||
<div className="grid gap-3 px-4 pb-16 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{list.map((p: any) => {
|
||||
{list.map((p: GalleryPhoto) => {
|
||||
const imageUrl = normalizeImageUrl(p.thumbnail_path || p.file_path);
|
||||
const createdLabel = p.created_at
|
||||
? new Date(p.created_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||
@@ -236,7 +246,7 @@ export default function GalleryPage() {
|
||||
const altText = t('galleryPage.photo.alt', { id: p.id, suffix: altSuffix }, `Foto ${p.id}${altSuffix}`);
|
||||
|
||||
const openPhoto = () => {
|
||||
const index = list.findIndex((photo: any) => photo.id === p.id);
|
||||
const index = list.findIndex((photo) => photo.id === p.id);
|
||||
setCurrentPhotoIndex(index >= 0 ? index : null);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { Page } from './_util';
|
||||
@@ -15,7 +14,6 @@ export default function HelpArticlePage() {
|
||||
const { t } = useTranslation();
|
||||
const [article, setArticle] = React.useState<HelpArticleDetail | null>(null);
|
||||
const [state, setState] = React.useState<'loading' | 'ready' | 'error'>('loading');
|
||||
const [servedFromCache, setServedFromCache] = React.useState(false);
|
||||
const basePath = params.token ? `/e/${encodeURIComponent(params.token)}/help` : '/help';
|
||||
|
||||
const loadArticle = React.useCallback(async () => {
|
||||
@@ -27,7 +25,6 @@ export default function HelpArticlePage() {
|
||||
try {
|
||||
const result = await getHelpArticle(slug, locale);
|
||||
setArticle(result.article);
|
||||
setServedFromCache(result.servedFromCache);
|
||||
setState('ready');
|
||||
} catch (error) {
|
||||
console.error('[HelpArticle] Failed to load article', error);
|
||||
@@ -117,7 +114,7 @@ function formatDate(value: string, locale: string): string {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,7 +147,7 @@ function formatDate(value: string, locale: string): string {
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
} catch (error) {
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,10 +6,9 @@ import { Separator } from '@/components/ui/separator';
|
||||
import EmotionPicker from '../components/EmotionPicker';
|
||||
import GalleryPreview from '../components/GalleryPreview';
|
||||
import { useGuestIdentity } from '../context/GuestIdentityContext';
|
||||
import { useEventStats } from '../context/EventStatsContext';
|
||||
import { useEventData } from '../hooks/useEventData';
|
||||
import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { Sparkles, UploadCloud, X, Camera, RefreshCw } from 'lucide-react';
|
||||
import { Sparkles, UploadCloud, X, RefreshCw } from 'lucide-react';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import type { EventBranding } from '../types/event-branding';
|
||||
@@ -17,7 +16,6 @@ import type { EventBranding } from '../types/event-branding';
|
||||
export default function HomePage() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const { name, hydrated } = useGuestIdentity();
|
||||
const stats = useEventStats();
|
||||
const { event } = useEventData();
|
||||
const { completedCount } = useGuestTaskProgress(token ?? '');
|
||||
const { t, locale } = useTranslation();
|
||||
@@ -100,10 +98,10 @@ export default function HomePage() {
|
||||
const payload = await response.json();
|
||||
if (cancelled) return;
|
||||
if (Array.isArray(payload) && payload.length) {
|
||||
missionPoolRef.current = payload.map((task: any) => ({
|
||||
missionPoolRef.current = payload.map((task: Record<string, unknown>) => ({
|
||||
id: Number(task.id),
|
||||
title: task.title ?? 'Mission',
|
||||
description: task.description ?? '',
|
||||
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,
|
||||
}));
|
||||
|
||||
@@ -38,8 +38,6 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
const toast = useToast();
|
||||
|
||||
const [standalonePhoto, setStandalonePhoto] = useState<Photo | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [task, setTask] = useState<Task | null>(null);
|
||||
const [taskLoading, setTaskLoading] = useState(false);
|
||||
const [likes, setLikes] = useState<number>(0);
|
||||
@@ -59,8 +57,6 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
useEffect(() => {
|
||||
if (isStandalone && photoId && !standalonePhoto && eventToken) {
|
||||
const fetchPhoto = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/photos/${photoId}?locale=${encodeURIComponent(locale)}`, {
|
||||
headers: {
|
||||
@@ -76,20 +72,17 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
setStandalonePhoto(location.state.photo);
|
||||
}
|
||||
} else {
|
||||
setError(t('lightbox.errors.notFound'));
|
||||
toast.push({ text: t('lightbox.errors.notFound'), type: 'error' });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(t('lightbox.errors.loadFailed'));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
console.warn('Standalone photo load failed', err);
|
||||
toast.push({ text: t('lightbox.errors.loadFailed'), type: 'error' });
|
||||
}
|
||||
};
|
||||
|
||||
fetchPhoto();
|
||||
} else if (!isStandalone) {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale]);
|
||||
}, [isStandalone, photoId, eventToken, standalonePhoto, location.state, t, locale, toast]);
|
||||
|
||||
// Update likes when photo changes
|
||||
React.useEffect(() => {
|
||||
@@ -163,8 +156,8 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
}
|
||||
);
|
||||
if (res.ok) {
|
||||
const tasks = await res.json();
|
||||
const foundTask = tasks.find((t: any) => t.id === taskId);
|
||||
const tasks = (await res.json()) as Task[];
|
||||
const foundTask = tasks.find((t) => t.id === taskId);
|
||||
if (foundTask) {
|
||||
setTask({
|
||||
id: foundTask.id,
|
||||
@@ -207,7 +200,9 @@ export default function PhotoLightbox({ photos, currentIndex, onClose, onIndexCh
|
||||
if (!arr.includes(photo.id)) {
|
||||
localStorage.setItem('liked-photo-ids', JSON.stringify([...arr, photo.id]));
|
||||
}
|
||||
} catch {}
|
||||
} catch (storageError) {
|
||||
console.warn('Failed to persist liked photo IDs', storageError);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Like failed:', error);
|
||||
setLiked(false);
|
||||
|
||||
@@ -40,7 +40,7 @@ export default function PublicGalleryPage(): React.ReactElement | null {
|
||||
|
||||
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
|
||||
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null;
|
||||
const effectiveLocale = storedLocale && isLocaleCode(storedLocale as any) ? (storedLocale as any) : DEFAULT_LOCALE;
|
||||
const effectiveLocale: LocaleCode = storedLocale && isLocaleCode(storedLocale) ? storedLocale : DEFAULT_LOCALE;
|
||||
|
||||
const applyMeta = useCallback((meta: GalleryMetaResponse) => {
|
||||
if (typeof window !== 'undefined' && token) {
|
||||
|
||||
@@ -39,7 +39,7 @@ export default function SharedPhotoPage() {
|
||||
if (!active) return;
|
||||
setState({ loading: false, error: null, data });
|
||||
})
|
||||
.catch((error: any) => {
|
||||
.catch((error: unknown) => {
|
||||
if (!active) return;
|
||||
setState({ loading: false, error: error?.message ?? t('share.error', 'Moment konnte nicht geladen werden.'), data: null });
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useGuestTaskProgress } from '../hooks/useGuestTaskProgress';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
||||
import EmotionPicker from '../components/EmotionPicker';
|
||||
import { useEventBranding } from '../context/EventBrandingContext';
|
||||
import { useTranslation, type TranslateFn } from '../i18n/useTranslation';
|
||||
|
||||
@@ -171,7 +170,7 @@ export default function TaskPickerPage() {
|
||||
const { branding } = useEventBranding();
|
||||
const { t, locale } = useTranslation();
|
||||
|
||||
const { completedCount, isCompleted } = useGuestTaskProgress(eventKey);
|
||||
const { isCompleted } = useGuestTaskProgress(eventKey);
|
||||
|
||||
const [tasks, setTasks] = React.useState<Task[]>([]);
|
||||
const [currentTask, setCurrentTask] = React.useState<Task | null>(null);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { LocaleCode } from '../i18n/messages';
|
||||
|
||||
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string };
|
||||
type Photo = { id: number; file_path?: string; thumbnail_path?: string; created_at?: string; session_id?: string | null };
|
||||
type RawPhoto = Record<string, unknown>;
|
||||
|
||||
export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
const [photos, setPhotos] = useState<Photo[]>([]);
|
||||
@@ -14,7 +15,7 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true
|
||||
);
|
||||
|
||||
async function fetchDelta() {
|
||||
const fetchDelta = useCallback(async () => {
|
||||
if (!token) {
|
||||
setLoading(false);
|
||||
return;
|
||||
@@ -56,9 +57,9 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
Array.isArray(json) ? json :
|
||||
json.photos || [];
|
||||
|
||||
const newPhotos = rawPhotos.map((photo: any) => ({
|
||||
...photo,
|
||||
session_id: photo?.session_id ?? photo?.guest_name ?? null,
|
||||
const newPhotos: Photo[] = rawPhotos.map((photo: RawPhoto) => ({
|
||||
...(photo as Photo),
|
||||
session_id: typeof photo.session_id === 'string' ? photo.session_id : (photo.guest_name as string | null) ?? null,
|
||||
}));
|
||||
|
||||
if (newPhotos.length > 0) {
|
||||
@@ -67,11 +68,9 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
if (latestAt.current) {
|
||||
// Delta mode: Add new photos to existing list
|
||||
const merged = [...newPhotos, ...photos];
|
||||
// Remove duplicates by ID
|
||||
const uniquePhotos = merged.filter((photo, index, self) =>
|
||||
index === self.findIndex(p => p.id === photo.id)
|
||||
);
|
||||
setPhotos(uniquePhotos);
|
||||
const byId = new Map<number, Photo>();
|
||||
merged.forEach((photo) => byId.set(photo.id, photo));
|
||||
setPhotos(Array.from(byId.values()));
|
||||
if (added > 0) setNewCount((c) => c + added);
|
||||
} else {
|
||||
// Initial load: Set all photos
|
||||
@@ -83,8 +82,8 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
latestAt.current = json.latest_photo_at;
|
||||
} else if (newPhotos.length > 0) {
|
||||
// Fallback: use newest photo timestamp
|
||||
const newest = newPhotos.reduce((latest: number, photo: any) => {
|
||||
const photoTime = new Date(photo.created_at || photo.created_at_timestamp || 0).getTime();
|
||||
const newest = newPhotos.reduce((latest: number, photo: RawPhoto) => {
|
||||
const photoTime = new Date((photo.created_at as string | undefined) || (photo.created_at_timestamp as number | undefined) || 0).getTime();
|
||||
return photoTime > latest ? photoTime : latest;
|
||||
}, 0);
|
||||
latestAt.current = new Date(newest).toISOString();
|
||||
@@ -104,7 +103,7 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
setLoading(false);
|
||||
// Don't update state on error - keep previous photos
|
||||
}
|
||||
}
|
||||
}, [locale, photos, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const onVis = () => setVisible(document.visibilityState === 'visible');
|
||||
@@ -123,15 +122,15 @@ export function usePollGalleryDelta(token: string, locale: LocaleCode) {
|
||||
latestAt.current = null;
|
||||
etagRef.current = null;
|
||||
setPhotos([]);
|
||||
fetchDelta();
|
||||
void fetchDelta();
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
// Poll less aggressively when hidden
|
||||
const interval = visible ? 30_000 : 90_000;
|
||||
timer.current = window.setInterval(fetchDelta, interval);
|
||||
timer.current = window.setInterval(() => { void fetchDelta(); }, interval);
|
||||
return () => {
|
||||
if (timer.current) window.clearInterval(timer.current);
|
||||
};
|
||||
}, [token, visible, locale]);
|
||||
}, [token, visible, locale, fetchDelta]);
|
||||
|
||||
function acknowledgeNew() { setNewCount(0); }
|
||||
return { loading, photos, newCount, acknowledgeNew };
|
||||
|
||||
@@ -21,7 +21,7 @@ export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) =>
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction('items', mode);
|
||||
const store = tx.objectStore('items');
|
||||
let result: any;
|
||||
let result: unknown;
|
||||
const wrap = async () => {
|
||||
try { result = await fn(store); } catch (e) { reject(e); }
|
||||
};
|
||||
@@ -31,4 +31,3 @@ export async function withStore<T>(mode: TxMode, fn: (store: IDBObjectStore) =>
|
||||
tx.onabort = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
export function notify(text: string, type: 'success'|'error') {
|
||||
// Lazy import to avoid cycle
|
||||
import('../components/ToastHost').then(({ useToast }) => {
|
||||
try {
|
||||
// This only works inside React tree; for SW-triggered, we fallback
|
||||
const evt = new CustomEvent('guest-toast', { detail: { text, type } });
|
||||
window.dispatchEvent(evt);
|
||||
} catch {}
|
||||
});
|
||||
import('../components/ToastHost')
|
||||
.then(() => {
|
||||
try {
|
||||
// This only works inside React tree; for SW-triggered, we fallback
|
||||
const evt = new CustomEvent('guest-toast', { detail: { text, type } });
|
||||
window.dispatchEvent(evt);
|
||||
} catch (error) {
|
||||
console.warn('Dispatching toast event failed', error);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn('Toast module failed to load', error);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,9 @@ export async function enqueue(item: Omit<QueueItem, 'id' | 'status' | 'retries'
|
||||
try {
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
(reg as ServiceWorkerRegistration & { sync?: SyncManager }).sync?.register('upload-queue');
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('Background sync registration failed', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,7 +85,9 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
|
||||
(pct) => {
|
||||
try {
|
||||
window.dispatchEvent(new CustomEvent('queue-progress', { detail: { id: it.id, progress: pct } }));
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('Queue progress dispatch failed', error);
|
||||
}
|
||||
}
|
||||
);
|
||||
// mark my-photo-ids for "Meine"
|
||||
@@ -91,7 +95,9 @@ async function attemptUpload(it: QueueItem): Promise<boolean> {
|
||||
const raw = localStorage.getItem('my-photo-ids');
|
||||
const arr: number[] = raw ? JSON.parse(raw) : [];
|
||||
if (json.id && !arr.includes(json.id)) localStorage.setItem('my-photo-ids', JSON.stringify([json.id, ...arr]));
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('Failed to persist my-photo-ids', error);
|
||||
}
|
||||
notify('Upload erfolgreich', 'success');
|
||||
return true;
|
||||
} catch {
|
||||
|
||||
@@ -5,7 +5,7 @@ export async function createUpload(
|
||||
it: QueueItem,
|
||||
deviceId: string,
|
||||
onProgress?: (percent: number) => void
|
||||
): Promise<any> {
|
||||
): Promise<Record<string, unknown>> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
@@ -22,7 +22,12 @@ export async function createUpload(
|
||||
};
|
||||
xhr.onload = () => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
try { resolve(JSON.parse(xhr.responseText)); } catch { resolve({}); }
|
||||
try {
|
||||
resolve(JSON.parse(xhr.responseText));
|
||||
} catch (error) {
|
||||
console.warn('Upload response parse failed', error);
|
||||
resolve({});
|
||||
}
|
||||
} else {
|
||||
reject(new Error('upload failed'));
|
||||
}
|
||||
|
||||
@@ -160,32 +160,41 @@ export async function fetchAchievements(
|
||||
tasks: toNumber(personalRaw.tasks),
|
||||
likes: toNumber(personalRaw.likes),
|
||||
badges: Array.isArray(personalRaw.badges)
|
||||
? personalRaw.badges.map((badge: any): AchievementBadge => ({
|
||||
id: safeString(badge.id),
|
||||
title: safeString(badge.title),
|
||||
description: safeString(badge.description),
|
||||
earned: Boolean(badge.earned),
|
||||
progress: toNumber(badge.progress),
|
||||
target: toNumber(badge.target, 1),
|
||||
}))
|
||||
? personalRaw.badges.map((badge): AchievementBadge => {
|
||||
const record = badge as Record<string, unknown>;
|
||||
return {
|
||||
id: safeString(record.id),
|
||||
title: safeString(record.title),
|
||||
description: safeString(record.description),
|
||||
earned: Boolean(record.earned),
|
||||
progress: toNumber(record.progress),
|
||||
target: toNumber(record.target, 1),
|
||||
};
|
||||
})
|
||||
: [],
|
||||
}
|
||||
: null;
|
||||
|
||||
const uploadsBoard = Array.isArray(leaderboards.uploads)
|
||||
? leaderboards.uploads.map((row: any): LeaderboardEntry => ({
|
||||
guest: safeString(row.guest),
|
||||
photos: toNumber(row.photos),
|
||||
likes: toNumber(row.likes),
|
||||
}))
|
||||
? leaderboards.uploads.map((row): LeaderboardEntry => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
guest: safeString(record.guest),
|
||||
photos: toNumber(record.photos),
|
||||
likes: toNumber(record.likes),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const likesBoard = Array.isArray(leaderboards.likes)
|
||||
? leaderboards.likes.map((row: any): LeaderboardEntry => ({
|
||||
guest: safeString(row.guest),
|
||||
photos: toNumber(row.photos),
|
||||
likes: toNumber(row.likes),
|
||||
}))
|
||||
? leaderboards.likes.map((row): LeaderboardEntry => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
guest: safeString(record.guest),
|
||||
photos: toNumber(record.photos),
|
||||
likes: toNumber(record.likes),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const topPhotoRaw = highlights.top_photo ?? null;
|
||||
@@ -210,21 +219,27 @@ export async function fetchAchievements(
|
||||
: null;
|
||||
|
||||
const timeline = Array.isArray(highlights.timeline)
|
||||
? highlights.timeline.map((row: any): TimelinePoint => ({
|
||||
date: safeString(row.date),
|
||||
photos: toNumber(row.photos),
|
||||
guests: toNumber(row.guests),
|
||||
}))
|
||||
? highlights.timeline.map((row): TimelinePoint => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
date: safeString(record.date),
|
||||
photos: toNumber(record.photos),
|
||||
guests: toNumber(record.guests),
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
const feed = feedRaw.map((row: any): FeedEntry => ({
|
||||
photoId: toNumber(row.photo_id),
|
||||
guest: safeString(row.guest),
|
||||
task: row.task ?? null,
|
||||
likes: toNumber(row.likes),
|
||||
createdAt: safeString(row.created_at),
|
||||
thumbnail: row.thumbnail ? safeString(row.thumbnail) : null,
|
||||
}));
|
||||
const feed = feedRaw.map((row): FeedEntry => {
|
||||
const record = row as Record<string, unknown>;
|
||||
return {
|
||||
photoId: toNumber(record.photo_id),
|
||||
guest: safeString(record.guest),
|
||||
task: (record as { task?: string }).task ?? null,
|
||||
likes: toNumber(record.likes),
|
||||
createdAt: safeString(record.created_at),
|
||||
thumbnail: record.thumbnail ? safeString(record.thumbnail) : null,
|
||||
};
|
||||
});
|
||||
|
||||
const payload: AchievementsPayload = {
|
||||
summary: {
|
||||
|
||||
@@ -40,8 +40,9 @@ async function handleResponse<T>(response: Response): Promise<T> {
|
||||
const data = await response.json().catch(() => null);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error((data && data.error && data.error.message) || 'Request failed');
|
||||
(error as any).code = data?.error?.code ?? response.status;
|
||||
const errorPayload = data as { error?: { message?: string; code?: unknown } } | null;
|
||||
const error = new Error(errorPayload?.error?.message ?? 'Request failed') as Error & { code?: unknown };
|
||||
error.code = errorPayload?.error?.code ?? response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
@@ -78,4 +79,3 @@ export async function fetchGalleryPhotos(token: string, cursor?: string | null,
|
||||
|
||||
return handleResponse<GalleryPhotosResponse>(response);
|
||||
}
|
||||
|
||||
|
||||
@@ -98,8 +98,8 @@ async function requestJson<T>(url: string): Promise<T> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = new Error('Help request failed');
|
||||
(error as any).status = response.status;
|
||||
const error = new Error('Help request failed') as Error & { status?: number };
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -62,10 +62,12 @@ export async function likePhoto(id: number): Promise<number> {
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: any = null;
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('Like photo: failed to parse error payload', error);
|
||||
}
|
||||
|
||||
if (res.status === 419) {
|
||||
const error: UploadError = new Error('CSRF token mismatch. Please refresh the page and try again.');
|
||||
@@ -75,12 +77,13 @@ export async function likePhoto(id: number): Promise<number> {
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
payload?.error?.message ?? `Like failed: ${res.status}`
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Like failed: ${res.status}`
|
||||
);
|
||||
error.code = payload?.error?.code ?? 'like_failed';
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? 'like_failed';
|
||||
error.status = res.status;
|
||||
if (payload?.error?.meta) {
|
||||
error.meta = payload.error.meta as Record<string, unknown>;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
|
||||
throw error;
|
||||
@@ -114,7 +117,7 @@ export async function uploadPhoto(
|
||||
const url = `/api/v1/events/${encodeURIComponent(eventToken)}/upload`;
|
||||
const headers = getCsrfHeaders();
|
||||
|
||||
const attemptUpload = (attempt: number): Promise<any> =>
|
||||
const attemptUpload = (): Promise<Record<string, unknown>> =>
|
||||
new Promise((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', url, true);
|
||||
@@ -139,7 +142,7 @@ export async function uploadPhoto(
|
||||
|
||||
xhr.onload = () => {
|
||||
const status = xhr.status;
|
||||
const payload = xhr.response ?? null;
|
||||
const payload = (xhr.response ?? null) as Record<string, unknown> | null;
|
||||
|
||||
if (status >= 200 && status < 300) {
|
||||
resolve(payload);
|
||||
@@ -147,12 +150,13 @@ export async function uploadPhoto(
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(
|
||||
payload?.error?.message ?? `Upload failed: ${status}`
|
||||
(payload as { error?: { message?: string } } | null)?.error?.message ?? `Upload failed: ${status}`
|
||||
);
|
||||
error.code = payload?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
|
||||
error.code = (payload as { error?: { code?: string } } | null)?.error?.code ?? (status === 0 ? 'network_error' : 'upload_failed');
|
||||
error.status = status;
|
||||
if (payload?.error?.meta) {
|
||||
error.meta = payload.error.meta as Record<string, unknown>;
|
||||
const meta = (payload as { error?: { meta?: Record<string, unknown> } } | null)?.error?.meta;
|
||||
if (meta) {
|
||||
error.meta = meta;
|
||||
}
|
||||
reject(error);
|
||||
};
|
||||
@@ -174,8 +178,9 @@ export async function uploadPhoto(
|
||||
|
||||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||||
try {
|
||||
const json = await attemptUpload(attempt + 1);
|
||||
return json?.photo_id ?? json?.id ?? json?.data?.id ?? 0;
|
||||
const json = await attemptUpload();
|
||||
const payload = json as { photo_id?: number; id?: number; data?: { id?: number } };
|
||||
return payload.photo_id ?? payload.id ?? payload.data?.id ?? 0;
|
||||
} catch (error) {
|
||||
const err = error as UploadError;
|
||||
|
||||
@@ -213,13 +218,16 @@ export async function createPhotoShareLink(eventToken: string, photoId: number):
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
let payload: any = null;
|
||||
let payload: unknown = null;
|
||||
try {
|
||||
payload = await res.clone().json();
|
||||
} catch {}
|
||||
} catch (error) {
|
||||
console.warn('Share link error payload parse failed', error);
|
||||
}
|
||||
|
||||
const error: UploadError = new Error(payload?.error?.message ?? 'Share link creation failed');
|
||||
error.code = payload?.error?.code ?? 'share_failed';
|
||||
const errorPayload = payload as { error?: { message?: string; code?: string } } | null;
|
||||
const error: UploadError = new Error(errorPayload?.error?.message ?? 'Share link creation failed');
|
||||
error.code = errorPayload?.error?.code ?? 'share_failed';
|
||||
error.status = res.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user