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)
|
// 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),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -61,32 +61,43 @@ async function archiveEvent(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
output.on('close', async () => {
|
output.on('close', async () => {
|
||||||
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
try {
|
||||||
|
logger.info(`Archive created: ${archiveName} (${archive.pointer()} bytes)`);
|
||||||
// Update database
|
|
||||||
await db('events').where('id', event.id).update({
|
// Update database
|
||||||
is_archived: true,
|
await db('events').where('id', event.id).update({
|
||||||
archive_path: path.relative(getStoragePath(), archivePath),
|
is_archived: true,
|
||||||
archived_at: new Date()
|
archive_path: path.relative(getStoragePath(), archivePath),
|
||||||
});
|
archived_at: new Date()
|
||||||
|
});
|
||||||
// Delete original files
|
|
||||||
await fs.rm(eventPath, { recursive: true });
|
// Delete original files
|
||||||
|
await fs.rm(eventPath, { recursive: true });
|
||||||
// Delete thumbnails
|
|
||||||
const photos = await db('photos').where('event_id', event.id);
|
// Delete thumbnails
|
||||||
for (const photo of photos) {
|
const photos = await db('photos').where('event_id', event.id);
|
||||||
if (photo.thumbnail_path) {
|
for (const photo of photos) {
|
||||||
const thumbPath = path.join(getStoragePath(), photo.thumbnail_path);
|
if (photo.thumbnail_path) {
|
||||||
await fs.unlink(thumbPath).catch(() => {}); // Ignore if already deleted
|
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);
|
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">
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
? eventTypes.map(et => ({
|
// event type changes" effect below re-runs on every render and silently
|
||||||
value: et.slug_prefix,
|
// overwrites the user's Theme Preset selection (#317).
|
||||||
name: et.name,
|
const availableEventTypes = useMemo(
|
||||||
emoji: et.emoji,
|
() => (eventTypes?.length
|
||||||
theme_preset: et.theme_preset
|
? eventTypes.map(et => ({
|
||||||
}))
|
value: et.slug_prefix,
|
||||||
: FALLBACK_EVENT_TYPES;
|
name: et.name,
|
||||||
|
emoji: et.emoji,
|
||||||
|
theme_preset: et.theme_preset
|
||||||
|
}))
|
||||||
|
: 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
|
||||||
|
|||||||
@@ -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"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user