Merge pull request #319 from the-luap/feat/prezip-and-photo-replace

fix: discussion #317 issues and #318 archive crash
This commit is contained in:
Paul Nothaft
2026-04-26 22:51:46 +02:00
committed by GitHub
12 changed files with 293 additions and 72 deletions
@@ -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);
};