🎨 Добавлен полный редактор стилей и поле 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:
@@ -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!');
|
||||
|
||||
} catch (error) {
|
||||
|
||||
78
database/style-editor-migration.sql
Normal file
78
database/style-editor-migration.sql
Normal 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();
|
||||
@@ -1,16 +1,23 @@
|
||||
/* Korea Tourism Agency - Main Styles */
|
||||
|
||||
/* CSS Variables */
|
||||
/* CSS Variables - могут быть переопределены через настройки сайта */
|
||||
:root {
|
||||
/* Основные цвета */
|
||||
--primary-color: #2563eb;
|
||||
--primary-light: #3b82f6;
|
||||
--primary-dark: #1d4ed8;
|
||||
--secondary-color: #dc2626;
|
||||
--accent-color: #059669;
|
||||
--success-color: #059669;
|
||||
--warning-color: #d97706;
|
||||
--info-color: #0891b2;
|
||||
--light-color: #f8fafc;
|
||||
--dark-color: #0f172a;
|
||||
--text-color: #334155;
|
||||
--background-color: #ffffff;
|
||||
--card-background: #f8fafc;
|
||||
|
||||
/* Серые тона */
|
||||
--gray-100: #f1f5f9;
|
||||
--gray-200: #e2e8f0;
|
||||
--gray-300: #cbd5e1;
|
||||
@@ -20,31 +27,56 @@
|
||||
--gray-700: #334155;
|
||||
--gray-800: #1e293b;
|
||||
--gray-900: #0f172a;
|
||||
|
||||
/* Корейские цвета */
|
||||
--korean-red: #c41e3a;
|
||||
--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: 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-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);
|
||||
|
||||
/* Макет */
|
||||
--hero-height-desktop: 70vh;
|
||||
--hero-height-mobile: 50vh;
|
||||
--compact-hero-height: 25vh;
|
||||
--container-max-width: 1200px;
|
||||
--navbar-height: 76px;
|
||||
}
|
||||
|
||||
/* Base Styles */
|
||||
body {
|
||||
font-family: var(--font-korean);
|
||||
line-height: 1.7;
|
||||
color: var(--gray-700);
|
||||
padding-top: 76px; /* Account for fixed navbar */
|
||||
font-family: var(--font-family-primary);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: var(--line-height-base);
|
||||
color: var(--text-color);
|
||||
background-color: var(--background-color);
|
||||
padding-top: var(--navbar-height);
|
||||
}
|
||||
|
||||
.font-korean {
|
||||
font-family: var(--font-korean);
|
||||
font-family: var(--font-family-primary);
|
||||
}
|
||||
|
||||
.font-display {
|
||||
font-family: var(--font-display);
|
||||
font-family: var(--font-family-display);
|
||||
}
|
||||
|
||||
/* Custom Bootstrap Overrides */
|
||||
@@ -52,7 +84,7 @@ body {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||
border: none;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.3s ease;
|
||||
transition: all var(--animation-duration) ease;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
@@ -63,8 +95,8 @@ body {
|
||||
|
||||
.text-gradient {
|
||||
background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%);
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
}
|
||||
|
||||
@@ -74,14 +106,15 @@ body {
|
||||
|
||||
/* Navigation Styles */
|
||||
.navbar {
|
||||
background: rgba(37, 99, 235, 0.95) !important;
|
||||
backdrop-filter: blur(10px);
|
||||
background: rgba(37, 99, 235, var(--hero-overlay-opacity)) !important;
|
||||
-webkit-backdrop-filter: blur(var(--blur-effect));
|
||||
backdrop-filter: blur(var(--blur-effect));
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 0.3s ease;
|
||||
transition: all var(--animation-duration) ease;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
font-family: var(--font-display);
|
||||
font-family: var(--font-family-display);
|
||||
font-weight: 700;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
@@ -89,7 +122,7 @@ body {
|
||||
.nav-link {
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
transition: all var(--animation-duration) ease;
|
||||
}
|
||||
|
||||
.nav-link:hover {
|
||||
@@ -111,21 +144,21 @@ body {
|
||||
/* Hero Section */
|
||||
.hero-section {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
|
||||
min-height: 70vh;
|
||||
min-height: var(--hero-height-desktop);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* Compact Hero Section for other pages */
|
||||
.hero-section.compact {
|
||||
min-height: 25vh;
|
||||
min-height: var(--compact-hero-height);
|
||||
padding: 3rem 0;
|
||||
}
|
||||
|
||||
/* Mobile optimization for hero sections */
|
||||
@media (max-width: 768px) {
|
||||
.hero-section {
|
||||
min-height: 50vh;
|
||||
min-height: var(--hero-height-mobile);
|
||||
padding: 2rem 0;
|
||||
}
|
||||
|
||||
@@ -154,9 +187,9 @@ body {
|
||||
left: 0;
|
||||
width: 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;
|
||||
pointer-events: none; /* Позволяет кликам проходить через overlay */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hero-section .container {
|
||||
|
||||
15
src/app.js
15
src/app.js
@@ -7,6 +7,7 @@ import compression from 'compression';
|
||||
import morgan from 'morgan';
|
||||
import methodOverride from 'method-override';
|
||||
import formatters from './helpers/formatters.js';
|
||||
import SiteSettingsHelper from './helpers/site-settings.js';
|
||||
import { adminJs, router as adminRouter } from './config/adminjs-simple.js';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { dirname } from 'path';
|
||||
@@ -76,8 +77,8 @@ app.use(session({
|
||||
app.set('view engine', 'ejs');
|
||||
app.set('views', path.join(__dirname, '../views'));
|
||||
|
||||
// Global template variables
|
||||
app.use((req, res, next) => {
|
||||
// Global template variables with site settings
|
||||
app.use(async (req, res, next) => {
|
||||
res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство';
|
||||
res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи';
|
||||
res.locals.user = req.session.user || null;
|
||||
@@ -85,6 +86,14 @@ app.use((req, res, next) => {
|
||||
res.locals.currentPath = req.path;
|
||||
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
|
||||
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 articlesRouter = (await import('./routes/articles.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;
|
||||
|
||||
app.use('/', indexRouter);
|
||||
@@ -156,6 +166,7 @@ app.use('/guides', guidesRouter);
|
||||
app.use('/articles', articlesRouter);
|
||||
app.use('/api', apiRouter);
|
||||
app.use('/api', ratingsRouter);
|
||||
app.use('/', settingsRouter); // Settings routes (CSS and API)
|
||||
|
||||
// Health check endpoint
|
||||
app.get('/health', (req, res) => {
|
||||
|
||||
@@ -42,6 +42,7 @@ const Routes = sequelize.define('routes', {
|
||||
price: { type: DataTypes.DECIMAL(10, 2) },
|
||||
duration: { type: DataTypes.INTEGER },
|
||||
max_group_size: { type: DataTypes.INTEGER },
|
||||
image_url: { type: DataTypes.STRING },
|
||||
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
||||
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
|
||||
@@ -187,6 +188,20 @@ const Holidays = sequelize.define('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' });
|
||||
GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
|
||||
@@ -222,8 +237,8 @@ const adminJsOptions = {
|
||||
options: {
|
||||
parent: { name: 'Контент', icon: 'DocumentText' },
|
||||
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'],
|
||||
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active', 'created_at', 'updated_at'],
|
||||
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', 'image_url', 'is_featured', 'is_active', 'created_at', 'updated_at'],
|
||||
filterProperties: ['title', 'type', 'is_active'],
|
||||
properties: {
|
||||
title: {
|
||||
@@ -263,6 +278,10 @@ const adminJsOptions = {
|
||||
type: 'number',
|
||||
isRequired: true,
|
||||
},
|
||||
image_url: {
|
||||
type: 'string',
|
||||
description: 'URL изображения тура (например: /images/tours/seoul-1.jpg)'
|
||||
},
|
||||
is_featured: { type: 'boolean' },
|
||||
is_active: { type: 'boolean' },
|
||||
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',
|
||||
|
||||
190
src/helpers/site-settings.js
Normal file
190
src/helpers/site-settings.js
Normal 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
86
src/routes/settings.js
Normal 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;
|
||||
@@ -7,10 +7,13 @@
|
||||
<meta name="description" content="<%= siteDescription %>">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" href="/images/favicon.ico">
|
||||
<link rel="icon" href="<%= siteSettings.favicon_url || '/images/favicon.ico' %>">
|
||||
|
||||
<!-- 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">
|
||||
<% if (siteSettings.google_fonts_url && siteSettings.google_fonts_url.trim()) { %>
|
||||
<link href="<%= siteSettings.google_fonts_url %>" rel="stylesheet">
|
||||
<% } %>
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
@@ -26,6 +29,8 @@
|
||||
|
||||
<!-- Custom CSS -->
|
||||
<link href="/css/main.css" rel="stylesheet">
|
||||
<!-- Dynamic Site Settings CSS -->
|
||||
<link href="/dynamic-styles.css" rel="stylesheet">
|
||||
|
||||
<!-- Open Graph Meta Tags -->
|
||||
<meta property="og:title" content="<%= title || siteName %>">
|
||||
@@ -42,7 +47,9 @@
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
||||
<div class="container">
|
||||
<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>
|
||||
</a>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user