feat: Add CSS template system with custom gallery styling support
## Changes ### CSS Template System - Added CSS class hooks to gallery components for custom template targeting - Gallery sidebar, header, footer, and photo cards can now be styled via CSS templates - CSS variables on :root allow themes to override colors, effects, and spacing ### Gallery Component CSS Classes Added - `.gallery-page` - Main gallery container - `.gallery-header` - Top header bar - `.gallery-sidebar` - Filter/download sidebar - `.gallery-sidebar-header`, `.gallery-sidebar-title`, `.gallery-sidebar-close` - `.gallery-sidebar-content`, `.gallery-sidebar-section` - `.gallery-sidebar-search-input`, `.gallery-sidebar-search-icon` - `.gallery-sidebar-backdrop` - Mobile overlay - `.gallery-btn`, `.gallery-btn-download` - Sidebar buttons - `.gallery-footer` - Footer section - `.photo-card`, `.photo-grid` - Photo display elements ### CSS Templates (Database) - Elegant Dark (id=1): Dark navy theme with light text and red accents - Liquid Glass Light (id=2): iOS 26 frosted glass effect with gradient background ### Bug Fixes - Fixed CSS variables not inheriting (moved from .gallery-page to :root) - Fixed sidebar position breaking layout (removed position: relative override) - Fixed Elegant Dark sidebar text visibility (white on white issue) ### Other Changes - Settings page refactoring and cleanup - i18n locale updates for new gallery features - Vite proxy port configuration fix - Admin auth route improvements - CSS templates service updates
This commit is contained in:
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -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;
|
||||||
+6
-14
@@ -25,6 +25,7 @@ const { startBackupService } = require('./src/services/backupService');
|
|||||||
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
const { startScheduledBackups } = require('./src/services/databaseBackup');
|
||||||
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
const { maintenanceMiddleware } = require('./src/middleware/maintenance');
|
||||||
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
const { sessionTimeoutMiddleware } = require('./src/middleware/sessionTimeout');
|
||||||
|
const { errorHandler, notFoundHandler } = require('./src/middleware/errorHandler');
|
||||||
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
const { createRateLimiter, createAuthRateLimiter } = require('./src/services/rateLimitService');
|
||||||
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
const { getPublicSitePayload } = require('./src/services/publicSiteService');
|
||||||
const cookieParser = require('cookie-parser');
|
const cookieParser = require('cookie-parser');
|
||||||
@@ -471,20 +472,11 @@ try {
|
|||||||
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
logger.warn('Failed to enable frontend static serving', { error: e.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Error handling middleware
|
// 404 handler for undefined API routes
|
||||||
app.use((err, req, res, next) => {
|
app.use('/api', notFoundHandler);
|
||||||
console.error('EXPRESS ERROR HANDLER:', err);
|
|
||||||
console.error('Error stack:', err.stack);
|
// Global error handler (must be last)
|
||||||
console.error('Request URL:', req.url);
|
app.use(errorHandler);
|
||||||
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 });
|
|
||||||
});
|
|
||||||
|
|
||||||
// Initialize services
|
// Initialize services
|
||||||
async function startServer() {
|
async function startServer() {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
+121
-206
@@ -1,31 +1,29 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const bcrypt = require('bcrypt');
|
const bcrypt = require('bcrypt');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body } = require('express-validator');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { endSession } = require('../middleware/sessionTimeout');
|
const { endSession } = require('../middleware/sessionTimeout');
|
||||||
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
const { validatePasswordStrength } = require('../utils/passwordGenerator');
|
||||||
|
const { handleAsync, validateRequest, successResponse } = require('../utils/routeHelpers');
|
||||||
|
const { NotFoundError, ConflictError, ValidationError } = require('../utils/errors');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Change password
|
// Get admin profile
|
||||||
router.get('/profile', adminAuth, async (req, res) => {
|
router.get('/profile', adminAuth, handleAsync(async (req, res) => {
|
||||||
try {
|
const admin = await db('admin_users')
|
||||||
const admin = await db('admin_users')
|
.where('id', req.admin.id)
|
||||||
.where('id', req.admin.id)
|
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
|
||||||
.select('id', 'username', 'email', 'last_login', 'last_login_ip', 'created_at', 'updated_at', 'must_change_password as mustChangePassword')
|
.first();
|
||||||
.first();
|
|
||||||
|
|
||||||
if (!admin) {
|
if (!admin) {
|
||||||
return res.status(404).json({ error: 'Admin user not found' });
|
throw new NotFoundError('Admin user');
|
||||||
}
|
|
||||||
|
|
||||||
res.json(admin);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Admin profile fetch error:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to fetch admin profile' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
res.json(admin);
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Update admin profile
|
||||||
router.put('/profile', [
|
router.put('/profile', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
body('username')
|
body('username')
|
||||||
@@ -37,212 +35,129 @@ router.put('/profile', [
|
|||||||
.isEmail()
|
.isEmail()
|
||||||
.withMessage('A valid email address is required')
|
.withMessage('A valid email address is required')
|
||||||
.normalizeEmail()
|
.normalizeEmail()
|
||||||
], async (req, res) => {
|
], handleAsync(async (req, res) => {
|
||||||
try {
|
validateRequest(req);
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
return res.status(400).json({ errors: errors.array() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = req.body.username.trim();
|
const username = req.body.username.trim();
|
||||||
const email = req.body.email.trim().toLowerCase();
|
const email = req.body.email.trim().toLowerCase();
|
||||||
const adminId = req.admin.id;
|
const adminId = req.admin.id;
|
||||||
|
|
||||||
const existingUsername = await db('admin_users')
|
// Check for username conflict
|
||||||
.where('username', username)
|
const existingUsername = await db('admin_users')
|
||||||
.whereNot('id', adminId)
|
.where('username', username)
|
||||||
.first();
|
.whereNot('id', adminId)
|
||||||
|
.first();
|
||||||
|
|
||||||
if (existingUsername) {
|
if (existingUsername) {
|
||||||
return res.status(409).json({ error: 'Username is already in use' });
|
throw new ConflictError('Username is already in use', 'username');
|
||||||
}
|
|
||||||
|
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
|
// 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', [
|
router.post('/change-password', [
|
||||||
adminAuth,
|
adminAuth,
|
||||||
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
body('currentPassword').notEmpty().withMessage('Current password is required'),
|
||||||
body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters')
|
body('newPassword').isLength({ min: 12 }).withMessage('New password must be at least 12 characters')
|
||||||
], async (req, res) => {
|
], handleAsync(async (req, res) => {
|
||||||
try {
|
validateRequest(req);
|
||||||
const errors = validationResult(req);
|
|
||||||
if (!errors.isEmpty()) {
|
|
||||||
return res.status(400).json({ errors: errors.array() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
const userId = req.admin.id; // Changed from req.user.id to req.admin.id
|
const userId = req.admin.id;
|
||||||
|
|
||||||
// Validate new password strength
|
// Validate new password strength
|
||||||
const passwordValidation = validatePasswordStrength(newPassword);
|
const passwordValidation = validatePasswordStrength(newPassword);
|
||||||
if (!passwordValidation.isValid) {
|
if (!passwordValidation.isValid) {
|
||||||
return res.status(400).json({
|
throw new ValidationError('Password does not meet security requirements', passwordValidation.messages);
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
|
||||||
// Update admin profile
|
// Get user from database
|
||||||
router.put('/profile', [
|
const user = await db('admin_users')
|
||||||
adminAuth,
|
.where('id', userId)
|
||||||
body('username').trim().notEmpty().withMessage('Username is required'),
|
.first();
|
||||||
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() });
|
|
||||||
}
|
|
||||||
|
|
||||||
const { username, email } = req.body;
|
if (!user) {
|
||||||
const userId = req.admin.id;
|
throw new NotFoundError('User');
|
||||||
|
|
||||||
// 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' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 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
|
// Logout
|
||||||
router.post('/logout', adminAuth, async (req, res) => {
|
router.post('/logout', adminAuth, handleAsync(async (req, res) => {
|
||||||
try {
|
// Get token from header
|
||||||
// Get token from header
|
const token = req.headers.authorization?.split(' ')[1];
|
||||||
const token = req.headers.authorization?.split(' ')[1];
|
if (token) {
|
||||||
if (token) {
|
// End the session
|
||||||
// End the session
|
endSession(token);
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
// 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;
|
module.exports = router;
|
||||||
|
|||||||
@@ -141,7 +141,8 @@ router.post('/', adminAuth, [
|
|||||||
body('allow_downloads').optional().isBoolean(),
|
body('allow_downloads').optional().isBoolean(),
|
||||||
body('disable_right_click').optional().isBoolean(),
|
body('disable_right_click').optional().isBoolean(),
|
||||||
body('watermark_downloads').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) => {
|
], async (req, res) => {
|
||||||
try {
|
try {
|
||||||
logger.debug('Create event request body', { body: req.body });
|
logger.debug('Create event request body', { body: req.body });
|
||||||
@@ -178,7 +179,9 @@ router.post('/', adminAuth, [
|
|||||||
allow_favorites = true,
|
allow_favorites = true,
|
||||||
require_name_email = false,
|
require_name_email = false,
|
||||||
moderate_comments = true,
|
moderate_comments = true,
|
||||||
show_feedback_to_guests = true
|
show_feedback_to_guests = true,
|
||||||
|
// CSS Template
|
||||||
|
css_template_id = null
|
||||||
} = req.body;
|
} = req.body;
|
||||||
|
|
||||||
const customerName = getCustomerNameFromPayload(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),
|
disable_right_click: formatBoolean(disable_right_click !== undefined ? disable_right_click : false),
|
||||||
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
watermark_downloads: formatBoolean(watermark_downloads !== undefined ? watermark_downloads : false),
|
||||||
watermark_text,
|
watermark_text,
|
||||||
require_password: formatBoolean(requirePassword)
|
require_password: formatBoolean(requirePassword),
|
||||||
|
css_template_id: css_template_id || null
|
||||||
}).returning('id');
|
}).returning('id');
|
||||||
|
|
||||||
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
// Handle both PostgreSQL (returns array of objects) and SQLite (returns array of IDs)
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ const secureImageService = require('../services/secureImageService');
|
|||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
const { getEventShareToken, resolveShareIdentifier, buildShareLinkVariants } = require('../services/shareLinkService');
|
||||||
|
const { handleAsync } = require('../utils/routeHelpers');
|
||||||
|
const { NotFoundError } = require('../utils/errors');
|
||||||
|
|
||||||
// Get storage path from environment or default
|
// Get storage path from environment or default
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../storage');
|
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
|
// Resolve gallery identifier (slug or token) to canonical data
|
||||||
router.get('/resolve/:identifier', async (req, res) => {
|
router.get('/resolve/:identifier', handleAsync(async (req, res) => {
|
||||||
try {
|
const { identifier } = req.params;
|
||||||
const { identifier } = req.params;
|
let result = await resolveShareIdentifier(identifier);
|
||||||
let result = await resolveShareIdentifier(identifier);
|
|
||||||
|
|
||||||
// If not found, check for redirect
|
// If not found, check for redirect
|
||||||
if (!result) {
|
if (!result) {
|
||||||
const newSlug = await checkSlugRedirect(identifier);
|
const newSlug = await checkSlugRedirect(identifier);
|
||||||
if (newSlug) {
|
if (newSlug) {
|
||||||
return res.status(301).json({
|
return res.status(301).json({
|
||||||
redirect: true,
|
redirect: true,
|
||||||
newSlug,
|
newSlug,
|
||||||
message: 'Gallery has been renamed'
|
message: 'Gallery has been renamed'
|
||||||
});
|
});
|
||||||
}
|
|
||||||
return res.status(404).json({ error: 'Gallery not found' });
|
|
||||||
}
|
}
|
||||||
|
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
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
logger.error('Error resolving gallery identifier:', error);
|
|
||||||
res.status(500).json({ error: 'Failed to resolve gallery link' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
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
|
// Verify share token
|
||||||
router.get('/:slug/verify-token/:token', async (req, res) => {
|
router.get('/:slug/verify-token/:token', handleAsync(async (req, res) => {
|
||||||
try {
|
const { slug, token } = req.params;
|
||||||
const { slug, token } = req.params;
|
|
||||||
|
const event = await db('events')
|
||||||
const event = await db('events')
|
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
||||||
.where({ slug, is_active: formatBoolean(true), is_archived: formatBoolean(false) })
|
.select('id', 'share_link', 'share_token')
|
||||||
.select('id', 'share_link', 'share_token')
|
.first();
|
||||||
.first();
|
|
||||||
|
if (!event) {
|
||||||
if (!event) {
|
throw new NotFoundError('Gallery');
|
||||||
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' });
|
|
||||||
}
|
}
|
||||||
});
|
|
||||||
|
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)
|
// Get gallery info (with optional token verification)
|
||||||
router.get('/:slug/info', async (req, res) => {
|
router.get('/:slug/info', async (req, res) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -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
|
||||||
|
};
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
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 { Button, Card, Input } from '../common';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
|
import { ThemeConfig, GALLERY_THEME_PRESETS, GalleryLayoutType } from '../../types/theme.types';
|
||||||
// import { settingsService } from '../../services/settings.service';
|
// import { settingsService } from '../../services/settings.service';
|
||||||
@@ -44,6 +44,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
const [localTheme, setLocalTheme] = useState<ThemeConfig>(value);
|
||||||
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
const [selectedPreset, setSelectedPreset] = useState(presetName);
|
||||||
const [customCss, setCustomCss] = useState(value.customCss || '');
|
const [customCss, setCustomCss] = useState(value.customCss || '');
|
||||||
|
const [showCssInstructions, setShowCssInstructions] = useState(false);
|
||||||
// const logoInputRef = useRef<HTMLInputElement>(null);
|
// const logoInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -494,7 +495,8 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
{/* Row 1: Font Size & Border Radius */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
{t('branding.fontSize')}
|
{t('branding.fontSize')}
|
||||||
@@ -525,7 +527,10 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
|
<option value="lg">{t('branding.borderRadiusOptions.large')}</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Row 2: Shadow Style & Background Pattern */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
{t('branding.shadowStyle')}
|
{t('branding.shadowStyle')}
|
||||||
@@ -563,7 +568,93 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
|
|
||||||
{/* Custom CSS */}
|
{/* Custom CSS */}
|
||||||
<Card className="p-6">
|
<Card className="p-6">
|
||||||
<h3 className="text-lg font-semibold text-neutral-900 mb-4">{t('branding.customCSS')}</h3>
|
<h3 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||||
|
<Code className="w-5 h-5" />
|
||||||
|
{t('branding.customCSS')}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
{/* Collapsible Instructions Panel */}
|
||||||
|
<div className="mb-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowCssInstructions(!showCssInstructions)}
|
||||||
|
className="flex items-center gap-2 text-sm text-primary-600 hover:text-primary-700 font-medium"
|
||||||
|
>
|
||||||
|
<Info className="w-4 h-4" />
|
||||||
|
{t('branding.cssInstructions.title', 'How to use Custom CSS')}
|
||||||
|
<ChevronDown className={`w-4 h-4 transition-transform ${showCssInstructions ? 'rotate-180' : ''}`} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{showCssInstructions && (
|
||||||
|
<div className="mt-3 p-4 bg-neutral-50 rounded-lg border border-neutral-200 text-sm space-y-4">
|
||||||
|
{/* Available CSS Variables */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||||
|
{t('branding.cssInstructions.variables', 'Theme CSS Variables')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-neutral-600 mb-2">
|
||||||
|
{t('branding.cssInstructions.variablesDesc', 'Use these CSS variables to match your theme presets:')}
|
||||||
|
</p>
|
||||||
|
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||||
|
{`--primary-color: ${localTheme.primaryColor || '#5C8762'};
|
||||||
|
--accent-color: ${localTheme.accentColor || '#22c55e'};
|
||||||
|
--background-color: ${localTheme.backgroundColor || '#fafafa'};
|
||||||
|
--text-color: ${localTheme.textColor || '#171717'};
|
||||||
|
--font-family: ${localTheme.fontFamily || 'Inter, sans-serif'};
|
||||||
|
--heading-font: ${localTheme.headingFontFamily || localTheme.fontFamily || 'Inter, sans-serif'};`}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Custom Gallery Layouts */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||||
|
{t('branding.cssInstructions.layouts', 'Custom Gallery Layouts')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-neutral-600 mb-2">
|
||||||
|
{t('branding.cssInstructions.layoutsDesc', 'Target gallery elements with these selectors:')}
|
||||||
|
</p>
|
||||||
|
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||||
|
{`.gallery-container { /* Main gallery wrapper */ }
|
||||||
|
.gallery-grid { /* Photo grid container */ }
|
||||||
|
.gallery-item { /* Individual photo card */ }
|
||||||
|
.gallery-header { /* Header section */ }
|
||||||
|
.gallery-hero { /* Hero image area */ }
|
||||||
|
.photo-overlay { /* Photo hover overlay */ }
|
||||||
|
.photo-actions { /* Like/favorite buttons */ }`}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Glassmorphism Example */}
|
||||||
|
<div>
|
||||||
|
<h4 className="font-semibold text-neutral-900 mb-2">
|
||||||
|
{t('branding.cssInstructions.glassEffect', 'Glassmorphism Effect')}
|
||||||
|
</h4>
|
||||||
|
<p className="text-neutral-600 mb-2">
|
||||||
|
{t('branding.cssInstructions.glassEffectDesc', 'Create modern glass effects:')}
|
||||||
|
</p>
|
||||||
|
<code className="block bg-neutral-800 text-green-400 p-3 rounded text-xs overflow-x-auto">
|
||||||
|
{`.glass-panel {
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
backdrop-filter: blur(20px);
|
||||||
|
-webkit-backdrop-filter: blur(20px);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
||||||
|
border-radius: 16px;
|
||||||
|
}`}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tips */}
|
||||||
|
<div className="flex items-start gap-2 p-3 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<Info className="w-4 h-4 text-blue-600 flex-shrink-0 mt-0.5" />
|
||||||
|
<div className="text-blue-800 text-xs">
|
||||||
|
<strong>{t('branding.cssInstructions.tip', 'Tip')}:</strong>{' '}
|
||||||
|
{t('branding.cssInstructions.tipText', 'Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass.')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<textarea
|
<textarea
|
||||||
value={customCss}
|
value={customCss}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
@@ -575,7 +666,7 @@ export const ThemeCustomizerEnhanced: React.FC<ThemeCustomizerEnhancedProps> = (
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
placeholder="/* Add custom CSS here */"
|
placeholder="/* Add custom CSS here */"
|
||||||
className="w-full h-32 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg"
|
className="w-full h-40 px-3 py-2 font-mono text-sm border border-neutral-300 rounded-lg bg-neutral-50"
|
||||||
/>
|
/>
|
||||||
<p className="mt-2 text-sm text-neutral-600">
|
<p className="mt-2 text-sm text-neutral-600">
|
||||||
{t('branding.customCSSHelp')}
|
{t('branding.customCSSHelp')}
|
||||||
|
|||||||
@@ -118,12 +118,12 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
const heroLogoSize = getLogoDimensions('hero');
|
const heroLogoSize = getLogoDimensions('hero');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-50">
|
<div className="gallery-page min-h-screen bg-neutral-50">
|
||||||
{/* Dynamic Favicon */}
|
{/* Dynamic Favicon */}
|
||||||
<DynamicFavicon />
|
<DynamicFavicon />
|
||||||
|
|
||||||
{/* Header structure */}
|
{/* Header structure */}
|
||||||
<header className={`bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
<header className={`gallery-header bg-white border-b border-neutral-200 sticky top-0 z-40 ${isNonGridLayout || theme.galleryLayout === 'hero' ? 'shadow-sm' : ''}`}>
|
||||||
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
{/* For non-grid layouts (excluding hero) - keep the current structure */}
|
||||||
{isNonGridLayout && (
|
{isNonGridLayout && (
|
||||||
<div className="bg-neutral-50 border-b border-neutral-200">
|
<div className="bg-neutral-50 border-b border-neutral-200">
|
||||||
@@ -145,12 +145,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
leftIcon={<Download className="w-4 h-4" />}
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
onClick={onDownloadAll}
|
onClick={onDownloadAll}
|
||||||
isLoading={isDownloading}
|
isLoading={isDownloading}
|
||||||
|
className="gallery-btn gallery-btn-download"
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||||
<span className="sm:hidden">{t('common.download')}</span>
|
<span className="sm:hidden">{t('common.download')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logout button */}
|
{/* Logout button */}
|
||||||
{showLogout && onLogout && (
|
{showLogout && onLogout && (
|
||||||
<Button
|
<Button
|
||||||
@@ -158,7 +159,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<LogOut className="w-4 h-4" />}
|
leftIcon={<LogOut className="w-4 h-4" />}
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="sm:min-w-0"
|
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -184,14 +185,14 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
|
|
||||||
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
{/* Logo - Show custom logo or fallback to PicPeak logo */}
|
||||||
{shouldShowLogo('header') && (
|
{shouldShowLogo('header') && (
|
||||||
<div className={`flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
<div className={`gallery-logo-wrapper flex-shrink-0 flex items-center gap-2 ${brandingSettings?.logo_position === 'center' ? 'flex-1' : ''} ${getLogoPositionClass()}`}>
|
||||||
<img
|
<img
|
||||||
src={brandingSettings?.logo_url ?
|
src={brandingSettings?.logo_url ?
|
||||||
buildResourceUrl(brandingSettings.logo_url) :
|
buildResourceUrl(brandingSettings.logo_url) :
|
||||||
'/picpeak-logo-transparent.png'
|
'/picpeak-logo-transparent.png'
|
||||||
}
|
}
|
||||||
alt={brandingSettings?.company_name || 'PicPeak'}
|
alt={brandingSettings?.company_name || 'PicPeak'}
|
||||||
className={`${headerLogoSize.className} w-auto object-contain`}
|
className={`gallery-logo ${headerLogoSize.className} w-auto object-contain`}
|
||||||
style={headerLogoSize.style}
|
style={headerLogoSize.style}
|
||||||
/>
|
/>
|
||||||
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
{shouldShowCompanyName() && brandingSettings?.company_name && (
|
||||||
@@ -253,13 +254,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
leftIcon={<Download className="w-4 h-4" />}
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
onClick={onDownloadAll}
|
onClick={onDownloadAll}
|
||||||
isLoading={isDownloading}
|
isLoading={isDownloading}
|
||||||
className="hidden sm:flex"
|
className="gallery-btn gallery-btn-download hidden sm:flex"
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||||
<span className="sm:hidden">{t('common.download')}</span>
|
<span className="sm:hidden">{t('common.download')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logout button */}
|
{/* Logout button */}
|
||||||
{showLogout && onLogout && (
|
{showLogout && onLogout && (
|
||||||
<Button
|
<Button
|
||||||
@@ -267,7 +268,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<LogOut className="w-4 h-4" />}
|
leftIcon={<LogOut className="w-4 h-4" />}
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="sm:min-w-0"
|
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -304,7 +305,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
{menuButton}
|
{menuButton}
|
||||||
{headerExtra}
|
{headerExtra}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right side - Action buttons */}
|
{/* Right side - Action buttons */}
|
||||||
<div className="flex items-center gap-3 flex-shrink-0">
|
<div className="flex items-center gap-3 flex-shrink-0">
|
||||||
{/* Download all button */}
|
{/* Download all button */}
|
||||||
@@ -315,12 +316,13 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
leftIcon={<Download className="w-4 h-4" />}
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
onClick={onDownloadAll}
|
onClick={onDownloadAll}
|
||||||
isLoading={isDownloading}
|
isLoading={isDownloading}
|
||||||
|
className="gallery-btn gallery-btn-download"
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
<span className="hidden sm:inline">{t('gallery.downloadAll')}</span>
|
||||||
<span className="sm:hidden">{t('common.download')}</span>
|
<span className="sm:hidden">{t('common.download')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Logout button */}
|
{/* Logout button */}
|
||||||
{showLogout && onLogout && (
|
{showLogout && onLogout && (
|
||||||
<Button
|
<Button
|
||||||
@@ -328,7 +330,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
size="sm"
|
size="sm"
|
||||||
leftIcon={<LogOut className="w-4 h-4" />}
|
leftIcon={<LogOut className="w-4 h-4" />}
|
||||||
onClick={onLogout}
|
onClick={onLogout}
|
||||||
className="sm:min-w-0"
|
className="gallery-btn gallery-btn-logout sm:min-w-0"
|
||||||
>
|
>
|
||||||
<span className="hidden sm:inline">{t('common.logout')}</span>
|
<span className="hidden sm:inline">{t('common.logout')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -341,8 +343,8 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
|
|
||||||
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
{/* Hero Header for non-grid layouts (excluding hero layout which has its own) */}
|
||||||
{isNonGridLayout && (
|
{isNonGridLayout && (
|
||||||
<div
|
<div
|
||||||
className="relative text-white overflow-hidden"
|
className="gallery-hero relative text-white overflow-hidden"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: theme.accentColor || '#22c55e',
|
backgroundColor: theme.accentColor || '#22c55e',
|
||||||
backgroundImage: theme.backgroundPattern !== 'none'
|
backgroundImage: theme.backgroundPattern !== 'none'
|
||||||
@@ -425,7 +427,7 @@ export const GalleryLayout: React.FC<GalleryLayoutProps> = ({
|
|||||||
<main className="container">{children}</main>
|
<main className="container">{children}</main>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
<footer className="gallery-footer mt-8 sm:mt-12 py-6 sm:py-8 border-t border-neutral-200">
|
||||||
<div className="container text-center px-4">
|
<div className="container text-center px-4">
|
||||||
{brandingSettings?.support_email && (
|
{brandingSettings?.support_email && (
|
||||||
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
|
<p className="text-xs sm:text-sm text-neutral-600 mb-2">
|
||||||
|
|||||||
@@ -110,8 +110,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<>
|
<>
|
||||||
{/* Backdrop for mobile */}
|
{/* Backdrop for mobile */}
|
||||||
{isMobile && isOpen && (
|
{isMobile && isOpen && (
|
||||||
<div
|
<div
|
||||||
className="fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
className="gallery-sidebar-backdrop fixed inset-0 bg-black bg-opacity-50 z-40 transition-opacity"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -120,17 +120,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<div
|
<div
|
||||||
ref={sidebarRef}
|
ref={sidebarRef}
|
||||||
className={`
|
className={`
|
||||||
fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
gallery-sidebar fixed top-0 left-0 h-full bg-white shadow-xl z-50 transition-transform duration-300 ease-in-out flex flex-col
|
||||||
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
${isMobile ? 'w-full max-w-sm' : 'w-80'}
|
||||||
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
${isOpen ? 'translate-x-0' : '-translate-x-full'}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-header flex items-center justify-between p-4 border-b border-neutral-200">
|
||||||
<h2 className="text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
<h2 className="gallery-sidebar-title text-lg font-semibold text-neutral-900">{t('gallery.filters')}</h2>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
className="gallery-sidebar-close p-2 hover:bg-neutral-100 rounded-lg transition-colors"
|
||||||
aria-label={t('common.close')}
|
aria-label={t('common.close')}
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5 text-neutral-600" />
|
<X className="w-5 h-5 text-neutral-600" />
|
||||||
@@ -138,7 +138,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Content */}
|
{/* Content */}
|
||||||
<div className="flex-1 overflow-y-auto">
|
<div className="gallery-sidebar-content flex-1 overflow-y-auto">
|
||||||
{/* Upload Section - Only show on mobile when uploads are allowed */}
|
{/* Upload Section - Only show on mobile when uploads are allowed */}
|
||||||
{isMobile && allowUploads && onUploadClick && (
|
{isMobile && allowUploads && onUploadClick && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="p-4 border-b border-neutral-200">
|
||||||
@@ -159,15 +159,15 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Search Section - Hidden for carousel layout */}
|
{/* Search Section - Hidden for carousel layout */}
|
||||||
{galleryLayout !== 'carousel' && (
|
{galleryLayout !== 'carousel' && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-search p-4 border-b border-neutral-200">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
<Search className="gallery-sidebar-search-icon absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-neutral-400" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={searchTerm}
|
value={searchTerm}
|
||||||
onChange={(e) => onSearchChange(e.target.value)}
|
onChange={(e) => onSearchChange(e.target.value)}
|
||||||
placeholder={t('gallery.searchPlaceholder')}
|
placeholder={t('gallery.searchPlaceholder')}
|
||||||
className="w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
className="gallery-sidebar-search-input w-full pl-10 pr-4 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,12 +175,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
|
{/* Download Section - Hidden if gallery is expired or downloads disabled */}
|
||||||
{allowDownloads && (
|
{allowDownloads && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-downloads p-4 border-b border-neutral-200">
|
||||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
{t('gallery.download')}
|
{t('gallery.download')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
@@ -188,7 +188,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
leftIcon={<Download className="w-4 h-4" />}
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
onClick={onDownloadAll}
|
onClick={onDownloadAll}
|
||||||
disabled={isDownloading || totalPhotos === 0}
|
disabled={isDownloading || totalPhotos === 0}
|
||||||
className="w-full"
|
className="gallery-btn gallery-btn-download w-full"
|
||||||
>
|
>
|
||||||
{t('gallery.downloadAll')} ({totalPhotos})
|
{t('gallery.downloadAll')} ({totalPhotos})
|
||||||
</Button>
|
</Button>
|
||||||
@@ -197,7 +197,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
variant={isSelectionMode ? 'secondary' : 'outline'}
|
variant={isSelectionMode ? 'secondary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onToggleSelectionMode}
|
onClick={onToggleSelectionMode}
|
||||||
className="w-full"
|
className="gallery-btn w-full"
|
||||||
>
|
>
|
||||||
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
{isSelectionMode ? t('gallery.cancelSelection') : t('gallery.selectPhotos')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -209,7 +209,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
leftIcon={<Download className="w-4 h-4" />}
|
leftIcon={<Download className="w-4 h-4" />}
|
||||||
onClick={onDownloadSelected}
|
onClick={onDownloadSelected}
|
||||||
disabled={isDownloading}
|
disabled={isDownloading}
|
||||||
className="w-full"
|
className="gallery-btn gallery-btn-download w-full"
|
||||||
>
|
>
|
||||||
{t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount})
|
{t('gallery.downloadSelected', { count: selectedCount })} ({selectedCount})
|
||||||
</Button>
|
</Button>
|
||||||
@@ -220,7 +220,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Feedback Filter Section */}
|
{/* Feedback Filter Section */}
|
||||||
{feedbackEnabled && onFilterChange && (
|
{feedbackEnabled && onFilterChange && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-feedback p-4 border-b border-neutral-200">
|
||||||
<GalleryFilter
|
<GalleryFilter
|
||||||
currentFilter={filterType}
|
currentFilter={filterType}
|
||||||
onFilterChange={(filter) => {
|
onFilterChange={(filter) => {
|
||||||
@@ -239,12 +239,12 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Categories Section - Hidden for carousel layout */}
|
{/* Categories Section - Hidden for carousel layout */}
|
||||||
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
{galleryLayout !== 'carousel' && categories.length > 0 && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-categories p-4 border-b border-neutral-200">
|
||||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{t('gallery.categories')}
|
{t('gallery.categories')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -252,7 +252,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
if (isMobile) onClose();
|
if (isMobile) onClose();
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`
|
||||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||||
${selectedCategoryId === null
|
${selectedCategoryId === null
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-50 text-primary-700'
|
||||||
: 'hover:bg-neutral-50 text-neutral-700'
|
: 'hover:bg-neutral-50 text-neutral-700'
|
||||||
@@ -266,7 +266,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
{categories.map((category) => {
|
{categories.map((category) => {
|
||||||
const count = photoCounts[category.id] || 0;
|
const count = photoCounts[category.id] || 0;
|
||||||
const isSelected = selectedCategoryId === category.id;
|
const isSelected = selectedCategoryId === category.id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -275,7 +275,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
if (isMobile) onClose();
|
if (isMobile) onClose();
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`
|
||||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center justify-between
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-50 text-primary-700'
|
||||||
: 'hover:bg-neutral-50 text-neutral-700'
|
: 'hover:bg-neutral-50 text-neutral-700'
|
||||||
@@ -295,8 +295,8 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{showMediaFilter && onMediaFilterChange && (
|
{showMediaFilter && onMediaFilterChange && (
|
||||||
<div className="p-4 border-b border-neutral-200">
|
<div className="gallery-sidebar-section gallery-sidebar-media p-4 border-b border-neutral-200">
|
||||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||||
<Filter className="w-4 h-4" />
|
<Filter className="w-4 h-4" />
|
||||||
{t('gallery.mediaType', 'Media')}
|
{t('gallery.mediaType', 'Media')}
|
||||||
</h3>
|
</h3>
|
||||||
@@ -304,6 +304,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
|
variant={mediaFilter === 'all' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="gallery-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onMediaFilterChange('all');
|
onMediaFilterChange('all');
|
||||||
if (isMobile) onClose();
|
if (isMobile) onClose();
|
||||||
@@ -314,6 +315,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
|
variant={mediaFilter === 'photo' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="gallery-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onMediaFilterChange('photo');
|
onMediaFilterChange('photo');
|
||||||
if (isMobile) onClose();
|
if (isMobile) onClose();
|
||||||
@@ -324,6 +326,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
<Button
|
<Button
|
||||||
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
|
variant={mediaFilter === 'video' ? 'primary' : 'outline'}
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="gallery-btn"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
onMediaFilterChange('video');
|
onMediaFilterChange('video');
|
||||||
if (isMobile) onClose();
|
if (isMobile) onClose();
|
||||||
@@ -337,17 +340,17 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
|
|
||||||
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
{/* Sort Section - Hidden for carousel and timeline layouts */}
|
||||||
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
{galleryLayout !== 'carousel' && galleryLayout !== 'timeline' && (
|
||||||
<div className="p-4">
|
<div className="gallery-sidebar-section gallery-sidebar-sort p-4">
|
||||||
<h3 className="text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
<h3 className="gallery-sidebar-section-title text-sm font-semibold text-neutral-700 mb-3 flex items-center gap-2">
|
||||||
<SortAsc className="w-4 h-4" />
|
<SortAsc className="w-4 h-4" />
|
||||||
{t('gallery.sortBy')}
|
{t('gallery.sortBy')}
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{sortOptions.map((option) => {
|
{sortOptions.map((option) => {
|
||||||
const Icon = option.icon;
|
const Icon = option.icon;
|
||||||
const isSelected = sortBy === option.value;
|
const isSelected = sortBy === option.value;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={option.value}
|
key={option.value}
|
||||||
@@ -356,7 +359,7 @@ export const GallerySidebar: React.FC<GallerySidebarProps> = ({
|
|||||||
if (isMobile) onClose();
|
if (isMobile) onClose();
|
||||||
}}
|
}}
|
||||||
className={`
|
className={`
|
||||||
w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
gallery-btn w-full text-left px-3 py-2 rounded-lg transition-colors flex items-center gap-3
|
||||||
${isSelected
|
${isSelected
|
||||||
? 'bg-primary-50 text-primary-700'
|
? 'bg-primary-50 text-primary-700'
|
||||||
: 'hover:bg-neutral-50 text-neutral-700'
|
: 'hover:bg-neutral-50 text-neutral-700'
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { api } from '../../config/api';
|
|||||||
import { Upload, Menu } from 'lucide-react';
|
import { Upload, Menu } from 'lucide-react';
|
||||||
import { galleryService } from '../../services/gallery.service';
|
import { galleryService } from '../../services/gallery.service';
|
||||||
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
import { useWatermarkSettings } from '../../hooks/useWatermarkSettings';
|
||||||
|
import { useGalleryCustomCss } from '../../hooks/useGalleryCustomCss';
|
||||||
import type { Photo } from '../../types';
|
import type { Photo } from '../../types';
|
||||||
|
|
||||||
interface GalleryViewProps {
|
interface GalleryViewProps {
|
||||||
@@ -55,6 +56,10 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
const [feedbackEnabled, setFeedbackEnabled] = useState(false);
|
||||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||||
const { watermarkEnabled } = useWatermarkSettings();
|
const { watermarkEnabled } = useWatermarkSettings();
|
||||||
|
|
||||||
|
// Load and inject custom CSS for this gallery
|
||||||
|
useGalleryCustomCss(slug);
|
||||||
|
|
||||||
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
const [protectionLevel, setProtectionLevel] = useState<'basic' | 'standard' | 'enhanced' | 'maximum'>('standard');
|
||||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||||
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
|
const [mediaFilter, setMediaFilter] = useState<'all' | 'photo' | 'video'>('all');
|
||||||
@@ -564,6 +569,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
className="gallery-btn"
|
||||||
leftIcon={<Menu className="w-4 h-4" />}
|
leftIcon={<Menu className="w-4 h-4" />}
|
||||||
onClick={() => setSidebarOpen(!sidebarOpen)}
|
onClick={() => setSidebarOpen(!sidebarOpen)}
|
||||||
aria-label={t('gallery.toggleMenu')}
|
aria-label={t('gallery.toggleMenu')}
|
||||||
|
|||||||
@@ -70,9 +70,9 @@ export const CarouselGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
const canQuickComment = Boolean(feedbackEnabled && feedbackOptions?.allowComments && onOpenPhotoWithFeedback);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className="photo-grid relative">
|
||||||
{/* Main Carousel */}
|
{/* Main Carousel */}
|
||||||
<div className="relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
|
<div className="photo-card relative h-[50vh] sm:h-[60vh] lg:h-[70vh] bg-black rounded-lg overflow-hidden">
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
src={currentPhoto.url}
|
src={currentPhoto.url}
|
||||||
alt={currentPhoto.filename}
|
alt={currentPhoto.filename}
|
||||||
|
|||||||
@@ -176,7 +176,7 @@ const GridPhoto: React.FC<GridPhotoProps> = ({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={ref}
|
ref={ref}
|
||||||
className={`relative group cursor-pointer aspect-square ${animationClass}`}
|
className={`photo-card relative group cursor-pointer aspect-square ${animationClass}`}
|
||||||
onClick={handlePhotoClick}
|
onClick={handlePhotoClick}
|
||||||
style={{
|
style={{
|
||||||
opacity: !inView && animationType === 'fade' ? 0 : 1
|
opacity: !inView && animationType === 'fade' ? 0 : 1
|
||||||
@@ -375,10 +375,10 @@ export const GridGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
|
|
||||||
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
const spacingClass = spacing === 'tight' ? 'gap-2' : spacing === 'relaxed' ? 'gap-6' : 'gap-4';
|
||||||
|
|
||||||
const gridClass = `grid ${spacingClass}
|
const gridClass = `photo-grid grid ${spacingClass}
|
||||||
grid-cols-${columns.mobile}
|
grid-cols-${columns.mobile}
|
||||||
sm:grid-cols-${columns.tablet}
|
sm:grid-cols-${columns.tablet}
|
||||||
lg:grid-cols-${columns.desktop}
|
lg:grid-cols-${columns.desktop}
|
||||||
xl:grid-cols-${columns.desktop + 1}`;
|
xl:grid-cols-${columns.desktop + 1}`;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -187,13 +187,13 @@ export const HeroGalleryLayout: React.FC<HeroGalleryLayoutProps> = ({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Grid Section */}
|
{/* Grid Section */}
|
||||||
<div id="gallery-grid-section" className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
<div id="gallery-grid-section" className="photo-grid grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 gap-4">
|
||||||
{remainingPhotos.map((photo) => {
|
{remainingPhotos.map((photo) => {
|
||||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative group cursor-pointer overflow-hidden rounded-lg"
|
className="photo-card relative group cursor-pointer overflow-hidden rounded-lg"
|
||||||
onClick={() => onPhotoClick(actualIndex)}
|
onClick={() => onPhotoClick(actualIndex)}
|
||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const MasonryPhoto: React.FC<MasonryPhotoProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
|
className="photo-card relative group cursor-pointer transition-all duration-300 hover:scale-[1.02]"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
@@ -243,9 +243,9 @@ export const MasonryGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
className="flex gap-4"
|
className="photo-grid flex gap-4"
|
||||||
style={{ gap: `${gutter}px` }}
|
style={{ gap: `${gutter}px` }}
|
||||||
>
|
>
|
||||||
{photoColumns.map((column, columnIndex) => (
|
{photoColumns.map((column, columnIndex) => (
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ const MosaicPhoto: React.FC<MosaicPhotoProps> = ({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
className={`photo-card relative group cursor-pointer overflow-hidden rounded-lg bg-neutral-100 ${className}`}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
onClick(e);
|
onClick(e);
|
||||||
@@ -417,7 +417,7 @@ export const MosaicGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full max-w-7xl mx-auto">
|
<div className="photo-grid w-full max-w-7xl mx-auto">
|
||||||
{renderMosaicLayout()}
|
{renderMosaicLayout()}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,13 +94,13 @@ export const TimelineGalleryLayout: React.FC<BaseGalleryLayoutProps> = ({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Photos grid for this date */}
|
{/* Photos grid for this date */}
|
||||||
<div className="lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
<div className="photo-grid lg:ml-24 grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
{group.photos.map((photo) => {
|
{group.photos.map((photo) => {
|
||||||
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
const actualIndex = photos.findIndex(p => p.id === photo.id);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={photo.id}
|
key={photo.id}
|
||||||
className="relative group cursor-pointer aspect-square"
|
className="photo-card relative group cursor-pointer aspect-square"
|
||||||
onClick={() => onPhotoClick(actualIndex)}
|
onClick={() => onPhotoClick(actualIndex)}
|
||||||
>
|
>
|
||||||
<AuthenticatedImage
|
<AuthenticatedImage
|
||||||
|
|||||||
@@ -0,0 +1,505 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'react-toastify';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { settingsService } from '../../../services/settings.service';
|
||||||
|
import { adminService } from '../../../services/admin.service';
|
||||||
|
import { useAdminAuth } from '../../../contexts';
|
||||||
|
import { toBoolean, toNumber } from '../../../utils/parsers';
|
||||||
|
|
||||||
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
|
export const MAX_FILES_PER_UPLOAD_LIMIT = 2000;
|
||||||
|
|
||||||
|
export interface GeneralSettings {
|
||||||
|
site_url: string;
|
||||||
|
default_expiration_days: number;
|
||||||
|
max_file_size_mb: number;
|
||||||
|
max_files_per_upload: number;
|
||||||
|
allowed_file_types: string;
|
||||||
|
enable_watermark: boolean;
|
||||||
|
enable_analytics: boolean;
|
||||||
|
enable_registration: boolean;
|
||||||
|
maintenance_mode: boolean;
|
||||||
|
short_gallery_urls: boolean;
|
||||||
|
default_language: string;
|
||||||
|
date_format: { format: string; locale: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SecuritySettings {
|
||||||
|
password_min_length: number;
|
||||||
|
password_complexity: string;
|
||||||
|
enable_2fa: boolean;
|
||||||
|
session_timeout_minutes: number;
|
||||||
|
max_login_attempts: number;
|
||||||
|
attempt_window_minutes: number;
|
||||||
|
lockout_duration_minutes: number;
|
||||||
|
enable_recaptcha: boolean;
|
||||||
|
recaptcha_site_key: string;
|
||||||
|
recaptcha_secret_key: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalyticsSettings {
|
||||||
|
umami_enabled: boolean;
|
||||||
|
umami_url: string;
|
||||||
|
umami_website_id: string;
|
||||||
|
umami_share_url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EventSettings {
|
||||||
|
event_require_customer_name: boolean;
|
||||||
|
event_require_customer_email: boolean;
|
||||||
|
event_require_admin_email: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSettingsState() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { t, i18n } = useTranslation();
|
||||||
|
const { updateUserProfile } = useAdminAuth();
|
||||||
|
|
||||||
|
// Fetch settings
|
||||||
|
const { data: settings, isLoading } = useQuery({
|
||||||
|
queryKey: ['admin-settings'],
|
||||||
|
queryFn: () => settingsService.getAllSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: adminProfile, isLoading: adminProfileLoading } = useQuery({
|
||||||
|
queryKey: ['admin-profile'],
|
||||||
|
queryFn: () => adminService.getAdminProfile(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// General settings state
|
||||||
|
const [generalSettings, setGeneralSettings] = useState<GeneralSettings>({
|
||||||
|
site_url: '',
|
||||||
|
default_expiration_days: 30,
|
||||||
|
max_file_size_mb: 50,
|
||||||
|
max_files_per_upload: 500,
|
||||||
|
allowed_file_types: 'jpg,jpeg,png,gif,webp',
|
||||||
|
enable_watermark: false,
|
||||||
|
enable_analytics: true,
|
||||||
|
enable_registration: false,
|
||||||
|
maintenance_mode: false,
|
||||||
|
short_gallery_urls: false,
|
||||||
|
default_language: 'en',
|
||||||
|
date_format: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||||
|
});
|
||||||
|
|
||||||
|
// Security settings state
|
||||||
|
const [securitySettings, setSecuritySettings] = useState<SecuritySettings>({
|
||||||
|
password_min_length: 8,
|
||||||
|
password_complexity: 'moderate',
|
||||||
|
enable_2fa: false,
|
||||||
|
session_timeout_minutes: 60,
|
||||||
|
max_login_attempts: 5,
|
||||||
|
attempt_window_minutes: 15,
|
||||||
|
lockout_duration_minutes: 30,
|
||||||
|
enable_recaptcha: false,
|
||||||
|
recaptcha_site_key: '',
|
||||||
|
recaptcha_secret_key: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Analytics settings state
|
||||||
|
const [analyticsSettings, setAnalyticsSettings] = useState<AnalyticsSettings>({
|
||||||
|
umami_enabled: false,
|
||||||
|
umami_url: '',
|
||||||
|
umami_website_id: '',
|
||||||
|
umami_share_url: ''
|
||||||
|
});
|
||||||
|
|
||||||
|
// Event creation settings state
|
||||||
|
const [eventSettings, setEventSettings] = useState<EventSettings>({
|
||||||
|
event_require_customer_name: true,
|
||||||
|
event_require_customer_email: true,
|
||||||
|
event_require_admin_email: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Account form state
|
||||||
|
const [accountForm, setAccountForm] = useState({
|
||||||
|
username: '',
|
||||||
|
email: ''
|
||||||
|
});
|
||||||
|
const [accountErrors, setAccountErrors] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
// Storage state
|
||||||
|
const [softLimitGb, setSoftLimitGb] = useState<number | ''>('');
|
||||||
|
const [softLimitDirty, setSoftLimitDirty] = useState(false);
|
||||||
|
const [capacityOverrideGb, setCapacityOverrideGb] = useState<number | ''>('');
|
||||||
|
const [availableOverrideGb, setAvailableOverrideGb] = useState<number | ''>('');
|
||||||
|
const [overrideDirty, setOverrideDirty] = useState(false);
|
||||||
|
|
||||||
|
// Initialize settings from API
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
if (settings.general_default_language && settings.general_default_language !== i18n.language) {
|
||||||
|
i18n.changeLanguage(settings.general_default_language);
|
||||||
|
}
|
||||||
|
|
||||||
|
setGeneralSettings({
|
||||||
|
site_url: settings.general_site_url || '',
|
||||||
|
default_expiration_days: toNumber(settings.general_default_expiration_days, 30),
|
||||||
|
max_file_size_mb: toNumber(settings.general_max_file_size_mb, 50),
|
||||||
|
max_files_per_upload: Math.min(
|
||||||
|
MAX_FILES_PER_UPLOAD_LIMIT,
|
||||||
|
Math.max(1, toNumber(settings.general_max_files_per_upload, 500))
|
||||||
|
),
|
||||||
|
allowed_file_types: settings.general_allowed_file_types || 'jpg,jpeg,png,gif,webp',
|
||||||
|
enable_watermark: toBoolean(settings.general_enable_watermark, false),
|
||||||
|
enable_analytics: toBoolean(settings.general_enable_analytics, true),
|
||||||
|
enable_registration: toBoolean(settings.general_enable_registration, false),
|
||||||
|
maintenance_mode: toBoolean(settings.general_maintenance_mode, false),
|
||||||
|
short_gallery_urls: toBoolean(settings.general_short_gallery_urls, false),
|
||||||
|
default_language: settings.general_default_language || 'en',
|
||||||
|
date_format: settings.general_date_format
|
||||||
|
? (typeof settings.general_date_format === 'string'
|
||||||
|
? { format: settings.general_date_format, locale: settings.general_date_format.includes('MM/dd') ? 'en-US' : 'en-GB' }
|
||||||
|
: settings.general_date_format)
|
||||||
|
: { format: 'dd/MM/yyyy', locale: 'en-GB' }
|
||||||
|
});
|
||||||
|
|
||||||
|
setSecuritySettings({
|
||||||
|
password_min_length: toNumber(settings.security_password_min_length, 8),
|
||||||
|
password_complexity: settings.security_password_complexity ?? 'moderate',
|
||||||
|
enable_2fa: toBoolean(settings.security_enable_2fa, false),
|
||||||
|
session_timeout_minutes: toNumber(settings.security_session_timeout_minutes, 60),
|
||||||
|
max_login_attempts: toNumber(settings.security_max_login_attempts, 5),
|
||||||
|
attempt_window_minutes: toNumber(settings.security_attempt_window_minutes, 15),
|
||||||
|
lockout_duration_minutes: toNumber(settings.security_lockout_duration_minutes, 30),
|
||||||
|
enable_recaptcha: toBoolean(settings.security_enable_recaptcha, false),
|
||||||
|
recaptcha_site_key: settings.security_recaptcha_site_key ?? '',
|
||||||
|
recaptcha_secret_key: settings.security_recaptcha_secret_key ?? ''
|
||||||
|
});
|
||||||
|
|
||||||
|
setAnalyticsSettings({
|
||||||
|
umami_enabled: toBoolean(settings.analytics_umami_enabled, false),
|
||||||
|
umami_url: settings.analytics_umami_url || '',
|
||||||
|
umami_website_id: settings.analytics_umami_website_id || '',
|
||||||
|
umami_share_url: settings.analytics_umami_share_url || ''
|
||||||
|
});
|
||||||
|
|
||||||
|
setEventSettings({
|
||||||
|
event_require_customer_name: toBoolean(settings.event_require_customer_name, true),
|
||||||
|
event_require_customer_email: toBoolean(settings.event_require_customer_email, true),
|
||||||
|
event_require_admin_email: toBoolean(settings.event_require_admin_email, true)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings, i18n]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (adminProfile) {
|
||||||
|
setAccountForm({
|
||||||
|
username: adminProfile.username || '',
|
||||||
|
email: adminProfile.email || ''
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [adminProfile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!settings || overrideDirty) return;
|
||||||
|
|
||||||
|
const capacityOverrideBytes = settings.general_storage_capacity_override_bytes ?? null;
|
||||||
|
const availableOverrideBytes = settings.general_storage_available_override_bytes ?? null;
|
||||||
|
|
||||||
|
setCapacityOverrideGb(
|
||||||
|
capacityOverrideBytes != null
|
||||||
|
? Number((capacityOverrideBytes / BYTES_PER_GB).toFixed(2))
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
|
||||||
|
setAvailableOverrideGb(
|
||||||
|
availableOverrideBytes != null
|
||||||
|
? Number((availableOverrideBytes / BYTES_PER_GB).toFixed(2))
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
}, [settings, overrideDirty]);
|
||||||
|
|
||||||
|
// Mutations
|
||||||
|
const saveGeneralMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const settingsData: Record<string, unknown> = {};
|
||||||
|
Object.entries(generalSettings).forEach(([key, value]) => {
|
||||||
|
if (key === 'date_format' && typeof value === 'object' && value.format) {
|
||||||
|
settingsData[`general_${key}`] = value.format;
|
||||||
|
} else {
|
||||||
|
settingsData[`general_${key}`] = value;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return settingsService.updateSettings(settingsData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSecurityMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const settingsData: Record<string, unknown> = {};
|
||||||
|
Object.entries(securitySettings).forEach(([key, value]) => {
|
||||||
|
settingsData[`security_${key}`] = value;
|
||||||
|
});
|
||||||
|
return settingsService.updateSettings(settingsData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveAnalyticsMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const settingsData: Record<string, unknown> = {};
|
||||||
|
Object.entries(analyticsSettings).forEach(([key, value]) => {
|
||||||
|
settingsData[`analytics_${key}`] = value;
|
||||||
|
});
|
||||||
|
return settingsService.updateSettings(settingsData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveEventSettingsMutation = useMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
const settingsData: Record<string, unknown> = {};
|
||||||
|
Object.entries(eventSettings).forEach(([key, value]) => {
|
||||||
|
settingsData[key] = value;
|
||||||
|
});
|
||||||
|
return settingsService.updateSettings(settingsData);
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['public-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateAdminProfileMutation = useMutation({
|
||||||
|
mutationFn: (payload: { username: string; email: string }) => adminService.updateAdminProfile(payload),
|
||||||
|
onSuccess: (updatedUser) => {
|
||||||
|
toast.success(t('settings.general.accountSaveSuccess'));
|
||||||
|
setAccountErrors({});
|
||||||
|
setAccountForm({
|
||||||
|
username: updatedUser.username,
|
||||||
|
email: updatedUser.email
|
||||||
|
});
|
||||||
|
updateUserProfile(updatedUser);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-profile'] });
|
||||||
|
},
|
||||||
|
onError: (error: { response?: { data?: { errors?: Array<{ path: string; msg: string }>; error?: string } } }) => {
|
||||||
|
if (error.response?.data?.errors) {
|
||||||
|
const fieldErrors: Record<string, string> = {};
|
||||||
|
for (const err of error.response.data.errors) {
|
||||||
|
if (err.path === 'username') {
|
||||||
|
fieldErrors.username = err.msg;
|
||||||
|
}
|
||||||
|
if (err.path === 'email') {
|
||||||
|
fieldErrors.email = err.msg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setAccountErrors(fieldErrors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error.response?.data?.error) {
|
||||||
|
toast.error(error.response.data.error);
|
||||||
|
} else {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveSoftLimitMutation = useMutation({
|
||||||
|
mutationFn: async (limitBytes: number | null) => {
|
||||||
|
return settingsService.updateSettings({
|
||||||
|
general_storage_soft_limit_bytes: limitBytes,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
setSoftLimitDirty(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveCapacityOverrideMutation = useMutation({
|
||||||
|
mutationFn: async (payload: { capacity: number | null; available: number | null }) => {
|
||||||
|
return settingsService.updateSettings({
|
||||||
|
general_storage_capacity_override_bytes: payload.capacity,
|
||||||
|
general_storage_available_override_bytes: payload.available,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.settingsSaved'));
|
||||||
|
setOverrideDirty(false);
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-storage-info'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['storage-info'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handlers
|
||||||
|
const handleAccountChange = (field: 'username' | 'email') => (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
setAccountForm((prev) => ({ ...prev, [field]: value }));
|
||||||
|
if (accountErrors[field]) {
|
||||||
|
setAccountErrors((prev) => ({ ...prev, [field]: '' }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAccountSubmit = (event: React.FormEvent<HTMLFormElement>) => {
|
||||||
|
event.preventDefault();
|
||||||
|
|
||||||
|
if (updateAdminProfileMutation.isPending) return;
|
||||||
|
|
||||||
|
const trimmedUsername = accountForm.username.trim();
|
||||||
|
const trimmedEmail = accountForm.email.trim();
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
|
||||||
|
if (!trimmedUsername) {
|
||||||
|
errors.username = t('settings.general.accountUsernameRequired');
|
||||||
|
} else if (trimmedUsername.length < 3) {
|
||||||
|
errors.username = t('settings.general.accountUsernameLength');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!trimmedEmail) {
|
||||||
|
errors.email = t('settings.general.accountEmailRequired');
|
||||||
|
} else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmedEmail)) {
|
||||||
|
errors.email = t('settings.general.accountEmailInvalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(errors).length > 0) {
|
||||||
|
setAccountErrors(errors);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateAdminProfileMutation.mutate({
|
||||||
|
username: trimmedUsername,
|
||||||
|
email: trimmedEmail
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveSoftLimit = () => {
|
||||||
|
if (saveSoftLimitMutation.isPending) return;
|
||||||
|
|
||||||
|
if (softLimitGb === '') {
|
||||||
|
saveSoftLimitMutation.mutate(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(softLimitGb);
|
||||||
|
|
||||||
|
if (!Number.isFinite(numericValue) || numericValue < 0) {
|
||||||
|
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitBytes = Math.max(0, Math.round(numericValue * BYTES_PER_GB));
|
||||||
|
saveSoftLimitMutation.mutate(limitBytes);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCapacityOverride = () => {
|
||||||
|
if (saveCapacityOverrideMutation.isPending) return;
|
||||||
|
|
||||||
|
if (capacityOverrideGb === '' && availableOverrideGb !== '') {
|
||||||
|
toast.error(t('settings.storage.capacityRequiredForAvailable'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacityValue = capacityOverrideGb === '' ? null : Number(capacityOverrideGb);
|
||||||
|
const availableValue = availableOverrideGb === '' ? null : Number(availableOverrideGb);
|
||||||
|
|
||||||
|
if ((capacityValue !== null && !Number.isFinite(capacityValue)) || (availableValue !== null && !Number.isFinite(availableValue))) {
|
||||||
|
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (capacityValue !== null && capacityValue < 0) {
|
||||||
|
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableValue !== null && availableValue < 0) {
|
||||||
|
toast.error(t('settings.storage.invalidSoftLimit'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const capacityBytes = capacityValue === null ? null : Math.max(0, Math.round(capacityValue * BYTES_PER_GB));
|
||||||
|
const availableBytes = availableValue === null ? null : Math.max(0, Math.round(availableValue * BYTES_PER_GB));
|
||||||
|
|
||||||
|
if (capacityBytes !== null && availableBytes !== null && availableBytes > capacityBytes) {
|
||||||
|
toast.error(t('settings.storage.availableExceedsCapacity'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveCapacityOverrideMutation.mutate({ capacity: capacityBytes, available: availableBytes });
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
// Loading states
|
||||||
|
isLoading,
|
||||||
|
adminProfileLoading,
|
||||||
|
|
||||||
|
// Settings data
|
||||||
|
settings,
|
||||||
|
generalSettings,
|
||||||
|
setGeneralSettings,
|
||||||
|
securitySettings,
|
||||||
|
setSecuritySettings,
|
||||||
|
analyticsSettings,
|
||||||
|
setAnalyticsSettings,
|
||||||
|
eventSettings,
|
||||||
|
setEventSettings,
|
||||||
|
|
||||||
|
// Account form
|
||||||
|
accountForm,
|
||||||
|
accountErrors,
|
||||||
|
handleAccountChange,
|
||||||
|
handleAccountSubmit,
|
||||||
|
updateAdminProfileMutation,
|
||||||
|
|
||||||
|
// Storage settings
|
||||||
|
softLimitGb,
|
||||||
|
setSoftLimitGb,
|
||||||
|
softLimitDirty,
|
||||||
|
setSoftLimitDirty,
|
||||||
|
capacityOverrideGb,
|
||||||
|
setCapacityOverrideGb,
|
||||||
|
availableOverrideGb,
|
||||||
|
setAvailableOverrideGb,
|
||||||
|
overrideDirty,
|
||||||
|
setOverrideDirty,
|
||||||
|
handleSaveSoftLimit,
|
||||||
|
handleSaveCapacityOverride,
|
||||||
|
saveSoftLimitMutation,
|
||||||
|
saveCapacityOverrideMutation,
|
||||||
|
|
||||||
|
// Save mutations
|
||||||
|
saveGeneralMutation,
|
||||||
|
saveSecurityMutation,
|
||||||
|
saveAnalyticsMutation,
|
||||||
|
saveEventSettingsMutation,
|
||||||
|
|
||||||
|
// Translation
|
||||||
|
t,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { settingsService } from '../../../services/settings.service';
|
||||||
|
|
||||||
|
export function useStatusTab(isActive: boolean) {
|
||||||
|
// Fetch storage info
|
||||||
|
const { data: storageInfo } = useQuery({
|
||||||
|
queryKey: ['admin-storage-info'],
|
||||||
|
queryFn: () => settingsService.getStorageInfo(),
|
||||||
|
enabled: isActive
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch system status
|
||||||
|
const { data: systemStatus } = useQuery({
|
||||||
|
queryKey: ['system-status'],
|
||||||
|
queryFn: () => settingsService.getSystemStatus(),
|
||||||
|
enabled: isActive,
|
||||||
|
refetchInterval: 30000
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
storageInfo,
|
||||||
|
systemStatus,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
// Hooks
|
||||||
|
export { useSettingsState, MAX_FILES_PER_UPLOAD_LIMIT } from './hooks/useSettingsState';
|
||||||
|
export type { GeneralSettings, SecuritySettings, AnalyticsSettings, EventSettings } from './hooks/useSettingsState';
|
||||||
|
export { useStatusTab } from './hooks/useStatusTab';
|
||||||
|
|
||||||
|
// Tab components
|
||||||
|
export { GeneralTab } from './tabs/GeneralTab';
|
||||||
|
export { EventsTab } from './tabs/EventsTab';
|
||||||
|
export { StatusTab } from './tabs/StatusTab';
|
||||||
|
export { SecurityTab } from './tabs/SecurityTab';
|
||||||
|
export { CategoriesTab } from './tabs/CategoriesTab';
|
||||||
|
export { AnalyticsTab } from './tabs/AnalyticsTab';
|
||||||
|
export { ModerationTab } from './tabs/ModerationTab';
|
||||||
|
export { StylingTab } from './tabs/StylingTab';
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Save, Globe, Key, Activity, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button, Card, Input } from '../../../components/common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { AnalyticsSettings } from '../hooks/useSettingsState';
|
||||||
|
|
||||||
|
interface AnalyticsTabProps {
|
||||||
|
analyticsSettings: AnalyticsSettings;
|
||||||
|
setAnalyticsSettings: React.Dispatch<React.SetStateAction<AnalyticsSettings>>;
|
||||||
|
saveAnalyticsMutation: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AnalyticsTab: React.FC<AnalyticsTabProps> = ({
|
||||||
|
analyticsSettings,
|
||||||
|
setAnalyticsSettings,
|
||||||
|
saveAnalyticsMutation,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.umamiIntegration')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={analyticsSettings.umami_enabled}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_enabled: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.analytics.enableUmami')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{analyticsSettings.umami_enabled && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.analytics.umamiUrl')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={analyticsSettings.umami_url}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_url: e.target.value }))}
|
||||||
|
placeholder="https://analytics.yourdomain.com"
|
||||||
|
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.analytics.umamiUrlHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.analytics.websiteId')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={analyticsSettings.umami_website_id}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_website_id: e.target.value }))}
|
||||||
|
placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||||
|
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.analytics.websiteIdHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.analytics.shareUrl')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={analyticsSettings.umami_share_url}
|
||||||
|
onChange={(e) => setAnalyticsSettings(prev => ({ ...prev, umami_share_url: e.target.value }))}
|
||||||
|
placeholder="https://analytics.yourdomain.com/share/..."
|
||||||
|
leftIcon={<Activity className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.analytics.shareUrlHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">{t('settings.analytics.umamiInfo')}</p>
|
||||||
|
<p>{t('settings.analytics.umamiInfoText')}</p>
|
||||||
|
<a href="https://umami.is" target="_blank" rel="noopener noreferrer" className="underline mt-1 inline-block">
|
||||||
|
{t('settings.analytics.learnMore')}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => saveAnalyticsMutation.mutate()}
|
||||||
|
isLoading={saveAnalyticsMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
{t('settings.analytics.saveAnalyticsSettings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Backend Analytics Info */}
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.analytics.backendAnalytics')}</h2>
|
||||||
|
<p className="text-sm text-neutral-700 mb-4">{t('settings.analytics.backendAnalyticsText')}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.tracked')}</h3>
|
||||||
|
<ul className="text-xs text-neutral-600 space-y-1">
|
||||||
|
<li>• {t('settings.analytics.galleryViews')}</li>
|
||||||
|
<li>• {t('settings.analytics.photoDownloads')}</li>
|
||||||
|
<li>• {t('settings.analytics.uniqueVisitors')}</li>
|
||||||
|
<li>• {t('settings.analytics.deviceTypes')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<h3 className="text-sm font-medium text-neutral-900 mb-2">{t('settings.analytics.privacy')}</h3>
|
||||||
|
<p className="text-xs text-neutral-600">
|
||||||
|
{t('settings.analytics.privacyText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Image } from 'lucide-react';
|
||||||
|
import { Card } from '../../../components/common';
|
||||||
|
import { CategoryManager } from '../../../components/admin/CategoryManager';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
export const CategoriesTab: React.FC = () => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<CategoryManager />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Image className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900">{t('settings.categories.about')}</h3>
|
||||||
|
<p className="text-sm text-blue-700 mt-1">
|
||||||
|
{t('settings.categories.aboutText')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Save, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button, Card } from '../../../components/common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { EventSettings } from '../hooks/useSettingsState';
|
||||||
|
|
||||||
|
interface EventsTabProps {
|
||||||
|
eventSettings: EventSettings;
|
||||||
|
setEventSettings: React.Dispatch<React.SetStateAction<EventSettings>>;
|
||||||
|
saveEventSettingsMutation: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EventsTab: React.FC<EventsTabProps> = ({
|
||||||
|
eventSettings,
|
||||||
|
setEventSettings,
|
||||||
|
saveEventSettingsMutation,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">
|
||||||
|
{t('settings.events.requiredFields', 'Required Fields')}
|
||||||
|
</h2>
|
||||||
|
<p className="text-sm text-neutral-600 mb-4">
|
||||||
|
{t('settings.events.requiredFieldsDescription', 'Configure which contact fields are required when creating new events.')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eventSettings.event_require_customer_name}
|
||||||
|
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_name: e.target.checked }))}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('settings.events.requireCustomerName', 'Require customer name')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.events.requireCustomerNameHelp', 'Customer name must be provided for new events')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eventSettings.event_require_customer_email}
|
||||||
|
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_customer_email: e.target.checked }))}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('settings.events.requireCustomerEmail', 'Require customer email')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.events.requireCustomerEmailHelp', 'Customer email must be provided for new events')}
|
||||||
|
</p>
|
||||||
|
{!eventSettings.event_require_customer_email && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{t('settings.events.customerEmailWarning', 'Required for sending gallery invitations')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-start gap-3">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={eventSettings.event_require_admin_email}
|
||||||
|
onChange={(e) => setEventSettings(prev => ({ ...prev, event_require_admin_email: e.target.checked }))}
|
||||||
|
className="mt-1 w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-neutral-700">
|
||||||
|
{t('settings.events.requireAdminEmail', 'Require admin email')}
|
||||||
|
</span>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.events.requireAdminEmailHelp', 'Admin email must be provided for new events')}
|
||||||
|
</p>
|
||||||
|
{!eventSettings.event_require_admin_email && (
|
||||||
|
<p className="text-xs text-amber-600 mt-1 flex items-center gap-1">
|
||||||
|
<AlertCircle className="w-3 h-3" />
|
||||||
|
{t('settings.events.adminEmailWarning', 'Required for receiving event notifications')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => saveEventSettingsMutation.mutate()}
|
||||||
|
isLoading={saveEventSettingsMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
{t('settings.events.saveSettings', 'Save Event Settings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p className="font-medium mb-1">{t('settings.events.noteTitle', 'Note')}</p>
|
||||||
|
<p>
|
||||||
|
{t('settings.events.noteText', 'These settings only affect new event creation. Existing events are not affected. Default behavior requires all fields.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Save, Globe, Mail, User } from 'lucide-react';
|
||||||
|
import { Button, Card, Input, Loading } from '../../../components/common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { GeneralSettings } from '../hooks/useSettingsState';
|
||||||
|
import { MAX_FILES_PER_UPLOAD_LIMIT } from '../hooks/useSettingsState';
|
||||||
|
|
||||||
|
interface GeneralTabProps {
|
||||||
|
generalSettings: GeneralSettings;
|
||||||
|
setGeneralSettings: React.Dispatch<React.SetStateAction<GeneralSettings>>;
|
||||||
|
saveGeneralMutation: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
accountForm: { username: string; email: string };
|
||||||
|
accountErrors: Record<string, string>;
|
||||||
|
handleAccountChange: (field: 'username' | 'email') => (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||||
|
handleAccountSubmit: (e: React.FormEvent<HTMLFormElement>) => void;
|
||||||
|
updateAdminProfileMutation: { isPending: boolean };
|
||||||
|
adminProfileLoading: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const GeneralTab: React.FC<GeneralTabProps> = ({
|
||||||
|
generalSettings,
|
||||||
|
setGeneralSettings,
|
||||||
|
saveGeneralMutation,
|
||||||
|
accountForm,
|
||||||
|
accountErrors,
|
||||||
|
handleAccountChange,
|
||||||
|
handleAccountSubmit,
|
||||||
|
updateAdminProfileMutation,
|
||||||
|
adminProfileLoading,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.accountSection')}</h2>
|
||||||
|
{adminProfileLoading ? (
|
||||||
|
<div className="py-8 flex justify-center">
|
||||||
|
<Loading size="md" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form className="space-y-4" onSubmit={handleAccountSubmit}>
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-account-username" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.accountUsername')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="admin-account-username"
|
||||||
|
type="text"
|
||||||
|
value={accountForm.username}
|
||||||
|
onChange={handleAccountChange('username')}
|
||||||
|
placeholder="admin"
|
||||||
|
leftIcon={<User className="w-5 h-5 text-neutral-400" />}
|
||||||
|
error={accountErrors.username}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.accountUsernameHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label htmlFor="admin-account-email" className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.accountEmail')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
id="admin-account-email"
|
||||||
|
type="email"
|
||||||
|
value={accountForm.email}
|
||||||
|
onChange={handleAccountChange('email')}
|
||||||
|
placeholder="admin@example.com"
|
||||||
|
leftIcon={<Mail className="w-5 h-5 text-neutral-400" />}
|
||||||
|
error={accountErrors.email}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.accountEmailHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="pt-2">
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="primary"
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
isLoading={updateAdminProfileMutation.isPending}
|
||||||
|
>
|
||||||
|
{t('settings.general.accountSaveButton')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.siteConfiguration')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.siteUrl')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="url"
|
||||||
|
value={generalSettings.site_url}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, site_url: e.target.value }))}
|
||||||
|
placeholder="https://yourdomain.com"
|
||||||
|
leftIcon={<Globe className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.siteUrlHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.defaultExpiration')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={generalSettings.default_expiration_days}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_expiration_days: parseInt(e.target.value) || 30 }))}
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.maxFileSize')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={generalSettings.max_file_size_mb}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, max_file_size_mb: parseInt(e.target.value) || 50 }))}
|
||||||
|
min="1"
|
||||||
|
max="500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.maxFilesPerUpload')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={generalSettings.max_files_per_upload}
|
||||||
|
onChange={(e) => {
|
||||||
|
const parsed = parseInt(e.target.value, 10);
|
||||||
|
setGeneralSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
max_files_per_upload: Number.isFinite(parsed)
|
||||||
|
? Math.min(MAX_FILES_PER_UPLOAD_LIMIT, Math.max(1, parsed))
|
||||||
|
: prev.max_files_per_upload
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
min="1"
|
||||||
|
max={MAX_FILES_PER_UPLOAD_LIMIT}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.maxFilesPerUploadHelp', { max: MAX_FILES_PER_UPLOAD_LIMIT })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.general.allowedFileTypes')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={generalSettings.allowed_file_types}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, allowed_file_types: e.target.value }))}
|
||||||
|
placeholder="jpg,jpeg,png,gif"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.allowedFileTypesHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.featureToggles')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-3">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generalSettings.enable_watermark}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_watermark: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableWatermark')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generalSettings.enable_analytics}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_analytics: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableAnalytics')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generalSettings.enable_registration}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, enable_registration: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableRegistration')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generalSettings.maintenance_mode}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, maintenance_mode: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.maintenanceMode')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={generalSettings.short_gallery_urls}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, short_gallery_urls: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.general.enableShortGalleryUrls')}</span>
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 ml-6 mt-1">
|
||||||
|
{t('settings.general.enableShortGalleryUrlsHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.language')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
{t('settings.general.language')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={generalSettings.default_language}
|
||||||
|
onChange={(e) => setGeneralSettings(prev => ({ ...prev, default_language: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="en">English</option>
|
||||||
|
<option value="de">Deutsch</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.defaultLanguageHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.general.dateTimeFormat')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-2">
|
||||||
|
{t('settings.general.dateFormat')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={generalSettings.date_format?.format || 'dd/MM/yyyy'}
|
||||||
|
onChange={(e) => {
|
||||||
|
const format = e.target.value;
|
||||||
|
const locale = format === 'MM/dd/yyyy' ? 'en-US' : 'en-GB';
|
||||||
|
setGeneralSettings(prev => ({
|
||||||
|
...prev,
|
||||||
|
date_format: { format, locale }
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="dd/MM/yyyy">DD/MM/YYYY (European)</option>
|
||||||
|
<option value="MM/dd/yyyy">MM/DD/YYYY (US)</option>
|
||||||
|
<option value="yyyy-MM-dd">YYYY-MM-DD (ISO)</option>
|
||||||
|
<option value="dd.MM.yyyy">DD.MM.YYYY (German)</option>
|
||||||
|
</select>
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('settings.general.dateFormatHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => saveGeneralMutation.mutate()}
|
||||||
|
isLoading={saveGeneralMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
{t('settings.general.saveGeneralSettings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { WordFilterManager } from '../../../components/admin/WordFilterManager';
|
||||||
|
|
||||||
|
export const ModerationTab: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<WordFilterManager />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Save, Key, AlertCircle } from 'lucide-react';
|
||||||
|
import { Button, Card, Input } from '../../../components/common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import type { SecuritySettings } from '../hooks/useSettingsState';
|
||||||
|
|
||||||
|
interface SecurityTabProps {
|
||||||
|
securitySettings: SecuritySettings;
|
||||||
|
setSecuritySettings: React.Dispatch<React.SetStateAction<SecuritySettings>>;
|
||||||
|
saveSecurityMutation: {
|
||||||
|
mutate: () => void;
|
||||||
|
isPending: boolean;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SecurityTab: React.FC<SecurityTabProps> = ({
|
||||||
|
securitySettings,
|
||||||
|
setSecuritySettings,
|
||||||
|
saveSecurityMutation,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.passwordSettings')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.minPasswordLength')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.password_min_length}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_min_length: parseInt(e.target.value) || 8 }))}
|
||||||
|
min="4"
|
||||||
|
max="32"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.passwordComplexity')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
value={securitySettings.password_complexity}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, password_complexity: e.target.value }))}
|
||||||
|
className="w-full px-3 py-2 border border-neutral-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-primary-500"
|
||||||
|
>
|
||||||
|
<option value="simple">{t('settings.security.complexitySimple')}</option>
|
||||||
|
<option value="moderate">{t('settings.security.complexityModerate')}</option>
|
||||||
|
<option value="strong">{t('settings.security.complexityStrong')}</option>
|
||||||
|
<option value="very_strong">{t('settings.security.complexityVeryStrong')}</option>
|
||||||
|
</select>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.passwordComplexityHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.sessionAuth')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.sessionTimeout')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.session_timeout_minutes}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, session_timeout_minutes: parseInt(e.target.value, 10) || 60 }))}
|
||||||
|
min="5"
|
||||||
|
max="1440"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.attemptWindowMinutes')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.attempt_window_minutes}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, attempt_window_minutes: parseInt(e.target.value, 10) || 15 }))}
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.attemptWindowMinutesHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.lockoutDurationMinutes')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.lockout_duration_minutes}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, lockout_duration_minutes: parseInt(e.target.value, 10) || 30 }))}
|
||||||
|
min="1"
|
||||||
|
max="1440"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.lockoutDurationMinutesHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.maxLoginAttempts')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={securitySettings.max_login_attempts}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, max_login_attempts: parseInt(e.target.value, 10) || 5 }))}
|
||||||
|
min="1"
|
||||||
|
max="50"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-sm text-neutral-600">
|
||||||
|
{t('settings.security.maxLoginAttemptsHelp')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={securitySettings.enable_2fa}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_2fa: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enable2FA')}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4">{t('settings.security.recaptchaSettings')}</h2>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="flex items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={securitySettings.enable_recaptcha}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, enable_recaptcha: e.target.checked }))}
|
||||||
|
className="w-4 h-4 text-primary-600 rounded focus:ring-primary-500"
|
||||||
|
/>
|
||||||
|
<span className="ml-2 text-sm text-neutral-700">{t('settings.security.enableRecaptcha')}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{securitySettings.enable_recaptcha && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.siteKey')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={securitySettings.recaptcha_site_key}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_site_key: e.target.value }))}
|
||||||
|
placeholder={t('settings.security.siteKey')}
|
||||||
|
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 mb-1">
|
||||||
|
{t('settings.security.secretKey')}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={securitySettings.recaptcha_secret_key}
|
||||||
|
onChange={(e) => setSecuritySettings(prev => ({ ...prev, recaptcha_secret_key: e.target.value }))}
|
||||||
|
placeholder={t('settings.security.secretKey')}
|
||||||
|
leftIcon={<Key className="w-5 h-5 text-neutral-400" />}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<AlertCircle className="w-5 h-5 text-blue-600 flex-shrink-0" />
|
||||||
|
<div className="text-sm text-blue-800">
|
||||||
|
<p>{t('settings.security.recaptchaHelp')} <a href="https://www.google.com/recaptcha/admin" target="_blank" rel="noopener noreferrer" className="underline">Google reCAPTCHA Admin</a></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => saveSecurityMutation.mutate()}
|
||||||
|
isLoading={saveSecurityMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
{t('settings.security.saveSecuritySettings')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,539 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Save,
|
||||||
|
Database,
|
||||||
|
Server,
|
||||||
|
CheckCircle,
|
||||||
|
Clock,
|
||||||
|
HardDrive,
|
||||||
|
Activity,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { Button, Card, Input } from '../../../components/common';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { settingsService } from '../../../services/settings.service';
|
||||||
|
import { useStatusTab } from '../hooks/useStatusTab';
|
||||||
|
|
||||||
|
const BYTES_PER_GB = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
|
interface StatusTabProps {
|
||||||
|
isActive: boolean;
|
||||||
|
handleSaveSoftLimit: () => void;
|
||||||
|
handleSaveCapacityOverride: () => void;
|
||||||
|
saveSoftLimitMutation: { isPending: boolean };
|
||||||
|
saveCapacityOverrideMutation: { isPending: boolean };
|
||||||
|
softLimitGb: number | '';
|
||||||
|
setSoftLimitGb: (value: number | '') => void;
|
||||||
|
softLimitDirty: boolean;
|
||||||
|
setSoftLimitDirty: (dirty: boolean) => void;
|
||||||
|
capacityOverrideGb: number | '';
|
||||||
|
setCapacityOverrideGb: (value: number | '') => void;
|
||||||
|
availableOverrideGb: number | '';
|
||||||
|
setAvailableOverrideGb: (value: number | '') => void;
|
||||||
|
overrideDirty: boolean;
|
||||||
|
setOverrideDirty: (dirty: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const StatusTab: React.FC<StatusTabProps> = ({
|
||||||
|
isActive,
|
||||||
|
handleSaveSoftLimit,
|
||||||
|
handleSaveCapacityOverride,
|
||||||
|
saveSoftLimitMutation,
|
||||||
|
saveCapacityOverrideMutation,
|
||||||
|
softLimitGb,
|
||||||
|
setSoftLimitGb,
|
||||||
|
softLimitDirty,
|
||||||
|
setSoftLimitDirty,
|
||||||
|
capacityOverrideGb,
|
||||||
|
setCapacityOverrideGb,
|
||||||
|
availableOverrideGb,
|
||||||
|
setAvailableOverrideGb,
|
||||||
|
overrideDirty,
|
||||||
|
setOverrideDirty,
|
||||||
|
}) => {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const { storageInfo, systemStatus } = useStatusTab(isActive);
|
||||||
|
|
||||||
|
// Sync soft limit from storage info
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageInfo || softLimitDirty) return;
|
||||||
|
|
||||||
|
const currentLimit = storageInfo.configured_soft_limit ?? storageInfo.storage_soft_limit ?? null;
|
||||||
|
|
||||||
|
if (currentLimit === null || currentLimit === undefined) {
|
||||||
|
setSoftLimitGb('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const limitGb = Number((currentLimit / BYTES_PER_GB).toFixed(2));
|
||||||
|
setSoftLimitGb(limitGb);
|
||||||
|
}, [storageInfo, softLimitDirty, setSoftLimitGb]);
|
||||||
|
|
||||||
|
// Sync capacity override from storage info
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageInfo || overrideDirty) return;
|
||||||
|
|
||||||
|
if (storageInfo.disk_override_source === 'env') {
|
||||||
|
setCapacityOverrideGb(
|
||||||
|
storageInfo.disk_total
|
||||||
|
? Number((storageInfo.disk_total / BYTES_PER_GB).toFixed(2))
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
setAvailableOverrideGb(
|
||||||
|
storageInfo.disk_available
|
||||||
|
? Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2))
|
||||||
|
: ''
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [storageInfo, overrideDirty, setCapacityOverrideGb, setAvailableOverrideGb]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Storage Overview */}
|
||||||
|
{storageInfo && (() => {
|
||||||
|
const configuredSoftLimit = storageInfo.configured_soft_limit ?? null;
|
||||||
|
const effectiveSoftLimit = storageInfo.storage_soft_limit || storageInfo.storage_limit || storageInfo.recommended_soft_limit || 1;
|
||||||
|
const safeEffectiveSoftLimit = Math.max(effectiveSoftLimit, 1);
|
||||||
|
const usageRatio = storageInfo.total_used / safeEffectiveSoftLimit;
|
||||||
|
const usagePercentage = Math.round(usageRatio * 100);
|
||||||
|
const usageWidth = Math.min(usageRatio * 100, 100);
|
||||||
|
const overSoftLimit = configuredSoftLimit != null
|
||||||
|
? storageInfo.total_used >= configuredSoftLimit
|
||||||
|
: usagePercentage >= 100;
|
||||||
|
const limitDisplayBytes = configuredSoftLimit ?? storageInfo.storage_soft_limit ?? storageInfo.storage_limit ?? null;
|
||||||
|
const limitDisplay = limitDisplayBytes != null
|
||||||
|
? settingsService.formatBytes(limitDisplayBytes)
|
||||||
|
: t('settings.storage.unlimited');
|
||||||
|
const diskCapacityBytes = storageInfo.disk_total ?? storageInfo.disk_total_raw ?? null;
|
||||||
|
const diskAvailableBytes = storageInfo.disk_available ?? storageInfo.disk_available_raw ?? null;
|
||||||
|
const diskFreeBytes = storageInfo.disk_free ?? storageInfo.disk_free_raw ?? null;
|
||||||
|
|
||||||
|
const diskCapacityDisplay = diskCapacityBytes != null
|
||||||
|
? settingsService.formatBytes(diskCapacityBytes)
|
||||||
|
: null;
|
||||||
|
const diskAvailableDisplay = diskAvailableBytes != null
|
||||||
|
? settingsService.formatBytes(diskAvailableBytes)
|
||||||
|
: null;
|
||||||
|
const diskFreeDisplay = diskFreeBytes != null
|
||||||
|
? settingsService.formatBytes(diskFreeBytes)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const recommendedDisplay = storageInfo.recommended_soft_limit != null
|
||||||
|
? settingsService.formatBytes(storageInfo.recommended_soft_limit)
|
||||||
|
: null;
|
||||||
|
const progressColor = overSoftLimit
|
||||||
|
? 'bg-red-600'
|
||||||
|
: usagePercentage >= 90
|
||||||
|
? 'bg-amber-500'
|
||||||
|
: 'bg-primary-600';
|
||||||
|
const limitCardClass = overSoftLimit ? 'bg-amber-50 border border-amber-200' : 'bg-neutral-50';
|
||||||
|
const limitValueClass = overSoftLimit ? 'text-amber-700' : 'text-neutral-900';
|
||||||
|
const limitDescriptorClass = overSoftLimit ? 'text-amber-700 font-semibold' : 'text-neutral-600';
|
||||||
|
const recommendedDescriptorValue = (recommendedDisplay ?? limitDisplay);
|
||||||
|
const diskMetricsReliable = storageInfo.disk_metrics_reliable;
|
||||||
|
const overrideSource = storageInfo.disk_override_source;
|
||||||
|
const overrideControlled = overrideSource === 'env';
|
||||||
|
|
||||||
|
const diskSummaryCards: Array<{ label: string; value: string }> = [];
|
||||||
|
if (diskCapacityDisplay && (diskMetricsReliable || overrideSource)) {
|
||||||
|
const label = storageInfo.disk_total != null
|
||||||
|
? t('settings.storage.diskCapacity')
|
||||||
|
: t('settings.storage.diskCapacityReported');
|
||||||
|
diskSummaryCards.push({ label, value: diskCapacityDisplay });
|
||||||
|
}
|
||||||
|
if (diskAvailableDisplay && (diskMetricsReliable || overrideSource)) {
|
||||||
|
const label = storageInfo.disk_available != null
|
||||||
|
? t('settings.storage.diskAvailable')
|
||||||
|
: t('settings.storage.diskAvailableReported');
|
||||||
|
diskSummaryCards.push({ label, value: diskAvailableDisplay });
|
||||||
|
}
|
||||||
|
if (diskFreeDisplay && storageInfo.disk_free == null && (diskMetricsReliable || overrideSource)) {
|
||||||
|
diskSummaryCards.push({
|
||||||
|
label: t('settings.storage.diskFreeReported'),
|
||||||
|
value: diskFreeDisplay
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (recommendedDisplay) {
|
||||||
|
diskSummaryCards.push({
|
||||||
|
label: t('settings.storage.recommendedSoftLimit'),
|
||||||
|
value: recommendedDisplay
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||||
|
<HardDrive className="w-5 h-5" />
|
||||||
|
{t('settings.systemStatus.storageOverview')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.storage.totalUsed')}</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{settingsService.formatBytes(storageInfo.total_used)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.storage.archiveStorage')}</p>
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">
|
||||||
|
{settingsService.formatBytes(storageInfo.archive_storage)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`rounded-lg p-4 ${limitCardClass}`}>
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.storage.storageLimit')}</p>
|
||||||
|
<p className={`text-2xl font-bold ${limitValueClass}`}>
|
||||||
|
{limitDisplay}
|
||||||
|
</p>
|
||||||
|
<p className={`text-xs mt-1 ${limitDescriptorClass}`}>
|
||||||
|
{storageInfo.soft_limit_configured
|
||||||
|
? t('admin.storageSoftLimitConfigured', { limit: limitDisplay })
|
||||||
|
: t('admin.storageSoftLimitRecommended', { limit: recommendedDescriptorValue })}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mb-4">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-neutral-600">{t('settings.storage.storageUsage')}</span>
|
||||||
|
<span className={`font-medium ${overSoftLimit ? 'text-red-600' : 'text-neutral-900'}`}>
|
||||||
|
{usagePercentage}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-neutral-200 rounded-full h-3">
|
||||||
|
<div
|
||||||
|
className={`${progressColor} h-3 rounded-full transition-all`}
|
||||||
|
style={{ width: `${usageWidth}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
|
||||||
|
<p className="text-sm text-neutral-600">
|
||||||
|
{t('settings.storage.storageLimitHelper')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{diskSummaryCards.length > 0 && (diskMetricsReliable || overrideSource) && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
{diskSummaryCards.map((card) => (
|
||||||
|
<div key={card.label} className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-xs text-neutral-500 uppercase tracking-wide">{card.label}</p>
|
||||||
|
<p className="text-lg font-semibold text-neutral-900 mt-1">{card.value}</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!diskMetricsReliable && !overrideSource && (
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{t('settings.storage.diskMetricsUnavailable')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-[minmax(0,1fr)]">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={0}
|
||||||
|
step="0.1"
|
||||||
|
value={softLimitGb === '' ? '' : softLimitGb}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setSoftLimitDirty(true);
|
||||||
|
if (value === '') {
|
||||||
|
setSoftLimitGb('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSoftLimitGb(numeric);
|
||||||
|
}}
|
||||||
|
label={t('settings.storage.softLimitInputLabel')}
|
||||||
|
helperText={t('settings.storage.softLimitHelper')}
|
||||||
|
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-neutral-500">
|
||||||
|
{t('settings.storage.limitNotEnforced')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (storageInfo.recommended_soft_limit != null) {
|
||||||
|
const value = Number((storageInfo.recommended_soft_limit / BYTES_PER_GB).toFixed(2));
|
||||||
|
setSoftLimitGb(value);
|
||||||
|
setSoftLimitDirty(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={storageInfo.recommended_soft_limit == null}
|
||||||
|
>
|
||||||
|
{t('settings.storage.applyRecommended')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
if (storageInfo.disk_available != null) {
|
||||||
|
const value = Number((storageInfo.disk_available / BYTES_PER_GB).toFixed(2));
|
||||||
|
setSoftLimitGb(value);
|
||||||
|
setSoftLimitDirty(true);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
disabled={storageInfo.disk_available == null}
|
||||||
|
>
|
||||||
|
{t('settings.storage.applyAvailable')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveSoftLimit}
|
||||||
|
isLoading={saveSoftLimitMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('settings.storage.saveSoftLimit')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t border-neutral-200 pt-4 mt-6 space-y-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-neutral-700">{t('settings.storage.overrideTitle')}</p>
|
||||||
|
{overrideControlled ? (
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideEnvNote')}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-xs text-neutral-500 mt-1">{t('settings.storage.diskOverrideSettingsHelp')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={0}
|
||||||
|
step="0.1"
|
||||||
|
value={capacityOverrideGb === '' ? '' : capacityOverrideGb}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setOverrideDirty(true);
|
||||||
|
if (value === '') {
|
||||||
|
setCapacityOverrideGb('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCapacityOverrideGb(numeric);
|
||||||
|
}}
|
||||||
|
label={t('settings.storage.overrideCapacityLabel')}
|
||||||
|
helperText={t('settings.storage.overrideCapacityHelper')}
|
||||||
|
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||||
|
disabled={overrideControlled}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
inputMode="decimal"
|
||||||
|
min={0}
|
||||||
|
step="0.1"
|
||||||
|
value={availableOverrideGb === '' ? '' : availableOverrideGb}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setOverrideDirty(true);
|
||||||
|
if (value === '') {
|
||||||
|
setAvailableOverrideGb('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const numeric = Number(value);
|
||||||
|
if (Number.isNaN(numeric)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setAvailableOverrideGb(numeric);
|
||||||
|
}}
|
||||||
|
label={t('settings.storage.overrideAvailableLabel')}
|
||||||
|
helperText={t('settings.storage.overrideAvailableHelper')}
|
||||||
|
rightIcon={<span className="text-xs font-semibold text-neutral-500 uppercase">GB</span>}
|
||||||
|
disabled={overrideControlled}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSaveCapacityOverride}
|
||||||
|
isLoading={saveCapacityOverrideMutation.isPending}
|
||||||
|
disabled={overrideControlled}
|
||||||
|
leftIcon={<Save className="w-4 h-4" />}
|
||||||
|
>
|
||||||
|
{t('settings.storage.saveOverride')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
|
||||||
|
{/* System Information */}
|
||||||
|
{systemStatus && (
|
||||||
|
<>
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||||
|
<Server className="w-5 h-5" />
|
||||||
|
{t('settings.systemStatus.systemInfo')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.systemStatus.platform')}</p>
|
||||||
|
<p className="font-semibold">{systemStatus.system.platform}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.systemStatus.nodeVersion')}</p>
|
||||||
|
<p className="font-semibold">{systemStatus.system.nodeVersion}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.systemStatus.uptime')}</p>
|
||||||
|
<p className="font-semibold">{Math.floor(systemStatus.system.uptime / 3600)}h {Math.floor((systemStatus.system.uptime % 3600) / 60)}m</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<p className="text-sm text-neutral-600">{t('settings.systemStatus.cpuCores')}</p>
|
||||||
|
<p className="font-semibold">{systemStatus.system.cpu.cores}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-neutral-900 mb-2">{t('settings.systemStatus.memoryUsage')}</h3>
|
||||||
|
<div className="mb-2">
|
||||||
|
<div className="flex justify-between text-sm mb-1">
|
||||||
|
<span className="text-neutral-600">{t('settings.systemStatus.memoryUsed')}</span>
|
||||||
|
<span className="font-medium">
|
||||||
|
{settingsService.formatBytes(systemStatus.system.memory.used)} / {settingsService.formatBytes(systemStatus.system.memory.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-neutral-200 rounded-full h-2">
|
||||||
|
<div
|
||||||
|
className="bg-blue-600 h-2 rounded-full transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${Math.round((systemStatus.system.memory.used / systemStatus.system.memory.total) * 100)}%`
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||||
|
<Database className="w-5 h-5" />
|
||||||
|
{t('settings.systemStatus.databaseInfo')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.events}</p>
|
||||||
|
<p className="text-xs text-neutral-600">{t('navigation.events')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.photos}</p>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.systemStatus.photos')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.admins}</p>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.systemStatus.admins')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">{systemStatus.database.tables.categories}</p>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.categories.title')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-3 text-center">
|
||||||
|
<p className="text-2xl font-bold text-neutral-900">{settingsService.formatBytes(systemStatus.database.size)}</p>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.systemStatus.dbSize')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card padding="md">
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 mb-4 flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5" />
|
||||||
|
{t('settings.systemStatus.services')}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.fileWatcher')}</p>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.systemStatus.fileWatcherDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.expirationChecker')}</p>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.systemStatus.expirationCheckerDesc')}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-neutral-50 rounded-lg p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<p className="text-sm font-medium text-neutral-700">{t('settings.systemStatus.emailProcessor')}</p>
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-600" />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-neutral-600">{t('settings.systemStatus.emailProcessorDesc')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||||
|
<h3 className="text-sm font-semibold text-blue-900 mb-2">{t('settings.systemStatus.emailQueue')}</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="text-blue-700">{t('settings.systemStatus.pending')}:</span>
|
||||||
|
<span className="ml-2 font-semibold text-blue-900">
|
||||||
|
{systemStatus.emailQueue.pending}
|
||||||
|
{systemStatus.emailQueue.stuck > 0 && (
|
||||||
|
<span className="text-orange-600 text-xs ml-1">
|
||||||
|
({systemStatus.emailQueue.stuck} stuck)
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-green-700">{t('settings.systemStatus.sent')}:</span>
|
||||||
|
<span className="ml-2 font-semibold text-green-900">{systemStatus.emailQueue.sent}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span className="text-red-700">{t('settings.systemStatus.failed')}:</span>
|
||||||
|
<span className="ml-2 font-semibold text-red-900">{systemStatus.emailQueue.failed}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{systemStatus.emailQueue.stuck > 0 && (
|
||||||
|
<div className="mt-3 p-3 bg-orange-50 rounded-md">
|
||||||
|
<p className="text-xs text-orange-800">
|
||||||
|
<span className="font-semibold">Warning: {systemStatus.emailQueue.stuck} email(s) stuck:</span> These emails have exceeded retry limits and won't be processed automatically.
|
||||||
|
Only {systemStatus.emailQueue.processable} of {systemStatus.emailQueue.pending} pending emails will be processed.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Last update time */}
|
||||||
|
{systemStatus && (
|
||||||
|
<div className="text-xs text-neutral-500 text-right flex items-center justify-end gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{t('settings.systemStatus.lastUpdate')}: {new Date(systemStatus.timestamp).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { CssTemplateEditor } from '../../../components/admin/CssTemplateEditor';
|
||||||
|
|
||||||
|
export const StylingTab: React.FC = () => {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<CssTemplateEditor />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -26,6 +26,9 @@ i18n
|
|||||||
interpolation: {
|
interpolation: {
|
||||||
escapeValue: false,
|
escapeValue: false,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Use v3 format for pluralization (_plural suffix instead of _one/_other)
|
||||||
|
compatibilityJSON: 'v3',
|
||||||
|
|
||||||
detection: {
|
detection: {
|
||||||
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
|
order: ['localStorage', 'cookie', 'navigator', 'htmlTag'],
|
||||||
|
|||||||
@@ -582,6 +582,11 @@
|
|||||||
"noThemeSet": "Kein Design konfiguriert",
|
"noThemeSet": "Kein Design konfiguriert",
|
||||||
"customizingTheme": "Galerie-Design anpassen",
|
"customizingTheme": "Galerie-Design anpassen",
|
||||||
"customizingThemeFor": "Design für {{event}} anpassen",
|
"customizingThemeFor": "Design für {{event}} anpassen",
|
||||||
|
"customCssTemplate": "Benutzerdefinierte CSS-Vorlage",
|
||||||
|
"customCssTemplateDesc": "Verwenden Sie eine CSS-Vorlage, um die Galerie mit einzigartigen visuellen Effekten zu gestalten.",
|
||||||
|
"noTemplate": "Keine Vorlage",
|
||||||
|
"useThemeOnly": "Nur Design-Vorlage verwenden",
|
||||||
|
"customTemplate": "Benutzerdefinierte Vorlage",
|
||||||
"title": "Veranstaltungen",
|
"title": "Veranstaltungen",
|
||||||
"create": "Veranstaltung erstellen",
|
"create": "Veranstaltung erstellen",
|
||||||
"createEvent": "Veranstaltung erstellen",
|
"createEvent": "Veranstaltung erstellen",
|
||||||
@@ -745,6 +750,7 @@
|
|||||||
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
|
"bulkArchivePartial": "{{success}} Veranstaltungen archiviert, {{failed}} fehlgeschlagen",
|
||||||
"searchEventsPlaceholder": "Veranstaltungen suchen...",
|
"searchEventsPlaceholder": "Veranstaltungen suchen...",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
|
"expiring": "Läuft ab",
|
||||||
"activeFilter": "Aktiv",
|
"activeFilter": "Aktiv",
|
||||||
"archivedFilter": "Archiviert",
|
"archivedFilter": "Archiviert",
|
||||||
"sortByName": "Nach Name",
|
"sortByName": "Nach Name",
|
||||||
@@ -776,6 +782,18 @@
|
|||||||
"bulkArchive": "Archivieren",
|
"bulkArchive": "Archivieren",
|
||||||
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
"confirmBulkArchive": "Sind Sie sicher, dass Sie {{count}} Veranstaltung(en) archivieren möchten?",
|
||||||
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
|
"confirmBulkArchiveDescription": "Diese Aktion kann nicht rückgängig gemacht werden. Archivierte Veranstaltungen sind nicht mehr öffentlich zugänglich.",
|
||||||
|
"copy": "Kopieren",
|
||||||
|
"copied": "Kopiert!",
|
||||||
|
"rename": {
|
||||||
|
"button": "Umbenennen",
|
||||||
|
"title": "Veranstaltung umbenennen",
|
||||||
|
"validating": "Neuer Name wird überprüft...",
|
||||||
|
"renamingFiles": "Dateien werden umbenannt...",
|
||||||
|
"complete": "Fertig!",
|
||||||
|
"failed": "Umbenennung fehlgeschlagen",
|
||||||
|
"filesRenamed": "{{count}} Dateien aktualisiert",
|
||||||
|
"confirm": "Veranstaltung umbenennen"
|
||||||
|
},
|
||||||
"stats": {
|
"stats": {
|
||||||
"totalEvents": "Gesamtveranstaltungen",
|
"totalEvents": "Gesamtveranstaltungen",
|
||||||
"activeEvents": "Aktive Veranstaltungen",
|
"activeEvents": "Aktive Veranstaltungen",
|
||||||
@@ -1089,6 +1107,17 @@
|
|||||||
"waves": "Wellen"
|
"waves": "Wellen"
|
||||||
},
|
},
|
||||||
"customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
|
"customCSSHelp": "Erweitert: Fügen Sie benutzerdefiniertes CSS hinzu, um das Erscheinungsbild weiter anzupassen",
|
||||||
|
"cssInstructions": {
|
||||||
|
"title": "Benutzerdefiniertes CSS verwenden",
|
||||||
|
"variables": "Design CSS-Variablen",
|
||||||
|
"variablesDesc": "Verwenden Sie diese CSS-Variablen passend zu Ihren Design-Vorlagen:",
|
||||||
|
"layouts": "Benutzerdefinierte Galerie-Layouts",
|
||||||
|
"layoutsDesc": "Sprechen Sie Galerie-Elemente mit diesen Selektoren an:",
|
||||||
|
"glassEffect": "Glasmorphismus-Effekt",
|
||||||
|
"glassEffectDesc": "Erstellen Sie moderne Glaseffekte:",
|
||||||
|
"tip": "Tipp",
|
||||||
|
"tipText": "Verwenden Sie CSS-Vorlagen unter Einstellungen > CSS-Vorlagen für vorgefertigte Designs wie Apple Liquid Glass."
|
||||||
|
},
|
||||||
"resetToDefault": "Auf Standard zurücksetzen",
|
"resetToDefault": "Auf Standard zurücksetzen",
|
||||||
"applyTheme": "Theme anwenden",
|
"applyTheme": "Theme anwenden",
|
||||||
"customTheme": "Benutzerdefiniertes Design",
|
"customTheme": "Benutzerdefiniertes Design",
|
||||||
@@ -1231,6 +1260,7 @@
|
|||||||
"photos_bulk_deleted": "{{count}} Fotos gelöscht aus {{eventName}}",
|
"photos_bulk_deleted": "{{count}} Fotos gelöscht aus {{eventName}}",
|
||||||
"settings_updated": "Einstellungen aktualisiert",
|
"settings_updated": "Einstellungen aktualisiert",
|
||||||
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
"event_updated": "Veranstaltung aktualisiert: {{eventName}}",
|
||||||
|
"event_renamed": "Veranstaltung umbenannt: {{eventName}}",
|
||||||
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
"event_deleted": "Veranstaltung gelöscht: {{eventName}}",
|
||||||
"password_changed": "Passwort geändert",
|
"password_changed": "Passwort geändert",
|
||||||
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
|
"email_resent": "Erstellungs-E-Mail erneut gesendet für: {{eventName}}",
|
||||||
@@ -1933,7 +1963,45 @@
|
|||||||
"photoFeedback": "Foto-Feedback",
|
"photoFeedback": "Foto-Feedback",
|
||||||
"hasFeedback": "Hat Feedback",
|
"hasFeedback": "Hat Feedback",
|
||||||
"hasComments": "Hat Kommentare",
|
"hasComments": "Hat Kommentare",
|
||||||
"hasRating": "Hat Bewertung"
|
"hasRating": "Hat Bewertung",
|
||||||
|
"settings": {
|
||||||
|
"title": "Gast-Feedback-Einstellungen",
|
||||||
|
"enableFeedback": "Feedback aktivieren",
|
||||||
|
"feedbackTypes": "Feedback-Typen",
|
||||||
|
"ratings": "Sternebewertungen",
|
||||||
|
"ratingsDesc": "Gästen erlauben, Fotos zu bewerten (1-5 Sterne)",
|
||||||
|
"likes": "Gefällt mir",
|
||||||
|
"likesDesc": "Einfache Gefällt mir-Funktion",
|
||||||
|
"comments": "Kommentare",
|
||||||
|
"commentsDesc": "Textkommentare auf Fotos",
|
||||||
|
"favorites": "Favoriten",
|
||||||
|
"favoritesDesc": "Fotos als Favoriten markieren"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"feedbackFilters": "Feedback-Filter",
|
||||||
|
"clear": "Löschen",
|
||||||
|
"rating": "Bewertung",
|
||||||
|
"allPhotos": "Alle Fotos",
|
||||||
|
"anyRating": "Jede Bewertung",
|
||||||
|
"oneStarPlus": "1+ Sterne",
|
||||||
|
"twoStarsPlus": "2+ Sterne",
|
||||||
|
"threeStarsPlus": "3+ Sterne",
|
||||||
|
"fourStarsPlus": "4+ Sterne",
|
||||||
|
"fiveStarsOnly": "Nur 5 Sterne",
|
||||||
|
"hasLikes": "Hat Gefällt mir",
|
||||||
|
"hasFavorites": "Hat Favoriten",
|
||||||
|
"hasComments": "Hat Kommentare",
|
||||||
|
"showingPhotos": "Fotos gesamt",
|
||||||
|
"withRatings": "Mit Bewertungen"
|
||||||
|
},
|
||||||
|
"export": {
|
||||||
|
"button": "Exportieren",
|
||||||
|
"success": "Export erfolgreich heruntergeladen",
|
||||||
|
"error": "Export fehlgeschlagen: ",
|
||||||
|
"exportSelected": "{{count}} ausgewählte exportieren",
|
||||||
|
"exportFiltered": "Gefilterte Fotos exportieren",
|
||||||
|
"hint": "Fotos auswählen oder Filter anwenden zum Exportieren"
|
||||||
},
|
},
|
||||||
"adminLogin": {
|
"adminLogin": {
|
||||||
"title": "Admin-Anmeldung",
|
"title": "Admin-Anmeldung",
|
||||||
|
|||||||
@@ -461,7 +461,22 @@
|
|||||||
"customizeTheme": "Customize Theme",
|
"customizeTheme": "Customize Theme",
|
||||||
"noThemeSet": "No theme configured",
|
"noThemeSet": "No theme configured",
|
||||||
"customizingTheme": "Customizing gallery theme",
|
"customizingTheme": "Customizing gallery theme",
|
||||||
"customizingThemeFor": "Customizing theme for {{event}}"
|
"customizingThemeFor": "Customizing theme for {{event}}",
|
||||||
|
"customCssTemplate": "Custom CSS Template",
|
||||||
|
"customCssTemplateDesc": "Apply a custom CSS template to style the gallery with unique visual effects.",
|
||||||
|
"noTemplate": "No Template",
|
||||||
|
"useThemeOnly": "Use theme preset only",
|
||||||
|
"customTemplate": "Custom Template",
|
||||||
|
"rename": {
|
||||||
|
"button": "Rename",
|
||||||
|
"title": "Rename Event",
|
||||||
|
"validating": "Validating new name...",
|
||||||
|
"renamingFiles": "Renaming files...",
|
||||||
|
"complete": "Complete!",
|
||||||
|
"failed": "Rename failed",
|
||||||
|
"filesRenamed": "{{count}} files updated",
|
||||||
|
"confirm": "Rename Event"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"settings": {
|
"settings": {
|
||||||
"title": "System Settings",
|
"title": "System Settings",
|
||||||
@@ -827,6 +842,17 @@
|
|||||||
"waves": "Waves"
|
"waves": "Waves"
|
||||||
},
|
},
|
||||||
"customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
|
"customCSSHelp": "Advanced: Add custom CSS to further customize the appearance",
|
||||||
|
"cssInstructions": {
|
||||||
|
"title": "How to use Custom CSS",
|
||||||
|
"variables": "Theme CSS Variables",
|
||||||
|
"variablesDesc": "Use these CSS variables to match your theme presets:",
|
||||||
|
"layouts": "Custom Gallery Layouts",
|
||||||
|
"layoutsDesc": "Target gallery elements with these selectors:",
|
||||||
|
"glassEffect": "Glassmorphism Effect",
|
||||||
|
"glassEffectDesc": "Create modern glass effects:",
|
||||||
|
"tip": "Tip",
|
||||||
|
"tipText": "Use CSS Templates from Settings > CSS Templates for pre-built designs like Apple Liquid Glass."
|
||||||
|
},
|
||||||
"resetToDefault": "Reset to Default",
|
"resetToDefault": "Reset to Default",
|
||||||
"applyTheme": "Apply Theme",
|
"applyTheme": "Apply Theme",
|
||||||
"customTheme": "Custom Theme",
|
"customTheme": "Custom Theme",
|
||||||
@@ -967,6 +993,7 @@
|
|||||||
"photos_bulk_deleted": "{{count}} photos deleted from {{eventName}}",
|
"photos_bulk_deleted": "{{count}} photos deleted from {{eventName}}",
|
||||||
"settings_updated": "Settings updated",
|
"settings_updated": "Settings updated",
|
||||||
"event_updated": "Event updated: {{eventName}}",
|
"event_updated": "Event updated: {{eventName}}",
|
||||||
|
"event_renamed": "Event renamed: {{eventName}}",
|
||||||
"event_deleted": "Event deleted: {{eventName}}",
|
"event_deleted": "Event deleted: {{eventName}}",
|
||||||
"password_changed": "Password changed",
|
"password_changed": "Password changed",
|
||||||
"email_resent": "Creation email resent for: {{eventName}}",
|
"email_resent": "Creation email resent for: {{eventName}}",
|
||||||
@@ -1631,7 +1658,35 @@
|
|||||||
"photoFeedback": "Photo Feedback",
|
"photoFeedback": "Photo Feedback",
|
||||||
"hasFeedback": "Has feedback",
|
"hasFeedback": "Has feedback",
|
||||||
"hasComments": "Has comments",
|
"hasComments": "Has comments",
|
||||||
"hasRating": "Has rating"
|
"hasRating": "Has rating",
|
||||||
|
"settings": {
|
||||||
|
"title": "Guest Feedback Settings",
|
||||||
|
"enableFeedback": "Enable feedback",
|
||||||
|
"feedbackTypes": "Feedback Types",
|
||||||
|
"ratings": "Star Ratings",
|
||||||
|
"ratingsDesc": "Allow guests to rate photos (1-5 stars)",
|
||||||
|
"likes": "Likes",
|
||||||
|
"likesDesc": "Simple like/unlike functionality",
|
||||||
|
"comments": "Comments",
|
||||||
|
"commentsDesc": "Text comments on photos",
|
||||||
|
"favorites": "Favorites",
|
||||||
|
"favoritesDesc": "Mark photos as favorites"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"filter": {
|
||||||
|
"feedbackFilters": "Feedback Filters",
|
||||||
|
"clear": "Clear",
|
||||||
|
"rating": "Rating",
|
||||||
|
"allPhotos": "All Photos",
|
||||||
|
"anyRating": "Any Rating",
|
||||||
|
"oneStarPlus": "1+ Stars",
|
||||||
|
"twoStarsPlus": "2+ Stars",
|
||||||
|
"threeStarsPlus": "3+ Stars",
|
||||||
|
"fourStarsPlus": "4+ Stars",
|
||||||
|
"fiveStarsOnly": "5 Stars Only",
|
||||||
|
"hasLikes": "Has likes",
|
||||||
|
"hasFavorites": "Has favorites",
|
||||||
|
"hasComments": "Has comments"
|
||||||
},
|
},
|
||||||
"adminLogin": {
|
"adminLogin": {
|
||||||
"title": "Admin Login",
|
"title": "Admin Login",
|
||||||
|
|||||||
@@ -383,18 +383,23 @@ export const BrandingPage: React.FC = () => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<label className="cursor-pointer">
|
<div>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
accept="image/png,image/jpeg,image/svg+xml"
|
accept="image/png,image/jpeg,image/svg+xml"
|
||||||
onChange={handleLogoUpload}
|
onChange={handleLogoUpload}
|
||||||
className="hidden"
|
className="hidden"
|
||||||
|
id="logo-upload"
|
||||||
/>
|
/>
|
||||||
<span className="btn-secondary inline-flex items-center">
|
<Button
|
||||||
<Upload className="w-4 h-4 mr-2" />
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => document.getElementById('logo-upload')?.click()}
|
||||||
|
leftIcon={<Upload className="w-4 h-4" />}
|
||||||
|
>
|
||||||
{brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
|
{brandingSettings.logo_url ? t('branding.changeLogo', 'Change Logo') : t('branding.uploadLogo', 'Upload Logo')}
|
||||||
</span>
|
</Button>
|
||||||
</label>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-neutral-600 mt-1">
|
<p className="text-xs text-neutral-600 mt-1">
|
||||||
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
|
{t('branding.logoHelp', 'PNG, JPG or SVG format, recommended width: 200px')}
|
||||||
|
|||||||
@@ -20,8 +20,10 @@ import { eventsService } from '../../services/events.service';
|
|||||||
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
import { useLocalizedDate } from '../../hooks/useLocalizedDate';
|
||||||
import { categoriesService } from '../../services/categories.service';
|
import { categoriesService } from '../../services/categories.service';
|
||||||
import { settingsService } from '../../services/settings.service';
|
import { settingsService } from '../../services/settings.service';
|
||||||
|
import { cssTemplatesService } from '../../services/cssTemplates.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
import { ThemeConfig, GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
||||||
|
import { Code } from 'lucide-react';
|
||||||
|
|
||||||
interface FormData {
|
interface FormData {
|
||||||
event_type: string;
|
event_type: string;
|
||||||
@@ -39,6 +41,7 @@ interface FormData {
|
|||||||
expires_in_days: number;
|
expires_in_days: number;
|
||||||
allow_user_uploads: boolean;
|
allow_user_uploads: boolean;
|
||||||
upload_category_id: number | null;
|
upload_category_id: number | null;
|
||||||
|
css_template_id: number | null;
|
||||||
feedback_settings: {
|
feedback_settings: {
|
||||||
feedback_enabled: boolean;
|
feedback_enabled: boolean;
|
||||||
allow_ratings: boolean;
|
allow_ratings: boolean;
|
||||||
@@ -98,6 +101,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
expires_in_days: 30,
|
expires_in_days: 30,
|
||||||
allow_user_uploads: false,
|
allow_user_uploads: false,
|
||||||
upload_category_id: null,
|
upload_category_id: null,
|
||||||
|
css_template_id: null,
|
||||||
feedback_settings: {
|
feedback_settings: {
|
||||||
feedback_enabled: false,
|
feedback_enabled: false,
|
||||||
allow_ratings: true,
|
allow_ratings: true,
|
||||||
@@ -122,6 +126,12 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
queryFn: () => categoriesService.getGlobalCategories()
|
queryFn: () => categoriesService.getGlobalCategories()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch enabled CSS templates
|
||||||
|
const { data: cssTemplates } = useQuery({
|
||||||
|
queryKey: ['css-templates', 'enabled'],
|
||||||
|
queryFn: () => cssTemplatesService.getEnabledTemplates()
|
||||||
|
});
|
||||||
|
|
||||||
// Fetch default settings
|
// Fetch default settings
|
||||||
const { data: settings } = useQuery({
|
const { data: settings } = useQuery({
|
||||||
queryKey: ['admin-settings'],
|
queryKey: ['admin-settings'],
|
||||||
@@ -268,6 +278,7 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
expiration_days: formData.expires_in_days,
|
expiration_days: formData.expires_in_days,
|
||||||
allow_user_uploads: formData.allow_user_uploads,
|
allow_user_uploads: formData.allow_user_uploads,
|
||||||
upload_category_id: formData.upload_category_id,
|
upload_category_id: formData.upload_category_id,
|
||||||
|
css_template_id: formData.css_template_id,
|
||||||
feedback_enabled: feedbackSettings.feedback_enabled,
|
feedback_enabled: feedbackSettings.feedback_enabled,
|
||||||
allow_ratings: feedbackSettings.allow_ratings,
|
allow_ratings: feedbackSettings.allow_ratings,
|
||||||
allow_likes: feedbackSettings.allow_likes,
|
allow_likes: feedbackSettings.allow_likes,
|
||||||
@@ -478,6 +489,55 @@ export const CreateEventPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Custom CSS Template Selection */}
|
||||||
|
{cssTemplates && cssTemplates.length > 0 && (
|
||||||
|
<div className="pt-6 border-t border-neutral-200">
|
||||||
|
<h3 className="text-md font-semibold text-neutral-900 mb-3 flex items-center gap-2">
|
||||||
|
<Code className="w-4 h-4" />
|
||||||
|
{t('events.customCssTemplate', 'Custom CSS Template')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-neutral-600 mb-4">
|
||||||
|
{t('events.customCssTemplateDesc', 'Apply a custom CSS template to style the gallery with unique visual effects.')}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
|
{/* No template option */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, css_template_id: null })}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
|
formData.css_template_id === null
|
||||||
|
? 'border-primary-600 bg-primary-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{t('events.noTemplate', 'No Template')}</div>
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('events.useThemeOnly', 'Use theme preset only')}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Available templates */}
|
||||||
|
{cssTemplates.map(template => (
|
||||||
|
<button
|
||||||
|
key={template.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, css_template_id: template.id })}
|
||||||
|
className={`p-4 rounded-lg border-2 transition-all text-left ${
|
||||||
|
formData.css_template_id === template.id
|
||||||
|
? 'border-primary-600 bg-primary-50'
|
||||||
|
: 'border-neutral-200 hover:border-neutral-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium text-sm">{template.name}</div>
|
||||||
|
<div className="text-xs text-neutral-500 mt-1">
|
||||||
|
{t('events.customTemplate', 'Custom Template')} {template.slot_number}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -950,9 +950,9 @@ export const EventDetailsPage: React.FC = () => {
|
|||||||
) : (
|
) : (
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<dt className="text-sm font-medium text-neutral-500">Source Mode</dt>
|
<dt className="text-sm font-medium text-neutral-500">{t('events.sourceMode', 'Source Mode')}</dt>
|
||||||
<dd className="mt-1 text-sm text-neutral-900">
|
<dd className="mt-1 text-sm text-neutral-900">
|
||||||
{event.source_mode === 'reference' ? 'Reference (external folder)' : 'Managed (upload)'}
|
{event.source_mode === 'reference' ? t('events.sourceModeReference', 'Reference external folder') : t('events.sourceModeManaged', 'Managed (upload to PicPeak)')}
|
||||||
{event.source_mode === 'reference' && event.external_path ? (
|
{event.source_mode === 'reference' && event.external_path ? (
|
||||||
<span className="text-neutral-500 ml-2">/external-media/{event.external_path}</span>
|
<span className="text-neutral-500 ml-2">/external-media/{event.external_path}</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -76,7 +76,14 @@ class CssTemplatesService {
|
|||||||
async getGalleryCss(slug: string): Promise<string | null> {
|
async getGalleryCss(slug: string): Promise<string | null> {
|
||||||
try {
|
try {
|
||||||
const response = await api.get(`/gallery/${slug}/css-template`, {
|
const response = await api.get(`/gallery/${slug}/css-template`, {
|
||||||
responseType: 'text'
|
responseType: 'text',
|
||||||
|
headers: {
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
'Pragma': 'no-cache'
|
||||||
|
},
|
||||||
|
params: {
|
||||||
|
_t: Date.now() // Cache-busting parameter
|
||||||
|
}
|
||||||
});
|
});
|
||||||
if (response.status === 204) {
|
if (response.status === 204) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
@@ -29,11 +29,11 @@ const config: VitestUserConfig = {
|
|||||||
host: true,
|
host: true,
|
||||||
proxy: {
|
proxy: {
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:7101',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
'/photos': {
|
'/photos': {
|
||||||
target: 'http://localhost:3001',
|
target: 'http://localhost:7101',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in New Issue
Block a user