Fix PicPeak regressions and close #22 #24 #25 #26 #27 #28

This commit is contained in:
2025-09-21 22:03:07 +02:00
parent 39d2244e1e
commit 8611206396
18 changed files with 434 additions and 63 deletions
+22 -4
View File
@@ -150,7 +150,13 @@ export const BrandingPage: React.FC = () => {
try {
const logoUrl = await settingsService.uploadLogo(file);
setBrandingSettings(prev => ({ ...prev, logo_url: logoUrl }));
setCurrentTheme(prev => ({ ...prev, logoUrl }));
setCurrentTheme(prev => {
const updated = { ...prev, logoUrl };
if (isPreviewMode) {
setTheme(updated);
}
return updated;
});
toast.success(t('toast.uploadSuccess'));
} catch (error) {
console.error('Failed to upload logo:', error);
@@ -159,6 +165,17 @@ export const BrandingPage: React.FC = () => {
}
};
const handleRemoveLogo = () => {
setBrandingSettings(prev => ({ ...prev, logo_url: '' }));
setCurrentTheme(prev => {
const updated = { ...prev, logoUrl: '' };
if (isPreviewMode) {
setTheme(updated);
}
return updated;
});
};
const handleWatermarkLogoUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
@@ -351,7 +368,7 @@ export const BrandingPage: React.FC = () => {
/>
<button
type="button"
onClick={() => handleBrandingChange('logo_url', '')}
onClick={handleRemoveLogo}
className="absolute -top-2 -right-2 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center hover:bg-red-600"
>
×
@@ -685,7 +702,8 @@ export const BrandingPage: React.FC = () => {
{t('branding.livePreview')}
</h3>
<GalleryPreview
theme={currentTheme}
theme={currentTheme}
branding={brandingSettings}
className="shadow-lg"
/>
</Card>
@@ -708,4 +726,4 @@ export const BrandingPage: React.FC = () => {
</div>
</ErrorBoundary>
);
};
};
+79 -8
View File
@@ -33,6 +33,13 @@ import { photosService, AdminPhoto, type PhotoFilters as PhotoFilterParams } fro
import { feedbackService, FeedbackSettings as FeedbackSettingsType } from '../../services/feedback.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);
@@ -104,15 +111,29 @@ export const EventDetailsPage: React.FC = () => {
}
}, [id, navigate]);
type EditFormState = {
welcome_message: string;
color_theme: string;
expires_at: string;
allow_user_uploads: boolean;
upload_category_id: number | null;
hero_photo_id: number | null;
host_name: string;
source_mode: 'managed' | 'reference';
external_path: string;
};
const [isEditing, setIsEditing] = useState(false);
const [editForm, setEditForm] = useState({
const [editForm, setEditForm] = useState<EditFormState>({
welcome_message: '',
color_theme: '',
expires_at: '',
allow_user_uploads: false,
upload_category_id: null as number | null,
hero_photo_id: null as number | null,
upload_category_id: null,
hero_photo_id: null,
host_name: '',
source_mode: 'managed',
external_path: '',
});
const [feedbackSettings, setFeedbackSettings] = useState<FeedbackSettingsType>({
feedback_enabled: false,
@@ -251,6 +272,8 @@ export const EventDetailsPage: React.FC = () => {
upload_category_id: event.upload_category_id || null,
hero_photo_id: event.hero_photo_id || null,
host_name: event.host_name || '',
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
external_path: event.external_path || '',
});
// Set feedback settings if available
@@ -298,6 +321,13 @@ export const EventDetailsPage: React.FC = () => {
// Use preset name for non-custom themes
themeToSave = currentPresetName;
}
const externalPathToSave = editForm.external_path?.trim() || '';
if (editForm.source_mode === 'reference' && !externalPathToSave) {
toast.error(t('events.externalFolderRequired', 'Please select an external folder before saving.'));
return;
}
// Clean up the data - remove undefined values
const updateData: any = {
@@ -318,6 +348,10 @@ export const EventDetailsPage: React.FC = () => {
if (editForm.hero_photo_id !== undefined) {
updateData.hero_photo_id = editForm.hero_photo_id;
}
updateData.source_mode = editForm.source_mode;
updateData.external_path = editForm.source_mode === 'reference'
? externalPathToSave
: null;
if (editForm.host_name !== undefined && editForm.host_name !== null) {
updateData.host_name = editForm.host_name;
}
@@ -461,11 +495,7 @@ export const EventDetailsPage: React.FC = () => {
)}
{event.share_link && !isEditing && (
<a
href={
event.share_link.startsWith('http')
? event.share_link
: `/gallery/${event.share_link}`
}
href={resolveShareLink(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm font-medium text-primary-600 hover:text-primary-700 border border-primary-600 rounded-lg hover:bg-primary-50 transition-colors"
@@ -610,6 +640,47 @@ export const EventDetailsPage: React.FC = () => {
onSelect={(photoId) => setEditForm(prev => ({ ...prev, hero_photo_id: photoId }))}
isEditing={isEditing}
/>
<div>
<label className="block text-sm font-medium text-neutral-700 mb-1">
{t('events.sourceMode', 'Source Mode')}
</label>
<select
value={editForm.source_mode}
onChange={(e) => {
const mode = e.target.value as 'managed' | 'reference';
setEditForm(prev => ({
...prev,
source_mode: mode,
external_path: mode === 'reference'
? (prev.external_path || event.external_path || '')
: ''
}));
}}
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
>
<option value="managed">{t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}</option>
<option value="reference">{t('events.sourceModeReference', 'Reference external folder')}</option>
</select>
<p className="text-xs text-neutral-500 mt-1">
{t('events.sourceModeHelp', 'Use managed mode for direct uploads or reference an external folder that is mounted at /external-media in Docker.')}
</p>
</div>
{editForm.source_mode === 'reference' && (
<div className="mt-3">
<label className="block text-sm font-medium text-neutral-700 mb-2">
{t('events.externalFolder', 'External Folder')}
</label>
<ExternalFolderPicker
value={editForm.external_path || ''}
onChange={(folder) => setEditForm(prev => ({ ...prev, external_path: folder }))}
/>
<p className="text-xs text-neutral-500 mt-1">
{t('events.externalFolderHint', 'These folders come from the /external-media mount inside the container. Ensure it is accessible to the backend process.')}
</p>
</div>
)}
<div>
<label className="flex items-center">
+8 -5
View File
@@ -25,6 +25,13 @@ import { eventsService } from '../../services/events.service';
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();
@@ -478,11 +485,7 @@ export const EventsListPage: React.FC = () => {
</button>
{event.share_link ? (
<a
href={
event.share_link.startsWith('http')
? event.share_link
: `/gallery/${event.share_link}`
}
href={resolveShareLink(event.share_link)}
target="_blank"
rel="noopener noreferrer"
className="w-full text-left px-4 py-2 text-sm text-neutral-700 hover:bg-neutral-100 flex items-center gap-2"
+42 -13
View File
@@ -23,6 +23,35 @@ import { useTranslation } from 'react-i18next';
const BYTES_PER_GB = 1024 * 1024 * 1024;
const toBoolean = (value: unknown, defaultValue = false): boolean => {
if (value === undefined || value === null) {
return defaultValue;
}
if (typeof value === 'boolean') {
return value;
}
if (typeof value === 'number') {
if (Number.isNaN(value)) return defaultValue;
return value !== 0;
}
if (typeof value === 'string') {
const normalized = value.toLowerCase().trim();
if (normalized === 'true' || normalized === '1') return true;
if (normalized === 'false' || normalized === '0') return false;
if (normalized === '') return defaultValue;
return Boolean(normalized);
}
return defaultValue;
};
const toNumber = (value: unknown, defaultValue: number): number => {
if (value === undefined || value === null || value === '') {
return defaultValue;
}
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : defaultValue;
};
export const SettingsPage: React.FC = () => {
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
const queryClient = useQueryClient();
@@ -100,13 +129,13 @@ export const SettingsPage: React.FC = () => {
// Extract general settings
setGeneralSettings({
site_url: settings.general_site_url || '',
default_expiration_days: settings.general_default_expiration_days || 30,
max_file_size_mb: settings.general_max_file_size_mb || 50,
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
enable_watermark: settings.general_enable_watermark || false,
enable_analytics: settings.general_enable_analytics || true,
enable_registration: settings.general_enable_registration || false,
maintenance_mode: settings.general_maintenance_mode || false,
enable_watermark: toBoolean(settings.general_enable_watermark, false),
enable_analytics: toBoolean(settings.general_enable_analytics, true),
enable_registration: toBoolean(settings.general_enable_registration, false),
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
default_language: settings.general_default_language || 'en',
date_format: settings.general_date_format
? (typeof settings.general_date_format === 'string'
@@ -117,20 +146,20 @@ export const SettingsPage: React.FC = () => {
// Extract security settings
setSecuritySettings({
require_password: settings.security_require_password ?? true,
password_min_length: settings.security_password_min_length ?? 8,
require_password: toBoolean(settings.security_require_password, true),
password_min_length: toNumber(settings.security_password_min_length, 8),
password_complexity: settings.security_password_complexity ?? 'moderate',
enable_2fa: settings.security_enable_2fa ?? false,
session_timeout_minutes: settings.security_session_timeout_minutes ?? 60,
max_login_attempts: settings.security_max_login_attempts ?? 5,
enable_recaptcha: settings.security_enable_recaptcha ?? false,
enable_2fa: toBoolean(settings.security_enable_2fa, false),
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
});
// Extract analytics settings
setAnalyticsSettings({
umami_enabled: settings.analytics_umami_enabled || false,
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
umami_url: settings.analytics_umami_url || '',
umami_website_id: settings.analytics_umami_website_id || '',
umami_share_url: settings.analytics_umami_share_url || ''