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
+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) // Helper to get branding defaults for new events (Feature 7: Branding Inheritance)
const getBrandingDefaults = async () => { const getBrandingDefaults = async () => {
try { try {
@@ -246,6 +271,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('upload_category_id').optional({ nullable: true, checkFalsy: true }).isInt(),
body('allow_downloads').optional().isBoolean(), body('allow_downloads').optional().isBoolean(),
body('disable_right_click').optional().isBoolean(), body('disable_right_click').optional().isBoolean(),
body('enable_devtools_protection').optional().isBoolean(),
body('watermark_downloads').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(),
body('watermark_text').optional().trim(), body('watermark_text').optional().trim(),
body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(), body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt(),
@@ -291,9 +317,10 @@ router.post('/', adminAuth, requirePermission('events.create'), [
upload_category_id = null, upload_category_id = null,
allow_downloads = true, allow_downloads = true,
disable_right_click = false, disable_right_click = false,
enable_devtools_protection: enableDevtoolsProtectionInput,
watermark_downloads = false, watermark_downloads = false,
watermark_text = null, watermark_text = null,
require_password: requirePasswordInput = true, require_password: requirePasswordInput,
// Feedback settings // Feedback settings
feedback_enabled = false, feedback_enabled = false,
allow_ratings = true, allow_ratings = true,
@@ -349,7 +376,14 @@ router.post('/', adminAuth, requirePermission('events.create'), [
return res.status(400).json({ errors: validationErrors }); 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 // Debug logging
logger.debug('Download control values', { 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 effectiveHeroLogoSize = req.body.hero_logo_size || brandingDefaults.hero_logo_size;
const effectiveHeroLogoPosition = req.body.hero_logo_position || brandingDefaults.hero_logo_position; 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 // Insert into database
const insertResult = await db('events').insert({ const insertResult = await db('events').insert({
slug, slug,
@@ -479,6 +524,7 @@ router.post('/', adminAuth, requirePermission('events.create'), [
upload_category_id, upload_category_id,
allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true), allow_downloads: formatBoolean(allow_downloads !== undefined ? allow_downloads : true),
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), 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_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
watermark_text, watermark_text,
require_password: formatBoolean(requirePassword), 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']) this.whereIn('setting_type', ['branding', 'theme', 'general', 'security', 'analytics', 'boolean'])
.orWhere('setting_key', 'like', 'analytics_%') .orWhere('setting_key', 'like', 'analytics_%')
.orWhere('setting_key', 'like', 'event_require_%') .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'); .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_admin_email: settingsObject.event_require_admin_email !== false,
event_require_event_date: settingsObject.event_require_event_date !== false, event_require_event_date: settingsObject.event_require_event_date !== false,
event_require_expiration: settingsObject.event_require_expiration !== 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) // Upload settings (safe to expose - needed for client-side validation)
allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp', allowed_file_types: settingsObject.general_allowed_file_types || 'jpg,jpeg,png,webp',
// SEO meta tag flags (safe to expose - these are intended for crawlers) // SEO meta tag flags (safe to expose - these are intended for crawlers)
+12 -1
View File
@@ -61,6 +61,7 @@ async function archiveEvent(event) {
} }
output.on('close', async () => { output.on('close', async () => {
try {
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`); logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
// Update database // Update database
@@ -82,11 +83,21 @@ async function archiveEvent(event) {
} }
} }
// Queue completion email // Queue completion email — admin_email is nullable on events (migration 073);
// skip queueing rather than violating email_queue.recipient_email NOT NULL.
if (event.admin_email) {
await queueEmail(event.id, event.admin_email, 'archive_complete', { await queueEmail(event.id, event.admin_email, 'archive_complete', {
event_name: event.event_name, event_name: event.event_name,
archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB' archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB'
}); });
} else {
logger.info(`Skipping archive_complete email for event ${event.slug}: no admin_email set`);
}
} catch (err) {
// Never let the close handler reject — it runs detached from the caller,
// and an unhandled rejection here crashes the backend process.
logger.error(`Post-archive cleanup failed for event ${event.slug}:`, err);
}
}); });
archive.pipe(output); archive.pipe(output);
@@ -847,8 +847,11 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
</div> </div>
)} )}
{/* Search and Filters - Only for grid layout */} {/* Search and Filters - Only for grid layout, when admin enables the
{!showSidebar ? ( 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"> <div className="mt-6">
<PhotoFilterBar <PhotoFilterBar
categories={data.categories} categories={data.categories}
@@ -50,6 +50,8 @@ export interface EventSettings {
event_require_admin_email: boolean; event_require_admin_email: boolean;
event_require_event_date: boolean; event_require_event_date: boolean;
event_require_expiration: boolean; event_require_expiration: boolean;
event_default_require_password: boolean;
gallery_show_filter_bar: boolean;
} }
export interface SeoSettings { export interface SeoSettings {
@@ -123,7 +125,9 @@ export function useSettingsState() {
event_require_customer_email: true, event_require_customer_email: true,
event_require_admin_email: true, event_require_admin_email: true,
event_require_event_date: 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 // SEO settings state
@@ -206,7 +210,9 @@ export function useSettingsState() {
event_require_customer_email: toBoolean(settings.event_require_customer_email, true), event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
event_require_admin_email: toBoolean(settings.event_require_admin_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_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({ setSeoSettings({
@@ -149,6 +149,44 @@ export const EventsTab: React.FC<EventsTabProps> = ({
</div> </div>
</label> </label>
</div> </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>
<div className="mt-6"> <div className="mt-6">
+22 -8
View File
@@ -71,6 +71,11 @@ export const BrandingPage: React.FC = () => {
mutationFn: settingsService.updateTheme, mutationFn: settingsService.updateTheme,
onSuccess: () => { onSuccess: () => {
toast.success(t('toast.themeUpdated')); 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: () => { onError: () => {
toast.error(t('toast.saveError')); toast.error(t('toast.saveError'));
@@ -117,13 +122,19 @@ export const BrandingPage: React.FC = () => {
}; };
const handleThemeChange = (newTheme: ThemeConfig) => { const handleThemeChange = (newTheme: ThemeConfig) => {
setCurrentTheme(newTheme); // Preset configs don't carry a logoUrl, so a preset change inside the
// Also update logo URL in branding settings if it changed // customizer arrives here with newTheme.logoUrl=undefined. Keep the
if (newTheme.logoUrl !== currentTheme.logoUrl) { // 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 || '' })); setBrandingSettings(prev => ({ ...prev, logo_url: newTheme.logoUrl || '' }));
} }
if (isPreviewMode) { if (isPreviewMode) {
setTheme(newTheme); setTheme(mergedTheme);
} }
}; };
@@ -132,9 +143,10 @@ export const BrandingPage: React.FC = () => {
// Get the preset theme config // Get the preset theme config
const preset = GALLERY_THEME_PRESETS[presetName]; const preset = GALLERY_THEME_PRESETS[presetName];
if (preset) { if (preset) {
setCurrentTheme(preset.config); // Preserve the existing logo when switching presets (#317).
setCurrentTheme(prev => ({ ...preset.config, logoUrl: prev.logoUrl }));
if (isPreviewMode) { if (isPreviewMode) {
setTheme(preset.config); setTheme({ ...preset.config, logoUrl: currentTheme.logoUrl });
} }
} }
}; };
@@ -201,10 +213,12 @@ export const BrandingPage: React.FC = () => {
const handleSave = async () => { const handleSave = async () => {
try { 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 = { const updatedBrandingSettings = {
...brandingSettings, ...brandingSettings,
logo_url: currentTheme.logoUrl || '' logo_url: currentTheme.logoUrl || brandingSettings.logo_url || ''
}; };
// Save branding settings to database // Save branding settings to database
+23 -4
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 { useNavigate } from 'react-router-dom';
import { import {
Calendar, Calendar,
@@ -146,15 +146,21 @@ export const CreateEventPage: React.FC = () => {
queryFn: () => eventTypesService.getActiveEventTypes() queryFn: () => eventTypesService.getActiveEventTypes()
}); });
// Compute event types to use (API data or fallback) // Compute event types to use (API data or fallback). Memoised so its
const availableEventTypes = eventTypes?.length // 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 => ({ ? eventTypes.map(et => ({
value: et.slug_prefix, value: et.slug_prefix,
name: et.name, name: et.name,
emoji: et.emoji, emoji: et.emoji,
theme_preset: et.theme_preset theme_preset: et.theme_preset
})) }))
: FALLBACK_EVENT_TYPES; : FALLBACK_EVENT_TYPES),
[eventTypes]
);
// Fetch default settings // Fetch default settings
const { data: settings } = useQuery({ const { data: settings } = useQuery({
@@ -185,6 +191,19 @@ export const CreateEventPage: React.FC = () => {
} }
}, [settings]); }, [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 // Update theme when event type changes
useEffect(() => { useEffect(() => {
// Find the selected event type's theme preset // 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 { eventsService } from '../../services/events.service';
import { publicSettingsService } from '../../services/publicSettings.service'; import { publicSettingsService } from '../../services/publicSettings.service';
import { api } from '../../config/api'; import { api } from '../../config/api';
import { buildResourceUrl } from '../../utils/url'; import { buildResourceUrl, buildShareLinkUrl } from '../../utils/url';
import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl'; import { isGalleryPublic, normalizeRequirePassword } from '../../utils/accessControl';
import { archiveService } from '../../services/archive.service'; import { archiveService } from '../../services/archive.service';
import { externalMediaService } from '../../services/externalMedia.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 { cssTemplatesService, type EnabledTemplate } from '../../services/cssTemplates.service';
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types'; 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 ExternalFolderPicker: React.FC<{ value: string; onChange: (p: string) => void }> = ({ value, onChange }) => {
const { t } = useTranslation(); const { t } = useTranslation();
const [entries, setEntries] = useState<{ path: string; entries: any[]; canNavigateUp: boolean } | null>(null); const [entries, setEntries] = useState<{ path: string; entries: any[]; canNavigateUp: boolean } | null>(null);
@@ -657,13 +650,15 @@ export const EventDetailsPage: React.FC = () => {
return; return;
} }
const shareUrl = buildShareLinkUrl(event.share_link);
// Try modern clipboard API first // Try modern clipboard API first
if (navigator.clipboard && window.isSecureContext) { if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(event.share_link); await navigator.clipboard.writeText(shareUrl);
} else { } else {
// Fallback for non-HTTPS contexts or older browsers // Fallback for non-HTTPS contexts or older browsers
const textArea = document.createElement('textarea'); const textArea = document.createElement('textarea');
textArea.value = event.share_link; textArea.value = shareUrl;
textArea.style.position = 'fixed'; textArea.style.position = 'fixed';
textArea.style.left = '-999999px'; textArea.style.left = '-999999px';
textArea.style.top = '-999999px'; textArea.style.top = '-999999px';
@@ -793,8 +788,8 @@ export const EventDetailsPage: React.FC = () => {
{event.share_link && !isEditing && ( {event.share_link && !isEditing && (
<a <a
href={event.is_draft href={event.is_draft
? `${resolveShareLink(event.share_link)}${resolveShareLink(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}` ? `${buildShareLinkUrl(event.share_link)}${buildShareLinkUrl(event.share_link).includes('?') ? '&' : '?'}preview=${eventsService.getPreviewToken() || ''}`
: resolveShareLink(event.share_link) : buildShareLinkUrl(event.share_link)
} }
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
@@ -1604,7 +1599,7 @@ export const EventDetailsPage: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={event.share_link} value={buildShareLinkUrl(event.share_link)}
readOnly 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" 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, Trash2,
Calendar, Calendar,
Image, Image,
Activity Activity,
Copy,
CheckCircle
} from 'lucide-react'; } from 'lucide-react';
import { parseISO, differenceInDays } from 'date-fns'; import { parseISO, differenceInDays } from 'date-fns';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
@@ -23,16 +25,10 @@ import { BulkArchiveModal } from '../../components/admin';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { eventsService } from '../../services/events.service'; import { eventsService } from '../../services/events.service';
import { isGalleryPublic } from '../../utils/accessControl'; import { isGalleryPublic } from '../../utils/accessControl';
import { buildShareLinkUrl } from '../../utils/url';
import type { Event } from '../../types'; import type { Event } from '../../types';
import { useTranslation } from 'react-i18next'; 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 = () => { export const EventsListPage: React.FC = () => {
const { t } = useTranslation(); const { t } = useTranslation();
const { format } = useLocalizedDate(); const { format } = useLocalizedDate();
@@ -46,6 +42,35 @@ export const EventsListPage: React.FC = () => {
const [activeDropdown, setActiveDropdown] = useState<number | null>(null); const [activeDropdown, setActiveDropdown] = useState<number | null>(null);
const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null); const [dropdownPosition, setDropdownPosition] = useState<{ top: number; left: number } | null>(null);
const [showBulkArchiveModal, setShowBulkArchiveModal] = useState(false); 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 // Get filter from URL
const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null; const statusFilter = searchParams.get('filter') as 'active' | 'archived' | 'draft' | null;
@@ -489,12 +514,26 @@ export const EventsListPage: React.FC = () => {
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => window.open(resolveShareLink(event.share_link), '_blank')} onClick={() => window.open(buildShareLinkUrl(event.share_link), '_blank')}
title={t('events.viewGallery')} title={t('events.viewGallery')}
> >
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</Button> </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> </div>
{/* Context menu for additional actions */} {/* Context menu for additional actions */}
@@ -539,7 +578,7 @@ export const EventsListPage: React.FC = () => {
</button> </button>
{event.share_link ? ( {event.share_link ? (
<a <a
href={resolveShareLink(event.share_link)} href={buildShareLinkUrl(event.share_link)}
target="_blank" target="_blank"
rel="noopener noreferrer" 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" 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')} {t('events.viewGallery')}
</a> </a>
) : null} ) : 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 ? ( {!event.is_archived ? (
<button <button
onClick={() => { onClick={() => {
@@ -31,6 +31,8 @@ export interface PublicSettings {
event_require_admin_email?: boolean; event_require_admin_email?: boolean;
event_require_event_date?: boolean; event_require_event_date?: boolean;
event_require_expiration?: boolean; event_require_expiration?: boolean;
event_default_require_password?: boolean;
gallery_show_filter_bar?: boolean;
} }
export const publicSettingsService = { export const publicSettingsService = {
+27
View File
@@ -132,3 +132,30 @@ export const isProductionMode = (): boolean => {
const apiBase = getApiBaseUrl(); const apiBase = getApiBaseUrl();
return !ABSOLUTE_URL_REGEX.test(apiBase); 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);
};