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:
@@ -0,0 +1,53 @@
|
|||||||
|
/**
|
||||||
|
* Migration: Add custom logo support to events table
|
||||||
|
*
|
||||||
|
* Allows per-event custom logo that overrides the global branding logo:
|
||||||
|
* - hero_logo_url: Public path to the uploaded custom logo
|
||||||
|
* - hero_logo_path: Full filesystem path to the custom logo
|
||||||
|
*
|
||||||
|
* Logo priority: Event custom logo > Global branding logo > Default PicPeak logo
|
||||||
|
*
|
||||||
|
* Addresses GitHub Issue #138: Per-event custom logo option
|
||||||
|
*/
|
||||||
|
|
||||||
|
exports.up = async function (knex) {
|
||||||
|
console.log('Adding custom logo columns to events table...');
|
||||||
|
|
||||||
|
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
|
||||||
|
if (!hasUrlColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.string('hero_logo_url', 500).nullable().defaultTo(null);
|
||||||
|
});
|
||||||
|
console.log('Added hero_logo_url column');
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
|
||||||
|
if (!hasPathColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.string('hero_logo_path', 500).nullable().defaultTo(null);
|
||||||
|
});
|
||||||
|
console.log('Added hero_logo_path column');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Migration 063_add_event_custom_logo completed successfully');
|
||||||
|
};
|
||||||
|
|
||||||
|
exports.down = async function (knex) {
|
||||||
|
console.log('Rolling back custom logo columns...');
|
||||||
|
|
||||||
|
const hasUrlColumn = await knex.schema.hasColumn('events', 'hero_logo_url');
|
||||||
|
if (hasUrlColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('hero_logo_url');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPathColumn = await knex.schema.hasColumn('events', 'hero_logo_path');
|
||||||
|
if (hasPathColumn) {
|
||||||
|
await knex.schema.table('events', (table) => {
|
||||||
|
table.dropColumn('hero_logo_path');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Custom logo columns dropped');
|
||||||
|
};
|
||||||
@@ -9,6 +9,7 @@ const bcrypt = require('bcrypt');
|
|||||||
const crypto = require('crypto');
|
const crypto = require('crypto');
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const multer = require('multer');
|
||||||
const { archiveEvent } = require('../services/archiveService');
|
const { archiveEvent } = require('../services/archiveService');
|
||||||
const { queueEmail } = require('../services/emailProcessor');
|
const { queueEmail } = require('../services/emailProcessor');
|
||||||
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
const { escapeLikePattern } = require('../utils/sqlSecurity');
|
||||||
@@ -18,6 +19,36 @@ const logger = require('../utils/logger');
|
|||||||
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
const { buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
const { parseBooleanInput, parseStringInput, parseJsonInput } = require('../utils/parsers');
|
||||||
const eventTypeService = require('../services/eventTypeService');
|
const eventTypeService = require('../services/eventTypeService');
|
||||||
|
const { validateFileType } = require('../utils/fileSecurityUtils');
|
||||||
|
|
||||||
|
// Get storage path from environment or default
|
||||||
|
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
||||||
|
|
||||||
|
// Configure multer for event logo uploads
|
||||||
|
const eventLogoStorage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
const uploadDir = path.join(getStoragePath(), 'uploads/logos/events');
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
cb(null, uploadDir);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `event-${req.params.id}-logo-${Date.now()}${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const eventLogoUpload = multer({
|
||||||
|
storage: eventLogoStorage,
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowedMimeTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/svg+xml'];
|
||||||
|
if (validateFileType(file.originalname, file.mimetype, allowedMimeTypes)) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only JPEG, PNG, GIF and SVG image files are allowed'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Helper to get event field requirements from settings
|
// Helper to get event field requirements from settings
|
||||||
const getEventFieldRequirements = async () => {
|
const getEventFieldRequirements = async () => {
|
||||||
@@ -818,6 +849,16 @@ router.delete('/:id', adminAuth, requirePermission('events.delete'), async (req,
|
|||||||
// Don't fail the transaction if file deletion fails
|
// Don't fail the transaction if file deletion fails
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Delete custom event logo if exists
|
||||||
|
if (event.hero_logo_path) {
|
||||||
|
try {
|
||||||
|
const fsPromises = require('fs').promises;
|
||||||
|
await fsPromises.unlink(event.hero_logo_path);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to delete event logo file during event deletion', { path: event.hero_logo_path, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Log activity (outside transaction)
|
// Log activity (outside transaction)
|
||||||
@@ -1145,4 +1186,103 @@ router.post('/bulk-archive', adminAuth, requirePermission('events.archive'), [
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Upload event custom logo
|
||||||
|
router.post('/:id/logo', adminAuth, requirePermission('events.edit'), eventLogoUpload.single('logo'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
// Check if event exists
|
||||||
|
let eventQuery = db('events').where('id', id);
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
const event = await eventQuery.first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'No logo file provided' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete old logo file if exists
|
||||||
|
if (event.hero_logo_path) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(event.hero_logo_path);
|
||||||
|
logger.debug('Deleted old event logo file', { path: event.hero_logo_path });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to delete old event logo file', { path: event.hero_logo_path, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const logoUrl = `/uploads/logos/events/${req.file.filename}`;
|
||||||
|
const logoPath = req.file.path;
|
||||||
|
|
||||||
|
await db('events')
|
||||||
|
.where('id', id)
|
||||||
|
.update({
|
||||||
|
hero_logo_url: logoUrl,
|
||||||
|
hero_logo_path: logoPath
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('event_logo_uploaded',
|
||||||
|
{ eventName: event.event_name, filename: req.file.filename },
|
||||||
|
id,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: 'Event logo uploaded successfully',
|
||||||
|
hero_logo_url: logoUrl
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error uploading event logo:', { error: error.message, eventId: req.params.id });
|
||||||
|
res.status(500).json({ error: 'Failed to upload event logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event custom logo
|
||||||
|
router.delete('/:id/logo', adminAuth, requirePermission('events.edit'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
let eventQuery = db('events').where('id', id);
|
||||||
|
if (req.admin.roleName === 'editor') {
|
||||||
|
eventQuery = eventQuery.where('created_by', req.admin.id);
|
||||||
|
}
|
||||||
|
const event = await eventQuery.first();
|
||||||
|
if (!event) {
|
||||||
|
return res.status(404).json({ error: 'Event not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete logo file if exists
|
||||||
|
if (event.hero_logo_path) {
|
||||||
|
try {
|
||||||
|
await fs.unlink(event.hero_logo_path);
|
||||||
|
logger.debug('Deleted event logo file', { path: event.hero_logo_path });
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to delete event logo file', { path: event.hero_logo_path, error: err.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await db('events')
|
||||||
|
.where('id', id)
|
||||||
|
.update({
|
||||||
|
hero_logo_url: null,
|
||||||
|
hero_logo_path: null
|
||||||
|
});
|
||||||
|
|
||||||
|
await logActivity('event_logo_removed',
|
||||||
|
{ eventName: event.event_name },
|
||||||
|
id,
|
||||||
|
{ type: 'admin', id: req.admin.id, name: req.admin.username }
|
||||||
|
);
|
||||||
|
|
||||||
|
res.json({ message: 'Event logo removed successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error deleting event logo:', { error: error.message, eventId: req.params.id });
|
||||||
|
res.status(500).json({ error: 'Failed to delete event logo' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -176,8 +176,9 @@ router.post('/:eventId/upload', adminAuth, requirePermission('photos.upload'), u
|
|||||||
return res.status(400).json({ error: 'No files uploaded' });
|
return res.status(400).json({ error: 'No files uploaded' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse category_id to number if provided
|
// Parse category_id to number if provided (handle string values like 'individual', 'collage')
|
||||||
const parsedCategoryId = category_id ? parseInt(category_id, 10) : null;
|
const rawParsed = category_id ? parseInt(category_id, 10) : NaN;
|
||||||
|
const parsedCategoryId = !isNaN(rawParsed) ? rawParsed : null;
|
||||||
|
|
||||||
// Determine photo type and category name
|
// Determine photo type and category name
|
||||||
let photoType = 'individual'; // default
|
let photoType = 'individual'; // default
|
||||||
|
|||||||
@@ -118,7 +118,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
'use_canvas_rendering',
|
'use_canvas_rendering',
|
||||||
'hero_logo_visible',
|
'hero_logo_visible',
|
||||||
'hero_logo_size',
|
'hero_logo_size',
|
||||||
'hero_logo_position'
|
'hero_logo_position',
|
||||||
|
'hero_logo_url'
|
||||||
)
|
)
|
||||||
.first();
|
.first();
|
||||||
|
|
||||||
@@ -168,7 +169,8 @@ router.get('/:slug/info', async (req, res) => {
|
|||||||
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
use_canvas_rendering: event.use_canvas_rendering === true || event.use_canvas_rendering === 1 || event.use_canvas_rendering === '1',
|
||||||
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
hero_logo_visible: event.hero_logo_visible !== false && event.hero_logo_visible !== 0 && event.hero_logo_visible !== '0',
|
||||||
hero_logo_size: event.hero_logo_size || 'medium',
|
hero_logo_size: event.hero_logo_size || 'medium',
|
||||||
hero_logo_position: event.hero_logo_position || 'top'
|
hero_logo_position: event.hero_logo_position || 'top',
|
||||||
|
hero_logo_url: event.hero_logo_url || null
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching gallery info:', error);
|
console.error('Error fetching gallery info:', error);
|
||||||
@@ -341,6 +343,7 @@ router.get('/:slug/photos', verifyGalleryAccess, async (req, res) => {
|
|||||||
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
hero_logo_visible: req.event.hero_logo_visible !== false && req.event.hero_logo_visible !== 0 && req.event.hero_logo_visible !== '0',
|
||||||
hero_logo_size: req.event.hero_logo_size || 'medium',
|
hero_logo_size: req.event.hero_logo_size || 'medium',
|
||||||
hero_logo_position: req.event.hero_logo_position || 'top',
|
hero_logo_position: req.event.hero_logo_position || 'top',
|
||||||
|
hero_logo_url: req.event.hero_logo_url || null,
|
||||||
...protectionSettings
|
...protectionSettings
|
||||||
},
|
},
|
||||||
categories: categories,
|
categories: categories,
|
||||||
|
|||||||
@@ -682,7 +682,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
onToggleSelectionMode={() => setIsSelectionMode(!isSelectionMode)}
|
||||||
showSelectionControls={!showSidebar}
|
showSelectionControls={!showSidebar}
|
||||||
eventName={event.event_name}
|
eventName={event.event_name}
|
||||||
eventLogo={brandingSettings?.logo_url}
|
eventLogo={data?.event?.hero_logo_url || brandingSettings?.logo_url}
|
||||||
eventDate={event.event_date}
|
eventDate={event.event_date}
|
||||||
expiresAt={event.expires_at}
|
expiresAt={event.expires_at}
|
||||||
allowDownloads={allowDownloads}
|
allowDownloads={allowDownloads}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const PhotoFavorites: React.FC<PhotoFavoritesProps> = ({
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
// Revert optimistic update
|
// Revert optimistic update
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const PhotoLikes: React.FC<PhotoLikesProps> = ({
|
|||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
queryClient.invalidateQueries({ queryKey: ['photo-feedback', gallerySlug, photoId] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['gallery-photos'] });
|
||||||
},
|
},
|
||||||
onError: (error: any) => {
|
onError: (error: any) => {
|
||||||
// Revert optimistic update
|
// Revert optimistic update
|
||||||
|
|||||||
@@ -218,16 +218,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
if (verify?.valid) {
|
if (verify?.valid) {
|
||||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||||
if (response?.event) {
|
if (response?.event) {
|
||||||
const normalizedEvent = normalizeEvent(response.event);
|
// Store token and slug BEFORE setting authenticated state to avoid
|
||||||
setEvent(normalizedEvent);
|
// race condition where photo queries fire before token is available
|
||||||
setIsAuthenticated(true);
|
|
||||||
if (normalizedEvent) {
|
|
||||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
|
||||||
}
|
|
||||||
if (response.token) {
|
if (response.token) {
|
||||||
storeGalleryToken(currentSlug, response.token);
|
storeGalleryToken(currentSlug, response.token);
|
||||||
}
|
}
|
||||||
setActiveGallerySlug(currentSlug);
|
setActiveGallerySlug(currentSlug);
|
||||||
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
|
if (normalizedEvent) {
|
||||||
|
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedEvent));
|
||||||
|
}
|
||||||
|
setIsAuthenticated(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -263,17 +265,18 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
|||||||
setError(null);
|
setError(null);
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||||
const normalizedEvent = normalizeEvent(response.event);
|
// Store token and slug BEFORE setting authenticated state to avoid
|
||||||
setEvent(normalizedEvent);
|
// race condition where photo queries fire before token is available
|
||||||
setIsAuthenticated(true);
|
|
||||||
if (response.token) {
|
if (response.token) {
|
||||||
storeGalleryToken(slug, response.token);
|
storeGalleryToken(slug, response.token);
|
||||||
}
|
}
|
||||||
setActiveGallerySlug(slug);
|
setActiveGallerySlug(slug);
|
||||||
|
const normalizedEvent = normalizeEvent(response.event);
|
||||||
|
setEvent(normalizedEvent);
|
||||||
if (normalizedEvent) {
|
if (normalizedEvent) {
|
||||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||||
}
|
}
|
||||||
|
setIsAuthenticated(true);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Invalid password');
|
setError(err.response?.data?.error || 'Invalid password');
|
||||||
throw err;
|
throw err;
|
||||||
|
|||||||
@@ -845,6 +845,15 @@
|
|||||||
"heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)",
|
"heroLogoPositionCenter": "Mitte (zwischen Titel und Datum)",
|
||||||
"heroLogoPositionBottom": "Unten (unter dem 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.",
|
"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",
|
"heroLogoVisibleLabel": "Logo sichtbar",
|
||||||
"heroLogoSizeLabel": "Größe",
|
"heroLogoSizeLabel": "Größe",
|
||||||
"heroLogoPositionLabel": "Position",
|
"heroLogoPositionLabel": "Position",
|
||||||
|
|||||||
@@ -471,6 +471,15 @@
|
|||||||
"heroLogoPositionCenter": "Center (between title and dates)",
|
"heroLogoPositionCenter": "Center (between title and dates)",
|
||||||
"heroLogoPositionBottom": "Bottom (below 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.",
|
"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",
|
"heroLogoVisibleLabel": "Logo visible",
|
||||||
"heroLogoSizeLabel": "Size",
|
"heroLogoSizeLabel": "Size",
|
||||||
"heroLogoPositionLabel": "Position",
|
"heroLogoPositionLabel": "Position",
|
||||||
|
|||||||
@@ -26,7 +26,8 @@ import {
|
|||||||
Monitor,
|
Monitor,
|
||||||
Droplets,
|
Droplets,
|
||||||
MousePointer,
|
MousePointer,
|
||||||
Layout
|
Layout,
|
||||||
|
Trash2
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { parseISO, differenceInDays, isValid } from 'date-fns';
|
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 { 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 { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { eventsService } from '../../services/events.service';
|
import { eventsService } from '../../services/events.service';
|
||||||
|
import { api } from '../../config/api';
|
||||||
|
import { buildResourceUrl } from '../../utils/url';
|
||||||
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
|
||||||
import { archiveService } from '../../services/archive.service';
|
import { archiveService } from '../../services/archive.service';
|
||||||
import { externalMediaService } from '../../services/externalMedia.service';
|
import { externalMediaService } from '../../services/externalMedia.service';
|
||||||
@@ -217,6 +220,7 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||||
const [showNewPassword, setShowNewPassword] = useState(false);
|
const [showNewPassword, setShowNewPassword] = useState(false);
|
||||||
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
const [showRenameDialog, setShowRenameDialog] = useState(false);
|
||||||
|
const [logoUploading, setLogoUploading] = useState(false);
|
||||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||||
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
|
const [cssTemplates, setCssTemplates] = useState<EnabledTemplate[]>([]);
|
||||||
@@ -460,6 +464,38 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
setIsEditing(true);
|
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 () => {
|
const handleSaveEdit = async () => {
|
||||||
// Prepare color_theme - if we have a custom theme, serialize it
|
// Prepare color_theme - if we have a custom theme, serialize it
|
||||||
let themeToSave = editForm.color_theme;
|
let themeToSave = editForm.color_theme;
|
||||||
@@ -1134,6 +1170,74 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
|
<option value="bottom">{t('events.heroLogoPositionBottom', 'Bottom (below dates)')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</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>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,11 @@ test.describe('External media gallery behavior', () => {
|
|||||||
await passwordField.fill(GALLERY_PASSWORD);
|
await passwordField.fill(GALLERY_PASSWORD);
|
||||||
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
const viewButton = page.getByRole('button', { name: /View Gallery/i });
|
||||||
if (await viewButton.count()) {
|
if (await viewButton.count()) {
|
||||||
await viewButton.click({ noWaitAfter: true, timeout: 2000 });
|
try {
|
||||||
|
await viewButton.click({ noWaitAfter: true, timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
// Auto-auth via share token may have already navigated to gallery view
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -160,20 +164,16 @@ test.describe('External media gallery behavior', () => {
|
|||||||
|
|
||||||
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
const ariaLabel = await favoritesButtonInLightbox.getAttribute('aria-label');
|
||||||
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
const isAlreadyFavorited = ariaLabel ? /Remove from favorites/i.test(ariaLabel) : false;
|
||||||
const refetchPromise = page.waitForResponse((res) => {
|
|
||||||
return res.request().method() === 'GET' && res.url().includes(`/api/gallery/${slug}/photos`);
|
|
||||||
});
|
|
||||||
if (!isAlreadyFavorited) {
|
if (!isAlreadyFavorited) {
|
||||||
const favResponsePromise = page.waitForResponse((res) => {
|
|
||||||
return res.request().method() === 'POST' && res.url().includes(`/api/gallery/${slug}/photos/`);
|
|
||||||
});
|
|
||||||
await favoritesButtonInLightbox.click();
|
await favoritesButtonInLightbox.click();
|
||||||
await Promise.all([favResponsePromise, refetchPromise]);
|
// Wait for the mutation to complete and the subsequent refetch with updated counts
|
||||||
} else {
|
// The onSuccess handler invalidates gallery-photos, triggering a fresh refetch
|
||||||
await refetchPromise;
|
await page.waitForTimeout(500);
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
}
|
}
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
await page.getByRole('button', { name: 'Close', exact: true }).click();
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Favorited' }).click();
|
await page.getByRole('button', { name: 'Favorited' }).click();
|
||||||
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
await expect(page.locator('.relative.group')).toHaveCount(1, { timeout: 15000 });
|
||||||
|
|||||||
@@ -101,7 +101,11 @@ test.describe('Gallery grid tile quick actions', () => {
|
|||||||
const passwordField = gallery.getByPlaceholder(/gallery password/i).first();
|
const passwordField = gallery.getByPlaceholder(/gallery password/i).first();
|
||||||
if (await passwordField.count()) {
|
if (await passwordField.count()) {
|
||||||
await passwordField.fill(GALLERY_PASSWORD);
|
await passwordField.fill(GALLERY_PASSWORD);
|
||||||
await gallery.getByRole('button', { name: /View Gallery/i }).click();
|
try {
|
||||||
|
await gallery.getByRole('button', { name: /View Gallery/i }).click({ noWaitAfter: true, timeout: 5000 });
|
||||||
|
} catch {
|
||||||
|
// Auto-auth via share token may have already navigated to gallery view
|
||||||
|
}
|
||||||
await gallery.waitForLoadState('networkidle');
|
await gallery.waitForLoadState('networkidle');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user