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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
Reference in New Issue
Block a user