🎨 Добавлен полный редактор стилей и поле 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!');
|
console.log('✨ Database initialization completed successfully!');
|
||||||
|
|
||||||
} catch (error) {
|
} 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 */
|
/* 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 {
|
||||||
|
|||||||
15
src/app.js
15
src/app.js
@@ -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) => {
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
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 %>">
|
<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>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user