diff --git a/database/init-database.js b/database/init-database.js index f247642..eed3776 100644 --- a/database/init-database.js +++ b/database/init-database.js @@ -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) { diff --git a/database/style-editor-migration.sql b/database/style-editor-migration.sql new file mode 100644 index 0000000..5fac46a --- /dev/null +++ b/database/style-editor-migration.sql @@ -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(); \ No newline at end of file diff --git a/public/css/main.css b/public/css/main.css index 28276fa..b149687 100644 --- a/public/css/main.css +++ b/public/css/main.css @@ -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 { diff --git a/src/app.js b/src/app.js index 8cdac38..8a0818a 100644 --- a/src/app.js +++ b/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) => { diff --git a/src/config/adminjs-simple.js b/src/config/adminjs-simple.js index ee7f164..812e805 100644 --- a/src/config/adminjs-simple.js +++ b/src/config/adminjs-simple.js @@ -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', diff --git a/src/helpers/site-settings.js b/src/helpers/site-settings.js new file mode 100644 index 0000000..c52b3f8 --- /dev/null +++ b/src/helpers/site-settings.js @@ -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; \ No newline at end of file diff --git a/src/routes/settings.js b/src/routes/settings.js new file mode 100644 index 0000000..ae7453a --- /dev/null +++ b/src/routes/settings.js @@ -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; \ No newline at end of file diff --git a/views/layout.ejs b/views/layout.ejs index 05b146d..03c16a4 100644 --- a/views/layout.ejs +++ b/views/layout.ejs @@ -7,10 +7,13 @@ - + + <% if (siteSettings.google_fonts_url && siteSettings.google_fonts_url.trim()) { %> + + <% } %> @@ -26,6 +29,8 @@ + + @@ -42,7 +47,9 @@