feat: add per-event custom logo upload with bug fixes
Add event-level custom logo upload/delete endpoints and UI, allowing per-event logos to override the global branding logo in gallery views. Also fixes several bugs discovered during testing: - fix: category_id 'individual' parsed as NaN causing photo upload failures - fix: gallery auth race condition where photos query fired before token stored - fix: gallery-photos query not invalidated after favorite/like mutations - fix: e2e test race conditions with View Gallery button detachment
This commit is contained in:
@@ -682,7 +682,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||
showSelectionControls={!showSidebar}
|
||||
eventName={event.event_name}
|
||||
eventLogo={brandingSettings?.logo_url}
|
||||
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
|
||||
eventDate={event.event_date}
|
||||
expiresAt={event.expires_at}
|
||||
allowDownloads={allowDownloads}
|
||||
|
||||
@@ -49,6 +49,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
|
||||
@@ -49,6 +49,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
||||
},
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
// Revert optimistic update
|
||||
|
||||
@@ -218,16 +218,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||
if (response?.event) {
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
// Store token and slug BEFORE setting authenticated state to avoid
|
||||
// race condition where photo queries fire before token is available
|
||||
if (response.token) {
|
||||
storeGalleryToken(currentSlug, response.token);
|
||||
}
|
||||
setActiveGallerySlug(currentSlug);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -263,17 +265,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
setIsAuthenticated(true);
|
||||
// Store token and slug BEFORE setting authenticated state to avoid
|
||||
// race condition where photo queries fire before token is available
|
||||
if (response.token) {
|
||||
storeGalleryToken(slug, response.token);
|
||||
}
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
setIsAuthenticated(true);
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error || 'Invalid password');
|
||||
throw err;
|
||||
|
||||
@@ -845,6 +845,15 @@
|
||||
"heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)",
|
||||
"heroLogoPositionBottom": "Unten (unter dem Datum)",
|
||||
"heroLogoInfo": "Diese Einstellungen gelten für das Hero-Layout der Galerie. Sie können das Logo ausblenden oder Größe und Position anpassen.",
|
||||
"eventCustomLogo": "Individuelles Event-Logo",
|
||||
"eventCustomLogoDescription": "Laden Sie ein individuelles Logo für dieses Event hoch. Dies überschreibt das globale Branding-Logo nur für diese Galerie.",
|
||||
"uploadEventLogo": "Logo hochladen",
|
||||
"replaceLogo": "Ersetzen",
|
||||
"removeLogo": "Entfernen",
|
||||
"eventLogoUploaded": "Event-Logo erfolgreich hochgeladen",
|
||||
"eventLogoUploadFailed": "Event-Logo konnte nicht hochgeladen werden",
|
||||
"eventLogoRemoved": "Event-Logo erfolgreich entfernt",
|
||||
"eventLogoRemoveFailed": "Event-Logo konnte nicht entfernt werden",
|
||||
"heroLogoVisibleLabel": "Logo sichtbar",
|
||||
"heroLogoSizeLabel": "Größe",
|
||||
"heroLogoPositionLabel": "Position",
|
||||
|
||||
@@ -471,6 +471,15 @@
|
||||
"heroLogoPositionCenter": "Center (between title and dates)",
|
||||
"heroLogoPositionBottom": "Bottom (below dates)",
|
||||
"heroLogoInfo": "These settings apply when the gallery uses the Hero layout. You can hide the logo or customize its size and position.",
|
||||
"eventCustomLogo": "Custom Event Logo",
|
||||
"eventCustomLogoDescription": "Upload a custom logo for this event. This overrides the global branding logo for this gallery only.",
|
||||
"uploadEventLogo": "Upload Logo",
|
||||
"replaceLogo": "Replace",
|
||||
"removeLogo": "Remove",
|
||||
"eventLogoUploaded": "Event logo uploaded successfully",
|
||||
"eventLogoUploadFailed": "Failed to upload event logo",
|
||||
"eventLogoRemoved": "Event logo removed successfully",
|
||||
"eventLogoRemoveFailed": "Failed to remove event logo",
|
||||
"heroLogoVisibleLabel": "Logo visible",
|
||||
"heroLogoSizeLabel": "Size",
|
||||
"heroLogoPositionLabel": "Position",
|
||||
|
||||
@@ -26,7 +26,8 @@ import {
|
||||
Monitor,
|
||||
Droplets,
|
||||
MousePointer,
|
||||
Layout
|
||||
Layout,
|
||||
Trash2
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays, isValid } from 'date-fns';
|
||||
|
||||
@@ -54,6 +55,8 @@ import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal, FeedbackSettings, FeedbackModerationPanel, EventRenameDialog, PhotoFilterPanel, PhotoExportMenu } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { api } from '../../config/api';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
import { externalMediaService } from '../../services/externalMedia.service';
|
||||
@@ -217,6 +220,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||
const [logoUploading, setLogoUploading] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
|
||||
@@ -460,6 +464,38 @@ export const EventDetailsPage: React.FC = () => {
|
||||
setIsEditing(true);
|
||||
};
|
||||
|
||||
const handleEventLogoUpload = async (file: File) => {
|
||||
if (!id) return;
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
const response = await api.post(`/admin/events/${id}/logo`, formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' }
|
||||
});
|
||||
toast.success(t('events.eventLogoUploaded', 'Event logo uploaded successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['event', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('events.eventLogoUploadFailed', 'Failed to upload event logo'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEventLogoRemove = async () => {
|
||||
if (!id) return;
|
||||
setLogoUploading(true);
|
||||
try {
|
||||
await api.delete(`/admin/events/${id}/logo`);
|
||||
toast.success(t('events.eventLogoRemoved', 'Event logo removed successfully'));
|
||||
queryClient.invalidateQueries({ queryKey: ['event', id] });
|
||||
} catch (error: any) {
|
||||
toast.error(error?.response?.data?.error || t('events.eventLogoRemoveFailed', 'Failed to remove event logo'));
|
||||
} finally {
|
||||
setLogoUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveEdit = async () => {
|
||||
// Prepare color_theme - if we have a custom theme, serialize it
|
||||
let themeToSave = editForm.color_theme;
|
||||
@@ -1134,6 +1170,74 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Custom Event Logo Upload */}
|
||||
<div className="ml-6 mt-3 pt-3 border-t border-neutral-100">
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 mb-2">
|
||||
{t('events.eventCustomLogoDescription', 'Upload a custom logo for this event. This overrides the global branding logo for this gallery only.')}
|
||||
</p>
|
||||
|
||||
{event.hero_logo_url ? (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-16 h-16 border border-neutral-200 rounded-md flex items-center justify-center bg-neutral-50 overflow-hidden">
|
||||
<img
|
||||
src={buildResourceUrl(event.hero_logo_url)}
|
||||
alt={t('events.eventCustomLogo', 'Custom Event Logo')}
|
||||
className="max-w-full max-h-full object-contain"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<label className="cursor-pointer inline-flex items-center gap-1 text-xs text-primary-600 hover:text-primary-700">
|
||||
<Upload className="w-3 h-3" />
|
||||
{t('events.replaceLogo', 'Replace')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleEventLogoUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleEventLogoRemove}
|
||||
disabled={logoUploading}
|
||||
className="inline-flex items-center gap-1 text-xs text-red-600 hover:text-red-700"
|
||||
>
|
||||
<Trash2 className="w-3 h-3" />
|
||||
{t('events.removeLogo', 'Remove')}
|
||||
</button>
|
||||
</div>
|
||||
{logoUploading && <Loading size="sm" />}
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<label className={`cursor-pointer inline-flex items-center gap-2 px-3 py-1.5 text-xs font-medium border border-neutral-300 rounded-md hover:bg-neutral-50 ${logoUploading ? 'opacity-50 pointer-events-none' : ''}`}>
|
||||
<Upload className="w-3.5 h-3.5" />
|
||||
{t('events.uploadEventLogo', 'Upload Logo')}
|
||||
<input
|
||||
type="file"
|
||||
className="hidden"
|
||||
accept="image/png,image/jpeg,image/gif,image/svg+xml"
|
||||
disabled={logoUploading}
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) handleEventLogoUpload(file);
|
||||
e.target.value = '';
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{logoUploading && <Loading size="sm" />}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user