feat: Add CSS template system with custom gallery styling support
## Changes ### CSS Template System - Added CSS class hooks to gallery components for custom template targeting - Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates - CSS variables on :root allow themes to override colors, effects, and spacing ### Gallery Component CSS Classes Added - `.gallery-page` - Main gallery container - `.gallery-header` - Top header bar - `.gallery-sidebar` - Filter/download sidebar - `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close` - `.gallery-sidebar-content`, `.gallery-sidebar-section` - `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon` - `.gallery-sidebar-backdrop` - Mobile overlay - `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons - `.gallery-footer` - Footer section - `.photo-card`, `.photo-grid` - Photo display elements ### CSS Templates (Database) - Elegant Dark (id=1): Dark navy theme with light text and red accents - Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background ### Bug Fixes - Fixed CSS variables not inheriting (moved from .gallery-page to :root) - Fixed sidebar position breaking layout (removed position: relative override) - Fixed Elegant Dark sidebar text visibility (white on white issue) ### Other Changes - Settings page refactoring and cleanup - i18n locale updates for new gallery features - Vite proxy port configuration fix - Admin auth route improvements - CSS templates service updates
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid } from 'lucide-react';
|
||||
import { Palette, RotateCcw, Check, Layout, Type, Sparkles, Grid3X3, Layers, Play, Clock, Image, LayoutGrid, ChevronDown, Code, Info } from 'lucide-react';
|
||||
import { Button, Card, Input } from '../common';
|
||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
|
||||
// import { settingsService } from '../../services/settings.service';
|
||||
@@ -44,6 +44,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
||||
const [showCssInstructions, setShowCssInstructions] = useState(false);
|
||||
// const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -494,7 +495,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{/* Row 1: Font Size & Border Radius */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.fontSize')}
|
||||
@@ -525,7 +527,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Shadow Style & Background Pattern */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||
{t('branding.shadowStyle')}
|
||||
@@ -563,7 +568,93 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
|
||||
{/* Custom CSS */}
|
||||
<Card className="p-6">
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
|
||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||
<Code className="w-5 h-5" />
|
||||
{t('branding.customCSS')}
|
||||
</h3>
|
||||
|
||||
{/* Collapsible Instructions Panel */}
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCssInstructions(!showCssInstructions)}
|
||||
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||
>
|
||||
<Info className="w-4 h-4" />
|
||||
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
|
||||
<ChevronDown className={`w-4 h-4 transition-transform ${showCssInstructions ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
{showCssInstructions && (
|
||||
<div className="mt-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200 text-sm space-y-4">
|
||||
{/* Available CSS Variables */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
|
||||
</h4>
|
||||
<p className="text-neutral-600 mb-2">
|
||||
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
|
||||
--accent-color: ${localTheme.accentColor || '#22c55e'};
|
||||
--background-color: ${localTheme.backgroundColor || '#fafafa'};
|
||||
--text-color: ${localTheme.textColor || '#171717'};
|
||||
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
|
||||
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Custom Gallery Layouts */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
|
||||
</h4>
|
||||
<p className="text-neutral-600 mb-2">
|
||||
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
{`.gallery-container { /* Main gallery wrapper */ }
|
||||
.gallery-grid { /* Photo grid container */ }
|
||||
.gallery-item { /* Individual photo card */ }
|
||||
.gallery-header { /* Header section */ }
|
||||
.gallery-hero { /* Hero image area */ }
|
||||
.photo-overlay { /* Photo hover overlay */ }
|
||||
.photo-actions { /* Like/favorite buttons */ }`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Glassmorphism Example */}
|
||||
<div>
|
||||
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
|
||||
</h4>
|
||||
<p className="text-neutral-600 mb-2">
|
||||
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
|
||||
</p>
|
||||
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||
{`.glass-panel {
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||
border-radius: 16px;
|
||||
}`}
|
||||
</code>
|
||||
</div>
|
||||
|
||||
{/* Tips */}
|
||||
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||
<div className="text-blue-800 text-xs">
|
||||
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
|
||||
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<textarea
|
||||
value={customCss}
|
||||
onChange={(e) => {
|
||||
@@ -575,7 +666,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
||||
}
|
||||
}}
|
||||
placeholder="/* Add custom CSS here */"
|
||||
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
|
||||
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
|
||||
/>
|
||||
<p className="mt-2 text-sm text-neutral-600">
|
||||
{t('branding.customCSSHelp')}
|
||||
|
||||
@@ -118,12 +118,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
const heroLogoSize = getLogoDimensions('hero');
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-50">
|
||||
<div className="gallery-page min-h-screen bg-neutral-50">
|
||||
{/* Dynamic Favicon */}
|
||||
<DynamicFavicon />
|
||||
|
||||
{/* Header structure */}
|
||||
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||
{isNonGridLayout && (
|
||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||
@@ -145,12 +145,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
@@ -158,7 +159,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="sm:min-w-0"
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
@@ -184,14 +185,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||
{shouldShowLogo('header') && (
|
||||
<div className={`flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||
<img
|
||||
src={brandingSettings?.logo_url ?
|
||||
buildResourceUrl(brandingSettings.logo_url) :
|
||||
'/picpeak-logo-transparent.png'
|
||||
}
|
||||
}
|
||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||
className={`${headerLogoSize.className} w-auto object-contain`}
|
||||
className={`gallery-logo ${headerLogoSize.className} w-auto object-contain`}
|
||||
style={headerLogoSize.style}
|
||||
/>
|
||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||
@@ -253,13 +254,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="hidden sm:flex"
|
||||
className="gallery-btn gallery-btn-download hidden sm:flex"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
@@ -267,7 +268,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="sm:min-w-0"
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
@@ -304,7 +305,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
{menuButton}
|
||||
{headerExtra}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Right side - Action buttons */}
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
{/* Download all button */}
|
||||
@@ -315,12 +316,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
isLoading={isDownloading}
|
||||
className="gallery-btn gallery-btn-download"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||
<span className="sm:hidden">{t('common.download')}</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Logout button */}
|
||||
{showLogout && onLogout && (
|
||||
<Button
|
||||
@@ -328,7 +330,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
size="sm"
|
||||
leftIcon={<LogOut className="w-4 h-4" />}
|
||||
onClick={onLogout}
|
||||
className="sm:min-w-0"
|
||||
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||
>
|
||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||
</Button>
|
||||
@@ -341,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
|
||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||
{isNonGridLayout && (
|
||||
<div
|
||||
className="relative text-white overflow-hidden"
|
||||
<div
|
||||
className="gallery-hero relative text-white overflow-hidden"
|
||||
style={{
|
||||
backgroundColor: theme.accentColor || '#22c55e',
|
||||
backgroundImage: theme.backgroundPattern !== 'none'
|
||||
@@ -425,7 +427,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
||||
<main className="container">{children}</main>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
||||
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
||||
<div className="container text-center px-4">
|
||||
{brandingSettings?.support_email && (
|
||||
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
|
||||
|
||||
@@ -110,8 +110,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<>
|
||||
{/* Backdrop for mobile */}
|
||||
{isMobile && isOpen && (
|
||||
<div
|
||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
<div
|
||||
className="gallery-sidebar-backdrop fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
)}
|
||||
@@ -120,17 +120,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<div
|
||||
ref={sidebarRef}
|
||||
className={`
|
||||
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
||||
gallery-sidebar fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||
`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-neutral-200">
|
||||
<h2 className="gallery-sidebar-title text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
className="gallery-sidebar-close p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||
aria-label={t('common.close')}
|
||||
>
|
||||
<X className="w-5 h-5 text-neutral-600" />
|
||||
@@ -138,7 +138,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
|
||||
{/* Upload Section - Only show on mobile when uploads are allowed */}
|
||||
{isMobile && allowUploads && onUploadClick && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
@@ -159,15 +159,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Search Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-neutral-200">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchTerm}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
placeholder={t('gallery.searchPlaceholder')}
|
||||
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -175,12 +175,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
|
||||
{allowDownloads && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-neutral-200">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Download className="w-4 h-4" />
|
||||
{t('gallery.download')}
|
||||
</h3>
|
||||
|
||||
|
||||
<div className="space-y-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -188,7 +188,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadAll}
|
||||
disabled={isDownloading || totalPhotos === 0}
|
||||
className="w-full"
|
||||
className="gallery-btn gallery-btn-download w-full"
|
||||
>
|
||||
{t('gallery.downloadAll')} ({totalPhotos})
|
||||
</Button>
|
||||
@@ -197,7 +197,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
variant={isSelectionMode ? 'secondary' : 'outline'}
|
||||
size="sm"
|
||||
onClick={onToggleSelectionMode}
|
||||
className="w-full"
|
||||
className="gallery-btn w-full"
|
||||
>
|
||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||
</Button>
|
||||
@@ -209,7 +209,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
leftIcon={<Download className="w-4 h-4" />}
|
||||
onClick={onDownloadSelected}
|
||||
disabled={isDownloading}
|
||||
className="w-full"
|
||||
className="gallery-btn gallery-btn-download w-full"
|
||||
>
|
||||
{t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount})
|
||||
</Button>
|
||||
@@ -220,7 +220,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Feedback Filter Section */}
|
||||
{feedbackEnabled && onFilterChange && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-neutral-200">
|
||||
<GalleryFilter
|
||||
currentFilter={filterType}
|
||||
onFilterChange={(filter) => {
|
||||
@@ -239,12 +239,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Categories Section - Hidden for carousel layout */}
|
||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-neutral-200">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.categories')}
|
||||
</h3>
|
||||
|
||||
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => {
|
||||
@@ -252,7 +252,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${selectedCategoryId === null
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
@@ -266,7 +266,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
{categories.map((category) => {
|
||||
const count = photoCounts[category.id] || 0;
|
||||
const isSelected = selectedCategoryId === category.id;
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={category.id}
|
||||
@@ -275,7 +275,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
@@ -295,8 +295,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
)}
|
||||
|
||||
{showMediaFilter && onMediaFilterChange && (
|
||||
<div className="p-4 border-b border-neutral-200">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-neutral-200">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<Filter className="w-4 h-4" />
|
||||
{t('gallery.mediaType', 'Media')}
|
||||
</h3>
|
||||
@@ -304,6 +304,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<Button
|
||||
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
className="gallery-btn"
|
||||
onClick={() => {
|
||||
onMediaFilterChange('all');
|
||||
if (isMobile) onClose();
|
||||
@@ -314,6 +315,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<Button
|
||||
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
className="gallery-btn"
|
||||
onClick={() => {
|
||||
onMediaFilterChange('photo');
|
||||
if (isMobile) onClose();
|
||||
@@ -324,6 +326,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
<Button
|
||||
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
|
||||
size="sm"
|
||||
className="gallery-btn"
|
||||
onClick={() => {
|
||||
onMediaFilterChange('video');
|
||||
if (isMobile) onClose();
|
||||
@@ -337,17 +340,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
|
||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||
<div className="p-4">
|
||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<div className="gallery-sidebar-section gallery-sidebar-sort p-4">
|
||||
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||
<SortAsc className="w-4 h-4" />
|
||||
{t('gallery.sortBy')}
|
||||
</h3>
|
||||
|
||||
|
||||
<div className="space-y-1">
|
||||
{sortOptions.map((option) => {
|
||||
const Icon = option.icon;
|
||||
const isSelected = sortBy === option.value;
|
||||
|
||||
|
||||
return (
|
||||
<button
|
||||
key={option.value}
|
||||
@@ -356,7 +359,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
||||
if (isMobile) onClose();
|
||||
}}
|
||||
className={`
|
||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||
${isSelected
|
||||
? 'bg-primary-50 text-primary-700'
|
||||
: 'hover:bg-neutral-50 text-neutral-700'
|
||||
|
||||
@@ -21,6 +21,7 @@ import { api } from '../../config/api';
|
||||
import { Upload, Menu } from 'lucide-react';
|
||||
import { galleryService } from '../../services/gallery.service';
|
||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||
import type { Photo } from '../../types';
|
||||
|
||||
interface GalleryViewProps {
|
||||
@@ -55,6 +56,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const { watermarkEnabled } = useWatermarkSettings();
|
||||
|
||||
// Load and inject custom CSS for this gallery
|
||||
useGalleryCustomCss(slug);
|
||||
|
||||
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
|
||||
@@ -564,6 +569,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="gallery-btn"
|
||||
leftIcon={<Menu className="w-4 h-4" />}
|
||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||
aria-label={t('gallery.toggleMenu')}
|
||||
|
||||
@@ -70,9 +70,9 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className="photo-grid relative">
|
||||
{/* Main Carousel */}
|
||||
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
|
||||
<div className="photo-card relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
|
||||
<AuthenticatedImage
|
||||
src={currentPhoto.url}
|
||||
alt={currentPhoto.filename}
|
||||
|
||||
@@ -176,7 +176,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
|
||||
onClick={handlePhotoClick}
|
||||
style={{
|
||||
opacity: !inView && animationType === 'fade' ? 0 : 1
|
||||
@@ -375,10 +375,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
|
||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||
|
||||
const gridClass = `grid ${spacingClass}
|
||||
grid-cols-${columns.mobile}
|
||||
sm:grid-cols-${columns.tablet}
|
||||
lg:grid-cols-${columns.desktop}
|
||||
const gridClass = `photo-grid grid ${spacingClass}
|
||||
grid-cols-${columns.mobile}
|
||||
sm:grid-cols-${columns.tablet}
|
||||
lg:grid-cols-${columns.desktop}
|
||||
xl:grid-cols-${columns.desktop + 1}`;
|
||||
|
||||
return (
|
||||
|
||||
@@ -187,13 +187,13 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
||||
</div>
|
||||
|
||||
{/* Grid Section */}
|
||||
<div id="gallery-grid-section" 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="photo-grid 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 (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group cursor-pointer overflow-hidden rounded-lg"
|
||||
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
|
||||
onClick={() => onPhotoClick(actualIndex)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
|
||||
@@ -54,7 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
||||
|
||||
return (
|
||||
<div
|
||||
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
|
||||
className="photo-card relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
|
||||
onClick={onClick}
|
||||
style={{
|
||||
...style,
|
||||
@@ -243,9 +243,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex gap-4"
|
||||
className="photo-grid flex gap-4"
|
||||
style={{ gap: `${gutter}px` }}
|
||||
>
|
||||
{photoColumns.map((column, columnIndex) => (
|
||||
|
||||
@@ -49,7 +49,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||
className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(e);
|
||||
@@ -417,7 +417,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-7xl mx-auto">
|
||||
<div className="photo-grid w-full max-w-7xl mx-auto">
|
||||
{renderMosaicLayout()}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -94,13 +94,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
||||
)}
|
||||
|
||||
{/* Photos grid for this date */}
|
||||
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
<div className="photo-grid lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||
{group.photos.map((photo) => {
|
||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||
return (
|
||||
<div
|
||||
key={photo.id}
|
||||
className="relative group cursor-pointer aspect-square"
|
||||
className="photo-card relative group cursor-pointer aspect-square"
|
||||
onClick={() => onPhotoClick(actualIndex)}
|
||||
>
|
||||
<AuthenticatedImage
|
||||
|
||||
Reference in New Issue
Block a user