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:
co-authored by
Paul Nothaft
parent
2c5ae6fbb9
commit
486239aeb9
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user