490 lines
21 KiB
JavaScript
490 lines
21 KiB
JavaScript
const path = require('path');
|
||
const fs = require('fs');
|
||
|
||
const translationsModule = require('../translations');
|
||
|
||
let db;
|
||
|
||
const LANGS = ['ru', 'en', 'ab'];
|
||
const LANG_LABELS = { ru: 'Русский', en: 'English', ab: 'Аԥсуа' };
|
||
|
||
const DEFAULT_ABOUT_IMAGE = 'img/123f15ff3-55ae-428f-8af9-7d53c01d28a5.webp';
|
||
|
||
const DEFAULT_GALLERY = [
|
||
{ image: 'img/h777.webp', overlay: 'gallery.overlay.hotel' },
|
||
{ image: 'img/16536f4b7-0c1c-4bbb-8537-96bbdd2e0886.webp', overlay: 'gallery.overlay.beach' },
|
||
{ image: 'img/1e4e22546-0208-4ba8-96fb-02052aecc8b5.webp', overlay: 'gallery.overlay.sunset' },
|
||
{ image: 'img/1e1ad70fe-ff01-4f94-9ca9-4926eba5bdd2.webp', overlay: 'gallery.overlay.mountains' },
|
||
{ image: 'img/123f15ff3-55ae-428f-8af9-7d53c01d28a5.webp', overlay: 'gallery.overlay.river' }
|
||
];
|
||
|
||
function getImagesDir() {
|
||
const dir = path.join(__dirname, '..', '..', 'data', 'content_images');
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
return dir;
|
||
}
|
||
|
||
function t(key, lang) {
|
||
const dict = translationsModule.translations[lang];
|
||
return (dict && dict[key]) || translationsModule.translations.ru[key] || '';
|
||
}
|
||
|
||
function init(database) {
|
||
db = database;
|
||
seedDefaults();
|
||
}
|
||
|
||
function seedDefaults() {
|
||
db.get('SELECT COUNT(*) AS count FROM gallery_items', [], (err, row) => {
|
||
if (err || !row || row.count > 0) return;
|
||
const stmt = db.prepare(
|
||
'INSERT INTO gallery_items (image_path, overlay_ru, overlay_en, overlay_ab, sort_order, is_active) VALUES (?, ?, ?, ?, ?, 1)'
|
||
);
|
||
DEFAULT_GALLERY.forEach((g, i) => {
|
||
stmt.run(g.image, t(g.overlay, 'ru'), t(g.overlay, 'en'), t(g.overlay, 'ab'), i + 1);
|
||
});
|
||
stmt.finalize();
|
||
console.log('✅ Default gallery initialized');
|
||
});
|
||
|
||
db.get('SELECT path FROM content_images WHERE slug = ?', ['about'], (err, row) => {
|
||
if (err || (row && row.path)) return;
|
||
db.run('INSERT OR IGNORE INTO content_images (slug, path) VALUES (?, ?)', ['about', DEFAULT_ABOUT_IMAGE]);
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- helpers
|
||
function qall(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
db.all(sql, params, (err, rows) => (err ? reject(err) : resolve(rows)));
|
||
});
|
||
}
|
||
|
||
function qget(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
db.get(sql, params, (err, row) => (err ? reject(err) : resolve(row)));
|
||
});
|
||
}
|
||
|
||
function qrun(sql, params = []) {
|
||
return new Promise((resolve, reject) => {
|
||
db.run(sql, params, function(err) { err ? reject(err) : resolve(this); });
|
||
});
|
||
}
|
||
|
||
function mergeTranslations(base, overrides) {
|
||
const merged = { ...base };
|
||
overrides.forEach(o => { merged[o.key] = o.value; });
|
||
return merged;
|
||
}
|
||
|
||
function getMergedTranslations(lang, callback) {
|
||
const base = translationsModule.getTranslations(lang) || translationsModule.getTranslations('ru');
|
||
db.all('SELECT key, value FROM content_overrides WHERE lang = ?', [lang], (err, overrides) => {
|
||
if (err) return callback(err, null);
|
||
callback(null, mergeTranslations(base, overrides || []));
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- manifest
|
||
function field(key, label, type) {
|
||
return { key, label, type: type || 'text' };
|
||
}
|
||
|
||
const OPERATORS = [
|
||
{ slug: 'mts', label: 'МТС' },
|
||
{ slug: 'megafon', label: 'МегаФон' },
|
||
{ slug: 'beeline', label: 'Билайн' },
|
||
{ slug: 'yota', label: 'Yota' },
|
||
{ slug: 'tele2', label: 'Tele2' }
|
||
];
|
||
|
||
const OPERATOR_FIELDS = [
|
||
['name', 'Название', 'text'],
|
||
['plan', 'Тариф', 'text'],
|
||
['calls_in', 'Входящие', 'text'],
|
||
['calls_out', 'Исходящие в РФ', 'text'],
|
||
['internet', 'Интернет', 'text'],
|
||
['bonus', 'Бонусы', 'text'],
|
||
['activation', 'Как подключить', 'textarea']
|
||
];
|
||
|
||
const BLOCKS = [
|
||
{
|
||
slug: 'hero', title: 'Главный экран',
|
||
fields: [
|
||
field('hero.badge', 'Бейдж'),
|
||
field('hero.title', 'Заголовок (после Hotel 777)'),
|
||
field('hero.subtitle', 'Подзаголовок', 'textarea'),
|
||
field('hero.btn_rooms', 'Кнопка «Выбрать номер»'),
|
||
field('hero.btn_call', 'Кнопка «Позвонить»'),
|
||
field('hero.btn_whatsapp', 'Кнопка WhatsApp'),
|
||
field('hero.btn_telegram', 'Кнопка Telegram'),
|
||
field('hero.btn_max', 'Кнопка Max'),
|
||
field('hero.stat_sea', 'Статистика: море'),
|
||
field('hero.stat_categories', 'Статистика: категории'),
|
||
field('hero.stat_guests', 'Статистика: гости'),
|
||
field('hero.stat_days', 'Статистика: дни')
|
||
]
|
||
},
|
||
{
|
||
slug: 'about', title: 'О гостинице', image: true,
|
||
fields: [
|
||
field('about.badge', 'Бейдж'),
|
||
field('about.title', 'Заголовок (перед Hotel 777)'),
|
||
field('about.desc1', 'Описание 1', 'textarea'),
|
||
field('about.desc2', 'Описание 2', 'textarea'),
|
||
field('about.badge_img', 'Бейдж на фото'),
|
||
field('about.feature1_title', 'Преимущество 1 — название'),
|
||
field('about.feature1_desc', 'Преимущество 1 — описание'),
|
||
field('about.feature2_title', 'Преимущество 2 — название'),
|
||
field('about.feature2_desc', 'Преимущество 2 — описание'),
|
||
field('about.feature3_title', 'Преимущество 3 — название'),
|
||
field('about.feature3_desc', 'Преимущество 3 — описание'),
|
||
field('about.feature4_title', 'Преимущество 4 — название'),
|
||
field('about.feature4_desc', 'Преимущество 4 — описание')
|
||
]
|
||
},
|
||
{
|
||
slug: 'rooms', title: 'Номера',
|
||
fields: [
|
||
field('rooms.badge', 'Бейдж'),
|
||
field('rooms.title', 'Заголовок'),
|
||
field('rooms.subtitle', 'Подзаголовок', 'textarea')
|
||
]
|
||
},
|
||
{
|
||
slug: 'gallery', title: 'Галерея',
|
||
fields: [
|
||
field('gallery.badge', 'Бейдж'),
|
||
field('gallery.title', 'Заголовок'),
|
||
field('gallery.subtitle', 'Подзаголовок', 'textarea')
|
||
],
|
||
galleryManager: true
|
||
},
|
||
{
|
||
slug: 'activities', title: 'Развлечения',
|
||
fields: [
|
||
field('activities.badge', 'Бейдж'),
|
||
field('activities.title', 'Заголовок'),
|
||
field('activities.subtitle', 'Подзаголовок', 'textarea')
|
||
]
|
||
},
|
||
{
|
||
slug: 'travel_tips', title: 'Советы туриста',
|
||
fields: [
|
||
field('travel_tips.badge', 'Бейдж'),
|
||
field('travel_tips.title', 'Заголовок'),
|
||
field('travel_tips.subtitle', 'Подзаголовок', 'textarea'),
|
||
field('travel_tips.important.title', 'Карточка «Важные моменты» — заголовок'),
|
||
...[1, 2, 3, 4].map(i => [
|
||
field(`travel_tips.important.${i}.title`, `Важное ${i} — название`),
|
||
field(`travel_tips.important.${i}.desc`, `Важное ${i} — описание`, 'textarea')
|
||
]).flat(),
|
||
field('travel_tips.customs.title', 'Карточка «Национальные особенности» — заголовок'),
|
||
...[1, 2, 3, 4].map(i => [
|
||
field(`travel_tips.customs.${i}.title`, `Особенность ${i} — название`),
|
||
field(`travel_tips.customs.${i}.desc`, `Особенность ${i} — описание`, 'textarea')
|
||
]).flat(),
|
||
field('travel_tips.mobile.title', 'Мобильная связь — заголовок'),
|
||
field('travel_tips.mobile.subtitle', 'Мобильная связь — подзаголовок', 'textarea'),
|
||
field('travel_tips.operators.russian.title', 'Российские операторы — заголовок'),
|
||
...OPERATORS.map(op => [
|
||
field(`travel_tips.operators.${op.slug}.name`, `Оператор ${op.label} — название`),
|
||
...OPERATOR_FIELDS.map(([sub, label, type]) =>
|
||
field(`travel_tips.operators.${op.slug}.${sub}`, `Оператор ${op.label} — ${label}`, type)
|
||
)
|
||
]).flat(),
|
||
field('travel_tips.operators.mts.badge', 'МТС — бейдж «Лучшее предложение»'),
|
||
field('travel_tips.operators.local.title', 'Местные операторы — заголовок'),
|
||
field('travel_tips.operators.local.desc', 'Местные операторы — описание', 'textarea'),
|
||
field('travel_tips.operators.local.note', 'Местные операторы — примечание', 'textarea'),
|
||
field('travel_tips.note', 'Примечание внизу', 'textarea')
|
||
]
|
||
},
|
||
{
|
||
slug: 'reviews', title: 'Отзывы',
|
||
fields: [
|
||
field('reviews.title', 'Заголовок'),
|
||
field('reviews.subtitle', 'Подзаголовок', 'textarea'),
|
||
field('reviews.write_first', 'Текст «будьте первым»')
|
||
]
|
||
},
|
||
{
|
||
slug: 'host', title: 'Хозяин',
|
||
fields: [
|
||
field('host.badge', 'Бейдж'),
|
||
field('host.title', 'Имя хозяина'),
|
||
field('host.quote', 'Цитата', 'textarea'),
|
||
field('host.desc1', 'Описание 1', 'textarea'),
|
||
field('host.desc2', 'Описание 2', 'textarea')
|
||
]
|
||
},
|
||
{
|
||
slug: 'contact', title: 'Контакты',
|
||
fields: [
|
||
field('contact.badge', 'Бейдж'),
|
||
field('contact.title', 'Заголовок'),
|
||
field('contact.subtitle', 'Подзаголовок', 'textarea'),
|
||
field('contact.host_label', 'Подпись «Хозяин гостиницы»'),
|
||
field('contact.host_name', 'Имя хозяина'),
|
||
field('contact.address_label', 'Подпись «Адрес»'),
|
||
field('contact.address_value', 'Значение адреса', 'textarea'),
|
||
field('contact.phone_label', 'Подпись «Телефон»'),
|
||
field('contact.hours_label', 'Подпись «Режим работы»'),
|
||
field('contact.hours_value', 'Значение режима работы'),
|
||
field('contact.location_label', 'Подпись «Расположение»'),
|
||
field('contact.location_value', 'Значение расположения', 'textarea'),
|
||
field('contact.whatsapp', 'Кнопка WhatsApp'),
|
||
field('contact.map_area', 'Подпись на карте'),
|
||
field('contact.map_desc', 'Описание на карте', 'textarea')
|
||
]
|
||
},
|
||
{
|
||
slug: 'cta', title: 'Блок бронирования (CTA)',
|
||
fields: [
|
||
field('cta.title', 'Заголовок'),
|
||
field('cta.subtitle', 'Подзаголовок', 'textarea')
|
||
]
|
||
},
|
||
{
|
||
slug: 'footer', title: 'Подвал',
|
||
fields: [
|
||
field('footer.desc', 'Описание', 'textarea'),
|
||
field('footer.nav_title', 'Заголовок «Навигация»'),
|
||
field('footer.services_title', 'Заголовок «Услуги»'),
|
||
field('footer.contact_title', 'Заголовок «Контакты»'),
|
||
field('footer.services_econom', 'Услуга: Номера Эконом'),
|
||
field('footer.services_standard', 'Услуга: Номера Стандарт'),
|
||
field('footer.services_vip', 'Услуга: Номера VIP'),
|
||
field('footer.services_sup', 'Услуга: Аренда SUP-бордов'),
|
||
field('footer.services_canteen', 'Услуга: Столовая'),
|
||
field('footer.copyright', 'Копирайт', 'textarea')
|
||
]
|
||
}
|
||
];
|
||
|
||
// ---------------------------------------------------------------- public
|
||
function getPublicGallery(req, res) {
|
||
const lang = req.query.lang || 'ru';
|
||
qall('SELECT id, image_path, overlay_ru, overlay_en, overlay_ab FROM gallery_items WHERE is_active = 1 ORDER BY sort_order ASC, id ASC')
|
||
.then(rows => {
|
||
const items = rows.map(r => ({
|
||
id: r.id,
|
||
image: r.image_path,
|
||
overlay: r['overlay_' + lang] || r.overlay_ru || ''
|
||
}));
|
||
res.json(items);
|
||
})
|
||
.catch(err => {
|
||
console.error('Get public gallery error:', err);
|
||
res.status(500).json({ error: 'Database error' });
|
||
});
|
||
}
|
||
|
||
function getPublicSite(req, res) {
|
||
qall('SELECT slug, path FROM content_images')
|
||
.then(rows => {
|
||
const images = {};
|
||
rows.forEach(r => { images[r.slug] = r.path; });
|
||
if (!images.about) images.about = DEFAULT_ABOUT_IMAGE;
|
||
res.json({ images });
|
||
})
|
||
.catch(err => {
|
||
console.error('Get site content error:', err);
|
||
res.status(500).json({ error: 'Database error' });
|
||
});
|
||
}
|
||
|
||
// ---------------------------------------------------------------- admin
|
||
function getAdminContent(req, res) {
|
||
Promise.all([
|
||
qall('SELECT lang, key, value FROM content_overrides'),
|
||
qall('SELECT slug, path FROM content_images'),
|
||
qall('SELECT * FROM gallery_items ORDER BY sort_order ASC, id ASC')
|
||
]).then(([overrides, images, gallery]) => {
|
||
const values = {};
|
||
LANGS.forEach(lang => {
|
||
values[lang] = mergeTranslations(
|
||
translationsModule.getTranslations(lang) || translationsModule.getTranslations('ru'),
|
||
overrides.filter(o => o.lang === lang)
|
||
);
|
||
});
|
||
|
||
const imagesMap = {};
|
||
images.forEach(r => { imagesMap[r.slug] = r.path; });
|
||
if (!imagesMap.about) imagesMap.about = DEFAULT_ABOUT_IMAGE;
|
||
|
||
res.json({
|
||
langs: LANGS,
|
||
langLabels: LANG_LABELS,
|
||
blocks: BLOCKS,
|
||
values,
|
||
images: imagesMap,
|
||
gallery
|
||
});
|
||
}).catch(err => {
|
||
console.error('Get admin content error:', err);
|
||
res.status(500).json({ error: 'Database error' });
|
||
});
|
||
}
|
||
|
||
function saveOverrides(req, res) {
|
||
const { lang, values } = req.body;
|
||
if (!LANGS.includes(lang)) return res.status(400).json({ error: 'Invalid language' });
|
||
if (!values || typeof values !== 'object') return res.status(400).json({ error: 'Values required' });
|
||
|
||
const defaults = translationsModule.getTranslations(lang) || translationsModule.getTranslations('ru');
|
||
|
||
const entries = Object.entries(values);
|
||
let processed = 0;
|
||
const fail = (err) => {
|
||
console.error('Save overrides error:', err);
|
||
res.status(500).json({ error: 'Database error' });
|
||
};
|
||
|
||
if (entries.length === 0) return res.json({ message: 'Saved' });
|
||
|
||
entries.forEach(([key, value]) => {
|
||
const val = String(value == null ? '' : value);
|
||
const isDefault = defaults[key] !== undefined && defaults[key] === val;
|
||
const sql = isDefault
|
||
? 'DELETE FROM content_overrides WHERE lang = ? AND key = ?'
|
||
: 'INSERT INTO content_overrides (lang, key, value, updated_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(lang, key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP';
|
||
const params = isDefault ? [lang, key] : [lang, key, val];
|
||
|
||
db.run(sql, params, (err) => {
|
||
if (err) return fail(err);
|
||
processed++;
|
||
if (processed === entries.length) res.json({ message: 'Saved', saved: entries.length });
|
||
});
|
||
});
|
||
}
|
||
|
||
function createGalleryItem(req, res) {
|
||
const { image_path, overlay_ru, overlay_en, overlay_ab, sort_order } = req.body;
|
||
if (!image_path) return res.status(400).json({ error: 'Image required' });
|
||
|
||
db.get(
|
||
'SELECT COALESCE(MAX(sort_order), 0) AS max FROM gallery_items',
|
||
[],
|
||
(err, row) => {
|
||
if (err) return res.status(500).json({ error: 'Database error' });
|
||
const order = (sort_order !== undefined && sort_order !== null && sort_order !== '') ? parseInt(sort_order, 10) : (row.max + 1);
|
||
db.run(
|
||
'INSERT INTO gallery_items (image_path, overlay_ru, overlay_en, overlay_ab, sort_order, is_active) VALUES (?, ?, ?, ?, ?, 1)',
|
||
[image_path, overlay_ru || '', overlay_en || '', overlay_ab || '', order],
|
||
function(err2) {
|
||
if (err2) {
|
||
console.error('Create gallery item error:', err2);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
res.status(201).json({ message: 'Gallery item created', id: this.lastID });
|
||
}
|
||
);
|
||
}
|
||
);
|
||
}
|
||
|
||
function updateGalleryItem(req, res) {
|
||
const id = parseInt(req.params.id, 10);
|
||
const allowed = ['image_path', 'overlay_ru', 'overlay_en', 'overlay_ab', 'sort_order', 'is_active'];
|
||
const fields = [];
|
||
const values = [];
|
||
|
||
allowed.forEach(f => {
|
||
if (req.body[f] !== undefined && req.body[f] !== null) {
|
||
fields.push(f + ' = ?');
|
||
values.push(req.body[f]);
|
||
}
|
||
});
|
||
|
||
if (!fields.length) return res.status(400).json({ error: 'No fields to update' });
|
||
|
||
values.push(id);
|
||
db.run(`UPDATE gallery_items SET ${fields.join(', ')} WHERE id = ?`, values, function(err) {
|
||
if (err) {
|
||
console.error('Update gallery item error:', err);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
if (this.changes === 0) return res.status(404).json({ error: 'Gallery item not found' });
|
||
res.json({ message: 'Gallery item updated', id });
|
||
});
|
||
}
|
||
|
||
function deleteGalleryItem(req, res) {
|
||
const id = parseInt(req.params.id, 10);
|
||
db.get('SELECT image_path FROM gallery_items WHERE id = ?', [id], (err, row) => {
|
||
if (err) {
|
||
console.error('Find gallery item error:', err);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
if (!row) return res.status(404).json({ error: 'Gallery item not found' });
|
||
|
||
db.run('DELETE FROM gallery_items WHERE id = ?', [id], function(err2) {
|
||
if (err2) {
|
||
console.error('Delete gallery item error:', err2);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
|
||
const filePath = row.image_path;
|
||
if (filePath && !filePath.startsWith('http') && !filePath.startsWith('img/')) {
|
||
const fullPath = path.join(__dirname, '..', '..', filePath);
|
||
if (fs.existsSync(fullPath)) {
|
||
fs.unlink(fullPath, (unlinkErr) => {
|
||
if (unlinkErr) console.error('Failed to delete file:', unlinkErr);
|
||
});
|
||
}
|
||
}
|
||
|
||
res.json({ message: 'Gallery item deleted', id });
|
||
});
|
||
});
|
||
}
|
||
|
||
function uploadImageHandler(req, res) {
|
||
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
|
||
const filename = req.file.filename;
|
||
const filePath = 'data/content_images/' + filename;
|
||
res.json({ message: 'Image uploaded', path: filePath, filename });
|
||
}
|
||
|
||
function saveBlockImage(req, res) {
|
||
const { slug, path: imagePath } = req.body;
|
||
if (!slug || !imagePath) return res.status(400).json({ error: 'Slug and path required' });
|
||
|
||
db.run(
|
||
'INSERT INTO content_images (slug, path, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) ON CONFLICT(slug) DO UPDATE SET path = excluded.path, updated_at = CURRENT_TIMESTAMP',
|
||
[slug, imagePath],
|
||
(err) => {
|
||
if (err) {
|
||
console.error('Save block image error:', err);
|
||
return res.status(500).json({ error: 'Database error' });
|
||
}
|
||
res.json({ message: 'Image saved', slug, path: imagePath });
|
||
}
|
||
);
|
||
}
|
||
|
||
function setupRoutes(app, authenticateToken, requireAdmin, upload) {
|
||
app.get('/api/content/gallery', getPublicGallery);
|
||
app.get('/api/content/site', getPublicSite);
|
||
|
||
app.get('/api/admin/content', authenticateToken, requireAdmin, getAdminContent);
|
||
app.put('/api/admin/content/overrides', authenticateToken, requireAdmin, saveOverrides);
|
||
app.post('/api/admin/content/gallery', authenticateToken, requireAdmin, createGalleryItem);
|
||
app.put('/api/admin/content/gallery/:id', authenticateToken, requireAdmin, updateGalleryItem);
|
||
app.delete('/api/admin/content/gallery/:id', authenticateToken, requireAdmin, deleteGalleryItem);
|
||
app.post('/api/admin/content/upload', authenticateToken, requireAdmin, upload.single('image'), uploadImageHandler);
|
||
app.put('/api/admin/content/images', authenticateToken, requireAdmin, saveBlockImage);
|
||
}
|
||
|
||
module.exports = {
|
||
init,
|
||
setupRoutes,
|
||
getImagesDir,
|
||
getMergedTranslations,
|
||
getPublicGallery,
|
||
getPublicSite,
|
||
getAdminContent
|
||
}; |