diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index a3c5085c..c12dadd4 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -113,6 +113,31 @@ const getEventFieldRequirements = async () => { } }; +// Helper to read app_settings booleans by key, used to inherit per-setting +// defaults onto new events. Returns `undefined` for missing/non-boolean rows +// so callers can fall back to a legacy default. +const readBooleanSetting = async (key) => { + try { + const setting = await db('app_settings').where('setting_key', key).first(); + if (!setting) return undefined; + let value = setting.setting_value; + if (typeof value === 'string') { + try { value = JSON.parse(value); } catch { /* keep raw */ } + } + return typeof value === 'boolean' ? value : undefined; + } catch (error) { + logger.error('Failed to read app setting', { key, error: error.message }); + return undefined; + } +}; + +// Helper to read the global "enable_devtools_protection" admin setting so +// new events inherit it instead of always falling back to the DB column default +// (#317 — admin disabled it globally but new events still got it ON). +const getDownloadProtectionDefaults = async () => { + return { enable_devtools_protection: await readBooleanSetting('enable_devtools_protection') }; +}; + // Helper to get branding defaults for new events (Feature 7: Branding Inheritance) const getBrandingDefaults = async () => { try { @@ -246,6 +271,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('allow_downloads').optional().isBoolean(), body('disable_right_click').optional().isBoolean(), + body('enable_devtools_protection').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), body('watermark_text').optional().trim(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), @@ -291,9 +317,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [ upload_category_id = null, allow_downloads = true, disable_right_click = false, + enable_devtools_protection: enableDevtoolsProtectionInput, watermark_downloads = false, watermark_text = null, - require_password: requirePasswordInput = true, + require_password: requirePasswordInput, // Feedback settings feedback_enabled = false, allow_ratings = true, @@ -349,7 +376,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [ return res.status(400).json({ errors: validationErrors }); } - const requirePassword = parseBooleanInput(requirePasswordInput, true); + // Default require_password from global "event_default_require_password" + // setting when the body omits it (#317 — admins want to flip the default). + let requirePasswordFallback = true; + if (requirePasswordInput === undefined) { + const setting = await readBooleanSetting('event_default_require_password'); + if (setting !== undefined) requirePasswordFallback = setting; + } + const requirePassword = parseBooleanInput(requirePasswordInput, requirePasswordFallback); // Debug logging logger.debug('Download control values', { @@ -457,6 +491,17 @@ router.post('/', adminAuth, requirePermission('events.create'), [ const effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size; const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; + // Inherit "Detect dev tools" from the global Image Security setting unless + // the request explicitly overrides it (#317 — admin disabled it globally + // but new events still got it ON because the column default is true). + const protectionDefaults = await getDownloadProtectionDefaults(); + const effectiveEnableDevtoolsProtection = + enableDevtoolsProtectionInput !== undefined + ? enableDevtoolsProtectionInput + : protectionDefaults.enable_devtools_protection !== undefined + ? protectionDefaults.enable_devtools_protection + : true; + // Insert into database const insertResult = await db('events').insert({ slug, @@ -479,6 +524,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [ upload_category_id, allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), + enable_devtools_protection: formatBoolean(effectiveEnableDevtoolsProtection), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_text, require_password: formatBoolean(requirePassword), diff --git a/backend/src/routes/publicSettings.js b/backend/src/routes/publicSettings.js index 23600ce4..ff7441e1 100644 --- a/backend/src/routes/publicSettings.js +++ b/backend/src/routes/publicSettings.js @@ -13,7 +13,11 @@ router.get('/', async (req, res) => { this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean']) .orWhere('setting_key', 'like', 'analytics_%') .orWhere('setting_key', 'like', 'event_require_%') - .orWhereIn('setting_key', ['seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai']); + .orWhereIn('setting_key', [ + 'seo_meta_noindex', 'seo_meta_nofollow', 'seo_meta_noai', + 'event_default_require_password', + 'gallery_show_filter_bar' + ]); }) .select('setting_key', 'setting_value'); }); @@ -78,6 +82,10 @@ router.get('/', async (req, res) => { event_require_admin_email: settingsObject.event_require_admin_email !== false, event_require_event_date: settingsObject.event_require_event_date !== false, event_require_expiration: settingsObject.event_require_expiration !== false, + // Default value for "Require password" toggle in event creation form + event_default_require_password: settingsObject.event_default_require_password !== false, + // Whether to show the search/sort filter bar in public galleries (default: true) + gallery_show_filter_bar: settingsObject.gallery_show_filter_bar !== false, // Upload settings (safe to expose - needed for client-side validation) allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp', // SEO meta tag flags (safe to expose - these are intended for crawlers) diff --git a/frontend/src/components/gallery/GalleryView.tsx b/frontend/src/components/gallery/GalleryView.tsx index 34c41552..1f55dc30 100644 --- a/frontend/src/components/gallery/GalleryView.tsx +++ b/frontend/src/components/gallery/GalleryView.tsx @@ -847,8 +847,11 @@ export const GalleryView: React.FC = ({ slug, event }) => { )} - {/* Search and Filters - Only for grid layout */} - {!showSidebar ? ( + {/* Search and Filters - Only for grid layout, when admin enables the + filter bar globally, and when the gallery actually has photos + (avoids the empty "Search photos by filename" row in the screenshot + from discussion #317). */} + {!showSidebar && settingsData?.gallery_show_filter_bar !== false && (data?.photos?.length ?? 0) > 0 ? (
= ({
+ +
+ +
+ +
+ +
diff --git a/frontend/src/pages/admin/BrandingPage.tsx b/frontend/src/pages/admin/BrandingPage.tsx index d0619d25..1fb2e86a 100644 --- a/frontend/src/pages/admin/BrandingPage.tsx +++ b/frontend/src/pages/admin/BrandingPage.tsx @@ -71,6 +71,11 @@ export const BrandingPage: React.FC = () => { mutationFn: settingsService.updateTheme, onSuccess: () => { toast.success(t('toast.themeUpdated')); + // Refresh both the admin settings cache (which the page reads from) and + // the public-settings cache (which the gallery reads from) so the saved + // theme is reflected without a manual reload (#317). + queryClient.invalidateQueries({ queryKey: ['admin-settings'] }); + queryClient.invalidateQueries({ queryKey: ['public-settings'] }); }, onError: () => { toast.error(t('toast.saveError')); @@ -117,13 +122,19 @@ export const BrandingPage: React.FC = () => { }; const handleThemeChange = (newTheme: ThemeConfig) => { - setCurrentTheme(newTheme); - // Also update logo URL in branding settings if it changed - if (newTheme.logoUrl !== currentTheme.logoUrl) { + // Preset configs don't carry a logoUrl, so a preset change inside the + // customizer arrives here with newTheme.logoUrl=undefined. Keep the + // existing logo instead of wiping branding_logo_url on save (#317). + const mergedTheme: ThemeConfig = { + ...newTheme, + logoUrl: newTheme.logoUrl ?? currentTheme.logoUrl + }; + setCurrentTheme(mergedTheme); + if (newTheme.logoUrl !== undefined && newTheme.logoUrl !== currentTheme.logoUrl) { setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' })); } if (isPreviewMode) { - setTheme(newTheme); + setTheme(mergedTheme); } }; @@ -132,9 +143,10 @@ export const BrandingPage: React.FC = () => { // Get the preset theme config const preset = GALLERY_THEME_PRESETS[presetName]; if (preset) { - setCurrentTheme(preset.config); + // Preserve the existing logo when switching presets (#317). + setCurrentTheme(prev => ({ ...preset.config, logoUrl: prev.logoUrl })); if (isPreviewMode) { - setTheme(preset.config); + setTheme({ ...preset.config, logoUrl: currentTheme.logoUrl }); } } }; @@ -201,10 +213,12 @@ export const BrandingPage: React.FC = () => { const handleSave = async () => { try { - // Sync logo URL from theme to branding settings + // Sync logo URL from theme to branding settings, but never let an + // undefined/empty theme.logoUrl wipe a logo that is still configured in + // branding settings (#317 — preset selection does not imply logo removal). const updatedBrandingSettings = { ...brandingSettings, - logo_url: currentTheme.logoUrl || '' + logo_url: currentTheme.logoUrl || brandingSettings.logo_url || '' }; // Save branding settings to database diff --git a/frontend/src/pages/admin/CreateEventPage.tsx b/frontend/src/pages/admin/CreateEventPage.tsx index 0a9624de..ce30d945 100644 --- a/frontend/src/pages/admin/CreateEventPage.tsx +++ b/frontend/src/pages/admin/CreateEventPage.tsx @@ -1,4 +1,4 @@ -import React, { useState, useRef, useEffect } from 'react'; +import React, { useState, useRef, useEffect, useMemo } from 'react'; import { useNavigate } from 'react-router-dom'; import { Calendar, @@ -146,15 +146,21 @@ export const CreateEventPage: React.FC = () => { queryFn: () => eventTypesService.getActiveEventTypes() }); - // Compute event types to use (API data or fallback) - const availableEventTypes = eventTypes?.length - ? eventTypes.map(et => ({ - value: et.slug_prefix, - name: et.name, - emoji: et.emoji, - theme_preset: et.theme_preset - })) - : FALLBACK_EVENT_TYPES; + // Compute event types to use (API data or fallback). Memoised so its + // identity is stable across renders — otherwise the "Update theme when + // event type changes" effect below re-runs on every render and silently + // overwrites the user's Theme Preset selection (#317). + const availableEventTypes = useMemo( + () => (eventTypes?.length + ? eventTypes.map(et => ({ + value: et.slug_prefix, + name: et.name, + emoji: et.emoji, + theme_preset: et.theme_preset + })) + : FALLBACK_EVENT_TYPES), + [eventTypes] + ); // Fetch default settings const { data: settings } = useQuery({ @@ -185,6 +191,19 @@ export const CreateEventPage: React.FC = () => { } }, [settings]); + // Honour the global "Require password by default" admin setting (#317). + // Apply once when public settings first load, before the user has interacted. + const requirePasswordDefaultApplied = useRef(false); + useEffect(() => { + if (requirePasswordDefaultApplied.current) return; + if (publicSettings?.event_default_require_password === undefined) return; + requirePasswordDefaultApplied.current = true; + setFormData(prev => ({ + ...prev, + require_password: publicSettings.event_default_require_password !== false + })); + }, [publicSettings]); + // Update theme when event type changes useEffect(() => { // Find the selected event type's theme preset diff --git a/frontend/src/pages/admin/EventDetailsPage.tsx b/frontend/src/pages/admin/EventDetailsPage.tsx index 5fb14cfa..99132d66 100644 --- a/frontend/src/pages/admin/EventDetailsPage.tsx +++ b/frontend/src/pages/admin/EventDetailsPage.tsx @@ -58,7 +58,7 @@ import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { publicSettingsService } from '../../services/publicSettings.service'; import { api } from '../../config/api'; -import { buildResourceUrl } from '../../utils/url'; +import { buildResourceUrl, buildShareLinkUrl } from '../../utils/url'; import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl'; import { archiveService } from '../../services/archive.service'; import { externalMediaService } from '../../services/externalMedia.service'; @@ -67,13 +67,6 @@ import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../.. import { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service'; import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; -const resolveShareLink = (link: string): string => { - if (!link) return '#'; - if (link.startsWith('http')) return link; - if (link.startsWith('/')) return link; - return `/gallery/${link}`; -}; - const ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => { const { t } = useTranslation(); const [entries, setEntries] = useState<{ path: string; entries: any[]; canNavigateUp: boolean } | null>(null); @@ -657,13 +650,15 @@ export const EventDetailsPage: React.FC = () => { return; } + const shareUrl = buildShareLinkUrl(event.share_link); + // Try modern clipboard API first if (navigator.clipboard && window.isSecureContext) { - await navigator.clipboard.writeText(event.share_link); + await navigator.clipboard.writeText(shareUrl); } else { // Fallback for non-HTTPS contexts or older browsers const textArea = document.createElement('textarea'); - textArea.value = event.share_link; + textArea.value = shareUrl; textArea.style.position = 'fixed'; textArea.style.left = '-999999px'; textArea.style.top = '-999999px'; @@ -793,8 +788,8 @@ export const EventDetailsPage: React.FC = () => { {event.share_link && !isEditing && ( {
diff --git a/frontend/src/pages/admin/EventsListPage.tsx b/frontend/src/pages/admin/EventsListPage.tsx index a3ca6e1a..f09cf903 100644 --- a/frontend/src/pages/admin/EventsListPage.tsx +++ b/frontend/src/pages/admin/EventsListPage.tsx @@ -12,7 +12,9 @@ import { Trash2, Calendar, Image, - Activity + Activity, + Copy, + CheckCircle } from 'lucide-react'; import { parseISO, differenceInDays } from 'date-fns'; import { toast } from 'react-toastify'; @@ -23,16 +25,10 @@ import { BulkArchiveModal } from '../../components/admin'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { eventsService } from '../../services/events.service'; import { isGalleryPublic } from '../../utils/accessControl'; +import { buildShareLinkUrl } from '../../utils/url'; import type { Event } from '../../types'; import { useTranslation } from 'react-i18next'; -const resolveShareLink = (link: string): string => { - if (!link) return '#'; - if (link.startsWith('http')) return link; - if (link.startsWith('/')) return link; - return `/gallery/${link}`; -}; - export const EventsListPage: React.FC = () => { const { t } = useTranslation(); const { format } = useLocalizedDate(); @@ -46,6 +42,35 @@ export const EventsListPage: React.FC = () => { const [activeDropdown, setActiveDropdown] = useState(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); + const [copiedEventId, setCopiedEventId] = useState(null); + + const copyShareLink = async (event: Event) => { + const url = buildShareLinkUrl(event.share_link); + if (!url || url === '#') { + toast.error(t('errors.noShareLink', 'No share link available')); + return; + } + try { + if (navigator.clipboard && window.isSecureContext) { + await navigator.clipboard.writeText(url); + } else { + const textArea = document.createElement('textarea'); + textArea.value = url; + textArea.style.position = 'fixed'; + textArea.style.left = '-999999px'; + document.body.appendChild(textArea); + textArea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textArea); + if (!ok) throw new Error('Copy failed'); + } + setCopiedEventId(event.id); + toast.success(t('events.linkCopied', 'Gallery link copied')); + setTimeout(() => setCopiedEventId((current) => (current === event.id ? null : current)), 2000); + } catch { + toast.error(t('errors.copyFailed', 'Failed to copy link')); + } + }; // Get filter from URL const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null; @@ -489,12 +514,26 @@ export const EventsListPage: React.FC = () => { )} + {event.share_link && ( + + )}
{/* Context menu for additional actions */} @@ -539,7 +578,7 @@ export const EventsListPage: React.FC = () => { {event.share_link ? (
{ {t('events.viewGallery')} ) : null} + {event.share_link ? ( + + ) : null} {!event.is_archived ? (