fix: address beta feedback - gallery layout fixes, Russian locale, email logo (#249)

- Add Russian (Русский) to admin settings language dropdown
- Fix Premium layout hero using thumbnail instead of hero_url
- Hide "Uncategorized" section header in Story layout for uncategorized photos
- Add PhotoLightbox to Story layout so photo clicks open full-screen view
- Use full-res images in StoryPhotoCard instead of thumbnails
- Defer public gallery auto-login text until settings/locale are loaded
- Add validation and debug logging for email logo URL construction

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-03-17 17:16:50 +01:00
committed by GitHub
co-authored by Paul Nothaft
parent 2c5ae6fbb9
commit 486239aeb9
7 changed files with 73 additions and 37 deletions
+5 -3
View File
@@ -153,9 +153,11 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
const hoverColor = darkenColor(primaryColor, 0.15); const hoverColor = darkenColor(primaryColor, 0.15);
// If no custom logo, use default PicPeak logo // Build full logo URL - ensure logoUrl is a valid non-empty string
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000'; const frontendUrl = (process.env.FRONTEND_URL || 'http://localhost:3000').replace(/\/+$/, '');
const logoFullUrl = `${frontendUrl}${logoUrl || '/picpeak-logo-transparent.png'}`; const logoPath = (typeof logoUrl === 'string' && logoUrl.trim()) ? logoUrl : '/picpeak-logo-transparent.png';
const logoFullUrl = `${frontendUrl}${logoPath.startsWith('/') ? '' : '/'}${logoPath}`;
logger.debug('Email logo URL:', { frontendUrl, logoPath, logoFullUrl });
return ` return `
<!DOCTYPE html> <!DOCTYPE html>
@@ -343,7 +343,7 @@ export const GalleryPremiumLayout: React.FC<GalleryPremiumLayoutProps> = ({
<div <div
className="gallery-premium-hero-bg" className="gallery-premium-hero-bg"
style={{ style={{
backgroundImage: heroPhoto ? `url(${heroPhoto.thumbnail_url || heroPhoto.url})` : undefined backgroundImage: heroPhoto ? `url(${heroPhoto.hero_url || heroPhoto.url})` : undefined
}} }}
/> />
<div className="gallery-premium-hero-overlay" /> <div className="gallery-premium-hero-overlay" />
@@ -17,6 +17,7 @@ import {
StoryFeedbackSheet, StoryFeedbackSheet,
StoryScrollToTop StoryScrollToTop
} from './story'; } from './story';
import { PhotoLightbox } from '../PhotoLightbox';
import './GalleryStoryLayout.css'; import './GalleryStoryLayout.css';
@@ -71,6 +72,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [favorites, setFavorites] = useState<Set<number>>(new Set()); const [favorites, setFavorites] = useState<Set<number>>(new Set());
const [selectedPhotoForFeedback, setSelectedPhotoForFeedback] = useState<Photo | null>(null); const [selectedPhotoForFeedback, setSelectedPhotoForFeedback] = useState<Photo | null>(null);
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const [comments, setComments] = useState<Record<number, Array<{ id: string; author: string; text: string; date: string }>>>({}); const [comments, setComments] = useState<Record<number, Array<{ id: string; author: string; text: string; date: string }>>>({});
const [ratings, setRatings] = useState<Record<number, number>>({}); const [ratings, setRatings] = useState<Record<number, number>>({});
const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null); const [savedIdentity, setSavedIdentity] = useState<{ name: string; email: string } | null>(null);
@@ -112,7 +114,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
// Group by category // Group by category
filteredPhotos.forEach(photo => { filteredPhotos.forEach(photo => {
const categoryName = photo.category_name || t('gallery.uncategorized', 'Gallery'); const categoryName = photo.category_name || '';
if (!photosByCategory[categoryName]) { if (!photosByCategory[categoryName]) {
photosByCategory[categoryName] = []; photosByCategory[categoryName] = [];
} }
@@ -163,6 +165,11 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
setSelectedPhotoForFeedback(photo); setSelectedPhotoForFeedback(photo);
}, []); }, []);
const handleOpenLightbox = useCallback((photo: Photo) => {
const index = photos.findIndex(p => p.id === photo.id);
setLightboxIndex(index >= 0 ? index : 0);
}, [photos]);
const handleCloseFeedback = useCallback(() => { const handleCloseFeedback = useCallback(() => {
setSelectedPhotoForFeedback(null); setSelectedPhotoForFeedback(null);
}, []); }, []);
@@ -312,7 +319,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
photos={scene.photos} photos={scene.photos}
favorites={favorites} favorites={favorites}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onPhotoClick={handleOpenFeedback} onPhotoClick={handleOpenLightbox}
slug={slug} slug={slug}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
protectionLevel={protectionLevel} protectionLevel={protectionLevel}
@@ -328,7 +335,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
index={index} index={index}
isFavorite={favorites.has(photo.id)} isFavorite={favorites.has(photo.id)}
onToggleFavorite={handleToggleFavorite} onToggleFavorite={handleToggleFavorite}
onClick={() => handleOpenFeedback(photo)} onClick={() => handleOpenLightbox(photo)}
slug={slug} slug={slug}
galleryId={`gallery-${scene.id}`} galleryId={`gallery-${scene.id}`}
allowDownloads={allowDownloads} allowDownloads={allowDownloads}
@@ -359,6 +366,22 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
)} )}
</footer> </footer>
{/* Lightbox */}
{lightboxIndex !== null && (
<PhotoLightbox
photos={photos}
initialIndex={lightboxIndex}
onClose={() => setLightboxIndex(null)}
slug={slug}
feedbackEnabled={feedbackEnabled}
allowDownloads={allowDownloads}
protectionLevel={protectionLevel}
useEnhancedProtection={useEnhancedProtection}
useCanvasRendering={useCanvasRendering}
onFeedbackChange={onFeedbackChange}
/>
)}
{/* Feedback Sheet */} {/* Feedback Sheet */}
{feedbackEnabled && ( {feedbackEnabled && (
<StoryFeedbackSheet <StoryFeedbackSheet
@@ -60,7 +60,7 @@ export const StoryPhotoCard: React.FC<StoryPhotoCardProps> = ({
className="block w-full h-full" className="block w-full h-full"
> >
<AuthenticatedImage <AuthenticatedImage
src={photo.thumbnail_url || photo.url} src={photo.url}
alt={photo.filename} alt={photo.filename}
onLoad={() => setIsLoaded(true)} onLoad={() => setIsLoaded(true)}
className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${ className={`w-full h-full object-cover transition-all duration-700 ease-out will-change-transform ${
@@ -18,27 +18,29 @@ export const StoryScene: React.FC<StorySceneProps> = ({
}) => { }) => {
return ( return (
<section className={`story-scene ${fullWidth ? 'full-width' : ''} ${className}`}> <section className={`story-scene ${fullWidth ? 'full-width' : ''} ${className}`}>
<div className="story-scene-header"> {title && (
<motion.h2 <div className="story-scene-header">
initial={{ opacity: 0, x: -20 }} <motion.h2
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
className="story-scene-title"
>
{title}
</motion.h2>
{subtitle && (
<motion.p
initial={{ opacity: 0, x: -20 }} initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }} whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }} viewport={{ once: true }}
transition={{ delay: 0.1 }} className="story-scene-title"
className="story-scene-subtitle"
> >
{subtitle} {title}
</motion.p> </motion.h2>
)} {subtitle && (
</div> <motion.p
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
viewport={{ once: true }}
transition={{ delay: 0.1 }}
className="story-scene-subtitle"
>
{subtitle}
</motion.p>
)}
</div>
)}
{children} {children}
</section> </section>
); );
@@ -247,6 +247,7 @@ export const GeneralTab: React.FC<GeneralTabProps> = ({
<option value="en">English</option> <option value="en">English</option>
<option value="de">Deutsch</option> <option value="de">Deutsch</option>
<option value="pt">Português (Brasil)</option> <option value="pt">Português (Brasil)</option>
<option value="ru">Русский</option>
</select> </select>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1"> <p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.general.defaultLanguageHelp')} {t('settings.general.defaultLanguageHelp')}
+20 -12
View File
@@ -106,7 +106,7 @@ export const GalleryPage: React.FC = () => {
}, [resolvedSlug]); }, [resolvedSlug]);
// Fetch branding settings // Fetch branding settings
const { data: settingsData } = useQuery({ const { data: settingsData, isLoading: isLoadingSettings } = useQuery({
queryKey: ['gallery-settings'], queryKey: ['gallery-settings'],
queryFn: async () => { queryFn: async () => {
const response = await api.get('/public/settings'); const response = await api.get('/public/settings');
@@ -177,7 +177,7 @@ export const GalleryPage: React.FC = () => {
return; return;
} }
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) { if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted && !isLoadingSettings) {
setAutoLoginAttempted(true); setAutoLoginAttempted(true);
setIsLoggingIn(true); setIsLoggingIn(true);
login(resolvedSlug, '') login(resolvedSlug, '')
@@ -194,7 +194,7 @@ export const GalleryPage: React.FC = () => {
setIsLoggingIn(false); setIsLoggingIn(false);
}); });
} }
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]); }, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier, isLoadingSettings]);
// Calculate days until expiration (null if no expiration set) // Calculate days until expiration (null if no expiration set)
const daysUntilExpiration = galleryInfo?.expires_at const daysUntilExpiration = galleryInfo?.expires_at
@@ -526,15 +526,23 @@ export const GalleryPage: React.FC = () => {
</> </>
) : ( ) : (
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<h2 className="text-base sm:text-lg lg:text-xl font-semibold"> {isLoadingSettings ? (
{t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')} <div className="flex justify-center py-4">
</h2> <Loading size="sm" />
<p className="text-sm text-neutral-600"> </div>
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')} ) : (
</p> <>
<div className="flex justify-center py-4"> <h2 className="text-base sm:text-lg lg:text-xl font-semibold">
<Loading size="sm" text={t('gallery.loading')} /> {t('gallery.publicGalleryTitle', 'This gallery is publicly accessible')}
</div> </h2>
<p className="text-sm text-neutral-600">
{t('gallery.publicGallerySubtitle', 'Loading the photos now...')}
</p>
<div className="flex justify-center py-4">
<Loading size="sm" text={t('gallery.loading')} />
</div>
</>
)}
{loginError && ( {loginError && (
<p className="text-xs text-red-600">{loginError}</p> <p className="text-xs text-red-600">{loginError}</p>
)} )}