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
+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={() => {