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:
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -61,32 +61,43 @@ async function archiveEvent(event) {
|
||||
}
|
||||
|
||||
output.on('close', async () => {
|
||||
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
||||
|
||||
// Update database
|
||||
await db('events').where('id', event.id).update({
|
||||
is_archived: true,
|
||||
archive_path: path.relative(getStoragePath(), archivePath),
|
||||
archived_at: new Date()
|
||||
});
|
||||
|
||||
// Delete original files
|
||||
await fs.rm(eventPath, { recursive: true });
|
||||
|
||||
// Delete thumbnails
|
||||
const photos = await db('photos').where('event_id', event.id);
|
||||
for (const photo of photos) {
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted
|
||||
try {
|
||||
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
||||
|
||||
// Update database
|
||||
await db('events').where('id', event.id).update({
|
||||
is_archived: true,
|
||||
archive_path: path.relative(getStoragePath(), archivePath),
|
||||
archived_at: new Date()
|
||||
});
|
||||
|
||||
// Delete original files
|
||||
await fs.rm(eventPath, { recursive: true });
|
||||
|
||||
// Delete thumbnails
|
||||
const photos = await db('photos').where('event_id', event.id);
|
||||
for (const photo of photos) {
|
||||
if (photo.thumbnail_path) {
|
||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
||||
await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted
|
||||
}
|
||||
}
|
||||
|
||||
// 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', {
|
||||
event_name: event.event_name,
|
||||
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);
|
||||
}
|
||||
|
||||
// Queue completion email
|
||||
await queueEmail(event.id, event.admin_email, 'archive_complete', {
|
||||
event_name: event.event_name,
|
||||
archive_size: (archive.pointer() / 1024 / 1024).toFixed(2) + ' MB'
|
||||
});
|
||||
});
|
||||
|
||||
archive.pipe(output);
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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={() => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user