- Added fonts:sync-google command (uses GOOGLE_FONTS_API_KEY, generates /public/fonts/google files, manifest, CSS, cache flush) and
exposed manifest via new GET /api/v1/tenant/fonts endpoint with fallbacks for existing local fonts.
- Imported generated fonts CSS, added API client + font loader hook, and wired branding page font fields to searchable selects (with
custom override) that auto-load selected fonts.
- Invites layout editor now offers font selection per element with runtime font loading for previews/export alignment.
- New tests cover font sync command and font manifest API.
Tests run: php artisan test --filter=Fonts --testsuite=Feature.
Note: repository already has other modified files (e.g., EventPublicController, SettingsStoreRequest, guest components, etc.); left
untouched. Run php artisan fonts:sync-google after setting the API key to populate /public/fonts/google.
370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
// @ts-nocheck
|
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
import { useParams } from 'react-router-dom';
|
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Dialog, DialogContent, DialogFooter } from '@/components/ui/dialog';
|
|
import { fetchGalleryMeta, fetchGalleryPhotos, type GalleryMetaResponse, type GalleryPhotoResource } from '../services/galleryApi';
|
|
import { useTranslation } from '../i18n/useTranslation';
|
|
import { DEFAULT_LOCALE, isLocaleCode } from '../i18n/messages';
|
|
import { AlertTriangle, Download, Loader2, X } from 'lucide-react';
|
|
import { getContrastingTextColor } from '../lib/color';
|
|
|
|
interface GalleryState {
|
|
meta: GalleryMetaResponse | null;
|
|
photos: GalleryPhotoResource[];
|
|
cursor: string | null;
|
|
loading: boolean;
|
|
loadingMore: boolean;
|
|
error: string | null;
|
|
expired: boolean;
|
|
}
|
|
|
|
const INITIAL_STATE: GalleryState = {
|
|
meta: null,
|
|
photos: [],
|
|
cursor: null,
|
|
loading: true,
|
|
loadingMore: false,
|
|
error: null,
|
|
expired: false,
|
|
};
|
|
|
|
const GALLERY_PAGE_SIZE = 30;
|
|
|
|
export default function PublicGalleryPage(): React.ReactElement | null {
|
|
const { token } = useParams<{ token: string }>();
|
|
const { t } = useTranslation();
|
|
const [state, setState] = useState<GalleryState>(INITIAL_STATE);
|
|
const [lightboxOpen, setLightboxOpen] = useState(false);
|
|
const [selectedPhoto, setSelectedPhoto] = useState<GalleryPhotoResource | null>(null);
|
|
const sentinelRef = useRef<HTMLDivElement | null>(null);
|
|
|
|
const localeStorageKey = token ? `guestGalleryLocale_${token}` : 'guestGalleryLocale';
|
|
const storedLocale = typeof window !== 'undefined' && token ? localStorage.getItem(localeStorageKey) : null;
|
|
const effectiveLocale: LocaleCode = storedLocale && isLocaleCode(storedLocale) ? storedLocale : DEFAULT_LOCALE;
|
|
|
|
const applyMeta = useCallback((meta: GalleryMetaResponse) => {
|
|
if (typeof window !== 'undefined' && token) {
|
|
localStorage.setItem(localeStorageKey, effectiveLocale);
|
|
}
|
|
setState((prev) => ({
|
|
...prev,
|
|
meta,
|
|
}));
|
|
}, [effectiveLocale, localeStorageKey, token]);
|
|
|
|
const loadInitial = useCallback(async () => {
|
|
if (!token) {
|
|
return;
|
|
}
|
|
|
|
setState((prev) => ({ ...prev, loading: true, error: null, expired: false, photos: [], cursor: null }));
|
|
|
|
try {
|
|
const meta = await fetchGalleryMeta(token, effectiveLocale);
|
|
applyMeta(meta);
|
|
|
|
const photoResponse = await fetchGalleryPhotos(token, null, GALLERY_PAGE_SIZE);
|
|
|
|
setState((prev) => ({
|
|
...prev,
|
|
loading: false,
|
|
photos: photoResponse.data,
|
|
cursor: photoResponse.next_cursor,
|
|
}));
|
|
} catch (error) {
|
|
const err = error as Error & { code?: string | number };
|
|
if (err.code === 'gallery_expired' || err.code === 410) {
|
|
setState((prev) => ({ ...prev, loading: false, expired: true }));
|
|
} else {
|
|
setState((prev) => ({
|
|
...prev,
|
|
loading: false,
|
|
error: err.message || t('galleryPublic.loadError'),
|
|
}));
|
|
}
|
|
}
|
|
}, [token, applyMeta, effectiveLocale, t]);
|
|
|
|
useEffect(() => {
|
|
loadInitial();
|
|
}, [loadInitial]);
|
|
|
|
useEffect(() => {
|
|
const mode = state.meta?.branding.mode;
|
|
if (!mode || typeof document === 'undefined') {
|
|
return;
|
|
}
|
|
|
|
const wasDark = document.documentElement.classList.contains('dark');
|
|
|
|
if (mode === 'dark') {
|
|
document.documentElement.classList.add('dark');
|
|
} else if (mode === 'light') {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
|
|
return () => {
|
|
if (wasDark) {
|
|
document.documentElement.classList.add('dark');
|
|
} else {
|
|
document.documentElement.classList.remove('dark');
|
|
}
|
|
};
|
|
}, [state.meta?.branding.mode]);
|
|
|
|
const loadMore = useCallback(async () => {
|
|
if (!token || !state.cursor || state.loadingMore) {
|
|
return;
|
|
}
|
|
|
|
setState((prev) => ({ ...prev, loadingMore: true }));
|
|
|
|
try {
|
|
const response = await fetchGalleryPhotos(token, state.cursor, GALLERY_PAGE_SIZE);
|
|
setState((prev) => ({
|
|
...prev,
|
|
photos: [...prev.photos, ...response.data],
|
|
cursor: response.next_cursor,
|
|
loadingMore: false,
|
|
}));
|
|
} catch (error) {
|
|
const err = error as Error;
|
|
setState((prev) => ({
|
|
...prev,
|
|
loadingMore: false,
|
|
error: err.message || t('galleryPublic.loadError'),
|
|
}));
|
|
}
|
|
}, [state.cursor, state.loadingMore, token, t]);
|
|
|
|
useEffect(() => {
|
|
if (!state.cursor || !sentinelRef.current) {
|
|
return;
|
|
}
|
|
|
|
const observer = new IntersectionObserver((entries) => {
|
|
const firstEntry = entries[0];
|
|
if (firstEntry?.isIntersecting) {
|
|
loadMore();
|
|
}
|
|
}, {
|
|
rootMargin: '400px',
|
|
threshold: 0,
|
|
});
|
|
|
|
observer.observe(sentinelRef.current);
|
|
|
|
return () => observer.disconnect();
|
|
}, [state.cursor, loadMore]);
|
|
|
|
const themeStyles = useMemo(() => {
|
|
if (!state.meta) {
|
|
return {} as React.CSSProperties;
|
|
}
|
|
|
|
const palette = state.meta.branding.palette ?? {};
|
|
const primary = palette.primary ?? state.meta.branding.primary_color;
|
|
const secondary = palette.secondary ?? state.meta.branding.secondary_color;
|
|
const background = palette.background ?? state.meta.branding.background_color;
|
|
const surface = palette.surface ?? state.meta.branding.surface_color ?? background;
|
|
|
|
return {
|
|
'--gallery-primary': primary,
|
|
'--gallery-secondary': secondary,
|
|
'--gallery-background': background,
|
|
'--gallery-surface': surface,
|
|
} as React.CSSProperties & Record<string, string>;
|
|
}, [state.meta]);
|
|
|
|
const headerStyle = useMemo(() => {
|
|
if (!state.meta) {
|
|
return {};
|
|
}
|
|
const palette = state.meta.branding.palette ?? {};
|
|
const primary = palette.primary ?? state.meta.branding.primary_color;
|
|
const secondary = palette.secondary ?? state.meta.branding.secondary_color ?? primary;
|
|
const textColor = getContrastingTextColor(primary ?? '#f43f5e', '#0f172a', '#ffffff');
|
|
return {
|
|
background: `linear-gradient(135deg, ${primary}, ${secondary})`,
|
|
color: textColor,
|
|
} satisfies React.CSSProperties;
|
|
}, [state.meta]);
|
|
|
|
const accentStyle = useMemo(() => {
|
|
if (!state.meta) {
|
|
return {};
|
|
}
|
|
return {
|
|
color: (state.meta.branding.palette?.primary ?? state.meta.branding.primary_color),
|
|
} satisfies React.CSSProperties;
|
|
}, [state.meta]);
|
|
|
|
const backgroundStyle = useMemo(() => {
|
|
if (!state.meta) {
|
|
return {};
|
|
}
|
|
return {
|
|
backgroundColor: state.meta.branding.palette?.background ?? state.meta.branding.background_color,
|
|
} satisfies React.CSSProperties;
|
|
}, [state.meta]);
|
|
|
|
const openLightbox = useCallback((photo: GalleryPhotoResource) => {
|
|
setSelectedPhoto(photo);
|
|
setLightboxOpen(true);
|
|
}, []);
|
|
|
|
const closeLightbox = useCallback(() => {
|
|
setLightboxOpen(false);
|
|
setSelectedPhoto(null);
|
|
}, []);
|
|
|
|
if (!token) {
|
|
return null;
|
|
}
|
|
|
|
if (state.expired) {
|
|
return (
|
|
<div className="flex min-h-screen flex-col items-center justify-center gap-6 px-6 text-center" style={backgroundStyle}>
|
|
<AlertTriangle className="h-12 w-12 text-destructive" aria-hidden />
|
|
<div className="space-y-2">
|
|
<h1 className="text-2xl font-semibold text-foreground">{t('galleryPublic.expiredTitle')}</h1>
|
|
<p className="text-sm text-muted-foreground">{t('galleryPublic.expiredDescription')}</p>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen" style={{ ...themeStyles, ...backgroundStyle }}>
|
|
<header className="sticky top-0 z-20 shadow-sm" style={headerStyle}>
|
|
<div className="mx-auto flex w-full max-w-5xl items-center justify-between px-5 py-4">
|
|
<div className="text-left">
|
|
<p className="text-xs uppercase tracking-widest opacity-80">Fotospiel</p>
|
|
<h1 className="text-xl font-semibold leading-tight">
|
|
{state.meta?.event.name || t('galleryPublic.title')}
|
|
</h1>
|
|
{state.meta?.event.gallery_expires_at && (
|
|
<p className="text-[11px] opacity-80">
|
|
{new Date(state.meta.event.gallery_expires_at).toLocaleDateString()}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="mx-auto flex w-full max-w-5xl flex-col gap-6 px-5 py-6">
|
|
{state.meta?.event.description && (
|
|
<div className="rounded-xl bg-white/70 p-4 shadow-sm backdrop-blur">
|
|
<p className="text-sm text-muted-foreground">{state.meta.event.description}</p>
|
|
</div>
|
|
)}
|
|
|
|
{state.error && (
|
|
<Alert variant="destructive">
|
|
<AlertTitle>{t('galleryPublic.loadError')}</AlertTitle>
|
|
<AlertDescription>
|
|
{state.error}
|
|
</AlertDescription>
|
|
</Alert>
|
|
)}
|
|
|
|
{state.loading && (
|
|
<div className="flex flex-col items-center justify-center gap-3 py-16 text-center">
|
|
<Loader2 className="h-10 w-10 animate-spin" aria-hidden />
|
|
<p className="text-sm text-muted-foreground">{t('galleryPublic.loading')}</p>
|
|
</div>
|
|
)}
|
|
|
|
{!state.loading && state.photos.length === 0 && !state.error && (
|
|
<div className="rounded-xl border border-dashed border-muted/60 p-10 text-center">
|
|
<h2 className="text-lg font-semibold text-foreground">{t('galleryPublic.emptyTitle')}</h2>
|
|
<p className="mt-2 text-sm text-muted-foreground">{t('galleryPublic.emptyDescription')}</p>
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
|
|
{state.photos.map((photo) => (
|
|
<button
|
|
key={photo.id}
|
|
type="button"
|
|
className="group relative overflow-hidden rounded-xl bg-white shadow-sm transition-transform hover:-translate-y-1 hover:shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
|
|
onClick={() => openLightbox(photo)}
|
|
style={accentStyle}
|
|
>
|
|
<img
|
|
src={photo.thumbnail_url ?? photo.full_url ?? ''}
|
|
alt={photo.guest_name ? `${photo.guest_name}s Foto` : `Foto ${photo.id}`}
|
|
loading="lazy"
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<div ref={sentinelRef} className="h-1 w-full" aria-hidden />
|
|
|
|
{state.loadingMore && (
|
|
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" aria-hidden />
|
|
{t('galleryPublic.loadingMore')}
|
|
</div>
|
|
)}
|
|
|
|
{!state.loading && state.cursor && (
|
|
<div className="flex justify-center">
|
|
<Button variant="outline" onClick={loadMore} disabled={state.loadingMore}>
|
|
{state.loadingMore ? <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden /> : null}
|
|
{t('galleryPublic.loadMore')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
<Dialog open={lightboxOpen} onOpenChange={(open) => (open ? setLightboxOpen(true) : closeLightbox())}>
|
|
<DialogContent className="max-w-3xl gap-6">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-sm font-medium text-muted-foreground">
|
|
{selectedPhoto?.guest_name || t('galleryPublic.lightboxGuestFallback')}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{selectedPhoto?.created_at ? new Date(selectedPhoto.created_at).toLocaleString() : ''}
|
|
</p>
|
|
</div>
|
|
<Button variant="ghost" size="icon" onClick={closeLightbox}>
|
|
<X className="h-4 w-4" aria-hidden />
|
|
<span className="sr-only">{t('common.actions.close')}</span>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="relative overflow-hidden rounded-2xl bg-black/5">
|
|
{selectedPhoto?.full_url && (
|
|
<img
|
|
src={selectedPhoto.full_url}
|
|
alt={selectedPhoto?.guest_name || `Foto ${selectedPhoto?.id}`}
|
|
className="w-full object-contain"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
<DialogFooter className="flex flex-col items-start gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="text-xs text-muted-foreground">
|
|
{selectedPhoto?.likes_count ? `${selectedPhoto.likes_count} ❤` : ''}
|
|
</div>
|
|
{selectedPhoto?.download_url && (
|
|
<Button asChild className="gap-2" style={accentStyle}>
|
|
<a href={selectedPhoto.download_url} target="_blank" rel="noopener noreferrer">
|
|
<Download className="h-4 w-4" aria-hidden />
|
|
{t('galleryPublic.download')}
|
|
</a>
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|