Merge pull request #241 from the-luap/fix/external-media-dimensions-and-email-colors
fix: external media dimensions, theme race condition, email color customization
This commit is contained in:
@@ -5,6 +5,7 @@ const { adminAuth } = require('../middleware/auth');
|
|||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
const { list, resolveExternalPath, getExternalMediaRoot } = require('../services/externalMediaService');
|
||||||
const { db, logActivity } = require('../database/db');
|
const { db, logActivity } = require('../database/db');
|
||||||
|
const sharp = require('sharp');
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
@@ -108,6 +109,18 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
|||||||
.first();
|
.first();
|
||||||
if (exists) { skipped++; continue; }
|
if (exists) { skipped++; continue; }
|
||||||
const stats = await fs.stat(f.full);
|
const stats = await fs.stat(f.full);
|
||||||
|
|
||||||
|
// Extract dimensions via Sharp
|
||||||
|
let width = null;
|
||||||
|
let height = null;
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(f.full).metadata();
|
||||||
|
width = metadata.width || null;
|
||||||
|
height = metadata.height || null;
|
||||||
|
} catch (dimErr) {
|
||||||
|
logger.warn(`Could not extract dimensions for ${f.rel}: ${dimErr.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
const inserted = await db('photos')
|
const inserted = await db('photos')
|
||||||
.insert({
|
.insert({
|
||||||
event_id: eventId,
|
event_id: eventId,
|
||||||
@@ -117,6 +130,8 @@ router.post('/events/:id/import-external', adminAuth, requirePermission('photos.
|
|||||||
thumbnail_path: null,
|
thumbnail_path: null,
|
||||||
type,
|
type,
|
||||||
size_bytes: stats.size,
|
size_bytes: stats.size,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
source_origin: 'external',
|
source_origin: 'external',
|
||||||
external_relpath: f.rel
|
external_relpath: f.rel
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -3,11 +3,10 @@ const router = express.Router();
|
|||||||
const { db } = require('../database/db');
|
const { db } = require('../database/db');
|
||||||
const { adminAuth } = require('../middleware/auth');
|
const { adminAuth } = require('../middleware/auth');
|
||||||
const { requirePermission } = require('../middleware/permissions');
|
const { requirePermission } = require('../middleware/permissions');
|
||||||
const path = require('path');
|
|
||||||
const fs = require('fs').promises;
|
const fs = require('fs').promises;
|
||||||
const logger = require('../utils/logger');
|
const logger = require('../utils/logger');
|
||||||
|
|
||||||
const getStoragePath = () => process.env.STORAGE_PATH || path.join(__dirname, '../../../storage');
|
const { resolvePhotoFilePath } = require('../services/photoResolver');
|
||||||
|
|
||||||
// Module-level progress state
|
// Module-level progress state
|
||||||
let repairProgress = {
|
let repairProgress = {
|
||||||
@@ -23,13 +22,18 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
|||||||
}
|
}
|
||||||
|
|
||||||
const photos = await db('photos')
|
const photos = await db('photos')
|
||||||
|
.join('events', 'photos.event_id', 'events.id')
|
||||||
.where(function () {
|
.where(function () {
|
||||||
this.whereNull('width').orWhereNull('height');
|
this.whereNull('photos.width').orWhereNull('photos.height');
|
||||||
})
|
})
|
||||||
.where(function () {
|
.where(function () {
|
||||||
this.where('media_type', '!=', 'video').orWhereNull('media_type');
|
this.where('photos.media_type', '!=', 'video').orWhereNull('photos.media_type');
|
||||||
})
|
})
|
||||||
.select('id', 'path', 'filename');
|
.select(
|
||||||
|
'photos.id', 'photos.path', 'photos.filename',
|
||||||
|
'photos.source_origin', 'photos.external_relpath', 'photos.event_id',
|
||||||
|
'events.source_mode', 'events.external_path', 'events.slug'
|
||||||
|
);
|
||||||
|
|
||||||
if (photos.length === 0) {
|
if (photos.length === 0) {
|
||||||
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
return res.json({ message: 'No photos need dimension repair', count: 0 });
|
||||||
@@ -61,15 +65,16 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
|||||||
|
|
||||||
for (const photo of photos) {
|
for (const photo of photos) {
|
||||||
try {
|
try {
|
||||||
if (!photo.path) {
|
const event = { source_mode: photo.source_mode, external_path: photo.external_path, slug: photo.slug };
|
||||||
logger.warn(`Photo ${photo.id} has no path, skipping dimension repair`);
|
let fullPath;
|
||||||
|
try {
|
||||||
|
fullPath = resolvePhotoFilePath(event, photo);
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn(`Photo ${photo.id} has no resolvable path, skipping dimension repair: ${err.message}`);
|
||||||
errorCount++;
|
errorCount++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const storagePath = getStoragePath();
|
|
||||||
const fullPath = path.join(storagePath, 'events/active', photo.path);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await fs.access(fullPath);
|
await fs.access(fullPath);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -85,8 +90,7 @@ router.post('/repair-dimensions', adminAuth, requirePermission('photos.edit'), a
|
|||||||
.where({ id: photo.id })
|
.where({ id: photo.id })
|
||||||
.update({
|
.update({
|
||||||
width: metadata.width,
|
width: metadata.width,
|
||||||
height: metadata.height,
|
height: metadata.height
|
||||||
updated_at: db.fn.now()
|
|
||||||
});
|
});
|
||||||
successCount++;
|
successCount++;
|
||||||
|
|
||||||
|
|||||||
@@ -111,35 +111,48 @@ async function getRecipientLanguage(email, eventId = null) {
|
|||||||
return 'en'; // Default to English
|
return 'en'; // Default to English
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Darken a hex color by a percentage (0-1)
|
||||||
|
function darkenColor(hex, amount = 0.15) {
|
||||||
|
const num = parseInt(hex.replace('#', ''), 16);
|
||||||
|
const r = Math.max(0, Math.min(255, ((num >> 16) & 0xFF) * (1 - amount)));
|
||||||
|
const g = Math.max(0, Math.min(255, ((num >> 8) & 0xFF) * (1 - amount)));
|
||||||
|
const b = Math.max(0, Math.min(255, (num & 0xFF) * (1 - amount)));
|
||||||
|
return `#${(1 << 24 | Math.round(r) << 16 | Math.round(g) << 8 | Math.round(b)).toString(16).slice(1)}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap HTML body in the styled email template with header, footer, and logo
|
// Wrap HTML body in the styled email template with header, footer, and logo
|
||||||
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
||||||
// Get branding settings for logo
|
// Get branding settings for logo and email colors
|
||||||
let logoUrl = '';
|
let logoUrl = '';
|
||||||
let companyName = 'PicPeak';
|
let companyName = 'PicPeak';
|
||||||
|
let primaryColor = '#5C8762';
|
||||||
|
let secondaryColor = '#f9f9f9';
|
||||||
try {
|
try {
|
||||||
const brandingSettings = await db('app_settings')
|
const brandingSettings = await db('app_settings')
|
||||||
.whereIn('setting_key', ['branding_logo_url', 'branding_company_name'])
|
.whereIn('setting_key', [
|
||||||
|
'branding_logo_url', 'branding_company_name',
|
||||||
|
'email_primary_color', 'email_secondary_color'
|
||||||
|
])
|
||||||
.select('setting_key', 'setting_value');
|
.select('setting_key', 'setting_value');
|
||||||
|
|
||||||
brandingSettings.forEach(setting => {
|
brandingSettings.forEach(setting => {
|
||||||
if (setting.setting_key === 'branding_logo_url' && setting.setting_value) {
|
const val = setting.setting_value;
|
||||||
try {
|
if (setting.setting_key === 'branding_logo_url' && val) {
|
||||||
logoUrl = JSON.parse(setting.setting_value);
|
try { logoUrl = JSON.parse(val); } catch (e) { logoUrl = val; }
|
||||||
} catch (e) {
|
} else if (setting.setting_key === 'branding_company_name' && val) {
|
||||||
logoUrl = setting.setting_value;
|
try { companyName = JSON.parse(val); } catch (e) { companyName = val; }
|
||||||
}
|
} else if (setting.setting_key === 'email_primary_color' && val) {
|
||||||
} else if (setting.setting_key === 'branding_company_name' && setting.setting_value) {
|
try { primaryColor = JSON.parse(val); } catch (e) { primaryColor = val; }
|
||||||
try {
|
} else if (setting.setting_key === 'email_secondary_color' && val) {
|
||||||
companyName = JSON.parse(setting.setting_value);
|
try { secondaryColor = JSON.parse(val); } catch (e) { secondaryColor = val; }
|
||||||
} catch (e) {
|
|
||||||
companyName = setting.setting_value;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error('Error fetching branding settings:', error);
|
logger.error('Error fetching branding settings:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hoverColor = darkenColor(primaryColor, 0.15);
|
||||||
|
|
||||||
// If no custom logo, use default PicPeak logo
|
// If no custom logo, use default PicPeak logo
|
||||||
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
const frontendUrl = process.env.FRONTEND_URL || 'http://localhost:3000';
|
||||||
const logoFullUrl = `${frontendUrl}${logoUrl || '/picpeak-logo-transparent.png'}`;
|
const logoFullUrl = `${frontendUrl}${logoUrl || '/picpeak-logo-transparent.png'}`;
|
||||||
@@ -172,7 +185,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.email-header {
|
.email-header {
|
||||||
background-color: #5C8762;
|
background-color: ${primaryColor};
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
@@ -185,7 +198,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
|||||||
padding: 40px 30px;
|
padding: 40px 30px;
|
||||||
}
|
}
|
||||||
.email-content h2 {
|
.email-content h2 {
|
||||||
color: #5C8762;
|
color: ${primaryColor};
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 20px;
|
margin-bottom: 20px;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
@@ -206,7 +219,7 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
|||||||
.button {
|
.button {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
padding: 12px 30px;
|
padding: 12px 30px;
|
||||||
background-color: #5C8762;
|
background-color: ${primaryColor};
|
||||||
color: white !important;
|
color: white !important;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
@@ -214,10 +227,10 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
|||||||
margin: 20px 0;
|
margin: 20px 0;
|
||||||
}
|
}
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background-color: #4a6f4f;
|
background-color: ${hoverColor};
|
||||||
}
|
}
|
||||||
.email-footer {
|
.email-footer {
|
||||||
background-color: #f9f9f9;
|
background-color: ${secondaryColor};
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-top: 1px solid #eee;
|
border-top: 1px solid #eee;
|
||||||
@@ -234,11 +247,11 @@ async function wrapEmailHtml(htmlBody, subject, language = 'en') {
|
|||||||
margin: 5px 0;
|
margin: 5px 0;
|
||||||
}
|
}
|
||||||
a {
|
a {
|
||||||
color: #5C8762;
|
color: ${primaryColor};
|
||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
a:hover {
|
a:hover {
|
||||||
color: #4a6f4f;
|
color: ${hoverColor};
|
||||||
}
|
}
|
||||||
strong {
|
strong {
|
||||||
color: #333;
|
color: #333;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { UserPhotoUpload } from './UserPhotoUpload';
|
|||||||
import type { FilterType } from './GalleryFilter';
|
import type { FilterType } from './GalleryFilter';
|
||||||
import { analyticsService } from '../../services/analytics.service';
|
import { analyticsService } from '../../services/analytics.service';
|
||||||
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
import { useDevToolsProtection } from '../../hooks/useDevToolsProtection';
|
||||||
import { GALLERY_THEME_PRESETS } from '../../types/theme.types';
|
|
||||||
import { api } from '../../config/api';
|
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';
|
||||||
@@ -44,7 +43,7 @@ interface GalleryViewProps {
|
|||||||
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { logout } = useGalleryAuth();
|
const { logout } = useGalleryAuth();
|
||||||
const { setTheme, theme } = useTheme();
|
const { theme } = useTheme();
|
||||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
const [selectedCategoryId, setSelectedCategoryId] = useState<number | string | null>(null);
|
||||||
const [searchTerm, setSearchTerm] = useState('');
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
const [sortBy, setSortBy] = useState<'date' | 'name' | 'size' | 'rating' | 'capture_date'>('date');
|
||||||
@@ -275,62 +274,6 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
setStaticHeroPhoto(defaultHeroPhoto);
|
setStaticHeroPhoto(defaultHeroPhoto);
|
||||||
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
|
}, [selectedCategoryId, data?.categories, data?.photos, defaultHeroPhoto]);
|
||||||
|
|
||||||
// Apply theme when settings are loaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (settingsData && data?.event) {
|
|
||||||
let themeToApply = null;
|
|
||||||
const fullEvent = data.event; // Use the full event data from API
|
|
||||||
|
|
||||||
if (fullEvent.color_theme) {
|
|
||||||
try {
|
|
||||||
// Check if it's a valid JSON string
|
|
||||||
if (fullEvent.color_theme.startsWith('{')) {
|
|
||||||
const eventTheme = JSON.parse(fullEvent.color_theme);
|
|
||||||
themeToApply = eventTheme;
|
|
||||||
} else {
|
|
||||||
// Handle legacy theme names - check if it's a preset
|
|
||||||
const preset = GALLERY_THEME_PRESETS[fullEvent.color_theme];
|
|
||||||
if (preset) {
|
|
||||||
themeToApply = preset.config;
|
|
||||||
} else {
|
|
||||||
// Unknown theme name, fall back to global theme
|
|
||||||
if (settingsData.theme_config) {
|
|
||||||
themeToApply = settingsData.theme_config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Invalid theme format - use default
|
|
||||||
// Fall back to global theme
|
|
||||||
if (settingsData.theme_config) {
|
|
||||||
themeToApply = settingsData.theme_config;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (settingsData.theme_config) {
|
|
||||||
// No event theme, use global theme
|
|
||||||
themeToApply = settingsData.theme_config;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Apply theme with a small delay to ensure it overrides any global theme
|
|
||||||
if (themeToApply) {
|
|
||||||
// Use setTimeout to ensure this runs after any global theme application
|
|
||||||
const timer = setTimeout(() => {
|
|
||||||
// If there's a hero photo, add it to gallery settings
|
|
||||||
if (fullEvent.hero_photo_id && themeToApply.gallerySettings) {
|
|
||||||
themeToApply.gallerySettings.heroImageId = fullEvent.hero_photo_id;
|
|
||||||
// Apply hero photo ID to existing gallery settings
|
|
||||||
} else if (fullEvent.hero_photo_id) {
|
|
||||||
themeToApply.gallerySettings = { heroImageId: fullEvent.hero_photo_id };
|
|
||||||
// Create gallery settings with hero photo ID
|
|
||||||
}
|
|
||||||
setTheme(themeToApply);
|
|
||||||
}, 0);
|
|
||||||
|
|
||||||
return () => clearTimeout(timer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}, [settingsData, data, setTheme]); // Use data instead of event prop
|
|
||||||
|
|
||||||
// Calculate days until expiration (null means never expires)
|
// Calculate days until expiration (null means never expires)
|
||||||
const daysUntilExpiration = event.expires_at
|
const daysUntilExpiration = event.expires_at
|
||||||
? differenceInDays(parseISO(event.expires_at), new Date())
|
? differenceInDays(parseISO(event.expires_at), new Date())
|
||||||
@@ -619,6 +562,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||||
|
welcomeMessage={event.welcome_message}
|
||||||
onLogout={logout}
|
onLogout={logout}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -794,6 +738,7 @@ export const GalleryView: React.FC<GalleryViewProps> = ({ slug, event }) => {
|
|||||||
headerStyle={data?.event?.header_style || theme.headerStyle}
|
headerStyle={data?.event?.header_style || theme.headerStyle}
|
||||||
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
heroDividerStyle={data?.event?.hero_divider_style || theme.heroDividerStyle || 'wave'}
|
||||||
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
heroImageAnchor={data?.event?.hero_image_anchor || 'center'}
|
||||||
|
welcomeMessage={event.welcome_message}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,8 @@ interface PhotoGridWithLayoutsProps {
|
|||||||
heroDividerStyle?: HeroDividerStyle;
|
heroDividerStyle?: HeroDividerStyle;
|
||||||
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
// Hero image anchor position (#162) – keyword or "X% Y%" focal point
|
||||||
heroImageAnchor?: string;
|
heroImageAnchor?: string;
|
||||||
|
// Welcome message (per-event) for layouts that display it
|
||||||
|
welcomeMessage?: string;
|
||||||
// Logout callback for full-page layouts
|
// Logout callback for full-page layouts
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
@@ -97,6 +99,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
headerStyle,
|
headerStyle,
|
||||||
heroDividerStyle = 'wave',
|
heroDividerStyle = 'wave',
|
||||||
heroImageAnchor = 'center',
|
heroImageAnchor = 'center',
|
||||||
|
welcomeMessage,
|
||||||
onLogout
|
onLogout
|
||||||
}) => {
|
}) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@@ -226,6 +229,7 @@ export const PhotoGridWithLayouts: React.FC<PhotoGridWithLayoutsProps> = ({
|
|||||||
heroLogoVisible,
|
heroLogoVisible,
|
||||||
heroLogoSize,
|
heroLogoSize,
|
||||||
heroLogoPosition,
|
heroLogoPosition,
|
||||||
|
welcomeMessage,
|
||||||
onLogout,
|
onLogout,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ interface CategoryScene {
|
|||||||
|
|
||||||
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
|
interface GalleryStoryLayoutProps extends BaseGalleryLayoutProps {
|
||||||
heroPhotoOverride?: Photo | null;
|
heroPhotoOverride?: Photo | null;
|
||||||
|
welcomeMessage?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
||||||
@@ -55,6 +56,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
|||||||
feedbackEnabled = false,
|
feedbackEnabled = false,
|
||||||
feedbackOptions,
|
feedbackOptions,
|
||||||
heroPhotoOverride,
|
heroPhotoOverride,
|
||||||
|
welcomeMessage,
|
||||||
onLogout
|
onLogout
|
||||||
}) => {
|
}) => {
|
||||||
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
// These props are passed by parent but we use our own feedback system, so mark as intentionally unused
|
||||||
@@ -348,7 +350,7 @@ export const GalleryStoryLayout: React.FC<GalleryStoryLayoutProps> = ({
|
|||||||
<footer className="story-footer">
|
<footer className="story-footer">
|
||||||
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
|
<h2 className="story-footer-title">{t('gallery.thankYou', 'Thank You')}</h2>
|
||||||
<p className="story-footer-text">
|
<p className="story-footer-text">
|
||||||
{t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
|
{welcomeMessage || t('gallery.thankYouMessage', 'For being part of our story and making our special day unforgettable.')}
|
||||||
</p>
|
</p>
|
||||||
{allowDownloads && (
|
{allowDownloads && (
|
||||||
<button className="story-footer-btn" onClick={handleDownloadAll}>
|
<button className="story-footer-btn" onClick={handleDownloadAll}>
|
||||||
|
|||||||
@@ -1963,6 +1963,13 @@
|
|||||||
"required": "erforderlich",
|
"required": "erforderlich",
|
||||||
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
|
"ignoreSslErrors": "SSL/TLS-Zertifikatfehler ignorieren",
|
||||||
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen.",
|
"ignoreSslWarning": "Warnung: Das Deaktivieren der Zertifikatüberprüfung macht die Verbindung anfällig für Man-in-the-Middle-Angriffe. Aktivieren Sie dies nur, wenn Sie dem SMTP-Server vertrauen und die Sicherheitsrisiken verstehen.",
|
||||||
|
"brandingTitle": "E-Mail-Branding",
|
||||||
|
"brandingDescription": "Passen Sie die Farben in E-Mail-Vorlagen an. Änderungen gelten für Kopfzeile, Schaltflächen, Links und Fußzeilen-Hintergrund.",
|
||||||
|
"primaryColor": "Primärfarbe",
|
||||||
|
"primaryColorHint": "Wird für Kopfzeile, Schaltflächen und Links verwendet",
|
||||||
|
"secondaryColor": "Fußzeilen-Hintergrund",
|
||||||
|
"secondaryColorHint": "Wird für den Hintergrund der Fußzeile verwendet",
|
||||||
|
"saveEmailColors": "E-Mail-Farben speichern",
|
||||||
"editor": {
|
"editor": {
|
||||||
"bold": "Fett",
|
"bold": "Fett",
|
||||||
"italic": "Kursiv",
|
"italic": "Kursiv",
|
||||||
|
|||||||
@@ -1660,6 +1660,13 @@
|
|||||||
"required": "required",
|
"required": "required",
|
||||||
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
|
"ignoreSslErrors": "Ignore SSL/TLS certificate errors",
|
||||||
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications.",
|
"ignoreSslWarning": "Warning: Disabling certificate verification makes the connection vulnerable to man-in-the-middle attacks. Only enable this if you trust the SMTP server and understand the security implications.",
|
||||||
|
"brandingTitle": "Email Branding",
|
||||||
|
"brandingDescription": "Customize the colors used in email templates. Changes apply to the header bar, buttons, links, and footer background.",
|
||||||
|
"primaryColor": "Primary Color",
|
||||||
|
"primaryColorHint": "Used for header, buttons, and links",
|
||||||
|
"secondaryColor": "Footer Background",
|
||||||
|
"secondaryColorHint": "Used for footer section background",
|
||||||
|
"saveEmailColors": "Save Email Colors",
|
||||||
"editor": {
|
"editor": {
|
||||||
"bold": "Bold",
|
"bold": "Bold",
|
||||||
"italic": "Italic",
|
"italic": "Italic",
|
||||||
|
|||||||
@@ -1636,6 +1636,13 @@
|
|||||||
"required": "obrigatorio",
|
"required": "obrigatorio",
|
||||||
"ignoreSslErrors": "Ignorar erros de certificado SSL/TLS",
|
"ignoreSslErrors": "Ignorar erros de certificado SSL/TLS",
|
||||||
"ignoreSslWarning": "Aviso: Desabilitar verificacao de certificado torna a conexao vulneravel a ataques man-in-the-middle. Ative somente se voce confia no servidor SMTP e entende as implicacoes de seguranca.",
|
"ignoreSslWarning": "Aviso: Desabilitar verificacao de certificado torna a conexao vulneravel a ataques man-in-the-middle. Ative somente se voce confia no servidor SMTP e entende as implicacoes de seguranca.",
|
||||||
|
"brandingTitle": "Identidade Visual do E-mail",
|
||||||
|
"brandingDescription": "Personalize as cores dos modelos de e-mail. As alteracoes se aplicam ao cabecalho, botoes, links e fundo do rodape.",
|
||||||
|
"primaryColor": "Cor Primaria",
|
||||||
|
"primaryColorHint": "Usada no cabecalho, botoes e links",
|
||||||
|
"secondaryColor": "Fundo do Rodape",
|
||||||
|
"secondaryColorHint": "Usada no fundo da secao de rodape",
|
||||||
|
"saveEmailColors": "Salvar Cores do E-mail",
|
||||||
"editor": {
|
"editor": {
|
||||||
"bold": "Negrito",
|
"bold": "Negrito",
|
||||||
"italic": "Italico",
|
"italic": "Italico",
|
||||||
|
|||||||
@@ -1636,6 +1636,13 @@
|
|||||||
"required": "обязательно",
|
"required": "обязательно",
|
||||||
"ignoreSslErrors": "Игнорировать ошибки SSL/TLS-сертификата",
|
"ignoreSslErrors": "Игнорировать ошибки SSL/TLS-сертификата",
|
||||||
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски.",
|
"ignoreSslWarning": "Предупреждение: Отключение проверки сертификата делает соединение уязвимым для атак «человек посередине». Включайте только если доверяете SMTP-серверу и понимаете риски.",
|
||||||
|
"brandingTitle": "Оформление писем",
|
||||||
|
"brandingDescription": "Настройте цвета в шаблонах писем. Изменения применяются к шапке, кнопкам, ссылкам и фону подвала.",
|
||||||
|
"primaryColor": "Основной цвет",
|
||||||
|
"primaryColorHint": "Используется для шапки, кнопок и ссылок",
|
||||||
|
"secondaryColor": "Фон подвала",
|
||||||
|
"secondaryColorHint": "Используется для фона подвала письма",
|
||||||
|
"saveEmailColors": "Сохранить цвета",
|
||||||
"editor": {
|
"editor": {
|
||||||
"bold": "Жирный",
|
"bold": "Жирный",
|
||||||
"italic": "Курсив",
|
"italic": "Курсив",
|
||||||
|
|||||||
@@ -156,6 +156,15 @@ export const GalleryPage: React.FC = () => {
|
|||||||
themeToApply = settingsData.theme_config;
|
themeToApply = settingsData.theme_config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject hero photo ID into theme gallery settings
|
||||||
|
if (themeToApply && galleryInfo.hero_photo_id) {
|
||||||
|
if (themeToApply.gallerySettings) {
|
||||||
|
themeToApply.gallerySettings.heroImageId = galleryInfo.hero_photo_id;
|
||||||
|
} else {
|
||||||
|
themeToApply.gallerySettings = { heroImageId: galleryInfo.hero_photo_id };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply theme
|
// Apply theme
|
||||||
if (themeToApply) {
|
if (themeToApply) {
|
||||||
setTheme(themeToApply);
|
setTheme(themeToApply);
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import { toast } from 'react-toastify';
|
|||||||
import { Button, Input, Card, Loading } from '../../components/common';
|
import { Button, Input, Card, Loading } from '../../components/common';
|
||||||
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
import { EmailPreviewModal } from '../../components/admin/EmailPreviewModal';
|
||||||
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
|
import { EmailTemplateEditor } from '../../components/admin/EmailTemplateEditor';
|
||||||
|
import { Palette } from 'lucide-react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
|
import { emailService, type EmailConfig, type EmailTemplate } from '../../services/email.service';
|
||||||
|
import { settingsService } from '../../services/settings.service';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
const defaultTemplateKeys = [
|
const defaultTemplateKeys = [
|
||||||
@@ -99,6 +101,8 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
htmlContent: '',
|
htmlContent: '',
|
||||||
textContent: ''
|
textContent: ''
|
||||||
});
|
});
|
||||||
|
const [emailPrimaryColor, setEmailPrimaryColor] = useState('#5C8762');
|
||||||
|
const [emailSecondaryColor, setEmailSecondaryColor] = useState('#f9f9f9');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
// SMTP Configuration state
|
// SMTP Configuration state
|
||||||
@@ -119,6 +123,19 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
queryFn: () => emailService.getConfig(),
|
queryFn: () => emailService.getConfig(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Fetch email branding colors from app settings
|
||||||
|
const { data: allSettings } = useQuery({
|
||||||
|
queryKey: ['admin-settings'],
|
||||||
|
queryFn: () => settingsService.getAllSettings(),
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (allSettings) {
|
||||||
|
if (allSettings.email_primary_color) setEmailPrimaryColor(allSettings.email_primary_color);
|
||||||
|
if (allSettings.email_secondary_color) setEmailSecondaryColor(allSettings.email_secondary_color);
|
||||||
|
}
|
||||||
|
}, [allSettings]);
|
||||||
|
|
||||||
// Fetch email templates
|
// Fetch email templates
|
||||||
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
const { data: templates = [], isLoading: templatesLoading } = useQuery({
|
||||||
queryKey: ['email-templates'],
|
queryKey: ['email-templates'],
|
||||||
@@ -186,6 +203,25 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const saveEmailColorsMutation = useMutation({
|
||||||
|
mutationFn: (colors: { email_primary_color: string; email_secondary_color: string }) =>
|
||||||
|
settingsService.updateSettings(colors),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success(t('toast.saveSuccess'));
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['admin-settings'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
toast.error(t('toast.saveError'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSaveEmailColors = () => {
|
||||||
|
saveEmailColorsMutation.mutate({
|
||||||
|
email_primary_color: emailPrimaryColor,
|
||||||
|
email_secondary_color: emailSecondaryColor,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSaveSmtp = () => {
|
const handleSaveSmtp = () => {
|
||||||
// Validate SMTP config
|
// Validate SMTP config
|
||||||
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
|
if (!smtpConfig.smtp_host || !smtpConfig.smtp_port || !smtpConfig.from_email) {
|
||||||
@@ -498,6 +534,76 @@ export const EmailConfigPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Email Branding - below SMTP settings */}
|
||||||
|
{activeTab === 'smtp' && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<Card padding="md">
|
||||||
|
<div className="flex items-center gap-2 mb-4">
|
||||||
|
<Palette className="w-5 h-5 text-neutral-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-neutral-900 dark:text-neutral-100">{t('email.brandingTitle')}</h2>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-neutral-500 dark:text-neutral-400 mb-6">{t('email.brandingDescription')}</p>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||||
|
{t('email.primaryColor')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.primaryColorHint')}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={emailPrimaryColor}
|
||||||
|
onChange={(e) => setEmailPrimaryColor(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={emailPrimaryColor}
|
||||||
|
onChange={(e) => setEmailPrimaryColor(e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
placeholder="#5C8762"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-neutral-700 dark:text-neutral-300 mb-2">
|
||||||
|
{t('email.secondaryColor')}
|
||||||
|
</label>
|
||||||
|
<p className="text-xs text-neutral-500 dark:text-neutral-400 mb-2">{t('email.secondaryColorHint')}</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={emailSecondaryColor}
|
||||||
|
onChange={(e) => setEmailSecondaryColor(e.target.value)}
|
||||||
|
className="w-10 h-10 rounded border border-neutral-300 dark:border-neutral-600 cursor-pointer"
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
type="text"
|
||||||
|
value={emailSecondaryColor}
|
||||||
|
onChange={(e) => setEmailSecondaryColor(e.target.value)}
|
||||||
|
className="w-32"
|
||||||
|
placeholder="#f9f9f9"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={handleSaveEmailColors}
|
||||||
|
isLoading={saveEmailColorsMutation.isPending}
|
||||||
|
leftIcon={<Save className="w-5 h-5" />}
|
||||||
|
>
|
||||||
|
{t('email.saveEmailColors')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Email Templates Tab */}
|
{/* Email Templates Tab */}
|
||||||
{activeTab === 'templates' && (
|
{activeTab === 'templates' && (
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
|
|||||||
@@ -242,6 +242,8 @@ export const settingsService = {
|
|||||||
endpoint = '/admin/settings/branding';
|
endpoint = '/admin/settings/branding';
|
||||||
} else if (firstKey?.startsWith('seo_')) {
|
} else if (firstKey?.startsWith('seo_')) {
|
||||||
endpoint = '/admin/settings/seo';
|
endpoint = '/admin/settings/seo';
|
||||||
|
} else if (firstKey?.startsWith('email_')) {
|
||||||
|
endpoint = '/admin/settings/general';
|
||||||
}
|
}
|
||||||
|
|
||||||
await api.put(endpoint, settings);
|
await api.put(endpoint, settings);
|
||||||
|
|||||||
Reference in New Issue
Block a user