This commit is contained in:
@@ -316,6 +316,19 @@ function initDatabase(db) {
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
type TEXT DEFAULT 'manual',
|
||||
restored_at DATETIME
|
||||
)`, () => {
|
||||
setupHeroMediaTable();
|
||||
});
|
||||
}
|
||||
|
||||
function setupHeroMediaTable() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS hero_media (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
type TEXT NOT NULL DEFAULT 'image',
|
||||
path TEXT NOT NULL,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, () => {
|
||||
setupIndices();
|
||||
});
|
||||
|
||||
173
modules/hero/index.js
Normal file
173
modules/hero/index.js
Normal file
@@ -0,0 +1,173 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) {
|
||||
db = database;
|
||||
}
|
||||
|
||||
function getImagesDir() {
|
||||
const dir = path.join(__dirname, '..', '..', 'data', 'hero_images');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getVideosDir() {
|
||||
const dir = path.join(__dirname, '..', '..', 'data', 'hero_videos');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function getPublicHero(req, res) {
|
||||
db.all(
|
||||
`SELECT id, type, path, sort_order FROM hero_media WHERE is_active = 1 ORDER BY sort_order ASC, id ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Get hero media error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
res.json(rows);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAdminHero(req, res) {
|
||||
db.all(
|
||||
`SELECT * FROM hero_media ORDER BY sort_order ASC, id ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Get admin hero media error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
rows.forEach(row => {
|
||||
row.created_at = row.created_at ? new Date(row.created_at).toISOString() : null;
|
||||
});
|
||||
res.json(rows);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function createHeroMedia(req, res) {
|
||||
const { type, path: mediaPath, sort_order } = req.body;
|
||||
|
||||
if (!type || !mediaPath) {
|
||||
return res.status(400).json({ error: 'Type and path are required' });
|
||||
}
|
||||
if (!['image', 'video'].includes(type)) {
|
||||
return res.status(400).json({ error: 'Type must be "image" or "video"' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT INTO hero_media (type, path, sort_order, is_active) VALUES (?, ?, ?, 1)`,
|
||||
[type, mediaPath, sort_order || 0],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Create hero media error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
res.status(201).json({ message: 'Hero media created', id: this.lastID });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updateHeroMedia(req, res) {
|
||||
const id = parseInt(req.params.id);
|
||||
const allowed = ['type', 'path', 'sort_order', 'is_active'];
|
||||
const fields = [];
|
||||
const values = [];
|
||||
|
||||
allowed.forEach(f => {
|
||||
if (req.body[f] !== undefined) {
|
||||
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 hero_media SET ${fields.join(', ')} WHERE id = ?`,
|
||||
values,
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Update hero media error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
return res.status(404).json({ error: 'Hero media not found' });
|
||||
}
|
||||
res.json({ message: 'Hero media updated', id });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function deleteHeroMedia(req, res) {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
db.get(`SELECT path FROM hero_media WHERE id = ?`, [id], (err, row) => {
|
||||
if (err) {
|
||||
console.error('Find hero media error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
if (!row) {
|
||||
return res.status(404).json({ error: 'Hero media not found' });
|
||||
}
|
||||
|
||||
db.run(`DELETE FROM hero_media WHERE id = ?`, [id], function(err) {
|
||||
if (err) {
|
||||
console.error('Delete hero media error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
|
||||
const filePath = row.path;
|
||||
if (filePath && !filePath.startsWith('http')) {
|
||||
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: 'Hero media deleted', id });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function uploadHeroImage(req, res) {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
const filename = req.file.filename;
|
||||
const imagePath = 'data/hero_images/' + filename;
|
||||
res.json({ message: 'Image uploaded', path: imagePath, filename });
|
||||
}
|
||||
|
||||
function uploadHeroVideo(req, res) {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
const filename = req.file.filename;
|
||||
const videoPath = 'data/hero_videos/' + filename;
|
||||
res.json({ message: 'Video uploaded', path: videoPath, filename });
|
||||
}
|
||||
|
||||
function setupRoutes(app, authenticateToken, requireAdmin, uploadImage, uploadVideo) {
|
||||
app.get('/api/hero', getPublicHero);
|
||||
|
||||
app.get('/api/admin/hero', authenticateToken, getAdminHero);
|
||||
app.post('/api/admin/hero', authenticateToken, createHeroMedia);
|
||||
app.put('/api/admin/hero/:id', authenticateToken, updateHeroMedia);
|
||||
app.delete('/api/admin/hero/:id', authenticateToken, deleteHeroMedia);
|
||||
app.post('/api/admin/hero/upload-image', authenticateToken, uploadImage.single('image'), uploadHeroImage);
|
||||
app.post('/api/admin/hero/upload-video', authenticateToken, uploadVideo.single('video'), uploadHeroVideo);
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes, getImagesDir, getVideosDir };
|
||||
@@ -191,7 +191,7 @@ const translations = {
|
||||
|
||||
// About
|
||||
'about.badge': 'О гостинице',
|
||||
'about.title': 'Добро пожаловать в Hotel 777',
|
||||
'about.title': 'Добро пожаловать в ',
|
||||
'about.desc1': 'Гостиница Hotel 777 расположена в живописном селе Мгудзырхуа, Гудаутский район Абхазии, на Набережной улице, 1 — прямо на берегу реки Гудаута и всего в нескольких шагах от Чёрного моря.',
|
||||
'about.desc2': 'Мы предлагаем уютные номера всех категорий, вкусную домашнюю кухню в столовой, аренду SUP-бордов и близость к пляжному кафе. Наши гости ценят аутентичное абхазское гостеприимство и современный комфорт.',
|
||||
'about.feature1_title': 'На берегу моря',
|
||||
@@ -514,7 +514,7 @@ const translations = {
|
||||
|
||||
// About
|
||||
'about.badge': 'About the Hotel',
|
||||
'about.title': 'Welcome to Hotel 777',
|
||||
'about.title': 'Welcome to ',
|
||||
'about.desc1': 'Hotel 777 is located in the picturesque village of Mgudzyrkhuwa, Gudauta District of Abkhazia, at 1 Naberezhnaya Street — right on the bank of the Gudauta River and just a few steps from the Black Sea.',
|
||||
'about.desc2': 'We offer cozy rooms of all categories, delicious home cooking in the canteen, SUP-board rental and proximity to a beach cafe. Our guests appreciate authentic Abkhazian hospitality and modern comfort.',
|
||||
'about.feature1_title': 'Seaside',
|
||||
@@ -840,7 +840,7 @@ const translations = {
|
||||
|
||||
// About
|
||||
'about.badge': 'Асасааирҭа иазкны',
|
||||
'about.title': 'Бзиала шәаабеит Hotel 777 аҟны',
|
||||
'about.title': 'Бзиала шәаабеит ',
|
||||
'about.desc1': 'Асасааирҭа Hotel 777 ишьҭоуп Мгудзырхәа акәакҵьаҿы, Гәдоуҭа араион Аԥсны, Набережнтә аулица, 1 — иара убас Гәдоуҭа аӡиас аԥшаҳәаҿы Амшын Еиқәаҵәа аҟынтәи имаҷны ашәҟақәа рыла.',
|
||||
'about.desc2': 'Ҳара иааҳшьҭуеит апалаҭа ҟәышқәа зегьы ркатегориа, астоловаиаҿы аҩнытәи акухниа ҳашала, SUP-дкақәа рендареи аԥшаҳәатәи акафеи азааигәара. Ҳсасцәа ирзыхиоит аԥсуа милаҭтә асасӡреи асовремениатәи акомфорти.',
|
||||
'about.feature1_title': 'Амшын аԥшаҳәаҿы',
|
||||
|
||||
@@ -207,6 +207,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
<a href="#" data-tab="promocodes"><i class="fas fa-ticket-alt"></i> Промокоды</a>
|
||||
<a href="#" data-tab="seasonal"><i class="fas fa-tags"></i> Сезонные цены</a>
|
||||
<a href="#" data-tab="activities"><i class="fas fa-umbrella-beach"></i> Развлечения</a>
|
||||
<a href="#" data-tab="hero"><i class="fas fa-image"></i> Главный экран</a>
|
||||
<a href="#" data-tab="reviews"><i class="fas fa-star"></i> Отзывы</a>
|
||||
<a href="#" data-tab="settings"><i class="fas fa-cog"></i> Настройки</a>
|
||||
<a href="#" data-tab="profile"><i class="fas fa-user-circle"></i> Профиль</a>
|
||||
@@ -617,6 +618,18 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-hero" class="tab-content">
|
||||
<div class="top-bar">
|
||||
<h1>Главный экран</h1>
|
||||
<button class="btn-gold" onclick="showHeroMediaModal()"><i class="fas fa-plus"></i> Добавить</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body-custom">
|
||||
<div id="heroMediaGrid" style="display: grid; grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); gap: clamp(10px, 2vw, 20px);"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -752,6 +765,9 @@ let token = localStorage.getItem('token');
|
||||
let currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');
|
||||
let editingActivityId = null;
|
||||
let tempActivityImagePath = null;
|
||||
let editingHeroMediaId = null;
|
||||
let tempHeroImagePath = null;
|
||||
let tempHeroVideoPath = null;
|
||||
|
||||
function getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }
|
||||
|
||||
@@ -788,6 +804,7 @@ function initTabs() {
|
||||
if (tab === 'promocodes') loadPromocodes();
|
||||
if (tab === 'seasonal') loadSeasonalPrices();
|
||||
if (tab === 'activities') loadActivitiesAdmin();
|
||||
if (tab === 'hero') loadHeroMediaAdmin();
|
||||
if (tab === 'reviews') loadReviews();
|
||||
if (tab === 'settings') loadSettings();
|
||||
if (tab === 'profile') loadProfile();
|
||||
@@ -2522,6 +2539,216 @@ async function showRoomModal(id) {
|
||||
}
|
||||
document.getElementById('roomModal').classList.add('show');
|
||||
}
|
||||
|
||||
// Hero Media Admin
|
||||
function loadHeroMediaAdmin() {
|
||||
api('/api/admin/hero').then(data => {
|
||||
renderHeroMediaCards(data);
|
||||
}).catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
function renderHeroMediaCards(items) {
|
||||
const grid = document.getElementById('heroMediaGrid');
|
||||
if (!items || items.length === 0) {
|
||||
grid.innerHTML = '<div style="grid-column: 1/-1; text-align: center; padding: 40px; color: #94a3b8;"><i class="fas fa-image" style="font-size: 2rem; display: block; margin-bottom: 12px;"></i>Нет медиа. Добавьте картинку или видео для главного экрана.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = items.map(item => {
|
||||
const isVideo = item.type === 'video';
|
||||
const isYouTube = isVideo && item.path && (item.path.includes('youtube') || item.path.includes('youtu.be'));
|
||||
const thumbStyle = isVideo
|
||||
? (isYouTube ? `background: #ff0000;` : `background: #1e293b;`)
|
||||
: `background-image: url('${item.path}'); background-size: cover; background-position: center;`;
|
||||
const icon = isVideo ? '<i class="fas fa-play" style="color: #fff; font-size: 1.5rem;"></i>' : '';
|
||||
const typeLabel = isVideo ? (isYouTube ? 'YouTube' : 'Видео') : 'Картинка';
|
||||
const activeClass = item.is_active ? 'hero-active-on' : 'hero-active-off';
|
||||
const activeText = item.is_active ? 'Активно' : 'Скрыто';
|
||||
|
||||
return `
|
||||
<div class="hero-media-card" style="background: #fff; border-radius: 12px; border: 1px solid #e2e8f0; overflow: hidden;">
|
||||
<div style="height: 180px; ${thumbStyle} display: flex; align-items: center; justify-content: center; background-color: #f1f5f9;">
|
||||
${icon}
|
||||
</div>
|
||||
<div style="padding: 12px 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px;">
|
||||
<span class="badge" style="background: ${isVideo ? '#ef4444' : '#3b82f6'}; color: #fff; font-size: 0.7rem;">${typeLabel}</span>
|
||||
<span style="font-size: 0.75rem; color: #94a3b8;">Порядок: ${item.sort_order}</span>
|
||||
</div>
|
||||
<div style="font-size: 0.75rem; color: #64748b; margin-bottom: 8px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">${item.path}</div>
|
||||
<div style="display: flex; gap: 4px; flex-wrap: wrap;">
|
||||
<button class="btn-primary-custom btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="editHeroMedia(${item.id})"><i class="fas fa-edit"></i></button>
|
||||
<button class="btn-gold btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="toggleHeroActive(${item.id}, ${item.is_active ? 0 : 1})">
|
||||
<span class="${activeClass}" style="font-size: 0.7rem;">${activeText}</span>
|
||||
</button>
|
||||
<button class="btn btn-danger btn-sm" style="padding: 4px 10px; font-size: 0.75rem;" onclick="deleteHeroMedia(${item.id})"><i class="fas fa-trash"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function showHeroMediaModal(id) {
|
||||
editingHeroMediaId = id || null;
|
||||
tempHeroImagePath = null;
|
||||
tempHeroVideoPath = null;
|
||||
document.getElementById('heroMediaModalTitle').textContent = id ? 'Редактировать медиа' : 'Добавить медиа';
|
||||
document.getElementById('heroMediaType').value = 'image';
|
||||
document.getElementById('heroMediaPath').value = '';
|
||||
document.getElementById('heroMediaSortOrder').value = '';
|
||||
document.getElementById('heroMediaImageInput').value = '';
|
||||
document.getElementById('heroMediaVideoInput').value = '';
|
||||
document.getElementById('heroMediaImagePreview').innerHTML = '';
|
||||
document.getElementById('heroMediaUrlGroup').style.display = 'none';
|
||||
document.getElementById('heroMediaImageGroup').style.display = 'block';
|
||||
document.getElementById('heroMediaVideoGroup').style.display = 'none';
|
||||
document.getElementById('heroMediaDeleteBtn').style.display = id ? 'inline-block' : 'none';
|
||||
|
||||
if (id) {
|
||||
api('/api/admin/hero').then(data => {
|
||||
const item = data.find(m => m.id === id);
|
||||
if (!item) return;
|
||||
document.getElementById('heroMediaType').value = item.type;
|
||||
onHeroTypeChange();
|
||||
if (item.type === 'video') {
|
||||
if (item.path.startsWith('http')) {
|
||||
document.getElementById('heroMediaPath').value = item.path;
|
||||
document.getElementById('heroMediaUrlGroup').style.display = 'block';
|
||||
} else {
|
||||
tempHeroVideoPath = item.path;
|
||||
document.getElementById('heroMediaVideoGroup').style.display = 'block';
|
||||
}
|
||||
} else {
|
||||
tempHeroImagePath = item.path;
|
||||
const img = document.createElement('img');
|
||||
img.src = item.path;
|
||||
img.style.maxWidth = '200px'; img.style.borderRadius = '8px';
|
||||
document.getElementById('heroMediaImagePreview').appendChild(img);
|
||||
}
|
||||
document.getElementById('heroMediaSortOrder').value = item.sort_order;
|
||||
}).catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
document.getElementById('heroMediaModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeHeroMediaModal() {
|
||||
document.getElementById('heroMediaModal').classList.remove('show');
|
||||
editingHeroMediaId = null;
|
||||
tempHeroImagePath = null;
|
||||
tempHeroVideoPath = null;
|
||||
}
|
||||
|
||||
function onHeroTypeChange() {
|
||||
const type = document.getElementById('heroMediaType').value;
|
||||
document.getElementById('heroMediaImageGroup').style.display = type === 'image' ? 'block' : 'none';
|
||||
document.getElementById('heroMediaVideoGroup').style.display = type === 'video' ? 'block' : 'none';
|
||||
document.getElementById('heroMediaUrlGroup').style.display = 'none';
|
||||
}
|
||||
|
||||
function showHeroUrlField() {
|
||||
document.getElementById('heroMediaUrlGroup').style.display = 'block';
|
||||
}
|
||||
|
||||
function uploadHeroImage() {
|
||||
const input = document.getElementById('heroMediaImageInput');
|
||||
const file = input.files[0];
|
||||
if (!file) return showToast('Выберите файл', 'error');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('image', file);
|
||||
|
||||
fetch(API + '/api/admin/hero/upload-image', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json().then(d => ({ status: r.status, body: d })))
|
||||
.then(({ status, body }) => {
|
||||
if (status !== 200) throw new Error(body.error || 'Upload failed');
|
||||
tempHeroImagePath = body.path;
|
||||
const preview = document.getElementById('heroMediaImagePreview');
|
||||
preview.innerHTML = '';
|
||||
const img = document.createElement('img');
|
||||
img.src = body.path;
|
||||
img.style.maxWidth = '200px'; img.style.borderRadius = '8px';
|
||||
preview.appendChild(img);
|
||||
showToast('Картинка загружена');
|
||||
})
|
||||
.catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
function uploadHeroVideo() {
|
||||
const input = document.getElementById('heroMediaVideoInput');
|
||||
const file = input.files[0];
|
||||
if (!file) return showToast('Выберите файл', 'error');
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('video', file);
|
||||
|
||||
fetch(API + '/api/admin/hero/upload-video', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
body: formData
|
||||
})
|
||||
.then(r => r.json().then(d => ({ status: r.status, body: d })))
|
||||
.then(({ status, body }) => {
|
||||
if (status !== 200) throw new Error(body.error || 'Upload failed');
|
||||
tempHeroVideoPath = body.path;
|
||||
showToast('Видео загружено');
|
||||
})
|
||||
.catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
function saveHeroMedia() {
|
||||
const type = document.getElementById('heroMediaType').value;
|
||||
const urlPath = document.getElementById('heroMediaPath').value.trim();
|
||||
const sortOrder = parseInt(document.getElementById('heroMediaSortOrder').value) || 0;
|
||||
|
||||
let mediaPath;
|
||||
if (type === 'image') {
|
||||
mediaPath = tempHeroImagePath || (editingHeroMediaId ? undefined : null);
|
||||
} else {
|
||||
mediaPath = urlPath || tempHeroVideoPath || (editingHeroMediaId ? undefined : null);
|
||||
}
|
||||
|
||||
if (!editingHeroMediaId && !mediaPath) {
|
||||
return showToast('Загрузите файл или укажите URL', 'error');
|
||||
}
|
||||
|
||||
const body = { type, sort_order: sortOrder };
|
||||
if (mediaPath) body.path = mediaPath;
|
||||
|
||||
const method = editingHeroMediaId ? 'PUT' : 'POST';
|
||||
const url = editingHeroMediaId ? `/api/admin/hero/${editingHeroMediaId}` : '/api/admin/hero';
|
||||
|
||||
api(url, { method, body: JSON.stringify(body) })
|
||||
.then(() => {
|
||||
closeHeroMediaModal();
|
||||
loadHeroMediaAdmin();
|
||||
showToast(editingHeroMediaId ? 'Медиа обновлено' : 'Медиа добавлено');
|
||||
})
|
||||
.catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
function editHeroMedia(id) {
|
||||
showHeroMediaModal(id);
|
||||
}
|
||||
|
||||
function toggleHeroActive(id, isActive) {
|
||||
api(`/api/admin/hero/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify({ is_active: isActive })
|
||||
})
|
||||
.then(() => { loadHeroMediaAdmin(); showToast(isActive ? 'Медиа активировано' : 'Медиа скрыто'); })
|
||||
.catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
|
||||
function deleteHeroMedia(id) {
|
||||
if (!confirm('Удалить это медиа?')) return;
|
||||
api(`/api/admin/hero/${id}`, { method: 'DELETE' })
|
||||
.then(() => { loadHeroMediaAdmin(); showToast('Медиа удалено'); })
|
||||
.catch(err => showToast(err.message, 'error'));
|
||||
}
|
||||
</script>
|
||||
|
||||
<!-- Room Edit Modal -->
|
||||
@@ -2778,5 +3005,55 @@ async function showRoomModal(id) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop-custom" id="heroMediaModal">
|
||||
<div class="modal-custom" style="max-width: min(95vw, 500px);">
|
||||
<div class="modal-header-custom">
|
||||
<h3 id="heroMediaModalTitle">Добавить медиа</h3>
|
||||
<button class="modal-close" onclick="closeHeroMediaModal()">×</button>
|
||||
</div>
|
||||
<div class="modal-body-custom">
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Тип</label>
|
||||
<select class="form-control" id="heroMediaType" onchange="onHeroTypeChange()">
|
||||
<option value="image">Картинка</option>
|
||||
<option value="video">Видео</option>
|
||||
</select>
|
||||
</div>
|
||||
<div id="heroMediaImageGroup" class="mb-3">
|
||||
<label class="form-label">Картинка</label>
|
||||
<div id="heroMediaImagePreview" style="margin-bottom: 8px;"></div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<input type="file" class="form-control" id="heroMediaImageInput" accept="image/*" style="font-size: 0.85rem;">
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="uploadHeroImage()"><i class="fas fa-upload"></i> Загрузить</button>
|
||||
</div>
|
||||
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">JPG/PNG/WebP, до 5 MB</small>
|
||||
</div>
|
||||
<div id="heroMediaVideoGroup" class="mb-3" style="display: none;">
|
||||
<label class="form-label">Видео</label>
|
||||
<div style="display: flex; gap: 8px; align-items: center; margin-bottom: 8px;">
|
||||
<input type="file" class="form-control" id="heroMediaVideoInput" accept="video/*" style="font-size: 0.85rem;">
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="uploadHeroVideo()"><i class="fas fa-upload"></i> Загрузить</button>
|
||||
</div>
|
||||
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-bottom: 8px;">MP4/WebM/MOV, до 100 MB</small>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="showHeroUrlField()" style="width: 100%;"><i class="fas fa-link"></i> Или вставить URL</button>
|
||||
</div>
|
||||
<div id="heroMediaUrlGroup" class="mb-3" style="display: none;">
|
||||
<label class="form-label">URL видео</label>
|
||||
<input type="text" class="form-control" id="heroMediaPath" placeholder="https://www.youtube.com/embed/... или https://.../video.mp4">
|
||||
<small class="text-muted" style="font-size: 0.75rem; display: block; margin-top: 4px;">YouTube embed URL или прямая ссылка на видеофайл</small>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Порядок показа</label>
|
||||
<input type="number" class="form-control" id="heroMediaSortOrder" min="0" value="0" placeholder="0">
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer-custom">
|
||||
<button type="button" id="heroMediaDeleteBtn" class="btn btn-danger btn-sm" onclick="deleteHeroMedia(editingHeroMediaId)" style="display: none; margin-right: auto;"><i class="fas fa-trash"></i> Удалить</button>
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="closeHeroMediaModal()">Отмена</button>
|
||||
<button type="button" class="btn-gold btn-sm" onclick="saveHeroMedia()"><i class="fas fa-save"></i> Сохранить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -163,13 +163,50 @@ h1, h2, h3, h4 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
.hero-bg-layer {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
}
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
.hero-bg-default {
|
||||
animation: heroZoom 20s ease-in-out infinite alternate;
|
||||
}
|
||||
.hero-bg-slide {
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease-in-out;
|
||||
}
|
||||
.hero-bg-slide.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.hero-bg-layer video {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
object-fit: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease-in-out;
|
||||
}
|
||||
.hero-bg-layer video.active {
|
||||
opacity: 1;
|
||||
}
|
||||
.hero-bg-layer iframe {
|
||||
position: absolute;
|
||||
top: 0; left: 0;
|
||||
width: 100%; height: 100%;
|
||||
border: none;
|
||||
opacity: 0;
|
||||
transition: opacity 1.5s ease-in-out;
|
||||
pointer-events: none;
|
||||
}
|
||||
.hero-bg-layer iframe.active {
|
||||
opacity: 1;
|
||||
}
|
||||
@keyframes heroZoom {
|
||||
0% { transform: scale(1); }
|
||||
100% { transform: scale(1.08); }
|
||||
|
||||
@@ -49,7 +49,9 @@
|
||||
|
||||
<!-- Hero Section -->
|
||||
<section class="hero" id="home">
|
||||
<img class="hero-bg" src="img/h777.webp" alt="Hotel 777">
|
||||
<div class="hero-bg-layer" id="heroBgLayer">
|
||||
<img class="hero-bg hero-bg-default" src="img/h777.webp" alt="Hotel 777" style="opacity: 1;">
|
||||
</div>
|
||||
<div class="hero-overlay"></div>
|
||||
<div class="container">
|
||||
<div class="hero-content">
|
||||
|
||||
@@ -348,13 +348,175 @@ document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
});
|
||||
});
|
||||
|
||||
// Hero media slideshow
|
||||
let heroSlides = [];
|
||||
let heroCurrentIndex = 0;
|
||||
let heroTimer = null;
|
||||
let heroPaused = false;
|
||||
|
||||
async function initHeroSlideshow() {
|
||||
const layer = document.getElementById('heroBgLayer');
|
||||
if (!layer) return;
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/hero');
|
||||
if (resp.ok) {
|
||||
const media = await resp.json();
|
||||
if (media && media.length > 0) {
|
||||
heroSlides = media;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.log('Hero API not available, using default image');
|
||||
}
|
||||
|
||||
if (heroSlides.length === 0) return;
|
||||
|
||||
layer.innerHTML = '';
|
||||
const defaultImg = layer.querySelector('.hero-bg-default');
|
||||
if (defaultImg) defaultImg.remove();
|
||||
|
||||
heroSlides.forEach((item, i) => {
|
||||
if (item.type === 'image') {
|
||||
const img = document.createElement('img');
|
||||
img.className = 'hero-bg hero-bg-slide';
|
||||
img.src = item.path;
|
||||
img.alt = 'Hotel 777';
|
||||
img.dataset.index = i;
|
||||
if (i === 0) img.classList.add('active');
|
||||
layer.appendChild(img);
|
||||
} else if (item.type === 'video') {
|
||||
const isYouTube = item.path.includes('youtube') || item.path.includes('youtu.be');
|
||||
if (isYouTube) {
|
||||
let embedUrl = item.path;
|
||||
if (item.path.includes('watch?v=')) {
|
||||
const videoId = item.path.split('watch?v=')[1]?.split('&')[0];
|
||||
embedUrl = 'https://www.youtube.com/embed/' + videoId + '?autoplay=0&controls=0&mute=1&loop=0&playlist=' + videoId + '&enablejsapi=1';
|
||||
}
|
||||
if (item.path.includes('youtu.be/')) {
|
||||
const videoId = item.path.split('youtu.be/')[1]?.split('?')[0];
|
||||
embedUrl = 'https://www.youtube.com/embed/' + videoId + '?autoplay=0&controls=0&mute=1&loop=0&playlist=' + videoId + '&enablejsapi=1';
|
||||
}
|
||||
if (!embedUrl.includes('enablejsapi=1')) {
|
||||
embedUrl += (embedUrl.includes('?') ? '&' : '?') + 'enablejsapi=1';
|
||||
}
|
||||
const iframe = document.createElement('iframe');
|
||||
iframe.src = embedUrl;
|
||||
iframe.allow = 'autoplay; encrypted-media';
|
||||
iframe.allowFullscreen = false;
|
||||
iframe.dataset.index = i;
|
||||
iframe.dataset.type = 'youtube';
|
||||
layer.appendChild(iframe);
|
||||
} else {
|
||||
const video = document.createElement('video');
|
||||
video.src = item.path;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.dataset.index = i;
|
||||
video.dataset.type = 'video';
|
||||
if (i === 0) video.classList.add('active');
|
||||
layer.appendChild(video);
|
||||
video.addEventListener('ended', () => onVideoEnded(i));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
startHeroSlideshow();
|
||||
}
|
||||
|
||||
function startHeroSlideshow() {
|
||||
if (heroSlides.length <= 1) {
|
||||
const firstSlide = document.querySelector('#heroBgLayer .active, #heroBgLayer img[data-index="0"], #heroBgLayer video[data-index="0"]');
|
||||
if (firstSlide && firstSlide.tagName === 'VIDEO') {
|
||||
firstSlide.play().catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
showHeroSlide(0);
|
||||
}
|
||||
|
||||
function showHeroSlide(index) {
|
||||
if (heroPaused) return;
|
||||
|
||||
const layer = document.getElementById('heroBgLayer');
|
||||
if (!layer) return;
|
||||
|
||||
const allSlides = layer.querySelectorAll('img, video, iframe');
|
||||
allSlides.forEach(s => s.classList.remove('active'));
|
||||
|
||||
const slide = layer.querySelector(`[data-index="${index}"]`);
|
||||
if (!slide) return;
|
||||
|
||||
// Reset transform on all slides (for parallax cleanup)
|
||||
allSlides.forEach(s => { if (s.tagName === 'IMG' || s.tagName === 'VIDEO') s.style.transform = ''; });
|
||||
|
||||
slide.classList.add('active');
|
||||
heroCurrentIndex = index;
|
||||
|
||||
if (heroTimer) clearTimeout(heroTimer);
|
||||
|
||||
if (slide.tagName === 'VIDEO') {
|
||||
slide.currentTime = 0;
|
||||
slide.play().catch(() => {});
|
||||
// 'ended' event will trigger next slide
|
||||
} else if (slide.dataset.type === 'youtube') {
|
||||
// Post message to YouTube iframe to play
|
||||
const src = new URL(slide.src);
|
||||
src.searchParams.set('autoplay', '1');
|
||||
slide.src = src.toString();
|
||||
// Wait ~30 seconds for YouTube video, then advance (no reliable end event without API)
|
||||
heroTimer = setTimeout(() => goToNextSlide(), 30000);
|
||||
} else {
|
||||
// Image: show for 10 seconds
|
||||
heroTimer = setTimeout(() => goToNextSlide(), 10000);
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoEnded(index) {
|
||||
if (heroCurrentIndex !== index) return;
|
||||
goToNextSlide();
|
||||
}
|
||||
|
||||
function goToNextSlide() {
|
||||
if (heroPaused || heroSlides.length === 0) return;
|
||||
const nextIndex = (heroCurrentIndex + 1) % heroSlides.length;
|
||||
|
||||
const prevSlide = document.querySelector(`#heroBgLayer [data-index="${heroCurrentIndex}"]`);
|
||||
if (prevSlide && prevSlide.tagName === 'VIDEO') {
|
||||
prevSlide.pause();
|
||||
}
|
||||
if (prevSlide && prevSlide.dataset.type === 'youtube') {
|
||||
prevSlide.src = prevSlide.src.replace('autoplay=1', 'autoplay=0');
|
||||
}
|
||||
|
||||
showHeroSlide(nextIndex);
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
heroPaused = document.hidden;
|
||||
if (!heroPaused && heroSlides.length > 1) {
|
||||
showHeroSlide(heroCurrentIndex);
|
||||
}
|
||||
});
|
||||
|
||||
// Parallax effect on hero
|
||||
window.addEventListener('scroll', () => {
|
||||
const hero = document.querySelector('.hero-bg');
|
||||
if (hero) {
|
||||
const hero = document.querySelector('#heroBgLayer .active');
|
||||
if (hero && (hero.tagName === 'IMG' || hero.tagName === 'VIDEO')) {
|
||||
const scrolled = window.scrollY;
|
||||
hero.style.transform = `scale(${1 + scrolled * 0.0001}) translateY(${scrolled * 0.3}px)`;
|
||||
} else {
|
||||
const fallback = document.querySelector('.hero-bg-default');
|
||||
if (fallback) {
|
||||
const scrolled = window.scrollY;
|
||||
fallback.style.transform = `scale(${1 + scrolled * 0.0001}) translateY(${scrolled * 0.3}px)`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initHeroSlideshow();
|
||||
});
|
||||
|
||||
// Gallery Lightbox
|
||||
|
||||
66
server.js
66
server.js
@@ -28,6 +28,10 @@ const roomsUploadsDir = path.join(__dirname, 'data', 'room_images');
|
||||
if (!fs.existsSync(roomsUploadsDir)) fs.mkdirSync(roomsUploadsDir, { recursive: true });
|
||||
const activityUploadsDir = path.join(__dirname, 'data', 'activity_images');
|
||||
if (!fs.existsSync(activityUploadsDir)) fs.mkdirSync(activityUploadsDir, { recursive: true });
|
||||
const heroImagesDir = path.join(__dirname, 'data', 'hero_images');
|
||||
if (!fs.existsSync(heroImagesDir)) fs.mkdirSync(heroImagesDir, { recursive: true });
|
||||
const heroVideosDir = path.join(__dirname, 'data', 'hero_videos');
|
||||
if (!fs.existsSync(heroVideosDir)) fs.mkdirSync(heroVideosDir, { recursive: true });
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, roomsUploadsDir),
|
||||
@@ -69,6 +73,46 @@ const uploadActivity = multer({
|
||||
}
|
||||
});
|
||||
|
||||
const heroImageStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, heroImagesDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const timestamp = Date.now();
|
||||
cb(null, `${timestamp}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadHeroImage = multer({
|
||||
storage: heroImageStorage,
|
||||
limits: { fileSize: 5 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.jpg', '.jpeg', '.png', '.webp'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (allowed.includes(ext)) cb(null, true);
|
||||
else cb(new Error('Только изображения: jpg, jpeg, png, webp'));
|
||||
}
|
||||
});
|
||||
|
||||
const heroVideoStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, heroVideosDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const timestamp = Date.now();
|
||||
cb(null, `${timestamp}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadHeroVideo = multer({
|
||||
storage: heroVideoStorage,
|
||||
limits: { fileSize: 100 * 1024 * 1024 },
|
||||
fileFilter: (req, file, cb) => {
|
||||
const allowed = ['.mp4', '.webm', '.mov', '.avi'];
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
if (allowed.includes(ext)) cb(null, true);
|
||||
else cb(new Error('Только видео: mp4, webm, mov, avi'));
|
||||
}
|
||||
});
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
console.error('FATAL: JWT_SECRET environment variable not set');
|
||||
process.exit(1);
|
||||
@@ -221,6 +265,7 @@ const seasonalPricesModule = require('./modules/seasonalPrices');
|
||||
const emailModule = require('./modules/email');
|
||||
const reportsModule = require('./modules/reports');
|
||||
const activitiesModule = require('./modules/activities');
|
||||
const heroModule = require('./modules/hero');
|
||||
const { runStartupTests } = require('./tests/runStartupTests');
|
||||
|
||||
modules.auth = authModule;
|
||||
@@ -237,6 +282,7 @@ modules.seasonalPrices = seasonalPricesModule;
|
||||
modules.email = emailModule;
|
||||
modules.reports = reportsModule;
|
||||
modules.activities = activitiesModule;
|
||||
modules.hero = heroModule;
|
||||
|
||||
authModule.init(db, JWT_SECRET);
|
||||
bookingsModule.init(db);
|
||||
@@ -251,6 +297,7 @@ seasonalPricesModule.init(db);
|
||||
emailModule.init(db, settingsModule);
|
||||
reportsModule.init(db);
|
||||
activitiesModule.init(db);
|
||||
heroModule.init(db);
|
||||
|
||||
function initDefaultRooms() {
|
||||
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
|
||||
@@ -323,6 +370,7 @@ seasonalPricesModule.setupRoutes(app, authModule.authenticateToken, authModule.r
|
||||
emailModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||
activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity);
|
||||
heroModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadHeroImage, uploadHeroVideo);
|
||||
|
||||
app.get('/api/translations/:lang', (req, res) => {
|
||||
const lang = req.params.lang;
|
||||
@@ -414,6 +462,24 @@ app.get('/data/activity_images/:filename', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data/hero_images/:filename', (req, res) => {
|
||||
const filePath = path.join(heroImagesDir, req.params.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
res.sendFile(filePath);
|
||||
} else {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data/hero_videos/:filename', (req, res) => {
|
||||
const filePath = path.join(heroVideosDir, req.params.filename);
|
||||
if (fs.existsSync(filePath)) {
|
||||
res.sendFile(filePath);
|
||||
} else {
|
||||
res.status(404).json({ error: 'File not found' });
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/', (req, res) => {
|
||||
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user