From 0da45e699ad998031aa56a92f2da5ee61a04e285 Mon Sep 17 00:00:00 2001 From: Paul Nothaft Date: Sat, 3 Jan 2026 08:59:01 +0100 Subject: [PATCH] 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 --- BUGS_AND_FEATURES.md | 1309 ++++++++++++ backend/data/photo_sharing.db | Bin 446464 -> 565248 bytes .../core/053_add_liquid_glass_templates.js | 693 ++++++ backend/server.js | 20 +- backend/src/middleware/errorHandler.js | 183 ++ backend/src/routes/adminAuth.js | 327 ++- backend/src/routes/adminEvents.js | 10 +- backend/src/routes/gallery.js | 106 +- backend/src/utils/errors.js | 121 ++ backend/src/utils/routeHelpers.js | 180 ++ frontend/TEST_PLAN.md | 1022 +++++++++ .../admin/ThemeCustomizerEnhanced.tsx | 99 +- .../src/components/gallery/GalleryLayout.tsx | 40 +- .../src/components/gallery/GallerySidebar.tsx | 63 +- .../src/components/gallery/GalleryView.tsx | 6 + .../gallery/layouts/CarouselGalleryLayout.tsx | 4 +- .../gallery/layouts/GridGalleryLayout.tsx | 10 +- .../gallery/layouts/HeroGalleryLayout.tsx | 4 +- .../gallery/layouts/MasonryGalleryLayout.tsx | 6 +- .../gallery/layouts/MosaicGalleryLayout.tsx | 4 +- .../gallery/layouts/TimelineGalleryLayout.tsx | 4 +- .../settings/hooks/useSettingsState.ts | 505 +++++ .../features/settings/hooks/useStatusTab.ts | 24 + frontend/src/features/settings/index.ts | 14 + .../features/settings/tabs/AnalyticsTab.tsx | 142 ++ .../features/settings/tabs/CategoriesTab.tsx | 29 + .../src/features/settings/tabs/EventsTab.tsx | 129 ++ .../src/features/settings/tabs/GeneralTab.tsx | 311 +++ .../features/settings/tabs/ModerationTab.tsx | 10 + .../features/settings/tabs/SecurityTab.tsx | 205 ++ .../src/features/settings/tabs/StatusTab.tsx | 539 +++++ .../src/features/settings/tabs/StylingTab.tsx | 10 + frontend/src/i18n/config.ts | 3 + frontend/src/i18n/locales/de.json | 70 +- frontend/src/i18n/locales/en.json | 59 +- frontend/src/pages/admin/BrandingPage.tsx | 15 +- frontend/src/pages/admin/CreateEventPage.tsx | 60 + frontend/src/pages/admin/EventDetailsPage.tsx | 4 +- frontend/src/pages/admin/SettingsPage.tsx | 1884 ++--------------- frontend/src/services/cssTemplates.service.ts | 9 +- frontend/vite.config.ts | 4 +- 41 files changed, 6112 insertions(+), 2125 deletions(-) create mode 100644 BUGS_AND_FEATURES.md create mode 100644 backend/migrations/core/053_add_liquid_glass_templates.js create mode 100644 backend/src/middleware/errorHandler.js create mode 100644 backend/src/utils/errors.js create mode 100644 backend/src/utils/routeHelpers.js create mode 100644 frontend/TEST_PLAN.md create mode 100644 frontend/src/features/settings/hooks/useSettingsState.ts create mode 100644 frontend/src/features/settings/hooks/useStatusTab.ts create mode 100644 frontend/src/features/settings/index.ts create mode 100644 frontend/src/features/settings/tabs/AnalyticsTab.tsx create mode 100644 frontend/src/features/settings/tabs/CategoriesTab.tsx create mode 100644 frontend/src/features/settings/tabs/EventsTab.tsx create mode 100644 frontend/src/features/settings/tabs/GeneralTab.tsx create mode 100644 frontend/src/features/settings/tabs/ModerationTab.tsx create mode 100644 frontend/src/features/settings/tabs/SecurityTab.tsx create mode 100644 frontend/src/features/settings/tabs/StatusTab.tsx create mode 100644 frontend/src/features/settings/tabs/StylingTab.tsx diff --git a/BUGS_AND_FEATURES.md b/BUGS_AND_FEATURES.md new file mode 100644 index 00000000..76696a34 --- /dev/null +++ b/BUGS_AND_FEATURES.md @@ -0,0 +1,1309 @@ +# PicPeak Bug Report & Feature Requests + +> **Generated:** January 2, 2026 +> **Version:** Frontend v1.1.15 / Backend v1.1.15 +> **Priority Levels:** P0 (Critical), P1 (High), P2 (Medium), P3 (Low) + +--- + +## Table of Contents + +1. [BUG-001: Logo Customization Section Formatting Issues](#bug-001-logo-customization-section-formatting-issues) +2. [BUG-002: Favicon Upload Section Missing Preview](#bug-002-favicon-upload-section-missing-preview) +3. [FEATURE-003: Custom CSS Instructions Panel](#feature-003-custom-css-instructions-panel) +4. [FEATURE-004: Custom CSS Integration with Theme Presets & Gallery Layouts](#feature-004-custom-css-integration-with-theme-presets--gallery-layouts) +5. [BUG-005: Custom Layouts Not Showing in Event Creation](#bug-005-custom-layouts-not-showing-in-event-creation) +6. [FEATURE-006: Apple Liquid Glass Design Custom CSS Templates](#feature-006-apple-liquid-glass-design-custom-css-templates) +7. [BUG-007: Typography & Style Section Row Formatting](#bug-007-typography--style-section-row-formatting) + +--- + +## BUG-001: Logo Customization Section Formatting Issues + +### Priority: P1 (High) + +### Location +- **Page:** `/admin/branding` +- **Section:** Company Information → Logo Customization + +### Current Behavior +1. The "Upload Logo" button appears as a plain text link instead of a proper button +2. No preview thumbnail is displayed when a logo has been uploaded +3. The layout doesn't show the currently uploaded logo image +4. Button styling is inconsistent with other upload buttons (e.g., Favicon) + +### Expected Behavior +1. "Upload Logo" should be a styled button matching the "Upload Favicon" button style +2. When a logo is uploaded, a preview thumbnail should be displayed (similar to how other image uploads work) +3. A "Remove Logo" or "Change Logo" option should appear when a logo exists +4. The section should show: + - Current logo preview (if uploaded) + - Upload/Change button + - Remove button (if logo exists) + - File size/dimension info + +### Technical Analysis + +**Affected Component:** `frontend/src/pages/admin/BrandingPage.tsx` or similar + +**Proposed Solution:** +```tsx +// Logo preview section structure +
+ {currentLogo ? ( +
+ Current Logo +
+ + +
+
+ ) : ( + + )} +

Recommended size: 200x60px, PNG or JPEG

+
+``` + +### Screenshots +- See: `.playwright-mcp/branding-logo-section.png` + +--- + +## BUG-002: Favicon Upload Section Missing Preview + +### Priority: P2 (Medium) + +### Location +- **Page:** `/admin/branding` +- **Section:** Company Information → Favicon + +### Current Behavior +1. Only shows "Upload Favicon" button +2. No preview of currently uploaded favicon +3. No way to see or remove existing favicon + +### Expected Behavior +1. Display current favicon preview (32x32px thumbnail) +2. Show "Change Favicon" when one exists +3. Provide "Remove Favicon" option +4. Display file info (name, size) when uploaded + +### Technical Analysis + +**Proposed Solution:** +```tsx +
+ +
+ {currentFavicon ? ( + <> + Current Favicon + + + + ) : ( + + )} +
+

PNG or ICO format, recommended size: 32x32px

+
+``` + +--- + +## FEATURE-003: Custom CSS Instructions Panel + +### Priority: P2 (Medium) + +### Location +- **Page:** `/admin/settings` → Custom CSS tab + +### Current Behavior +- Simple textarea with minimal guidance +- Only shows: "Advanced: Add custom CSS to further customize the appearance" +- CSS variables are documented in comments within the template + +### Required Enhancement +Add a collapsible instruction panel with comprehensive documentation: + +### Proposed Implementation + +```tsx + + + + CSS Documentation & Guide + + + + {/* Documentation content */} + + +``` + +### Documentation Content Structure + +#### 1. Available CSS Custom Properties +```css +/* Color Variables */ +--gallery-bg: #ffffff; /* Main background color */ +--gallery-bg-secondary: #f5f5f5; /* Secondary/card background */ +--gallery-text: #171717; /* Primary text color */ +--gallery-text-muted: #737373; /* Muted/secondary text */ +--gallery-accent: #22c55e; /* Accent/highlight color */ +--gallery-accent-hover: #16a34a; /* Accent hover state */ +--gallery-border: #e5e5e5; /* Border color */ + +/* Spacing & Layout */ +--gallery-spacing: 16px; /* Base spacing unit */ +--gallery-radius: 8px; /* Border radius */ +--gallery-shadow: 0 1px 3px rgba(0,0,0,0.1); /* Box shadow */ + +/* Typography */ +--gallery-font-body: 'Inter', sans-serif; +--gallery-font-heading: 'Inter', sans-serif; +--gallery-font-size-base: 16px; +``` + +#### 2. Targetable CSS Classes +```css +/* Page Structure */ +.gallery-page { } /* Root gallery container */ +.gallery-header { } /* Header section */ +.gallery-content { } /* Main content area */ +.gallery-footer { } /* Footer section */ + +/* Photo Grid */ +.photo-grid { } /* Grid container */ +.photo-card { } /* Individual photo card */ +.photo-card img { } /* Photo image */ +.photo-card-overlay { } /* Hover overlay */ +.photo-card-info { } /* Photo metadata */ + +/* Lightbox */ +.lightbox-overlay { } /* Lightbox background */ +.lightbox-content { } /* Lightbox container */ +.lightbox-image { } /* Lightbox image */ +.lightbox-controls { } /* Navigation controls */ + +/* Buttons & Interactions */ +.gallery-btn { } /* Primary buttons */ +.gallery-btn-secondary { } /* Secondary buttons */ +.gallery-link { } /* Links */ + +/* Categories */ +.category-filter { } /* Category filter bar */ +.category-pill { } /* Category pill/tag */ + +/* Masonry Layout */ +.masonry-grid { } /* Masonry container */ +.masonry-item { } /* Masonry item */ + +/* Carousel Layout */ +.carousel-container { } /* Carousel wrapper */ +.carousel-slide { } /* Individual slide */ +.carousel-nav { } /* Navigation arrows */ +.carousel-dots { } /* Pagination dots */ + +/* Timeline Layout */ +.timeline-container { } /* Timeline wrapper */ +.timeline-item { } /* Timeline entry */ +.timeline-date { } /* Date marker */ + +/* Hero Layout */ +.hero-section { } /* Hero image area */ +.hero-image { } /* Featured image */ +.hero-content { } /* Hero text content */ +``` + +#### 3. Layout-Specific Customization +```css +/* Grid Layout Customization */ +.gallery-page[data-layout="grid"] .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: var(--gallery-spacing); +} + +/* Masonry Layout Customization */ +.gallery-page[data-layout="masonry"] .masonry-grid { + column-count: 4; + column-gap: var(--gallery-spacing); +} + +/* Carousel Layout Customization */ +.gallery-page[data-layout="carousel"] .carousel-container { + height: 80vh; +} + +/* Timeline Layout Customization */ +.gallery-page[data-layout="timeline"] .timeline-container { + max-width: 1200px; + margin: 0 auto; +} +``` + +#### 4. Responsive Breakpoints +```css +/* Mobile: < 640px */ +@media (max-width: 639px) { } + +/* Tablet: 640px - 1023px */ +@media (min-width: 640px) and (max-width: 1023px) { } + +/* Desktop: >= 1024px */ +@media (min-width: 1024px) { } +``` + +#### 5. Animation Classes +```css +/* Available animations */ +.animate-fade { } /* Fade in */ +.animate-scale { } /* Scale up */ +.animate-slide { } /* Slide in */ +.animate-none { } /* No animation */ +``` + +--- + +## FEATURE-004: Custom CSS Integration with Theme Presets & Gallery Layouts + +### Priority: P0 (Critical) + +### Location +- **Page:** `/admin/settings` → Custom CSS tab +- **Page:** `/admin/branding` → Gallery Theme section +- **Page:** `/admin/events/new` → Theme & Style section + +### Current Behavior +- Custom CSS templates exist but are isolated +- Theme presets don't integrate with custom CSS +- Users cannot define custom theme presets +- Users cannot define custom gallery layouts +- No way to use CSS variables from theme presets + +### Required Features + +#### 4.1 Custom Theme Preset Creation + +Users should be able to create custom theme presets that appear alongside built-in presets: + +```typescript +interface CustomThemePreset { + id: string; + name: string; + description: string; + thumbnail?: string; + // Inherits from base preset or standalone + basePreset?: 'classic-grid' | 'elegant-wedding' | 'modern-masonry' | null; + // CSS template reference + cssTemplateId: 1 | 2 | 3; + // Override variables + variables: { + primaryColor: string; + accentColor: string; + backgroundColor: string; + textColor: string; + fontBody: string; + fontHeading: string; + borderRadius: string; + spacing: string; + }; + // Layout settings + layout: 'grid' | 'masonry' | 'carousel' | 'timeline' | 'hero' | 'mosaic' | 'custom'; + layoutSettings: { + columns: { mobile: number; tablet: number; desktop: number }; + spacing: 'tight' | 'normal' | 'relaxed'; + animation: 'none' | 'fade' | 'scale' | 'slide'; + }; +} +``` + +#### 4.2 Custom Gallery Layout Definition + +Allow users to define custom layouts via CSS: + +```css +/* Custom Layout 1: Magazine Style */ +.gallery-page[data-layout="custom-1"] .photo-grid { + display: grid; + grid-template-columns: 2fr 1fr 1fr; + grid-template-rows: auto; + gap: 8px; +} + +.gallery-page[data-layout="custom-1"] .photo-card:first-child { + grid-row: span 2; +} + +/* Custom Layout 2: Polaroid Style */ +.gallery-page[data-layout="custom-2"] .photo-card { + background: white; + padding: 12px 12px 40px 12px; + box-shadow: 0 4px 6px rgba(0,0,0,0.1); + transform: rotate(var(--rotation, 0deg)); +} + +/* Custom Layout 3: Filmstrip */ +.gallery-page[data-layout="custom-3"] .photo-grid { + display: flex; + overflow-x: auto; + gap: 4px; + padding: 20px; + background: #1a1a1a; +} +``` + +#### 4.3 Variable Inheritance System + +Custom CSS should be able to reference theme preset variables: + +```css +/* Access theme preset variables */ +.gallery-page { + /* These come from the selected theme preset */ + background: var(--theme-bg, var(--gallery-bg)); + color: var(--theme-text, var(--gallery-text)); + + /* Override specific elements */ + --gallery-accent: var(--theme-accent); +} + +/* Conditional styling based on preset */ +.gallery-page[data-preset="elegant-wedding"] { + /* Wedding-specific overrides */ +} + +.gallery-page[data-preset="custom"] { + /* Full custom styling */ +} +``` + +### Database Schema Addition + +```sql +-- Custom theme presets table +CREATE TABLE custom_theme_presets ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL, + description TEXT, + thumbnail_url VARCHAR(500), + base_preset VARCHAR(50), + css_template_id INTEGER REFERENCES css_templates(id), + variables JSONB NOT NULL DEFAULT '{}', + layout VARCHAR(50) NOT NULL DEFAULT 'grid', + layout_settings JSONB NOT NULL DEFAULT '{}', + is_enabled BOOLEAN DEFAULT true, + sort_order INTEGER DEFAULT 0, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- Link events to custom presets +ALTER TABLE events ADD COLUMN custom_preset_id INTEGER REFERENCES custom_theme_presets(id); +``` + +### UI Components Required + +1. **Theme Preset Builder** - Visual editor for creating custom presets +2. **Layout Designer** - CSS grid/flexbox visual editor for custom layouts +3. **Variable Picker** - Color/font/spacing picker that outputs CSS variables +4. **Preview Synchronization** - Real-time preview of custom themes + +--- + +## BUG-005: Custom Layouts Not Showing in Event Creation + +### Priority: P1 (High) + +### Location +- **Page:** `/admin/events/new` → Theme & Style → Gallery Layout + +### Current Behavior +- Only shows 6 default layouts: grid, masonry, carousel, timeline, hero, mosaic +- Custom CSS templates with enabled custom layouts don't appear +- No "Custom 1", "Custom 2", "Custom 3" options visible + +### Expected Behavior +When Custom CSS templates are enabled in Settings: +1. Additional layout options should appear: "Custom 1", "Custom 2", "Custom 3" +2. Only show custom layouts that have content (non-empty CSS) +3. Display the template name as the layout name +4. Show a preview thumbnail if available + +### Technical Analysis + +**Current Gallery Layout Options:** +```typescript +const GALLERY_LAYOUTS = [ + { value: 'grid', label: 'Grid', description: 'Classic grid layout...' }, + { value: 'masonry', label: 'Masonry', description: 'Pinterest-style...' }, + { value: 'carousel', label: 'Carousel', description: 'Full-screen slideshow...' }, + { value: 'timeline', label: 'Timeline', description: 'Photos organized by date' }, + { value: 'hero', label: 'Hero', description: 'Featured image with grid...' }, + { value: 'mosaic', label: 'Mosaic', description: 'Artistic layout...' }, +]; +``` + +**Required Addition:** +```typescript +// Fetch enabled custom templates +const { data: customTemplates } = useQuery({ + queryKey: ['customCssTemplates'], + queryFn: () => settingsService.getCustomCssTemplates(), +}); + +// Filter to only enabled templates with content +const enabledCustomLayouts = customTemplates + ?.filter(t => t.enabled && t.content?.trim()) + .map((t, index) => ({ + value: `custom-${index + 1}`, + label: t.name || `Custom ${index + 1}`, + description: 'Custom CSS layout template', + isCustom: true, + })); + +const allLayouts = [...GALLERY_LAYOUTS, ...(enabledCustomLayouts || [])]; +``` + +### API Endpoint Required + +```typescript +// GET /api/admin/settings/custom-css-templates +interface CustomCssTemplate { + id: number; + name: string; + enabled: boolean; + content: string; + hasLayoutDefinition: boolean; // true if contains custom layout CSS +} +``` + +--- + +## FEATURE-006: Apple Liquid Glass Design Custom CSS Templates + +### Priority: P2 (Medium) + +### Overview +Create 2 premium custom CSS templates implementing Apple's "Liquid Glass" design language introduced at WWDC 2025. This design features translucent surfaces, dynamic light refraction effects, and sophisticated blur treatments. + +### References +- [Apple's Liquid Glass UI design + CSS guide](https://dev.to/gruszdev/apples-liquid-glass-revolution-how-glassmorphism-is-shaping-ui-design-in-2025-with-css-code-1221) +- [Recreating Apple's Liquid Glass Effect with Pure CSS](https://dev.to/kevinbism/recreating-apples-liquid-glass-effect-with-pure-css-3gpl) +- [CSS-Tricks: Getting Clarity on Apple's Liquid Glass](https://css-tricks.com/getting-clarity-on-apples-liquid-glass/) +- [Liquid Glass CSS Generator](https://liquidglassgen.com/) + +### Template 1: "Liquid Glass Light" + +A light-themed glassmorphism design with subtle transparency and soft shadows. + +```css +/* + * PicPeak Custom CSS Template: Liquid Glass Light + * Inspired by Apple's iOS 26 Liquid Glass Design Language + * + * Features: + * - Translucent frosted glass surfaces + * - Dynamic light refraction effects + * - Subtle specular highlights + * - Soft depth shadows + */ + +/* ===== Base Theme Variables ===== */ +.gallery-page { + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-bg-elevated: rgba(255, 255, 255, 0.85); + --glass-border: rgba(255, 255, 255, 0.5); + --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + --glass-blur: 20px; + --glass-saturation: 180%; + + --gallery-bg: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + --gallery-text: #1a1a2e; + --gallery-text-muted: rgba(26, 26, 46, 0.7); + --gallery-accent: #667eea; + --gallery-accent-hover: #764ba2; + --gallery-radius: 24px; + --gallery-spacing: 20px; +} + +/* ===== Page Background ===== */ +.gallery-page { + background: var(--gallery-bg); + min-height: 100vh; + position: relative; +} + +/* Animated gradient background */ +.gallery-page::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.3) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 40%); + pointer-events: none; + z-index: 0; +} + +/* ===== Glass Card Base ===== */ +.glass-surface { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + box-shadow: + var(--glass-shadow), + inset 0 1px 1px rgba(255, 255, 255, 0.8), + inset 0 -1px 1px rgba(0, 0, 0, 0.05); +} + +/* Liquid shine effect */ +.glass-surface::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.4) 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100% + ); + border-radius: var(--gallery-radius) var(--gallery-radius) 0 0; + pointer-events: none; +} + +/* ===== Gallery Header ===== */ +.gallery-header { + background: var(--glass-bg-elevated); + backdrop-filter: blur(30px) saturate(200%); + -webkit-backdrop-filter: blur(30px) saturate(200%); + border-bottom: 1px solid var(--glass-border); + padding: calc(var(--gallery-spacing) * 1.5); + position: sticky; + top: 0; + z-index: 100; +} + +.gallery-title { + color: var(--gallery-text); + font-weight: 700; + font-size: 1.75rem; + letter-spacing: -0.02em; + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5); +} + +/* ===== Photo Grid ===== */ +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--gallery-spacing); + padding: calc(var(--gallery-spacing) * 2); + position: relative; + z-index: 1; +} + +/* ===== Photo Cards - Glass Style ===== */ +.photo-card { + position: relative; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + overflow: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.1), + inset 0 1px 1px rgba(255, 255, 255, 0.6); +} + +.photo-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.3) 0%, + transparent 100% + ); + pointer-events: none; + z-index: 1; + border-radius: var(--gallery-radius) var(--gallery-radius) 0 0; +} + +.photo-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: + 0 20px 40px rgba(102, 126, 234, 0.3), + 0 8px 16px rgba(0, 0, 0, 0.1), + inset 0 1px 1px rgba(255, 255, 255, 0.8); +} + +.photo-card img { + width: 100%; + height: 240px; + object-fit: cover; + transition: transform 0.4s ease; +} + +.photo-card:hover img { + transform: scale(1.05); +} + +.photo-card-info { + padding: var(--gallery-spacing); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.3) 100% + ); +} + +/* ===== Buttons - Glass Style ===== */ +.gallery-btn { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: calc(var(--gallery-radius) / 2); + padding: 12px 24px; + color: var(--gallery-text); + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.gallery-btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.4) 0%, + transparent 100% + ); +} + +.gallery-btn:hover { + background: var(--glass-bg-elevated); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); +} + +.gallery-btn-primary { + background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-hover) 100%); + color: white; + border: none; +} + +/* ===== Lightbox - Glass Style ===== */ +.lightbox-overlay { + background: rgba(26, 26, 46, 0.8); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); +} + +.lightbox-content { + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); +} + +/* ===== Category Pills ===== */ +.category-pill { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: 9999px; + padding: 8px 20px; + font-size: 0.875rem; + font-weight: 500; + color: var(--gallery-text); + transition: all 0.3s ease; +} + +.category-pill:hover, +.category-pill.active { + background: var(--gallery-accent); + color: white; + border-color: var(--gallery-accent); +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .gallery-page { + --gallery-radius: 16px; + --gallery-spacing: 12px; + --glass-blur: 16px; + } + + .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .photo-card img { + height: 180px; + } +} + +/* ===== Accessibility: Reduce Motion ===== */ +@media (prefers-reduced-motion: reduce) { + .photo-card, + .gallery-btn { + transition: none; + } + + .photo-card:hover { + transform: none; + } +} + +/* ===== Accessibility: Reduce Transparency ===== */ +@media (prefers-reduced-transparency: reduce) { + .glass-surface, + .photo-card, + .gallery-btn { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(255, 255, 255, 0.95); + } +} +``` + +### Template 2: "Liquid Glass Dark" + +A dark-themed glassmorphism design with deep translucency and neon accents. + +```css +/* + * PicPeak Custom CSS Template: Liquid Glass Dark + * Inspired by Apple's iOS 26 Liquid Glass Design Language + * + * Features: + * - Deep translucent dark surfaces + * - Neon accent highlights + * - Dramatic glass reflections + * - Subtle animated gradients + */ + +/* ===== Base Theme Variables ===== */ +.gallery-page { + --glass-bg: rgba(15, 15, 35, 0.7); + --glass-bg-elevated: rgba(25, 25, 55, 0.85); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-border-highlight: rgba(255, 255, 255, 0.2); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + --glass-blur: 24px; + --glass-saturation: 150%; + + --gallery-bg: #0a0a1a; + --gallery-text: #f0f0f5; + --gallery-text-muted: rgba(240, 240, 245, 0.6); + --gallery-accent: #00d4ff; + --gallery-accent-secondary: #ff00e5; + --gallery-accent-hover: #00ffea; + --gallery-radius: 20px; + --gallery-spacing: 20px; + + /* Neon glow variables */ + --neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2); + --neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2); +} + +/* ===== Page Background ===== */ +.gallery-page { + background: var(--gallery-bg); + min-height: 100vh; + position: relative; + overflow-x: hidden; +} + +/* Animated mesh gradient background */ +.gallery-page::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + right: -50%; + bottom: -50%; + background: + radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%), + radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%), + radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%); + animation: gradientShift 20s ease-in-out infinite; + pointer-events: none; + z-index: 0; +} + +@keyframes gradientShift { + 0%, 100% { transform: translate(0, 0) rotate(0deg); } + 25% { transform: translate(2%, 2%) rotate(1deg); } + 50% { transform: translate(-1%, 3%) rotate(-1deg); } + 75% { transform: translate(3%, -2%) rotate(2deg); } +} + +/* ===== Gallery Header ===== */ +.gallery-header { + background: var(--glass-bg-elevated); + backdrop-filter: blur(30px) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation)); + border-bottom: 1px solid var(--glass-border-highlight); + padding: calc(var(--gallery-spacing) * 1.5); + position: sticky; + top: 0; + z-index: 100; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.gallery-title { + color: var(--gallery-text); + font-weight: 700; + font-size: 1.75rem; + letter-spacing: -0.02em; + background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ===== Photo Grid ===== */ +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--gallery-spacing); + padding: calc(var(--gallery-spacing) * 2); + position: relative; + z-index: 1; +} + +/* ===== Photo Cards - Dark Glass Style ===== */ +.photo-card { + position: relative; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + overflow: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +/* Top highlight reflection */ +.photo-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.3) 50%, + transparent 100% + ); + z-index: 2; +} + +/* Inner glow effect */ +.photo-card::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--gallery-radius); + padding: 1px; + background: linear-gradient( + 135deg, + rgba(0, 212, 255, 0) 0%, + rgba(0, 212, 255, 0) 40%, + rgba(0, 212, 255, 0.1) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0; + transition: opacity 0.4s ease; +} + +.photo-card:hover { + transform: translateY(-8px) scale(1.02); + border-color: var(--glass-border-highlight); + box-shadow: + 0 24px 48px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(0, 212, 255, 0.2), + var(--neon-glow); +} + +.photo-card:hover::after { + opacity: 1; +} + +.photo-card img { + width: 100%; + height: 240px; + object-fit: cover; + transition: transform 0.4s ease, filter 0.4s ease; + filter: brightness(0.9); +} + +.photo-card:hover img { + transform: scale(1.05); + filter: brightness(1); +} + +.photo-card-info { + padding: var(--gallery-spacing); + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.2) 0%, + rgba(0, 0, 0, 0.4) 100% + ); + color: var(--gallery-text); +} + +.photo-card-info p { + color: var(--gallery-text-muted); + font-size: 0.875rem; +} + +/* ===== Buttons - Neon Glass Style ===== */ +.gallery-btn { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: calc(var(--gallery-radius) / 2); + padding: 12px 24px; + color: var(--gallery-text); + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.gallery-btn:hover { + border-color: var(--gallery-accent); + box-shadow: var(--neon-glow); + color: var(--gallery-accent); +} + +.gallery-btn-primary { + background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%); + color: white; + border: none; + box-shadow: var(--neon-glow); +} + +.gallery-btn-primary:hover { + box-shadow: + 0 0 30px rgba(0, 212, 255, 0.6), + 0 0 60px rgba(0, 212, 255, 0.3), + 0 0 90px rgba(255, 0, 229, 0.2); + transform: translateY(-2px); +} + +/* ===== Lightbox - Dark Glass ===== */ +.lightbox-overlay { + background: rgba(5, 5, 15, 0.9); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); +} + +.lightbox-content { + background: var(--glass-bg-elevated); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--glass-border-highlight); + border-radius: var(--gallery-radius); + box-shadow: + 0 24px 80px rgba(0, 0, 0, 0.5), + var(--neon-glow); +} + +/* ===== Category Pills ===== */ +.category-pill { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: 9999px; + padding: 8px 20px; + font-size: 0.875rem; + font-weight: 500; + color: var(--gallery-text-muted); + transition: all 0.3s ease; +} + +.category-pill:hover { + border-color: var(--gallery-accent); + color: var(--gallery-accent); + box-shadow: var(--neon-glow); +} + +.category-pill.active { + background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%); + color: white; + border-color: transparent; + box-shadow: var(--neon-glow); +} + +/* ===== Scrollbar Styling ===== */ +.gallery-page ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.gallery-page ::-webkit-scrollbar-track { + background: var(--glass-bg); + border-radius: 4px; +} + +.gallery-page ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%); + border-radius: 4px; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .gallery-page { + --gallery-radius: 16px; + --gallery-spacing: 12px; + --glass-blur: 16px; + } + + .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .photo-card img { + height: 180px; + } + + /* Reduce animation complexity on mobile */ + .gallery-page::before { + animation: none; + } +} + +/* ===== Accessibility: Reduce Motion ===== */ +@media (prefers-reduced-motion: reduce) { + .gallery-page::before { + animation: none; + } + + .photo-card, + .gallery-btn { + transition: none; + } + + .photo-card:hover { + transform: none; + } +} + +/* ===== Accessibility: Reduce Transparency ===== */ +@media (prefers-reduced-transparency: reduce) { + .photo-card, + .gallery-btn, + .gallery-header { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .gallery-page { + --glass-bg: rgba(20, 20, 40, 0.98); + --glass-bg-elevated: rgba(30, 30, 60, 0.98); + } +} +``` + +--- + +## BUG-007: Typography & Style Section Row Formatting + +### Priority: P2 (Medium) + +### Location +- **Page:** `/admin/events/new` → Theme & Style → Typography & Style section +- **Page:** `/admin/branding` → Gallery Theme → Typography & Style section + +### Current Behavior +The second row of Typography & Style section contains 4 dropdown fields crammed into one row: +- Font Size +- Border Radius +- Shadow Style +- Background + +These fields are truncated and show abbreviated text (e.g., "Nor", "Lar", "Sub") because they don't have enough horizontal space. + +### Expected Behavior +Split the 4 fields into 2 rows of 2 fields each: +- **Row 1:** Font Size, Border Radius +- **Row 2:** Shadow Style, Background + +### Screenshots +- See: `.playwright-mcp/typography-style-visible.png` + +### Technical Analysis + +**Current Code Structure (likely):** +```tsx +
+ + + + +
+``` + +**Proposed Fix:** +```tsx +{/* Row 1: Font Size & Border Radius */} +
+ + + + + + +
+ +{/* Row 2: Shadow Style & Background */} +
+ + + + + + +
+``` + +### Files to Modify +1. `frontend/src/pages/admin/EventCreatePage.tsx` (or similar) +2. `frontend/src/pages/admin/BrandingPage.tsx` +3. `frontend/src/components/admin/ThemeCustomizer.tsx` (if shared component) + +--- + +## Implementation Priority Order + +| Priority | Issue | Effort | Impact | +|----------|-------|--------|--------| +| P0 | FEATURE-004: Custom CSS + Theme Integration | High | Critical for customization | +| P1 | BUG-001: Logo Upload Formatting | Medium | User experience | +| P1 | BUG-005: Custom Layouts in Events | Medium | Feature completeness | +| P2 | BUG-002: Favicon Preview | Low | User experience | +| P2 | BUG-007: Typography Row Layout | Low | UI polish | +| P2 | FEATURE-003: CSS Instructions | Medium | User guidance | +| P2 | FEATURE-006: Liquid Glass Templates | Medium | Premium feature | + +--- + +## Testing Checklist + +### BUG-001 & BUG-002 +- [ ] Logo upload displays preview after upload +- [ ] Logo can be changed/removed +- [ ] Favicon upload displays preview +- [ ] Favicon can be changed/removed +- [ ] Button styling matches design system + +### FEATURE-003 +- [ ] Collapsible instructions panel works +- [ ] All CSS variables documented +- [ ] All CSS classes documented +- [ ] Code examples copy correctly + +### FEATURE-004 +- [ ] Custom theme presets can be created +- [ ] Custom presets appear in event creation +- [ ] CSS variables from presets work in custom CSS +- [ ] Custom layouts render correctly + +### BUG-005 +- [ ] Enabled custom templates show in layout selector +- [ ] Only templates with content appear +- [ ] Custom layouts apply correctly to galleries + +### FEATURE-006 +- [ ] Liquid Glass Light template renders correctly +- [ ] Liquid Glass Dark template renders correctly +- [ ] Animations respect prefers-reduced-motion +- [ ] Transparency respects prefers-reduced-transparency +- [ ] Mobile responsiveness works + +### BUG-007 +- [ ] Typography section shows 2 items per row +- [ ] Dropdown labels fully visible +- [ ] Responsive behavior maintained + +--- + +*Document generated for PicPeak development team* diff --git a/backend/data/photo_sharing.db b/backend/data/photo_sharing.db index bd8a5bbd515fa32cce1af6830ef8af28d4f2dca4..2a0b50034e0bb1e6d659951866cae379014549e8 100644 GIT binary patch literal 565248 zcmeFa3veSxdY}mqBtU`>sP!1A)g!4IEr|_vvxx`62gq)TVxwDBznkolTC%oSL?wU{ z3sq26fo!VHoq<}?%=meGK6^d(`aL(^i?h9J$K2YUnDEAp>>K-b!j6rwH@rKxH+CFh z?}R-&cHE6`cI}A0KeOII0VG?kRZIO-5=2(zLQxspuZgNNypY0Hpj$4K>EtxF8^n`0lZHFC%!0ATiGo24sZKbNRD`B% za7)*&UM*c*OR&{H^rw2 zb<$aVeAnvR@&-y^r&&widSx({JaQ!b&JMF9kEEVohx``gxkHuLzPzNKm+Ula6N_hb?T%d#PDf~~Eqs!RmMF{ttEIG9MisT(}luTmwvU1mVq3{7j_ zx6+Fchh0O3``{g5wf@UabwT#qY)~`bM+R8{X=Sx4fl=EY0u{TLd8y%tk-qgz%+#(K zB}|is2-PH>UkCl#_1w8F?UGFh5)hP~m;y3tq%1WgdVLqonw7W3hql;arZIi^HE?k# zUpEA!srwu6z#nfAMU<2()uLXm5wR)n2fI$iZc{eVG*#p4MAwBXSQm{Xtd)kg&6m}t z0;XXHR6B%?Q!9co5s|724XV)D+Mv>$Mk)}(l`_#GuF=c-8uP1k`pxrErop#((?83Q zMyFPw3+}HmzXm;b?wAIp1}}&rj6d2aL$j$n^T2_&+S?z%E$V*W7)%`*E;nkbp)wO` zOHYG7=&}xWY+Jaa#sDAMKh9fc24cyB2gC1F8BQjMZhl7n5Y3Bsj9*2P361D}MBXm5 zAs9VknNe%j*A?0$b*2NNy*);k-XQ$?wn0F7mR(Z4HDpx~_u~qi64~l$-)o|}rO;A) zrrNjK=x9|WGJ&UxrNxz{#nYv<(GUIhLA&2JeTLUzC_;EyQZ}rP)4V=lY1Tgi>z2pt zFfA{H?#(T9ExjIzC9_a#jP=-d339XQtFOOjeQxTGn&8%2`f^`1dF)vDIx|rHbs3(e zc%4Adrs2W=XT)c+Z8winb2|<>({IUPX7r!UvocMw&Cfnx33lwcolae+!u$N+Yuv0h zzPFW{X?i<}nTEy&GeUUlsHxCD?$W=QLzi6=IbD74;l&n_%PNe~+Pi_WDnsXB)L^vi zbPXOO>dI)}BZ%Ebz`KrrXaX3&!w3m_>y9B38}?ZDY1DydRJ{rx|L7(7xNr$Ro_Pj7 z@&)*KA`Kr8J^~;2569s%ns_h-Pvc_ZHxefk4<>#HF5wRnKmter2_OL^fCP{L59Kb{@`}d>A^{|T1dsp{Kmter2_OL^fCP}h`%EDDKqMdLHxzP%H%J{;FUbbcW~Op| z2kwuIhs&^tie8psC4n9}bYEmXY%VRZpLt=)g<-(cy54vHSR@^0%kE$m7|mno$;6AH zp&^>#z(C~5KB3WYl81gTk%$}#dzN5HiBY&NHnL*#ZqD{&{G`O3*ht6nQ*cuIunxzDo4I2L~e)R`N15sH$ob(W7J0 z$dN(%{N8NvT2s--`Xh(Kb*ZWebWxT*1q!%7?3MkK@fgTpE_@m_RQ?93F@uuihsTqTp4n_VvqtoFB#b(Yxk zo%`hathk;n=ktZ!I+>mkD!Hk0b~cl%keQk6v@l!f4=&XAO1=H{{3Nt5Q<$wxmkSv( zn;~=4g={68pU!~Hxv8nC*+}oV@=|~6#i5#EG>VgxvRW2ox?Q4J$j;=0RbbCMbiDkZ z%;aWfgy|fC*3Aj)WUi7I%F|Q1>2iLqkQK7)=KBAqLy130d@k{H;!?s${MKD;hA0{m zKmter2_OL^aL*BVClh`ubp25N@Wdn>HuKIitrt(892yLT9v>PU3_Thi8X64KKM!2+ z4N$v|j;vIxu*Q=+W38X$N_0yVadmSubLPaErKQU+uT0i&Y-eZBNt+j6BDI%i3s-K;T`Ooe zn&)n&tdm=o=r+_;F?FOoC(KP}Q^!;ErIcbg*U7Xfic@f%z9Xt?aBP9-+^dL{a%COv z(W4EVtN9B32d-9B#W*9>C3zbvxGW3X6+zOt(~=GY_wC~x90(xMWHlnt6|ZOZmU%SLSb5b{rcp~Q&*Z@MGJp!c;(twcgpIW%gG*z-HVwt#cfG&_M3UI1RNCu1&PA8&cn%X0vNJV-PwVI zdA9Wvc9Dw}uyr#%YsPU7&b@F-XN9d3&OT&sRC|-{!tH{YMcA5;#{*OzkKaLM zzmjsS3F4b;FYt%oI^cp=SE*Oc5!CENE)Uu$PGyR@!ZK);I|KF&DPfpMIQiYrH!B9H zNESI^-7@f?JGOQV0LdJi zzbKSz+~T?{G%MgPn#hUJ3Q!G`*)(;DXa)!4X-SGSrlgjO6$?dF-n!l^mUdeLnXxIT?6+dMr^XJ^N>-uLcxIqph3_M9sv>I zOsSMBK}2u^2=((I-?y*Hqykb*L4$QwmPGDI=?u)oPk{_^j{8+5{7~rF(&r9-=2&Y! zU;5Fx?>opoY!9)H`w8$ccXQ`}r$#C!w6o2z(2L{ufCV|B%AT=pffXuFF<|^=y#{#Y1 zi#-@Sz0WMQO1p711rq@*ZlRX9xtq5q47E{YcbT1-{VhFNPk;v2^3+NfnzEfohF~?p z`c}K!b5uav2#F!NEZNDeD%MrQQ0r!I>j4c;NOy#C^e{G;=AG*dWoe%RUAE2)^$6p3 zv-XK!Gzyv}FHAONh!>{*e0rlZA`^lP$2b-_I-f!`R~t0$IokNpg;F}CAzR)NO(1}P z&{?Te@@3KXX)p#iBvvX!5N4cl))9D7JC&L8*myQ5%2Ia@ws9wd+aBO`@8xjnqXvnW5ZP<)M*e3>Ol53JiG%vMZ9Y-vDZip1$4w#H=uUJ zwfKZ>zh>xxuFmdoX()mqJi$4kz;n}$0P(nMMckf@r)?nhKG{k<-rG9jai3XqGl?F9 zd3Q|EK9G0E^)@H^AmEN3osm8~{#NPP{QZf)rcc6(eK!B_mxdpYzcuvdL(dKV^k5`b zi@qBjANcYck}|CI)YJlZJt|(FfVg(fxEQAy#&uPe73;Pq`AcgOyR#s*3U`CWFbFuB0tR? zJ@@?Th4aV3`ZnOHfRs1XG`CcPQ3jdJ=I2gi=s(c@Q z>)^w8rEFPvh-)28-o>(g?5>n8Q_Rn`4nB0Z%9d>%eDE%nEj!yf_`qE(+x>T=Z1C2k z0QPp^Zj~+5I(XmRD_bgI$~OK1_6z2oL+g>;-$792W?B!A4TXlx&g<^!yE4#s&9)vM zy=z4~JYtFls{Np-_0X(|@3CQ1#={@9jPPDx_M2V?`ms#wvG`poV4(;Er?RbwhweH9 z1ktj^EQmIEH;M*x^AJ&Dcb#ZcutvF<&9@$o-i@LGHBjilT`1aAp_rd;J=}jciUzNO zK(xqRC|Z81m<2cJyBkHz(cp17Y>IZ5^j`3^UKim*J_jEgIrw<(;OOXZDB8ao8dk;{ zL%$mP&(X)>5Bxy_NB{}kCV^YeM@K{9!m+n+9?HWve-1?HGCaCV%)6k^p6In+T;5ET z;rrcS;!We}>K0hj#g+mtSr=gj)49(MIe`R0r9wJ)`9o(Jp5+kMO;``Bm`i@?Bq{rn zFZ#v+iieT3bqs&dOzgT~^Zt2c1|F%38LG(K z_k8hIF#?L5IQDkcRAl5ewYI;&`T}I&POEegt*tTho46n%v=`QxSE4sHajg$MDkk?_Y6}xSg4`pTz@W|D9RarS!Y*Gxk*L>CPyB zI%Bm$8gN9}I?<@y?fqF{f^MjQ{w%kj{>*AFmz{zsuwtfAqTl07H@j>~Wmwt=-<)h7 zlh)LT82()RkACM#SO`s+a$Yn&iyGXXl|vId&_DEgSz#+JS0z~Zo1e~Rp4^XGm}xGH z5KOY(@r9+2TcdrZ2T;iS^#FuykL7wprmv-8W;K)VZS=$G?~bzd|B1Il@Gt%#0VIF~ zkN^@u0!RP}AOR$R1dsp{KmvD`Kr0%)FO-BsY+&WnKz`f)V=y!@Fz~>E!4NxTCitkC z+!G|T^75I@RcTH&7GHX)uz2pu3sc(!Hk_YZ*;ZdyE;lZpJH4@W>5cmOiRbE>=am=F z$YMSG| z+1N}ZfCP{L5T>IF>wkF#IE zbLUIknt5@JJGv%`Yn-GQq)IeSQ4LOM$}-o`q`IJOa~ouv6Pku9DP@h+p|s=Nnl3l1 zYuu)wm1}}_G*>93oqTYY70esk4YFGrMX2wZMidN4LqENB)KuNzNnMcS?sW)aT~c;S z)DU!iOVvcaCg`>9Wo;3;tkwx%C%P_FK_G+Npd`wwtZKYb12WDv9iR|imXwX|eZqadAsB5a+?#%p;X2bqry^+%Sys1rQQcBx zRk+oN#ga#kgg-rQ+9g#T|9X=&3Dcta>tLIOx`}SfOie@dST_izk+Rf~z>Ie@3{$LK zte6Q1wkfP}uj#4+_F?itV)+D28Yn7+||u zYQ)gC`Lfznz`ktPcBEj+-me8#REUG_t8~Ksyci zRCNDeBYwm3-@Gl`VzuxzocIN^dQ507wLX@>b~)OUQVLYQ_8u{lhC8-cUaxi-25D-y zegOIv2t=-k*$k_vP7J|}Q?%nsZ^vWFlSjg>$0bE1H#|;fMn%48P_;f@!h4oO~*^O`i5f3C8+yYN8Psbc2*zXwfeTaffCqh z*3x5t82$?FjovZs$kPRRe#PFXrzPIW-e~`w%bVw;taEvbH#?UybU-={VY=Y{8uP~h z&z;)}072skslf}P2qUw$KHrW85FcqD?2Q<3iw@PiUP_11aJf-a4Yeb}zzsS)gpoxZ zf-sHjyNwq2j}snV{=>oyX7%G(Y#BkyM?Z$p;j>qwZ>Q| zuuFi43txTxJ?nE*chp3mQqq_EqRC^&!q=IZ=&#F?L3o`&bfZHS|DO?`xwPFpN{1E4 zA!i!b|KA;tx+nz_Kmter2_OL^fCP{L5G6b{>8v6{h#YUIP$OFKM8KVcz-N8J|2Fj&gL}C8OyS&RHUlz`{NwrlxvuJpi(s8GvsrB97=dm=MuZE!9eZMWSUZn9dyxsA?0D%|b&RaI?1&FNk8hMSB z4Vcq{_vkfMlimn?3z8jA-&%MeMklM@ne=GZAoYd}lYW+Noj*Soq+RFkZRyv}&BIGR zKV*-JQ+jiqwTCDv-@K+b&AKXX^EIO`!)u=uds*Ibm%fN_?t&U%id3R+h(Nv8yVQLv z#+FP3>uQ^Ebp@g~t+)5=B9p^&(JG{S37y%^E9rlHFqT|A9Bz%X>CC!R)dV`ttMe5} zhPPj^e87CK;@woIQ-@^GaomZ``aPE(YQBm z%U+zSeY8EAUK1J(S=#fP$n>qjBnxeCUt%p_D;gwY+nU#hHnfre83$Op$Ui+G_Aou@9pq2Po zEP3w9aBJRtPuFO`w7Frx>IL2Yg@y^sq(RU2RsCR~QF~L^Nj(KvFYG+UP&HaLvUrqk z-FzsPeDcZgtvS;;JZZg!Kip;-o@BRW8D1H>+PR%*Q;?ewU+l#oCa_?8*i zmRva&Zk;n1 z5)+Vi;pU5Z=>Im=vK0#57v6l2WDi)fTJM?nSe7YhaAGR2TQ|hYv5{DEIUR1zFfr^~ z=1KxfZb`JP+kba75Trj%dkLm!{xl3BTNywX(zFIfW6851klB0qle)zPNIT|-)idWF z$ zAb0`H$pBuwyN1HFLA!2K`SP&XZ@wr?z!EP*sff~2uv1c{R9DT*&+H#uz7 z>37aP!UhLFV7>{pUbZS^%l++9pZ?tI+|fAa`H@6e|FK#+Te`|!zPfy2@#=N%T{b2>RH8$k!XJwe7 zhAX;pneM^hR$vb=yz0nha)pV^>_jflWi!R8Ofgpw$pEw~miSZJIx+G0iN8zyY2q&v zf1UW-#5eA)2BS1c;Pwc-{oFu2H22EeJGYYMJl!_9F6e|`7s?yW25jeqx7p%p>nisg zyULwmSGfspNs|l-e2H6>iDoR+a;JimE=qM03^sZnSu*C}Av!7U)Y_Jj+bVE~>>Zual8_k`Dt&ftd7Z)b2 z%Vx5sdrKKLHJHwXecQ6g36%=08sS7qht0WgG6U>fS2u~aeS*6T z+xv9_`?@z_Q=W^LFD&tHEaKe&(5lTgJ1w5z8oe9xO3I^) z)bW(V9DeulO}!crsNX);KOCAk`}UDr`PPBFRT3QH0F}btXsdNg_TSt}lkFReK* zYPIbNy4b5(E;G}0LKM)QRRO1a3hXdVtMrv-ogT8$-8NSYK{Gf?n;I2t0n`khVz4dB zwBXZCbEOGQBU-=vQ=9>xh3RkbRU&fN8Z;qYyrG+C1o+dgss_043M^Bi$qf1zuft#d zR8|Xmcn!05E1AQ&-2~Uo@Il?Y7r%JCJBfI3^W(doB#>Oo4) z)gcFenC)aM0fMb{CK3;glebrHWr=O9X56y~IstasiF}K8!!Edbj9emgR(3D?q{t8|4iMsnEY{LkO*NbbBxdOJTS=jQU`u9_tva zennU1O&Wh8ma<3;2C1vCjUE2qP<35`m1)e2ed_38&3onA)3SSU=@Dx8)&3K0_PlO( z^R)j1yWKs`zV%4oa4383t;N>ORyfbz8M3t={Fb08FowRaHZ{&1RiA|;VW0=&O6FlU z+zJ4uChNNnSP?Zaa$6c zW@Ns~gf5dtNMW0<$%ID$D&Ecxj5)S-Cqge)XlLs-<2bVkyL49AIt)7OmzZ==qNre5 zaL|XwnLEs>t#I%VQEfv1?3TPBa5XqB@|xeSo{H9w!e$ML)PJTJXR&%Zu_upXZDEFR>`ho$`ZxK!6=ONp(aEa+P3wf zc5&z7tSH*o+I;BQQU2ccvUP-vHQgYa>*4?dz{y8l0(cp{W|BJnR0zn$1j{4a@rm-tp< zF0q!l0Jrc52_OL^fCP{L5h z|AEN3ebG!7`B)?yc4z(U$;-a`BgckpnXPFn`u@K!@g1tm*WvyDcgFr`?B~YnW7A{d z(cd5ar=x45PmFwPUkLJz8cX^R)8gM$e&NnpGfVYeA*3{5omXhwshpn{%Z_#Gv zqoJAbqj`JJI~)oRCmO%r6yPAm?Q186^6XqR zw3IyL)V>8%Xd1jR+pv!n7xjuD>%`Jffc($S`b(g%Q(=0R*Q9C<4rrF8^2RPD%+Ewa z^AEW)_>MIf;hl1`NA+{lK;gha`;Crc!1fF8l=WgLK=PA?Xy}=wlNHXZ)V3Q`WA;hP zOm5S%1F{#VsMt<+IKE$QiiA!EG8dv^cg;JUkA|{t)y~oXg50~#Y%Us_a9gYA!OloU zy3wPRvsth&S4_yF>-5k7c2;yxAx~w%v|S+~C%oAyo2S?Fy0I<8Y5HIqT^o6NG8($% z*34cO@l-E$0p5z%;ShXjU7}XrQ;O$KL__D?Qs_Gm9#jM`>UFBBx}?_yqg?A*#+iv| zXvHnVG%)z?0@!K3uX3I}9u1XTBGy_7w+t#sZziqBj>STS)~k=^D};y?9S?7o>6aPQ zB}2cNqOX~iYN~iMRTplgig38}@zfSq@11fZBj6a@`eWAR zfQp|v3Kc^Xurr$bra{A2pJGMQ?gcE2LnQDN0GgF5a7I6UN2xdKERJ-~U3?01_qo*r zes(qldoj<~*B|&y_q?Z`q0dLwa)JOXP=0M@(_fr6ufDl%-NVVb3*g@S= zXMp)$cr+TiknAkQF=P9+7Y&)a7kB>SP~1a3ilc)nIOK<_X7|$09*TyR1De?8oVyov z;Sp+Yoni&r+wR3(JQxjK2`J7sJg{TfC`t!Tdu!+Ehp8cVibq2TI1vj0yG!+Q64+-u zOA+Z}FvSdnKI^uNgx9ye;d6kQockC^c%-vzXSkB#^4!z(pFI?I`XR9TAe%EgcNRN# z7wN?Zp_EIGbRXUoRh$E@p3SS4rls3|`LYVZjNU+dF=Z^-JEsW9jhPt&)R zGK_U=yR<47i-soMy3B0My}JX}t|ca@PRVkjf1r zY;+g+WCRL26j%^jhQt=L=r=2+GF;9IR$c8h7mkL~9#{5+CVw7y z=)T%og3l`p^!>jQ`L)pCgTvwYuaA9t=CK{}O+AEIIbm@#*;0kuQvV z|H$NsHgat&Kb#)>QsM_-eZW~*Ezp-Zl*q(?JT8s>;n+9h|2Xj<;(t1LF8Y1Z`oI^W za|5CN|J47Lfrb9h4ICc0KJY(9uSOpkXbgOP;J*$0>(PHU`kSM%k#CIt{ph8UuZ$ia z{j<^L(0?5I*Mq+|REz!hp*M$qX7HOs%aLD;{M*R?Gh7~iA@-NCcl#ggpYFfW|MK9( z;9G;@(0J@h?3ZF%EFPPT{m@8q_@{@zKK$L`Tf@IK_`eMe^#5w~52Bxs{-@|~M*nU< za}1ak3EbTT9*aEFXD$M=pM5+>x`cl3aHQ0ByA0pn1z-0EUs^?v zLYNkVLngQRx+)UV{dSFLs>nr3U2@l8=^oL_bTZaoJglIFT_LKn{_)7kwp)}|4Zc~f zW(af&)}N0qJ70S|vgBTNX8>6Po0`ni`C@-IxcZMCjuhOh6=73?w)1e~)kBea_l8>> zt?GMxEqCv!4WSI9{8ze2Vz=()F4-*wUH6Nm!VD~&kOrqw?PNN3fM257=wq@RSL?zXwBiS(5Z0uYDF@x z`ZJr#xk4xJ-DF#mXw)u05xEd{=ZAR9DDA$% z#EaCRJjKwP%!#P8(iOB9sLDJE%tv4I-g)|7z;n->je>bMr3pu-o}(t^xoc_Sg(H!3 zVcV7LneDbynU<50yqktjf|(Sbr*!N*_6e|OscJ!Pm0yRk$_9KPC_K;AR9QSj#RE%) z6A#TPcAmu{IImCT#q|=i2$n-{>M%@_)TRz!wWpt^mPVUtPPf}2SU{=4y38d?zRUfB z-T3EdAx^z@RgvK7h=vA>!XmwIx1^#8RXT5HK0mS<`e!M2Gc6p6q-+Xw)iiux94+G+ zs#TB7?(vy+%TLi1T|_jsbCSA?Rer#Wi3Muiws^kR4)fG0>^tr66^hG*z`ZXavITll27_cFe5y1DIXJ6bpRLJ@adJ z1v&3|bLC8>=a|o%Y5kMsSr&bqJ8XWAbpYmMc;?B-C0jf;?-n@MJxM*oYDBlW>=WaW z6+2C@x#|hlQJ4bR{jM{gP3NMIQ}eecYrPZA$Ec2+8=X_UPt$;DPe=M6Nz*i|eam^c zrm+#WeL)X&l&a9a-#!OB?!8HEmu~2>r&yn&eC?ced)ztHCuzK}Z+oUlpYU*ZCjM*- za3(;Hcr43FWzRFESeP(DIydPv;9=HrwB0bn6z8!wCy(X$k24qWPX<{5m(3?WMx%>u zoYtJ8_5F^}hgvg*kM>Mt&1QT&6O&sj?UPgwdhWHG)dQY;j=frp?+?0Xp*`RU8g>i8 zzVGv7u_@XUJqb_j@GD`=A8O3Gno*D0(NN<|!i@NDxYI1d{$k9Dl(0>>6FEQhe5l3`@7tu^9&I>#<6O)Ro-_Xk{)uR zI(sWg&;NUH@Yzt}vxz6hesfG4dusIGkN)83vm^iS$S;n(GBP&&mEq=aI{qi|!q5*5 z{v{;DA0&VTkN^@u0!ZMVC(wH0!Qs$&awyr7!ugOnFKI0;WYd!D3hE8DSM+M zJQc#zd=*wf6sLBgc0rjOm(3NkGsRr4^~3{ro0tP_Vonzev#lrYzuUy@_llV*WO`W?BPN4wPoDbzg=9IDZH5FdVG;h#(MleBh{yMb-g~3r- za}!`exoj~z3x&Oqh=(SiusjszOwb0D=bgEP5(CRRH+F{vn+6|wVf4-jM$0-ka%Tj~ z%oelR7l!YMV63cj@jE2g3|R0BLw80nTGqM2J0w^E-2a8xoe_+dbuM}*1j`f);7qv} z;0UevZ2VSPrGB%lG&qtIzsoiF`Y~8tCBkf~)n%~%!KwAIGSlaeP|R$xFeSkeGimV0 z=8&ftn6iR%P&|dO4(X!l+^aDC&YiKAf^sE#r~&PQ=u5E``aA1Wyj5kvC33|~zT6iN z9fPW>v?_X{o^HJe^9u|oUC~nV);SYBYV}nzRm^9L`Pn9%Ey4QHQK;p;_M?S~YzDg0 z)f|ZL_M`39!kq$r{-5fvgc5&}_=SX&I5GC!vELs1p|R^Q5Ae66UmX3^=nJF6BY!aR znUQlN_YME?@GlP6h9}~G9sgqdX8hTBWaxK?zHjK-(36AzdGKEhHV5YiBeAc<-iV!! z-5>p<=ubt(=o16q9Qc)ioq=-$L;e4@|7W2h{6PXp;2t9|JJlb$l+P6Myda9O03Xgh z6#1$Qqj~2$y7UvT>7FrM7P=i6c}-2``$H!oXO88Bg$3O*&_N#@X*xZf>kplS3|W?e zg=_legT9_-Usy!iDzfuEo9z$rkT1jXscdr!tbZ^gaYZ7s=zLXNml86W{?Ie|%+wq! zfqqR8ykZpLfX6bNo8)VpUFYN^YuwZ<%dshmM1?mJ?2UPFhPe~{p({|u49j2ye;sF+ ztP^I|X_nPl69Nkf40zuOh-eFgvz|KMA9@99EwHTioWFNPi2WUS!TD#7^@lD%{wbE< zB!V*Py~2d*1`vxAka$CypvN>q?i* znWLr}O#^}z4X`qoE`*^cbOwt#HQpaO3)PvXGH?NPy*Pdwfqng_%=Rx8~e0l7RT)J`VNb00GsYZhSZr){lGaNQ>#>JMGdXR@%d51hrsO3x~T zxi3XzrKP2Td8Pf=zAY`u}b4<~(>0lX(iT=tkOU`zK?s$FIcm z@%YeR4E>)&KRTogogI35Xk_ru2ER1;Q&189AOR$R1dsp{KmzwF0lH3h*Xh71x_q_U z)L))1?%r*hZz94&nmtbGW$BvtuG4oJCQO&9x=FgCclT+z6K1mArr;)yMP7l@_B7pg zoUQ}jZEEcpW7T^a?dhYDYf#YcQ)X!sQB;+cA1ho%<%4E`iZS|?=sc&INgNMYg*~C zbmT=yu!kw6N0}+^I$iW}CiWhth7OtK?KTbc2xGO&l+Qt?z~0k44>OC{<5W)46mGX^ zn~yymxgOrz6wN~{8g!YCdC-)<=hVvsrbxR@v)s@4?_o;iKrSMM_cwiVAKm)0hpCbT z!?LGokum1|y{14$>2{4C(;XwM!F!n67-qiEV;Un)x43klvKTV+cb}dZWNqm+6%jMr z(skM)YL?JrieZ3R!LHK@{VWdcHgym&<52Hug1)iHks;5N0Db=-y6;{chG4sq01`j~ zNB{{S0VIF~kN^@u0!RP}+#3Yw`+t1?zc+M^twaJy00|%gB!C2v01`j~NB{{SfqRz# zKL6jly2q9y0VIF~kN^@u0!RP}AOR$R1dzbJLja%u?;TxZOOXH)Kmter2_OL^fCP{L z5|(ylXT3dDo6}Yo=Ul>9?Ze zvE=-G_|r!WVO=J6!ZsyzxjbP>zPk9_`BG1glDNi6ib3GJi#bI#IHf7eTtk!Ug0{_V zkZn$A8mgp}HBu*v0rJ^3nixAXLlRdv+@_$FYl3#PkjbQ-@*#<1Mr)oVz7!URZns$- zy7UHFD}M>-;9sq>4nK$?K{r6G7obQW2W6!Hs9eAtBK; zRpaYK*M%w+Y>*qYSdc_Bh{y}Z8Yc<{F{C=7rKT5^WKox0f_9CIha!ZRVON*yV>DI(kv83>1xFz_^8~E#vO&i+{LM_^o({I%@ z7^2zY`H+^nJ8)i3({{D2Z1KI80x7WD3ac#))2geGRu3f!ay12@(E?+3EtS(-- z+{4V)#HJaDd7YG1MFhQAer%d71UJ_Y3LfusY8!{vD3Vg8mT$M6DYTo<>1tCegI{!W z>bjsv73eH@7MFd=po*@}Wk|Xql|fp|-)vcAwd!1JK>uT3Ul+<7%|>t~lFp0THV-Z9 z=HpuexQ!~yv=ne@gVY<~!@W}L%`&*FUTMnRQkl^VLPsY)C%^!lrXW++PU@ykH0X-F zigWHs4KHUJ#p=WmAp8h66JMNh^4VqjBaI-|C55N=0s_rSX_dQp?fm)l&XHKMPzbjk zWsWO}H+XTKw`iN*MC)G{bYfqn{xI%$*r05tg9c@$!NhXZbSsjIC1;@i@!V2yCSe2SJi0eEy(4}{k@NaQ$W>^w^!{LKxYZ5u5~+^dR6WwO20Y%SS*>%hTlpu zt#ww_dF8O*A3GD=mN&XG`TfcsIa7!17=%)raGV`dbecL`lm+NF>#AstPa%HT<4ksk z$`3=(CiDuPThQCl@wK$sr2|XnKORka-x#WH_(qyN6p#M!rx+{(8OOdS*LrYiwl$1bu(R4J{9M<1z9=;`^YqdTZK)-MegQJwx5fqTZngd7AE1g zmFl#$o0F#bp=13IwYB|j0`Sl7?|UeeJ@=WR*3DKpf9?lA`ROYAAU7J4wIy{+(CE(6 z>#$*zyIfNZl{+iQu!((}E73%-`FTZE6{;iOb;x4guoZQh2pRyXsjxXl5h%0cwi;O3 z4OVv5Dm!)aCfku>B~5W#5^QE?ViDLJ3k8Y7Hr+H`hpp65*Uk=XrrFjV%`R39*ah!4 z<2Yx4q1dIf!qy3A@3`RvMTBiVoD9YQ*#x#pRD!Mg95lDugq?G4$qNEkgPq?eK|-rI zUY3-Not+e?8{0BDnGz+vAq(3@kQ45ZiFH{mZ_INIK@>sKBA3lHZg4sHm}mA{)zqdU zPLx$y)r#Da!qV)_bZ#C@m;Jp}gN;7(Y(MRU2)lz#trS681un0vnn<(>O%SD~UgQc; zX6n=@BwaFWEiFt6r)64A?iMgtG+*C%^moX2k#% z$s#ALTLvC<$F}hE5?LolwR9IN@2H@|#;qE`yzRggVCJiEo`K>-D3Hvt`HMoi#x1VP zLbC$yqKTXctpL?9nN3rd2*fY2HV*b_bCq|$tZ`@G{k#q*6ev=;W^i><)3`U9)ptLy zRAsnwf_qMaZPj4d1YBO>R-ix~t`qGzxSOtX)LXeK*?RZO3It&Tl2C#=2Pv9x5`|VJ zvLM3MttQy1;z(*Y&lDDR!8Pk{&;@+VZ?}26J&uM}8m!;D*X6+L^6!_|aTQfDCbrPO;Qx9r>ciNvmuex+m*m=y!)>p zwZbtg?lc-|b6#@;BZ8wdDxmYY9epl9P^xGItdE)&6m?d6_sj483{j3RtZS#BTxL5H zD{#gLgpi3Q&+%`D1lG`EO9!8OwsdBzNZ*~=Q z=Qs=nXW57?APms~j8nm37DM9;qO@rj8qiNvh%+LDlHK56DMO{U5aoQ{)00@EQ2!{B z%GB9=(VoNUrR=^9x;bCRx30>fzk$=Nf#3LvpZ)f4{4lp%Z>X9fC?g>zyEYgSOX zY>1rU;2-F-%&~misNguaO`k}r*7E|>fIEVxV|#ZHud;FY364H1GSkq_$BFhQ09FVj z-iFSZ6E+1&rpI-0%?6z0q&G-e5@a~^>2>-%U=3v^K_jNs*d}sXRmNc;CBfiGZ5rG+ zKJ_DugQe(6ICi>)NtnoKH;H)s zwkD9`@$OSWaqs+(+x+-$ZH{+uXuMbJUP-wVQS`~zntg&_JxJj%mmK`z;S=~Y+|H@P zb=nhj@ffq7yJ$kr#$#Irgix5d+f6s#>Ami1@}{!*+=KSmZW(Ag&de~H&Q8xz_QR3L zgQ0Qq_R6g+$(#S9znejXufTweh?aSsVSjKFFs7^%QG%HwZV{%c6ndU5cNGR{Ff}dG z!M-p3?orCDgiQ$3p&pyymYgc+A%m=nj;X8?J#Umd4F~&4vhGf(n3Es-eS=`h+#`6H zVT)BtyF1@WXLe{~9Ugt6xeVu{>V$(ym+}Ui2iLb@Mz0Rj-BlgN?;2N!b57wjHzvE) zbeK5Wgz0Y>F~d-{3BzIY`HB9&p~5L_Ffe8ee0Y2GrhDbuvuEwaCEg82Jif2?pJ?k( z*WHl>^qW4=y?RcxPv};=sl@Ck(LP}+e`jaE_UZFD41n~d83sUVN8P+_zXpcq|9$Y2 z9w-44Kmter2_OL^fCP{L5AvA9|<4xbe(5q!VOW-QqQYi5`$B>n4(=4Wjo>Qda9! zrl3@w+A6}Ps!2xo!Zh-FQ_={p2zA1fx**8`)v^NXsz^Y1!uP0|H9@aIGaV>~%2%62 z4-}Lo2e?qG$P30AC&F)rR3{F(rE6EOmM*UHtIHQkE31naE(6DAgC^TY2D^<`3nWzu zXd{)SqH08{Dpd1FEwcetr#X#Oh(?q$(Jc$m*O=|3Ih86|CI;b_7FU)QPnXi6Q21DK zW+r?~WtQu3*ito-uSl{%G(GU*Nx#(wrnxPfr8Tc{n}SxZ3EI(YCX;q-9cWS~0t?x8 zQ{*6lW4X50sclL+FO&^wlXStiwm(&-Tj@e9`AQ+&x+Ezgx#6^!S(z-=B}3=8;1-yZ zZU~yecTKinv0J`)y7ZFABYT&$M%CxKuO~6(?5%k%-IAsTldW(e{N{L9DR@Z)kCZAB z(RefHNMNNvOjUSeyFt3kKfee0_f&ZACb>ndfjVsw>F*vX*V1nvJ`qc%)8WtVFcWUn zRB&QjJN~~4exvo@ye(@ri||KSM^ZajgT%HxjI7^y?MM!(K&Q}AI6)q+Ska3&$PEZM z^b;Obly(wI$w9|BE&-z`nd%``{WBOe{@=zlo-)8hDrG$6JD?(1D?Iph0D863zJo#GXnp!+<|2)tT83c@&XuJ(!IpuYi2>0aZJlvS)bVFLbu2 zaE|G?fv}rozN9c58py?S+Am#_i zG2K%Rf6i`l*r`B{+yo3|=fka6SXHKg4m10C)dka3q}Q9>9&4v8(XL%wzH+U^S%ca= zmTAh`B{8)z7am>z-szV@=Bw_jGcy{!J*E{neZA4vRpDDn4+ZzjHz`18cKX>;)h2_OL^fCP{L5uRjtV>i0bVe<$&6m;?Cp4_WI_TqJ-5 zkN^@u0!RP}AOR$R1dsp{KmtgB5};4}eeC(ak3IkQvFHCj_Wa*xKL39$l=%0FuO+?< zGXQ^q(2xKUKmter2_OL^fCP{L5~tp)lRK5V3#h zi$xw9qF2o4|DO*f{!8NPiQi59ABms8n_GyoAps)Nop8prR@1x2N+l2&>01`j~NB{{S0VIF~kN^@u z0!RP}Py+P*KR*9c2>62pkN^@u0!RP}AOR$R1dsp{KmthMqe}pv|3A9A#g-ufB!C2v z01`j~NB{{S0VIF~kN^^}3E=vFn*{zJ2_OL^fCP{L5K0pu1dsp{Kmter2_OL^fCP{L5sd8l-}vr?-pAXPy22RD2$Bu=6 zc+n8n<@UPUYAIQwtS&xxz7(i;suxKyNR?=uq8gmilx41=Np(Tn<~GPSCo~OJQpy^s zgS{Ph$^a!$SRHvGg;!jzTobgT`RTM%I3(C4nl7oz8n-EE?0zoe-seP85t_2Wjb~3} zPGrU*eMQykg0aRK8b)L$G3&LJf03J%t~RKzHSH_lqd=Y zF{C>2N@pP?3bZchzM&d|%2$Oqf!PT?MA>|22XF+c|oqKnq<`Kt`Z8M1c$v| z6LN)V3g6MDo!8U@+|dnH6RL!l)v{pt^jXMc(w+njP2H44qWS8A`_NzlmbKk52(S)r zu9X0a50W`+R^&+pCd=&7F|9&@ic%+r033uhjv4}70D}hIi#)X}8<1lK4u_>{SFe^X zuJWtP7fLIuix)0K&SrzAzm1$=u|%pW)T@r#c4)a#n$t*yKtwMS-Rold+EIw&YiSy_ zMIsY}a7&9TON*yVY3*bzId>%7ddP{Bmf=E?y1#p%S5EsvB<&eGlXI6Ya-OS4EmN~D zNn-kw3$f%9)cIs!opn_tJUEuo)IFteJuCL)X|LTXgQhsnnZ>5xS(=X}j~ogAzy)vc zqwVw5xa13ZDbJl0#0_uIx_ou{!s6BI+_}dcrR9r|^#UlIyLbuyyLSFO=$nO# zc71#9m(_ZMYQ}F7W=25a8hs8-kvm;Fvv}?NDmM;7E0R*BE@nw+Y&XDL+YFZm7M zhP0OJn6ZwAVTdH|WhYQH1f$l`4(BfA-_}Owo*mP@_uUrQAzWxQ_|7iG^mB#UV7Hs% zIWtE?qFH3PS~^pL7`0SdaSVz210B<``{2{3OXo{4 zDqJayj)P+m2J)g{TcWc5%ZP=&C*7Z3!9;)sTjcKtW%@q0%Z* zgb3^pap~4$GqL2QnQ&`~*?ol&_VC1SshY?`I5l8EZ2nzb3&ytaP!X6q@Zw3ovjnEOEtg?(Ip#{od5){mt`&yRFnslL z_KsfZm;pLELOWL&WC~?N+N7iP_5pR4CI}n}+CJ7X08Hch|9#DAU}7YI1dsp{Kmter z2_OL^fCP{L5k4|3j%Z6dDO20VIF~kN^@u0!RP}AOR$R1dzbK2;lSozHnh; zB!C2v01`j~NB{{S0VIF~kN^@u0v}2Oxc>h`sWucE2_OL^fCP{L5NT`euHmblf$=gyb7 zlzB159bJ>eHBM3tQY9L9`Rek8#jDr3bEWIt;G;!*Qhn?>xv-Bd}lf+-q>#RC|Z}^ zApH8aL3F1jkf+&@RY4>oFBof_C>X?$>V!L8IBlqUkU~_q6k2NURISjnvE=-*aBEyrL~=tCZ}4?Nkt#$t_;sPY z(QNQqQ?Y)t@0_*tvwZP%=_L-d5}0X?Qx$HlE$bR+(Jhy1CcX1qESU!h(w!1C1P%1p zR>X3T+RY4IYc_AYEu__Ji?QS+RG;juzS)$-w#uLHQ8`WCwQ8FkYchC2W9IR2>j740 zxvqmLRf*2)a|bxo%Zrdfb|GofhHo9!^mWPHDKlps+we@x5I^{gZ4x(1*)Pwm8p@oqD01w6h$SZ`!f#)9Bcn&u_Cj9t>#qIwZK=>7AKO*fTfvgfS7miwkk`0%Rh5aLaJE{; zvota}Ewv*TTm$Rbhc+RD)l3=ziRQ*Y$3UGbX)vs)8j-3Bwbr9<^=VEc6#@~aOms^s zeT~_Bnuacs$iyJr(&Ea};^|Vl_3ZgrvOXSeZ8HPXNx7*>#x^gjRh=)(61CWhq(IjM zz5&0}5Uv|Sy}@^;|AeJ7%bE9EmT64BTvW@QLaYHOM~=00>&bJmw5O^_Nr zCBL=@$vcbaEwI#Rm-FZgvE-{Dr|GM{hVrKM&|bEupsT$E?OsL~N!<)>qQ3dsax6JM z9)9})=BqwYeSfU@z0`N_wp`RBk`rqS{!nWf1n_lIOu8h7j=pddB{q3P;hl;zG)uR!1rVi2Hy1xXYzsc~YSD5>DXtd+PdA}QX+`lbX zcEqzgM6ZjQejYRk13m2S;Q{_@YJqQiP=U~4d)_#Gx}ZJvF5$r|rbo4&M;N=r4qt}C zAwmc+gcIyw;OnFvB>qmCUGCmV-=UuV@ab6c0z4ADXxg*)@x~eF3NmkvS=ycu&b3+a zF1bysZ7YLWkq#bj4XR-*MYZ9Mkj~e zZz1$cp~Tk`|0HoWF*No^V?Q@mADbQvkN*DXKOJ2gePZNWBfmQG#>lCW@bFiMmEoiD zug5@a2K;?f-iJ=lZYo zk4C;4`E=w$LRTX|!b66R;3q4|eg8K5e4%dMaC>t6lb zG*CEjFmLT2p?iSV1)cDExkkjMOj0THPJrYm3(?RsNhd38-PN`mRAaDNoHjvZa(B&M zoT6eo*e)&XlTN1t)2%vBNgdJk5rjMnjj}nn5Au4W^g60Ov^5 zVaKPmE>SD*DaCUqqM>tcDO-Z3@SvhiqUlssbxE%aM!D9rj58C_(284zX<)GLQ`PoY z&a=m(p^{64Zcmjo;+8=L>CL3|*s)ls(0cXJe1#Bp9t3QzG6*kAb;;0grr;0Q_MIx; zOx1-OsUmEaKc3oxU0Lc@s+gb7WOk0H03AIW!AXpFcZKB@g>C&Y>vBNF&m4t{ zAqv=tddDCBy&pHgg%I#i_zP+4o5?Ud}n65p*uBc);VbxsdtaY^Bfq?!On63 z12}k!?(y!Px%jv*7TeYBDa@UI3=A)PEdT$r_pZTlWZ9Wm0U!yY_`p26heNemoefS8 ziD?v24-_8NB)dTnV6*vb0&MncrjS*YRh5mZ%<9Z6fFMO_Fpr+mV>Q~)*s;<`T7^8; zO4iyTdpAZ3$9S}MuM`f)Xsw8q_J_l3c}E+vLLRMVL$kI++F1EJ_vX!4WfcID-Cf;t z0TMuE-p9G;o_p@O=bU?vVB8{94N(B7mjZeWMc&1b@0G_7B(5CmY{esEyUp$^vkBd~ zxywI{<{s_QTotRMP2jb)om;!`*nz~wm?1_M=gtjX`5~gWPO!x07oxYFo4b1aK;p%i z<^1#8|-2P`!E#xX*O9QUOcT4bBKd)GeAE6MwiQ z6hkJLJ_ZO+bhhn@D`_RCe2Ttz(b)M%LG^Jo7Z6`|2I@U+`pj==~a3L5rn zW1pIInys;kvB~o*#T8=e_F>u2NF|$U%VPy-rW(H zJrGlef+O@8Q+9t$ow{lF8q$S*psOiwlDjgXmR@dY>Uwv8&kUiV$6_0z$PHr+v#d6{ zSDzm|ka#*kY+&>3Y?W5BF0<-Nr`dr6iSfWv4zQ-Kb&l*WB_{R_4UGQo=#Py)JDNE9 zt)q927LNSgk>5S?r6Zp?a`f?C;wCO+sU6#R+5>KzZv<1kzX1y zM;1qp9{gVq{_?@r!R39+!`~hLABKNs_|@T09QfY;6Z_ZqeRbc^9*+02sKkK8z`Kuu z=e8HQS_#3TGz;-jTI~{4NjQz+upCs+xbGoq&7{(qR647q=b2({=JIF2a9?|Te+kh` zDmYYAR4238wi;toJVd5FMNjz8RAx3mGdDduJDr}H$xXI!1{d`Pe*W;KKl^vvhqfOd zPGq)kMjPakAQW|$J=H?Iz^U`3d^1~gMxWZ$D%5p~g_=p(h!2WS>`;UaZHi9Fh&r`r za4vrA|2?DO$oX^>7Ub1LLZb-&Ovte3eg|QR)#ktv;ECfH=@(ss(p3Ro?c@hYTnb#~t z(5rawsw#cs9Rc7y&CJp_)iAA!X5COLdfP@9%?h5mdcMo*mf41eW2~-OrvG?Fb4WXa z(s#$~mRiQ&C<84{6Pwor5AC{OK!Hli0=%gr3@X*ObONf~G+52139S>C3W3n_-3^AH`xl) zAx9`xOeW!gNsulbvpWBNx(rp+qumIA!{1Tsdzlgr*^fnQwM;B4LK)N9hDqgn3n>*x zmVsy;lIH7(gIIUcp@s6j)EZ>BjxI=xGS*P7nig*UucnGv=ZQe;z6M&X*HHU^1zt2h z_2EDTQ4F$|kD+s?|Lv(yLmmvaTva!8&^NJwKuN6aiPs?=1xM(^KyZ(SOHaLq*@`c1 z5>Yx^*>Ht3uVRSf0f_zPJgyBFXPPm*G z1QB)V&;D}a7KkEP)^4|2riGZPT-z6F`MZ-ZfgFOR>5!xvTSZ5$HFSd%?*24f`m^so zeG})KxbhShG*bBg&adLE040zg7Fno&T3QY3kk}_bRnHTv&rBDx^M(9eW(BMsXDGj# zu%8$>rfOD9mE4U3iIb3s{VG-6fo>IA!yf&-2=H*C^(*luFfh@zpY(Nz&Y>!sdc~>t zsIh#FG)a%=e7&&P{KO&1hFY|-#WYD_(@qE}=dK<|T!3XN(2l)M(G1&QJy6L~yjpfN z?a{{BE8sFPP6gVa>IiyFx-k~H(ca&qe&I4!)?lc9u zpNDq%(~s@<;30bfUlw<E=c67_eXj(AoKsd`v)%Bqr`y3fW&~rfW&~rfW&~r zfW&~rfW&~rfW*MNjRAT8|J}y8>`!7qVnAX*VnAX*VnAX*VnAX*VnAX*VnAZx-N%4D z|G)bz|;=pj?*s+mB?z(CrBtQL#iCTJU|8U~5$586L<{$x# zUE#lCN@n*VuJcnUc>zJ0Tgvsi>6kVJXm0Uf)OJjriJ{@d6HgEfi2iD+NJvFtVA&sW zr3c;8H?*?G@}^nhkC@V*8W>JI{y5rRL=q-MMBFO7zhlah{r|w|Zze|ndh{EkZ;$@w z=pT>%+Yi7L%OOY%NDN2}NDN2}NDN2}NDN2}NDN2}NDN2}yk9Yp+;=Q7z_b+y4)1#` zG2kjB_J4TaQ;C6~x?$hZeNUjI&?gMBkDkI{@bErlq!KCtvi}breKj$98b9Qh#DK(r z#DK(r#DK(r#DK(r#DK(r#DK(r#K4|0uw5KXCQgjcZa=yG%>D`zDH?XU-gfR>xAYCQ zyro<>kz{)7y#GS8)*Eks-D(@P)Aj7xaIw=BVQ;;K7YfPS=YMrH);=6DgJf}O3rj#_*Drv}fZ73_crc`vKtu&O2MqO>7 z>87$k&8Dt<&9bD8E87bL$;1;+J+b}x_KE#^vxR((cDs35LuO^=s)?+h+C}k@;0V5W zkn#A9K7=ul{eN)uPZOhmI{Mdpo?=-+VnAX*VnAX*VnAX*VnAX*VnAX*VnAX*VnAY` zUobE_bYdW&e;GVH^u&m#tswjVk&*w97=7{RuO9j9BR_rQP>u{dZPsk7#Ym8 z!|3M3t;Ca^-OTT%7gk6&D*A?AX{(K+AByj$e28}wGn%>Gbi)dXu^j$vd?#A6>sV%Y z(+Ml&k0trN_-@K0UB@!Bn{HSkF_w|F_)fHg!Lf`SJm^nYiZce0O3$a#*{NN&=65jrli|zhxlAE9$GFJ$#Nd5_%TWH%z-6rk z!2K_~gFA3v;IhekXy8UjA-}I1xODy>0B6O&FTQ8n+Lv3&M-r(kpU-c~Ga7*YyppST>ujhURDs{Ijy?el93??=s3O8fsI!caNMbqGgz)MG~GPtMCqCo0sD5 zXUbT(_Lx#vZFqvf@xyMH%kbr>wi}Ht#f756{7I-K(S@zw(Fg>$K#@KJ2zZ@{{@gc# zh8@$=DvGU@+m=Rc8x^Ij8t7Gl^9X;bR}};6O5+_qIM~T#Z-8GL^Km#fF%}%n*r&r} zlbtW*=Xh(nMvgj)TEhNct~X2@4l~{7Bd)YJGw0!Pvkch-^P*Y5&>VRr@pSF2@y~57 z<@BFE_T?AZ4|mRr+U=GO48CHvt;pm@CYR>YElgx_fy*$;60h&|%^C`mMb)(8NyBZ9 zO&UCp3{z>CMoqI6l~IdcYG@vfc++NM@;AuAh{#HCNlWrqM2 zz3E9o?B_tdpx~NnRqTn=C5x@L>m*PrD{hrlO>I)~E*KB5MYdvggYRTA7Sk>f1I4bx zSWiSo@S$J@My6#lx=4IP$F7l!m>8d?LEn+#VH(-eX{hb0X1D~ZStv*g%6&@SUBpQ) z>sm=WWsReex`|DiGrI|a`;NXk_!{f2NiY!gZxu-yeD7YME2RxSwAr>Q=${cM)~n-@ z1g%f39|j&ZW$5L)qcpX;rBvVkrlnkX`|CD*=391 z9E8}n@%A?yy{2IeZE8jZFUX;fI#)`})eqd31AsT}7G07nxR?~{g#;kFPG?k7V(BdB zmZz6zo`GW_I}#O?Oulvd8Kqg3CoOMGrSF}o{uf!2zc2o*_7mDp@^@Hl4hY;y5@?>+8V$2UWFe7;WSST$ zP;8pvRqbt^LYh}hqf?TB_|iopNAU(UkzANbo`x>5$2MP`r*Cgx$EN8Nl1YC3jT`lO`2>Ow3)1Fl$5&xy`?m6T()3aBv_z=sX71_Dd?sDSfI zjtLg6v*%6Yq@!$_mbImP=d(XYlBz46hmIQF3DZ4nuu}`QY?uztEw+Qx2hRI-cK+Z0 zuM+sT51$9$GaZ2JSYkk8Kw>~*;2~h(t0xaWk{Dn5(&86SXgT(a{cv@R#VSspTID;x z^wY`>u60zdwKV9JbnLCm>>cgkUEc}C#DY*-DyCPR!~otr+i@)I%{CNv*wk>%&Y%v& zPK*uB4XlgfG3{=r+YPqGHT1JW6c7-sZnZT35RL60Et0adi0Hjc<%&9V><(02vMeaQ zeCYvF1wAP?q1T0xlJvBll~#4UcGPAoRM8PnMWxeI1S*L!Lc3Jg%Ii!?=V~(=W({b7 z0<8%s6*o{1LkRQ%P+YWHmf5m&=wYDu3t|N~7F|^VZuvnsp&9}00qF}>1=kEy?)Vi_ zRUqQO{&qIU4XKxfjOE{Ox z=9EmjFr6-B^Kt=3SFKM1!IkS_Q7%BN&<=+B1H1s`3X&@*AhpOXY|j$Gk)gj%3=Mue zG5WKIK0o+v{(b1r4?Q~irO`u)pL__S=&qYM@ROwI$v(iYrmypf1%c=d4@cj77Aayr!t^p7_ zY>kdyh2_+TkI|2)O=Vg|7!V;dVv+@oO@qV&MIf_VMIemk&(UsE%kBt@H1`22sAyHS zjqoIKRNy+WtieKQ!jr(j5rh{8!p9Sd;h#Nod|>+txHDP?fG1+lKyT zTjS67xp=yH_2P>+mz0ZF&o8~~uAZ1WYowgzYYQVhwdOavM!o(}2E7i4dTq38q5cMY z_UD)G=n1c=j|1Nz*6|Aqi~#r$_|N^J3Fj^^_Iq~@k_sc^No7Jg3C)O6frk(KpdmJrQP%i}rg~d% zwwou%$9>N3l_vC)UwH9N_;BmaR?NOMm#0YrYb;i=NoW@MEb+g1lwpr1j8(J^2h7bb z`e1jf0iIffm)(YH7cKG$As42YvJ7=q>{i6lnKDeEsbeF}5xk^QX{cMf0Duz%W9@uL zkD~|HJ%0^o`Hof$ODJ%lG0CR`e){1T4b`Z%fn%Qxv8#AIUo!|?zA_ziT{HY*#G2?G zj7ViHaI+F0McUyTKv@_o%hzr!UA%CW34*6wQ7VMlaplI+@)AU-#ibQxjpNnD zJOY5x1_msTE7z`qsxL1=?p#?C0%W-D@Vvw{+pDqhFDG4r*OUUU6nIIn=VI~B0&ke! z{nJ7TOjgL3Y!!Q~fQbc+8}Jnmxf?=qq;%&pja&1U*5KzHciA@)luwP5e7w%tHb9+M z+T7USZ3TFSJ3g9;6F@~Phrz)+wO!G>z1!3)bSQIKGz!->+Dn5^AiNMuj~*{PxQO$H z7c`f?*y<0m2=+K`EiW#tEH0d1iZ&Bu<1t)8Frxso88@yEADew*VEdy?OfEOEzk%CA z*7jvz@8PSl!m{@|Scl8Y-YX`YGm)9w|9?NR|M!z$+x1ah7JTn9@b{eI4<)8v{L;-Y z+}nOM$9@ZAgt#wJ`OYtX(X+^iOA{5iz52Hs;_ZB1SKI$L(zw&xdMqD;$_{e5Ly}YCycyt6MND zLSX_4QsK5m?jKZb8%&TVXZ$jJfgsk?b&@LNF=mH!htb90=gn_-g=v?G-J7k!n;;nU z{CR#**LA;7Q~1{V)J_>}Fm@G`Y{tVI#Ia))(1LfL^@-n=`ieBP8SKz-!epku`TsknvmBqRRyPv$e3SGSiw zb}&(+(RdTTyV1Pki*J|X$jnd9&tzZS9)I*;Vv5ZQGsr%t-NvOOgb#w&y-#qSElkf% z&gJF->mx3#2m2S++{|QpX71JPOCNooQ#)=3<%vbl&VouVedK*VnoPcsnVp=S&cAx* z!^0r?C(n$;Q4zwSHKyh!XD2g03tCOj7p7+l`RU2&45oJcL;bvD2v+cTaIL!gk4b&O*) ziN^;=Kb{!<{n6hT{pnG2^ajrTe>(c{qyH0qzo%3sJC_)c7?2o{7?2o{7?2o{7?2o{ z7?2o{7?2nUVBm*`&J1+(awDC?li`=X8yTq@0(tSsKhF2la$AjcLpm1o(({*jH1?(I zpz=ucWzgT_qn$5(_lb{0UWS|_J{TjH!awSNlGb=aUI<^7X1%A@a{%ZtPHLm7SOME;Ok3>@Qk#zFXGzq|7OVIRve z-5X~i_y18A|L@R0PaOK^$fS9J`&}p^p^q2Rh^Ghk6ErPtAf6(!1Us6a^BtkAB59RG zbC(fSJdo`8?p@cvgM}OKo~WFKlujWYq{*X277!=h&{+QRa08UUJQ&*Px*K?wU=t-p zFxqfHrk4O2!; zt&IS3HWEfs7`811VTTn8$Fy3M2Hp=mFXyALjP5x@+>L?giMRsmry@kUFP z8fJMtn2wqz1mIujzDoN5&UKyvG$*dV)tuK@{vU9%IEXjFJ8#0Ka=^1 zBLBg|W+QecT~Kqh1^(HKP2lX;C_Il~ew(l7_6&ZV3A- z;_f4`og2K{Lz(D;(k2iHZh<0wrYUZ*%<0sh`-Wwd=M7WlunJPx$7@#AY}72u;~q%P z?oFCVZN{Nb?Z{<#xtjI%H;_I&ZkGKFZb5|D;6&|auzV+LS}^XY^#MYHN^18MaHf<E;cm)XH0te>MxlH)xM5|^KXCCawq6cyN0-#)dEWkEfSu4EVhaKERI=- zBtRb%sB>B5372J#osAUaTMg$f-Se0COmrV4)$rQwk?{V(7NB51-jYMWy&V>pSAw&R zZflB)?X;8-GqkT)Pcq5K_{Q?J%NGzae(`x_;l|?g7hk%#bYr!kyc5)$>_N6kL9!9b zOD4P1TQW8Qcy~f0ZYMX156mX<>lqT=%&*#CUhgHk#pGP?Ep5FsdE9&V$@j9I617*R zwMs=5nbr$R#?R4S&S^8{(lbKXJ(0=hXS2bb&lQvTE+=xd&&A|%A7gR#T4HMuS>BQ0 zy^38YwaMbl7qDM8D%ce%3?W}}$le}^uq_g^GEoY_s5{!ru&nV)IyMiyVQz|C@H{Rx zP5=sJd{?=M=x+T}$ZJm-X?K_2zJ*LHh^ojF)(a%2c1A__^z~xkZl+so{%*edHo{gr zTMg~ZSVOm+6#Lsf$0;5dX)aW-NZq11$9Ae@I!FytP-uR}yxh3#_!*EeBHYZ)?7RE+ zJn-p2>m7On$BM83x%-B%A|o8cM6>E1`9h7y$eRkjf?13OVC>Wo2behTxRNkSOnh2U zL+k9tDxFH%Sq6!Q%Sb$?I3_=+f`Fha(CPd?nD|QKpq}{3;@s>^)>}F8$3!uCu9PT< zhNQ`{voFy}_X1ZU2uaEP7>YWNIMys#F+~$sE#TS9qx6+q2Odp4`^p!ueZIJTJjZ^s zADDnpoNRF%t(=d9%JyQWhC1{kW!stvs_m8Z4ncIz!$C@`w2>W~((SP>MM_1FZ+U%( z*0~VEJtB1@L$9=(lrzqs`KT_%EhjQ2lknbd#bmaQ52hZ}dr;g@oG|!_n1WY($6^1V zM5D$r*QN-WHz>&w$pSPY_aRHvXNvO=r$*N9b>u#zvaDT7{2|J)Gg8-SNG2Z)tI1?9 z{3PR%lI(_w(B4%Ej;9}Dw<=_%Q~{e8)@OlxOTNw4iINGpC$2yC*H>JYf`z6gt0)UZfpPqy+%mz(9zgpVosEIaGdXEtLMm`++kPmhKpo31W?y}%-)Do z3hWiV1`^%LJ)C2!)`Ixm)ppHM&?7~Qq)?b-`1z!DR?L>9&|VZ8zsw|!(c+X`5ZgqE zO(BiWeQ-f?--+6bwb|)%u6+NBfUtgSCM`5(OpBppW?Wr?tvfr3fWrNa=^ zisw6E!*Go9RLXT`U?L}bh{9X!%?4yq%5JT+ThPzIK3dk1?2W`y;0;pKd4}XBq&X;o z9pyWp{W-;RDq%#*bc!~h+Qi^?LCCP*g^4aoK`79hWRgi&$#}oC0a-9e6nuU=3F5Ue zj|@maBL^gwFB%`hlZywzMGz~!c}6{yWDX6Dx4+)5I#BhIXj~$7AMBN@iox&|5hDukF`_=IEzG}(SGG0~TW3fuH?A!=Gfg>hbCz960FB@QguCww#fAw`xLSB|Pm-O{ROfV1FC z?!_M}3xiM&h80*h?$aNthoU>YzvXFv6MUVFOU#fXrs56FxdXb=U=pg+RMH;jnSf$o zTV7hqEh?Fi&w8M-MD%6LP2>4rhd=<2_176o^{EDB@>oR*x(LGk_ijB`8}3u zK4?ss&i|=w4q1l^EEjDDQzqdNJ`9@fJQ?K3L04bR;@+8_(*1#j<4$*C(Z`caLcDwT z1YEQ?wFd00ny&}n(E%n`ovzLVgsTVk1%lfSKgvg(f|$}nIFsgPr!)Bs1hVTG1ss7% z+Sb?>Y>^}mi3|> zx;{}-T)7ZN53bedB^Yrdi?4dtVD90L*pOl2F|Uxk#5|C3$`OZM9FL%#qQzh+oc9`Z z3ZjRs7L$L;BOb>h!`2|$vlhfrh#!Ppr*Cm)6Ru6MQvY&paW+7hdzbo`^YcsTb786f z7Lk`;3yA#ZX~^C=(G|GV&JzGcVpm3_3QfA|1GjYUeH|P-Q)PH8nYnj&IbFIg+aI8# zood{H3!rzXJNtlVo;Z&BKqsFbOf;G7#zDKVD3tGDJJbkgHc<`(rO_^U3D-RZSct#M zNdd~z!uMtYXrb52hd%t>U+l@`Ex3@cUc@2mJzQ)Lei)PcK3C+#Z_IMvm;1il_l1I4 z?)z0s!yPsz*h65n-u@$f1sRg*$y2U%DIN5qec);+>3ha*tyK)>UB|P>N z97(zCi$O(=<*o-EGl)7k>0^C2%uTXv>qgbIn&ftGz^bcOwv*r?v3M}qXlL#}g!!6La(RUi?gZ>0K z%Ok{ttyWoB0C!lf&3)6ftSx4658Bz;6!WM8UN`}P4ss}^_6*a3ivY~`N{`z(-4|Li z8C(>1?+Su+7lC__6Hi@Y?n%Rfa+428r{KcfNz;4jks700PLcGo2O;7Jb$_I{YgYoU z(xN2=ZvhryhBFo6ED#E|(4i8Axiqi{;c4rzd)#D;hc+PKJ3x^c+@}xCZ-Dg{r?3%S zF+~(V`}=+XLI-sgXHrl7Lrn!X5_X5)H8h0$0v5>d&y`)=wlA*m!+D_%DHwh=?$t+| z2SEs2?e{vqr5Rl7#xM2nCd5HWGv59N^jDCi+~5d){DczoRp^YO;Brq-l^UmP%PmhY z2erVNpgBE!qm52~t{T<7v+XO>7)w(WabMe0fO`8?fUtV+R-uAyuPRx@{bM((RSgz@ zlLh*Sta`Dqxjwi>*YQZzd0vHnlT9vEm&(<)bBCga@QJg-vgwLuZNUA>=2~{}@CVds z`3vBIB-;C=dH74c+tg01>)oMq*z;3Yn4lC56mXwZlY9Vj*?VvT$y6m%MZ_`5R7w8d zo5Dac)ptyIc%L&>I?Uh4S6$!01vq{LA$MtZW^FqNj}~+C$BnqCjO8BXxg#Posk7rg zJGXR7MM7w~&r^I4$%vhL{1cLU779s4bS_=3lR$)H91i9d4%~F?CI^3p0EN;-(i4ca zBnoj!=v_R{dSM1aK}3YlI2;vs3N#XMkmm>59b<@y@K74!p*avK?1DJG`KkmSO%Bf& z`1#gTA-Iqgy^~!>%nOLI47G?M$U>_H8sRrWAi(fO-FO9_OF6~h6Zb9xaVz#rulxw@ zQ^w9`XTNNOig3kdFVu6NC(-~qVvnSJ5_o8?a162NjL~tld+FVJ?2sHS#OUis|Hq@B zJn~bAzjyfNq5uBS6Ui@({OQQKgTH)mXt;jh?E@$G|J1&}+P5(+KTcr z{PT;Md~tSMS!f|5%q^{SNq465x!K9wjB@JI^Q%`bPavZII?Q5PdEFdW7V8!+6-;Gv z^OI@%R#wz1R03`l3g++U|3ENrg?-ka1%q&OWj~wDKKKWk5 zNWt~Oq?g7%mfrr6WS;_pKki=B zg&e`i&;LJ~7_A)rpN~!)`PswYKfHYCA0+=Gd3xkmM)n=74}W_&ec-G6|G)jO@B3%_ zJ~8y=!S4=UANZ#OA5Z+~gCGC5k7xT%sLX;%%pu9z_VG+#!ZusT;eeIyW7wwp61HjL zV#g=@7`9Yj!j>cX?D#|g=KF0_^G~x4TmYr>B&BZ?UQ{9 z8|3cePxUcupXf)}GP7*YI?=zdJv~OSeeSV1gN%Niu24RsEMC8<@W06%lbI>baBGXO z&xGwVGwJ+fW-^nHwag%<2<0uB%FIm8C|Ar90{Kj3@=KZNIboxjNl#B^A|@J0bjZIw zJ-_|*lkY2NmG=*{v)fNU@qU8#_>T~@ePu}GRGNda?(u$wZF>9o5BDo<*k2y&W7vME z?_d*#YkvO!R`C4)@}X}YQegl8^O4I3f9v2Q!?zFo?+2dS|MmR`_cez8aAs#3P`Ty4vqn|nY&7;ST+&%m!hZhh1qeF+1 z=E%PsnLYTm;lCSx^}x3djO~AG-(T#zI`rQSeQfZ~!2dCDF7ay*etO@13){yl9tQWl z^NxVixgM_nGpTeYmCjPe1n8ABv)jkZeGFU4gY7-jkA(E`IR!q68SGxz_0>LxZLQB> z%PP2MlFpIe-|=Gq!Ir`HP{?B~e5Q|K`*c6TMh-{VAztre*k0>L*fMiWGxBQx!Z!8_ zhpiv4B|tq51ZHt$eYr0Y2sbyFbZ+%AY%ldAY-!9Xn90pPhHbSkVFSHkEv)o0Y&ZH6 zwi$ABKK^1K!*;zdVS^xtb$YFTVH>;3Ve8Y)izMOv_7hh)kbP?X&1bfsxZKweX6Nbr zzyI*RbS=hts{R`U@HSZkzp5ZOzXYE;G8TQDBsM>$nBc}=E*VJ`|_noCYFE{Ynv627EsVgN5 z>CLsu1k&V{S)M@3c5Z2Q%QS3c6I5F5QbR8*E#SdsDWG3BkV$!}qHmmKf8h_M+)!=% z%94LBj~c77OZywhsIt*@|gxAss;jDFvgDE?O8kx-rUi)1tCk*+F<48%S-L zPD8KN9i^&n03;p709?Z>K-kurB~)@W+tHh*p{orW^QbyXsjWBg+-f7`Jmq{LeD4zI z6*ie71!5H5>zJ-h)2aZu=yif>?LDLUHaIx&B&=lz!0~n-<4#3a} z&te^;4pmDA%yL)b^}Ze5VGYPdc@lbu3&fdr|Oy-*erl2 z!^5^HQc^25Am{8#-P|mzwx-ktX=7ni4HqGl{fsXQ@L-kEtw-aWsG7QEAysT(8w_la zK_-D5V3ye;4&p5r+5y;N+0+_kED@!t**0y9?w*6yWVS)7jCZ#zjb>bsFXgA*B%V$c zZN<=T`z&2>M^I1@KR)_WwmWW**kU)T6}@J#{a3jl7B)r{+EQ-1RGeY_=SNv26%p5Zv~mbu6EN~F75=gAp@a9Kw>1Pxy&1@jc5_nrI=OLXh6!C{LW{8>GV_pAhA0KdEj?G`&H$6)q+5x zd=l&b8D*)Vt3Y9I76-)i9{4K8=gPqi?36SnHuM?Yg;%mB?oHrmi8m1`Iu z(j{XLwSfpwDIp6BkFbbF9X@C-ROwA1bk_rY0TLXT6zJSfOAEA6sBEde=|F%2P$lxgepWojSb&aq+KB+YxH z!;n;k$K;T0g{Q+FQnGE`I&-fjNR03H&NlD_&dIAcRD${(r&e|Z~67a zOa;m&-7exZRK)oM zilu??yKe`+_G6E%|LSwwO9v7cKm1ru)Zu5GBFTV74t}w0+haY1j{oMzx6cnJ&iw1+ z@r~4-W@D^F&r9k^AzZrWfjIG!igg?;Lg!{)LTHDC^eD7M5!Fxz>LWUK(fNy=ed&-H zBb;W0GO6VW#k?O(KPjsGDvnwsed)b>frCU)gTt$_L)86A_x@1LJqC?I&(fM+fkj+iO4@du~S^d;UY9tv%0YXf&$2R zCJyb?WaaD%w^A^nTp<-1KL)d7A&OVL;z3xbYT$H9A01EYSAhnb6b~_-`RZ`qi*Jz8 z4UC2>r}xt5avRnpVLyxMa8W4wqt+=>3WE%x4P?hy(znl!M1BOsM47WqSWUDA}ISzp0@_byYd6=^8Nw$-?I5A?>h z+Wz|B=RK$rYqZ?O36nSPKHQtS8z6+%KX1y>q4NWvV*PVk1_=Z-!>BpMN95BKjE@nR zbas{&ctIW5HeB;x8TwF5*ib^28Bq*o3Wc*-*Rl%Rk7Yt}3Ca)HNO|E4_$dOcH=QSG&`&3o8Ksa0jN|r*Vq$11-lN7NBEG?Lrp+O_Hd_aM6x&^Z&)R7Qu zT6F$Td?;STkxwNCBnBh~BnBh~BnBh~BnBh~BnBh~BnBh~BnCbh7&vn1?;DJgG_C>p4Iz^)mH-A`S4E@cv7J7eg@y61^>XLHv z>ctmtE-4qUo?m)dSql}3X<`kI=#f%u%Bi(LUu)x^f2uZo?BvOTw?-Vbgi8lOs6rop z)CFO6;oRk=m{#@58ab+IH7G&hmZ%ueeUtAsc{0IQXNz62(2cT%>llVJp@hKbsEPNa z8(vd3RI3c5%&B~O+;7&s4x*gAX)x7pN4UT6kFtG>6bx0 zllkA{(Hpo0L*T&bT5PFK-S0A=PLGQ>wtfecU(##n16zC0OFY_5M&sni&}OFird|wh!*bwhA0|@ zW2-cL>=}UZbQBCSyM#g5>;^)xXpioxSg`9KSBH&=W z@=~s8MP6Ra!P@Y#3*!UZCm95Gqg{jhMFrj$Wyda>jS99sJc@bQ4uON+rbkWA`@%(t zXNmUL#;4d0(vA!;8n9v-!NgKZ83N+9sPBpTR{-h^6Q8()-|P4}o=!-}y9OU2sx{ zRL1#2kppMr=0RD*R^mQ3!z9(EET236lCbLy6a#cy1oHrIUmHL2cZscMvAo<(ot%px z3X%^>(J|LGLu~5og%L|fto67`?tady5*UGILC)7-8$Q+^ANbrT8#PSrx&hIJA`9owkgE@K0*#k(SjHGH*&>y*Q&3BTN3gB;*+O-`La3xj5jFvtO-KV5hkOc>IK zUmZSn&0C@2c6Nln)D!+-Pki*K6VK(P3?f@nAd7ghyqi@>UM+2FSjB=~*Kw>~*Kw>~*Kw>~* zKw>~*Kw{vX$ACQlzw|E3LngrAMlQhLc&{Op{Ywl;3`h(}3`h(}3`h(}3`h(} z3`h(}3`h+80Ab+3zJY@SgGczU!~EAF{wv9UjqqOw`LALA>j3|?pa0sof8WT#fg$#D za3B62k>~#(AR3ZGl^Bp1kQk5{kQk5{kQk5{kQk5{kQk5{=zk3006#J?NQZm;qEkJ7 z?c=BVT_^=yZNPqc{_p>pmJmt|NDN2}NDN2}NDN2}NDN2}NDN2}NDTZSV}PId=^T%P zziZCl#lhe825{W~r2YQ~nS|unB?cr0BnBh~BnBh~BnBh~BnBh~BnBh~-Uk@iwW)t^ z{{Kg4=pT9?j9-E%F(5G@F(5G@F(5G@F(5G@F(5G@F(5G@F(5H;-x!eb|L+@hvZBO* z#DK(r#DK(r#DK(r#DK(r#DK(r#DK)W`vwF10qMZ#-x9oE;J@BCpi4j{1|$aFrx=(U z8c7U1e&pc6TyE&5;pk37t7OyJnN&KHN@tbyd|^6W$j-z(!hj2I&B49Z{Leo;kW7p` z^_#OJBe~pCL#wHVqnuZ*^{G!Kl}{LEX91TxmX1K;Zp^Hb0%8Nr!9mm#MnBLFhhFt2`C*~;{E z_@$VJv@+egtz^^qVbx0Nsq}>6{+rB=hpUJ=$z)r%!*9Gv$js2IdjYgxCwvB|kWWS| z#!DWp7LI5dT&M%y}-!94KSZ2hE4rUS+RcriV;+0ZKyNcj}A8ZEU_VGE(GHp-{M zcyi}w9PA;>2j zWI9X5gUGyIHyu;CU|~2uIke~@CL9k5i51-jAH=evC)6ALPB|`jOaa(-(*T38w3eni zr_?qo1C@;l1@qigZ=cG}0hbd>rfQ8d{M6J|be;uTsQJC2&*Lh5D2u992{IEtmNK5Q zu>=VsJTawOfK&+#KA|bG)WW6tQ#Kk}_SFXQ(Yju#Xa?1FEY+}eM>mZE|GR2hO%Pnp zRx~ib2{e8?<+5Dwso&`?s0@=h>5r|zm=7Bv>zcZAd!`-il~XCO76z@~amN&}-N2y-vdc~=;_4*V+D%Ni{jmf14X6X&B>|o}xsLOtY{N7HpFL`bS=la0C{t`< z?~;tEaD&hLiI|0GzXTGHu#C zBi-K6>=x)l-_VqW${UdGn%KkwJg?5rWX2}OxEj%WuFX^` zm1#Wp6_4J#YNcAPmhg^jLEh6`mA>(mRHVylQ*Ugc0}OMks_K?>j z*kqP>SDI^oZ!pUznr+n30$10sI9m-3kL-2{R%<+VwQ`r$Ewk<5MP0K@|M3cpFzD7E zE8HD(wS_2SX$|$ZRzWeQXJ0U2^(A8gwM%+#@nWT}wx}~^HD=|h^=n*NdX3Dj4n_?F z!aY7f%@W{M+l>ZVshTEK#Nr{Q79BOl?oGKsPwh-ZQ)*eA&1cp5az3YNYG!siJy$Ae znM}5tPS4Mk@;*JSX0nCMOd&rvna<3;DxT*utJz#3oyNb(bUOViBS&s8*DTvn$z)r( zIiwjob<|W17%(fL`f()u*3!O-#I46NPfm^Vb@X(Pbu{IzqgEY;nbb62PhFPMbt!_MmZ)|F_>YLh&FMVeZd}w_)(i7juH`nH zmoLv>HSS!#e#OyOZ(UU|x)UDgp>KezY>7F|bgnDx*G)NMy<&bAgE{amTGT3v6xab^ALQh8&xUAZ)QF4w4R zy|8s@xjC6#xX_p_)t%eMe0sJ1N?Ptn*8628^(3RInd)phKUbY8=hNC;TAQEAXR4Xp zOd8u$c6xexPHaBu>?%2v?1mAPSnh|C`j3Mtn3$2$6-2a7Mr_%FqnIlvMMYi}A zlm0|A_wvi;3+kO)FVtVzm|5Q3+_V;-Z_i$BnCk5dGq=uN&T3a2d%1i;%jah*Z@x11 z>hz27i3%*|AZP zx;gN4;+4m843{Jt-{M<(6q_U(KjASS)?HQ8DkN0;(lN(cHi+6~w~)t++%i!(x?%Q( zTQAoxt!!rNZ!W*|!kf9t8yg$x<;ms6#p|!GOf_$BW#%sF8&_Y}>aWh_U%WkkGjH8) zUy{3epYo!*QwY7StXJ^%!tcJaQUM*?!)tp+Mna<9X zbMtvPFqXXKi-j{?$U!{LPtMJxi9_X6w4fk1XQN_sbV2RZNso1fQ}So`Q~baE!-o>1 zZyfy0@ZSu70e{Lbi2;cLi2;cLiGd#^3~V-rk3D;QVEcq_RJ7ad+-eu0^0Ty}W3FpP z5%(D0Y-{|*{>2+h3#&`Y&8rt*yt$-Yyn24=Wo4~vr8UJg#OpQX)LNj~wQ*;C_}CeA z{6uHR@Vs9S^}BE9egk#lyQKna5iW8YHj>yM)cuh z$Bz$ut?AGe4~`!G^T*tVR~OD*UQ)*R!!e*nudFHL)>?yS|MeReuPoenMY*)}in4HX z_1eX&sC8xO>Z)?}8vftBe0f4yv#Bfcpe_vyJ)p*Z1rLigRiledxuYIjjR2@DxN5{RK^GnMMH!rU$i#KoF0LF`}7q2X>tS(%+ zj*hh3E#1=WaD~8>=y+9j;BdPJH+Qq4!SW!QJDE9&2B-k$3nehy9YxYq#C>0EsZInH z{>w1Pc+O^`b0Vom0}lE{Ty`>W*sa(xy)wXX(}5_pXpyUOvE0zh>oG+cfSbUXg}Yko zJzI?wB@2c(H!TagdEm)3iVe^ysE1xFi_b4DUcyS~e61U&PL@<#FP~HwE^>izudezn#;2ps8otom@YI6MjQiR zcT5yR8T9sqv5L0gV6N?=4|ca2ZCfjrRbxZ7io1a>X!R)wlV`+H_ z+-7lUMOou`buo_%m9%dH{o~5DtAO|N5_aE}C9w;H+YZl5OtX7wEIOcYbHTU0S{>j1 zXnpwDm6HS8&$10SRIg|^Os8nHn_)p9^6i+dWJ+(Oc7DXsr-BIs`Y?@2`OGpps$ zTW9uSw)7TK$Tq=_4P88GZb`GHbfoF+NF$hYl#JdFX3Ih8h6cYW>4qlV(7O1$1#N1J zuFn^39#Kz9Y5!NE=E~3>8}uaz;*=^y?e5b4FTG>mJ@42b2>U=)EutH=JM%kB|o1oW#{sx8EtkZTb-V-%;j;LXdb@( zZeey+cD9hsPR``I-%4TxDRhj&gNLrl?$l9GpC;;)PA#gV#Nz%5imu#%Z%C@dyq*kr zl@*lvG>h>mULmkD!aU+81mZB}@diN-5r1=~ZB^B>#sl}-N{U6(R+s}C#d~If5_!wD zlzR~zL1HPgaSo9j+5bF6be!c89YtrsK&)>0X_TY?c^0*IrlUOkUL2i8erC@)Q@?N1kpN6vfvK)y;z#djK)eaIczra3_TojEsrbrbxXMIFz+Pu{8O=$>~sk zZrKLnc&9ORL_5wS7^RY;5SJZQ!`N`EHW5WT4N5`zXtD zI=v{>2uaMNQwu6An|4z#hl!Cwcp?(J7sVuEOo zN5oALDP<8+mAB+RBqE_`t)YiB{TW0mj>M?Oyxm9KavrcbI_V5izIiXDI*wYdlaFCR zsp<&IKmfs2qBA~u%M=lvErf3%|5gDekKdqG6t!8R5a|e+PBHmKP}^vP^+KVfAwV;a zZBML~b$;?B_3U@z0izqX2H;psZy#_Z7{C`HQ*C$@eM)OI^cF%C%%z{gvVBTX9U%EB zEJH+QXOxr4fhSHQ0D3k%L(CEUjD>AyQG8qQb_evRIL>LlTk|u#O=_Nc!bi7xu$0Vv z#$}mkeJ_odb*qeY3TTrc$A7oY4*E^!I-A8JWHajqMdrIRHZl>u`A&-Y-MMH~MZk@M zn6j0+UL{=dc*!W7LNpI}qQe6g0ihL@0>c|{17%bG+`6^}&r~>IhpXSkoB;H@ipfKB zpts4{XZ!%*1;veGJ+8p_7pmyNQi&8h12Bi`B8)zKkZsjzTQu0JVIss$0!r-9Cv(6>$;PAPl0d)#CJ^$z-1pMAw=O1L}6#^ID{IugQx7Ky9n9( zrwtXGXqw!CDeD4j+mj?BB~!d;bjL-c@h}#BW)tOTD=n?uZh&aKF6au2n^+;p&-kO= zaWu7rya~8aPSoR=NCDucDX0qW#spCJu?R1Zp$KZ@$x}La?Ri>7oM8ponAn!%F-Clm z*Xi;ejIFaxcWWm;&4RB-_zYi(&-45fv=;|2=F{ni+8(0PcWKX)S~?)~g7>fJ6|Dp^ODUIey9)Ux zIJ>&88d@U|Ctmmp*luxRw1KTKv?w6pxrdE#w-1$Lh1bzLz>UHkxSYma9~~J;9*og6 zY9aQ)c}F)!6XGD8+{GNaG#um(?tDR(VyRkon6@tTf{Ej&tlco$6hDeZr@jlP^Hxe2 z(07-e#q*zVb_M~TANqfn>>!dFh8Ampw3z*W-**%HzPs;s-zoYeKoSEI10P5X$ozjY z|DVkN=Sj7$Za1L#j2_)&{y#r^WJJZ`r>ztC>>_P)&8f`)C-eWYaJtdNW4jMe(MZ1bP@?vQ7V;Q`b;+k#rE$t>zR-qq3ewG& z75&QJuo3K>6G(9+6oRP& zU|LR{mqG#pjUL%rQ%?o$AU1c?u~96TgqrmEnt-MyPB3_Y#2tRI;dtvA2 z)+oxF4VykQ-}~+>zM&7Meb`jUm?P|0zJX6T7WwajE=N=g)jC~{Fd)B%ulVogSTie} zDqMgA8K1jVK|HY##5Kff4*42&Sf_LO@&9&mFbNX=H|6()f{7ef>$KruVbCQCORqA`~Hy&JhO_b^VX=dc`@qnW<~ zfCG3A!1n1f26sV)#~r(hXl;=dWdm8*bDq}>Cu6^TvBF#c*!hM0^0-Zeqd9_QR;Jr% zA_(T^q29i3kxB8)$KAT{ow-)JJFdYbtiXRr$$2hlIrx~6Crc*BJP0FRc_A0J%nbRV zQT3=&B?2pVvNH_v9G(sP#LUbrGOhTIXR|ZY zB{d6QJmGj&P0#15CFUZ7zTv8-&Xv>Esxs@9n47Q8R<--_L<{%@&I#Vf?8lyU&Yj9Y z!5I$;z5K}p`ZHPRV1%2SSMvrtJqhaM$7}96=4~kML-HKUMBT}l=NQOCxMXpy(DjGv zITo~>0q?#6y3>g)T=e&Th>J8oG5?S@1W%A*VvQ%FbeC#qPgr45<=_*=Vk_Dk>K$#W z!L07won%6}Y}Rg=<#i2V@phu0tGCT&Gp$>&)@AHUtxmVGf>g-+e#{5u4!b5tu?<6+ohWDF9Wsn&3YO@W%&;#CeS{`WI4An?$k^@r7P3bYTV0IVB0%UManZR z(rd&qI*ol;i@?nHt)RQJ7^a&MPQ2oLXw;1!s)xk>nkcRqR7 zi)}TV@we6dI3^C?%>6#DfS6OS!QeQK83HWNTO0nnb^qdkznv4q^*n=UTiTsJkmsd1Ys1>E5 zk$a=~NF~-0+`-&`v)%)i8f%>d`+cI^<5yW;@`eEv|R;cGD&jiFeFYl%NAR$TQ%= z9d7$B+8}^jcWee1j>))~!2X2eKfJcF5Ii^RfD1Pyba-&bR>`8t>{DK)jPTu7n6rDA zN~sKL<@{QyKsCtv-TKO*Rw~e0R&*9zmKQh=6prG%wxR~T)o^~+5V{&Dsjvx)_r|94 z>Mp#%8Tak!*1d;@_Tn++qY}+#|g(<$3$DiXLBK^OyT{__qFIDGy_mDw;s>x;w31UDQ%Pt zcuJiL-&MM-QouDP1|oZA>vuF9vT!rc(wYQ#18Gd@78<|k1Ac}(XIcKr=bK%RwZcC) zu!r*-94;}1w&QVeYQ;_+AP4UJokC1@IF8c@2rN;6ZT&c7G=#t&U?&Xk9$yVL5L^$} z>n8Lv7oaQiPcM@!2sLik;Ne|#hw$*YK?xNMJ)w5#H&jSSo{Kd=eYcwi4p^4fLP-5n zI9-@2f@*>`E<|O=y+^DC@Rlm>MX>rR1`|etpfP&AOIqZcd7O)DfbC_(_UE3A*>D$; zdeytQd4b&&VYy;EP*6rehJyHBf;13j5Nw$7d?8d2`mNnu6(OvMNq@Mla*hF_$6916 zJ~1yr=W6QSWi3Le3(p>Z$_6b25MTTNXCNDaP6uY3!W#`N^J!Ztw@Z3CRnqS0nso|K zTm}GXj@I=%#x$5_ldphPvlY1Lf%A8R=Oti*5MKjC0J{@|-lwJu#0OjYg`GOb|%dn~1>z$k*XQe6~LB<7cQrP<@ za52)*RLj4w=oJetY-$nA6qn7gR)@Vne5iWLD|>jbWC zM(M5t=L{zWpTPt%6TgK;u5O0s04gdHDVUb1qc8U*V`MJep{qk&W$eD$Ez!7*rn($7 zm^kh!&N}cxc6{stBO99)fTIHyh*D2Hj<7!kt~R*@*Lg#kvF&id-oz7A zfurjkryUY3Si}y_5FQ>>vgU1)NaITHS15cXB0`E}=j|I1JO7SdFoP4a9o) z3dK&Ed%C3qZWcG$!_y$D(wPa8Re{+~PGLig7VJG=9ToT}__^mC<5YC!2^lw{ryjv2 zOOEjlP@ikDhui?UbZ>*>LWj6fA6I`jv>ENwUH;)SO&5l?YoZLDT(Hy;#ljU_(yo3! zs>g_uM3Ag86Ayu7LS4m-j|irOs1D@*!ndwi-lMVAqmG+ova&nu&t_9m6N;&e3V%VAt&#e6D+_!%5N zv{t*CX&lLdYd;EPT8?5Jx6d$4$7DTm!WWfT)yet zZZhNu?Z?1~Td}>_*)AG**h{`y=@^^olRyw1Tom7<9NuvZVT9i0l*Q%Xz%eT{%V8b^ zi&)p=6Ko=TJ}q$S5t{O46ZsnB!l+dFi|B?I#M7S1CR9119NRk$?X@*`KVXXNUMIF# z`8c?kUDUJ!K`q=z&~x&wwNu3DDLL{vSt(m)qd}1)_~#%C6_SWh$`r)wuhF%YiBI(Q7cPd!8vkHnXBL%_zbo1;W!s{q%z1@+f$` zy>o1jBFW!xsgsL!-MgOYsFo74i|{|@{kZ;UjK(}Gx@)qBV*->*al4r3_!cOcVllnq z%X8fi?{PI8^@WBPl-vCVT-4CU#fK9sq3BEHuw{mI3^n(TY{(a?&{QEWl)ghpl)R$fGH^ zqDk&&mKMQ}d?_`)fylW$d~Vo@o%A@Z3Td&ICNx%M*E=wii^j$K)2{Ct1%$$&7BjwL zWcVKLg~-Js{YGcUKQD$jcCV!;mNSlVWCX*122Wrmcc^$7p@$6WSsuYNL@lx6J)yRj zexXAi#>K8stFA$j8wR}tDoJzh8gPy1j5J~uOyqP)n*00FqwhdJ01kpEzX!-6Y`1W0 zugol+Ze8x>gAk=~@`;Qr>hA-0pFFwygh$5LV9%lPQ_LG`v242^fH+n1trXnIBVM6)z*X4Dz+x9` zFXyyS{UFWm)mGEFv)&hSqDygSa5XeEy3k(F5JeuJa00?)xxO_4lVO3b z>2GL^W3j>=&O`8v;u1r8a-Q^JVqbO5Q?5S+w^GFj&pTPB_;D=2<+%1LI;Jpd6v9@J zy~V7L2zwNi*o$g~FLNg~2K$PU0HR7$g@-bBH@6gAmKly+-tG;6-7jD_4Zas2}?oPIoi7z4sM;8A5 z*zV>#QFlL7w#)VJkCSeOhwe}X$uY8DbJ}s`QCEj_&J`AW7$JlTE?#;G9Bn$NK-7FE zy&J?NH_D&x4Z)tMDBDY<%48L7S z#EKOJtUmEha2pytDM2JzM-OK=Vga(>=;~QNE#V?Ppo$Y%a7nnel;2Gv1Yz>bayj9c zEE(^Yj-BT81D-rvHH;99%i`MrLCp&ACZfe0^m@Wreo=kUM3tFLBoz1H38t+-EEjuv zfQ&D=8547RUQszvQW*IMV%p?f>{AQNCwzZ*l0cyOge|GLgxUCT7dz>8iMFv=Txg3^ zs-)>``O@GS4Q!x7eVSzw4}oaJ-_?rfZ?EvPwUcX2^aUqT=)sjzsogDf z=qu&5lmTV8=G^7*^#JgKi1!{>1k zy*l+GD=&!T#y2!JR|kU=%(K>^2M@>SML)|mW@$qutFbU(=~0ob3kp8;V{FiXI7P_u+KXs*o>AXX-p!Q9dx$jU&;XwI8a zVFS4}a!6D9#0ECmz_kGr$d)X};asR!A?(LiVO|zQ(kx6AadL(E^7Y|ob3>3HI-N} zYas&o;6j*p{Q}7dcZ*^ovyM?My);P3Z<0nE^~!-r2s|9woqsx?brXVqZrbzU<8j!} z;Gpv04e8>ndpd}j

?YNS8cLQ>fT2g^uT+RzDwZFgLnkaD9NUH-F356NXm-g2>R< zE|QpHf(o95I1`cOr}q7;(zHG@4-sTnK?N;Nm8e;oXHXr<$KCO`hHSI@kI>EP^BMwM z#A58uult$y7ucZCQmR(mB3OfWg_N*Y5%vmc^`n_I!?Rc3I!-kQtWvaKD`$?tCdbgV z0n?p6`K5-rt?F!hOL&!r+c9c(fzA1^?{@FrgQWzGr0b?tM{4y86jSeheD+Qrd-iKF zk+VFa!0i%vG5x^K(Zq{i-v05|@$(+8PcpMSJ$5W@$H8kwF2Ja?Qhapw5IrnA*~#Sle&6Hw z&2L&d(_7j{w2g?%0RTkU-~YY6ZCq|z4sbTShnPtq*Dc4(q?g<#x5!O$gIq^r_>YdZ zEF=llOGd2@G*ngx8W?0|8QY4UHExn5$wp29a)Mm!h-*7Yj898b`D|{HY^1Hv9HZi2 zfQFAD@)x;8z9c*FP4YT9Ng7BcSwKb;4S$0l;A3QRN1C<;qY)bS45Vj9af!xxXF?Gs z`9PL~tAn+1h|91A>gy{SgH27L+NwrymJn_<3{+MI8yhRabybZ^iOLMYNFWrhSh*^= zDrhs(TnU*P*#}g)-y9#uWyAXFx~95{<-y>xrGd&Cq}aJ+<3(2q%rf@57V|c;26Sk8 z67JBoh0tPSY%f~z)@F}O*So`9Pj@~!z?b=}=f3CdXaNFNkuF8qvFde4D z6^5?4l#OHJ#l);ExS5z+bl48fMILy-uRzjkbvlRievHu{#KF#Gg$moN_?=X2fmE_r z+NX;*#B=8H7I@#ra{XiO+yravoTgl+VjE0jT#hTY85-4g!(v*$3nDXn7rexCS>hBb zw!=~e=oZW=dts--kjht3H)inQhkEdI|!?~$LH}n3-ZU8w`R-qz&<6g zNdm4Rt<8Rkw)CRLCs4ZX%=hZ$xE3xP3?=ccUPYp;_EFWH@664eP~KY1OCUtwy@!Sx z+3Qn{KzNN+)jrCd?_*8nO>lY(SX0FuLq*G01;XKA!&;vu_ebay3$6Qdb*JvmEoV2T zK0>#8k;VPs5C(C3eR(=dXqAP7mL2OJ@5}Z0+-_%{*IlmQZ9u|P0sb3bA^UI*o=VP> zYLeHt*nqPzor;A_K7%WTvm62enFM7Z?{<7R_koQ56ptg0ZYs{z*$bXHo9QG(V0-ZZ0JE+pUSED)=s=bQX|b z$$qkxd_nR_GVa1>`xc}00j6hDg%o}y=ZAE64@yERnF~_3KP4n>a9BoQ*4BD!_iMk|uSgs*>U4xP-s%TWm~!I;ff>OyrADsZ7gqghC!iKnvZL zBlx)pm}niwOa1(4y-OHAeDJBckV~TVPGQ&+{{F#{DzGH#SNh^vK`ENu-anuc6rvY$ zgdx`Z$%M*lD=9Wv>?l9c1-^|- UgjcE9DY)3=_CfRN86h422PzdlrT_o{ diff --git a/backend/migrations/core/053_add_liquid_glass_templates.js b/backend/migrations/core/053_add_liquid_glass_templates.js new file mode 100644 index 00000000..2900eb0f --- /dev/null +++ b/backend/migrations/core/053_add_liquid_glass_templates.js @@ -0,0 +1,693 @@ +/** + * Migration: Add Liquid Glass CSS Templates + * Updates template slots 2 and 3 with Apple-inspired Liquid Glass designs + */ + +const LIQUID_GLASS_LIGHT = `/* + * PicPeak Custom CSS Template: Liquid Glass Light + * Inspired by Apple's iOS 26 Liquid Glass Design Language + * + * Features: + * - Translucent frosted glass surfaces + * - Dynamic light refraction effects + * - Subtle specular highlights + * - Soft depth shadows + */ + +/* ===== Base Theme Variables ===== */ +.gallery-page { + --glass-bg: rgba(255, 255, 255, 0.7); + --glass-bg-elevated: rgba(255, 255, 255, 0.85); + --glass-border: rgba(255, 255, 255, 0.5); + --glass-shadow: 0 8px 32px rgba(31, 38, 135, 0.15); + --glass-blur: 20px; + --glass-saturation: 180%; + + --gallery-bg: linear-gradient(135deg, #667eea 0%, #764ba2 50%, #f093fb 100%); + --gallery-text: #1a1a2e; + --gallery-text-muted: rgba(26, 26, 46, 0.7); + --gallery-accent: #667eea; + --gallery-accent-hover: #764ba2; + --gallery-radius: 24px; + --gallery-spacing: 20px; +} + +/* ===== Page Background ===== */ +.gallery-page { + background: var(--gallery-bg); + min-height: 100vh; + position: relative; +} + +/* Animated gradient background */ +.gallery-page::before { + content: ''; + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: + radial-gradient(circle at 20% 80%, rgba(255, 255, 255, 0.3) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.2) 0%, transparent 40%); + pointer-events: none; + z-index: 0; +} + +/* ===== Glass Card Base ===== */ +.glass-surface { + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + box-shadow: + var(--glass-shadow), + inset 0 1px 1px rgba(255, 255, 255, 0.8), + inset 0 -1px 1px rgba(0, 0, 0, 0.05); +} + +/* Liquid shine effect */ +.glass-surface::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.4) 0%, + rgba(255, 255, 255, 0.1) 50%, + transparent 100% + ); + border-radius: var(--gallery-radius) var(--gallery-radius) 0 0; + pointer-events: none; +} + +/* ===== Gallery Header ===== */ +.gallery-header { + background: var(--glass-bg-elevated); + backdrop-filter: blur(30px) saturate(200%); + -webkit-backdrop-filter: blur(30px) saturate(200%); + border-bottom: 1px solid var(--glass-border); + padding: calc(var(--gallery-spacing) * 1.5); + position: sticky; + top: 0; + z-index: 100; +} + +.gallery-title { + color: var(--gallery-text); + font-weight: 700; + font-size: 1.75rem; + letter-spacing: -0.02em; + text-shadow: 0 1px 2px rgba(255, 255, 255, 0.5); +} + +/* ===== Photo Grid ===== */ +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--gallery-spacing); + padding: calc(var(--gallery-spacing) * 2); + position: relative; + z-index: 1; +} + +/* ===== Photo Cards - Glass Style ===== */ +.photo-card { + position: relative; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + overflow: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 16px rgba(0, 0, 0, 0.1), + inset 0 1px 1px rgba(255, 255, 255, 0.6); +} + +.photo-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 40%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.3) 0%, + transparent 100% + ); + pointer-events: none; + z-index: 1; + border-radius: var(--gallery-radius) var(--gallery-radius) 0 0; +} + +.photo-card:hover { + transform: translateY(-8px) scale(1.02); + box-shadow: + 0 20px 40px rgba(102, 126, 234, 0.3), + 0 8px 16px rgba(0, 0, 0, 0.1), + inset 0 1px 1px rgba(255, 255, 255, 0.8); +} + +.photo-card img { + width: 100%; + height: 240px; + object-fit: cover; + transition: transform 0.4s ease; +} + +.photo-card:hover img { + transform: scale(1.05); +} + +.photo-card-info { + padding: var(--gallery-spacing); + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.1) 0%, + rgba(255, 255, 255, 0.3) 100% + ); +} + +/* ===== Buttons - Glass Style ===== */ +.gallery-btn { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: calc(var(--gallery-radius) / 2); + padding: 12px 24px; + color: var(--gallery-text); + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.gallery-btn::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 50%; + background: linear-gradient( + 180deg, + rgba(255, 255, 255, 0.4) 0%, + transparent 100% + ); +} + +.gallery-btn:hover { + background: var(--glass-bg-elevated); + transform: translateY(-2px); + box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); +} + +.gallery-btn-primary { + background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-hover) 100%); + color: white; + border: none; +} + +/* ===== Lightbox - Glass Style ===== */ +.lightbox-overlay { + background: rgba(26, 26, 46, 0.8); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); +} + +.lightbox-content { + background: var(--glass-bg); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + box-shadow: 0 24px 48px rgba(0, 0, 0, 0.2); +} + +/* ===== Category Pills ===== */ +.category-pill { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: 9999px; + padding: 8px 20px; + font-size: 0.875rem; + font-weight: 500; + color: var(--gallery-text); + transition: all 0.3s ease; +} + +.category-pill:hover, +.category-pill.active { + background: var(--gallery-accent); + color: white; + border-color: var(--gallery-accent); +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .gallery-page { + --gallery-radius: 16px; + --gallery-spacing: 12px; + --glass-blur: 16px; + } + + .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .photo-card img { + height: 180px; + } +} + +/* ===== Accessibility: Reduce Motion ===== */ +@media (prefers-reduced-motion: reduce) { + .photo-card, + .gallery-btn { + transition: none; + } + + .photo-card:hover { + transform: none; + } +} + +/* ===== Accessibility: Reduce Transparency ===== */ +@media (prefers-reduced-transparency: reduce) { + .glass-surface, + .photo-card, + .gallery-btn { + backdrop-filter: none; + -webkit-backdrop-filter: none; + background: rgba(255, 255, 255, 0.95); + } +}`; + +const LIQUID_GLASS_DARK = `/* + * PicPeak Custom CSS Template: Liquid Glass Dark + * Inspired by Apple's iOS 26 Liquid Glass Design Language + * + * Features: + * - Deep translucent dark surfaces + * - Neon accent highlights + * - Dramatic glass reflections + * - Subtle animated gradients + */ + +/* ===== Base Theme Variables ===== */ +.gallery-page { + --glass-bg: rgba(15, 15, 35, 0.7); + --glass-bg-elevated: rgba(25, 25, 55, 0.85); + --glass-border: rgba(255, 255, 255, 0.1); + --glass-border-highlight: rgba(255, 255, 255, 0.2); + --glass-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); + --glass-blur: 24px; + --glass-saturation: 150%; + + --gallery-bg: #0a0a1a; + --gallery-text: #f0f0f5; + --gallery-text-muted: rgba(240, 240, 245, 0.6); + --gallery-accent: #00d4ff; + --gallery-accent-secondary: #ff00e5; + --gallery-accent-hover: #00ffea; + --gallery-radius: 20px; + --gallery-spacing: 20px; + + /* Neon glow variables */ + --neon-glow: 0 0 20px rgba(0, 212, 255, 0.5), 0 0 40px rgba(0, 212, 255, 0.2); + --neon-glow-secondary: 0 0 20px rgba(255, 0, 229, 0.5), 0 0 40px rgba(255, 0, 229, 0.2); +} + +/* ===== Page Background ===== */ +.gallery-page { + background: var(--gallery-bg); + min-height: 100vh; + position: relative; + overflow-x: hidden; +} + +/* Animated mesh gradient background */ +.gallery-page::before { + content: ''; + position: fixed; + top: -50%; + left: -50%; + right: -50%; + bottom: -50%; + background: + radial-gradient(circle at 30% 20%, rgba(0, 212, 255, 0.15) 0%, transparent 40%), + radial-gradient(circle at 70% 80%, rgba(255, 0, 229, 0.1) 0%, transparent 40%), + radial-gradient(circle at 50% 50%, rgba(100, 100, 255, 0.05) 0%, transparent 60%); + animation: gradientShift 20s ease-in-out infinite; + pointer-events: none; + z-index: 0; +} + +@keyframes gradientShift { + 0%, 100% { transform: translate(0, 0) rotate(0deg); } + 25% { transform: translate(2%, 2%) rotate(1deg); } + 50% { transform: translate(-1%, 3%) rotate(-1deg); } + 75% { transform: translate(3%, -2%) rotate(2deg); } +} + +/* ===== Gallery Header ===== */ +.gallery-header { + background: var(--glass-bg-elevated); + backdrop-filter: blur(30px) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(30px) saturate(var(--glass-saturation)); + border-bottom: 1px solid var(--glass-border-highlight); + padding: calc(var(--gallery-spacing) * 1.5); + position: sticky; + top: 0; + z-index: 100; + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.1); +} + +.gallery-title { + color: var(--gallery-text); + font-weight: 700; + font-size: 1.75rem; + letter-spacing: -0.02em; + background: linear-gradient(135deg, var(--gallery-text) 0%, var(--gallery-accent) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +/* ===== Photo Grid ===== */ +.photo-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); + gap: var(--gallery-spacing); + padding: calc(var(--gallery-spacing) * 2); + position: relative; + z-index: 1; +} + +/* ===== Photo Cards - Dark Glass Style ===== */ +.photo-card { + position: relative; + background: var(--glass-bg); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + border: 1px solid var(--glass-border); + border-radius: var(--gallery-radius); + overflow: hidden; + transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: + 0 4px 24px rgba(0, 0, 0, 0.3), + inset 0 1px 0 rgba(255, 255, 255, 0.05); +} + +/* Top highlight reflection */ +.photo-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + height: 1px; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.3) 50%, + transparent 100% + ); + z-index: 2; +} + +/* Inner glow effect */ +.photo-card::after { + content: ''; + position: absolute; + inset: 0; + border-radius: var(--gallery-radius); + padding: 1px; + background: linear-gradient( + 135deg, + rgba(0, 212, 255, 0) 0%, + rgba(0, 212, 255, 0) 40%, + rgba(0, 212, 255, 0.1) 100% + ); + -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + pointer-events: none; + opacity: 0; + transition: opacity 0.4s ease; +} + +.photo-card:hover { + transform: translateY(-8px) scale(1.02); + border-color: var(--glass-border-highlight); + box-shadow: + 0 24px 48px rgba(0, 0, 0, 0.4), + 0 0 0 1px rgba(0, 212, 255, 0.2), + var(--neon-glow); +} + +.photo-card:hover::after { + opacity: 1; +} + +.photo-card img { + width: 100%; + height: 240px; + object-fit: cover; + transition: transform 0.4s ease, filter 0.4s ease; + filter: brightness(0.9); +} + +.photo-card:hover img { + transform: scale(1.05); + filter: brightness(1); +} + +.photo-card-info { + padding: var(--gallery-spacing); + background: linear-gradient( + 180deg, + rgba(0, 0, 0, 0.2) 0%, + rgba(0, 0, 0, 0.4) 100% + ); + color: var(--gallery-text); +} + +.photo-card-info p { + color: var(--gallery-text-muted); + font-size: 0.875rem; +} + +/* ===== Buttons - Neon Glass Style ===== */ +.gallery-btn { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: calc(var(--gallery-radius) / 2); + padding: 12px 24px; + color: var(--gallery-text); + font-weight: 600; + cursor: pointer; + transition: all 0.3s ease; + position: relative; +} + +.gallery-btn:hover { + border-color: var(--gallery-accent); + box-shadow: var(--neon-glow); + color: var(--gallery-accent); +} + +.gallery-btn-primary { + background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%); + color: white; + border: none; + box-shadow: var(--neon-glow); +} + +.gallery-btn-primary:hover { + box-shadow: + 0 0 30px rgba(0, 212, 255, 0.6), + 0 0 60px rgba(0, 212, 255, 0.3), + 0 0 90px rgba(255, 0, 229, 0.2); + transform: translateY(-2px); +} + +/* ===== Lightbox - Dark Glass ===== */ +.lightbox-overlay { + background: rgba(5, 5, 15, 0.9); + backdrop-filter: blur(40px); + -webkit-backdrop-filter: blur(40px); +} + +.lightbox-content { + background: var(--glass-bg-elevated); + backdrop-filter: blur(24px); + -webkit-backdrop-filter: blur(24px); + border: 1px solid var(--glass-border-highlight); + border-radius: var(--gallery-radius); + box-shadow: + 0 24px 80px rgba(0, 0, 0, 0.5), + var(--neon-glow); +} + +/* ===== Category Pills ===== */ +.category-pill { + background: var(--glass-bg); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid var(--glass-border); + border-radius: 9999px; + padding: 8px 20px; + font-size: 0.875rem; + font-weight: 500; + color: var(--gallery-text-muted); + transition: all 0.3s ease; +} + +.category-pill:hover { + border-color: var(--gallery-accent); + color: var(--gallery-accent); + box-shadow: var(--neon-glow); +} + +.category-pill.active { + background: linear-gradient(135deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%); + color: white; + border-color: transparent; + box-shadow: var(--neon-glow); +} + +/* ===== Scrollbar Styling ===== */ +.gallery-page ::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +.gallery-page ::-webkit-scrollbar-track { + background: var(--glass-bg); + border-radius: 4px; +} + +.gallery-page ::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, var(--gallery-accent) 0%, var(--gallery-accent-secondary) 100%); + border-radius: 4px; +} + +/* ===== Responsive ===== */ +@media (max-width: 768px) { + .gallery-page { + --gallery-radius: 16px; + --gallery-spacing: 12px; + --glass-blur: 16px; + } + + .photo-grid { + grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); + } + + .photo-card img { + height: 180px; + } + + /* Reduce animation complexity on mobile */ + .gallery-page::before { + animation: none; + } +} + +/* ===== Accessibility: Reduce Motion ===== */ +@media (prefers-reduced-motion: reduce) { + .gallery-page::before { + animation: none; + } + + .photo-card, + .gallery-btn { + transition: none; + } + + .photo-card:hover { + transform: none; + } +} + +/* ===== Accessibility: Reduce Transparency ===== */ +@media (prefers-reduced-transparency: reduce) { + .photo-card, + .gallery-btn, + .gallery-header { + backdrop-filter: none; + -webkit-backdrop-filter: none; + } + + .gallery-page { + --glass-bg: rgba(20, 20, 40, 0.98); + --glass-bg-elevated: rgba(30, 30, 60, 0.98); + } +}`; + +exports.up = async function(knex) { + // Update template slot 2 with Liquid Glass Light + await knex('css_templates') + .where({ slot_number: 2 }) + .update({ + name: 'Liquid Glass Light', + css_content: LIQUID_GLASS_LIGHT, + is_enabled: true, + is_default: false, + updated_at: knex.fn.now() + }); + + // Update template slot 3 with Liquid Glass Dark + await knex('css_templates') + .where({ slot_number: 3 }) + .update({ + name: 'Liquid Glass Dark', + css_content: LIQUID_GLASS_DARK, + is_enabled: true, + is_default: false, + updated_at: knex.fn.now() + }); +}; + +exports.down = async function(knex) { + // Revert to empty templates + await knex('css_templates') + .where({ slot_number: 2 }) + .update({ + name: 'Untitled', + css_content: '', + is_enabled: false, + is_default: false, + updated_at: knex.fn.now() + }); + + await knex('css_templates') + .where({ slot_number: 3 }) + .update({ + name: 'Untitled', + css_content: '', + is_enabled: false, + is_default: false, + updated_at: knex.fn.now() + }); +}; + +// Export templates for use elsewhere +module.exports.LIQUID_GLASS_LIGHT = LIQUID_GLASS_LIGHT; +module.exports.LIQUID_GLASS_DARK = LIQUID_GLASS_DARK; diff --git a/backend/server.js b/backend/server.js index fb9f9e6c..d49556a5 100644 --- a/backend/server.js +++ b/backend/server.js @@ -25,6 +25,7 @@ const { startBackupService } = require('./src/services/backupService'); const { startScheduledBackups } = require('./src/services/databaseBackup'); const { maintenanceMiddleware } = require('./src/middleware/maintenance'); const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout'); +const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler'); const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService'); const { getPublicSitePayload } = require('./src/services/publicSiteService'); const cookieParser = require('cookie-parser'); @@ -471,20 +472,11 @@ try { logger.warn('Failed to enable frontend static serving', { error: e.message }); } -// Error handling middleware -app.use((err, req, res, next) => { - console.error('EXPRESS ERROR HANDLER:', err); - console.error('Error stack:', err.stack); - console.error('Request URL:', req.url); - console.error('Request method:', req.method); - logger.error('Express error handler:', { - message: err.message, - stack: err.stack, - url: req.url, - method: req.method - }); - res.status(500).json({ error: 'Something went wrong!', details: err.message }); -}); +// 404 handler for undefined API routes +app.use('/api', notFoundHandler); + +// Global error handler (must be last) +app.use(errorHandler); // Initialize services async function startServer() { diff --git a/backend/src/middleware/errorHandler.js b/backend/src/middleware/errorHandler.js new file mode 100644 index 00000000..782a2370 --- /dev/null +++ b/backend/src/middleware/errorHandler.js @@ -0,0 +1,183 @@ +/** + * Global error handler middleware. + * Catches all errors and returns standardized responses. + * Distinguishes between operational errors (expected) and programming errors (bugs). + */ + +const logger = require('../utils/logger'); +const { AppError } = require('../utils/errors'); + +/** + * Determines if an error is operational (expected) or a programming error (bug). + * Operational errors are expected failures like validation errors, not found, etc. + * Programming errors are bugs that should be logged and investigated. + * + * @param {Error} err - The error to check + * @returns {boolean} True if operational error + */ +const isOperationalError = (err) => { + return err instanceof AppError && err.isOperational; +}; + +/** + * Formats error for development environment (includes stack trace). + * + * @param {Error} err - The error object + * @returns {Object} Formatted error response + */ +const formatDevError = (err) => { + return { + error: err.message, + code: err.code || 'INTERNAL_ERROR', + stack: err.stack, + ...(err.details && { details: err.details }), + ...(err.field && { field: err.field }) + }; +}; + +/** + * Formats error for production environment (hides sensitive details). + * + * @param {Error} err - The error object + * @param {boolean} isOperational - Whether this is an operational error + * @returns {Object} Formatted error response + */ +const formatProdError = (err, isOperational) => { + // For operational errors, show the message + if (isOperational) { + return { + error: err.message, + code: err.code || 'ERROR', + ...(err.details && { details: err.details }), + ...(err.field && { field: err.field }) + }; + } + + // For programming errors, hide details + return { + error: 'An unexpected error occurred', + code: 'INTERNAL_ERROR' + }; +}; + +/** + * Handles specific error types and converts them to AppError format. + * + * @param {Error} err - The error to handle + * @returns {Error} Converted error or original error + */ +const handleKnownErrors = (err) => { + // Handle Knex/Database errors + if (err.code === 'SQLITE_CONSTRAINT' || err.code === '23505') { + const { AppError } = require('../utils/errors'); + const error = new AppError('A record with this value already exists', 409, 'DUPLICATE_ENTRY'); + error.isOperational = true; + return error; + } + + // Handle JSON parsing errors + if (err instanceof SyntaxError && err.status === 400 && 'body' in err) { + const { ValidationError } = require('../utils/errors'); + return new ValidationError('Invalid JSON in request body'); + } + + // Handle multer file upload errors + if (err.code === 'LIMIT_FILE_SIZE') { + const { ValidationError } = require('../utils/errors'); + return new ValidationError('File size exceeds the maximum allowed limit'); + } + + if (err.code === 'LIMIT_UNEXPECTED_FILE') { + const { ValidationError } = require('../utils/errors'); + return new ValidationError('Unexpected file field'); + } + + return err; +}; + +/** + * Global error handler middleware. + * Must be registered last, after all routes. + * + * @param {Error} err - The error object + * @param {Request} req - Express request object + * @param {Response} res - Express response object + * @param {Function} next - Express next function + */ +const errorHandler = (err, req, res, next) => { + // If headers already sent, delegate to Express default handler + if (res.headersSent) { + return next(err); + } + + // Convert known error types + const error = handleKnownErrors(err); + + // Determine error status code + const statusCode = error.statusCode || error.status || 500; + const operational = isOperationalError(error); + + // Log the error + const logContext = { + url: req.originalUrl, + method: req.method, + ip: req.ip, + statusCode, + errorCode: error.code, + operational, + ...(req.admin && { adminId: req.admin.id }), + ...(req.gallerySlug && { gallerySlug: req.gallerySlug }) + }; + + if (operational) { + // Operational errors are expected, log at warn level + logger.warn('Operational error', { + ...logContext, + message: error.message + }); + } else { + // Programming errors are bugs, log at error level with stack + logger.error('Unhandled error', { + ...logContext, + message: error.message, + stack: error.stack + }); + } + + // Format and send response + const isDev = process.env.NODE_ENV === 'development'; + const response = isDev ? formatDevError(error) : formatProdError(error, operational); + + res.status(statusCode).json(response); +}; + +/** + * 404 handler for undefined routes. + * Should be registered after all routes but before errorHandler. + * + * @param {Request} req - Express request object + * @param {Response} res - Express response object + * @param {Function} next - Express next function + */ +const notFoundHandler = (req, res, next) => { + const { NotFoundError } = require('../utils/errors'); + next(new NotFoundError('Route', req.originalUrl)); +}; + +/** + * Async handler that catches unhandled promise rejections. + * Use this to wrap async route handlers. + * + * @param {Function} fn - Async function to wrap + * @returns {Function} Wrapped function + */ +const asyncHandler = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); +}; + +module.exports = { + errorHandler, + notFoundHandler, + asyncHandler, + isOperationalError +}; diff --git a/backend/src/routes/adminAuth.js b/backend/src/routes/adminAuth.js index 0b31888c..dfc5c183 100644 --- a/backend/src/routes/adminAuth.js +++ b/backend/src/routes/adminAuth.js @@ -1,31 +1,29 @@ const express = require('express'); const bcrypt = require('bcrypt'); -const { body, validationResult } = require('express-validator'); +const { body } = require('express-validator'); const { db, logActivity } = require('../database/db'); const { adminAuth } = require('../middleware/auth'); const { endSession } = require('../middleware/sessionTimeout'); const { validatePasswordStrength } = require('../utils/passwordGenerator'); +const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers'); +const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors'); const router = express.Router(); -// Change password -router.get('/profile', adminAuth, async (req, res) => { - try { - const admin = await db('admin_users') - .where('id', req.admin.id) - .select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword') - .first(); +// Get admin profile +router.get('/profile', adminAuth, handleAsync(async (req, res) => { + const admin = await db('admin_users') + .where('id', req.admin.id) + .select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword') + .first(); - if (!admin) { - return res.status(404).json({ error: 'Admin user not found' }); - } - - res.json(admin); - } catch (error) { - console.error('Admin profile fetch error:', error); - res.status(500).json({ error: 'Failed to fetch admin profile' }); + if (!admin) { + throw new NotFoundError('Admin user'); } -}); + res.json(admin); +})); + +// Update admin profile router.put('/profile', [ adminAuth, body('username') @@ -37,212 +35,129 @@ router.put('/profile', [ .isEmail() .withMessage('A valid email address is required') .normalizeEmail() -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } +], handleAsync(async (req, res) => { + validateRequest(req); - const username = req.body.username.trim(); - const email = req.body.email.trim().toLowerCase(); - const adminId = req.admin.id; + const username = req.body.username.trim(); + const email = req.body.email.trim().toLowerCase(); + const adminId = req.admin.id; - const existingUsername = await db('admin_users') - .where('username', username) - .whereNot('id', adminId) - .first(); + // Check for username conflict + const existingUsername = await db('admin_users') + .where('username', username) + .whereNot('id', adminId) + .first(); - if (existingUsername) { - return res.status(409).json({ error: 'Username is already in use' }); - } - - const existingEmail = await db('admin_users') - .where('email', email) - .whereNot('id', adminId) - .first(); - - if (existingEmail) { - return res.status(409).json({ error: 'Email address is already in use' }); - } - - await db('admin_users') - .where('id', adminId) - .update({ - username, - email, - updated_at: new Date() - }); - - await logActivity('admin_profile_updated', - { username, email }, - null, - { type: 'admin', id: adminId, name: req.admin.username } - ); - - const updatedAdmin = await db('admin_users') - .where('id', adminId) - .select('id', 'username', 'email', 'must_change_password as mustChangePassword') - .first(); - - res.json({ - message: 'Admin profile updated successfully', - user: updatedAdmin - }); - } catch (error) { - console.error('Admin profile update error:', error); - res.status(500).json({ error: 'Failed to update admin profile' }); + if (existingUsername) { + throw new ConflictError('Username is already in use', 'username'); } -}); + // Check for email conflict + const existingEmail = await db('admin_users') + .where('email', email) + .whereNot('id', adminId) + .first(); + + if (existingEmail) { + throw new ConflictError('Email address is already in use', 'email'); + } + + await db('admin_users') + .where('id', adminId) + .update({ + username, + email, + updated_at: new Date() + }); + + await logActivity('admin_profile_updated', + { username, email }, + null, + { type: 'admin', id: adminId, name: req.admin.username } + ); + + const updatedAdmin = await db('admin_users') + .where('id', adminId) + .select('id', 'username', 'email', 'must_change_password as mustChangePassword') + .first(); + + successResponse(res, { + message: 'Admin profile updated successfully', + user: updatedAdmin + }); +})); + +// Change password router.post('/change-password', [ adminAuth, body('currentPassword').notEmpty().withMessage('Current password is required'), body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } +], handleAsync(async (req, res) => { + validateRequest(req); - const { currentPassword, newPassword } = req.body; - const userId = req.admin.id; // Changed from req.user.id to req.admin.id + const { currentPassword, newPassword } = req.body; + const userId = req.admin.id; - // Validate new password strength - const passwordValidation = validatePasswordStrength(newPassword); - if (!passwordValidation.isValid) { - return res.status(400).json({ - error: 'Password does not meet security requirements', - details: passwordValidation.messages - }); - } - - // Get user from database - const user = await db('admin_users') - .where('id', userId) - .first(); - - if (!user) { - return res.status(404).json({ error: 'User not found' }); - } - - // Verify current password - const validPassword = await bcrypt.compare(currentPassword, user.password_hash); - if (!validPassword) { - return res.status(400).json({ error: 'Current password is incorrect' }); - } - - // Hash new password with more rounds - const newPasswordHash = await bcrypt.hash(newPassword, 12); - - // Update password and clear must_change_password flag - await db('admin_users') - .where('id', userId) - .update({ - password_hash: newPasswordHash, - must_change_password: false, - updated_at: new Date() - }); - - // Log activity - await logActivity('password_changed', - { admin_id: userId }, - null, - { type: 'admin', id: userId, name: user.username } - ); - - res.json({ message: 'Password changed successfully' }); - } catch (error) { - console.error('Password change error:', error); - res.status(500).json({ error: 'Failed to change password' }); + // Validate new password strength + const passwordValidation = validatePasswordStrength(newPassword); + if (!passwordValidation.isValid) { + throw new ValidationError('Password does not meet security requirements', passwordValidation.messages); } -}); -// Update admin profile -router.put('/profile', [ - adminAuth, - body('username').trim().notEmpty().withMessage('Username is required'), - body('email').trim().isEmail().withMessage('Valid email is required') -], async (req, res) => { - try { - const errors = validationResult(req); - if (!errors.isEmpty()) { - return res.status(400).json({ errors: errors.array() }); - } + // Get user from database + const user = await db('admin_users') + .where('id', userId) + .first(); - const { username, email } = req.body; - const userId = req.admin.id; - - // Check for email conflicts - const existingEmail = await db('admin_users') - .where('email', email) - .whereNot('id', userId) - .first(); - - if (existingEmail) { - return res.status(409).json({ error: 'Email is already in use by another admin' }); - } - - // Check username conflict (if multiple admins are supported) - const existingUsername = await db('admin_users') - .where('username', username) - .whereNot('id', userId) - .first(); - - if (existingUsername) { - return res.status(409).json({ error: 'Username is already in use by another admin' }); - } - - await db('admin_users') - .where('id', userId) - .update({ - username, - email, - updated_at: new Date() - }); - - const updatedUser = await db('admin_users') - .select('id', 'username', 'email', 'must_change_password') - .where('id', userId) - .first(); - - await logActivity( - 'admin_profile_updated', - { admin_id: userId, updated_fields: ['username', 'email'] }, - null, - { type: 'admin', id: userId, name: username } - ); - - res.json({ user: updatedUser }); - } catch (error) { - console.error('Admin profile update error:', error); - res.status(500).json({ error: 'Failed to update admin profile' }); + if (!user) { + throw new NotFoundError('User'); } -}); + + // Verify current password + const validPassword = await bcrypt.compare(currentPassword, user.password_hash); + if (!validPassword) { + throw new ValidationError('Current password is incorrect'); + } + + // Hash new password with more rounds + const newPasswordHash = await bcrypt.hash(newPassword, 12); + + // Update password and clear must_change_password flag + await db('admin_users') + .where('id', userId) + .update({ + password_hash: newPasswordHash, + must_change_password: false, + updated_at: new Date() + }); + + // Log activity + await logActivity('password_changed', + { admin_id: userId }, + null, + { type: 'admin', id: userId, name: user.username } + ); + + successResponse(res, { message: 'Password changed successfully' }); +})); // Logout -router.post('/logout', adminAuth, async (req, res) => { - try { - // Get token from header - const token = req.headers.authorization?.split(' ')[1]; - if (token) { - // End the session - endSession(token); - } - - // Log activity - await logActivity('admin_logout', - { admin_id: req.admin.id }, - null, - { type: 'admin', id: req.admin.id, name: req.admin.username } - ); - - res.json({ message: 'Logged out successfully' }); - } catch (error) { - console.error('Logout error:', error); - res.status(500).json({ error: 'Failed to logout' }); +router.post('/logout', adminAuth, handleAsync(async (req, res) => { + // Get token from header + const token = req.headers.authorization?.split(' ')[1]; + if (token) { + // End the session + endSession(token); } -}); + + // Log activity + await logActivity('admin_logout', + { admin_id: req.admin.id }, + null, + { type: 'admin', id: req.admin.id, name: req.admin.username } + ); + + successResponse(res, { message: 'Logged out successfully' }); +})); module.exports = router; diff --git a/backend/src/routes/adminEvents.js b/backend/src/routes/adminEvents.js index 2b0a61b3..0b136d10 100644 --- a/backend/src/routes/adminEvents.js +++ b/backend/src/routes/adminEvents.js @@ -141,7 +141,8 @@ router.post('/', adminAuth, [ body('allow_downloads').optional().isBoolean(), body('disable_right_click').optional().isBoolean(), body('watermark_downloads').optional().isBoolean(), - body('watermark_text').optional().trim() + body('watermark_text').optional().trim(), + body('css_template_id').optional({ nullable: true, checkFalsy: true }).isInt() ], async (req, res) => { try { logger.debug('Create event request body', { body: req.body }); @@ -178,7 +179,9 @@ router.post('/', adminAuth, [ allow_favorites = true, require_name_email = false, moderate_comments = true, - show_feedback_to_guests = true + show_feedback_to_guests = true, + // CSS Template + css_template_id = null } = req.body; const customerName = getCustomerNameFromPayload(req.body); @@ -299,7 +302,8 @@ router.post('/', adminAuth, [ disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false), watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false), watermark_text, - require_password: formatBoolean(requirePassword) + require_password: formatBoolean(requirePassword), + css_template_id: css_template_id || null }).returning('id'); // Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs) diff --git a/backend/src/routes/gallery.js b/backend/src/routes/gallery.js index eb72bd46..08e98c7e 100644 --- a/backend/src/routes/gallery.js +++ b/backend/src/routes/gallery.js @@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService'); const logger = require('../utils/logger'); const { resolvePhotoFilePath } = require('../services/photoResolver'); const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService'); +const { handleAsync } = require('../utils/routeHelpers'); +const { NotFoundError } = require('../utils/errors'); // Get storage path from environment or default const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage'); @@ -32,69 +34,59 @@ async function checkSlugRedirect(slug) { } // Resolve gallery identifier (slug or token) to canonical data -router.get('/resolve/:identifier', async (req, res) => { - try { - const { identifier } = req.params; - let result = await resolveShareIdentifier(identifier); +router.get('/resolve/:identifier', handleAsync(async (req, res) => { + const { identifier } = req.params; + let result = await resolveShareIdentifier(identifier); - // If not found, check for redirect - if (!result) { - const newSlug = await checkSlugRedirect(identifier); - if (newSlug) { - return res.status(301).json({ - redirect: true, - newSlug, - message: 'Gallery has been renamed' - }); - } - return res.status(404).json({ error: 'Gallery not found' }); + // If not found, check for redirect + if (!result) { + const newSlug = await checkSlugRedirect(identifier); + if (newSlug) { + return res.status(301).json({ + redirect: true, + newSlug, + message: 'Gallery has been renamed' + }); } - - const { event, matchType, shareToken } = result; - const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken }); - const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); - - res.json({ - slug: event.slug, - token: shareToken, - matchType, - share_link: event.share_link, - share_path: linkVariants.sharePath, - share_url: linkVariants.shareUrl, - short_enabled: linkVariants.shortEnabled, - requires_password: requiresPassword - }); - } catch (error) { - logger.error('Error resolving gallery identifier:', error); - res.status(500).json({ error: 'Failed to resolve gallery link' }); + throw new NotFoundError('Gallery'); } -}); + + const { event, matchType, shareToken } = result; + const linkVariants = await buildShareLinkVariants({ slug: event.slug, shareToken }); + const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0'); + + res.json({ + slug: event.slug, + token: shareToken, + matchType, + share_link: event.share_link, + share_path: linkVariants.sharePath, + share_url: linkVariants.shareUrl, + short_enabled: linkVariants.shortEnabled, + requires_password: requiresPassword + }); +})); // Verify share token -router.get('/:slug/verify-token/:token', async (req, res) => { - try { - const { slug, token } = req.params; - - const event = await db('events') - .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) - .select('id', 'share_link', 'share_token') - .first(); - - if (!event) { - return res.status(404).json({ error: 'Gallery not found' }); - } - - const expectedToken = getEventShareToken(event); - if (token !== expectedToken) { - return res.status(404).json({ error: 'Invalid gallery link' }); - } - - res.json({ valid: true }); - } catch (error) { - console.error('Error verifying token:', error); - res.status(500).json({ error: 'Failed to verify token' }); +router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => { + const { slug, token } = req.params; + + const event = await db('events') + .where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) }) + .select('id', 'share_link', 'share_token') + .first(); + + if (!event) { + throw new NotFoundError('Gallery'); } -}); + + const expectedToken = getEventShareToken(event); + if (token !== expectedToken) { + throw new NotFoundError('Gallery', 'Invalid gallery link'); + } + + res.json({ valid: true }); +})); // Get gallery info (with optional token verification) router.get('/:slug/info', async (req, res) => { diff --git a/backend/src/utils/errors.js b/backend/src/utils/errors.js new file mode 100644 index 00000000..ff676d29 --- /dev/null +++ b/backend/src/utils/errors.js @@ -0,0 +1,121 @@ +/** + * Custom error classes for standardized error handling across the application. + * These errors are caught by the global error handler and converted to appropriate HTTP responses. + */ + +/** + * Base class for operational errors (expected errors that can occur during normal operation) + */ +class AppError extends Error { + constructor(message, statusCode = 500, code = 'INTERNAL_ERROR') { + super(message); + this.statusCode = statusCode; + this.code = code; + this.isOperational = true; + Error.captureStackTrace(this, this.constructor); + } + + toJSON() { + return { + error: this.message, + code: this.code, + ...(process.env.NODE_ENV === 'development' && { stack: this.stack }) + }; + } +} + +/** + * Validation error - for invalid input data (400 Bad Request) + */ +class ValidationError extends AppError { + constructor(message = 'Validation failed', details = null) { + super(message, 400, 'VALIDATION_ERROR'); + this.details = details; + } + + toJSON() { + return { + ...super.toJSON(), + ...(this.details && { details: this.details }) + }; + } +} + +/** + * Not found error - for resources that don't exist (404 Not Found) + */ +class NotFoundError extends AppError { + constructor(resource = 'Resource', identifier = null) { + const message = identifier + ? `${resource} with identifier '${identifier}' not found` + : `${resource} not found`; + super(message, 404, 'NOT_FOUND'); + this.resource = resource; + this.identifier = identifier; + } +} + +/** + * Unauthorized error - for missing or invalid authentication (401 Unauthorized) + */ +class UnauthorizedError extends AppError { + constructor(message = 'Authentication required') { + super(message, 401, 'UNAUTHORIZED'); + } +} + +/** + * Forbidden error - for insufficient permissions (403 Forbidden) + */ +class ForbiddenError extends AppError { + constructor(message = 'Access denied') { + super(message, 403, 'FORBIDDEN'); + } +} + +/** + * Conflict error - for resource conflicts (409 Conflict) + */ +class ConflictError extends AppError { + constructor(message = 'Resource conflict', field = null) { + super(message, 409, 'CONFLICT'); + this.field = field; + } + + toJSON() { + return { + ...super.toJSON(), + ...(this.field && { field: this.field }) + }; + } +} + +/** + * Rate limit error - for too many requests (429 Too Many Requests) + */ +class RateLimitError extends AppError { + constructor(message = 'Too many requests', retryAfter = null) { + super(message, 429, 'RATE_LIMIT_EXCEEDED'); + this.retryAfter = retryAfter; + } +} + +/** + * Service unavailable error - for maintenance mode or service issues (503 Service Unavailable) + */ +class ServiceUnavailableError extends AppError { + constructor(message = 'Service temporarily unavailable') { + super(message, 503, 'SERVICE_UNAVAILABLE'); + } +} + +module.exports = { + AppError, + ValidationError, + NotFoundError, + UnauthorizedError, + ForbiddenError, + ConflictError, + RateLimitError, + ServiceUnavailableError +}; diff --git a/backend/src/utils/routeHelpers.js b/backend/src/utils/routeHelpers.js new file mode 100644 index 00000000..d29e9913 --- /dev/null +++ b/backend/src/utils/routeHelpers.js @@ -0,0 +1,180 @@ +/** + * Route helper utilities for standardized request handling. + * Provides async error wrapping, validation, and response formatting. + */ + +const { validationResult } = require('express-validator'); +const { ValidationError } = require('./errors'); + +/** + * Wraps an async route handler to catch errors and pass them to the error handler. + * Eliminates the need for try/catch blocks in every route. + * + * @param {Function} fn - Async route handler function + * @returns {Function} Express middleware function + * + * @example + * router.get('/events', handleAsync(async (req, res) => { + * const events = await eventService.getAll(); + * res.json(events); + * })); + */ +const handleAsync = (fn) => { + return (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); + }; +}; + +/** + * Validates the request using express-validator and throws ValidationError if invalid. + * Should be called at the beginning of route handlers after validation middleware. + * + * @param {Request} req - Express request object + * @throws {ValidationError} If validation fails + * + * @example + * router.post('/events', [ + * body('name').notEmpty(), + * body('date').isDate() + * ], handleAsync(async (req, res) => { + * validateRequest(req); + * // ... rest of handler + * })); + */ +const validateRequest = (req) => { + const errors = validationResult(req); + if (!errors.isEmpty()) { + const errorDetails = errors.array().map(err => ({ + field: err.path || err.param, + message: err.msg + })); + throw new ValidationError('Validation failed', errorDetails); + } +}; + +/** + * Sends a standardized success response. + * + * @param {Response} res - Express response object + * @param {*} data - Data to send in the response + * @param {number} [statusCode=200] - HTTP status code + * @param {string} [message] - Optional success message + * + * @example + * successResponse(res, { event }, 201, 'Event created successfully'); + */ +const successResponse = (res, data, statusCode = 200, message = null) => { + const response = message ? { message, ...data } : data; + res.status(statusCode).json(response); +}; + +/** + * Sends a standardized error response. + * Note: Prefer throwing custom errors and letting the error handler format the response. + * + * @param {Response} res - Express response object + * @param {string} message - Error message + * @param {number} [statusCode=500] - HTTP status code + * @param {string} [code] - Optional error code + * @param {*} [details] - Optional additional error details + * + * @example + * errorResponse(res, 'Invalid input', 400, 'VALIDATION_ERROR', { field: 'email' }); + */ +const errorResponse = (res, message, statusCode = 500, code = null, details = null) => { + const response = { + error: message, + ...(code && { code }), + ...(details && { details }) + }; + res.status(statusCode).json(response); +}; + +/** + * Creates a route handler with built-in validation. + * Combines handleAsync and validateRequest for cleaner route definitions. + * + * @param {Function} fn - Async route handler function + * @returns {Function} Express middleware function + * + * @example + * router.post('/events', [ + * body('name').notEmpty() + * ], withValidation(async (req, res) => { + * const event = await eventService.create(req.body); + * successResponse(res, { event }, 201); + * })); + */ +const withValidation = (fn) => { + return handleAsync(async (req, res, next) => { + validateRequest(req); + return fn(req, res, next); + }); +}; + +/** + * Extracts pagination parameters from query string with defaults. + * + * @param {Request} req - Express request object + * @param {Object} [defaults] - Default values + * @param {number} [defaults.page=1] - Default page number + * @param {number} [defaults.limit=20] - Default items per page + * @param {number} [defaults.maxLimit=100] - Maximum allowed limit + * @returns {{ page: number, limit: number, offset: number }} + * + * @example + * const { page, limit, offset } = getPagination(req); + * const events = await db('events').limit(limit).offset(offset); + */ +const getPagination = (req, defaults = {}) => { + const { page: defaultPage = 1, limit: defaultLimit = 20, maxLimit = 100 } = defaults; + + let page = parseInt(req.query.page, 10) || defaultPage; + let limit = parseInt(req.query.limit, 10) || defaultLimit; + + // Ensure valid values + page = Math.max(1, page); + limit = Math.min(Math.max(1, limit), maxLimit); + + const offset = (page - 1) * limit; + + return { page, limit, offset }; +}; + +/** + * Creates a paginated response with metadata. + * + * @param {*} data - Data array + * @param {number} total - Total count of items + * @param {number} page - Current page + * @param {number} limit - Items per page + * @returns {Object} Paginated response object + * + * @example + * const events = await db('events').limit(limit).offset(offset); + * const total = await db('events').count('* as count').first(); + * res.json(paginatedResponse(events, total.count, page, limit)); + */ +const paginatedResponse = (data, total, page, limit) => { + const totalPages = Math.ceil(total / limit); + return { + data, + pagination: { + page, + limit, + total, + totalPages, + hasMore: page < totalPages + } + }; +}; + +module.exports = { + handleAsync, + validateRequest, + successResponse, + errorResponse, + withValidation, + getPagination, + paginatedResponse +}; diff --git a/frontend/TEST_PLAN.md b/frontend/TEST_PLAN.md new file mode 100644 index 00000000..863443ae --- /dev/null +++ b/frontend/TEST_PLAN.md @@ -0,0 +1,1022 @@ +# PicPeak E2E Test Plan + +## Overview + +This document provides a comprehensive end-to-end test plan for the PicPeak photo sharing platform. All tests are designed to be executed using **Playwright MCP Browser** or **Chrome DevTools MCP** tools. + +## Prerequisites + +### Environment Setup +- [ ] Frontend dev server running on `http://localhost:5173` +- [ ] Backend server running on `http://localhost:3001` +- [ ] Database seeded with test data +- [ ] Admin credentials available (default: admin/admin) + +### MCP Tools Required +- `mcp__playwright__browser_navigate` - Navigate to URLs +- `mcp__playwright__browser_snapshot` - Capture page state +- `mcp__playwright__browser_click` - Click elements +- `mcp__playwright__browser_fill_form` - Fill form fields +- `mcp__playwright__browser_type` - Type text +- `mcp__playwright__browser_console_messages` - Check console errors +- `mcp__chrome-devtools__list_console_messages` - Alternative console check +- `mcp__chrome-devtools__list_network_requests` - Monitor API calls + +### Test Data Requirements +- At least 2 active events with photos +- At least 1 expired event +- At least 1 archived event (optional) +- Categories configured +- Email templates configured + +--- + +## Test Execution Methodology + +### Before Each Test Section +``` +1. Navigate to the target page +2. Take a snapshot to verify page loaded +3. Check console for errors: browser_console_messages(level: "error") +4. Verify no network request failures +``` + +### After Each Test Section +``` +1. Take final snapshot +2. Check console for new errors +3. Document any issues found +``` + +--- + +## 1. Authentication Tests + +### 1.1 Admin Login +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| AUTH-001 | Valid login | Navigate to `/admin/login`, enter valid credentials, click Login | Redirect to dashboard, session created | +| AUTH-002 | Invalid password | Enter wrong password | Error message displayed, no redirect | +| AUTH-003 | Empty fields | Submit with empty fields | Validation errors shown | +| AUTH-004 | Session persistence | Login, refresh page | Stay logged in | +| AUTH-005 | Logout | Click logout button | Redirect to login, session cleared | + +### 1.2 Gallery Authentication +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAUTH-001 | Valid gallery password | Navigate to gallery, enter correct password | Gallery content displayed | +| GAUTH-002 | Invalid gallery password | Enter wrong password | Error message, access denied | +| GAUTH-003 | Token-based access | Access gallery with token in URL | Direct access without password | +| GAUTH-004 | Expired gallery access | Access expired gallery | Appropriate expiration message | + +--- + +## 2. Dashboard Tests + +### 2.1 Dashboard Display +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DASH-001 | Stats display | Navigate to `/admin/dashboard` | All stat cards show correct values | +| DASH-002 | Active Events count | Check "Active Events" card | Matches actual active events | +| DASH-003 | Expiring Soon count | Check "Expiring Soon" card | Shows events expiring in 7 days | +| DASH-004 | Total Photos count | Check "Total Photos" card | Matches sum of all event photos | +| DASH-005 | Storage Used | Check "Storage Used" card | Shows correct storage value | +| DASH-006 | Total Views | Check "Total Views" card | Shows accumulated views | +| DASH-007 | Downloads count | Check "Downloads" card | Shows accumulated downloads | +| DASH-008 | Archived Events | Check "Archived Events" card | Matches archived count | +| DASH-009 | System Health | Check "System Health" card | Shows "Healthy" or appropriate status | + +### 2.2 Dashboard Widgets +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DASH-010 | Expiring events list | Check "Events Expiring Soon" section | Lists events expiring within 7 days | +| DASH-011 | Recent activity | Check "Recent Activity" section | Shows recent actions with timestamps | +| DASH-012 | Create Event button | Click "Create Event" | Navigate to event creation | + +### 2.3 Translation Check - Dashboard +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DASH-T01 | English translation | Set language to English | All text in English, no translation keys visible | +| DASH-T02 | German translation | Set language to German | All text in German, no translation keys visible | +| DASH-T03 | Translation completeness | Check all labels and buttons | No `t('...')` keys or undefined text | + +--- + +## 3. Events Management Tests + +### 3.1 Events List Page +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EVT-001 | Events table display | Navigate to `/admin/events` | Table shows all events | +| EVT-002 | Stats cards | Check stat cards | Total, Active, Photos, Expiring counts correct | +| EVT-003 | Search functionality | Type in search box | Events filtered by name | +| EVT-004 | Filter - All | Click "All" filter | All events shown | +| EVT-005 | Filter - Active | Click "Active" filter | Only active events shown | +| EVT-006 | Filter - Expiring | Click "Expiring" filter | Only expiring events shown | +| EVT-007 | Filter - Archived | Click "Archived" filter | Only archived events shown | +| EVT-008 | Select all checkbox | Click header checkbox | All events selected | +| EVT-009 | Bulk actions | Select multiple, check actions | Bulk action options available | + +### 3.2 Event Creation +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EVT-010 | Open create form | Click "Create Event" | Modal/page opens | +| EVT-011 | Required fields validation | Submit empty form | Validation errors for required fields | +| EVT-012 | Event name required | Leave name empty | Error shown | +| EVT-013 | Event date required | Leave date empty | Error shown | +| EVT-014 | Event type selection | Select different types | Type saved correctly | +| EVT-015 | Password generation | Click generate password | Random password generated | +| EVT-016 | Custom password | Enter custom password | Password accepted | +| EVT-017 | Expiry date setting | Set expiry date | Date saved correctly | +| EVT-018 | Customer email optional | Leave email empty (if not required) | Event created without email | +| EVT-019 | Customer email required | Leave email empty (when required) | Validation error shown | +| EVT-020 | Successful creation | Fill all required, submit | Event created, redirect to details | + +### 3.3 Event Creation - CSS Template Selection +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EVT-CSS-001 | CSS templates visible | Check Theme & Style section | "Custom CSS Template" section shown (if templates exist) | +| EVT-CSS-002 | No template option | Check "No Template" card | Selectable, selected by default | +| EVT-CSS-003 | Template cards display | Check enabled templates | Templates show name and slot number | +| EVT-CSS-004 | Select template | Click template card | Card highlighted, template selected | +| EVT-CSS-005 | Template persistence | Create event with template | Template saved with event | +| EVT-CSS-006 | Hidden when no templates | Disable all CSS templates | Section not visible | + +### 3.4 Event Creation - Required Fields Settings +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EVT-REQ-001 | Email required - enabled | Enable in settings, create event without email | Validation error for email | +| EVT-REQ-002 | Email required - disabled | Disable in settings, create event without email | Event created successfully | +| EVT-REQ-003 | Customer name required - enabled | Enable in settings, create event without name | Validation error for customer name | +| EVT-REQ-004 | Customer name required - disabled | Disable in settings, create event without name | Event created successfully | +| EVT-REQ-005 | Admin email required - enabled | Enable in settings, create event without admin email | Validation error | +| EVT-REQ-006 | Admin email required - disabled | Disable in settings, create event without admin email | Event created successfully | +| EVT-REQ-007 | Welcome message required - enabled | Enable in settings, create without message | Validation error | +| EVT-REQ-008 | Welcome message required - disabled | Disable in settings, create without message | Event created successfully | + +### 3.4 Event Actions +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EVT-020 | View details | Click actions → View Details | Navigate to event details | +| EVT-021 | View gallery | Click actions → View Gallery | Opens gallery in new tab | +| EVT-022 | Archive event | Click actions → Archive | Confirmation dialog, event archived | +| EVT-023 | Delete event | Click actions → Delete | Confirmation dialog, event deleted | + +### 3.5 Translation Check - Events +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EVT-T01 | Events list - English | Check all labels | No translation keys visible | +| EVT-T02 | Events list - German | Switch to German | All text translated | +| EVT-T03 | Create event modal - both languages | Check form labels | All properly translated | + +--- + +## 4. Event Details Tests + +### 4.1 Overview Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DET-001 | Event header | Check header section | Title, date, type, status displayed | +| DET-002 | Event information | Check info section | All fields displayed correctly | +| DET-003 | Source mode display | Check source mode | Shows "Managed" or "Watch folder" | +| DET-004 | Customer info | Check customer details | Name, email displayed | +| DET-005 | Dates display | Check created/expires | Both dates shown correctly | +| DET-006 | Share link | Check share link section | Link displayed with copy button | +| DET-007 | Copy link | Click copy button | Link copied to clipboard | +| DET-008 | Reset password | Click "Reset Gallery Password" | Confirmation, new password generated | +| DET-009 | Resend email | Click "Resend Creation Email" | Email sent notification | +| DET-010 | Photo statistics | Check stats section | Views, downloads, visitors shown | +| DET-011 | Theme preview | Check theme section | Current theme settings displayed | + +### 4.2 Photos Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DET-020 | Photos grid | Click Photos tab | Photo grid displayed | +| DET-021 | Search photos | Type in search box | Photos filtered by filename | +| DET-022 | Category filter | Select category | Photos filtered by category | +| DET-023 | Sort by date | Select "Sort by Date" | Photos sorted chronologically | +| DET-024 | Sort by name | Select "Sort by Name" | Photos sorted alphabetically | +| DET-025 | Sort by size | Select "Sort by Size" | Photos sorted by file size | +| DET-026 | Sort by rating | Select "Sort by Rating" | Photos sorted by rating | +| DET-027 | Sort direction | Click sort direction button | Order reversed | +| DET-028 | Rating filter | Select rating filter | Photos filtered by minimum rating | +| DET-029 | Has likes filter | Check "Has likes" | Only liked photos shown | +| DET-030 | Has favorites filter | Check "Has favorites" | Only favorited photos shown | +| DET-031 | Has comments filter | Check "Has comments" | Only commented photos shown | +| DET-032 | Upload photos | Click "Upload Photos" | Upload modal opens | +| DET-033 | Photo upload | Select and upload files | Photos uploaded, appear in grid | +| DET-034 | Select photos | Click "Select Photos" | Selection mode enabled | +| DET-035 | Bulk select | Select multiple photos | Selection count updates | +| DET-036 | Export selected | Select photos, click Export | Export options shown | +| DET-037 | Delete photos | Select photos, delete | Confirmation, photos removed | + +### 4.3 Categories Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DET-040 | Categories display | Click Categories tab | Global and event categories shown | +| DET-041 | Add category | Click "Add", enter name | New category created | +| DET-042 | Edit category | Edit category name | Name updated | +| DET-043 | Delete category | Delete event category | Category removed | +| DET-044 | Global categories | Check global list | All global categories displayed | + +### 4.4 Event Edit +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DET-050 | Open edit | Click "Edit" button | Edit modal/page opens | +| DET-051 | Edit name | Change event name | Name updated | +| DET-052 | Edit date | Change event date | Date updated | +| DET-053 | Edit expiry | Change expiry date | Expiry updated | +| DET-054 | Edit customer info | Change customer details | Info updated | +| DET-055 | Edit theme | Change theme settings | Theme updated | +| DET-056 | Save changes | Click save | Changes persisted | +| DET-057 | Cancel edit | Click cancel | Changes discarded | + +### 4.5 Translation Check - Event Details +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| DET-T01 | Overview tab | Check all labels | No translation keys | +| DET-T02 | Photos tab | Check all labels | No translation keys | +| DET-T03 | Categories tab | Check all labels | No translation keys | +| DET-T04 | Both languages | Switch languages | All text properly translated | + +--- + +## 5. Gallery (Public View) Tests + +### 5.1 Gallery Access +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAL-001 | Password prompt | Navigate to gallery URL | Password form displayed | +| GAL-002 | Enter password | Enter correct password | Gallery content shown | +| GAL-003 | Token access | Use URL with token | Direct access granted | +| GAL-004 | Invalid password | Enter wrong password | Error message shown | +| GAL-005 | Session persistence | Enter password, navigate away, return | Still authenticated | + +### 5.2 Gallery Display +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAL-010 | Header display | Check gallery header | Event name, branding shown | +| GAL-011 | Photo grid | Check main content | Photos displayed in configured layout | +| GAL-012 | Grid layout | Set grid layout | Photos in uniform grid | +| GAL-013 | Masonry layout | Set masonry layout | Pinterest-style layout | +| GAL-014 | Hero layout | Set hero layout | Featured image with grid below | +| GAL-015 | Carousel layout | Set carousel layout | Slideshow navigation | +| GAL-016 | Timeline layout | Set timeline layout | Photos by date | +| GAL-017 | Mosaic layout | Set mosaic layout | Varied sizes | +| GAL-018 | Footer display | Check footer | Legal links, copyright | + +### 5.3 Gallery Features +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAL-020 | Search photos | Type in search | Photos filtered | +| GAL-021 | Sort by date | Click sort by date | Photos reordered | +| GAL-022 | Sort by name | Click sort by name | Photos reordered | +| GAL-023 | Sort by size | Click sort by size | Photos reordered | +| GAL-024 | Sort by rating | Click sort by rating | Photos reordered | +| GAL-025 | Category filter | Select category | Photos filtered | + +### 5.4 Photo Interactions +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAL-030 | Open lightbox | Click photo | Lightbox opens | +| GAL-031 | Navigate lightbox | Use arrows/keys | Navigate between photos | +| GAL-032 | Close lightbox | Click close/escape | Lightbox closes | +| GAL-033 | Download single | Click download in lightbox | Photo downloaded | +| GAL-034 | Like photo | Click like button | Like registered, count updates | +| GAL-035 | Favorite photo | Click favorite button | Favorite registered | +| GAL-036 | Rate photo | Click rating stars | Rating saved | +| GAL-037 | Add comment | Type and submit comment | Comment added | + +### 5.5 Bulk Download +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAL-040 | Download all | Click "Download All" | ZIP download starts | +| GAL-041 | Select photos | Click "Select Photos" | Selection mode enabled | +| GAL-042 | Download selected | Select photos, download | Selected photos in ZIP | + +### 5.6 Translation Check - Gallery +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| GAL-T01 | Password page | Check all labels | No translation keys | +| GAL-T02 | Gallery view | Check all buttons/labels | No translation keys | +| GAL-T03 | Lightbox | Check all controls | No translation keys | +| GAL-T04 | German locale | Switch to German | All text translated | + +--- + +## 6. Archives Tests + +### 6.1 Archives List +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| ARC-001 | Archives page | Navigate to `/admin/archives` | Archives list displayed | +| ARC-002 | Stats cards | Check stat cards | Total, storage, photos, avg size shown | +| ARC-003 | Search archives | Type in search | Archives filtered | +| ARC-004 | Type filter | Select event type | Archives filtered | +| ARC-005 | Sort by date | Select sort by date | Archives sorted | +| ARC-006 | Sort by name | Select sort by name | Archives sorted | +| ARC-007 | Sort by size | Select sort by size | Archives sorted | +| ARC-008 | Empty state | No archives | "No archives found" message | + +### 6.2 Archive Actions +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| ARC-010 | Download archive | Click download action | ZIP file downloads | +| ARC-011 | Restore archive | Click restore action | Confirmation, event restored | +| ARC-012 | Delete archive | Click delete action | Confirmation, archive removed | +| ARC-013 | View archive info | Click archive row | Archive details shown | + +### 6.3 Translation Check - Archives +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| ARC-T01 | Archives page - English | Check all labels | No translation keys | +| ARC-T02 | Archives page - German | Switch to German | All text translated | + +--- + +## 7. Settings Tests + +### 7.1 General Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-001 | General tab display | Navigate to Settings, General tab | All sections visible | +| SET-002 | Account section | Check account form | Username, password fields | +| SET-003 | Update password | Change password, save | Password updated | +| SET-004 | Site name | Change site name, save | Name updated | +| SET-005 | Timezone | Change timezone, save | Timezone updated | +| SET-006 | Date format | Change format, save | Format updated | +| SET-007 | Language setting | Change default language | Language updated | +| SET-008 | Feature toggles | Toggle features on/off | Settings saved | +| SET-009 | Max uploads setting | Change max files per upload | Setting saved | + +### 7.2 Event Creation Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-010 | Events tab display | Click "Event Creation" tab | Required fields options shown | +| SET-011 | Customer email required | Toggle on/off | Setting saved | +| SET-012 | Customer name required | Toggle on/off | Setting saved | +| SET-013 | Admin email required | Toggle on/off | Setting saved | +| SET-014 | Welcome message required | Toggle on/off | Setting saved | +| SET-015 | Settings persistence | Change settings, refresh | Settings persisted | + +### 7.3 System Status Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-020 | Status tab display | Click "System Status" tab | All status sections shown | +| SET-021 | Storage overview | Check storage section | Used, available, limit shown | +| SET-022 | Soft limit setting | Set soft limit, save | Limit saved | +| SET-023 | Capacity override | Set override values | Values saved | +| SET-024 | System info | Check system section | Version, uptime, memory shown | +| SET-025 | Database info | Check database section | Type, size, connections shown | +| SET-026 | Background services | Check services list | Service status indicators | + +### 7.4 Security Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-030 | Security tab display | Click "Security" tab | All security options shown | +| SET-031 | Password requirements | Change min length | Setting saved | +| SET-032 | Session timeout | Change timeout value | Setting saved | +| SET-033 | Max login attempts | Change attempts value | Setting saved | +| SET-034 | reCAPTCHA settings | Configure reCAPTCHA | Settings saved | +| SET-035 | reCAPTCHA site key | Enter site key | Key saved | +| SET-036 | reCAPTCHA secret | Enter secret key | Key saved | + +### 7.5 Categories Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-040 | Categories tab display | Click "Categories" tab | Category manager shown | +| SET-041 | List categories | Check categories list | All global categories shown | +| SET-042 | Add category | Add new category | Category created | +| SET-043 | Edit category | Edit category name | Name updated | +| SET-044 | Delete category | Delete category | Category removed | +| SET-045 | Reorder categories | Drag to reorder | Order saved | + +### 7.6 Analytics Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-050 | Analytics tab display | Click "Analytics" tab | Analytics settings shown | +| SET-051 | Umami website ID | Enter website ID | ID saved | +| SET-052 | Umami host URL | Enter host URL | URL saved | +| SET-053 | Backend analytics info | Check backend section | Connection status shown | + +### 7.7 Moderation Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-060 | Moderation tab display | Click "Moderation" tab | Word filter manager shown | +| SET-061 | Add filter word | Add blocked word | Word added to list | +| SET-062 | Remove filter word | Remove word | Word removed | +| SET-063 | Filter action | Change filter action | Action saved | + +### 7.8 Custom CSS Tab +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-070 | Styling tab display | Click "Custom CSS" tab | CSS editor shown | +| SET-071 | Template selection | Select template 1/2/3 | Template loaded | +| SET-072 | Template name | Change template name | Name saved | +| SET-073 | Enable template | Toggle enable checkbox | State saved | +| SET-074 | Edit CSS | Modify CSS content | CSS saved | +| SET-075 | Reset to default | Click reset button | Default CSS restored | +| SET-076 | Character count | Check character counter | Shows correct count | + +### 7.9 Liquid Glass CSS Templates (FEATURE-006) +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-080 | Liquid Glass Light | Check template slot 2 | "Liquid Glass Light" template present | +| SET-081 | Liquid Glass Dark | Check template slot 3 | "Liquid Glass Dark" template present | +| SET-082 | Glass variables | Check Light template CSS | Contains --glass-bg, --glass-blur variables | +| SET-083 | Neon glow variables | Check Dark template CSS | Contains --neon-glow variable | +| SET-084 | Backdrop filter | Check templates | Contains backdrop-filter: blur() | +| SET-085 | Reduced motion | Check templates | Contains @media (prefers-reduced-motion) | +| SET-086 | Reduced transparency | Check templates | Contains @media (prefers-reduced-transparency) | +| SET-087 | Responsive styles | Check templates | Contains @media (max-width: 768px) | +| SET-088 | Template enabled | Toggle enable for Liquid Glass | Template becomes available in event creation | + +### 7.10 Translation Check - Settings +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| SET-T01 | All tabs - English | Check every tab | No translation keys visible | +| SET-T02 | All tabs - German | Switch to German, check tabs | All text translated | +| SET-T03 | Form labels | Check all form labels | Properly translated | +| SET-T04 | Button text | Check all buttons | Properly translated | +| SET-T05 | Error messages | Trigger validation errors | Errors translated | + +--- + +## 8. Analytics Page Tests + +### 8.1 Analytics Display +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| ANA-001 | Analytics page | Navigate to `/admin/analytics` | Dashboard displayed | +| ANA-002 | Page views card | Check page views | Count and chart shown | +| ANA-003 | Unique visitors | Check visitors card | Count and chart shown | +| ANA-004 | Downloads card | Check downloads | Count shown | +| ANA-005 | Time range filter | Select 7/30/90 days | Data updates | +| ANA-006 | Top pages list | Check top pages section | Pages with view counts | +| ANA-007 | Device breakdown | Check device section | Desktop/mobile/tablet % | +| ANA-008 | Storage usage | Check storage section | Used/available/photos | +| ANA-009 | Refresh data | Click refresh button | Data reloads | + +### 8.2 Translation Check - Analytics +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| ANA-T01 | Analytics - English | Check all labels | No translation keys | +| ANA-T02 | Analytics - German | Switch to German | All text translated | + +--- + +## 9. Email Settings Tests + +### 9.1 SMTP Configuration +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EML-001 | Email page | Navigate to `/admin/email` | SMTP settings shown | +| EML-002 | SMTP host | Enter host value | Field accepts input | +| EML-003 | SMTP port | Enter port number | Field accepts number | +| EML-004 | Security type | Select TLS/SSL | Selection saved | +| EML-005 | Username | Enter username | Field accepts input | +| EML-006 | Password | Enter password | Field masked | +| EML-007 | Show password | Click show button | Password visible | +| EML-008 | From email | Enter from address | Field accepts email | +| EML-009 | From name | Enter from name | Field accepts input | +| EML-010 | Save settings | Click save | Settings persisted | +| EML-011 | Ignore SSL toggle | Toggle certificate check | Setting saved | + +### 9.2 Test Email +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EML-020 | Test email input | Enter test email address | Field accepts email | +| EML-021 | Send test email | Click send button | Email sent, success message | +| EML-022 | Test email failure | Wrong SMTP config | Error message shown | + +### 9.3 Email Templates +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EML-030 | Templates tab | Click "Email Templates" | Template list shown | +| EML-031 | Template list | Check all templates | 10 templates listed | +| EML-032 | Select template | Click template | Editor loads template | +| EML-033 | Edit subject | Modify subject line | Subject updated | +| EML-034 | Edit body | Modify HTML body | Body updated | +| EML-035 | Language switch | Click English/German | Language version loaded | +| EML-036 | Preview template | Click preview | Preview rendered | +| EML-037 | Save template | Click save | Template saved | +| EML-038 | Variables display | Check variables section | Available variables listed | + +### 9.4 Translation Check - Email +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| EML-T01 | SMTP tab - English | Check all labels | No translation keys | +| EML-T02 | Templates tab - English | Check all labels | No translation keys | +| EML-T03 | Both tabs - German | Switch to German | All text translated | + +--- + +## 10. Branding Tests + +### 10.1 Company Information +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-001 | Branding page | Navigate to `/admin/branding` | All sections shown | +| BRD-002 | Company name | Enter company name | Field accepts input | +| BRD-003 | Company tagline | Enter tagline | Field accepts input | +| BRD-004 | Support email | Enter email | Field accepts email | +| BRD-005 | Footer text | Enter footer text | Field accepts input | +| BRD-006 | Upload favicon | Upload favicon file | Favicon saved | +| BRD-007 | Upload logo | Upload logo file | Logo displayed | +| BRD-008 | Remove logo | Click remove button | Logo removed | +| BRD-009 | Logo size | Select size option | Size applied | +| BRD-010 | Logo position | Select left/center/right | Position saved | +| BRD-011 | Display mode | Select logo/name/both | Mode applied | +| BRD-012 | Header logo toggle | Toggle show in header | Setting saved | +| BRD-013 | Hero logo toggle | Toggle show in hero | Setting saved | +| BRD-014 | White label | Toggle hide PicPeak | Branding hidden | +| BRD-015 | Watermarks | Toggle watermarks | Setting saved | + +### 10.2 Theme Presets +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-020 | Classic Grid preset | Select Classic Grid | Theme applied | +| BRD-021 | Elegant Wedding preset | Select Elegant Wedding | Theme applied | +| BRD-022 | Modern Masonry preset | Select Modern Masonry | Theme applied | +| BRD-023 | Birthday preset | Select Birthday | Theme applied | +| BRD-024 | Corporate preset | Select Corporate | Theme applied | +| BRD-025 | Artistic preset | Select Artistic | Theme applied | + +### 10.3 Gallery Layout +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-030 | Grid layout | Select grid | Layout applied | +| BRD-031 | Masonry layout | Select masonry | Layout applied | +| BRD-032 | Carousel layout | Select carousel | Layout applied | +| BRD-033 | Timeline layout | Select timeline | Layout applied | +| BRD-034 | Hero layout | Select hero | Layout applied | +| BRD-035 | Mosaic layout | Select mosaic | Layout applied | +| BRD-036 | Photo spacing | Change spacing | Setting applied | +| BRD-037 | Photo animation | Change animation | Setting applied | + +### 10.4 Colors and Typography +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-040 | Primary color | Change primary color | Color picker works, color applied | +| BRD-041 | Accent color | Change accent color | Color applied | +| BRD-042 | Background color | Change background | Color applied | +| BRD-043 | Text color | Change text color | Color applied | +| BRD-044 | Body font | Select body font | Font applied | +| BRD-045 | Heading font | Select heading font | Font applied | +| BRD-046 | Font size | Select font size | Size applied | +| BRD-047 | Border radius | Select radius | Radius applied | +| BRD-048 | Shadow style | Select shadow | Shadow applied | +| BRD-049 | Background pattern | Select pattern | Pattern applied | + +### 10.5 Live Preview +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-050 | Preview display | Check live preview section | Preview shown | +| BRD-051 | Preview updates | Change settings | Preview updates | +| BRD-052 | Live preview toggle | Toggle live preview | Immediate updates | +| BRD-053 | Save changes | Click save | All settings persisted | +| BRD-054 | Preview button | Click preview button | Full preview opens | + +### 10.6 Custom CSS Instructions Panel +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-060 | Instructions toggle | Click "How to use Custom CSS" | Panel expands/collapses | +| BRD-061 | CSS variables section | Check variables section | Theme variables displayed with current values | +| BRD-062 | Gallery layouts section | Check layouts section | CSS selectors documented | +| BRD-063 | Glassmorphism example | Check glass effect section | Example CSS shown | +| BRD-064 | Tip section | Check tip box | Links to CSS Templates | +| BRD-065 | Panel closed by default | Load page | Instructions panel collapsed | + +### 10.7 Typography Row Layout +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-070 | Font size & border radius | Check first row | 2 items per row, fully visible | +| BRD-071 | Shadow & background | Check second row | 2 items per row, fully visible | +| BRD-072 | Dropdown values visible | Check all dropdowns | Full text visible (not truncated) | +| BRD-073 | Mobile responsive | Check on mobile viewport | Stacks to 1 column properly | + +### 10.8 Translation Check - Branding +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BRD-T01 | Branding - English | Check all labels | No translation keys | +| BRD-T02 | Branding - German | Switch to German | All text translated | + +--- + +## 11. Backup & Restore Tests + +### 11.1 Backup Dashboard +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BAK-001 | Backup page | Navigate to `/admin/backup` | Dashboard shown | +| BAK-002 | Backup health | Check health indicator | Status displayed | +| BAK-003 | Stats cards | Check total/size/duration | Values displayed | +| BAK-004 | Backup coverage | Check coverage section | DB/Photos/Archives status | +| BAK-005 | Storage destination | Check destination info | Path and retention shown | +| BAK-006 | Run backup now | Click run backup | Backup process starts | + +### 11.2 Backup Configuration +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BAK-010 | Configuration tab | Click Configuration | Settings form shown | +| BAK-011 | Enable backups | Toggle enable | Setting saved | +| BAK-012 | Backup schedule | Set schedule | Schedule saved | +| BAK-013 | Include database | Toggle database | Setting saved | +| BAK-014 | Include photos | Toggle photos | Setting saved | +| BAK-015 | Retention period | Set retention days | Setting saved | +| BAK-016 | Storage path | Set backup path | Path saved | +| BAK-017 | S3 configuration | Configure S3 | Settings saved | + +### 11.3 Backup History +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BAK-020 | History tab | Click Backup History | History list shown | +| BAK-021 | Backup list | Check backup entries | Date, size, status shown | +| BAK-022 | Download backup | Click download | Backup file downloads | +| BAK-023 | Delete backup | Click delete | Confirmation, backup removed | + +### 11.4 Restore +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BAK-030 | Restore tab | Click Restore | Restore options shown | +| BAK-031 | Select backup | Choose backup to restore | Backup selected | +| BAK-032 | Upload backup | Upload backup file | File accepted | +| BAK-033 | Restore backup | Click restore | Confirmation, restore starts | + +### 11.5 Translation Check - Backup +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| BAK-T01 | All tabs - English | Check all tabs | No translation keys | +| BAK-T02 | All tabs - German | Switch to German | All text translated | + +--- + +## 12. CMS Pages Tests + +### 12.1 Public Landing Page +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| CMS-001 | CMS page | Navigate to `/admin/cms` | Page displayed | +| CMS-002 | Landing toggle | Toggle public landing | Setting saved | +| CMS-003 | Landing HTML | Edit HTML content | Content saved | +| CMS-004 | Landing CSS | Edit CSS content | CSS saved | +| CMS-005 | Save public site | Click save | Content published | +| CMS-006 | Reset to default | Click reset | Default content restored | +| CMS-007 | Live preview | Check preview (when enabled) | Preview shows content | + +### 12.2 Legal Pages +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| CMS-010 | Privacy Policy | Select Privacy Policy | Editor loads content | +| CMS-011 | Legal Notice | Select Legal Notice | Editor loads content | +| CMS-012 | Edit title | Change page title | Title updated | +| CMS-013 | Edit content | Use markdown editor | Content updated | +| CMS-014 | English version | Click English tab | English content loaded | +| CMS-015 | German version | Click German tab | German content loaded | +| CMS-016 | Save page | Click save | Content saved | +| CMS-017 | Preview links | Click preview link | Page opens in new tab | + +### 12.3 Rich Text Editor +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| CMS-020 | Heading buttons | Click H1-H6 buttons | Headings applied | +| CMS-021 | Bold text | Click bold | Text bolded | +| CMS-022 | Italic text | Click italic | Text italicized | +| CMS-023 | Code formatting | Click code | Code formatted | +| CMS-024 | Lists | Click bullet/number list | Lists created | +| CMS-025 | Blockquote | Click quote | Quote formatted | +| CMS-026 | Link | Click link button | Link added | +| CMS-027 | Alignment | Click align buttons | Text aligned | +| CMS-028 | Undo/Redo | Click undo/redo | Changes undone/redone | +| CMS-029 | Fullscreen | Click fullscreen | Editor expands | + +### 12.4 Translation Check - CMS +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| CMS-T01 | CMS page - English | Check all labels | No translation keys | +| CMS-T02 | CMS page - German | Switch to German | All text translated | + +--- + +## 13. Cross-Cutting Tests + +### 13.1 Translation Button (All Pages) +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| TRANS-001 | Language button visibility | Check header on all pages | Language button always visible | +| TRANS-002 | Language dropdown | Click language button | Dropdown shows EN/DE options | +| TRANS-003 | Switch to German | Select German | UI switches to German | +| TRANS-004 | Switch to English | Select English | UI switches to English | +| TRANS-005 | Language persistence | Change language, refresh | Language persists | + +### 13.2 Translation Completeness Check +**Execute on every page:** + +| Page | Test Steps | Check Points | +|------|------------|--------------| +| Login | Load page in EN and DE | All labels, buttons, errors translated | +| Dashboard | Load page in EN and DE | All cards, labels, activity items | +| Events List | Load page in EN and DE | Headers, filters, table columns, actions | +| Event Details | All tabs in EN and DE | All sections, buttons, labels | +| Event Create/Edit | Form in EN and DE | All field labels, placeholders, errors | +| Gallery (Public) | Load in EN and DE | All buttons, filters, messages | +| Archives | Load in EN and DE | Table, filters, messages | +| Settings (all tabs) | Each tab in EN and DE | All form labels, descriptions, buttons | +| Analytics | Load in EN and DE | All cards, sections, labels | +| Email Settings | Both tabs in EN and DE | All form fields, template names | +| Branding | Load in EN and DE | All sections, options, labels | +| Backup | All tabs in EN and DE | All cards, buttons, status messages | +| CMS | Load in EN and DE | All editor labels, page names | + +### 13.3 Responsive Design +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| RESP-001 | Desktop view | Set viewport 1920x1080 | Full layout displayed | +| RESP-002 | Tablet view | Set viewport 768x1024 | Responsive layout | +| RESP-003 | Mobile view | Set viewport 375x667 | Mobile layout, hamburger menu | +| RESP-004 | Sidebar collapse | Resize window | Sidebar collapses appropriately | +| RESP-005 | Table responsiveness | Check tables on mobile | Tables scroll or stack | + +### 13.4 Error Handling +| Test ID | Test Case | Steps | Expected Result | +|---------|-----------|-------|-----------------| +| ERR-001 | Network error | Disable network, perform action | Error message shown | +| ERR-002 | 404 page | Navigate to invalid route | 404 page displayed | +| ERR-003 | API error | Trigger API error | User-friendly error message | +| ERR-004 | Form validation | Submit invalid forms | Field-level errors shown | +| ERR-005 | Session expired | Let session expire | Redirect to login | + +--- + +## 14. Console Error Monitoring + +### 14.1 Error Check Procedure +Execute after each major test section: + +```javascript +// Using Playwright MCP +browser_console_messages({ level: "error" }) + +// Using Chrome DevTools MCP +list_console_messages({ types: ["error"] }) +``` + +### 14.2 Acceptable vs Critical Errors + +**Acceptable (informational):** +- React DevTools suggestions +- React Router future flag warnings +- Third-party library deprecation warnings + +**Critical (must fix):** +- Uncaught exceptions +- Network request failures (4xx, 5xx) +- React rendering errors +- TypeScript/JavaScript runtime errors +- Failed to fetch errors +- CORS errors + +### 14.3 Error Documentation Template +``` +| Page | Error Message | Severity | Status | +|------|---------------|----------|--------| +| [page] | [error text] | Critical/Warning | Open/Fixed | +``` + +--- + +## 15. Network Request Monitoring + +### 15.1 API Health Check +```javascript +// Using Chrome DevTools MCP +list_network_requests({ resourceTypes: ["fetch", "xhr"] }) +``` + +### 15.2 Expected API Endpoints + +| Endpoint Pattern | Method | Expected Status | +|------------------|--------|-----------------| +| `/api/admin/auth/*` | POST | 200/401 | +| `/api/admin/events` | GET | 200 | +| `/api/admin/events/*` | GET/PUT/DELETE | 200/404 | +| `/api/admin/settings` | GET/PUT | 200 | +| `/api/admin/photos/*` | GET/POST/DELETE | 200 | +| `/api/gallery/*` | GET | 200/401 | +| `/api/admin/backup/*` | GET/POST | 200 | + +### 15.3 Failed Request Documentation +``` +| Endpoint | Method | Status | Error | Impact | +|----------|--------|--------|-------|--------| +| [url] | [method] | [status] | [error] | [impact] | +``` + +--- + +## 16. Backend Service Log Monitoring + +### 16.1 Log Check Command +```bash +# Check backend logs for errors +docker logs picpeak-backend 2>&1 | grep -i error + +# Or if running locally +tail -f backend/logs/error.log +``` + +### 16.2 Log Patterns to Watch + +| Pattern | Severity | Action | +|---------|----------|--------| +| `ERROR` | High | Investigate immediately | +| `WARN` | Medium | Document and monitor | +| `UnhandledPromiseRejection` | Critical | Fix immediately | +| `ECONNREFUSED` | High | Check service connectivity | +| `JWT` errors | High | Check authentication | +| `Database` errors | Critical | Check DB connection | + +### 16.3 Service Health Checks +```bash +# Check all services +curl http://localhost:3001/api/health + +# Expected response +{ "status": "healthy", "services": { "database": "up", "redis": "up" } } +``` + +--- + +## 17. State-Based Testing + +### 17.1 Event States +| State | Conditions | Expected Behavior | +|-------|------------|-------------------| +| Active | Created, not expired | Full access, all features | +| Expiring | Within 7 days of expiry | Warning indicators shown | +| Expired | Past expiry date | Limited access, archive prompt | +| Archived | Manually archived | ZIP available, no gallery access | + +### 17.2 Settings State Combinations + +#### Required Fields Matrix +| Customer Email | Customer Name | Admin Email | Welcome Msg | Test Scenario | +|----------------|---------------|-------------|-------------|---------------| +| Required | Required | Required | Required | All fields mandatory | +| Required | Optional | Optional | Optional | Only email required | +| Optional | Required | Optional | Optional | Only name required | +| Optional | Optional | Optional | Optional | All fields optional | + +**Test each combination:** +1. Configure settings +2. Attempt to create event with missing field +3. Verify validation works correctly + +### 17.3 Theme State Testing +| Theme Setting | Layout | Test Points | +|---------------|--------|-------------| +| Classic Grid | grid | Uniform photo sizes | +| Elegant Wedding | hero | Hero image prominent | +| Modern Masonry | masonry | Varied photo heights | +| Birthday | carousel | Slideshow works | +| Corporate | timeline | Date grouping | +| Artistic | mosaic | Mixed sizes | + +--- + +## 18. Test Execution Checklist + +### Pre-Test Setup +- [ ] Start frontend dev server +- [ ] Start backend server +- [ ] Verify database connection +- [ ] Clear browser cache/cookies +- [ ] Set up MCP browser connection + +### Test Execution Order +1. [ ] Authentication Tests (Section 1) +2. [ ] Dashboard Tests (Section 2) +3. [ ] Events Management Tests (Section 3) +4. [ ] Event Details Tests (Section 4) +5. [ ] Gallery Tests (Section 5) +6. [ ] Archives Tests (Section 6) +7. [ ] Settings Tests (Section 7) +8. [ ] Analytics Tests (Section 8) +9. [ ] Email Settings Tests (Section 9) +10. [ ] Branding Tests (Section 10) +11. [ ] Backup Tests (Section 11) +12. [ ] CMS Tests (Section 12) +13. [ ] Cross-Cutting Tests (Section 13) +14. [ ] Console Error Review (Section 14) +15. [ ] Network Request Review (Section 15) +16. [ ] Backend Log Review (Section 16) +17. [ ] State-Based Tests (Section 17) + +### Post-Test Actions +- [ ] Document all failures +- [ ] Capture screenshots of issues +- [ ] Log console errors +- [ ] Note network failures +- [ ] Update test status + +--- + +## 19. Test Result Summary Template + +```markdown +## Test Results - [Date] + +### Environment +- Frontend Version: x.x.x +- Backend Version: x.x.x +- Browser: Chrome/Firefox/Safari +- Tester: [Name] + +### Summary +| Category | Total | Passed | Failed | Blocked | +|----------|-------|--------|--------|---------| +| Auth | X | X | X | X | +| Dashboard | X | X | X | X | +| ... | ... | ... | ... | ... | + +### Critical Issues +1. [Issue description] +2. [Issue description] + +### Translation Gaps +| Page | Missing Translation | Language | +|------|---------------------|----------| +| ... | ... | ... | + +### Console Errors +| Page | Error | Severity | +|------|-------|----------| +| ... | ... | ... | + +### Recommendations +1. [Recommendation] +2. [Recommendation] +``` + +--- + +## Appendix A: MCP Tool Reference + +### Navigation +```javascript +mcp__playwright__browser_navigate({ url: "http://localhost:5173/admin/dashboard" }) +``` + +### Take Snapshot +```javascript +mcp__playwright__browser_snapshot() +``` + +### Click Element +```javascript +mcp__playwright__browser_click({ element: "Description", ref: "e123" }) +``` + +### Fill Form +```javascript +mcp__playwright__browser_type({ element: "Input field", ref: "e456", text: "value" }) +``` + +### Check Console +```javascript +mcp__playwright__browser_console_messages({ level: "error" }) +``` + +### Check Network +```javascript +mcp__chrome-devtools__list_network_requests({ resourceTypes: ["fetch", "xhr"] }) +``` + +--- + +## Appendix B: Common Test Data + +### Admin Credentials +- Username: `admin` +- Password: `admin` (or configured password) + +### Test Event Data +```json +{ + "name": "Test Event", + "date": "2026-01-15", + "type": "wedding", + "password": "test123", + "customerName": "John Doe", + "customerEmail": "john@example.com", + "expiryDate": "2026-02-15" +} +``` + +### Test Gallery Password +- Default: `test123` or as configured per event + +--- + +*Last Updated: January 2026* +*Version: 1.0* diff --git a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx index 3ea4b9a9..ba14e9f0 100644 --- a/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx +++ b/frontend/src/components/admin/ThemeCustomizerEnhanced.tsx @@ -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 = ( const [localTheme, setLocalTheme] = useState(value); const [selectedPreset, setSelectedPreset] = useState(presetName); const [customCss, setCustomCss] = useState(value.customCss || ''); + const [showCssInstructions, setShowCssInstructions] = useState(false); // const logoInputRef = useRef(null); useEffect(() => { @@ -494,7 +495,8 @@ export const ThemeCustomizerEnhanced: React.FC = ( -

+ {/* Row 1: Font Size & Border Radius */} +
+
+ {/* Row 2: Shadow Style & Background Pattern */} +