fix: address bugs and feature requests from discussion #317

- Share link: display and copy now use the absolute URL built from the
  current origin instead of the relative path stored in events.share_link.
  Added a Copy Link button to the events list (inline + dropdown).
- Detect dev tools default: event creation now reads the global
  enable_devtools_protection app setting instead of always falling back to
  the column default; admins who disable it globally get new events with
  it disabled too.
- Require password default: added a global "Require password by default"
  setting (event_default_require_password, default true), exposed via
  Settings -> Events. Create-event form initialises from it.
- Filter bar: added gallery_show_filter_bar setting and hide the search/
  sort row in the public gallery when off, or when the gallery has zero
  photos (fixes the empty-state UX from the screenshot).
- Theme picker unclickable on Create Event: memoised availableEventTypes
  so its identity is stable. The "auto-apply event-type recommended
  preset" effect was firing on every render due to the unstable array
  reference and silently overwriting the user's preset selection ~1ms
  after each click.
- Branding logo disappearing on theme change: handlePresetChange and
  handleThemeChange no longer wipe the existing logoUrl when a preset
  config (which carries no logoUrl) is applied; handleSave falls back to
  brandingSettings.logo_url. themeMutation now invalidates the
  admin-settings and public-settings caches so saved theme changes appear
  immediately.
This commit is contained in:
Paul Nothaft
2026-04-26 22:48:59 +02:00
parent e4b0f961b7
commit 6cfff6f6a6
11 changed files with 258 additions and 48 deletions
+48 -2
View File
@@ -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),
+9 -1
View File
@@ -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)
@@ -847,8 +847,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
</div>
)}
{/* 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 ? (
<div className="mt-6">
<PhotoFilterBar
categories={data.categories}
@@ -50,6 +50,8 @@ export interface EventSettings {
event_require_admin_email: boolean;
event_require_event_date: boolean;
event_require_expiration: boolean;
event_default_require_password: boolean;
gallery_show_filter_bar: boolean;
}
export interface SeoSettings {
@@ -123,7 +125,9 @@ export function useSettingsState() {
event_require_customer_email: true,
event_require_admin_email: true,
event_require_event_date: true,
event_require_expiration: true
event_require_expiration: true,
event_default_require_password: true,
gallery_show_filter_bar: true
});
// SEO settings state
@@ -206,7 +210,9 @@ export function useSettingsState() {
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_email, true),
event_require_event_date: toBoolean(settings.event_require_event_date, true),
event_require_expiration: toBoolean(settings.event_require_expiration, true)
event_require_expiration: toBoolean(settings.event_require_expiration, true),
event_default_require_password: toBoolean(settings.event_default_require_password, true),
gallery_show_filter_bar: toBoolean(settings.gallery_show_filter_bar, true)
});
setSeoSettings({
@@ -149,6 +149,44 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.event_default_require_password}
onChange={(e) => setEventSettings(prev => ({ ...prev, event_default_require_password: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.defaultRequirePassword', 'Require password by default')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.defaultRequirePasswordHelp', 'Pre-check "Require password" when creating new events. Disable for quicker creation of public galleries.')}
</p>
</div>
</label>
</div>
<div>
<label className="flex items-start gap-3">
<input
type="checkbox"
checked={eventSettings.gallery_show_filter_bar}
onChange={(e) => setEventSettings(prev => ({ ...prev, gallery_show_filter_bar: e.target.checked }))}
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
/>
<div>
<span className="text-sm font-medium text-neutral-700 dark:text-neutral-300">
{t('settings.events.showGalleryFilterBar', 'Show filter bar in galleries')}
</span>
<p className="text-xs text-neutral-500 dark:text-neutral-400 mt-1">
{t('settings.events.showGalleryFilterBarHelp', 'Display the search-by-filename and sort controls above grid-layout galleries. Disable for a cleaner layout.')}
</p>
</div>
</label>
</div>
</div>
<div className="mt-6">
+22 -8
View File
@@ -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
+29 -10
View File
@@ -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
+8 -13
View File
@@ -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 && (
<a
href={event.is_draft
? `${resolveShareLink(event.share_link)}${resolveShareLink(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: resolveShareLink(event.share_link)
? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: buildShareLinkUrl(event.share_link)
}
target="_blank"
rel="noopener noreferrer"
@@ -1604,7 +1599,7 @@ export const EventDetailsPage: React.FC = () => {
<div className="flex items-center gap-2">
<input
type="text"
value={event.share_link}
value={buildShareLinkUrl(event.share_link)}
readOnly
className="flex-1 px-3 py-2 bg-neutral-50 dark:bg-neutral-700 border border-neutral-300 dark:border-neutral-600 text-neutral-900 dark:text-neutral-100 rounded-lg text-sm"
/>
+62 -10
View File
@@ -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<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false);
const [copiedEventId, setCopiedEventId] = useState<number | null>(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 = () => {
<Button
variant="ghost"
size="sm"
onClick={() => window.open(resolveShareLink(event.share_link), '_blank')}
onClick={() => window.open(buildShareLinkUrl(event.share_link), '_blank')}
title={t('events.viewGallery')}
>
<ExternalLink className="w-4 h-4" />
</Button>
)}
{event.share_link && (
<Button
variant="ghost"
size="sm"
onClick={() => copyShareLink(event)}
title={t('events.copyLink', 'Copy Link')}
>
{copiedEventId === event.id ? (
<CheckCircle className="w-4 h-4 text-green-600" />
) : (
<Copy className="w-4 h-4" />
)}
</Button>
)}
</div>
{/* Context menu for additional actions */}
@@ -539,7 +578,7 @@ export const EventsListPage: React.FC = () => {
</button>
{event.share_link ? (
<a
href={resolveShareLink(event.share_link)}
href={buildShareLinkUrl(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="md:hidden w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
@@ -552,6 +591,19 @@ export const EventsListPage: React.FC = () => {
{t('events.viewGallery')}
</a>
) : null}
{event.share_link ? (
<button
onClick={() => {
copyShareLink(event);
setActiveDropdown(null);
setDropdownPosition(null);
}}
className="md:hidden w-full text-left px-4 py-2 text-sm text-neutral-700 dark:text-neutral-300 hover:bg-neutral-100 dark:hover:bg-neutral-700 flex items-center gap-2"
>
<Copy className="w-4 h-4" />
{t('events.copyLink', 'Copy Link')}
</button>
) : null}
{!event.is_archived ? (
<button
onClick={() => {
@@ -31,6 +31,8 @@ export interface PublicSettings {
event_require_admin_email?: boolean;
event_require_event_date?: boolean;
event_require_expiration?: boolean;
event_default_require_password?: boolean;
gallery_show_filter_bar?: boolean;
}
export const publicSettingsService = {
+27
View File
@@ -132,3 +132,30 @@ export const isProductionMode = (): boolean => {
const apiBase = getApiBaseUrl();
return !ABSOLUTE_URL_REGEX.test(apiBase);
};
/**
* Build a fully-qualified gallery share URL from the value stored in
* `events.share_link`. The DB stores the relative path (e.g.
* `/gallery/<slug>/<token>`); for display and clipboard copy we need an
* absolute URL the recipient can paste into a browser. Absolute inputs are
* passed through (with the same localhost-fallback rule used elsewhere).
*
* @param link - The relative or absolute share link
* @returns A fully-qualified URL, or `'#'` if input is empty
*/
export const buildShareLinkUrl = (link: string | null | undefined): string => {
if (!link) return '#';
if (ABSOLUTE_URL_REGEX.test(link)) {
if (!shouldFallbackToRelative(link)) return link;
try {
const parsed = new URL(link);
return buildFromOrigin(`${parsed.pathname}${parsed.search}${parsed.hash}`);
} catch {
return link;
}
}
const path = link.startsWith('/') ? link : `/gallery/${link}`;
return buildFromOrigin(path);
};