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 @@