херо
All checks were successful
Deploy hotel / deploy-kdo (push) Successful in 40s

This commit is contained in:
2026-07-19 00:18:20 +05:00
parent b13e570d8b
commit 7c7cf1b1aa
8 changed files with 736 additions and 6 deletions

View File

@@ -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
View 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 };

View File

@@ -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': 'Амшын аԥшаҳәаҿы',

View File

@@ -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()">&times;</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>

View File

@@ -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); }

View File

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

View File

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

View File

@@ -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'));
});