Files
picpeak/frontend/src/pages/GalleryPage.tsx
T
paul ac1cd96ecd
Test and Lint / backend-test (push) Successful in 1m16s
continuous-integration/drone/push Build is failing
Test and Lint / frontend-test (push) Successful in 2m18s
Version and Release / version-bump (push) Successful in 43s
Version and Release / trigger-drone (push) Successful in 4s
fix: resolve frontend API routing issues for Traefik deployment
Major fixes for production deployment with Traefik:

1. API Path Fixes:
   - Remove double /api prefix from all frontend service calls
   - Fix auth.service.ts to use correct paths (/auth/admin/login)
   - Update all services to use single /api prefix from base URL
   - Fix template literal paths in photo services

2. Docker Configuration:
   - Add build args for VITE_API_URL in docker-compose.prod.yml
   - Create Dockerfile.prod with proper API URL configuration
   - Ensure frontend is built with correct API base path

3. Documentation:
   - Add comprehensive TRAEFIK_DEPLOYMENT.md guide
   - Document proper Traefik labels and routing configuration
   - Include troubleshooting steps for common issues
   - Explain network configuration and SSL handling

This resolves:
- 502 Bad Gateway errors
- Double /api/api paths in requests
- Frontend unable to communicate with backend
- Login functionality not working

The frontend now correctly calls the backend API through Traefik's
routing, with all requests going to /api/* being forwarded to the
backend service on port 3000.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-13 23:18:00 +02:00

383 lines
14 KiB
TypeScript

import React, { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { Calendar, AlertCircle, Clock } from 'lucide-react';
import { differenceInDays, parseISO } from 'date-fns';
import { useTranslation } from 'react-i18next';
import { useLocalizedDate } from '../hooks/useLocalizedDate';
import { useQuery } from '@tanstack/react-query';
import { Card, CardContent, Input, Button, Loading, ReCaptcha } from '../components/common';
import { useGalleryAuth, useTheme } from '../contexts';
import { useGalleryInfo } from '../hooks/useGallery';
import { GalleryView } from '../components/gallery';
import { analyticsService } from '../services/analytics.service';
import { api } from '../config/api';
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
import { buildResourceUrl } from '../utils/url';
export const GalleryPage: React.FC = () => {
const { slug, token } = useParams<{ slug: string; token?: string }>();
const { isAuthenticated, login, event } = useGalleryAuth();
const { t, i18n } = useTranslation();
const { format } = useLocalizedDate();
const { setTheme } = useTheme();
const [password, setPassword] = useState('');
const [isLoggingIn, setIsLoggingIn] = useState(false);
const [loginError, setLoginError] = useState<string | null>(null);
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
// Fetch gallery info (public data)
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
// Fetch branding settings
const { data: settingsData } = useQuery({
queryKey: ['gallery-settings'],
queryFn: async () => {
const response = await api.get('/public/settings');
return response.data;
},
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
});
// Set language from admin settings when on login page
React.useEffect(() => {
if (!isAuthenticated && settingsData?.default_language) {
i18n.changeLanguage(settingsData.default_language);
}
}, [settingsData, isAuthenticated, i18n]);
// Apply theme for login page
React.useEffect(() => {
if (!isAuthenticated && galleryInfo && settingsData) {
let themeToApply = null;
if (galleryInfo.color_theme) {
try {
// Check if it's a valid JSON string
if (galleryInfo.color_theme.startsWith('{')) {
themeToApply = JSON.parse(galleryInfo.color_theme);
} else {
// Handle legacy theme names - check if it's a preset
const preset = GALLERY_THEME_PRESETS[galleryInfo.color_theme];
if (preset) {
themeToApply = preset.config;
} else {
// Unknown theme name, fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
}
} catch (e) {
console.error('Failed to parse event theme:', e);
// Fall back to global theme
if (settingsData.theme_config) {
themeToApply = settingsData.theme_config;
}
}
} else if (settingsData.theme_config) {
// No event theme, use global theme
themeToApply = settingsData.theme_config;
}
// Apply theme
if (themeToApply) {
setTheme(themeToApply);
}
}
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
// Calculate days until expiration
const daysUntilExpiration = galleryInfo
? differenceInDays(parseISO(galleryInfo.expires_at), new Date())
: null;
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault();
e.stopPropagation(); // Prevent any bubbling
if (!password.trim()) {
setLoginError(t('auth.pleaseEnterPassword'));
return;
}
try {
setIsLoggingIn(true);
setLoginError(null);
await login(slug!, password, recaptchaToken);
// Track successful password entry
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: true
});
} catch (error: any) {
console.error('Login error:', error);
const errorMessage = error.response?.data?.error || 'Invalid password';
const statusCode = error.response?.status;
// Map backend error messages to user-friendly translations
if (statusCode === 401 || errorMessage.toLowerCase().includes('invalid password')) {
setLoginError(t('auth.wrongPassword'));
} else if (statusCode === 429 || errorMessage.toLowerCase().includes('too many')) {
setLoginError(t('auth.tooManyAttempts'));
} else if (statusCode === 404) {
setLoginError(t('errors.galleryNotFound'));
} else {
setLoginError(t('auth.invalidPassword'));
}
// Track failed password entry
analyticsService.trackGalleryEvent('password_entry', {
gallery: slug,
success: false,
statusCode
});
// Keep the password field to allow retry
// Do not clear the password
} finally {
setIsLoggingIn(false);
}
};
// Show loading state
if (isLoadingInfo) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center">
<Loading size="lg" text={t('gallery.loading')} />
</div>
</div>
);
}
// Show error state
if (infoError) {
// Check if it's an archived gallery error
const errorMessage = (infoError as any)?.response?.data?.error;
const isArchived = errorMessage?.includes('archived');
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">
{t(isArchived ? 'errors.galleryArchived' : 'errors.galleryNotFound')}
</h2>
<p className="text-neutral-600">
{t(isArchived ? 'errors.galleryArchivedMessage' : 'errors.galleryNotFoundMessage')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
}
// Show expired state
if (galleryInfo?.is_expired) {
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex flex-col">
{/* Logo at top */}
{settingsData?.branding_logo_url && (
<div className="p-8 text-center">
<img
src={buildResourceUrl(settingsData.branding_logo_url)}
alt={settingsData.branding_company_name || 'Company Logo'}
className="h-16 w-auto object-contain mx-auto"
/>
</div>
)}
<div className="flex-1 flex items-center justify-center">
<Card className="max-w-md w-full mx-4">
<CardContent className="text-center py-12">
<Clock className="w-16 h-16 text-amber-500 mx-auto mb-4" />
<h2 className="text-xl font-semibold mb-2">{t('gallery.expired')}</h2>
<p className="text-neutral-600 mb-4">
{t('gallery.expiredOn', { date: format(parseISO(galleryInfo.expires_at), 'PP') })}
</p>
<p className="text-sm text-neutral-500">
{t('gallery.contactOrganizer')}
</p>
</CardContent>
</Card>
</div>
{/* Legal Links */}
<div className="p-8 text-center">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
);
}
// Show gallery view if authenticated
if (isAuthenticated && event) {
return <GalleryView slug={slug!} event={event} />;
}
// Show login form
return (
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
<div className="min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-lg">
{/* Logo/Header */}
<div className="text-center mb-4 sm:mb-6">
<img
src={settingsData?.branding_logo_url ?
buildResourceUrl(settingsData.branding_logo_url) :
'/picpeak-logo-transparent.png'
}
alt={settingsData?.branding_company_name || 'PicPeak'}
className="h-12 sm:h-16 lg:h-20 w-auto object-contain mx-auto mb-3 sm:mb-4"
/>
<h1 className="text-xl sm:text-2xl lg:text-3xl font-bold mb-2 px-2" style={{ color: 'var(--color-text, #171717)' }}>
{galleryInfo?.event_name}
</h1>
<div className="flex items-center justify-center text-xs sm:text-sm" style={{ color: 'var(--color-text, #171717)', opacity: 0.7 }}>
<Calendar className="w-3 h-3 sm:w-4 sm:h-4 mr-1" />
<span className="truncate">{format(parseISO(galleryInfo!.event_date), 'PP')}</span>
</div>
</div>
{/* Expiration Warning */}
{daysUntilExpiration !== null && daysUntilExpiration <= 7 && (
<div className="mb-4 p-3 bg-amber-50 border border-amber-200 rounded-lg">
<div className="flex items-start">
<AlertCircle className="w-4 h-4 sm:w-5 sm:h-5 text-amber-600 mt-0.5 mr-2 flex-shrink-0" />
<div>
<p className="text-xs sm:text-sm font-medium text-amber-800">
{t('gallery.expiresIn', { count: daysUntilExpiration })}
</p>
<p className="text-xs text-amber-700 mt-1">
{t('gallery.downloadBefore')}
</p>
</div>
</div>
</div>
)}
{/* Login Card */}
<Card>
<CardContent className="p-4 sm:p-6">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold mb-4">{t('auth.enterPassword')}</h2>
<form onSubmit={handleLogin} className="space-y-4">
<Input
type="password"
label={t('auth.password')}
placeholder={t('auth.passwordPlaceholder')}
value={password}
onChange={(e) => setPassword(e.target.value)}
error={loginError || undefined}
autoFocus
className="text-sm sm:text-base"
/>
<ReCaptcha
onChange={setRecaptchaToken}
onExpired={() => setRecaptchaToken(null)}
/>
<Button
type="submit"
variant="primary"
size="lg"
className="w-full text-sm sm:text-base"
isLoading={isLoggingIn}
disabled={isLoggingIn}
>
{t('gallery.viewGallery')}
</Button>
</form>
<p className="text-xs text-neutral-500 text-center mt-4 sm:mt-6">
{t('auth.passwordHint')}
</p>
</CardContent>
</Card>
{/* Legal Links */}
<div className="text-center mt-4 sm:mt-6">
<div className="flex items-center justify-center gap-4">
<Link
to="/impressum"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.impressum')}
</Link>
<span className="text-xs text-neutral-400">|</span>
<Link
to="/datenschutz"
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
>
{t('legal.datenschutz')}
</Link>
</div>
<p className="text-xs mt-2 text-neutral-500">
Powered by <span className="font-semibold">PicPeak</span>
</p>
</div>
</div>
</div>
</div>
);
};