🎨 Добавлен полный редактор стилей и поле image_url для туров

 Новые функции:
- Поле image_url в модели туров для изменения изображений через админ-панель
- Расширенная модель настроек сайта с категориями: colors, typography, images, effects, layout
- Динамический CSS генератор на основе настроек (/dynamic-styles.css)
- API для управления настройками сайта (/api/site-settings)

🎯 Редактор стилей:
- Управление цветами (основные, акцентные, текст, фон)
- Настройка типографики (шрифты, размеры, межстрочный интервал)
- Управление изображениями (фоны, логотипы, фавикон)
- Эффекты (прозрачность, тени, размытие, скругления)
- Макет (высота секций, размеры контейнеров)
- Пользовательский CSS код

🛠️ Техническая реализация:
- SiteSettingsHelper с кешированием для производительности
- CSS переменные для динамического изменения стилей
- Автоматическая миграция базы данных
- Интеграция с AdminJS для удобного управления
- Загрузка настроек в шаблоны для доступности

📊 База данных:
- Расширена таблица site_settings (добавлено поле category)
- Новые типы настроек: color, file
- 27 предустановленных настроек для полного контроля над дизайном
- Автоматическое применение миграций при старте приложения
This commit is contained in:
2025-11-29 22:03:00 +09:00
parent a461fea9d9
commit ed871fc4d1
8 changed files with 528 additions and 28 deletions

View File

@@ -55,6 +55,30 @@ export async function initDatabase() {
} }
} }
// Check if style editor migration is applied
try {
const result = await db.query("SELECT 1 FROM site_settings WHERE setting_key = 'primary_color' LIMIT 1");
if (result.rows.length === 0) {
console.log('🎨 Installing style editor features...');
const styleMigrationPath = path.join(__dirname, 'style-editor-migration.sql');
if (fs.existsSync(styleMigrationPath)) {
const styleMigration = fs.readFileSync(styleMigrationPath, 'utf8');
await db.query(styleMigration);
console.log('✅ Style editor installed successfully');
}
} else {
console.log(' Style editor already installed');
}
} catch (error) {
console.log('🎨 Installing style editor features...');
const styleMigrationPath = path.join(__dirname, 'style-editor-migration.sql');
if (fs.existsSync(styleMigrationPath)) {
const styleMigration = fs.readFileSync(styleMigrationPath, 'utf8');
await db.query(styleMigration);
console.log('✅ Style editor installed successfully');
}
}
console.log('✨ Database initialization completed successfully!'); console.log('✨ Database initialization completed successfully!');
} catch (error) { } catch (error) {

View File

@@ -0,0 +1,78 @@
-- Миграция для добавления полей image_url и расширения настроек сайта
-- Добавление поля image_url в таблицу routes (если его нет)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'routes' AND column_name = 'image_url') THEN
ALTER TABLE routes ADD COLUMN image_url VARCHAR(255);
END IF;
END $$;
-- Добавление поля category в таблицу site_settings (если его нет)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
WHERE table_name = 'site_settings' AND column_name = 'category') THEN
ALTER TABLE site_settings ADD COLUMN category VARCHAR(50) DEFAULT 'general';
END IF;
END $$;
-- Обновление типов в таблице site_settings
DO $$
BEGIN
-- Удаляем старое ограничение типов если оно есть
ALTER TABLE site_settings DROP CONSTRAINT IF EXISTS site_settings_setting_type_check;
-- Добавляем новое ограничение с расширенными типами
ALTER TABLE site_settings ADD CONSTRAINT site_settings_setting_type_check
CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'color', 'file'));
END $$;
-- Добавление расширенных настроек сайта для редактора стилей
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, category, updated_at) VALUES
-- Основные цвета темы
('primary_color', '#2563eb', 'color', 'Основной цвет сайта', 'colors', NOW()),
('secondary_color', '#dc2626', 'color', 'Вторичный цвет сайта', 'colors', NOW()),
('accent_color', '#059669', 'color', 'Акцентный цвет', 'colors', NOW()),
('text_color', '#334155', 'color', 'Основной цвет текста', 'colors', NOW()),
('background_color', '#ffffff', 'color', 'Цвет фона', 'colors', NOW()),
('card_background', '#f8fafc', 'color', 'Цвет фона карточек', 'colors', NOW()),
-- Фоновые изображения
('hero_background_url', '/images/korea-hero.jpg', 'file', 'Фоновое изображение главной страницы', 'images', NOW()),
('default_tour_image', '/images/placeholder.jpg', 'file', 'Изображение тура по умолчанию', 'images', NOW()),
('site_logo_url', '/images/korea-logo.png', 'file', 'Логотип сайта', 'images', NOW()),
('favicon_url', '/images/favicon.ico', 'file', 'Иконка сайта', 'images', NOW()),
-- Типографика
('font_family_primary', 'Noto Sans KR, Malgun Gothic, 맑은 고딕, sans-serif', 'text', 'Основной шрифт', 'typography', NOW()),
('font_family_display', 'Playfair Display, serif', 'text', 'Декоративный шрифт', 'typography', NOW()),
('font_size_base', '16', 'number', 'Базовый размер шрифта (px)', 'typography', NOW()),
('line_height_base', '1.7', 'number', 'Базовая высота строки', 'typography', NOW()),
-- Эффекты и наложения
('hero_overlay_opacity', '0.8', 'number', 'Прозрачность наложения на hero фоне (0-1)', 'effects', NOW()),
('hero_overlay_color', '#2563eb', 'color', 'Цвет наложения на hero фоне', 'effects', NOW()),
('card_shadow', '0 4px 6px -1px rgba(0, 0, 0, 0.1)', 'text', 'Тень карточек (CSS shadow)', 'effects', NOW()),
('border_radius', '8', 'number', 'Радиус скругления углов (px)', 'effects', NOW()),
('blur_effect', '10', 'number', 'Сила размытия эффектов (px)', 'effects', NOW()),
-- Макет и размеры
('hero_height_desktop', '70', 'number', 'Высота hero секции на десктопе (vh)', 'layout', NOW()),
('hero_height_mobile', '50', 'number', 'Высота hero секции на мобильных (vh)', 'layout', NOW()),
('compact_hero_height', '25', 'number', 'Высота компактных hero секций (vh)', 'layout', NOW()),
('container_max_width', '1200', 'number', 'Максимальная ширина контейнера (px)', 'layout', NOW()),
('navbar_height', '76', 'number', 'Высота навигационной панели (px)', 'layout', NOW()),
-- Дополнительные стили
('custom_css', '', 'text', 'Дополнительный CSS код', 'theme', NOW()),
('google_fonts_url', '', 'text', 'URL для подключения Google Fonts', 'typography', NOW()),
('animation_duration', '0.3', 'number', 'Длительность анимаций (секунды)', 'effects', NOW())
ON CONFLICT (setting_key) DO UPDATE SET
setting_value = EXCLUDED.setting_value,
setting_type = EXCLUDED.setting_type,
description = EXCLUDED.description,
category = EXCLUDED.category,
updated_at = NOW();

View File

@@ -1,16 +1,23 @@
/* Korea Tourism Agency - Main Styles */ /* Korea Tourism Agency - Main Styles */
/* CSS Variables */ /* CSS Variables - могут быть переопределены через настройки сайта */
:root { :root {
/* Основные цвета */
--primary-color: #2563eb; --primary-color: #2563eb;
--primary-light: #3b82f6; --primary-light: #3b82f6;
--primary-dark: #1d4ed8; --primary-dark: #1d4ed8;
--secondary-color: #dc2626; --secondary-color: #dc2626;
--accent-color: #059669;
--success-color: #059669; --success-color: #059669;
--warning-color: #d97706; --warning-color: #d97706;
--info-color: #0891b2; --info-color: #0891b2;
--light-color: #f8fafc; --light-color: #f8fafc;
--dark-color: #0f172a; --dark-color: #0f172a;
--text-color: #334155;
--background-color: #ffffff;
--card-background: #f8fafc;
/* Серые тона */
--gray-100: #f1f5f9; --gray-100: #f1f5f9;
--gray-200: #e2e8f0; --gray-200: #e2e8f0;
--gray-300: #cbd5e1; --gray-300: #cbd5e1;
@@ -20,31 +27,56 @@
--gray-700: #334155; --gray-700: #334155;
--gray-800: #1e293b; --gray-800: #1e293b;
--gray-900: #0f172a; --gray-900: #0f172a;
/* Корейские цвета */
--korean-red: #c41e3a; --korean-red: #c41e3a;
--korean-blue: #003478; --korean-blue: #003478;
--font-korean: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
--font-display: 'Playfair Display', serif; /* Типографика */
--font-family-primary: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
--font-family-display: 'Playfair Display', serif;
--font-size-base: 16px;
--line-height-base: 1.7;
/* Эффекты */
--hero-overlay-opacity: 0.8;
--hero-overlay-color: #2563eb;
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--border-radius: 8px;
--blur-effect: 10px;
--animation-duration: 0.3s;
/* Тени */
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); --shadow: var(--card-shadow);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04); --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
/* Макет */
--hero-height-desktop: 70vh;
--hero-height-mobile: 50vh;
--compact-hero-height: 25vh;
--container-max-width: 1200px;
--navbar-height: 76px;
} }
/* Base Styles */ /* Base Styles */
body { body {
font-family: var(--font-korean); font-family: var(--font-family-primary);
line-height: 1.7; font-size: var(--font-size-base);
color: var(--gray-700); line-height: var(--line-height-base);
padding-top: 76px; /* Account for fixed navbar */ color: var(--text-color);
background-color: var(--background-color);
padding-top: var(--navbar-height);
} }
.font-korean { .font-korean {
font-family: var(--font-korean); font-family: var(--font-family-primary);
} }
.font-display { .font-display {
font-family: var(--font-display); font-family: var(--font-family-display);
} }
/* Custom Bootstrap Overrides */ /* Custom Bootstrap Overrides */
@@ -52,7 +84,7 @@ body {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
border: none; border: none;
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transition: all 0.3s ease; transition: all var(--animation-duration) ease;
} }
.btn-primary:hover { .btn-primary:hover {
@@ -63,8 +95,8 @@ body {
.text-gradient { .text-gradient {
background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%); background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%);
background-clip: text;
-webkit-background-clip: text; -webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent; -webkit-text-fill-color: transparent;
} }
@@ -74,14 +106,15 @@ body {
/* Navigation Styles */ /* Navigation Styles */
.navbar { .navbar {
background: rgba(37, 99, 235, 0.95) !important; background: rgba(37, 99, 235, var(--hero-overlay-opacity)) !important;
backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(var(--blur-effect));
backdrop-filter: blur(var(--blur-effect));
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
transition: all 0.3s ease; transition: all var(--animation-duration) ease;
} }
.navbar-brand { .navbar-brand {
font-family: var(--font-display); font-family: var(--font-family-display);
font-weight: 700; font-weight: 700;
font-size: 1.5rem; font-size: 1.5rem;
} }
@@ -89,7 +122,7 @@ body {
.nav-link { .nav-link {
font-weight: 500; font-weight: 500;
position: relative; position: relative;
transition: all 0.3s ease; transition: all var(--animation-duration) ease;
} }
.nav-link:hover { .nav-link:hover {
@@ -111,21 +144,21 @@ body {
/* Hero Section */ /* Hero Section */
.hero-section { .hero-section {
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%); background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
min-height: 70vh; min-height: var(--hero-height-desktop);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
/* Compact Hero Section for other pages */ /* Compact Hero Section for other pages */
.hero-section.compact { .hero-section.compact {
min-height: 25vh; min-height: var(--compact-hero-height);
padding: 3rem 0; padding: 3rem 0;
} }
/* Mobile optimization for hero sections */ /* Mobile optimization for hero sections */
@media (max-width: 768px) { @media (max-width: 768px) {
.hero-section { .hero-section {
min-height: 50vh; min-height: var(--hero-height-mobile);
padding: 2rem 0; padding: 2rem 0;
} }
@@ -154,9 +187,9 @@ body {
left: 0; left: 0;
width: 100%; width: 100%;
height: 100%; height: 100%;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.8) 0%, rgba(0, 52, 120, 0.9) 100%); background: linear-gradient(135deg, rgba(37, 99, 235, var(--hero-overlay-opacity)) 0%, rgba(0, 52, 120, 0.9) 100%);
z-index: 2; z-index: 2;
pointer-events: none; /* Позволяет кликам проходить через overlay */ pointer-events: none;
} }
.hero-section .container { .hero-section .container {

View File

@@ -7,6 +7,7 @@ import compression from 'compression';
import morgan from 'morgan'; import morgan from 'morgan';
import methodOverride from 'method-override'; import methodOverride from 'method-override';
import formatters from './helpers/formatters.js'; import formatters from './helpers/formatters.js';
import SiteSettingsHelper from './helpers/site-settings.js';
import { adminJs, router as adminRouter } from './config/adminjs-simple.js'; import { adminJs, router as adminRouter } from './config/adminjs-simple.js';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
import { dirname } from 'path'; import { dirname } from 'path';
@@ -76,8 +77,8 @@ app.use(session({
app.set('view engine', 'ejs'); app.set('view engine', 'ejs');
app.set('views', path.join(__dirname, '../views')); app.set('views', path.join(__dirname, '../views'));
// Global template variables // Global template variables with site settings
app.use((req, res, next) => { app.use(async (req, res, next) => {
res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство'; res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство';
res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи'; res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи';
res.locals.user = req.session.user || null; res.locals.user = req.session.user || null;
@@ -85,6 +86,14 @@ app.use((req, res, next) => {
res.locals.currentPath = req.path; res.locals.currentPath = req.path;
res.locals.page = 'home'; // default page res.locals.page = 'home'; // default page
// Load site settings for templates
try {
res.locals.siteSettings = await SiteSettingsHelper.getAllSettings();
} catch (error) {
console.error('Error loading site settings for templates:', error);
res.locals.siteSettings = {};
}
// Add all helper functions to template globals // Add all helper functions to template globals
Object.assign(res.locals, formatters); Object.assign(res.locals, formatters);
@@ -148,6 +157,7 @@ const toursRouter = (await import('./routes/tours.js')).default;
const guidesRouter = (await import('./routes/guides.js')).default; const guidesRouter = (await import('./routes/guides.js')).default;
const articlesRouter = (await import('./routes/articles.js')).default; const articlesRouter = (await import('./routes/articles.js')).default;
const apiRouter = (await import('./routes/api.js')).default; const apiRouter = (await import('./routes/api.js')).default;
const settingsRouter = (await import('./routes/settings.js')).default;
const ratingsRouter = (await import('./routes/ratings.js')).default; const ratingsRouter = (await import('./routes/ratings.js')).default;
app.use('/', indexRouter); app.use('/', indexRouter);
@@ -156,6 +166,7 @@ app.use('/guides', guidesRouter);
app.use('/articles', articlesRouter); app.use('/articles', articlesRouter);
app.use('/api', apiRouter); app.use('/api', apiRouter);
app.use('/api', ratingsRouter); app.use('/api', ratingsRouter);
app.use('/', settingsRouter); // Settings routes (CSS and API)
// Health check endpoint // Health check endpoint
app.get('/health', (req, res) => { app.get('/health', (req, res) => {

View File

@@ -42,6 +42,7 @@ const Routes = sequelize.define('routes', {
price: { type: DataTypes.DECIMAL(10, 2) }, price: { type: DataTypes.DECIMAL(10, 2) },
duration: { type: DataTypes.INTEGER }, duration: { type: DataTypes.INTEGER },
max_group_size: { type: DataTypes.INTEGER }, max_group_size: { type: DataTypes.INTEGER },
image_url: { type: DataTypes.STRING },
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false }, is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
is_active: { type: DataTypes.BOOLEAN, defaultValue: true }, is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }, created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
@@ -187,6 +188,20 @@ const Holidays = sequelize.define('holidays', {
tableName: 'holidays' tableName: 'holidays'
}); });
// Модель настроек сайта
const SiteSettings = sequelize.define('site_settings', {
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
setting_key: { type: DataTypes.STRING, allowNull: false, unique: true },
setting_value: { type: DataTypes.TEXT },
setting_type: { type: DataTypes.ENUM('text', 'number', 'boolean', 'json', 'color', 'file'), defaultValue: 'text' },
description: { type: DataTypes.TEXT },
category: { type: DataTypes.STRING, defaultValue: 'general' },
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
}, {
timestamps: false,
tableName: 'site_settings'
});
// Определение связей между моделями // Определение связей между моделями
Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' }); Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' });
GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' }); GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
@@ -222,8 +237,8 @@ const adminJsOptions = {
options: { options: {
parent: { name: 'Контент', icon: 'DocumentText' }, parent: { name: 'Контент', icon: 'DocumentText' },
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'], listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active'], editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active'],
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active', 'created_at', 'updated_at'], showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active', 'created_at', 'updated_at'],
filterProperties: ['title', 'type', 'is_active'], filterProperties: ['title', 'type', 'is_active'],
properties: { properties: {
title: { title: {
@@ -263,6 +278,10 @@ const adminJsOptions = {
type: 'number', type: 'number',
isRequired: true, isRequired: true,
}, },
image_url: {
type: 'string',
description: 'URL изображения тура (например: /images/tours/seoul-1.jpg)'
},
is_featured: { type: 'boolean' }, is_featured: { type: 'boolean' },
is_active: { type: 'boolean' }, is_active: { type: 'boolean' },
created_at: { created_at: {
@@ -633,6 +652,58 @@ const adminJsOptions = {
} }
}, },
} }
},
{
resource: SiteSettings,
options: {
parent: { name: 'Администрирование', icon: 'Settings' },
listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
filterProperties: ['setting_key', 'category', 'setting_type'],
properties: {
setting_key: {
isTitle: true,
isRequired: true,
description: 'Уникальный ключ настройки (например: primary_color, hero_background_url)'
},
setting_value: {
type: 'textarea',
isRequired: true,
description: 'Значение настройки (цвет в HEX, URL изображения, текст и т.д.)'
},
setting_type: {
availableValues: [
{ value: 'text', label: 'Текст' },
{ value: 'number', label: 'Число' },
{ value: 'boolean', label: 'Да/Нет' },
{ value: 'json', label: 'JSON' },
{ value: 'color', label: 'Цвет (HEX)' },
{ value: 'file', label: 'Файл/URL' }
],
isRequired: true
},
description: {
type: 'textarea',
description: 'Описание назначения этой настройки'
},
category: {
availableValues: [
{ value: 'general', label: 'Общие' },
{ value: 'theme', label: 'Тема и стили' },
{ value: 'colors', label: 'Цвета' },
{ value: 'typography', label: 'Типографика' },
{ value: 'images', label: 'Изображения' },
{ value: 'effects', label: 'Эффекты' },
{ value: 'layout', label: 'Макет' }
],
defaultValue: 'general'
},
updated_at: {
isVisible: { list: true, filter: true, show: true, edit: false },
}
},
}
} }
], ],
rootPath: '/admin', rootPath: '/admin',

View File

@@ -0,0 +1,190 @@
import db from '../config/database.js';
class SiteSettingsHelper {
static cache = new Map();
static lastUpdate = null;
static cacheTimeout = 5 * 60 * 1000; // 5 минут
/**
* Получить все настройки сайта с кешированием
*/
static async getAllSettings() {
const now = Date.now();
// Проверяем кеш
if (this.lastUpdate && (now - this.lastUpdate) < this.cacheTimeout && this.cache.size > 0) {
return Object.fromEntries(this.cache);
}
try {
const result = await db.query('SELECT setting_key, setting_value, setting_type FROM site_settings ORDER BY category, setting_key');
// Очищаем кеш и заполняем новыми данными
this.cache.clear();
for (const row of result.rows) {
let value = row.setting_value;
// Преобразуем значение в зависимости от типа
switch (row.setting_type) {
case 'number':
value = parseFloat(value) || 0;
break;
case 'boolean':
value = value === 'true' || value === '1';
break;
case 'json':
try {
value = JSON.parse(value);
} catch {
value = {};
}
break;
}
this.cache.set(row.setting_key, value);
}
this.lastUpdate = now;
return Object.fromEntries(this.cache);
} catch (error) {
console.error('Error loading site settings:', error);
return {};
}
}
/**
* Получить настройку по ключу
*/
static async getSetting(key, defaultValue = null) {
const settings = await this.getAllSettings();
return settings[key] !== undefined ? settings[key] : defaultValue;
}
/**
* Установить настройку
*/
static async setSetting(key, value, type = 'text', description = '', category = 'general') {
try {
await db.query(`
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, category, updated_at)
VALUES ($1, $2, $3, $4, $5, NOW())
ON CONFLICT (setting_key)
DO UPDATE SET
setting_value = EXCLUDED.setting_value,
setting_type = EXCLUDED.setting_type,
description = EXCLUDED.description,
category = EXCLUDED.category,
updated_at = NOW()
`, [key, String(value), type, description, category]);
// Обновляем кеш
this.cache.set(key, value);
return true;
} catch (error) {
console.error('Error setting site setting:', error);
return false;
}
}
/**
* Получить настройки по категории
*/
static async getSettingsByCategory(category) {
try {
const result = await db.query(
'SELECT setting_key, setting_value, setting_type FROM site_settings WHERE category = $1 ORDER BY setting_key',
[category]
);
const settings = {};
for (const row of result.rows) {
let value = row.setting_value;
switch (row.setting_type) {
case 'number':
value = parseFloat(value) || 0;
break;
case 'boolean':
value = value === 'true' || value === '1';
break;
case 'json':
try {
value = JSON.parse(value);
} catch {
value = {};
}
break;
}
settings[row.setting_key] = value;
}
return settings;
} catch (error) {
console.error('Error loading settings by category:', error);
return {};
}
}
/**
* Генерировать CSS переменные из настроек
*/
static async generateCSSVariables() {
const settings = await this.getAllSettings();
let css = ':root {\n';
// Цвета
if (settings.primary_color) css += ` --primary-color: ${settings.primary_color};\n`;
if (settings.secondary_color) css += ` --secondary-color: ${settings.secondary_color};\n`;
if (settings.accent_color) css += ` --accent-color: ${settings.accent_color};\n`;
if (settings.text_color) css += ` --text-color: ${settings.text_color};\n`;
if (settings.background_color) css += ` --background-color: ${settings.background_color};\n`;
if (settings.card_background) css += ` --card-background: ${settings.card_background};\n`;
// Типографика
if (settings.font_family_primary) css += ` --font-family-primary: ${settings.font_family_primary};\n`;
if (settings.font_family_display) css += ` --font-family-display: ${settings.font_family_display};\n`;
if (settings.font_size_base) css += ` --font-size-base: ${settings.font_size_base}px;\n`;
if (settings.line_height_base) css += ` --line-height-base: ${settings.line_height_base};\n`;
// Эффекты
if (settings.hero_overlay_opacity) css += ` --hero-overlay-opacity: ${settings.hero_overlay_opacity};\n`;
if (settings.hero_overlay_color) css += ` --hero-overlay-color: ${settings.hero_overlay_color};\n`;
if (settings.card_shadow) css += ` --card-shadow: ${settings.card_shadow};\n`;
if (settings.border_radius) css += ` --border-radius: ${settings.border_radius}px;\n`;
if (settings.blur_effect) css += ` --blur-effect: ${settings.blur_effect}px;\n`;
// Макет
if (settings.hero_height_desktop) css += ` --hero-height-desktop: ${settings.hero_height_desktop}vh;\n`;
if (settings.hero_height_mobile) css += ` --hero-height-mobile: ${settings.hero_height_mobile}vh;\n`;
if (settings.compact_hero_height) css += ` --compact-hero-height: ${settings.compact_hero_height}vh;\n`;
if (settings.container_max_width) css += ` --container-max-width: ${settings.container_max_width}px;\n`;
if (settings.navbar_height) css += ` --navbar-height: ${settings.navbar_height}px;\n`;
// Анимации
if (settings.animation_duration) css += ` --animation-duration: ${settings.animation_duration}s;\n`;
css += '}\n';
// Добавляем пользовательский CSS
if (settings.custom_css) {
css += '\n/* Пользовательский CSS */\n' + settings.custom_css + '\n';
}
return css;
}
/**
* Очистить кеш настроек
*/
static clearCache() {
this.cache.clear();
this.lastUpdate = null;
}
}
export default SiteSettingsHelper;

86
src/routes/settings.js Normal file
View File

@@ -0,0 +1,86 @@
import express from 'express';
import SiteSettingsHelper from '../helpers/site-settings.js';
const router = express.Router();
/**
* Динамический CSS на основе настроек сайта
*/
router.get('/dynamic-styles.css', async (req, res) => {
try {
const css = await SiteSettingsHelper.generateCSSVariables();
res.setHeader('Content-Type', 'text/css');
res.setHeader('Cache-Control', 'public, max-age=300'); // Кеш на 5 минут
res.send(css);
} catch (error) {
console.error('Error generating dynamic CSS:', error);
res.status(500).send('/* Error generating dynamic CSS */');
}
});
/**
* API для получения настроек сайта
*/
router.get('/api/site-settings', async (req, res) => {
try {
const settings = await SiteSettingsHelper.getAllSettings();
res.json(settings);
} catch (error) {
console.error('Error loading site settings:', error);
res.status(500).json({ error: 'Failed to load site settings' });
}
});
/**
* API для получения настроек по категории
*/
router.get('/api/site-settings/:category', async (req, res) => {
try {
const { category } = req.params;
const settings = await SiteSettingsHelper.getSettingsByCategory(category);
res.json(settings);
} catch (error) {
console.error('Error loading site settings by category:', error);
res.status(500).json({ error: 'Failed to load site settings' });
}
});
/**
* API для обновления настройки
*/
router.post('/api/site-settings', async (req, res) => {
try {
const { key, value, type, description, category } = req.body;
if (!key || value === undefined) {
return res.status(400).json({ error: 'Key and value are required' });
}
const success = await SiteSettingsHelper.setSetting(key, value, type, description, category);
if (success) {
res.json({ success: true, message: 'Setting updated successfully' });
} else {
res.status(500).json({ error: 'Failed to update setting' });
}
} catch (error) {
console.error('Error updating site setting:', error);
res.status(500).json({ error: 'Failed to update setting' });
}
});
/**
* Очистка кеша настроек (для админов)
*/
router.post('/api/site-settings/clear-cache', async (req, res) => {
try {
SiteSettingsHelper.clearCache();
res.json({ success: true, message: 'Cache cleared successfully' });
} catch (error) {
console.error('Error clearing cache:', error);
res.status(500).json({ error: 'Failed to clear cache' });
}
});
export default router;

View File

@@ -7,10 +7,13 @@
<meta name="description" content="<%= siteDescription %>"> <meta name="description" content="<%= siteDescription %>">
<!-- Favicon --> <!-- Favicon -->
<link rel="icon" href="/images/favicon.ico"> <link rel="icon" href="<%= siteSettings.favicon_url || '/images/favicon.ico' %>">
<!-- Google Fonts --> <!-- Google Fonts -->
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet">
<% if (siteSettings.google_fonts_url && siteSettings.google_fonts_url.trim()) { %>
<link href="<%= siteSettings.google_fonts_url %>" rel="stylesheet">
<% } %>
<!-- Bootstrap CSS --> <!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
@@ -26,6 +29,8 @@
<!-- Custom CSS --> <!-- Custom CSS -->
<link href="/css/main.css" rel="stylesheet"> <link href="/css/main.css" rel="stylesheet">
<!-- Dynamic Site Settings CSS -->
<link href="/dynamic-styles.css" rel="stylesheet">
<!-- Open Graph Meta Tags --> <!-- Open Graph Meta Tags -->
<meta property="og:title" content="<%= title || siteName %>"> <meta property="og:title" content="<%= title || siteName %>">
@@ -42,7 +47,9 @@
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top"> <nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
<div class="container"> <div class="container">
<a class="navbar-brand" href="/"> <a class="navbar-brand" href="/">
<img src="/images/korea-logo.png" alt="Korea Tourism" height="40" class="me-2"> <% if (siteSettings.site_logo_url && siteSettings.site_logo_url.trim()) { %>
<img src="<%= siteSettings.site_logo_url %>" alt="<%= siteName %>" height="40" class="me-2">
<% } %>
<span class="fw-bold"><%= siteName %></span> <span class="fw-bold"><%= siteName %></span>
</a> </a>