refactor: rename project from wedding-photo-sharing to PicPeak
- Update Docker image names and network configurations - Rename package.json project names to picpeak-backend/frontend - Update CI/CD configurations (Drone CI and GitHub Actions) - Update documentation and setup scripts - Update application branding in source code - Change default database name to picpeak - Update PM2 ecosystem config 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "photo-sharing-frontend",
|
||||
"name": "picpeak-frontend",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
AdminLoginPage,
|
||||
AdminDashboard,
|
||||
EventsListPage,
|
||||
CreateEventPage,
|
||||
CreateEventPageEnhanced as CreateEventPage,
|
||||
EventDetailsPage,
|
||||
EmailConfigPage,
|
||||
ArchivesPage,
|
||||
|
||||
@@ -88,8 +88,9 @@ export const AdminHeader: React.FC<AdminHeaderProps> = ({ onMenuClick }) => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - PicPeak branding */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2">
|
||||
{/* Center - Logo and PicPeak text */}
|
||||
<div className="absolute left-1/2 transform -translate-x-1/2 flex items-center gap-3">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="PicPeak" className="h-10 w-auto object-contain" />
|
||||
<span className="text-2xl" style={{ fontFamily: 'Poppins, sans-serif', fontWeight: 600, color: '#145346' }}>PicPeak</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -49,11 +49,10 @@ export const AdminSidebar: React.FC<AdminSidebarProps> = ({ isOpen, onClose }) =
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col h-screen lg:h-full">
|
||||
{/* Logo/Brand */}
|
||||
{/* Brand */}
|
||||
<div className="flex items-center justify-between h-16 px-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<div className="flex items-center">
|
||||
<img src="/picpeak-kamera-transparent.png" alt="Camera" className="w-8 h-8 object-contain" />
|
||||
<span className="ml-2 text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
<span className="text-xl font-bold text-neutral-900">{t('admin.title')}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Card } from '../common';
|
||||
import { Camera } from 'lucide-react';
|
||||
import { ThemeConfig, GalleryLayoutType } from '../../types/theme.types';
|
||||
|
||||
@@ -71,7 +70,6 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
|
||||
switch (activeLayout) {
|
||||
case 'grid': {
|
||||
const cols = theme.gallerySettings?.gridColumns || { mobile: 2, tablet: 3, desktop: 4 };
|
||||
return (
|
||||
<div className={`grid grid-cols-3 md:grid-cols-4 ${gapClass}`}>
|
||||
{mockPhotos.slice(0, 8).map((photo) => (
|
||||
@@ -170,7 +168,9 @@ export const GalleryPreview: React.FC<GalleryPreviewProps> = ({
|
||||
borderColor: theme.primaryColor ? `${theme.primaryColor}20` : '#e5e7eb',
|
||||
}}
|
||||
>
|
||||
<h3 className="text-sm font-medium capitalize">{activeLayout} Layout</h3>
|
||||
<h3 className="text-sm font-medium">
|
||||
Gallery Preview - <span className="capitalize">{activeLayout}</span> Layout
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
{/* Preview Content */}
|
||||
|
||||
@@ -57,17 +57,8 @@ export const AuthenticatedImage: React.FC<AuthenticatedImageProps> = ({
|
||||
// Create a new URL with auth header
|
||||
const fetchImage = async () => {
|
||||
try {
|
||||
// If watermark is requested and this is a gallery photo, use the protected images endpoint
|
||||
// Use the src as-is since it should already be the correct endpoint
|
||||
let imageUrl = src;
|
||||
if (useWatermark && src.includes('/photos/')) {
|
||||
// Extract gallery slug and photo ID from the URL
|
||||
// URL format: /photos/events/active/{slug}/photos/{photoId}.jpg
|
||||
const match = src.match(/\/photos\/events\/active\/([^\/]+)\/photos\/(\d+)\./);
|
||||
if (match) {
|
||||
const [, slug, photoId] = match;
|
||||
imageUrl = `/api/images/${slug}/photo/${photoId}/view`;
|
||||
}
|
||||
}
|
||||
|
||||
// Prepend API URL for absolute paths
|
||||
const apiUrl = import.meta.env.VITE_API_URL || 'http://localhost:3001';
|
||||
|
||||
@@ -29,6 +29,7 @@ interface GalleryLayoutProps {
|
||||
onDownloadAll?: () => void;
|
||||
isDownloading?: boolean;
|
||||
headerExtra?: React.ReactNode;
|
||||
menuButton?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
@@ -41,6 +42,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
onDownloadAll,
|
||||
isDownloading = false,
|
||||
headerExtra,
|
||||
menuButton,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
@@ -65,6 +67,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button and other header extras */}
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
@@ -106,8 +109,15 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{!isNonGridLayout && theme.galleryLayout !== 'hero' && (
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between gap-2 sm:gap-4">
|
||||
{/* Left side - Logo and menu button on mobile */}
|
||||
{/* Left side - Menu button, Logo */}
|
||||
<div className="flex items-center gap-2 sm:gap-4 flex-shrink-0">
|
||||
{/* Menu button */}
|
||||
{menuButton && (
|
||||
<div className="flex-shrink-0">
|
||||
{menuButton}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="flex-shrink-0">
|
||||
<img
|
||||
@@ -119,11 +129,6 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
className="h-8 sm:h-10 lg:h-12 w-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Menu button on mobile */}
|
||||
<div className="flex-shrink-0 sm:hidden">
|
||||
{headerExtra}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Center - Event info */}
|
||||
@@ -154,10 +159,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
||||
{/* Menu button on desktop */}
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
{/* Extra header items (upload button, etc.) */}
|
||||
{headerExtra && (
|
||||
<div className="hidden sm:block">
|
||||
{headerExtra}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Download all button - hidden on mobile when sidebar is shown */}
|
||||
{showDownloadAll && onDownloadAll && (
|
||||
@@ -214,7 +221,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<div className="container py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Left side - Menu button */}
|
||||
<div className="flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
@@ -273,21 +281,27 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto filter brightness-0 invert"
|
||||
className="h-16 sm:h-20 lg:h-24 w-auto object-contain mx-auto"
|
||||
style={{
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 2px 4px rgba(0, 0, 0, 0.3))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Name */}
|
||||
<h1
|
||||
className="text-3xl sm:text-4xl lg:text-5xl font-bold mb-4"
|
||||
style={{ fontFamily: headingFontFamily }}
|
||||
style={{
|
||||
fontFamily: headingFontFamily,
|
||||
textShadow: '0 2px 4px rgba(0, 0, 0, 0.3)'
|
||||
}}
|
||||
>
|
||||
{event.event_name}
|
||||
</h1>
|
||||
|
||||
{/* Event Details */}
|
||||
{(event.event_date || event.expires_at) && (
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80">
|
||||
<div className="flex flex-wrap items-center justify-center gap-4 sm:gap-6 text-white/80" style={{ textShadow: '0 1px 3px rgba(0, 0, 0, 0.3)' }}>
|
||||
{event.event_date && (
|
||||
<span className="flex items-center text-lg">
|
||||
<Calendar className="w-5 h-5 mr-2" />
|
||||
|
||||
@@ -18,6 +18,7 @@ import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||
import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
|
||||
interface GalleryViewProps {
|
||||
slug: string;
|
||||
@@ -48,9 +49,21 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false);
|
||||
const [selectedPhotos, setSelectedPhotos] = useState<Set<number>>(new Set());
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const { watermarkEnabled } = useWatermarkSettings();
|
||||
|
||||
// Fetch photos
|
||||
const { data, isLoading, error } = useGalleryPhotos(slug);
|
||||
|
||||
// Debug logging
|
||||
useEffect(() => {
|
||||
console.log('Event prop:', event);
|
||||
console.log('Event prop hero_photo_id:', event?.hero_photo_id);
|
||||
if (data) {
|
||||
console.log('Gallery data:', data);
|
||||
console.log('Event data from API:', data.event);
|
||||
console.log('Hero photo ID from API:', data.event?.hero_photo_id);
|
||||
}
|
||||
}, [data, event]);
|
||||
const downloadAllMutation = useDownloadAllPhotos();
|
||||
|
||||
// Handle window resize
|
||||
@@ -88,18 +101,19 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
|
||||
// Apply theme when settings are loaded
|
||||
useEffect(() => {
|
||||
if (settingsData && event) {
|
||||
if (settingsData && data?.event) {
|
||||
let themeToApply = null;
|
||||
const fullEvent = data.event; // Use the full event data from API
|
||||
|
||||
if (event.color_theme) {
|
||||
if (fullEvent.color_theme) {
|
||||
try {
|
||||
// Check if it's a valid JSON string
|
||||
if (event.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(event.color_theme);
|
||||
if (fullEvent.color_theme.startsWith('{')) {
|
||||
const eventTheme = JSON.parse(fullEvent.color_theme);
|
||||
themeToApply = eventTheme;
|
||||
} else {
|
||||
// Handle legacy theme names - check if it's a preset
|
||||
const preset = GALLERY_THEME_PRESETS[event.color_theme];
|
||||
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
||||
if (preset) {
|
||||
themeToApply = preset.config;
|
||||
} else {
|
||||
@@ -126,10 +140,12 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
// Use setTimeout to ensure this runs after any global theme application
|
||||
const timer = setTimeout(() => {
|
||||
// If there's a hero photo, add it to gallery settings
|
||||
if (event.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = event.hero_photo_id;
|
||||
} else if (event.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: event.hero_photo_id };
|
||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
||||
console.log('Setting hero photo ID in existing gallery settings:', fullEvent.hero_photo_id);
|
||||
} else if (fullEvent.hero_photo_id) {
|
||||
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
||||
console.log('Creating gallery settings with hero photo ID:', fullEvent.hero_photo_id);
|
||||
}
|
||||
setTheme(themeToApply);
|
||||
}, 0);
|
||||
@@ -137,7 +153,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
}, [settingsData, event, setTheme]); // Include all dependencies
|
||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
||||
|
||||
// Calculate days until expiration
|
||||
const daysUntilExpiration = differenceInDays(parseISO(event.expires_at), new Date());
|
||||
@@ -175,8 +191,17 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
});
|
||||
|
||||
// Transform URLs for watermarks if enabled
|
||||
if (watermarkEnabled) {
|
||||
photos = photos.map(photo => ({
|
||||
...photo,
|
||||
url: `/api/gallery/${slug}/photo/${photo.id}`,
|
||||
thumbnail_url: `/api/gallery/${slug}/photo/${photo.id}` // Use watermarked version for thumbnails too
|
||||
}));
|
||||
}
|
||||
|
||||
return photos;
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy]);
|
||||
}, [data?.photos, selectedCategoryId, searchTerm, sortBy, watermarkEnabled, slug]);
|
||||
|
||||
const handleDownloadAll = () => {
|
||||
downloadAllMutation.mutate(slug);
|
||||
@@ -307,7 +332,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
totalPhotos={data?.photos.length || 0}
|
||||
isMobile={isMobile}
|
||||
galleryLayout={theme.galleryLayout}
|
||||
allowUploads={event.allow_user_uploads}
|
||||
allowUploads={data?.event?.allow_user_uploads || event?.allow_user_uploads || false}
|
||||
onUploadClick={() => setShowUploadModal(true)}
|
||||
/>
|
||||
) : null}
|
||||
@@ -320,23 +345,24 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
showDownloadAll={!showSidebar}
|
||||
onDownloadAll={handleDownloadAll}
|
||||
isDownloading={downloadAllMutation.isPending}
|
||||
menuButton={showSidebar ? (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.menu')}</span>
|
||||
</Button>
|
||||
) : undefined}
|
||||
headerExtra={(() => {
|
||||
const items = [];
|
||||
|
||||
if (showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="menu-button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.menu')}</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
console.log('Header extra - data loaded:', !!data);
|
||||
console.log('Header extra - allow uploads:', data?.event?.allow_user_uploads);
|
||||
console.log('Header extra - showSidebar:', showSidebar);
|
||||
console.log('Header extra - isMobile:', isMobile);
|
||||
|
||||
if (daysUntilExpiration <= 1 && daysUntilExpiration > 0) {
|
||||
items.push(
|
||||
@@ -345,7 +371,8 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
// Upload button only on desktop when sidebar is shown
|
||||
if (event.allow_user_uploads && showSidebar && !isMobile) {
|
||||
const allowUploads = data?.event?.allow_user_uploads || event?.allow_user_uploads;
|
||||
if (allowUploads && showSidebar && !isMobile) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
@@ -360,7 +387,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
}
|
||||
|
||||
// Upload button for non-sidebar layouts
|
||||
if (event.allow_user_uploads && !showSidebar) {
|
||||
if (allowUploads && !showSidebar) {
|
||||
items.push(
|
||||
<Button
|
||||
key="upload-button"
|
||||
@@ -376,7 +403,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
);
|
||||
}
|
||||
|
||||
return <>{items}</>;
|
||||
return items.length > 0 ? <>{items}</> : null;
|
||||
})()}
|
||||
>
|
||||
{/* Expiration Banner */}
|
||||
@@ -420,10 +447,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
</div>
|
||||
|
||||
{/* Upload Modal */}
|
||||
{showUploadModal && (
|
||||
{showUploadModal && (data?.event?.allow_user_uploads || event?.allow_user_uploads) && (
|
||||
<UserPhotoUpload
|
||||
eventId={event.id}
|
||||
categoryId={event.upload_category_id}
|
||||
eventId={data?.event?.id || event?.id}
|
||||
categoryId={data?.event?.upload_category_id || event?.upload_category_id}
|
||||
onUploadComplete={() => {
|
||||
setShowUploadModal(false);
|
||||
// Refetch photos after upload
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useState } from 'react';
|
||||
import { Upload, X, CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'react-toastify';
|
||||
import { Button, Card, CardContent } from '../common';
|
||||
import { Button } from '../common';
|
||||
import { api } from '../../config/api';
|
||||
|
||||
interface UserPhotoUploadProps {
|
||||
@@ -114,7 +114,7 @@ export const UserPhotoUpload: React.FC<UserPhotoUploadProps> = ({
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-end sm:items-center justify-center z-50 p-0 sm:p-4">
|
||||
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-t-2xl sm:rounded-2xl shadow-xl">
|
||||
<div className="w-full sm:max-w-2xl bg-white flex flex-col max-h-[100vh] sm:max-h-[90vh] rounded-2xl shadow-xl overflow-hidden">
|
||||
{/* Fixed Header */}
|
||||
<div className="flex items-center justify-between p-4 sm:p-6 border-b border-neutral-200 flex-shrink-0">
|
||||
<h2 className="text-lg sm:text-xl font-semibold text-neutral-900">{t('upload.uploadPhotos')}</h2>
|
||||
|
||||
@@ -31,24 +31,49 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
const { format } = useLocalizedDate();
|
||||
const { theme } = useTheme();
|
||||
const [heroPhoto, setHeroPhoto] = useState<Photo | null>(null);
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
const gallerySettings = theme.gallerySettings || {};
|
||||
const overlayOpacity = gallerySettings.heroOverlayOpacity || 0.3;
|
||||
|
||||
// Select hero photo (first photo or specified one)
|
||||
// Reset initialization when heroImageId changes
|
||||
useEffect(() => {
|
||||
if (gallerySettings.heroImageId) {
|
||||
setHasInitialized(false);
|
||||
}
|
||||
}, [gallerySettings.heroImageId]);
|
||||
|
||||
// Select hero photo (admin-selected or first photo only if gallery was empty)
|
||||
useEffect(() => {
|
||||
if (photos.length > 0) {
|
||||
const heroId = gallerySettings.heroImageId;
|
||||
const hero = heroId ? photos.find(p => p.id === heroId) : photos[0];
|
||||
setHeroPhoto(hero || photos[0]);
|
||||
console.log('HeroGalleryLayout - heroImageId:', heroId, 'photos:', photos.length);
|
||||
|
||||
// If admin has selected a specific hero image, always use it
|
||||
if (heroId) {
|
||||
const adminSelectedHero = photos.find(p => p.id === heroId);
|
||||
console.log('Looking for hero photo with ID:', heroId, 'Found:', adminSelectedHero?.filename);
|
||||
if (adminSelectedHero) {
|
||||
setHeroPhoto(adminSelectedHero);
|
||||
setHasInitialized(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only auto-select first photo on initial load when gallery was empty
|
||||
// This prevents changing the hero when new photos are uploaded
|
||||
if (!hasInitialized) {
|
||||
setHeroPhoto(photos[0]);
|
||||
setHasInitialized(true);
|
||||
}
|
||||
}
|
||||
}, [photos, gallerySettings.heroImageId]);
|
||||
}, [photos, gallerySettings.heroImageId, hasInitialized]);
|
||||
|
||||
if (!heroPhoto) return null;
|
||||
|
||||
const remainingPhotos = photos.filter(p => p.id !== heroPhoto.id);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="relative -mt-6">
|
||||
{/* Hero Section */}
|
||||
<div className="relative h-[60vh] sm:h-[70vh] lg:h-[80vh] -mx-4 sm:-mx-6 lg:-mx-8 mb-8">
|
||||
<AuthenticatedImage
|
||||
@@ -67,17 +92,20 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
{/* Hero Content */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
{/* Logo */}
|
||||
{eventLogo && (
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto drop-shadow-lg filter brightness-0 invert"
|
||||
style={{ filter: 'drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
<div className="mb-6">
|
||||
<img
|
||||
src={eventLogo ?
|
||||
`${import.meta.env.VITE_API_URL || 'http://localhost:3001'}${eventLogo}` :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
alt="Event logo"
|
||||
className="h-20 sm:h-24 lg:h-32 mx-auto"
|
||||
style={{
|
||||
filter: 'brightness(0) invert(1) drop-shadow(0 4px 6px rgba(0, 0, 0, 0.5))'
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Event Title */}
|
||||
{eventName && (
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { settingsService } from '../services/settings.service';
|
||||
|
||||
export function useWatermarkSettings() {
|
||||
const [watermarkEnabled, setWatermarkEnabled] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchSettings = async () => {
|
||||
try {
|
||||
const settings = await settingsService.getSettingsByType('branding');
|
||||
const brandingSettings = settingsService.formatBrandingSettings(settings);
|
||||
setWatermarkEnabled(brandingSettings.watermark_enabled);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch watermark settings:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchSettings();
|
||||
}, []);
|
||||
|
||||
return { watermarkEnabled, loading };
|
||||
}
|
||||
@@ -227,6 +227,8 @@
|
||||
"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.",
|
||||
"extendSevenDays": "Um 7 Tage verlängern",
|
||||
"overview": "Übersicht",
|
||||
"photos": "Fotos",
|
||||
"categories": "Kategorien",
|
||||
@@ -266,7 +268,7 @@
|
||||
"colorTheme": "Farbthema",
|
||||
"galleryExpiration": "Galerie-Ablauf",
|
||||
"galleryExpiresIn": "Galerie läuft ab in",
|
||||
"daysAfterEvent": "Tage nach der Veranstaltung",
|
||||
"daysAfterEvent": "Tage nach dem Veranstaltungsdatum",
|
||||
"expiresOn": "Läuft ab am",
|
||||
"themeAndStyle": "Design & Stil",
|
||||
"galleryWillExpireOn": "Galerie läuft ab am {{date}}",
|
||||
@@ -279,6 +281,12 @@
|
||||
"selectCategory": "Wählen Sie eine Kategorie für Benutzer-Uploads",
|
||||
"uploadCategoryHelp": "Alle von Benutzern hochgeladenen Fotos werden dieser Kategorie hinzugefügt",
|
||||
"userUploadWarning": "Benutzer-Uploads werden moderiert und können jederzeit von Administratoren entfernt werden.",
|
||||
"heroPhoto": "Hero-Foto",
|
||||
"heroPhotoHelp": "Wählen Sie ein hervorgehobenes Foto für das Hero-Galerie-Layout",
|
||||
"selectHeroPhoto": "Hero-Foto auswählen",
|
||||
"noHeroPhotoSelected": "Kein Hero-Foto ausgewählt",
|
||||
"heroPhotoSelected": "Hero-Foto ausgewählt",
|
||||
"noPhotosAvailable": "Keine Fotos verfügbar",
|
||||
"processingRequest": "Ihre Anfrage wird verarbeitet...",
|
||||
"eventTypeWedding": "Hochzeit",
|
||||
"eventTypeBirthday": "Geburtstag",
|
||||
@@ -324,6 +332,8 @@
|
||||
"loadingEvents": "Veranstaltungen werden geladen...",
|
||||
"failedToLoadEvents": "Veranstaltungen konnten nicht geladen werden",
|
||||
"tryAgain": "Erneut versuchen",
|
||||
"eventExpiredMessage": "Diese Veranstaltung ist abgelaufen",
|
||||
"guestsCannotAccessGallery": "Gäste können nicht mehr auf die Galerie zugreifen. Erwägen Sie, diese Veranstaltung zu archivieren.",
|
||||
"bulkArchiveSuccess": "{{count}} Veranstaltungen erfolgreich archiviert",
|
||||
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
|
||||
"searchEventsPlaceholder": "Veranstaltungen suchen...",
|
||||
@@ -458,6 +468,7 @@
|
||||
"titleFull": "Branding & Anpassung",
|
||||
"subtitle": "Passen Sie das Aussehen Ihrer Galerien an",
|
||||
"loadingBranding": "Branding-Einstellungen werden geladen...",
|
||||
"themeAndStyle": "Theme & Stil",
|
||||
"companyInfo": "Unternehmensinformationen",
|
||||
"companyName": "Unternehmensname",
|
||||
"companyNameHelp": "Wird in Galerie-Headern und E-Mails angezeigt",
|
||||
@@ -598,6 +609,14 @@
|
||||
"totalPhotos": "Gesamte Fotos",
|
||||
"storagePercent": "{{percent}}% von {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archivierte Veranstaltungen",
|
||||
"systemHealth": "Systemstatus",
|
||||
"health": {
|
||||
"healthy": "Gesund",
|
||||
"warning": "Warnung",
|
||||
"error": "Fehler",
|
||||
"checking": "Prüfe..."
|
||||
},
|
||||
"notifications": "Benachrichtigungen",
|
||||
"viewAllNotifications": "Alle Benachrichtigungen anzeigen",
|
||||
"noNotifications": "Keine neuen Benachrichtigungen",
|
||||
@@ -664,7 +683,29 @@
|
||||
"viewAllActivity": "Alle Aktivitäten anzeigen",
|
||||
"quickActions": "Schnellaktionen",
|
||||
"viewArchives": "Archive anzeigen",
|
||||
"analytics": "Analytik"
|
||||
"analytics": "Analytik",
|
||||
"activities": {
|
||||
"event_created": "Neue Veranstaltung erstellt: {{eventName}}",
|
||||
"photos_uploaded": "{{count}} Fotos hochgeladen zu {{eventName}}",
|
||||
"event_archived": "Veranstaltung archiviert: {{eventName}}",
|
||||
"archive_restored": "Archiv wiederhergestellt: {{eventName}}",
|
||||
"archive_deleted": "Archiv gelöscht: {{eventName}}",
|
||||
"archive_downloaded": "Archiv heruntergeladen: {{eventName}}",
|
||||
"email_config_updated": "E-Mail-Konfiguration aktualisiert",
|
||||
"email_template_updated": "E-Mail-Vorlage aktualisiert: {{template}}",
|
||||
"branding_updated": "Branding-Einstellungen aktualisiert",
|
||||
"theme_updated": "Theme-Einstellungen aktualisiert",
|
||||
"bulk_download": "{{count}} Fotos heruntergeladen von {{eventName}}",
|
||||
"gallery_password_entry": "Passwort eingegeben für {{eventName}}",
|
||||
"expiration_warning_viewed": "Ablaufwarnung angesehen für {{eventName}}",
|
||||
"settings_updated": "Einstellungen aktualisiert",
|
||||
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
||||
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
||||
"category_created": "Kategorie erstellt: {{categoryName}}",
|
||||
"category_updated": "Kategorie aktualisiert: {{categoryName}}",
|
||||
"category_deleted": "Kategorie gelöscht: {{categoryName}}",
|
||||
"unknown": "Unbekannte Aktivität"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Nicht gefunden",
|
||||
|
||||
@@ -185,6 +185,7 @@
|
||||
},
|
||||
"events": {
|
||||
"title": "Events",
|
||||
"create": "Create",
|
||||
"createEvent": "Create Event",
|
||||
"totalViews": "Total Views",
|
||||
"totalDownloads": "Total Downloads",
|
||||
@@ -198,7 +199,9 @@
|
||||
"hostEmailPlaceholder": "host@example.com",
|
||||
"adminEmailPlaceholder": "admin@example.com",
|
||||
"securityAndAccess": "Security & Access",
|
||||
"accessAndSecurity": "Access & Security",
|
||||
"enterPassword": "Enter password",
|
||||
"passwordPlaceholder": "Enter a secure password",
|
||||
"confirmPasswordPlaceholder": "Confirm password",
|
||||
"galleryExpiresOn": "Gallery will expire on {{date}}",
|
||||
"guestsWillReceiveWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||
@@ -279,13 +282,18 @@
|
||||
"confirmPassword": "Confirm Password",
|
||||
"showPasswords": "Show passwords",
|
||||
"gallerySettings": "Gallery Settings",
|
||||
"themeAndStyle": "Theme & Style",
|
||||
"colorTheme": "Color Theme",
|
||||
"galleryExpiration": "Gallery Expiration",
|
||||
"galleryExpiresIn": "Gallery Expires In",
|
||||
"daysAfterEvent": "days after event date",
|
||||
"expiresOn": "Expires on",
|
||||
"galleryWillExpireOn": "Gallery will expire on {{date}}",
|
||||
"expirationWarning": "Guests will receive a warning email 7 days before expiration.",
|
||||
"userUploads": "User Upload Settings",
|
||||
"allowUserUploads": "Allow guests to upload photos",
|
||||
"allowUserUploadsHelp": "Enable guests to upload their own photos to this gallery",
|
||||
"allowUserUploadsDescription": "Enable guests to upload their own photos to this gallery",
|
||||
"uploadCategory": "Upload Category",
|
||||
"selectCategory": "Select a category for user uploads",
|
||||
"uploadCategoryHelp": "All user-uploaded photos will be added to this category",
|
||||
@@ -513,6 +521,7 @@
|
||||
"titleFull": "Branding & Customization",
|
||||
"subtitle": "Customize the look and feel of your galleries",
|
||||
"loadingBranding": "Loading branding settings...",
|
||||
"themeAndStyle": "Theme & Style",
|
||||
"companyInfo": "Company Information",
|
||||
"companyName": "Company Name",
|
||||
"companyNameHelp": "Displayed in gallery headers and emails",
|
||||
@@ -653,6 +662,14 @@
|
||||
"totalPhotos": "Total Photos",
|
||||
"storagePercent": "{{percent}}% of {{limit}}",
|
||||
"version": "Version",
|
||||
"archivedEvents": "Archived Events",
|
||||
"systemHealth": "System Health",
|
||||
"health": {
|
||||
"healthy": "Healthy",
|
||||
"warning": "Warning",
|
||||
"error": "Error",
|
||||
"checking": "Checking..."
|
||||
},
|
||||
"notifications": "Notifications",
|
||||
"viewAllNotifications": "View all notifications",
|
||||
"noNotifications": "No new notifications",
|
||||
@@ -717,7 +734,29 @@
|
||||
"viewAllActivity": "View all activity",
|
||||
"quickActions": "Quick Actions",
|
||||
"viewArchives": "View Archives",
|
||||
"analytics": "Analytics"
|
||||
"analytics": "Analytics",
|
||||
"activities": {
|
||||
"event_created": "New event created: {{eventName}}",
|
||||
"photos_uploaded": "{{count}} photos uploaded to {{eventName}}",
|
||||
"event_archived": "Event archived: {{eventName}}",
|
||||
"archive_restored": "Archive restored: {{eventName}}",
|
||||
"archive_deleted": "Archive deleted: {{eventName}}",
|
||||
"archive_downloaded": "Archive downloaded: {{eventName}}",
|
||||
"email_config_updated": "Email configuration updated",
|
||||
"email_template_updated": "Email template updated: {{template}}",
|
||||
"branding_updated": "Branding settings updated",
|
||||
"theme_updated": "Theme settings updated",
|
||||
"bulk_download": "{{count}} photos downloaded from {{eventName}}",
|
||||
"gallery_password_entry": "Password entered for {{eventName}}",
|
||||
"expiration_warning_viewed": "Expiration warning viewed for {{eventName}}",
|
||||
"settings_updated": "Settings updated",
|
||||
"event_updated": "Event updated: {{eventName}}",
|
||||
"event_deleted": "Event deleted: {{eventName}}",
|
||||
"category_created": "Category created: {{categoryName}}",
|
||||
"category_updated": "Category updated: {{categoryName}}",
|
||||
"category_deleted": "Category deleted: {{categoryName}}",
|
||||
"unknown": "Unknown activity"
|
||||
}
|
||||
},
|
||||
"errors": {
|
||||
"notFound": "Not Found",
|
||||
|
||||
+75
-18
@@ -186,38 +186,95 @@
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* Custom range slider styles */
|
||||
.slider {
|
||||
/* Custom range slider styles - Updated */
|
||||
input[type="range"].slider {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #d4d4d4 !important;
|
||||
border-radius: 4px;
|
||||
outline: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-track {
|
||||
@apply bg-neutral-200 h-2 rounded-lg;
|
||||
/* Webkit browsers like Chrome/Safari */
|
||||
input[type="range"].slider::-webkit-slider-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #d4d4d4 !important;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider::-moz-range-track {
|
||||
@apply bg-neutral-200 h-2 rounded-lg;
|
||||
}
|
||||
|
||||
.slider::-webkit-slider-thumb {
|
||||
input[type="range"].slider::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all;
|
||||
margin-top: -6px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #5C8762;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
margin-top: -6px; /* Center the thumb vertically */
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.slider::-moz-range-thumb {
|
||||
@apply bg-primary-600 h-5 w-5 rounded-full cursor-pointer transition-all border-0;
|
||||
input[type="range"].slider::-webkit-slider-thumb:hover {
|
||||
background: #4a6f4f;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.slider:hover::-webkit-slider-thumb {
|
||||
@apply bg-primary-700 scale-110;
|
||||
/* Firefox */
|
||||
input[type="range"].slider::-moz-range-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: #d4d4d4 !important;
|
||||
border-radius: 4px;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.slider:hover::-moz-range-thumb {
|
||||
@apply bg-primary-700 scale-110;
|
||||
input[type="range"].slider::-moz-range-thumb {
|
||||
border: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #5C8762;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type="range"].slider::-moz-range-thumb:hover {
|
||||
background: #4a6f4f;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* IE/Edge */
|
||||
input[type="range"].slider::-ms-track {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: transparent;
|
||||
border-color: transparent;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
input[type="range"].slider::-ms-fill-lower {
|
||||
background: #d4d4d4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"].slider::-ms-fill-upper {
|
||||
background: #d4d4d4;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
input[type="range"].slider::-ms-thumb {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: #5C8762;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ import {
|
||||
Clock,
|
||||
Plus,
|
||||
HardDrive,
|
||||
Image
|
||||
Image,
|
||||
Archive,
|
||||
Heart
|
||||
} from 'lucide-react';
|
||||
import { differenceInDays, parseISO } from 'date-fns';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -44,6 +46,13 @@ export const AdminDashboard: React.FC = () => {
|
||||
queryFn: () => adminService.getRecentActivity(10),
|
||||
});
|
||||
|
||||
// Fetch system health
|
||||
const { data: systemHealth } = useQuery({
|
||||
queryKey: ['admin-system-health'],
|
||||
queryFn: () => adminService.getSystemHealth(),
|
||||
refetchInterval: 30000, // Refresh every 30 seconds
|
||||
});
|
||||
|
||||
// Fetch events data for expiring events
|
||||
const { data: eventsData, isLoading: eventsLoading } = useQuery({
|
||||
queryKey: ['admin-events-summary'],
|
||||
@@ -74,7 +83,7 @@ export const AdminDashboard: React.FC = () => {
|
||||
return num.toString();
|
||||
};
|
||||
|
||||
// Build statistics cards
|
||||
// Build statistics cards - always show 8 cards in 2x4 grid
|
||||
const stats: StatCard[] = [
|
||||
{
|
||||
title: t('admin.activeEvents'),
|
||||
@@ -101,28 +110,34 @@ export const AdminDashboard: React.FC = () => {
|
||||
icon: HardDrive,
|
||||
color: 'text-purple-600',
|
||||
},
|
||||
{
|
||||
title: t('admin.totalViews'),
|
||||
value: formatNumber(dashboardStats?.totalViews || 0),
|
||||
change: dashboardStats?.viewsTrend ? t('admin.percentFromLastWeek', { percent: `${dashboardStats.viewsTrend > 0 ? '+' : ''}${dashboardStats.viewsTrend}` }) : undefined,
|
||||
icon: Eye,
|
||||
color: 'text-indigo-600',
|
||||
},
|
||||
{
|
||||
title: t('admin.downloads'),
|
||||
value: formatNumber(dashboardStats?.totalDownloads || 0),
|
||||
change: dashboardStats?.downloadsTrend ? t('admin.percentFromLastWeek', { percent: `${dashboardStats.downloadsTrend > 0 ? '+' : ''}${dashboardStats.downloadsTrend}` }) : undefined,
|
||||
icon: Download,
|
||||
color: 'text-pink-600',
|
||||
},
|
||||
{
|
||||
title: t('admin.archivedEvents'),
|
||||
value: dashboardStats?.archivedEvents || 0,
|
||||
icon: Archive,
|
||||
color: 'text-gray-600',
|
||||
},
|
||||
{
|
||||
title: t('admin.systemHealth'),
|
||||
value: systemHealth ? t(`admin.health.${systemHealth.overall}`) : t('admin.health.checking'),
|
||||
icon: Heart,
|
||||
color: systemHealth?.overall === 'healthy' ? 'text-green-600' : systemHealth?.overall === 'warning' ? 'text-yellow-600' : 'text-red-600',
|
||||
},
|
||||
];
|
||||
|
||||
// Add second row of stats if we have trend data
|
||||
if (dashboardStats?.totalViews !== undefined) {
|
||||
stats.push(
|
||||
{
|
||||
title: t('admin.totalViews'),
|
||||
value: formatNumber(dashboardStats.totalViews),
|
||||
change: dashboardStats.viewsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.viewsTrend}` }) : undefined,
|
||||
icon: Eye,
|
||||
color: 'text-indigo-600',
|
||||
},
|
||||
{
|
||||
title: t('admin.downloads'),
|
||||
value: formatNumber(dashboardStats.totalDownloads),
|
||||
change: dashboardStats.downloadsTrend > 0 ? t('admin.percentFromLastWeek', { percent: `+${dashboardStats.downloadsTrend}` }) : undefined,
|
||||
icon: Download,
|
||||
color: 'text-pink-600',
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Page Header */}
|
||||
@@ -243,12 +258,31 @@ export const AdminDashboard: React.FC = () => {
|
||||
return colors[type] || 'bg-gray-500';
|
||||
};
|
||||
|
||||
// Format activity message with translations
|
||||
const getActivityMessage = (): string => {
|
||||
const translationKey = `admin.activities.${activity.type}`;
|
||||
const params: Record<string, any> = {
|
||||
eventName: activity.eventName || t('common.unknown'),
|
||||
count: activity.metadata?.count || 0,
|
||||
template: activity.metadata?.template_key || '',
|
||||
categoryName: activity.metadata?.category_name || ''
|
||||
};
|
||||
|
||||
// Check if translation exists
|
||||
const translated = t(translationKey, params);
|
||||
if (typeof translated === 'string') {
|
||||
return translated;
|
||||
}
|
||||
// Fallback to unknown activity if translation not found
|
||||
return t('admin.activities.unknown') as string;
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={activity.id} className="flex items-start gap-3">
|
||||
<div className={`w-2 h-2 rounded-full mt-1.5 flex-shrink-0 ${getActivityColor(activity.type)}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-neutral-900 break-words">
|
||||
{adminService.formatActivityMessage(activity)}
|
||||
{getActivityMessage()}
|
||||
</p>
|
||||
<p className="text-xs text-neutral-500">{activity.actorName}</p>
|
||||
<p className="text-xs text-neutral-400 mt-1">
|
||||
@@ -261,14 +295,6 @@ export const AdminDashboard: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{recentActivity && recentActivity.length > 5 && (
|
||||
<button
|
||||
onClick={() => navigate('/admin/activity')}
|
||||
className="w-full mt-4 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
{t('admin.viewAllActivity')} →
|
||||
</button>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -270,21 +270,6 @@ export const BrandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingSettings.watermark_enabled}
|
||||
onChange={(e) => handleBrandingChange('watermark_enabled', 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.enableWatermarks')}</span>
|
||||
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
@@ -330,6 +315,21 @@ export const BrandingPage: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-6 border-t border-neutral-200">
|
||||
<label className="flex items-center gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={brandingSettings.watermark_enabled}
|
||||
onChange={(e) => handleBrandingChange('watermark_enabled', 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.enableWatermarks')}</span>
|
||||
<p className="text-xs text-neutral-600">{t('branding.watermarkHelp')}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Watermark Settings */}
|
||||
{brandingSettings.watermark_enabled && (
|
||||
<div className="mt-6 space-y-6 border-t border-neutral-200 pt-6">
|
||||
@@ -422,7 +422,15 @@ export const BrandingPage: React.FC = () => {
|
||||
step="10"
|
||||
value={brandingSettings.watermark_opacity || 50}
|
||||
onChange={(e) => handleBrandingChange('watermark_opacity', parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
className="w-full slider"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
height: '8px',
|
||||
background: '#d4d4d4',
|
||||
borderRadius: '4px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||
<span>10%</span>
|
||||
@@ -443,7 +451,15 @@ export const BrandingPage: React.FC = () => {
|
||||
step="5"
|
||||
value={brandingSettings.watermark_size || 15}
|
||||
onChange={(e) => handleBrandingChange('watermark_size', parseInt(e.target.value))}
|
||||
className="w-full h-2 bg-neutral-200 rounded-lg appearance-none cursor-pointer slider"
|
||||
className="w-full slider"
|
||||
style={{
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
height: '8px',
|
||||
background: '#d4d4d4',
|
||||
borderRadius: '4px',
|
||||
outline: 'none'
|
||||
}}
|
||||
/>
|
||||
<div className="flex justify-between text-xs text-neutral-500 mt-1">
|
||||
<span>5%</span>
|
||||
|
||||
@@ -235,10 +235,14 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
};
|
||||
|
||||
const handlePresetChange = (presetName: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
theme_preset: presetName
|
||||
}));
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
theme_preset: presetName,
|
||||
theme_config: preset.config
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -383,7 +387,7 @@ export const CreateEventPageEnhanced: React.FC = () => {
|
||||
onChange={handleThemeChange}
|
||||
presetName={formData.theme_preset}
|
||||
onPresetChange={handlePresetChange}
|
||||
isPreviewMode={false}
|
||||
isPreviewMode={true}
|
||||
showGalleryLayouts={true}
|
||||
hideActions={true}
|
||||
/>
|
||||
|
||||
@@ -15,16 +15,14 @@ import {
|
||||
CheckCircle,
|
||||
Upload,
|
||||
Image,
|
||||
Key,
|
||||
Palette,
|
||||
Settings
|
||||
Key
|
||||
} from 'lucide-react';
|
||||
import { parseISO, differenceInDays } from 'date-fns';
|
||||
import { toast } from 'react-toastify';
|
||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||
|
||||
import { Button, Input, Card, Loading } from '../../components/common';
|
||||
import { PhotoUpload, EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeDisplay, ThemeCustomizerEnhanced, ThemeEditorModal, HeroPhotoSelector, PhotoUploadModal } from '../../components/admin';
|
||||
import { EventCategoryManager, AdminPhotoGrid, AdminPhotoViewer, PhotoFilters, PasswordResetModal, ThemeCustomizerEnhanced, ThemeDisplay, HeroPhotoSelector, PhotoUploadModal } from '../../components/admin';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { eventsService } from '../../services/events.service';
|
||||
import { archiveService } from '../../services/archive.service';
|
||||
@@ -60,10 +58,8 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const [activeTab, setActiveTab] = useState<'overview' | 'photos' | 'categories'>('overview');
|
||||
const [selectedPhoto, setSelectedPhoto] = useState<{ photo: AdminPhoto; index: number } | null>(null);
|
||||
const [showPasswordReset, setShowPasswordReset] = useState(false);
|
||||
const [showThemeCustomizer, setShowThemeCustomizer] = useState(false);
|
||||
const [currentTheme, setCurrentTheme] = useState<ThemeConfig | null>(null);
|
||||
const [currentPresetName, setCurrentPresetName] = useState<string>('default');
|
||||
const [showThemeEditorModal, setShowThemeEditorModal] = useState(false);
|
||||
|
||||
// Photo filters state
|
||||
const [photoFilters, setPhotoFilters] = useState({
|
||||
@@ -203,7 +199,7 @@ export const EventDetailsPage: React.FC = () => {
|
||||
const handleSaveEdit = () => {
|
||||
// Prepare color_theme - if we have a custom theme, serialize it
|
||||
let themeToSave = editForm.color_theme;
|
||||
if (currentTheme && (currentPresetName === 'custom' || showThemeCustomizer)) {
|
||||
if (currentTheme && currentPresetName === 'custom') {
|
||||
themeToSave = JSON.stringify(currentTheme);
|
||||
} else if (currentPresetName && currentPresetName !== 'custom') {
|
||||
// Use preset name for non-custom themes
|
||||
@@ -256,34 +252,6 @@ export const EventDetailsPage: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeModalSave = async (theme: ThemeConfig, presetName: string) => {
|
||||
// Prepare theme for saving
|
||||
let themeToSave: string;
|
||||
if (presetName !== 'custom' && GALLERY_THEME_PRESETS[presetName]) {
|
||||
// Save preset name for standard presets
|
||||
themeToSave = presetName;
|
||||
} else {
|
||||
// Save full theme config for custom themes
|
||||
themeToSave = JSON.stringify(theme);
|
||||
}
|
||||
|
||||
try {
|
||||
// Update the event with new theme
|
||||
await eventsService.updateEvent(parseInt(id!), {
|
||||
color_theme: themeToSave
|
||||
});
|
||||
|
||||
// Invalidate queries to refresh the data
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-event', id] });
|
||||
|
||||
// Close modal and show success
|
||||
setShowThemeEditorModal(false);
|
||||
toast.success(t('toast.themeUpdated'));
|
||||
} catch (error) {
|
||||
console.error('Failed to save theme:', error);
|
||||
toast.error(t('toast.saveError'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -492,73 +460,6 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||
{t('events.galleryTheme')}
|
||||
</label>
|
||||
{!showThemeCustomizer ? (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
value={currentPresetName}
|
||||
onChange={(e) => {
|
||||
const presetName = e.target.value;
|
||||
setCurrentPresetName(presetName);
|
||||
if (presetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setCurrentTheme(preset.config);
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||
>
|
||||
{Object.entries(GALLERY_THEME_PRESETS).map(([key, preset]) => (
|
||||
<option key={key} value={key}>
|
||||
{preset.name}
|
||||
</option>
|
||||
))}
|
||||
<option value="custom">{t('branding.customTheme')}</option>
|
||||
</select>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Settings className="w-4 h-4" />}
|
||||
onClick={() => setShowThemeCustomizer(true)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('branding.customizeTheme')}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-neutral-600">{t('branding.customizingTheme')}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setShowThemeCustomizer(false)}
|
||||
>
|
||||
{t('common.hide')}
|
||||
</Button>
|
||||
</div>
|
||||
<ThemeCustomizerEnhanced
|
||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||
onChange={setCurrentTheme}
|
||||
presetName={currentPresetName}
|
||||
onPresetChange={(presetName) => {
|
||||
setCurrentPresetName(presetName);
|
||||
if (presetName !== 'custom') {
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}}
|
||||
isPreviewMode={false}
|
||||
showGalleryLayouts={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Hero Photo Selection */}
|
||||
<HeroPhotoSelector
|
||||
photos={photos || []}
|
||||
@@ -822,28 +723,44 @@ export const EventDetailsPage: React.FC = () => {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{/* Gallery Theme */}
|
||||
<Card padding="md">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('events.galleryTheme')}</h2>
|
||||
{!event.is_archived && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
leftIcon={<Palette className="w-4 h-4" />}
|
||||
onClick={() => setShowThemeEditorModal(true)}
|
||||
>
|
||||
{t('events.customizeTheme')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ThemeDisplay
|
||||
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
||||
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
||||
showDetails={true}
|
||||
/>
|
||||
</Card>
|
||||
{/* Theme & Style */}
|
||||
{isEditing && !event.is_archived && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.themeAndStyle')}</h2>
|
||||
<ThemeCustomizerEnhanced
|
||||
value={currentTheme || GALLERY_THEME_PRESETS.default.config}
|
||||
onChange={(theme) => {
|
||||
setCurrentTheme(theme);
|
||||
setEditForm(prev => ({ ...prev, color_theme: JSON.stringify(theme) }));
|
||||
}}
|
||||
presetName={currentPresetName}
|
||||
onPresetChange={(presetName) => {
|
||||
setCurrentPresetName(presetName);
|
||||
if (presetName !== 'custom') {
|
||||
const preset = GALLERY_THEME_PRESETS[presetName];
|
||||
if (preset) {
|
||||
setCurrentTheme(preset.config);
|
||||
setEditForm(prev => ({ ...prev, color_theme: presetName }));
|
||||
}
|
||||
}
|
||||
}}
|
||||
isPreviewMode={false}
|
||||
showGalleryLayouts={true}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Theme Display (when not editing) */}
|
||||
{!isEditing && !event.is_archived && (
|
||||
<Card padding="md">
|
||||
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('events.galleryTheme')}</h2>
|
||||
<ThemeDisplay
|
||||
theme={event.color_theme || GALLERY_THEME_PRESETS.default.config}
|
||||
presetName={event.color_theme && !event.color_theme.startsWith('{') ? event.color_theme : undefined}
|
||||
showDetails={true}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* Archive Status */}
|
||||
{event.is_archived ? (
|
||||
@@ -995,16 +912,6 @@ export const EventDetailsPage: React.FC = () => {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Theme Editor Modal */}
|
||||
{showThemeEditorModal && (
|
||||
<ThemeEditorModal
|
||||
isOpen={showThemeEditorModal}
|
||||
onClose={() => setShowThemeEditorModal(false)}
|
||||
onSave={handleThemeModalSave}
|
||||
currentTheme={event.color_theme || 'default'}
|
||||
eventName={event.event_name}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export { AdminLoginPage } from './AdminLoginPage';
|
||||
export { AdminDashboard } from './AdminDashboard';
|
||||
export { EventsListPage } from './EventsListPage';
|
||||
export { CreateEventPageEnhanced as CreateEventPage } from './CreateEventPageEnhanced';
|
||||
export { CreateEventPageEnhanced } from './CreateEventPageEnhanced';
|
||||
export { EventDetailsPage } from './EventDetailsPage';
|
||||
export { EmailConfigPage } from './EmailConfigPage';
|
||||
export { ArchivesPage } from './ArchivesPage';
|
||||
|
||||
@@ -46,7 +46,7 @@ export const LegalPage: React.FC = () => {
|
||||
// Update page title
|
||||
useEffect(() => {
|
||||
if (page?.title) {
|
||||
document.title = `${page.title} - Wedding Photo Sharing`;
|
||||
document.title = `${page.title} - PicPeak`;
|
||||
}
|
||||
}, [page?.title]);
|
||||
|
||||
@@ -129,7 +129,7 @@ export const LegalPage: React.FC = () => {
|
||||
</Link>
|
||||
</div>
|
||||
<p className="text-sm text-neutral-500 mt-4">
|
||||
© 2024 Wedding Photo Sharing. All rights reserved.
|
||||
© 2024 PicPeak. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -9,6 +9,29 @@ export interface DashboardStats {
|
||||
totalDownloads: number;
|
||||
viewsTrend: number;
|
||||
downloadsTrend: number;
|
||||
archivedEvents: number;
|
||||
}
|
||||
|
||||
export interface SystemHealth {
|
||||
overall: 'healthy' | 'warning' | 'error';
|
||||
services: {
|
||||
database: 'healthy' | 'warning' | 'error';
|
||||
email: 'healthy' | 'warning' | 'error';
|
||||
storage: 'healthy' | 'warning' | 'error';
|
||||
memory: 'healthy' | 'warning' | 'error';
|
||||
};
|
||||
details: {
|
||||
emailQueue: {
|
||||
pending: number;
|
||||
failed: number;
|
||||
};
|
||||
memory: {
|
||||
total: number;
|
||||
free: number;
|
||||
used: number;
|
||||
percentage: number;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
@@ -63,6 +86,12 @@ export const adminService = {
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// System health check
|
||||
async getSystemHealth(): Promise<SystemHealth> {
|
||||
const response = await api.get<SystemHealth>('/api/admin/dashboard/health');
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Format activity message
|
||||
formatActivityMessage(activity: Activity): string {
|
||||
const messages: Record<string, string> = {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Photo } from '../types';
|
||||
|
||||
interface PhotoUrlOptions {
|
||||
slug: string;
|
||||
photo: Photo;
|
||||
watermarkEnabled?: boolean;
|
||||
token?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the appropriate URL for a photo, using watermarked endpoint if enabled
|
||||
*/
|
||||
export function getPhotoUrl({ slug, photo, watermarkEnabled = false, token }: PhotoUrlOptions): string {
|
||||
if (watermarkEnabled && token) {
|
||||
// Use the watermarked photo endpoint
|
||||
return `/api/gallery/${slug}/photo/${photo.id}`;
|
||||
}
|
||||
|
||||
// Use the static photo URL
|
||||
return photo.url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the download URL for a photo
|
||||
*/
|
||||
export function getPhotoDownloadUrl(slug: string, photoId: number): string {
|
||||
return `/api/gallery/${slug}/download/${photoId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user