This commit is contained in:
205
modules/activities/index.js
Normal file
205
modules/activities/index.js
Normal file
@@ -0,0 +1,205 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
|
||||
let db;
|
||||
|
||||
function init(database) {
|
||||
db = database;
|
||||
}
|
||||
|
||||
function getUploadsDir() {
|
||||
const dir = path.join(__dirname, '..', '..', 'data', 'activity_images');
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function validLangCount(row) {
|
||||
let count = 0;
|
||||
if (row.title_ru || row.desc_ru) count++;
|
||||
if (row.title_en || row.desc_en) count++;
|
||||
if (row.title_ab || row.desc_ab) count++;
|
||||
return count;
|
||||
}
|
||||
|
||||
function pickLang(row, lang) {
|
||||
const filled = validLangCount(row);
|
||||
if (filled === 1) {
|
||||
if (row.title_ru || row.desc_ru) return { title: row.title_ru, desc: row.desc_ru };
|
||||
if (row.title_en || row.desc_en) return { title: row.title_en, desc: row.desc_en };
|
||||
return { title: row.title_ab, desc: row.desc_ab };
|
||||
}
|
||||
return {
|
||||
title: row['title_' + lang] || row.title_ru || '',
|
||||
desc: row['desc_' + lang] || row.desc_ru || ''
|
||||
};
|
||||
}
|
||||
|
||||
function getPublicActivities(req, res) {
|
||||
const lang = req.query.lang || 'ru';
|
||||
|
||||
db.all(
|
||||
`SELECT id, title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path
|
||||
FROM activities WHERE is_active = 1 ORDER BY sort_order ASC, id ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Get activities error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
|
||||
const activities = rows.map(row => {
|
||||
const localized = pickLang(row, lang);
|
||||
return {
|
||||
id: row.id,
|
||||
title: localized.title,
|
||||
desc: localized.desc,
|
||||
icon: row.icon,
|
||||
image_path: row.image_path
|
||||
};
|
||||
});
|
||||
|
||||
res.json(activities);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function getAdminActivities(req, res) {
|
||||
db.all(
|
||||
`SELECT * FROM activities ORDER BY sort_order ASC, id ASC`,
|
||||
[],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
console.error('Get admin activities 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 createActivity(req, res) {
|
||||
const { title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path, sort_order } = req.body;
|
||||
|
||||
const rawRow = { title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab };
|
||||
const count = validLangCount(rawRow);
|
||||
if (count !== 1 && count !== 3) {
|
||||
return res.status(400).json({ error: 'Должен быть заполнен 1 язык или все 3' });
|
||||
}
|
||||
|
||||
db.run(
|
||||
`INSERT INTO activities (title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path, sort_order, is_active)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[title_ru || null, title_en || null, title_ab || null, desc_ru || null, desc_en || null, desc_ab || null, icon || null, image_path || null, sort_order || 0],
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Create activity error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
res.status(201).json({ message: 'Activity created', id: this.lastID });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function updateActivity(req, res) {
|
||||
const id = parseInt(req.params.id);
|
||||
|
||||
const langFields = ['title_ru', 'title_en', 'title_ab', 'desc_ru', 'desc_en', 'desc_ab'];
|
||||
const anyLangChanged = langFields.some(f => req.body[f] !== undefined);
|
||||
|
||||
if (anyLangChanged) {
|
||||
const rawRow = {};
|
||||
langFields.forEach(f => { rawRow[f] = req.body[f]; });
|
||||
const count = validLangCount(rawRow);
|
||||
if (count !== 0 && count !== 1 && count !== 3) {
|
||||
return res.status(400).json({ error: 'Должен быть заполнен 1 язык или все 3' });
|
||||
}
|
||||
}
|
||||
|
||||
const allowed = ['title_ru', 'title_en', 'title_ab', 'desc_ru', 'desc_en', 'desc_ab', 'icon', 'image_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] === '' ? null : req.body[f]);
|
||||
}
|
||||
});
|
||||
|
||||
if (!fields.length) {
|
||||
return res.status(400).json({ error: 'No fields to update' });
|
||||
}
|
||||
|
||||
values.push(id);
|
||||
|
||||
db.run(
|
||||
`UPDATE activities SET ${fields.join(', ')} WHERE id = ?`,
|
||||
values,
|
||||
function(err) {
|
||||
if (err) {
|
||||
console.error('Update activity error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
res.json({ message: 'Activity updated', id });
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function deleteActivity(req, res) {
|
||||
const id = parseInt(req.params.id);
|
||||
db.run(`DELETE FROM activities WHERE id = ?`, [id], function(err) {
|
||||
if (err) {
|
||||
console.error('Delete activity error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
if (this.changes === 0) {
|
||||
return res.status(404).json({ error: 'Activity not found' });
|
||||
}
|
||||
res.json({ message: 'Activity deleted', id });
|
||||
});
|
||||
}
|
||||
|
||||
function uploadActivityImage(req, res) {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const filename = req.file.filename;
|
||||
const activityId = parseInt(req.params.id);
|
||||
|
||||
const imagePath = 'data/activity_images/' + filename;
|
||||
|
||||
if (activityId && req.body.update_activity === '1') {
|
||||
db.run(`UPDATE activities SET image_path = ? WHERE id = ?`, [imagePath, activityId], function(err) {
|
||||
if (err) {
|
||||
console.error('Update activity image error:', err);
|
||||
return res.status(500).json({ error: 'Database error' });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ message: 'Image uploaded', path: imagePath, filename });
|
||||
}
|
||||
|
||||
function setupRoutes(app, authenticateToken, requireAdmin, upload) {
|
||||
app.get('/api/activities', getPublicActivities);
|
||||
|
||||
app.get('/api/admin/activities', authenticateToken, getAllActivities);
|
||||
app.post('/api/admin/activities', authenticateToken, createActivity);
|
||||
app.put('/api/admin/activities/:id', authenticateToken, updateActivity);
|
||||
app.delete('/api/admin/activities/:id', authenticateToken, deleteActivity);
|
||||
app.post('/api/admin/activities/upload', authenticateToken, upload.single('image'), uploadActivityImage);
|
||||
app.post('/api/admin/activities/:id/image', authenticateToken, upload.single('image'), uploadActivityImage);
|
||||
}
|
||||
|
||||
function getAllActivities(req, res) {
|
||||
getAdminActivities(req, res);
|
||||
}
|
||||
|
||||
module.exports = { init, setupRoutes, getUploadsDir };
|
||||
@@ -259,6 +259,52 @@ function initDatabase(db) {
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`);
|
||||
setupActivitiesTable();
|
||||
}
|
||||
|
||||
function setupActivitiesTable() {
|
||||
db.run(`CREATE TABLE IF NOT EXISTS activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
title_ru TEXT,
|
||||
title_en TEXT,
|
||||
title_ab TEXT,
|
||||
desc_ru TEXT,
|
||||
desc_en TEXT,
|
||||
desc_ab TEXT,
|
||||
icon TEXT,
|
||||
image_path TEXT,
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
is_active INTEGER DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)`, () => {
|
||||
db.get("SELECT COUNT(*) as count FROM activities", [], (err, row) => {
|
||||
if (err || row.count > 0) return;
|
||||
const stmt = db.prepare(`INSERT INTO activities (title_ru, title_en, title_ab, desc_ru, desc_en, desc_ab, icon, image_path, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
|
||||
stmt.run(
|
||||
'SUP-борды', 'SUP Boards', 'SUP-дкақәа',
|
||||
'Аренда SUP-бордов прямо на пляже. Исследуйте побережье с воды — идеальный способ увидеть Абхазию с нового ракурса!',
|
||||
'Rent SUP-boards right on the beach. Explore the coast from the water — the perfect way to see Abkhazia from a new angle!',
|
||||
'SUP-дкақәа рендара аԥшаҳәаҟны иара убас. Аӡы аҟнытәи аԥшаҳәа аҭҵаара — Аԥсны алахреи ала аҿаԥшра аидеалтә мҩа!',
|
||||
'fas fa-water', 'img/105d059e5-a204-499f-a01c-c540f6ae1e5d.webp', 1
|
||||
);
|
||||
stmt.run(
|
||||
'Кафе у пляжа', 'Beach Cafe', 'Акафе аԥшаҳәаҟны',
|
||||
'Всего 2 минуты пешком — уютное кафе с видом на море. Абхазская и русская кухня, свежие морепродукты и ароматный кофе.',
|
||||
'Just a 2-minute walk — a cozy cafe with a sea view. Abkhazian and Russian cuisine, fresh seafood and aromatic coffee.',
|
||||
'2 минуҭк мацара ашьааршәара — акафе ҟәышқәа амшын ахсаала ала. Аԥсуеи аурыси акухниа, амшынтәи аԥсыӡқәа ҿыцқәеи акафе иҭагаз.',
|
||||
'fas fa-coffee', 'img/18abbb136-0e24-4c92-a568-fec8011e029b.webp', 2
|
||||
);
|
||||
stmt.run(
|
||||
'Столовая Hotel 777', 'Hotel 777 Canteen', 'Астоловаиа Hotel 777',
|
||||
'Домашняя кухня каждый день! Завтраки, обеды и ужины из свежих местных продуктов. Аджика, хачапури, азу и многое другое.',
|
||||
'Home cooking every day! Breakfast, lunch and dinner made from fresh local ingredients. Adjika, khachapuri, azu and much more.',
|
||||
'Аҩнытәи акухниа есымша! Ашьажьыкра, аинышәареи ахархәареи аҭыԥантәи аԥсыӡқәа ҿыцқәа ирылхуп. Аџьыка, ахачаԥури, азуи даҽагьы.',
|
||||
'fas fa-utensils', 'img/1f00325ba-df2c-4bb9-9ec6-28958e11f843.webp', 3
|
||||
);
|
||||
stmt.finalize();
|
||||
console.log('✅ Default activities initialized');
|
||||
});
|
||||
});
|
||||
setupBackupsTable();
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,7 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
<a href="#" data-tab="rooms"><i class="fas fa-door-open"></i> Номера</a>
|
||||
<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="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>
|
||||
@@ -598,6 +599,18 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="tab-activities" class="tab-content">
|
||||
<div class="top-bar">
|
||||
<h1>Развлечения — Чем заняться рядом</h1>
|
||||
<button class="btn-gold" onclick="showActivityModal()"><i class="fas fa-plus"></i> Добавить</button>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-body-custom">
|
||||
<div id="activitiesGrid" 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>
|
||||
|
||||
@@ -731,6 +744,8 @@ tr.row-checkout-today { background: #fef2f2 !important; border-left: 4px solid #
|
||||
const API = '';
|
||||
let token = localStorage.getItem('token');
|
||||
let currentUser = JSON.parse(localStorage.getItem('currentUser') || 'null');
|
||||
let editingActivityId = null;
|
||||
let tempActivityImagePath = null;
|
||||
|
||||
function getHeaders() { return { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token }; }
|
||||
|
||||
@@ -766,6 +781,7 @@ function initTabs() {
|
||||
if (tab === 'rooms') loadRooms();
|
||||
if (tab === 'promocodes') loadPromocodes();
|
||||
if (tab === 'seasonal') loadSeasonalPrices();
|
||||
if (tab === 'activities') loadActivitiesAdmin();
|
||||
if (tab === 'reviews') loadReviews();
|
||||
if (tab === 'settings') loadSettings();
|
||||
if (tab === 'profile') loadProfile();
|
||||
@@ -2158,7 +2174,10 @@ function hideSeasonalModal() {
|
||||
document.getElementById('seasonalModal').classList.remove('show');
|
||||
}
|
||||
|
||||
document.getElementById('seasonalForm').addEventListener('submit', async function(e) {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const seasonalForm = document.getElementById('seasonalForm');
|
||||
if (!seasonalForm) return;
|
||||
seasonalForm.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
const id = document.getElementById('editSeasonalId').value;
|
||||
const data = {
|
||||
@@ -2179,6 +2198,7 @@ document.getElementById('seasonalForm').addEventListener('submit', async functio
|
||||
loadSeasonalPrices();
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
});
|
||||
});
|
||||
|
||||
function editSeasonal(id) { showSeasonalModal(id); }
|
||||
|
||||
@@ -2190,6 +2210,176 @@ async function deleteSeasonal(id) {
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function loadActivitiesAdmin() {
|
||||
try {
|
||||
const activities = await api('/api/admin/activities');
|
||||
renderActivitiesCards(activities);
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
function renderActivitiesCards(activities) {
|
||||
const grid = document.getElementById('activitiesGrid');
|
||||
if (!activities || activities.length === 0) {
|
||||
grid.innerHTML = '<div class="text-center text-muted" style="padding: 40px; grid-column: 1 / -1;">Нет развлечений. Нажмите "Добавить" для создания.</div>';
|
||||
return;
|
||||
}
|
||||
grid.innerHTML = activities.map(a => {
|
||||
const imageSrc = a.image_path || 'data:image/svg+xml,%3Csvg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 400 250"%3E%3Crect fill="%23274151" width="400" height="250"/%3E%3Ctext fill="%2364748b" font-family="sans-serif" font-size="14" x="50%25" y="50%25" text-anchor="middle" dy=".3em"%3EБез фото%3C/text%3E%3C/svg%3E';
|
||||
let langs = [];
|
||||
if (a.title_ru || a.desc_ru) langs.push('RU');
|
||||
if (a.title_en || a.desc_en) langs.push('EN');
|
||||
if (a.title_ab || a.desc_ab) langs.push('AB');
|
||||
const langsBadge = langs.length === 3
|
||||
? '<span class="badge badge-admin" style="margin-left:6px;">RU+EN+AB</span>'
|
||||
: `<span class="badge badge-user" style="margin-left:6px;">${langs.join('+')}</span>`;
|
||||
const statusClass = a.is_active ? 'badge-status-оплачена' : 'badge-status-отменена';
|
||||
const statusText = a.is_active ? 'Активен' : 'Скрыт';
|
||||
const iconHtml = a.icon ? `<span style="color:#c9a84c;margin-right:6px;"><i class="${a.icon}"></i></span>` : '';
|
||||
const title = a.title_ru || a.title_en || a.title_ab || 'Без названия';
|
||||
|
||||
return '<div class="room-admin-card">' +
|
||||
'<div class="room-admin-image">' +
|
||||
'<img src="' + esc(imageSrc) + '" alt="' + esc(title) + '" onerror="this.src=\'data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2214%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EБез фото%3C/text%3E%3C/svg%3E\'">' +
|
||||
'<span class="badge ' + statusClass + '" style="position:absolute;top:10px;left:10px;font-size:0.65rem;">' + statusText + '</span>' +
|
||||
'<span style="position:absolute;top:10px;right:10px;font-size:0.65rem;">#' + a.sort_order + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="room-admin-body">' +
|
||||
'<h4 class="room-admin-name">' + iconHtml + esc(title) + langsBadge + '</h4>' +
|
||||
'<div class="room-admin-meta">' +
|
||||
'<span><i class="fas fa-globe"></i> ' + langs.join(' + ') + '</span>' +
|
||||
'<span><i class="fas fa-sort-numeric-down"></i> Порядок: ' + a.sort_order + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="room-admin-actions">' +
|
||||
'<button class="btn-primary-custom btn-sm" onclick="showActivityModal(' + a.id + ')" style="flex:1;"><i class="fas fa-edit"></i> Редактировать</button>' +
|
||||
'<button class="btn-' + (a.is_active ? 'warning' : 'success') + ' btn-sm" onclick="toggleActivityActive(' + a.id + ', ' + !a.is_active + ')" style="padding:8px 12px;"><i class="fas fa-' + (a.is_active ? 'eye-slash' : 'eye') + '"></i></button>' +
|
||||
'<button class="btn-danger-custom btn-sm" onclick="deleteActivity(' + a.id + ')" style="padding:8px 12px;"><i class="fas fa-trash"></i></button>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function toggleActivityActive(id, isActive) {
|
||||
try {
|
||||
await api('/api/admin/activities/' + id, { method: 'PUT', body: JSON.stringify({ is_active: isActive ? 1 : 0 }) });
|
||||
loadActivitiesAdmin();
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function showActivityModal(id) {
|
||||
document.getElementById('activityModalTitle').textContent = id ? 'Редактировать развлечение' : 'Добавить развлечение';
|
||||
document.getElementById('editActivityId').value = id || '';
|
||||
document.getElementById('actTitleRu').value = '';
|
||||
document.getElementById('actTitleEn').value = '';
|
||||
document.getElementById('actTitleAb').value = '';
|
||||
document.getElementById('actDescRu').value = '';
|
||||
document.getElementById('actDescEn').value = '';
|
||||
document.getElementById('actDescAb').value = '';
|
||||
document.getElementById('actIcon').value = '';
|
||||
document.getElementById('actSortOrder').value = '0';
|
||||
document.getElementById('actIsActive').checked = true;
|
||||
document.getElementById('activityImageInput').value = '';
|
||||
document.getElementById('activityImagePreview').innerHTML = '';
|
||||
tempActivityImagePath = null;
|
||||
editingActivityId = id || null;
|
||||
|
||||
if (id) {
|
||||
try {
|
||||
const activities = await api('/api/admin/activities');
|
||||
const a = activities.find(x => x.id === id);
|
||||
if (a) {
|
||||
document.getElementById('actTitleRu').value = a.title_ru || '';
|
||||
document.getElementById('actTitleEn').value = a.title_en || '';
|
||||
document.getElementById('actTitleAb').value = a.title_ab || '';
|
||||
document.getElementById('actDescRu').value = a.desc_ru || '';
|
||||
document.getElementById('actDescEn').value = a.desc_en || '';
|
||||
document.getElementById('actDescAb').value = a.desc_ab || '';
|
||||
document.getElementById('actIcon').value = a.icon || '';
|
||||
document.getElementById('actSortOrder').value = a.sort_order || 0;
|
||||
document.getElementById('actIsActive').checked = a.is_active === 1;
|
||||
tempActivityImagePath = a.image_path || null;
|
||||
if (a.image_path) {
|
||||
document.getElementById('activityImagePreview').innerHTML = `<img src="${a.image_path}" style="max-width:200px;max-height:120px;object-fit:cover;border-radius:8px;">`;
|
||||
}
|
||||
}
|
||||
} catch(e) { showToast(e.message, 'error'); }
|
||||
}
|
||||
document.getElementById('activityModal').classList.add('show');
|
||||
}
|
||||
|
||||
function closeActivityModal() {
|
||||
document.getElementById('activityModal').classList.remove('show');
|
||||
editingActivityId = null;
|
||||
tempActivityImagePath = null;
|
||||
}
|
||||
|
||||
async function saveActivity() {
|
||||
const data = {
|
||||
title_ru: document.getElementById('actTitleRu').value.trim() || null,
|
||||
title_en: document.getElementById('actTitleEn').value.trim() || null,
|
||||
title_ab: document.getElementById('actTitleAb').value.trim() || null,
|
||||
desc_ru: document.getElementById('actDescRu').value.trim() || null,
|
||||
desc_en: document.getElementById('actDescEn').value.trim() || null,
|
||||
desc_ab: document.getElementById('actDescAb').value.trim() || null,
|
||||
icon: document.getElementById('actIcon').value.trim() || null,
|
||||
sort_order: parseInt(document.getElementById('actSortOrder').value) || 0,
|
||||
is_active: document.getElementById('actIsActive').checked ? 1 : 0
|
||||
};
|
||||
|
||||
const filled = [!!data.title_ru || !!data.desc_ru, !!data.title_en || !!data.desc_en, !!data.title_ab || !!data.desc_ab].filter(Boolean).length;
|
||||
if (filled !== 0 && filled !== 1 && filled !== 3) {
|
||||
showToast('Должен быть заполнен 1 язык или все 3', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (tempActivityImagePath) {
|
||||
data.image_path = tempActivityImagePath;
|
||||
}
|
||||
|
||||
try {
|
||||
if (editingActivityId) {
|
||||
await api('/api/admin/activities/' + editingActivityId, { method: 'PUT', body: JSON.stringify(data) });
|
||||
showToast('Развлечение обновлено');
|
||||
} else {
|
||||
await api('/api/admin/activities', { method: 'POST', body: JSON.stringify(data) });
|
||||
showToast('Развлечение добавлено');
|
||||
}
|
||||
closeActivityModal();
|
||||
loadActivitiesAdmin();
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function deleteActivity(id) {
|
||||
if (!confirm('Удалить это развлечение?')) return;
|
||||
try {
|
||||
await api('/api/admin/activities/' + id, { method: 'DELETE' });
|
||||
showToast('Развлечение удалено');
|
||||
loadActivitiesAdmin();
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
async function uploadActivityImage() {
|
||||
const input = document.getElementById('activityImageInput');
|
||||
const file = input.files[0];
|
||||
if (!file) { showToast('Выберите файл', 'error'); return; }
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('image', file);
|
||||
|
||||
try {
|
||||
const res = await fetch(API + '/api/admin/activities/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + token },
|
||||
body: fd
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) throw new Error(data.error || 'Ошибка загрузки');
|
||||
tempActivityImagePath = data.path;
|
||||
document.getElementById('activityImagePreview').innerHTML = `<img src="${data.path}" style="max-width:200px;max-height:120px;object-fit:cover;border-radius:8px;">`;
|
||||
showToast('Изображение загружено');
|
||||
} catch(err) { showToast(err.message, 'error'); }
|
||||
}
|
||||
|
||||
// Security keys
|
||||
async function loadSecurityKeys() {
|
||||
try {
|
||||
@@ -2510,5 +2700,78 @@ async function showRoomModal(id) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-backdrop-custom" id="activityModal">
|
||||
<div class="modal-custom" style="max-width: min(95vw, 700px);">
|
||||
<div class="modal-header-custom">
|
||||
<h3 id="activityModalTitle">Добавить развлечение</h3>
|
||||
<button class="modal-close" onclick="closeActivityModal()">×</button>
|
||||
</div>
|
||||
<form id="activityForm">
|
||||
<input type="hidden" id="editActivityId">
|
||||
<div class="modal-body-custom" style="max-height: 65vh; overflow-y: auto;">
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Название (RU)</label>
|
||||
<input type="text" class="form-control" id="actTitleRu" placeholder="На русском">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Название (EN)</label>
|
||||
<input type="text" class="form-control" id="actTitleEn" placeholder="На английском">
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Название (AB)</label>
|
||||
<input type="text" class="form-control" id="actTitleAb" placeholder="На абхазском">
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Описание (RU)</label>
|
||||
<textarea class="form-control" id="actDescRu" rows="2" placeholder="На русском"></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Описание (EN)</label>
|
||||
<textarea class="form-control" id="actDescEn" rows="2" placeholder="На английском"></textarea>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<label class="form-label">Описание (AB)</label>
|
||||
<textarea class="form-control" id="actDescAb" rows="2" placeholder="На абхазском"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<small class="text-muted d-block mb-3" style="margin-top: -8px;">Заполните либо 1 язык, либо все 3. Промежуточные варианты не допускаются.</small>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-md-6">
|
||||
<label class="form-label">Иконка (Font Awesome)</label>
|
||||
<input type="text" class="form-control" id="actIcon" placeholder="fas fa-water">
|
||||
<small class="text-muted">Например: fas fa-water, fas fa-coffee, fas fa-utensils</small>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label class="form-label">Порядок</label>
|
||||
<input type="number" class="form-control" id="actSortOrder" min="0" value="0">
|
||||
</div>
|
||||
<div class="col-md-3 d-flex align-items-end">
|
||||
<div class="form-check mb-2">
|
||||
<input type="checkbox" class="form-check-input" id="actIsActive" checked>
|
||||
<label class="form-check-label" for="actIsActive">Активно</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label class="form-label">Изображение</label>
|
||||
<div id="activityImagePreview" style="margin-bottom: 8px;"></div>
|
||||
<div style="display: flex; gap: 8px; align-items: center;">
|
||||
<input type="file" class="form-control" id="activityImageInput" accept="image/*" style="font-size: 0.85rem; max-width: 300px;">
|
||||
<button type="button" class="btn btn-primary btn-sm" onclick="uploadActivityImage()"><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>
|
||||
<div class="modal-footer-custom">
|
||||
<button type="button" class="btn btn-secondary btn-sm" onclick="closeActivityModal()">Отмена</button>
|
||||
<button type="button" class="btn-gold btn-sm" onclick="saveActivity()"><i class="fas fa-save"></i> Сохранить</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -229,38 +229,7 @@
|
||||
<div class="abkhazian-pattern"></div>
|
||||
<p class="section-subtitle" data-i18n="activities.subtitle">SUP-борды, пляжное кафе и незабываемые виды — всё это ждёт вас</p>
|
||||
</div>
|
||||
<div class="row g-4">
|
||||
<div class="col-lg-4 animate-on-scroll">
|
||||
<div class="activity-card">
|
||||
<img src="img/105d059e5-a204-499f-a01c-c540f6ae1e5d.webp" alt="SUP-борд">
|
||||
<div class="activity-overlay">
|
||||
<div class="activity-icon"><i class="fas fa-water"></i></div>
|
||||
<h3 class="activity-title" data-i18n="activities.sup_title">SUP-борды</h3>
|
||||
<p class="activity-desc" data-i18n="activities.sup_desc">Аренда SUP-бордов прямо на пляже. Исследуйте побережье с воды — идеальный способ увидеть Абхазию с нового ракурса!</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 animate-on-scroll">
|
||||
<div class="activity-card">
|
||||
<img src="img/18abbb136-0e24-4c92-a568-fec8011e029b.webp" alt="Кафе у пляжа">
|
||||
<div class="activity-overlay">
|
||||
<div class="activity-icon"><i class="fas fa-coffee"></i></div>
|
||||
<h3 class="activity-title" data-i18n="activities.cafe_title">Кафе у пляжа</h3>
|
||||
<p class="activity-desc" data-i18n="activities.cafe_desc">Всего 2 минуты пешком — уютное кафе с видом на море. Абхазская и русская кухня, свежие морепродукты и ароматный кофе.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4 animate-on-scroll">
|
||||
<div class="activity-card">
|
||||
<img src="img/1f00325ba-df2c-4bb9-9ec6-28958e11f843.webp" alt="Столовая">
|
||||
<div class="activity-overlay">
|
||||
<div class="activity-icon"><i class="fas fa-utensils"></i></div>
|
||||
<h3 class="activity-title" data-i18n="activities.canteen_title">Столовая Hotel 777</h3>
|
||||
<p class="activity-desc" data-i18n="activities.canteen_desc">Домашняя кухня каждый день! Завтраки, обеды и ужины из свежих местных продуктов. Аджика, хачапури, азу и многое другое.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-4" id="activitiesContainer"></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ const I18n = {
|
||||
localStorage.setItem('lang', lang);
|
||||
this.updateUI();
|
||||
this.updateNavLabels();
|
||||
if (typeof loadActivities === 'function') loadActivities();
|
||||
} catch (err) {
|
||||
console.error('I18n error:', err);
|
||||
if (lang !== 'ru') {
|
||||
|
||||
@@ -379,3 +379,49 @@ document.addEventListener('keydown', function(e) {
|
||||
closeLightbox();
|
||||
}
|
||||
});
|
||||
|
||||
async function loadActivities() {
|
||||
const container = document.getElementById('activitiesContainer');
|
||||
if (!container) return;
|
||||
|
||||
const lang = (window.I18n && I18n.currentLang) ? I18n.currentLang : 'ru';
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/activities?lang=' + lang);
|
||||
if (!res.ok) throw new Error('Failed to load activities');
|
||||
const activities = await res.json();
|
||||
|
||||
if (!activities.length) {
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = activities.map(a => {
|
||||
const imgSrc = a.image_path || '';
|
||||
const iconHtml = a.icon ? `<div class="activity-icon"><i class="${a.icon}"></i></div>` : '';
|
||||
const onclickAttr = imgSrc ? `onclick="openLightbox('${imgSrc}', '${(a.title || '').replace(/'/g, "\\'")}')"` : '';
|
||||
return `<div class="col-lg-4 animate-on-scroll">
|
||||
<div class="activity-card" ${onclickAttr}>
|
||||
${imgSrc ? `<img src="${imgSrc}" alt="${a.title || ''}">` : ''}
|
||||
<div class="activity-overlay">
|
||||
${iconHtml}
|
||||
<h3 class="activity-title">${a.title || ''}</h3>
|
||||
<p class="activity-desc">${a.desc || ''}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.animate-on-scroll').forEach(el => {
|
||||
observer.observe(el);
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Activities load error:', err);
|
||||
}
|
||||
}
|
||||
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', loadActivities);
|
||||
} else {
|
||||
loadActivities();
|
||||
}
|
||||
|
||||
@@ -62,13 +62,15 @@ function renderRoomsPublic(rooms) {
|
||||
}).join('');
|
||||
imagesHtml = `<div class="room-img-carousel">
|
||||
<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img" id="roomCarouselImg${index}"
|
||||
onclick="roomImageClick(${index})"
|
||||
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EФото%3C/text%3E%3C/svg%3E'">
|
||||
<button class="room-carousel-prev" onclick="roomCarouselPrev(${index})">‹</button>
|
||||
<button class="room-carousel-next" onclick="roomCarouselNext(${index})">›</button>
|
||||
<div class="room-img-dots">${dotsHtml}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
imagesHtml = `<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img"
|
||||
imagesHtml = `<img src="${fullImageSrc}" alt="${room.name}" class="room-carousel-img" id="roomCarouselImg${index}"
|
||||
onclick="roomImageClick(${index})"
|
||||
onerror="this.src='data:image/svg+xml,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 400 250%22%3E%3Crect fill=%22%23274151%22 width=%22400%22 height=%22250%22/%3E%3Ctext fill=%22%2364748b%22 font-family=%22sans-serif%22 font-size=%2216%22 x=%2250%25%22 y=%2250%25%22 text-anchor=%22middle%22 dy=%22.3em%22%3EФото%3C/text%3E%3C/svg%3E'">`;
|
||||
}
|
||||
|
||||
@@ -120,11 +122,28 @@ function renderRoomsPublic(rooms) {
|
||||
}).join('');
|
||||
|
||||
initRoomBookingHandlers();
|
||||
|
||||
Object.values(roomCarouselTimers).forEach(t => clearInterval(t));
|
||||
roomCarouselTimers = {};
|
||||
rooms.forEach((room, i) => {
|
||||
const images = Array.isArray(room.images) ? room.images : [];
|
||||
if (images.length > 1) {
|
||||
roomCarouselTimers[i] = setInterval(() => roomCarouselNext(i), 10000);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
let roomCarouselState = {};
|
||||
let roomCarouselTimers = {};
|
||||
|
||||
function roomImageClick(index) {
|
||||
const img = document.getElementById('roomCarouselImg' + index);
|
||||
const room = cachedRooms[index];
|
||||
if (img && room) {
|
||||
openLightbox(img.src, room.name);
|
||||
}
|
||||
}
|
||||
|
||||
function roomCarouselPrev(index) {
|
||||
if (!roomCarouselState[index]) roomCarouselState[index] = 0;
|
||||
const imgs = getRoomImagesForIndex(index);
|
||||
|
||||
35
server.js
35
server.js
@@ -26,6 +26,8 @@ const MONITORING_PASSWORD = process.env.MONITORING_PASSWORD || 'monitoring123';
|
||||
|
||||
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 storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, roomsUploadsDir),
|
||||
@@ -47,6 +49,26 @@ const upload = multer({
|
||||
}
|
||||
});
|
||||
|
||||
const activityStorage = multer.diskStorage({
|
||||
destination: (req, file, cb) => cb(null, activityUploadsDir),
|
||||
filename: (req, file, cb) => {
|
||||
const ext = path.extname(file.originalname).toLowerCase();
|
||||
const timestamp = Date.now();
|
||||
cb(null, `${timestamp}${ext}`);
|
||||
}
|
||||
});
|
||||
|
||||
const uploadActivity = multer({
|
||||
storage: activityStorage,
|
||||
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'));
|
||||
}
|
||||
});
|
||||
|
||||
if (!JWT_SECRET) {
|
||||
console.error('FATAL: JWT_SECRET environment variable not set');
|
||||
process.exit(1);
|
||||
@@ -198,6 +220,7 @@ const backupModule = require('./modules/backup');
|
||||
const seasonalPricesModule = require('./modules/seasonalPrices');
|
||||
const emailModule = require('./modules/email');
|
||||
const reportsModule = require('./modules/reports');
|
||||
const activitiesModule = require('./modules/activities');
|
||||
const { runStartupTests } = require('./tests/runStartupTests');
|
||||
|
||||
modules.auth = authModule;
|
||||
@@ -213,6 +236,7 @@ modules.backup = backupModule;
|
||||
modules.seasonalPrices = seasonalPricesModule;
|
||||
modules.email = emailModule;
|
||||
modules.reports = reportsModule;
|
||||
modules.activities = activitiesModule;
|
||||
|
||||
authModule.init(db, JWT_SECRET);
|
||||
bookingsModule.init(db);
|
||||
@@ -226,6 +250,7 @@ backupModule.init(db, dbPath);
|
||||
seasonalPricesModule.init(db);
|
||||
emailModule.init(db, settingsModule);
|
||||
reportsModule.init(db);
|
||||
activitiesModule.init(db);
|
||||
|
||||
function initDefaultRooms() {
|
||||
db.get("SELECT COUNT(*) as count FROM rooms", (err, row) => {
|
||||
@@ -297,6 +322,7 @@ backupModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAd
|
||||
seasonalPricesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||
emailModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||
reportsModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin);
|
||||
activitiesModule.setupRoutes(app, authModule.authenticateToken, authModule.requireAdmin, uploadActivity);
|
||||
|
||||
app.get('/api/translations/:lang', (req, res) => {
|
||||
const lang = req.params.lang;
|
||||
@@ -379,6 +405,15 @@ app.get('/data/room_images/:filename', (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data/activity_images/:filename', (req, res) => {
|
||||
const filePath = path.join(activityUploadsDir, 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