Merge remote-tracking branch 'upstream/main'

This commit is contained in:
2025-11-25 22:02:23 +02:00
104 changed files with 3858 additions and 3874 deletions
@@ -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');
}
+51 -8
View File
@@ -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) => {
+11 -5
View File
@@ -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}
+128 -48
View File
@@ -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}
+8 -2
View File
@@ -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,
});
};
+35 -14
View File
@@ -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",
+36 -15
View File
@@ -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",
+143 -10
View File
@@ -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;
+29
View File
@@ -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
+14 -13
View File
@@ -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>
+11 -24
View File
@@ -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>
)}
+2 -2
View File
@@ -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 ${
+247 -64
View File
@@ -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>
+21
View File
@@ -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;
}
};
+18 -6
View File
@@ -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;
+6 -1
View File
@@ -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;
},
};
+13 -4
View File
@@ -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)
};
},
+13 -2
View File
@@ -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;