Merge remote-tracking branch 'upstream/main'
This commit is contained in:
@@ -109,8 +109,11 @@ export const AdminPhotoViewer: React.FC<AdminPhotoViewerProps> = ({
|
||||
await photosService.updatePhotoCategory(eventId, currentPhoto.id, categoryId);
|
||||
toast.success('Category updated');
|
||||
setShowCategoryMenu(false);
|
||||
// Trigger refresh to update the photo data
|
||||
onPhotoDeleted(); // This will refresh the photos list
|
||||
// Invalidate photos query to refresh data
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId.toString()] });
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-event-photos', eventId] });
|
||||
// Also trigger the parent's refresh callback
|
||||
onPhotoDeleted();
|
||||
} catch (error) {
|
||||
toast.error('Failed to update category');
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import { api } from '../../config/api';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { categoriesService } from '../../services/categories.service';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PhotoUploadProps {
|
||||
@@ -13,6 +14,9 @@ interface PhotoUploadProps {
|
||||
onUploadComplete?: () => void;
|
||||
}
|
||||
|
||||
const DEFAULT_MAX_FILES_PER_UPLOAD = 500;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadComplete }) => {
|
||||
const { t } = useTranslation();
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
@@ -29,6 +33,22 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
queryFn: () => categoriesService.getEventCategories(eventId),
|
||||
});
|
||||
|
||||
const { data: settings } = useQuery({
|
||||
queryKey: ['admin-settings'],
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const maxFilesPerUpload = React.useMemo(() => {
|
||||
const rawValue = settings?.general_max_files_per_upload;
|
||||
const parsed = Number(rawValue);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return DEFAULT_MAX_FILES_PER_UPLOAD;
|
||||
}
|
||||
return Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, Math.floor(parsed)));
|
||||
}, [settings]);
|
||||
|
||||
const remainingSlots = Math.max(maxFilesPerUpload - selectedFiles.length, 0);
|
||||
|
||||
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
const imageFiles = files.filter(file =>
|
||||
@@ -37,13 +57,19 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
|
||||
// Check total file count with existing files
|
||||
const totalFiles = selectedFiles.length + imageFiles.length;
|
||||
if (totalFiles > 500) {
|
||||
const allowedNewFiles = 500 - selectedFiles.length;
|
||||
if (totalFiles > maxFilesPerUpload) {
|
||||
const allowedNewFiles = maxFilesPerUpload - selectedFiles.length;
|
||||
if (allowedNewFiles <= 0) {
|
||||
toast.error(t('upload.maxFilesReached') || 'Maximum 500 files allowed');
|
||||
toast.error(
|
||||
t('upload.maxFilesReached', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files allowed`
|
||||
);
|
||||
return;
|
||||
}
|
||||
toast.warning(t('upload.someFilesSkipped') || `Only ${allowedNewFiles} more files can be added (500 max)`);
|
||||
toast.warning(
|
||||
t('upload.someFilesSkipped', { allowed: allowedNewFiles, limit: maxFilesPerUpload }) ||
|
||||
`Only ${allowedNewFiles} more files can be added (limit ${maxFilesPerUpload})`
|
||||
);
|
||||
setSelectedFiles(prev => [...prev, ...imageFiles.slice(0, allowedNewFiles)]);
|
||||
return;
|
||||
}
|
||||
@@ -59,8 +85,11 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
if (selectedFiles.length === 0) return;
|
||||
|
||||
// Validate file count
|
||||
if (selectedFiles.length > 500) {
|
||||
toast.error(t('upload.tooManyFiles') || 'Maximum 500 files can be uploaded at once');
|
||||
if (selectedFiles.length > maxFilesPerUpload) {
|
||||
toast.error(
|
||||
t('upload.tooManyFiles', { limit: maxFilesPerUpload }) ||
|
||||
`Maximum ${maxFilesPerUpload} files can be uploaded at once`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -68,7 +97,7 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
setUploadProgress(0);
|
||||
|
||||
// For large uploads, chunk the files to prevent memory issues
|
||||
const CHUNK_SIZE = 50; // Upload 50 files at a time
|
||||
const CHUNK_SIZE = Math.max(1, Math.min(50, maxFilesPerUpload)); // Upload up to 50 (or limit) files at a time
|
||||
const chunks = [];
|
||||
|
||||
for (let i = 0; i < selectedFiles.length; i += CHUNK_SIZE) {
|
||||
@@ -187,7 +216,21 @@ export const PhotoUpload: React.FC<PhotoUploadProps> = ({ eventId, onUploadCompl
|
||||
{t('upload.clickToUpload')}
|
||||
</p>
|
||||
<p className="text-sm text-neutral-500">
|
||||
{t('upload.fileRequirements')}
|
||||
{t('upload.fileRequirements', { limit: maxFilesPerUpload })}
|
||||
</p>
|
||||
<p
|
||||
className={clsx(
|
||||
"text-xs mt-2",
|
||||
remainingSlots === 0 ? "text-red-600" : "text-neutral-500"
|
||||
)}
|
||||
>
|
||||
{remainingSlots === 0
|
||||
? t('upload.limitReached', { limit: maxFilesPerUpload })
|
||||
: t('upload.limitInfo', {
|
||||
selected: selectedFiles.length,
|
||||
limit: maxFilesPerUpload,
|
||||
remaining: remainingSlots,
|
||||
})}
|
||||
</p>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
|
||||
@@ -1,5 +1,11 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { buildResourceUrl } from '../../utils/url';
|
||||
import {
|
||||
getActiveGallerySlug,
|
||||
getGalleryToken,
|
||||
inferGallerySlugFromLocation,
|
||||
resolveSlugFromRequestUrl,
|
||||
} from '../../utils/galleryAuthStorage';
|
||||
|
||||
interface AuthenticatedImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
|
||||
src: string;
|
||||
@@ -52,7 +58,6 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
}) => {
|
||||
const unusedProps = {
|
||||
protectFromDownload,
|
||||
slug,
|
||||
photoId,
|
||||
requiresToken,
|
||||
secureUrlTemplate,
|
||||
@@ -76,7 +81,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let objectUrl: string | null = null;
|
||||
let aborted = false;
|
||||
const objectUrls: string[] = [];
|
||||
|
||||
// Determine which token to use based on context
|
||||
if (!src) {
|
||||
@@ -88,37 +94,79 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
setIsLoading(true);
|
||||
setError(false);
|
||||
|
||||
// Create a new URL with auth header
|
||||
const resolveSlug = (candidateSrc?: string): string | null => {
|
||||
if (slug) {
|
||||
return slug;
|
||||
}
|
||||
const fromUrl = candidateSrc ? resolveSlugFromRequestUrl(candidateSrc) : null;
|
||||
if (fromUrl) {
|
||||
return fromUrl;
|
||||
}
|
||||
return getActiveGallerySlug() || inferGallerySlugFromLocation();
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (rawUrl: string | undefined | null): Promise<string> => {
|
||||
if (!rawUrl) {
|
||||
throw new Error('No URL provided');
|
||||
}
|
||||
|
||||
// Build full URL for the image
|
||||
const fullImageUrl = rawUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${rawUrl}`)
|
||||
: rawUrl.startsWith('/')
|
||||
? buildResourceUrl(rawUrl)
|
||||
: rawUrl;
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
const slugForRequest = resolveSlug(rawUrl);
|
||||
const token = getGalleryToken(slugForRequest);
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include',
|
||||
headers: Object.keys(headers).length ? headers : undefined,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const objectUrl = URL.createObjectURL(blob);
|
||||
objectUrls.push(objectUrl);
|
||||
return objectUrl;
|
||||
};
|
||||
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
|
||||
// Build full URL for the image
|
||||
// For API paths that start with /admin, we need to prepend /api
|
||||
const fullImageUrl = imageUrl.startsWith('/admin')
|
||||
? buildResourceUrl(`/api${imageUrl}`)
|
||||
: imageUrl.startsWith('/')
|
||||
? buildResourceUrl(imageUrl)
|
||||
: imageUrl;
|
||||
|
||||
// Fetch authenticated image
|
||||
const response = await fetch(fullImageUrl, {
|
||||
credentials: 'include'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch image: ${response.status} ${response.statusText}`);
|
||||
const primaryUrl = await fetchWithAuth(src);
|
||||
if (!aborted) {
|
||||
setImageSrc(primaryUrl);
|
||||
setError(false);
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
setImageSrc(objectUrl);
|
||||
setIsLoading(false);
|
||||
} catch (err) {
|
||||
// Image loading failed - use fallback
|
||||
setError(true);
|
||||
setImageSrc(fallbackSrc || '');
|
||||
setIsLoading(false);
|
||||
if (fallbackSrc && fallbackSrc !== src) {
|
||||
try {
|
||||
const fallbackUrl = await fetchWithAuth(fallbackSrc);
|
||||
if (!aborted) {
|
||||
setImageSrc(fallbackUrl);
|
||||
setError(false);
|
||||
}
|
||||
return;
|
||||
} catch (fallbackError) {
|
||||
// Swallow and mark error below
|
||||
}
|
||||
}
|
||||
if (!aborted) {
|
||||
setError(true);
|
||||
setImageSrc('');
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!aborted) {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
@@ -127,11 +175,11 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
|
||||
// Cleanup function
|
||||
return () => {
|
||||
if (objectUrl) {
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
}
|
||||
aborted = true;
|
||||
objectUrls.forEach((url) => URL.revokeObjectURL(url));
|
||||
};
|
||||
}, [src, fallbackSrc, useWatermark, isGallery]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [src, fallbackSrc, slug]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
|
||||
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
|
||||
logo_display_header?: boolean;
|
||||
logo_display_hero?: boolean;
|
||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||
hide_powered_by?: boolean;
|
||||
};
|
||||
showLogout?: boolean;
|
||||
onLogout?: () => void;
|
||||
@@ -438,7 +439,10 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
</p>
|
||||
)}
|
||||
<p className="text-xs sm:text-sm text-neutral-500">
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'} | Powered by <span className="font-semibold">PicPeak</span>
|
||||
{brandingSettings?.footer_text || '© 2024 Your Company. All rights reserved.'}
|
||||
{!brandingSettings?.hide_powered_by && (
|
||||
<> | Powered by <span className="font-semibold">PicPeak</span></>
|
||||
)}
|
||||
</p>
|
||||
{brandingSettings?.company_name && brandingSettings?.company_tagline && (
|
||||
<p className="text-xs text-neutral-400 mt-2">
|
||||
|
||||
@@ -71,8 +71,9 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
setGuestId(storedGuestId);
|
||||
}, []);
|
||||
|
||||
// Fetch photos with filter support
|
||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, filterType, guestId);
|
||||
// Fetch photos WITHOUT filter (always get all photos, filter on frontend)
|
||||
// This ensures counts are always calculated from the full dataset
|
||||
const { data, isLoading, error, refetch } = useGalleryPhotos(slug, 'all', guestId);
|
||||
|
||||
// Set protection level when data is available
|
||||
useEffect(() => {
|
||||
@@ -164,6 +165,13 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
footer_text: settingsData.branding_footer_text || '© 2024 Your Company. All rights reserved.',
|
||||
watermark_enabled: settingsData.branding_watermark_enabled || false,
|
||||
logo_url: settingsData.branding_logo_url || null,
|
||||
logo_size: settingsData.branding_logo_size || 'medium',
|
||||
logo_max_height: settingsData.branding_logo_max_height || 48,
|
||||
logo_position: settingsData.branding_logo_position || 'left',
|
||||
logo_display_header: settingsData.branding_logo_display_header !== false,
|
||||
logo_display_hero: settingsData.branding_logo_display_hero !== false,
|
||||
logo_display_mode: settingsData.branding_logo_display_mode || 'logo_and_text',
|
||||
hide_powered_by: settingsData.branding_hide_powered_by === true,
|
||||
});
|
||||
}
|
||||
}, [settingsData]);
|
||||
|
||||
@@ -231,7 +231,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{showFeedbackActions && onQuickComment && (
|
||||
{showFeedbackActions && feedbackOptions?.allowComments && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => {
|
||||
|
||||
@@ -168,23 +168,26 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Scroll Indicator */}
|
||||
<div className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleScrollToGrid}
|
||||
className="rounded-full border border-white/30 bg-white/10 p-3 text-white transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 hover:bg-white/20"
|
||||
aria-label={t('gallery.scrollToGallery', 'Scroll to gallery')}
|
||||
>
|
||||
<ChevronDown className="w-8 h-8 drop-shadow-lg" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
// Scroll to the grid section
|
||||
const gridSection = document.getElementById('gallery-grid-section');
|
||||
if (gridSection) {
|
||||
gridSection.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
} else {
|
||||
// Fallback: scroll down by hero section height
|
||||
window.scrollBy({ top: window.innerHeight * 0.9, behavior: 'smooth' });
|
||||
}
|
||||
}}
|
||||
className="absolute bottom-8 left-1/2 transform -translate-x-1/2 animate-bounce cursor-pointer hover:scale-110 transition-transform focus:outline-none focus:ring-2 focus:ring-white focus:ring-opacity-50 rounded-full p-2"
|
||||
aria-label="Scroll to gallery"
|
||||
>
|
||||
<ChevronDown className="w-8 h-8 text-white drop-shadow-lg" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Grid Section */}
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4"
|
||||
>
|
||||
<div id="gallery-grid-section" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||
{remainingPhotos.map((photo) => {
|
||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||
return (
|
||||
|
||||
@@ -117,7 +117,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
<Download className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{onQuickComment && (
|
||||
{feedbackEnabled && feedbackOptions?.allowComments && onQuickComment && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={(e) => { e.stopPropagation(); onQuickComment(); }}
|
||||
@@ -127,7 +127,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
<MessageSquare className="w-5 h-5 text-neutral-800" />
|
||||
</button>
|
||||
)}
|
||||
{feedbackOptions?.allowLikes && (
|
||||
{feedbackEnabled && feedbackOptions?.allowLikes && (
|
||||
<button
|
||||
className="p-2 bg-white/90 rounded-full hover:bg-white transition-colors"
|
||||
onClick={async (e) => {
|
||||
|
||||
@@ -13,7 +13,7 @@ interface AdminAuthContextType {
|
||||
error: string | null;
|
||||
mustChangePassword: boolean;
|
||||
updatePasswordChanged: () => void;
|
||||
updateProfile: (user: AdminUser) => void;
|
||||
updateUserProfile: (updates: Partial<AdminUser>) => void;
|
||||
}
|
||||
|
||||
const AdminAuthContext = createContext<AdminAuthContextType | undefined>(undefined);
|
||||
@@ -105,9 +105,15 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
||||
}
|
||||
};
|
||||
|
||||
const updateProfile = (updatedUser: AdminUser) => {
|
||||
setUser(updatedUser);
|
||||
sessionStorage.setItem('admin_user', JSON.stringify(updatedUser));
|
||||
const updateUserProfile = (updates: Partial<AdminUser>) => {
|
||||
setUser((prev) => {
|
||||
if (!prev) {
|
||||
return prev;
|
||||
}
|
||||
const nextUser = { ...prev, ...updates };
|
||||
sessionStorage.setItem('admin_user', JSON.stringify(nextUser));
|
||||
return nextUser;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -121,7 +127,7 @@ export const AdminAuthProvider: React.FC<AdminAuthProviderProps> = ({ children }
|
||||
error,
|
||||
mustChangePassword,
|
||||
updatePasswordChanged,
|
||||
updateProfile,
|
||||
updateUserProfile,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../config/api';
|
||||
import { authService, galleryService } from '../services';
|
||||
import { cleanupOldGalleryAuth } from '../utils/cleanupGalleryAuth';
|
||||
@@ -61,52 +62,133 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
const [event, setEvent] = useState<GalleryEvent | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Get current gallery slug from URL
|
||||
const getCurrentGallerySlug = () => {
|
||||
const pathParts = window.location.pathname.split('/');
|
||||
if (pathParts[1] === 'gallery' && pathParts[2]) {
|
||||
return pathParts[2];
|
||||
}
|
||||
return null;
|
||||
};
|
||||
const [routeError, setRouteError] = useState<string | null>(null);
|
||||
const location = useLocation();
|
||||
const [routeInfo, setRouteInfo] = useState<{ slug: string | null; token?: string; identifier: string | null; ready: boolean }>({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier: null,
|
||||
ready: false,
|
||||
});
|
||||
const lastResolvedIdentifier = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
cleanupOldGalleryAuth();
|
||||
}, []);
|
||||
|
||||
const slugAtMount = getCurrentGallerySlug();
|
||||
if (slugAtMount) {
|
||||
setActiveGallerySlug(slugAtMount);
|
||||
} else {
|
||||
clearActiveGallerySlug();
|
||||
}
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const initialise = async () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const parseRoute = async () => {
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
|
||||
if (!currentSlug) {
|
||||
setIsLoading(false);
|
||||
if (segments[0] !== 'gallery') {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
setRouteError(null);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setActiveGallerySlug(currentSlug);
|
||||
const identifier = segments[1] || null;
|
||||
const tokenSegment = segments[2];
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
if (!identifier) {
|
||||
if (!cancelled) {
|
||||
setRouteInfo({ slug: null, token: undefined, identifier: null, ready: true });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const looksLikeToken = /^[0-9a-fA-F]{32}$/.test(identifier) && !tokenSegment;
|
||||
|
||||
if (looksLikeToken) {
|
||||
if (lastResolvedIdentifier.current === identifier) {
|
||||
setRouteInfo(prev => ({
|
||||
slug: prev.slug,
|
||||
token: prev.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
}));
|
||||
setRouteError(null);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolved = await galleryService.resolveIdentifier(identifier);
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: resolved.slug,
|
||||
token: resolved.token,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
} catch (err: any) {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = identifier;
|
||||
setRouteInfo({
|
||||
slug: null,
|
||||
token: undefined,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(err?.response?.data?.error || 'Unable to resolve gallery link');
|
||||
}
|
||||
} else {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setRouteInfo({
|
||||
slug: identifier,
|
||||
token: tokenSegment,
|
||||
identifier,
|
||||
ready: true,
|
||||
});
|
||||
setRouteError(null);
|
||||
}
|
||||
};
|
||||
|
||||
setRouteInfo(prev => ({ ...prev, ready: false }));
|
||||
parseRoute();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [location.pathname]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!routeInfo.ready) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!routeInfo.slug) {
|
||||
clearActiveGallerySlug();
|
||||
setIsAuthenticated(false);
|
||||
setEvent(null);
|
||||
setIsLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const currentSlug = routeInfo.slug;
|
||||
setActiveGallerySlug(currentSlug);
|
||||
|
||||
const storedEvent = sessionStorage.getItem(`gallery_event_${currentSlug}`);
|
||||
if (storedEvent) {
|
||||
try {
|
||||
const parsed = JSON.parse(storedEvent);
|
||||
if (parsed && parsed.id) {
|
||||
const normalizedStored = normalizeEvent(parsed);
|
||||
setEvent(normalizedStored);
|
||||
if (normalizedStored) {
|
||||
sessionStorage.setItem(`gallery_event_${currentSlug}`, JSON.stringify(normalizedStored));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
}
|
||||
}
|
||||
|
||||
const initialise = async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const sessionResponse = await api.get<{ valid: boolean; type: string; eventSlug?: string }>(
|
||||
@@ -118,7 +200,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
setIsAuthenticated(true);
|
||||
|
||||
if (!storedEvent) {
|
||||
// Fetch gallery details to hydrate context
|
||||
const galleryData = await galleryService.getGalleryPhotos(currentSlug);
|
||||
if (galleryData?.event) {
|
||||
const normalizedEvent = normalizeEvent(galleryData.event);
|
||||
@@ -132,14 +213,10 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
return;
|
||||
}
|
||||
|
||||
// If no active session, check for share token in URL
|
||||
const parts = window.location.pathname.split('/');
|
||||
const urlToken = parts.length >= 5 ? parts[4] : (parts.length >= 4 ? parts[3] : undefined);
|
||||
|
||||
if (urlToken) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, urlToken);
|
||||
if (routeInfo.token) {
|
||||
const verify = await galleryService.verifyToken(currentSlug, routeInfo.token);
|
||||
if (verify?.valid) {
|
||||
const response = await authService.shareLinkLogin(currentSlug, urlToken);
|
||||
const response = await authService.shareLinkLogin(currentSlug, routeInfo.token);
|
||||
if (response?.event) {
|
||||
const normalizedEvent = normalizeEvent(response.event);
|
||||
setEvent(normalizedEvent);
|
||||
@@ -156,29 +233,33 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
}
|
||||
|
||||
// No valid session found
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
} catch (error) {
|
||||
} catch (initialiseError: any) {
|
||||
setIsAuthenticated(false);
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
setEvent(null);
|
||||
clearGalleryToken(currentSlug);
|
||||
if (initialiseError?.response?.data?.error) {
|
||||
setError(initialiseError.response.data.error);
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
initialise();
|
||||
|
||||
return () => {
|
||||
clearActiveGallerySlug();
|
||||
};
|
||||
}, []);
|
||||
}, [routeInfo]);
|
||||
|
||||
const login = async (slug: string, password?: string, recaptchaToken?: string | null) => {
|
||||
try {
|
||||
setRouteError(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
const response = await authService.verifyGalleryPassword(slug, password, recaptchaToken);
|
||||
@@ -190,7 +271,6 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
}
|
||||
setActiveGallerySlug(slug);
|
||||
|
||||
// Store event data for quick reloads (non-sensitive)
|
||||
if (normalizedEvent) {
|
||||
sessionStorage.setItem(`gallery_event_${slug}`, JSON.stringify(normalizedEvent));
|
||||
}
|
||||
@@ -203,7 +283,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
};
|
||||
|
||||
const logout = () => {
|
||||
const currentSlug = getCurrentGallerySlug();
|
||||
const currentSlug = routeInfo.slug;
|
||||
if (currentSlug) {
|
||||
sessionStorage.removeItem(`gallery_event_${currentSlug}`);
|
||||
clearGalleryToken(currentSlug);
|
||||
@@ -222,7 +302,7 @@ export const GalleryAuthProvider: React.FC<GalleryAuthProviderProps> = ({ childr
|
||||
login,
|
||||
logout,
|
||||
isLoading,
|
||||
error,
|
||||
error: routeError ?? error,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
|
||||
@@ -2,12 +2,18 @@ import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { galleryService } from '../services';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
export const useGalleryInfo = (slug: string, token?: string) => {
|
||||
export const useGalleryInfo = (slug?: string, token?: string, enabled: boolean = true) => {
|
||||
return useQuery({
|
||||
queryKey: ['gallery-info', slug, token],
|
||||
queryFn: () => galleryService.getGalleryInfo(slug, token),
|
||||
queryFn: () => {
|
||||
if (!slug) {
|
||||
throw new Error('Gallery slug is required');
|
||||
}
|
||||
return galleryService.getGalleryInfo(slug, token);
|
||||
},
|
||||
retry: 1,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
enabled: Boolean(slug) && enabled,
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "Keine Kategorie",
|
||||
"eventSpecific": "(Veranstaltungsspezifisch)",
|
||||
"clickToUpload": "Klicken zum Hochladen oder per Drag & Drop",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei)",
|
||||
"fileRequirements": "JPEG, PNG oder WebP (max. 50MB pro Datei, {{limit}} Dateien pro Upload)",
|
||||
"selectedFiles": "Ausgewählte Dateien",
|
||||
"uploading": "Wird hochgeladen...",
|
||||
"uploadComplete": "Upload abgeschlossen!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "Alle Bilder aus dem ausgewählten Ordner werden importiert.",
|
||||
"selectExternalFolder": "Externen Ordner unter /external-media auswählen",
|
||||
"importFromSelectedFolder": "Ausgewählten Ordner importieren",
|
||||
"maxFilesReached": "Maximal 500 Dateien erlaubt",
|
||||
"someFilesSkipped": "Einige Dateien wurden übersprungen (500 Dateien Limit)",
|
||||
"tooManyFiles": "Maximal 500 Dateien können gleichzeitig hochgeladen werden",
|
||||
"maxFilesReached": "Maximal {{limit}} Dateien erlaubt",
|
||||
"someFilesSkipped": "Nur {{allowed}} weitere Dateien erlaubt (Limit {{limit}})",
|
||||
"tooManyFiles": "Maximal {{limit}} Dateien können gleichzeitig hochgeladen werden",
|
||||
"limitInfo": "{{selected}} von {{limit}} Dateien ausgewählt ({{remaining}} verbleibend)",
|
||||
"limitReached": "Upload-Limit erreicht ({{limit}} Dateien pro Vorgang)",
|
||||
"uploadingChunks": "Lade {{count}} Dateien in {{total}} Teilen hoch..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -566,8 +568,8 @@
|
||||
"eventName": "Veranstaltungsname",
|
||||
"eventType": "Veranstaltungstyp",
|
||||
"eventDate": "Veranstaltungsdatum",
|
||||
"hostEmail": "Gastgeber-E-Mail",
|
||||
"hostName": "Name des Gastgebers",
|
||||
"hostEmail": "E-Mail des Kunden",
|
||||
"hostName": "Name des Kunden",
|
||||
"hostNamePlaceholder": "Max Mustermann",
|
||||
"adminEmail": "Admin-E-Mail",
|
||||
"expirationDate": "Ablaufdatum",
|
||||
@@ -589,8 +591,8 @@
|
||||
"eventExpired": "Diese Veranstaltung ist abgelaufen",
|
||||
"eventExpiresIn": "Diese Veranstaltung läuft in {{days}} Tagen ab",
|
||||
"guestsNoAccess": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
||||
"warningEmailsSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Gastgeber gesendet.",
|
||||
"warningEmailsSent": "Warn-E-Mails wurden an den Kunden gesendet.",
|
||||
"warningEmailsHaveBeenSent": "Warn-E-Mails wurden an den Kunden gesendet.",
|
||||
"extendSevenDays": "Um 7 Tage verlängern",
|
||||
"overview": "Übersicht",
|
||||
"photos": "Fotos",
|
||||
@@ -631,7 +633,7 @@
|
||||
"organizeCategoriesInfo": "Organisieren Sie Ihre Fotos in Kategorien. Kategorien helfen Gästen, bestimmte Fototypen zu navigieren und zu finden.",
|
||||
"categoriesTip": "Tipp: Kategorien sind spezifisch für jede Veranstaltung. Sie können auch globale Kategorien in den Einstellungen erstellen.",
|
||||
"contactInformation": "Kontaktinformationen",
|
||||
"hostEmailHelp": "Erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
|
||||
"hostEmailHelp": "Der Kunde erhält Benachrichtigungen zur Galerie-Erstellung und zum Ablauf",
|
||||
"adminEmailHelp": "Erhält Systembenachrichtigungen und Archivbestätigungen",
|
||||
"securityAccess": "Sicherheit & Zugriff",
|
||||
"galleryPassword": "Galerie-Passwort",
|
||||
@@ -686,7 +688,7 @@
|
||||
"eventNamePlaceholder": "z.B. Max & Maria's Hochzeit",
|
||||
"welcomeMessageOptional": "Willkommensnachricht (Optional)",
|
||||
"welcomeMessagePlaceholder": "Willkommen zu unserem besonderen Tag! Laden Sie diese Erinnerungen gerne herunter und teilen Sie sie...",
|
||||
"hostEmailPlaceholder": "gastgeber@beispiel.de",
|
||||
"hostEmailPlaceholder": "kunde@beispiel.de",
|
||||
"adminEmailPlaceholder": "admin@beispiel.de",
|
||||
"securityAndAccess": "Sicherheit & Zugriff",
|
||||
"accessAndSecurity": "Zugriff & Sicherheit",
|
||||
@@ -773,12 +775,16 @@
|
||||
"defaultExpirationHelp": "Wie lange Galerien standardmäßig aktiv bleiben",
|
||||
"maxFileSize": "Max. Dateigröße (MB)",
|
||||
"maxFileSizeHelp": "Maximale Größe pro hochgeladenem Foto",
|
||||
"maxFilesPerUpload": "Max. Dateien pro Upload",
|
||||
"maxFilesPerUploadHelp": "Maximale Anzahl an Fotos pro Upload-Vorgang (1-{{max}}).",
|
||||
"allowedFileTypes": "Erlaubte Dateitypen",
|
||||
"allowedFileTypesHelp": "Kommagetrennte Liste von Dateierweiterungen",
|
||||
"featureToggles": "Funktionsschalter",
|
||||
"enableWatermark": "Wasserzeichen auf Fotos aktivieren",
|
||||
"enableAnalytics": "Analytics-Tracking aktivieren",
|
||||
"enableRegistration": "Selbstregistrierung für Admins erlauben",
|
||||
"enableShortGalleryUrls": "Kurze Galerie-Links verwenden",
|
||||
"enableShortGalleryUrlsHelp": "Entfernt den Veranstaltungs-Slug aus neuen Freigabelinks und lässt bestehende Links weiterhin funktionieren.",
|
||||
"maintenanceMode": "Wartungsmodus aktivieren",
|
||||
"language": "Sprache",
|
||||
"defaultLanguage": "Standardsprache",
|
||||
@@ -791,7 +797,18 @@
|
||||
"saveGeneralSettings": "Allgemeine Einstellungen speichern",
|
||||
"dateTimeFormat": "Datums- & Zeitformat",
|
||||
"dateFormat": "Datumsformat",
|
||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden"
|
||||
"dateFormatHelp": "Wie Daten in E-Mails und in der gesamten Anwendung angezeigt werden",
|
||||
"accountSection": "Admin-Konto",
|
||||
"accountUsername": "Admin-Benutzername",
|
||||
"accountUsernameHelp": "Wird im Admin-Bereich angezeigt und in Aktivitätsprotokollen verwendet.",
|
||||
"accountUsernameRequired": "Benutzername ist erforderlich",
|
||||
"accountUsernameLength": "Benutzername muss mindestens 3 Zeichen lang sein",
|
||||
"accountEmail": "Admin-E-Mail",
|
||||
"accountEmailHelp": "Wird für die Anmeldung und für Sicherheitsbenachrichtigungen verwendet.",
|
||||
"accountEmailRequired": "E-Mail-Adresse ist erforderlich",
|
||||
"accountEmailInvalid": "Bitte eine gültige E-Mail-Adresse eingeben",
|
||||
"accountSaveButton": "Kontodaten speichern",
|
||||
"accountSaveSuccess": "Kontodaten aktualisiert"
|
||||
},
|
||||
"publicSite": {
|
||||
"tabLabel": "Öffentliche Seite",
|
||||
@@ -875,7 +892,11 @@
|
||||
"sessionTimeout": "Sitzungs-Timeout (Minuten)",
|
||||
"sessionTimeoutHelp": "Admin-Sitzungs-Timeout in Minuten",
|
||||
"maxLoginAttempts": "Max. Anmeldeversuche",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche vor Sperrung",
|
||||
"maxLoginAttemptsHelp": "Maximale fehlgeschlagene Anmeldeversuche pro IP vor Sperrung",
|
||||
"attemptWindowMinutes": "Versuchsfenster (Minuten)",
|
||||
"attemptWindowMinutesHelp": "Zeitraum, in dem fehlgeschlagene Anmeldeversuche gezählt werden",
|
||||
"lockoutDurationMinutes": "Sperrdauer (Minuten)",
|
||||
"lockoutDurationMinutesHelp": "Wie lange Galerie oder Konto nach zu vielen Fehlern gesperrt bleiben",
|
||||
"enable2FA": "Zwei-Faktor-Authentifizierung für Admins aktivieren",
|
||||
"recaptchaSettings": "reCAPTCHA-Einstellungen",
|
||||
"enableRecaptcha": "reCAPTCHA für Anmeldeformulare aktivieren",
|
||||
@@ -1362,8 +1383,8 @@
|
||||
},
|
||||
"validation": {
|
||||
"eventNameRequired": "Veranstaltungsname ist erforderlich",
|
||||
"hostEmailRequired": "Gastgeber-E-Mail ist erforderlich",
|
||||
"hostNameRequired": "Der Name des Gastgebers ist erforderlich",
|
||||
"hostEmailRequired": "Die E-Mail des Kunden ist erforderlich",
|
||||
"hostNameRequired": "Der Name des Kunden ist erforderlich",
|
||||
"adminEmailRequired": "Admin-E-Mail ist erforderlich",
|
||||
"invalidEmailFormat": "Ungültiges E-Mail-Format",
|
||||
"passwordRequired": "Passwort ist erforderlich",
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
"noCategory": "No category",
|
||||
"eventSpecific": "(Event specific)",
|
||||
"clickToUpload": "Click to upload or drag and drop",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file)",
|
||||
"fileRequirements": "JPEG, PNG or WebP (max 50MB per file, {{limit}} files per upload)",
|
||||
"selectedFiles": "Selected files",
|
||||
"uploading": "Uploading...",
|
||||
"uploadComplete": "Upload complete!",
|
||||
@@ -59,9 +59,11 @@
|
||||
"externalImportInfo": "All pictures from the selected folder will be imported.",
|
||||
"selectExternalFolder": "Select external folder under /external-media",
|
||||
"importFromSelectedFolder": "Import from selected folder",
|
||||
"maxFilesReached": "Maximum 500 files allowed",
|
||||
"someFilesSkipped": "Some files were skipped (500 file limit)",
|
||||
"tooManyFiles": "Maximum 500 files can be uploaded at once",
|
||||
"maxFilesReached": "Maximum {{limit}} files allowed",
|
||||
"someFilesSkipped": "Only {{allowed}} more files can be added (limit {{limit}})",
|
||||
"tooManyFiles": "Maximum {{limit}} files can be uploaded at once",
|
||||
"limitInfo": "{{selected}} of {{limit}} files selected ({{remaining}} remaining)",
|
||||
"limitReached": "Upload limit reached ({{limit}} files per batch)",
|
||||
"uploadingChunks": "Uploading {{count}} files in {{total}} batches..."
|
||||
},
|
||||
"navigation": {
|
||||
@@ -225,7 +227,7 @@
|
||||
"eventNamePlaceholder": "e.g., John & Jane's Wedding",
|
||||
"welcomeMessageOptional": "Welcome Message (Optional)",
|
||||
"welcomeMessagePlaceholder": "Welcome to our special day! Feel free to download and share these memories...",
|
||||
"hostEmailPlaceholder": "host@example.com",
|
||||
"hostEmailPlaceholder": "customer@example.com",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"securityAndAccess": "Security & Access",
|
||||
"accessAndSecurity": "Access & Security",
|
||||
@@ -251,8 +253,8 @@
|
||||
"eventName": "Event Name",
|
||||
"eventType": "Event Type",
|
||||
"eventDate": "Event Date",
|
||||
"hostEmail": "Host Email",
|
||||
"hostName": "Host Name",
|
||||
"hostEmail": "Customer Email",
|
||||
"hostName": "Customer Name",
|
||||
"hostNamePlaceholder": "John Smith",
|
||||
"adminEmail": "Admin Email",
|
||||
"adminNotificationEmail": "Admin Notification Email",
|
||||
@@ -275,7 +277,7 @@
|
||||
"eventExpired": "This event has expired",
|
||||
"eventExpiresIn": "This event expires in {{days}} days",
|
||||
"guestsNoAccess": "Guests can no longer access the gallery. Consider archiving this event.",
|
||||
"warningEmailsSent": "Warning emails have been sent to the host.",
|
||||
"warningEmailsSent": "Warning emails have been sent to the customer.",
|
||||
"overview": "Overview",
|
||||
"photos": "Photos",
|
||||
"categories": "Categories",
|
||||
@@ -315,7 +317,7 @@
|
||||
"organizeCategoriesInfo": "Organize your photos into categories. Categories help guests navigate and find specific types of photos.",
|
||||
"categoriesTip": "Tip: Categories are specific to each event. You can also create global categories in Settings.",
|
||||
"contactInformation": "Contact Information",
|
||||
"hostEmailHelp": "Will receive gallery creation and expiration notifications",
|
||||
"hostEmailHelp": "Customer will receive gallery creation and expiration notifications",
|
||||
"adminEmailHelp": "Will receive system notifications and archive confirmations",
|
||||
"securityAccess": "Security & Access",
|
||||
"galleryPassword": "Gallery Password",
|
||||
@@ -406,13 +408,13 @@
|
||||
"tryAgain": "Try Again",
|
||||
"eventExpiredMessage": "This event has expired",
|
||||
"guestsCannotAccessGallery": "Guests can no longer access the gallery. Consider archiving this event.",
|
||||
"warningEmailsHaveBeenSent": "Warning emails have been sent to the host.",
|
||||
"warningEmailsHaveBeenSent": "Warning emails have been sent to the customer.",
|
||||
"extendSevenDays": "Extend 7 Days",
|
||||
"overview": "Overview",
|
||||
"eventInformation": "Event Information",
|
||||
"welcomeMessageLabel": "Welcome Message",
|
||||
"noWelcomeMessageSet": "No welcome message set",
|
||||
"hostEmail": "Host Email",
|
||||
"hostEmail": "Customer Email",
|
||||
"adminEmail": "Admin Email",
|
||||
"createdOn": "Created",
|
||||
"expires": "Expires",
|
||||
@@ -453,12 +455,16 @@
|
||||
"defaultExpirationHelp": "How long galleries remain active by default",
|
||||
"maxFileSize": "Max File Size (MB)",
|
||||
"maxFileSizeHelp": "Maximum size per uploaded photo",
|
||||
"maxFilesPerUpload": "Max Files per Upload",
|
||||
"maxFilesPerUploadHelp": "Maximum number of photos allowed in a single upload batch (1-{{max}}).",
|
||||
"allowedFileTypes": "Allowed File Types",
|
||||
"allowedFileTypesHelp": "Comma-separated list of file extensions",
|
||||
"featureToggles": "Feature Toggles",
|
||||
"enableWatermark": "Enable watermark on photos",
|
||||
"enableAnalytics": "Enable analytics tracking",
|
||||
"enableRegistration": "Allow self-registration for admins",
|
||||
"enableShortGalleryUrls": "Use short gallery URLs",
|
||||
"enableShortGalleryUrlsHelp": "Removes the event slug from new share links while keeping existing links working.",
|
||||
"maintenanceMode": "Enable maintenance mode",
|
||||
"language": "Language",
|
||||
"defaultLanguage": "Default Language",
|
||||
@@ -471,7 +477,18 @@
|
||||
"saveGeneralSettings": "Save General Settings",
|
||||
"dateTimeFormat": "Date & Time Format",
|
||||
"dateFormat": "Date Format",
|
||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application"
|
||||
"dateFormatHelp": "How dates are displayed in emails and throughout the application",
|
||||
"accountSection": "Admin Account",
|
||||
"accountUsername": "Admin Username",
|
||||
"accountUsernameHelp": "Displayed in the admin interface and used in activity logs.",
|
||||
"accountUsernameRequired": "Username is required",
|
||||
"accountUsernameLength": "Username must be at least 3 characters",
|
||||
"accountEmail": "Admin Email",
|
||||
"accountEmailHelp": "Used for login and receiving security notifications.",
|
||||
"accountEmailRequired": "Email address is required",
|
||||
"accountEmailInvalid": "Enter a valid email address",
|
||||
"accountSaveButton": "Save account details",
|
||||
"accountSaveSuccess": "Account details updated"
|
||||
},
|
||||
"publicSite": {
|
||||
"tabLabel": "Public Site",
|
||||
@@ -555,7 +572,11 @@
|
||||
"sessionTimeout": "Session Timeout (minutes)",
|
||||
"sessionTimeoutHelp": "Admin session timeout in minutes",
|
||||
"maxLoginAttempts": "Max Login Attempts",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts before lockout",
|
||||
"maxLoginAttemptsHelp": "Maximum failed login attempts per IP before lockout",
|
||||
"attemptWindowMinutes": "Attempt Window (minutes)",
|
||||
"attemptWindowMinutesHelp": "How long to look back when counting failed login attempts",
|
||||
"lockoutDurationMinutes": "Lockout Duration (minutes)",
|
||||
"lockoutDurationMinutesHelp": "How long the gallery or account stays locked after too many failures",
|
||||
"enable2FA": "Enable two-factor authentication for admins",
|
||||
"recaptchaSettings": "reCAPTCHA Settings",
|
||||
"enableRecaptcha": "Enable reCAPTCHA for login forms",
|
||||
@@ -967,8 +988,8 @@
|
||||
},
|
||||
"validation": {
|
||||
"eventNameRequired": "Event name is required",
|
||||
"hostEmailRequired": "Host email is required",
|
||||
"hostNameRequired": "Host name is required",
|
||||
"hostEmailRequired": "Customer email is required",
|
||||
"hostNameRequired": "Customer name is required",
|
||||
"adminEmailRequired": "Admin email is required",
|
||||
"invalidEmailFormat": "Invalid email format",
|
||||
"passwordRequired": "Password is required",
|
||||
|
||||
@@ -11,13 +11,14 @@ import { useGalleryAuth, useTheme } from '../contexts';
|
||||
import { useGalleryInfo } from '../hooks/useGallery';
|
||||
import { GalleryView } from '../components/gallery';
|
||||
import { analyticsService } from '../services/analytics.service';
|
||||
import { galleryService } from '../services';
|
||||
import { api } from '../config/api';
|
||||
import { GALLERY_THEME_PRESETS } from '../types/theme.types';
|
||||
import { buildResourceUrl } from '../utils/url';
|
||||
import { isGalleryPublic, normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const GalleryPage: React.FC = () => {
|
||||
const { slug, token } = useParams<{ slug: string; token?: string }>();
|
||||
const { slug: rawSlug, token: rawToken } = useParams<{ slug: string; token?: string }>();
|
||||
const { isAuthenticated, login, event } = useGalleryAuth();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { format } = useLocalizedDate();
|
||||
@@ -27,10 +28,82 @@ export const GalleryPage: React.FC = () => {
|
||||
const [loginError, setLoginError] = useState<string | null>(null);
|
||||
const [recaptchaToken, setRecaptchaToken] = useState<string | null>(null);
|
||||
const [autoLoginAttempted, setAutoLoginAttempted] = useState(false);
|
||||
const [resolvedSlug, setResolvedSlug] = useState<string | null>(() => {
|
||||
if (rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug)) {
|
||||
return null;
|
||||
}
|
||||
return rawSlug || null;
|
||||
});
|
||||
const [resolvedToken, setResolvedToken] = useState<string | undefined>(rawToken);
|
||||
const [isResolvingIdentifier, setIsResolvingIdentifier] = useState<boolean>(() =>
|
||||
Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug))
|
||||
);
|
||||
const [identifierError, setIdentifierError] = useState<string | null>(null);
|
||||
const lastResolvedIdentifier = React.useRef<string | null>(null);
|
||||
|
||||
// Fetch gallery info (public data)
|
||||
const { data: galleryInfo, isLoading: isLoadingInfo, error: infoError } = useGalleryInfo(slug!, token);
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
|
||||
const looksLikeToken = Boolean(rawSlug && !rawToken && /^[0-9a-fA-F]{32}$/.test(rawSlug));
|
||||
|
||||
if (!rawSlug) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (!looksLikeToken) {
|
||||
lastResolvedIdentifier.current = null;
|
||||
setResolvedSlug(rawSlug);
|
||||
setResolvedToken(rawToken);
|
||||
setIsResolvingIdentifier(false);
|
||||
setIdentifierError(null);
|
||||
} else if (lastResolvedIdentifier.current !== rawSlug) {
|
||||
setIsResolvingIdentifier(true);
|
||||
setIdentifierError(null);
|
||||
|
||||
galleryService.resolveIdentifier(rawSlug)
|
||||
.then((data) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(data.slug);
|
||||
setResolvedToken(data.token);
|
||||
setIdentifierError(null);
|
||||
})
|
||||
.catch((error: any) => {
|
||||
if (cancelled) return;
|
||||
lastResolvedIdentifier.current = rawSlug;
|
||||
setResolvedSlug(null);
|
||||
setResolvedToken(undefined);
|
||||
const message = error?.response?.data?.error || 'Unable to resolve gallery link';
|
||||
setIdentifierError(message);
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
setIsResolvingIdentifier(false);
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [rawSlug, rawToken]);
|
||||
|
||||
const canFetchGalleryInfo = Boolean(resolvedSlug) && !isResolvingIdentifier;
|
||||
const {
|
||||
data: galleryInfo,
|
||||
isLoading: isLoadingInfoQuery,
|
||||
error: infoError
|
||||
} = useGalleryInfo(canFetchGalleryInfo ? resolvedSlug ?? undefined : undefined, resolvedToken, canFetchGalleryInfo);
|
||||
const isLoadingInfo = isLoadingInfoQuery || isResolvingIdentifier;
|
||||
const requiresPassword = normalizeRequirePassword(galleryInfo?.requires_password, true);
|
||||
|
||||
React.useEffect(() => {
|
||||
setAutoLoginAttempted(false);
|
||||
}, [resolvedSlug]);
|
||||
|
||||
// Fetch branding settings
|
||||
const { data: settingsData } = useQuery({
|
||||
@@ -91,14 +164,14 @@ export const GalleryPage: React.FC = () => {
|
||||
}, [galleryInfo, settingsData, isAuthenticated, setTheme]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!slug) {
|
||||
if (!resolvedSlug || isResolvingIdentifier) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (galleryInfo && isGalleryPublic(galleryInfo.requires_password) && !isAuthenticated && !autoLoginAttempted) {
|
||||
setAutoLoginAttempted(true);
|
||||
setIsLoggingIn(true);
|
||||
login(slug, '')
|
||||
login(resolvedSlug, '')
|
||||
.then(() => {
|
||||
setLoginError(null);
|
||||
})
|
||||
@@ -112,7 +185,7 @@ export const GalleryPage: React.FC = () => {
|
||||
setIsLoggingIn(false);
|
||||
});
|
||||
}
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, slug]);
|
||||
}, [galleryInfo, isAuthenticated, autoLoginAttempted, login, resolvedSlug, isResolvingIdentifier]);
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = galleryInfo
|
||||
@@ -131,11 +204,16 @@ export const GalleryPage: React.FC = () => {
|
||||
try {
|
||||
setIsLoggingIn(true);
|
||||
setLoginError(null);
|
||||
await login(slug!, requiresPassword ? password : '', recaptchaToken);
|
||||
if (!resolvedSlug) {
|
||||
setLoginError(t('errors.galleryNotFound'));
|
||||
return;
|
||||
}
|
||||
|
||||
await login(resolvedSlug, requiresPassword ? password : '', recaptchaToken);
|
||||
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
gallery: resolvedSlug,
|
||||
success: true
|
||||
});
|
||||
}
|
||||
@@ -158,7 +236,7 @@ export const GalleryPage: React.FC = () => {
|
||||
// Track failed password entry
|
||||
if (requiresPassword) {
|
||||
analyticsService.trackGalleryEvent('password_entry', {
|
||||
gallery: slug,
|
||||
gallery: resolvedSlug ?? rawSlug ?? 'unknown',
|
||||
success: false,
|
||||
statusCode
|
||||
});
|
||||
@@ -182,6 +260,59 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
if (identifierError && !resolvedSlug && !isResolvingIdentifier) {
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--color-background, #fafafa)' }}>
|
||||
<div className="min-h-screen flex flex-col">
|
||||
{settingsData?.branding_logo_url && (
|
||||
<div className="p-8 text-center">
|
||||
<img
|
||||
src={buildResourceUrl(settingsData.branding_logo_url)}
|
||||
alt={settingsData.branding_company_name || 'Company Logo'}
|
||||
className="h-16 w-auto object-contain mx-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<Card className="max-w-md w-full mx-4">
|
||||
<CardContent className="text-center py-12">
|
||||
<AlertCircle className="w-16 h-16 text-red-500 mx-auto mb-4" />
|
||||
<h2 className="text-xl font-semibold mb-2">
|
||||
{t('errors.galleryNotFound')}
|
||||
</h2>
|
||||
<p className="text-neutral-600">
|
||||
{identifierError}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="p-8 text-center">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<Link
|
||||
to="/impressum"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.impressum')}
|
||||
</Link>
|
||||
<span className="text-xs text-neutral-400">|</span>
|
||||
<Link
|
||||
to="/datenschutz"
|
||||
className="text-xs text-neutral-500 hover:text-neutral-700 transition-colors"
|
||||
>
|
||||
{t('legal.datenschutz')}
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-xs mt-2 text-neutral-500">
|
||||
Powered by <span className="font-semibold">PicPeak</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Show error state
|
||||
if (infoError) {
|
||||
// Check if it's an archived gallery error
|
||||
@@ -299,9 +430,11 @@ export const GalleryPage: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const gallerySlugForView = resolvedSlug ?? rawSlug ?? '';
|
||||
|
||||
// Show gallery view if authenticated
|
||||
if (isAuthenticated && event) {
|
||||
return <GalleryView slug={slug!} event={event} />;
|
||||
return <GalleryView slug={gallerySlugForView} event={event} />;
|
||||
}
|
||||
|
||||
// Show login form
|
||||
|
||||
@@ -68,6 +68,7 @@ export const AdminLoginPage: React.FC = () => {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
toast.dismiss();
|
||||
|
||||
if (!validateForm()) {
|
||||
return;
|
||||
|
||||
@@ -23,6 +23,14 @@ export const BrandingPage: React.FC = () => {
|
||||
watermark_size: 15,
|
||||
watermark_logo_url: '',
|
||||
favicon_url: '',
|
||||
logo_url: '',
|
||||
logo_size: 'medium',
|
||||
logo_max_height: 48,
|
||||
logo_position: 'left',
|
||||
logo_display_header: true,
|
||||
logo_display_hero: true,
|
||||
logo_display_mode: 'logo_and_text',
|
||||
hide_powered_by: false,
|
||||
});
|
||||
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig>(theme);
|
||||
@@ -508,6 +516,27 @@ export const BrandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* White Label Settings */}
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||
<h3 className="text-md font-semibold text-neutral-900 mb-4">{t('branding.whiteLabel', 'White Label')}</h3>
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingSettings.hide_powered_by === true}
|
||||
onChange={(e) => handleBrandingChange('hide_powered_by', e.target.checked)}
|
||||
className="rounded border-neutral-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
<div>
|
||||
<span className="text-sm font-medium text-neutral-900">
|
||||
{t('branding.hidePoweredBy', 'Hide "Powered by PicPeak" branding')}
|
||||
</span>
|
||||
<p className="text-xs text-neutral-600">
|
||||
{t('branding.hidePoweredByHelp', 'Remove the PicPeak attribution from gallery footers for a fully white-labeled experience')}
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
|
||||
@@ -25,7 +25,7 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
@@ -122,7 +122,7 @@ export const CreateEventPage: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: format(new Date(), 'yyyy-MM-dd'),
|
||||
host_email: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
@@ -198,10 +198,10 @@ export const CreateEventPage: React.FC = () => {
|
||||
newErrors.event_name = t('validation.eventNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
||||
if (!formData.customer_email) {
|
||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.admin_email) {
|
||||
@@ -245,7 +245,8 @@ export const CreateEventPage: React.FC = () => {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_email.split('@')[0],
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
@@ -388,17 +389,17 @@ export const CreateEventPage: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.contactInformation')}</h2>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Host Email */}
|
||||
{/* Customer Email */}
|
||||
<div>
|
||||
<label htmlFor="host_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
<label htmlFor="customer_email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.hostEmail')}
|
||||
</label>
|
||||
<Input
|
||||
id="host_email"
|
||||
id="customer_email"
|
||||
type="email"
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
/>
|
||||
|
||||
@@ -27,8 +27,8 @@ interface FormData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name: string;
|
||||
host_email: string;
|
||||
customer_name: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password: boolean;
|
||||
password: string;
|
||||
@@ -86,8 +86,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: 'wedding',
|
||||
event_name: '',
|
||||
event_date: new Date().toISOString().split('T')[0], // Initialize with ISO date format
|
||||
host_name: '',
|
||||
host_email: '',
|
||||
customer_name: '',
|
||||
customer_email: '',
|
||||
admin_email: '',
|
||||
require_password: true,
|
||||
password: '',
|
||||
@@ -184,14 +184,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
newErrors.event_date = t('validation.eventDateRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_name) {
|
||||
newErrors.host_name = t('validation.hostNameRequired');
|
||||
if (!formData.customer_name) {
|
||||
newErrors.customer_name = t('validation.hostNameRequired');
|
||||
}
|
||||
|
||||
if (!formData.host_email) {
|
||||
newErrors.host_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.host_email)) {
|
||||
newErrors.host_email = t('validation.invalidEmailFormat');
|
||||
if (!formData.customer_email) {
|
||||
newErrors.customer_email = t('validation.hostEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.customer_email)) {
|
||||
newErrors.customer_email = t('validation.invalidEmailFormat');
|
||||
}
|
||||
|
||||
if (!formData.admin_email) {
|
||||
@@ -236,8 +236,8 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
event_type: formData.event_type,
|
||||
event_name: formData.event_name,
|
||||
event_date: formData.event_date,
|
||||
host_name: formData.host_name,
|
||||
host_email: formData.host_email,
|
||||
customer_name: formData.customer_name,
|
||||
customer_email: formData.customer_email,
|
||||
admin_email: formData.admin_email,
|
||||
require_password: formData.require_password,
|
||||
password: formData.require_password ? formData.password : undefined,
|
||||
@@ -472,9 +472,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
<Input
|
||||
label={t('events.hostName')}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
value={formData.host_name}
|
||||
onChange={handleInputChange('host_name')}
|
||||
error={errors.host_name}
|
||||
value={formData.customer_name}
|
||||
onChange={handleInputChange('customer_name')}
|
||||
error={errors.customer_name}
|
||||
leftIcon={<Calendar className="w-5 h-5" />}
|
||||
/>
|
||||
|
||||
@@ -482,9 +482,9 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
type="email"
|
||||
label={t('events.hostEmail')}
|
||||
placeholder={t('events.hostEmailPlaceholder')}
|
||||
value={formData.host_email}
|
||||
onChange={handleInputChange('host_email')}
|
||||
error={errors.host_email}
|
||||
value={formData.customer_email}
|
||||
onChange={handleInputChange('customer_email')}
|
||||
error={errors.customer_email}
|
||||
leftIcon={<Mail className="w-5 h-5" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -122,7 +122,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: boolean;
|
||||
upload_category_id: number | null;
|
||||
hero_photo_id: number | null;
|
||||
host_name: string;
|
||||
customer_name: string;
|
||||
source_mode: 'managed' | 'reference';
|
||||
external_path: string;
|
||||
require_password: boolean;
|
||||
@@ -138,7 +138,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: false,
|
||||
upload_category_id: null,
|
||||
hero_photo_id: null,
|
||||
host_name: '',
|
||||
customer_name: '',
|
||||
source_mode: 'managed',
|
||||
external_path: '',
|
||||
require_password: true,
|
||||
@@ -304,7 +304,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
allow_user_uploads: event.allow_user_uploads || false,
|
||||
upload_category_id: event.upload_category_id || null,
|
||||
hero_photo_id: event.hero_photo_id || null,
|
||||
host_name: event.host_name || '',
|
||||
customer_name: event.customer_name || '',
|
||||
source_mode: event.source_mode === 'reference' ? 'reference' : 'managed',
|
||||
external_path: event.external_path || '',
|
||||
require_password: normalizeRequirePassword(event.require_password),
|
||||
@@ -411,8 +411,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
updateData.external_path = editForm.source_mode === 'reference'
|
||||
? externalPathToSave
|
||||
: null;
|
||||
if (editForm.host_name !== undefined && editForm.host_name !== null) {
|
||||
updateData.host_name = editForm.host_name;
|
||||
if (editForm.customer_name !== undefined && editForm.customer_name !== null) {
|
||||
updateData.customer_name = editForm.customer_name;
|
||||
}
|
||||
|
||||
if (editForm.new_password) {
|
||||
@@ -687,8 +687,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</label>
|
||||
<Input
|
||||
type="text"
|
||||
value={editForm.host_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, host_name: e.target.value }))}
|
||||
value={editForm.customer_name}
|
||||
onChange={(e) => setEditForm(prev => ({ ...prev, customer_name: e.target.value }))}
|
||||
placeholder={t('events.hostNamePlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
@@ -903,14 +903,14 @@ export const EventDetailsPage: React.FC = () => {
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostName')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">
|
||||
{event.host_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
{event.customer_name || <span className="text-neutral-400">{t('common.notSet')}</span>}
|
||||
</dd>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt className="text-sm font-medium text-neutral-500">{t('events.hostEmail')}</dt>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.host_email}</dd>
|
||||
<dd className="mt-1 text-sm text-neutral-900">{event.customer_email}</dd>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -1144,22 +1144,9 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}
|
||||
}
|
||||
}}
|
||||
isPreviewMode={false}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
onApply={async (theme, { presetName }) => {
|
||||
const resolvedPreset = presetName || 'custom';
|
||||
setCurrentTheme(theme);
|
||||
setCurrentPresetName(resolvedPreset);
|
||||
|
||||
const themeValue = resolvedPreset !== 'custom'
|
||||
? resolvedPreset
|
||||
: JSON.stringify(theme);
|
||||
|
||||
setEditForm(prev => ({ ...prev, color_theme: themeValue }));
|
||||
|
||||
await applyThemeMutation.mutateAsync({ theme, presetName: resolvedPreset });
|
||||
}}
|
||||
isApplying={applyThemeMutation.isPending}
|
||||
hideActions={true}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -159,7 +159,7 @@ export const EventsListPage: React.FC = () => {
|
||||
events = events.filter(e =>
|
||||
e.event_name.toLowerCase().includes(term) ||
|
||||
e.event_type.toLowerCase().includes(term) ||
|
||||
e.host_email.toLowerCase().includes(term)
|
||||
(e.customer_email || '').toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -428,7 +428,7 @@ export const EventsListPage: React.FC = () => {
|
||||
<td className="px-6 py-4">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-neutral-900">{event.event_name}</p>
|
||||
<p className="text-xs text-neutral-500">{event.host_email}</p>
|
||||
<p className="text-xs text-neutral-500">{event.customer_email}</p>
|
||||
<div className="mt-1">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[11px] font-medium ${
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Save,
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Save,
|
||||
Database,
|
||||
Globe,
|
||||
Key,
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
CheckCircle,
|
||||
Clock,
|
||||
HardDrive,
|
||||
Activity
|
||||
Activity,
|
||||
Mail,
|
||||
User
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'react-toastify';
|
||||
|
||||
@@ -19,11 +21,12 @@ import { CategoryManager } from '../../components/admin/CategoryManager';
|
||||
import { WordFilterManager } from '../../components/admin/WordFilterManager';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { settingsService } from '../../services/settings.service';
|
||||
import { authService } from '../../services/auth.service';
|
||||
import { adminService } from '../../services/admin.service';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAdminAuth } from '../../contexts';
|
||||
|
||||
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||
const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||
|
||||
const toBoolean = (value: unknown, defaultValue = false): boolean => {
|
||||
if (value === undefined || value === null) {
|
||||
@@ -58,23 +61,7 @@ export const SettingsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'status' | 'security' | 'categories' | 'analytics' | 'moderation'>('general');
|
||||
const queryClient = useQueryClient();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { user, updateProfile: updateAuthProfile } = useAdminAuth();
|
||||
|
||||
const [profileForm, setProfileForm] = useState({
|
||||
username: user?.username ?? '',
|
||||
email: user?.email ?? '',
|
||||
});
|
||||
const [profileError, setProfileError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (user) {
|
||||
setProfileForm({ username: user.username, email: user.email });
|
||||
}
|
||||
}, [user]);
|
||||
|
||||
const isProfileDirty = user
|
||||
? (profileForm.username !== user.username || profileForm.email !== user.email)
|
||||
: Boolean(profileForm.username.trim() || profileForm.email.trim());
|
||||
const { updateUserProfile } = useAdminAuth();
|
||||
|
||||
// Fetch settings
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
@@ -82,6 +69,11 @@ export const SettingsPage: React.FC = () => {
|
||||
queryFn: () => settingsService.getAllSettings(),
|
||||
});
|
||||
|
||||
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
|
||||
queryKey: ['admin-profile'],
|
||||
queryFn: () => adminService.getAdminProfile(),
|
||||
});
|
||||
|
||||
// Fetch storage info
|
||||
const { data: storageInfo } = useQuery({
|
||||
queryKey: ['admin-storage-info'],
|
||||
@@ -116,11 +108,13 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: '',
|
||||
default_expiration_days: 30,
|
||||
max_file_size_mb: 50,
|
||||
max_files_per_upload: 500,
|
||||
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||
enable_watermark: false,
|
||||
enable_analytics: true,
|
||||
enable_registration: false,
|
||||
maintenance_mode: false,
|
||||
short_gallery_urls: false,
|
||||
default_language: 'en',
|
||||
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||
});
|
||||
@@ -132,6 +126,8 @@ export const SettingsPage: React.FC = () => {
|
||||
enable_2fa: false,
|
||||
session_timeout_minutes: 60,
|
||||
max_login_attempts: 5,
|
||||
attempt_window_minutes: 15,
|
||||
lockout_duration_minutes: 30,
|
||||
enable_recaptcha: false,
|
||||
recaptcha_site_key: '',
|
||||
recaptcha_secret_key: ''
|
||||
@@ -150,6 +146,11 @@ export const SettingsPage: React.FC = () => {
|
||||
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
||||
const [overrideDirty, setOverrideDirty] = useState(false);
|
||||
const [accountForm, setAccountForm] = useState({
|
||||
username: '',
|
||||
email: ''
|
||||
});
|
||||
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
if (settings) {
|
||||
@@ -163,11 +164,16 @@ export const SettingsPage: React.FC = () => {
|
||||
site_url: settings.general_site_url || '',
|
||||
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||
max_files_per_upload: Math.min(
|
||||
MAX_FILES_PER_UPLOAD_LIMIT,
|
||||
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||
),
|
||||
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||
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),
|
||||
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||
default_language: settings.general_default_language || 'en',
|
||||
date_format: settings.general_date_format
|
||||
? (typeof settings.general_date_format === 'string'
|
||||
@@ -183,6 +189,8 @@ export const SettingsPage: React.FC = () => {
|
||||
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),
|
||||
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||
@@ -198,6 +206,15 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
}, [settings, i18n]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (adminProfile) {
|
||||
setAccountForm({
|
||||
username: adminProfile.username || '',
|
||||
email: adminProfile.email || ''
|
||||
});
|
||||
}
|
||||
}, [adminProfile]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!settings || overrideDirty) {
|
||||
return;
|
||||
@@ -318,6 +335,83 @@ export const SettingsPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
|
||||
const updateAdminProfileMutation = useMutation({
|
||||
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
|
||||
onSuccess: (updatedUser) => {
|
||||
toast.success(t('settings.general.accountSaveSuccess'));
|
||||
setAccountErrors({});
|
||||
setAccountForm({
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email
|
||||
});
|
||||
updateUserProfile(updatedUser);
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-profile'] });
|
||||
},
|
||||
onError: (error: any) => {
|
||||
if (error.response?.data?.errors) {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
for (const err of error.response.data.errors) {
|
||||
if (err.path === 'username') {
|
||||
fieldErrors.username = err.msg;
|
||||
}
|
||||
if (err.path === 'email') {
|
||||
fieldErrors.email = err.msg;
|
||||
}
|
||||
}
|
||||
setAccountErrors(fieldErrors);
|
||||
return;
|
||||
}
|
||||
|
||||
if (error.response?.data?.error) {
|
||||
toast.error(error.response.data.error);
|
||||
} else {
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const handleAccountChange = (field: 'username' | 'email') => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const value = event.target.value;
|
||||
setAccountForm((prev) => ({ ...prev, [field]: value }));
|
||||
if (accountErrors[field]) {
|
||||
setAccountErrors((prev) => ({ ...prev, [field]: '' }));
|
||||
}
|
||||
};
|
||||
|
||||
const handleAccountSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (updateAdminProfileMutation.isPending) {
|
||||
return;
|
||||
}
|
||||
|
||||
const trimmedUsername = accountForm.username.trim();
|
||||
const trimmedEmail = accountForm.email.trim();
|
||||
const errors: Record<string, string> = {};
|
||||
|
||||
if (!trimmedUsername) {
|
||||
errors.username = t('settings.general.accountUsernameRequired');
|
||||
} else if (trimmedUsername.length < 3) {
|
||||
errors.username = t('settings.general.accountUsernameLength');
|
||||
}
|
||||
|
||||
if (!trimmedEmail) {
|
||||
errors.email = t('settings.general.accountEmailRequired');
|
||||
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
|
||||
errors.email = t('settings.general.accountEmailInvalid');
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setAccountErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
updateAdminProfileMutation.mutate({
|
||||
username: trimmedUsername,
|
||||
email: trimmedEmail
|
||||
});
|
||||
};
|
||||
|
||||
const saveSoftLimitMutation = useMutation({
|
||||
mutationFn: async (limitBytes: number | null) => {
|
||||
return settingsService.updateSettings({
|
||||
@@ -513,43 +607,61 @@ export const SettingsPage: React.FC = () => {
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-2">{t('admin.accountSettings.title')}</h2>
|
||||
<p className="text-sm text-neutral-500 mb-4">{t('admin.accountSettings.description')}</p>
|
||||
<form className="space-y-4" onSubmit={handleProfileSubmit}>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('admin.accountSettings.username')}
|
||||
</label>
|
||||
<Input
|
||||
value={profileForm.username}
|
||||
onChange={(e) => setProfileForm(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder={t('admin.accountSettings.usernamePlaceholder')}
|
||||
maxLength={120}
|
||||
/>
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
|
||||
{adminProfileLoading ? (
|
||||
<div className="py-8 flex justify-center">
|
||||
<Loading size="md" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('admin.accountSettings.email')}
|
||||
</label>
|
||||
<Input
|
||||
type="email"
|
||||
value={profileForm.email}
|
||||
onChange={(e) => setProfileForm(prev => ({ ...prev, email: e.target.value }))}
|
||||
placeholder={t('admin.accountSettings.emailPlaceholder')}
|
||||
/>
|
||||
</div>
|
||||
{profileError && (
|
||||
<p className="text-sm text-red-600">{profileError}</p>
|
||||
)}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={!isProfileDirty || updateProfileMutation.isPending}
|
||||
>
|
||||
{updateProfileMutation.isPending ? t('common.saving') : t('admin.accountSettings.updateButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<form className="space-y-4" onSubmit={handleAccountSubmit}>
|
||||
<div>
|
||||
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.accountUsername')}
|
||||
</label>
|
||||
<Input
|
||||
id="admin-account-username"
|
||||
type="text"
|
||||
value={accountForm.username}
|
||||
onChange={handleAccountChange('username')}
|
||||
placeholder="admin"
|
||||
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||
error={accountErrors.username}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.accountUsernameHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.accountEmail')}
|
||||
</label>
|
||||
<Input
|
||||
id="admin-account-email"
|
||||
type="email"
|
||||
value={accountForm.email}
|
||||
onChange={handleAccountChange('email')}
|
||||
placeholder="admin@example.com"
|
||||
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||
error={accountErrors.email}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.accountEmailHelp')}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
leftIcon={<Save className="w-5 h-5" />}
|
||||
isLoading={updateAdminProfileMutation.isPending}
|
||||
>
|
||||
{t('settings.general.accountSaveButton')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card padding="md">
|
||||
@@ -572,7 +684,7 @@ export const SettingsPage: React.FC = () => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.defaultExpiration')}
|
||||
@@ -597,6 +709,29 @@ export const SettingsPage: React.FC = () => {
|
||||
max="500"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.general.maxFilesPerUpload')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={generalSettings.max_files_per_upload}
|
||||
onChange={(e) => {
|
||||
const parsed = parseInt(e.target.value, 10);
|
||||
setGeneralSettings(prev => ({
|
||||
...prev,
|
||||
max_files_per_upload: Number.isFinite(parsed)
|
||||
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
|
||||
: prev.max_files_per_upload
|
||||
}));
|
||||
}}
|
||||
min="1"
|
||||
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
||||
/>
|
||||
<p className="text-xs text-neutral-500 mt-1">
|
||||
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
@@ -659,6 +794,21 @@ export const SettingsPage: React.FC = () => {
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={generalSettings.short_gallery_urls}
|
||||
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||
/>
|
||||
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
|
||||
</label>
|
||||
<p className="text-xs text-neutral-500 ml-6 mt-1">
|
||||
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
@@ -1227,7 +1377,7 @@ export const SettingsPage: React.FC = () => {
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.sessionTimeout')}
|
||||
@@ -1235,11 +1385,41 @@ export const SettingsPage: React.FC = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.session_timeout_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value) || 60 }))}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
|
||||
min="5"
|
||||
max="1440"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.attemptWindowMinutes')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.attempt_window_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
|
||||
min="1"
|
||||
max="1440"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.attemptWindowMinutesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.lockoutDurationMinutes')}
|
||||
</label>
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.lockout_duration_minutes}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
|
||||
min="1"
|
||||
max="1440"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.lockoutDurationMinutesHelp')}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('settings.security.maxLoginAttempts')}
|
||||
@@ -1247,10 +1427,13 @@ export const SettingsPage: React.FC = () => {
|
||||
<Input
|
||||
type="number"
|
||||
value={securitySettings.max_login_attempts}
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value) || 5 }))}
|
||||
min="3"
|
||||
max="10"
|
||||
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
|
||||
min="1"
|
||||
max="50"
|
||||
/>
|
||||
<p className="mt-1 text-sm text-neutral-600">
|
||||
{t('settings.security.maxLoginAttemptsHelp')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -47,6 +47,17 @@ export interface Activity {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface AdminProfile {
|
||||
id: number;
|
||||
username: string;
|
||||
email: string;
|
||||
mustChangePassword?: boolean;
|
||||
last_login?: string | null;
|
||||
last_login_ip?: string | null;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface AnalyticsData {
|
||||
chartData: Array<{
|
||||
date: string;
|
||||
@@ -130,5 +141,15 @@ export const adminService = {
|
||||
// Change password
|
||||
async changePassword(data: { currentPassword: string; newPassword: string }): Promise<void> {
|
||||
await api.post('/admin/auth/change-password', data);
|
||||
},
|
||||
|
||||
async getAdminProfile(): Promise<AdminProfile> {
|
||||
const response = await api.get<AdminProfile>('/admin/auth/profile');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async updateAdminProfile(data: { username: string; email: string }): Promise<AdminProfile> {
|
||||
const response = await api.put<{ user: AdminProfile }>('/admin/auth/profile', data);
|
||||
return response.data.user;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2,16 +2,27 @@ import { api } from '../config/api';
|
||||
import type { Event } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
const normalizeEvent = (event: Event): Event => ({
|
||||
...event,
|
||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||
});
|
||||
const normalizeEvent = (event: Event): Event => {
|
||||
const legacyHostName = (event as any)?.host_name;
|
||||
const legacyHostEmail = (event as any)?.host_email;
|
||||
|
||||
const customerName = event.customer_name ?? legacyHostName ?? undefined;
|
||||
const customerEmail = event.customer_email ?? legacyHostEmail ?? '';
|
||||
|
||||
return {
|
||||
...event,
|
||||
customer_name: customerName,
|
||||
customer_email: customerEmail,
|
||||
require_password: normalizeRequirePassword((event as any)?.require_password, true),
|
||||
};
|
||||
};
|
||||
|
||||
interface CreateEventData {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_email: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
@@ -33,7 +44,8 @@ interface CreateEventData {
|
||||
interface UpdateEventData {
|
||||
event_name?: string;
|
||||
event_date?: string;
|
||||
host_email?: string;
|
||||
customer_name?: string;
|
||||
customer_email?: string;
|
||||
admin_email?: string;
|
||||
require_password?: boolean;
|
||||
password?: string;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { api } from '../config/api';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats } from '../types';
|
||||
import type { GalleryInfo, GalleryData, GalleryStats, ResolvedGalleryIdentifier } from '../types';
|
||||
import { normalizeRequirePassword } from '../utils/accessControl';
|
||||
|
||||
export const galleryService = {
|
||||
@@ -119,4 +119,9 @@ export const galleryService = {
|
||||
const response = await api.get<GalleryStats>(`/gallery/${slug}/stats`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
async resolveIdentifier(identifier: string): Promise<ResolvedGalleryIdentifier> {
|
||||
const response = await api.get<ResolvedGalleryIdentifier>(`/gallery/resolve/${identifier}`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,7 @@ export interface BrandingSettings {
|
||||
logo_display_header?: boolean;
|
||||
logo_display_hero?: boolean;
|
||||
logo_display_mode?: 'logo_only' | 'text_only' | 'logo_and_text';
|
||||
hide_powered_by?: boolean;
|
||||
}
|
||||
|
||||
export interface ThemeSettings {
|
||||
@@ -256,6 +257,13 @@ export const settingsService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Helper to parse boolean values that might come as strings or actual booleans
|
||||
_parseBoolean(value: any, defaultValue: boolean): boolean {
|
||||
if (value === true || value === 'true') return true;
|
||||
if (value === false || value === 'false') return false;
|
||||
return defaultValue;
|
||||
},
|
||||
|
||||
// Format branding settings from raw data
|
||||
formatBrandingSettings(rawSettings: Record<string, any>): BrandingSettings {
|
||||
return {
|
||||
@@ -263,7 +271,7 @@ export const settingsService = {
|
||||
company_tagline: rawSettings.branding_company_tagline || '',
|
||||
support_email: rawSettings.branding_support_email || '',
|
||||
footer_text: rawSettings.branding_footer_text || '',
|
||||
watermark_enabled: rawSettings.branding_watermark_enabled || false,
|
||||
watermark_enabled: this._parseBoolean(rawSettings.branding_watermark_enabled, false),
|
||||
watermark_position: rawSettings.branding_watermark_position || 'bottom-right',
|
||||
watermark_opacity: rawSettings.branding_watermark_opacity || 50,
|
||||
watermark_size: rawSettings.branding_watermark_size || 15,
|
||||
@@ -273,9 +281,10 @@ export const settingsService = {
|
||||
logo_size: rawSettings.branding_logo_size || 'medium',
|
||||
logo_max_height: rawSettings.branding_logo_max_height || 48,
|
||||
logo_position: rawSettings.branding_logo_position || 'left',
|
||||
logo_display_header: rawSettings.branding_logo_display_header !== false,
|
||||
logo_display_hero: rawSettings.branding_logo_display_hero !== false,
|
||||
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text'
|
||||
logo_display_header: this._parseBoolean(rawSettings.branding_logo_display_header, true),
|
||||
logo_display_hero: this._parseBoolean(rawSettings.branding_logo_display_hero, true),
|
||||
logo_display_mode: rawSettings.branding_logo_display_mode || 'logo_and_text',
|
||||
hide_powered_by: this._parseBoolean(rawSettings.branding_hide_powered_by, false)
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -5,8 +5,8 @@ export interface Event {
|
||||
event_type: string;
|
||||
event_name: string;
|
||||
event_date: string;
|
||||
host_name?: string;
|
||||
host_email: string;
|
||||
customer_name?: string;
|
||||
customer_email: string;
|
||||
admin_email: string;
|
||||
welcome_message?: string;
|
||||
color_theme?: string;
|
||||
@@ -111,6 +111,17 @@ export interface GalleryStats {
|
||||
unique_visitors: number;
|
||||
}
|
||||
|
||||
export interface ResolvedGalleryIdentifier {
|
||||
slug: string;
|
||||
token: string;
|
||||
matchType: string;
|
||||
share_link: string;
|
||||
share_path: string;
|
||||
share_url: string;
|
||||
short_enabled: boolean;
|
||||
requires_password: boolean;
|
||||
}
|
||||
|
||||
// Auth types
|
||||
export interface AdminUser {
|
||||
id: number;
|
||||
|
||||
Reference in New Issue
Block a user