Compare commits
3 Commits
a461fea9d9
...
01c2244168
| Author | SHA1 | Date | |
|---|---|---|---|
| 01c2244168 | |||
| b4e513e996 | |||
| ed871fc4d1 |
11
.dockerignore
Normal file
11
.dockerignore
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
node_modules
|
||||||
|
npm-debug.log
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
README.md
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
coverage/
|
||||||
|
.nyc_output
|
||||||
|
.vscode/
|
||||||
13
.gitignore
vendored
13
.gitignore
vendored
@@ -15,6 +15,7 @@ yarn-error.log*
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
*.sqlite3
|
*.sqlite3
|
||||||
*.db
|
*.db
|
||||||
|
database/backups/
|
||||||
|
|
||||||
# Node modules
|
# Node modules
|
||||||
node_modules/
|
node_modules/
|
||||||
@@ -41,13 +42,21 @@ build/
|
|||||||
# History
|
# History
|
||||||
.history/
|
.history/
|
||||||
|
|
||||||
# Docker
|
# Docker volumes
|
||||||
.dockerignore
|
docker-data/
|
||||||
|
postgres-data/
|
||||||
|
|
||||||
# Temporary files
|
# Temporary files
|
||||||
tmp/
|
tmp/
|
||||||
temp/
|
temp/
|
||||||
|
|
||||||
|
# Uploaded files (keep structure but ignore content)
|
||||||
|
public/uploads/*
|
||||||
|
!public/uploads/.gitkeep
|
||||||
|
|
||||||
|
# Runtime files
|
||||||
|
*.pid
|
||||||
|
|
||||||
# Uploaded files
|
# Uploaded files
|
||||||
public/uploads/*
|
public/uploads/*
|
||||||
!public/uploads/.gitkeep
|
!public/uploads/.gitkeep
|
||||||
|
|||||||
21
README.md
21
README.md
@@ -1,12 +1,29 @@
|
|||||||
# Korea Tourism Agency 🇰🇷
|
# 🇰🇷 Korea Tourism Agency
|
||||||
|
|
||||||
Современный сайт туристического агентства для внутренних поездок по Корее с профессиональной админ-панелью.
|
Modern tourism website for domestic travel in Korea with comprehensive admin panel.
|
||||||
|
|
||||||

|

|
||||||

|

|
||||||

|

|
||||||

|

|
||||||
|
|
||||||
|
## 🌟 Features
|
||||||
|
|
||||||
|
### 🏛️ Website Features
|
||||||
|
- **Tour Packages**: City tours, mountain hiking, fishing expeditions
|
||||||
|
- **Guide Profiles**: Experienced local guides with specializations
|
||||||
|
- **Travel Blog**: Articles about Korean culture, food, and destinations
|
||||||
|
- **Responsive Design**: Mobile-first approach with modern UI
|
||||||
|
- **SEO Optimized**: Clean URLs and meta tags
|
||||||
|
|
||||||
|
### 🛠️ Admin Panel Features
|
||||||
|
- **Route Management**: Create and manage tour packages
|
||||||
|
- **Guide Management**: Profile management with photos and schedules
|
||||||
|
- **Article System**: Blog management with categories
|
||||||
|
- **Image Editor**: Integrated upload, gallery, and URL options
|
||||||
|
- **User Management**: Admin authentication and roles
|
||||||
|
- **Dashboard**: Analytics and quick access to all features
|
||||||
|
|
||||||
## 🚀 Быстрый старт
|
## 🚀 Быстрый старт
|
||||||
|
|
||||||
### Автоматический деплой
|
### Автоматический деплой
|
||||||
|
|||||||
27
database/apply-image-fix.js
Normal file
27
database/apply-image-fix.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import db from '../src/config/database.js';
|
||||||
|
import fs from 'fs';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
async function applyImageTriggersFix() {
|
||||||
|
try {
|
||||||
|
console.log('🖼️ Applying image triggers fix migration...');
|
||||||
|
|
||||||
|
const migrationPath = path.join(__dirname, 'image-triggers-fix.sql');
|
||||||
|
const sql = fs.readFileSync(migrationPath, 'utf8');
|
||||||
|
|
||||||
|
await db.query(sql);
|
||||||
|
console.log('✅ Image triggers fix applied successfully');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log('ℹ️ Some changes may already be applied:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
applyImageTriggersFix();
|
||||||
58
database/check-admins.cjs
Normal file
58
database/check-admins.cjs
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
async function checkAdminsTable() {
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔍 Проверяем таблицу admins...');
|
||||||
|
|
||||||
|
// Проверяем, существует ли таблица
|
||||||
|
const tableExists = await pool.query(`
|
||||||
|
SELECT EXISTS (
|
||||||
|
SELECT FROM information_schema.tables
|
||||||
|
WHERE table_schema = 'public'
|
||||||
|
AND table_name = 'admins'
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('📋 Таблица admins существует:', tableExists.rows[0].exists);
|
||||||
|
|
||||||
|
if (tableExists.rows[0].exists) {
|
||||||
|
// Получаем структуру таблицы
|
||||||
|
const structure = await pool.query(`
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'admins'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📋 Структура таблицы admins:');
|
||||||
|
structure.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.column_name}: ${row.data_type} ${row.is_nullable === 'NO' ? '(NOT NULL)' : '(nullable)'}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем количество записей
|
||||||
|
const count = await pool.query('SELECT COUNT(*) FROM admins');
|
||||||
|
console.log(`\n📊 Записей в таблице: ${count.rows[0].count}`);
|
||||||
|
|
||||||
|
// Получаем несколько записей для примера
|
||||||
|
const sample = await pool.query('SELECT id, username, name, role, is_active FROM admins LIMIT 3');
|
||||||
|
console.log('\n👥 Примеры записей:');
|
||||||
|
sample.rows.forEach(admin => {
|
||||||
|
console.log(` - ID: ${admin.id}, Username: ${admin.username}, Name: ${admin.name}, Role: ${admin.role}, Active: ${admin.is_active}`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
console.log('❌ Таблица admins не существует! Нужно создать её.');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка:', error.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkAdminsTable();
|
||||||
40
database/check-article-categories.js
Normal file
40
database/check-article-categories.js
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import db from '../src/config/database.js';
|
||||||
|
|
||||||
|
async function checkArticleCategories() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Проверяем ограничения на категории articles...');
|
||||||
|
|
||||||
|
// Проверяем constraint (для новых версий PostgreSQL)
|
||||||
|
const constraints = await db.query(`
|
||||||
|
SELECT conname, pg_get_constraintdef(oid) as definition
|
||||||
|
FROM pg_constraint
|
||||||
|
WHERE conrelid = 'articles'::regclass
|
||||||
|
AND contype = 'c'
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📋 Ограничения articles:');
|
||||||
|
constraints.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.conname}: ${row.definition}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем существующие категории
|
||||||
|
const existingCategories = await db.query(`
|
||||||
|
SELECT DISTINCT category
|
||||||
|
FROM articles
|
||||||
|
WHERE category IS NOT NULL
|
||||||
|
ORDER BY category
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📂 Существующие категории:');
|
||||||
|
existingCategories.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.category}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkArticleCategories();
|
||||||
31
database/check-articles-schema.cjs
Normal file
31
database/check-articles-schema.cjs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
require('dotenv').config();
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
async function checkArticlesSchema() {
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
console.log('🔍 Проверяем структуру таблицы articles...');
|
||||||
|
|
||||||
|
const result = await pool.query(`
|
||||||
|
SELECT column_name, data_type, is_nullable, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'articles'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📋 Структура таблицы articles:');
|
||||||
|
result.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.column_name}: ${row.data_type} ${row.is_nullable === 'NO' ? '(NOT NULL)' : '(nullable)'} ${row.column_default ? `default: ${row.column_default}` : ''}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка:', error.message);
|
||||||
|
} finally {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkArticlesSchema();
|
||||||
51
database/check-tables.js
Normal file
51
database/check-tables.js
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
import db from '../src/config/database.js';
|
||||||
|
|
||||||
|
async function checkTables() {
|
||||||
|
try {
|
||||||
|
console.log('🔍 Проверяем структуру таблиц...');
|
||||||
|
|
||||||
|
// Проверяем guides
|
||||||
|
const guidesDesc = await db.query(`
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'guides'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📋 Таблица guides:');
|
||||||
|
guidesDesc.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.column_name}: ${row.data_type}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем articles
|
||||||
|
const articlesDesc = await db.query(`
|
||||||
|
SELECT column_name, data_type
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'articles'
|
||||||
|
ORDER BY ordinal_position
|
||||||
|
`);
|
||||||
|
|
||||||
|
console.log('\n📰 Таблица articles:');
|
||||||
|
articlesDesc.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.column_name}: ${row.data_type}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Проверяем тип поля languages в guides
|
||||||
|
const languagesType = await db.query(`
|
||||||
|
SELECT data_type, column_default
|
||||||
|
FROM information_schema.columns
|
||||||
|
WHERE table_name = 'guides' AND column_name = 'languages'
|
||||||
|
`);
|
||||||
|
|
||||||
|
if (languagesType.rows.length > 0) {
|
||||||
|
console.log(`\n🔤 Поле languages: ${languagesType.rows[0].data_type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkTables();
|
||||||
7
database/create-admin.sql
Normal file
7
database/create-admin.sql
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
-- Создание тестового администратора для проверки
|
||||||
|
INSERT INTO admins (username, password, name, email, role) VALUES
|
||||||
|
('admin', '$2a$10$rOjLbFbCqbCQPZdJQWb1gO6WvhzJP1O5VuItXwDJV4tTJYg4oEGoC', 'Главный администратор', 'admin@koreatour.ru', 'admin')
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
name = EXCLUDED.name,
|
||||||
|
email = EXCLUDED.email,
|
||||||
|
role = EXCLUDED.role;
|
||||||
25
database/create-test-admin.cjs
Normal file
25
database/create-test-admin.cjs
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
// Создание тестового администратора для AdminJS
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
|
function generateBcryptHash(password) {
|
||||||
|
// Упрощенная версия - используем готовый хеш
|
||||||
|
// Пароль: admin123
|
||||||
|
return '$2a$10$rOjLbFbCqbCQPZdJQWb1gO6WvhzJP1O5VuItXwDJV4tTJYg4oEGoC';
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTestAdmin() {
|
||||||
|
const hashedPassword = generateBcryptHash('admin123');
|
||||||
|
|
||||||
|
console.log('🔐 Создаем тестового администратора...');
|
||||||
|
console.log('📧 Email: admin@koreatour.ru');
|
||||||
|
console.log('👤 Username: admin');
|
||||||
|
console.log('🔑 Password: admin123');
|
||||||
|
console.log('🔒 Hashed password:', hashedPassword);
|
||||||
|
|
||||||
|
const sql = `INSERT INTO admins (username, password, name, email, role, is_active) VALUES ('admin', '${hashedPassword}', 'Главный администратор', 'admin@koreatour.ru', 'admin', true) ON CONFLICT (username) DO UPDATE SET password = EXCLUDED.password, name = EXCLUDED.name, email = EXCLUDED.email, role = EXCLUDED.role, is_active = EXCLUDED.is_active;`;
|
||||||
|
|
||||||
|
console.log('\n▶️ Запустите эту команду:');
|
||||||
|
console.log(`docker-compose exec db psql -U tourism_user -d korea_tourism -c "${sql}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
createTestAdmin();
|
||||||
57
database/image-triggers-fix.sql
Normal file
57
database/image-triggers-fix.sql
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
-- Исправление триггеров для таблиц с изображениями
|
||||||
|
-- Запускается автоматически при инициализации базы данных
|
||||||
|
|
||||||
|
-- Сначала удаляем существующие проблемные триггеры если они есть
|
||||||
|
DROP TRIGGER IF EXISTS routes_updated_at_trigger ON routes;
|
||||||
|
DROP TRIGGER IF EXISTS guides_updated_at_trigger ON guides;
|
||||||
|
|
||||||
|
-- Создаем функцию для обновления updated_at (если её нет)
|
||||||
|
CREATE OR REPLACE FUNCTION update_updated_at_column()
|
||||||
|
RETURNS TRIGGER AS $$
|
||||||
|
BEGIN
|
||||||
|
NEW.updated_at = CURRENT_TIMESTAMP;
|
||||||
|
RETURN NEW;
|
||||||
|
END;
|
||||||
|
$$ language 'plpgsql';
|
||||||
|
|
||||||
|
-- Создаем триггеры для обновления updated_at при изменении записей
|
||||||
|
CREATE TRIGGER routes_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON routes
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
CREATE TRIGGER guides_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON guides
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
-- Добавляем индексы для оптимизации запросов к изображениям
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_routes_image_url ON routes(image_url) WHERE image_url IS NOT NULL;
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_guides_image_url ON guides(image_url) WHERE image_url IS NOT NULL;
|
||||||
|
|
||||||
|
-- Создаем таблицу для метаданных загруженных изображений (опционально)
|
||||||
|
CREATE TABLE IF NOT EXISTS image_metadata (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
url VARCHAR(500) NOT NULL UNIQUE,
|
||||||
|
filename VARCHAR(255) NOT NULL,
|
||||||
|
original_name VARCHAR(255),
|
||||||
|
size_bytes INTEGER,
|
||||||
|
width INTEGER,
|
||||||
|
height INTEGER,
|
||||||
|
mime_type VARCHAR(100),
|
||||||
|
entity_type VARCHAR(50), -- 'routes', 'guides', 'articles', etc.
|
||||||
|
entity_id INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Индекс для поиска изображений по типу сущности
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_metadata_entity ON image_metadata(entity_type, entity_id);
|
||||||
|
|
||||||
|
-- Триггер для image_metadata
|
||||||
|
CREATE TRIGGER image_metadata_updated_at_trigger
|
||||||
|
BEFORE UPDATE ON image_metadata
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_updated_at_column();
|
||||||
|
|
||||||
|
COMMIT;
|
||||||
@@ -55,6 +55,43 @@ export async function initDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if style editor migration is applied
|
||||||
|
try {
|
||||||
|
const result = await db.query("SELECT 1 FROM site_settings WHERE setting_key = 'primary_color' LIMIT 1");
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
console.log('🎨 Installing style editor features...');
|
||||||
|
const styleMigrationPath = path.join(__dirname, 'style-editor-migration.sql');
|
||||||
|
if (fs.existsSync(styleMigrationPath)) {
|
||||||
|
const styleMigration = fs.readFileSync(styleMigrationPath, 'utf8');
|
||||||
|
await db.query(styleMigration);
|
||||||
|
console.log('✅ Style editor installed successfully');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('ℹ️ Style editor already installed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('🎨 Installing style editor features...');
|
||||||
|
const styleMigrationPath = path.join(__dirname, 'style-editor-migration.sql');
|
||||||
|
if (fs.existsSync(styleMigrationPath)) {
|
||||||
|
const styleMigration = fs.readFileSync(styleMigrationPath, 'utf8');
|
||||||
|
await db.query(styleMigration);
|
||||||
|
console.log('✅ Style editor installed successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Apply image triggers fix migration
|
||||||
|
try {
|
||||||
|
console.log('🖼️ Installing image triggers fix...');
|
||||||
|
const imageTriggersMigrationPath = path.join(__dirname, 'image-triggers-fix.sql');
|
||||||
|
if (fs.existsSync(imageTriggersMigrationPath)) {
|
||||||
|
const imageTriggersMigration = fs.readFileSync(imageTriggersMigrationPath, 'utf8');
|
||||||
|
await db.query(imageTriggersMigration);
|
||||||
|
console.log('✅ Image triggers fix applied successfully');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log('ℹ️ Image triggers fix - some changes may already be applied:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
console.log('✨ Database initialization completed successfully!');
|
console.log('✨ Database initialization completed successfully!');
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
78
database/style-editor-migration.sql
Normal file
78
database/style-editor-migration.sql
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
-- Миграция для добавления полей image_url и расширения настроек сайта
|
||||||
|
|
||||||
|
-- Добавление поля image_url в таблицу routes (если его нет)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'routes' AND column_name = 'image_url') THEN
|
||||||
|
ALTER TABLE routes ADD COLUMN image_url VARCHAR(255);
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Добавление поля category в таблицу site_settings (если его нет)
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
||||||
|
WHERE table_name = 'site_settings' AND column_name = 'category') THEN
|
||||||
|
ALTER TABLE site_settings ADD COLUMN category VARCHAR(50) DEFAULT 'general';
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Обновление типов в таблице site_settings
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
-- Удаляем старое ограничение типов если оно есть
|
||||||
|
ALTER TABLE site_settings DROP CONSTRAINT IF EXISTS site_settings_setting_type_check;
|
||||||
|
|
||||||
|
-- Добавляем новое ограничение с расширенными типами
|
||||||
|
ALTER TABLE site_settings ADD CONSTRAINT site_settings_setting_type_check
|
||||||
|
CHECK (setting_type IN ('text', 'number', 'boolean', 'json', 'color', 'file'));
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
-- Добавление расширенных настроек сайта для редактора стилей
|
||||||
|
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, category, updated_at) VALUES
|
||||||
|
-- Основные цвета темы
|
||||||
|
('primary_color', '#2563eb', 'color', 'Основной цвет сайта', 'colors', NOW()),
|
||||||
|
('secondary_color', '#dc2626', 'color', 'Вторичный цвет сайта', 'colors', NOW()),
|
||||||
|
('accent_color', '#059669', 'color', 'Акцентный цвет', 'colors', NOW()),
|
||||||
|
('text_color', '#334155', 'color', 'Основной цвет текста', 'colors', NOW()),
|
||||||
|
('background_color', '#ffffff', 'color', 'Цвет фона', 'colors', NOW()),
|
||||||
|
('card_background', '#f8fafc', 'color', 'Цвет фона карточек', 'colors', NOW()),
|
||||||
|
|
||||||
|
-- Фоновые изображения
|
||||||
|
('hero_background_url', '/images/korea-hero.jpg', 'file', 'Фоновое изображение главной страницы', 'images', NOW()),
|
||||||
|
('default_tour_image', '/images/placeholder.jpg', 'file', 'Изображение тура по умолчанию', 'images', NOW()),
|
||||||
|
('site_logo_url', '/images/korea-logo.png', 'file', 'Логотип сайта', 'images', NOW()),
|
||||||
|
('favicon_url', '/images/favicon.ico', 'file', 'Иконка сайта', 'images', NOW()),
|
||||||
|
|
||||||
|
-- Типографика
|
||||||
|
('font_family_primary', 'Noto Sans KR, Malgun Gothic, 맑은 고딕, sans-serif', 'text', 'Основной шрифт', 'typography', NOW()),
|
||||||
|
('font_family_display', 'Playfair Display, serif', 'text', 'Декоративный шрифт', 'typography', NOW()),
|
||||||
|
('font_size_base', '16', 'number', 'Базовый размер шрифта (px)', 'typography', NOW()),
|
||||||
|
('line_height_base', '1.7', 'number', 'Базовая высота строки', 'typography', NOW()),
|
||||||
|
|
||||||
|
-- Эффекты и наложения
|
||||||
|
('hero_overlay_opacity', '0.8', 'number', 'Прозрачность наложения на hero фоне (0-1)', 'effects', NOW()),
|
||||||
|
('hero_overlay_color', '#2563eb', 'color', 'Цвет наложения на hero фоне', 'effects', NOW()),
|
||||||
|
('card_shadow', '0 4px 6px -1px rgba(0, 0, 0, 0.1)', 'text', 'Тень карточек (CSS shadow)', 'effects', NOW()),
|
||||||
|
('border_radius', '8', 'number', 'Радиус скругления углов (px)', 'effects', NOW()),
|
||||||
|
('blur_effect', '10', 'number', 'Сила размытия эффектов (px)', 'effects', NOW()),
|
||||||
|
|
||||||
|
-- Макет и размеры
|
||||||
|
('hero_height_desktop', '70', 'number', 'Высота hero секции на десктопе (vh)', 'layout', NOW()),
|
||||||
|
('hero_height_mobile', '50', 'number', 'Высота hero секции на мобильных (vh)', 'layout', NOW()),
|
||||||
|
('compact_hero_height', '25', 'number', 'Высота компактных hero секций (vh)', 'layout', NOW()),
|
||||||
|
('container_max_width', '1200', 'number', 'Максимальная ширина контейнера (px)', 'layout', NOW()),
|
||||||
|
('navbar_height', '76', 'number', 'Высота навигационной панели (px)', 'layout', NOW()),
|
||||||
|
|
||||||
|
-- Дополнительные стили
|
||||||
|
('custom_css', '', 'text', 'Дополнительный CSS код', 'theme', NOW()),
|
||||||
|
('google_fonts_url', '', 'text', 'URL для подключения Google Fonts', 'typography', NOW()),
|
||||||
|
('animation_duration', '0.3', 'number', 'Длительность анимаций (секунды)', 'effects', NOW())
|
||||||
|
|
||||||
|
ON CONFLICT (setting_key) DO UPDATE SET
|
||||||
|
setting_value = EXCLUDED.setting_value,
|
||||||
|
setting_type = EXCLUDED.setting_type,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW();
|
||||||
36
database/update-test-images.js
Normal file
36
database/update-test-images.js
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import db from '../src/config/database.js';
|
||||||
|
|
||||||
|
async function updateTestImages() {
|
||||||
|
try {
|
||||||
|
console.log('🖼️ Обновляем тестовые данные с изображениями...');
|
||||||
|
|
||||||
|
// Обновляем первый тур с изображением
|
||||||
|
await db.query('UPDATE routes SET image_url = $1 WHERE id = 1', ['/uploads/routes/seoul-city-tour.jpg']);
|
||||||
|
|
||||||
|
// Обновляем первого гида с изображением
|
||||||
|
await db.query('UPDATE guides SET image_url = $1 WHERE id = 1', ['/uploads/guides/guide-profile.jpg']);
|
||||||
|
|
||||||
|
console.log('✅ Изображения добавлены в базу данных');
|
||||||
|
|
||||||
|
// Проверяем результат
|
||||||
|
const routes = await db.query('SELECT id, title, image_url FROM routes WHERE image_url IS NOT NULL LIMIT 3');
|
||||||
|
const guides = await db.query('SELECT id, name, image_url FROM guides WHERE image_url IS NOT NULL LIMIT 3');
|
||||||
|
|
||||||
|
console.log('📋 Туры с изображениями:');
|
||||||
|
routes.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.title}: ${row.image_url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('👨🏫 Гиды с изображениями:');
|
||||||
|
guides.rows.forEach(row => {
|
||||||
|
console.log(` - ${row.name}: ${row.image_url}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Ошибка:', error);
|
||||||
|
}
|
||||||
|
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateTestImages();
|
||||||
1497
package-lock.json
generated
1497
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,8 @@
|
|||||||
"@adminjs/express": "^6.1.0",
|
"@adminjs/express": "^6.1.0",
|
||||||
"@adminjs/sequelize": "^4.1.1",
|
"@adminjs/sequelize": "^4.1.1",
|
||||||
"@adminjs/upload": "^4.0.2",
|
"@adminjs/upload": "^4.0.2",
|
||||||
|
"@babel/core": "^7.28.5",
|
||||||
|
"@babel/preset-react": "^7.28.5",
|
||||||
"adminjs": "^7.5.0",
|
"adminjs": "^7.5.0",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.2",
|
||||||
@@ -39,11 +41,14 @@
|
|||||||
"method-override": "^3.0.0",
|
"method-override": "^3.0.0",
|
||||||
"moment": "^2.29.4",
|
"moment": "^2.29.4",
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^2.0.0",
|
"multer": "^1.4.5-lts.1",
|
||||||
"pg": "^8.11.3",
|
"pg": "^8.11.3",
|
||||||
"pg-hstore": "^2.3.4",
|
"pg-hstore": "^2.3.4",
|
||||||
|
"react": "^19.2.0",
|
||||||
|
"react-dom": "^19.2.0",
|
||||||
"sequelize": "^6.37.7",
|
"sequelize": "^6.37.7",
|
||||||
"sequelize-cli": "^6.6.3"
|
"sequelize-cli": "^6.6.3",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"nodemon": "^3.0.2"
|
"nodemon": "^3.0.2"
|
||||||
|
|||||||
187
public/admin-image-editor-demo.html
Normal file
187
public/admin-image-editor-demo.html
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Демонстрация редактора изображений в AdminJS</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header bg-primary text-white">
|
||||||
|
<h4 class="mb-0">
|
||||||
|
<i class="fas fa-images me-2"></i>
|
||||||
|
Редактор изображений для AdminJS - Готов к использованию!
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="alert alert-success">
|
||||||
|
<i class="fas fa-check-circle me-2"></i>
|
||||||
|
<strong>Редактор изображений успешно интегрирован в AdminJS!</strong>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5>🎉 Что было реализовано:</h5>
|
||||||
|
<ul class="list-group list-group-flush mb-4">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
Полнофункциональный редактор изображений с галереей
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
API для загрузки и управления изображениями
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
Автоматическая интеграция с полями изображений в AdminJS
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
Превью изображений и организация по папкам
|
||||||
|
</li>
|
||||||
|
<li class="list-group-item">
|
||||||
|
<i class="fas fa-check text-success me-2"></i>
|
||||||
|
Оптимизация изображений через Sharp
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<h5>📝 Как использовать в AdminJS:</h5>
|
||||||
|
<ol class="mb-4">
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Войдите в админ-панель:</strong>
|
||||||
|
<a href="/admin" class="btn btn-primary btn-sm ms-2" target="_blank">
|
||||||
|
<i class="fas fa-external-link-alt me-1"></i>
|
||||||
|
Открыть AdminJS
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Данные для входа:</strong>
|
||||||
|
<ul class="mt-1">
|
||||||
|
<li>Username: <code>admin</code></li>
|
||||||
|
<li>Password: Используйте существующий пароль администратора</li>
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Редактируйте маршруты, гидов или статьи</strong>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Для полей изображений появится кнопка "📷 Выбрать"</strong>
|
||||||
|
</li>
|
||||||
|
<li class="mb-2">
|
||||||
|
<strong>Нажмите кнопку для открытия редактора в модальном окне</strong>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
|
||||||
|
<h5>🔧 Функции редактора:</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-info text-white">
|
||||||
|
<i class="fas fa-upload me-2"></i>Загрузка
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Drag & Drop</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Формат: JPG, PNG, GIF</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Макс. размер: 5МБ</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Автооптимизация</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-warning text-dark">
|
||||||
|
<i class="fas fa-images me-2"></i>Галерея
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Все загруженные изображения</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Фильтр по папкам</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Превью с именами</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Поиск по категориям</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<div class="card-header bg-success text-white">
|
||||||
|
<i class="fas fa-link me-2"></i>По URL
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Внешние изображения</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Прямые ссылки</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Предпросмотр</li>
|
||||||
|
<li><i class="fas fa-check-circle text-success me-2"></i>Быстрая вставка</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h5>📂 Организация файлов:</h5>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 border rounded">
|
||||||
|
<i class="fas fa-route fa-2x text-primary mb-2"></i>
|
||||||
|
<h6>/uploads/routes/</h6>
|
||||||
|
<small class="text-muted">Изображения маршрутов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 border rounded">
|
||||||
|
<i class="fas fa-user-tie fa-2x text-info mb-2"></i>
|
||||||
|
<h6>/uploads/guides/</h6>
|
||||||
|
<small class="text-muted">Фотографии гидов</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 border rounded">
|
||||||
|
<i class="fas fa-newspaper fa-2x text-warning mb-2"></i>
|
||||||
|
<h6>/uploads/articles/</h6>
|
||||||
|
<small class="text-muted">Изображения статей</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="text-center p-3 border rounded">
|
||||||
|
<i class="fas fa-folder fa-2x text-secondary mb-2"></i>
|
||||||
|
<h6>/uploads/general/</h6>
|
||||||
|
<small class="text-muted">Общие изображения</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<i class="fas fa-info-circle me-2"></i>
|
||||||
|
<strong>Техническая информация:</strong>
|
||||||
|
<br>
|
||||||
|
Редактор автоматически определяет поля изображений по именам (image_url, photo, avatar) и добавляет к ним кнопку выбора.
|
||||||
|
Изображения оптимизируются до разрешения 1200x800 с качеством 85% для оптимальной производительности.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="/admin" class="btn btn-primary btn-lg me-2" target="_blank">
|
||||||
|
<i class="fas fa-cogs me-2"></i>
|
||||||
|
Открыть админ-панель
|
||||||
|
</a>
|
||||||
|
<a href="/test-image-editor.html" class="btn btn-outline-secondary btn-lg" target="_blank">
|
||||||
|
<i class="fas fa-test-tube me-2"></i>
|
||||||
|
Тестовая страница
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -32,6 +32,35 @@
|
|||||||
margin: 0.125rem;
|
margin: 0.125rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Image Editor Button Styles */
|
||||||
|
.image-editor-btn {
|
||||||
|
background: #007bff !important;
|
||||||
|
color: white !important;
|
||||||
|
border: none !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
font-size: 13px !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
white-space: nowrap !important;
|
||||||
|
transition: background 0.2s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-btn:hover {
|
||||||
|
background: #0056b3 !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Image Preview Styles */
|
||||||
|
.image-preview {
|
||||||
|
max-width: 180px !important;
|
||||||
|
max-height: 120px !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
border: 1px solid #ddd !important;
|
||||||
|
border-radius: 4px !important;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
margin-bottom: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* Cards Enhancement */
|
/* Cards Enhancement */
|
||||||
.card {
|
.card {
|
||||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
@@ -216,3 +245,60 @@
|
|||||||
50% { transform: scale(1.05); }
|
50% { transform: scale(1.05); }
|
||||||
100% { transform: scale(1); }
|
100% { transform: scale(1); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Стили для редактора изображений в AdminJS */
|
||||||
|
.image-editor-btn {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
font-size: 13px !important;
|
||||||
|
padding: 6px 12px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-btn:hover {
|
||||||
|
background: #0056b3 !important;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Стили для превью изображений */
|
||||||
|
.image-preview {
|
||||||
|
border: 2px solid #e9ecef !important;
|
||||||
|
transition: border-color 0.2s ease;
|
||||||
|
margin-top: 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview:hover {
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Улучшенные стили для полей изображений */
|
||||||
|
input[name*="image_url"]:focus,
|
||||||
|
input[name*="photo"]:focus,
|
||||||
|
input[name*="avatar"]:focus {
|
||||||
|
border-left: 3px solid #007bff;
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(0, 123, 255, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Анимация для успешного выбора изображения */
|
||||||
|
@keyframes imageSelected {
|
||||||
|
0% { transform: scale(1); }
|
||||||
|
50% { transform: scale(1.05); }
|
||||||
|
100% { transform: scale(1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-selected {
|
||||||
|
animation: imageSelected 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive стили для редактора */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.image-editor-btn {
|
||||||
|
margin-left: 0 !important;
|
||||||
|
margin-top: 10px !important;
|
||||||
|
display: block !important;
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-preview {
|
||||||
|
max-width: 100% !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,23 @@
|
|||||||
/* Korea Tourism Agency - Main Styles */
|
/* Korea Tourism Agency - Main Styles */
|
||||||
|
|
||||||
/* CSS Variables */
|
/* CSS Variables - могут быть переопределены через настройки сайта */
|
||||||
:root {
|
:root {
|
||||||
|
/* Основные цвета */
|
||||||
--primary-color: #2563eb;
|
--primary-color: #2563eb;
|
||||||
--primary-light: #3b82f6;
|
--primary-light: #3b82f6;
|
||||||
--primary-dark: #1d4ed8;
|
--primary-dark: #1d4ed8;
|
||||||
--secondary-color: #dc2626;
|
--secondary-color: #dc2626;
|
||||||
|
--accent-color: #059669;
|
||||||
--success-color: #059669;
|
--success-color: #059669;
|
||||||
--warning-color: #d97706;
|
--warning-color: #d97706;
|
||||||
--info-color: #0891b2;
|
--info-color: #0891b2;
|
||||||
--light-color: #f8fafc;
|
--light-color: #f8fafc;
|
||||||
--dark-color: #0f172a;
|
--dark-color: #0f172a;
|
||||||
|
--text-color: #334155;
|
||||||
|
--background-color: #ffffff;
|
||||||
|
--card-background: #f8fafc;
|
||||||
|
|
||||||
|
/* Серые тона */
|
||||||
--gray-100: #f1f5f9;
|
--gray-100: #f1f5f9;
|
||||||
--gray-200: #e2e8f0;
|
--gray-200: #e2e8f0;
|
||||||
--gray-300: #cbd5e1;
|
--gray-300: #cbd5e1;
|
||||||
@@ -20,31 +27,56 @@
|
|||||||
--gray-700: #334155;
|
--gray-700: #334155;
|
||||||
--gray-800: #1e293b;
|
--gray-800: #1e293b;
|
||||||
--gray-900: #0f172a;
|
--gray-900: #0f172a;
|
||||||
|
|
||||||
|
/* Корейские цвета */
|
||||||
--korean-red: #c41e3a;
|
--korean-red: #c41e3a;
|
||||||
--korean-blue: #003478;
|
--korean-blue: #003478;
|
||||||
--font-korean: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
|
|
||||||
--font-display: 'Playfair Display', serif;
|
/* Типографика */
|
||||||
|
--font-family-primary: 'Noto Sans KR', 'Malgun Gothic', '맑은 고딕', sans-serif;
|
||||||
|
--font-family-display: 'Playfair Display', serif;
|
||||||
|
--font-size-base: 16px;
|
||||||
|
--line-height-base: 1.7;
|
||||||
|
|
||||||
|
/* Эффекты */
|
||||||
|
--hero-overlay-opacity: 0.8;
|
||||||
|
--hero-overlay-color: #2563eb;
|
||||||
|
--card-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
|
--border-radius: 8px;
|
||||||
|
--blur-effect: 10px;
|
||||||
|
--animation-duration: 0.3s;
|
||||||
|
|
||||||
|
/* Тени */
|
||||||
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05);
|
||||||
--shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
|
--shadow: var(--card-shadow);
|
||||||
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06);
|
||||||
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
|
/* Макет */
|
||||||
|
--hero-height-desktop: 70vh;
|
||||||
|
--hero-height-mobile: 50vh;
|
||||||
|
--compact-hero-height: 25vh;
|
||||||
|
--container-max-width: 1200px;
|
||||||
|
--navbar-height: 76px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Base Styles */
|
/* Base Styles */
|
||||||
body {
|
body {
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-family-primary);
|
||||||
line-height: 1.7;
|
font-size: var(--font-size-base);
|
||||||
color: var(--gray-700);
|
line-height: var(--line-height-base);
|
||||||
padding-top: 76px; /* Account for fixed navbar */
|
color: var(--text-color);
|
||||||
|
background-color: var(--background-color);
|
||||||
|
padding-top: var(--navbar-height);
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-korean {
|
.font-korean {
|
||||||
font-family: var(--font-korean);
|
font-family: var(--font-family-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.font-display {
|
.font-display {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-family-display);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Custom Bootstrap Overrides */
|
/* Custom Bootstrap Overrides */
|
||||||
@@ -52,7 +84,7 @@ body {
|
|||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--primary-light) 100%);
|
||||||
border: none;
|
border: none;
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transition: all 0.3s ease;
|
transition: all var(--animation-duration) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary:hover {
|
.btn-primary:hover {
|
||||||
@@ -63,8 +95,8 @@ body {
|
|||||||
|
|
||||||
.text-gradient {
|
.text-gradient {
|
||||||
background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%);
|
background: linear-gradient(135deg, var(--korean-red) 0%, var(--korean-blue) 100%);
|
||||||
background-clip: text;
|
|
||||||
-webkit-background-clip: text;
|
-webkit-background-clip: text;
|
||||||
|
background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -74,14 +106,15 @@ body {
|
|||||||
|
|
||||||
/* Navigation Styles */
|
/* Navigation Styles */
|
||||||
.navbar {
|
.navbar {
|
||||||
background: rgba(37, 99, 235, 0.95) !important;
|
background: rgba(37, 99, 235, var(--hero-overlay-opacity)) !important;
|
||||||
backdrop-filter: blur(10px);
|
-webkit-backdrop-filter: blur(var(--blur-effect));
|
||||||
|
backdrop-filter: blur(var(--blur-effect));
|
||||||
box-shadow: var(--shadow-md);
|
box-shadow: var(--shadow-md);
|
||||||
transition: all 0.3s ease;
|
transition: all var(--animation-duration) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-family: var(--font-display);
|
font-family: var(--font-family-display);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
}
|
}
|
||||||
@@ -89,7 +122,7 @@ body {
|
|||||||
.nav-link {
|
.nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
position: relative;
|
position: relative;
|
||||||
transition: all 0.3s ease;
|
transition: all var(--animation-duration) ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-link:hover {
|
.nav-link:hover {
|
||||||
@@ -111,21 +144,21 @@ body {
|
|||||||
/* Hero Section */
|
/* Hero Section */
|
||||||
.hero-section {
|
.hero-section {
|
||||||
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
|
background: linear-gradient(135deg, var(--primary-color) 0%, var(--korean-blue) 100%);
|
||||||
min-height: 70vh;
|
min-height: var(--hero-height-desktop);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Compact Hero Section for other pages */
|
/* Compact Hero Section for other pages */
|
||||||
.hero-section.compact {
|
.hero-section.compact {
|
||||||
min-height: 25vh;
|
min-height: var(--compact-hero-height);
|
||||||
padding: 3rem 0;
|
padding: 3rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Mobile optimization for hero sections */
|
/* Mobile optimization for hero sections */
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.hero-section {
|
.hero-section {
|
||||||
min-height: 50vh;
|
min-height: var(--hero-height-mobile);
|
||||||
padding: 2rem 0;
|
padding: 2rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,9 +187,9 @@ body {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg, rgba(37, 99, 235, 0.8) 0%, rgba(0, 52, 120, 0.9) 100%);
|
background: linear-gradient(135deg, rgba(37, 99, 235, var(--hero-overlay-opacity)) 0%, rgba(0, 52, 120, 0.9) 100%);
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
pointer-events: none; /* Позволяет кликам проходить через overlay */
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-section .container {
|
.hero-section .container {
|
||||||
|
|||||||
543
public/image-editor-compact.html
Normal file
543
public/image-editor-compact.html
Normal file
@@ -0,0 +1,543 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Выбор изображения</title>
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||||
|
font-size: 14px;
|
||||||
|
background: #fff;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
.container {
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 20px auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
border-bottom: 2px solid #eee;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
.tab {
|
||||||
|
padding: 10px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 14px;
|
||||||
|
color: #666;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.tab.active {
|
||||||
|
color: #007bff;
|
||||||
|
border-bottom-color: #007bff;
|
||||||
|
}
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Загрузка */
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
background: #fafafa;
|
||||||
|
transition: all 0.2s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.upload-zone:hover, .upload-zone.dragover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #f0f8ff;
|
||||||
|
}
|
||||||
|
.upload-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
color: #ccc;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
.file-input {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Галерея */
|
||||||
|
.gallery {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(120px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.gallery-item {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.gallery-item:hover {
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
.gallery-item.selected {
|
||||||
|
border-color: #007bff;
|
||||||
|
box-shadow: 0 0 10px rgba(0,123,255,0.3);
|
||||||
|
}
|
||||||
|
.gallery-item img {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* URL ввод */
|
||||||
|
.url-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Превью */
|
||||||
|
.preview {
|
||||||
|
text-align: center;
|
||||||
|
margin: 20px 0;
|
||||||
|
}
|
||||||
|
.preview img {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 150px;
|
||||||
|
object-fit: cover;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки */
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
padding-top: 20px;
|
||||||
|
border-top: 1px solid #eee;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: #f0f0f0;
|
||||||
|
}
|
||||||
|
.btn-primary:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
.error {
|
||||||
|
color: #dc3545;
|
||||||
|
background: #f8d7da;
|
||||||
|
border: 1px solid #f5c6cb;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container">
|
||||||
|
<div class="tabs">
|
||||||
|
<button class="tab active" onclick="switchTab('upload')">📤 Загрузить</button>
|
||||||
|
<button class="tab" onclick="switchTab('gallery')">🖼️ Галерея</button>
|
||||||
|
<button class="tab" onclick="switchTab('url')">🔗 По ссылке</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Загрузка -->
|
||||||
|
<div id="upload-tab" class="tab-content active">
|
||||||
|
<div class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<p><strong>Выберите файл</strong> или перетащите сюда</p>
|
||||||
|
<p style="color: #666; font-size: 12px;">JPG, PNG, GIF (макс. 5МБ)</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="fileInput" class="file-input" accept="image/*">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Галерея -->
|
||||||
|
<div id="gallery-tab" class="tab-content">
|
||||||
|
<div id="galleryContent" class="loading">Загрузка галереи...</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- URL -->
|
||||||
|
<div id="url-tab" class="tab-content">
|
||||||
|
<input type="url" id="urlInput" class="url-input" placeholder="Вставьте ссылку на изображение">
|
||||||
|
<button class="btn" onclick="previewFromUrl()">Предварительный просмотр</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Превью -->
|
||||||
|
<div id="preview" class="preview" style="display: none;">
|
||||||
|
<img id="previewImage" src="" alt="Превью">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Действия -->
|
||||||
|
<div class="actions">
|
||||||
|
<button class="btn" onclick="closeEditor()">Отмена</button>
|
||||||
|
<button class="btn btn-primary" onclick="selectImage()">Выбрать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedImageUrl = '';
|
||||||
|
let currentField = null;
|
||||||
|
|
||||||
|
// Получаем параметры из URL
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const fieldName = urlParams.get('field') || 'image';
|
||||||
|
const currentValue = urlParams.get('current') || '';
|
||||||
|
|
||||||
|
// Настройка обработчиков загрузки
|
||||||
|
function setupUploadHandlers() {
|
||||||
|
// Обработка загрузки файла
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
if (fileInput) {
|
||||||
|
fileInput.removeEventListener('change', handleFileSelect); // Убираем предыдущий
|
||||||
|
fileInput.addEventListener('change', handleFileSelect);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag & Drop
|
||||||
|
const uploadZone = document.querySelector('.upload-zone');
|
||||||
|
if (uploadZone) {
|
||||||
|
// Убираем предыдущие обработчики
|
||||||
|
uploadZone.removeEventListener('dragover', handleDragOver);
|
||||||
|
uploadZone.removeEventListener('dragleave', handleDragLeave);
|
||||||
|
uploadZone.removeEventListener('drop', handleDrop);
|
||||||
|
|
||||||
|
// Добавляем новые
|
||||||
|
uploadZone.addEventListener('dragover', handleDragOver);
|
||||||
|
uploadZone.addEventListener('dragleave', handleDragLeave);
|
||||||
|
uploadZone.addEventListener('drop', handleDrop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчики событий
|
||||||
|
function handleFileSelect(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showError('Файл слишком большой. Максимум 5МБ.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragOver(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.classList.add('dragover');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDragLeave(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.classList.remove('dragover');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
e.currentTarget.classList.remove('dragover');
|
||||||
|
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
if (file.size > 5 * 1024 * 1024) {
|
||||||
|
showError('Файл слишком большой. Максимум 5МБ.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
uploadFile(file);
|
||||||
|
} else {
|
||||||
|
showError('Пожалуйста, выберите изображение (JPG, PNG, GIF)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показываем текущее изображение если есть
|
||||||
|
if (currentValue) {
|
||||||
|
showPreview(currentValue);
|
||||||
|
selectedImageUrl = currentValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализируем обработчики загрузки
|
||||||
|
setupUploadHandlers();
|
||||||
|
|
||||||
|
// Переключение табов
|
||||||
|
function switchTab(tabName) {
|
||||||
|
// Убираем активные классы
|
||||||
|
document.querySelectorAll('.tab').forEach(tab => tab.classList.remove('active'));
|
||||||
|
document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
|
||||||
|
|
||||||
|
// Добавляем активные классы
|
||||||
|
event.target.classList.add('active');
|
||||||
|
document.getElementById(tabName + '-tab').classList.add('active');
|
||||||
|
|
||||||
|
// Загружаем галерею при первом открытии
|
||||||
|
if (tabName === 'gallery') {
|
||||||
|
loadGallery();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка файла на сервер
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
formData.append('folder', getFolderName());
|
||||||
|
|
||||||
|
try {
|
||||||
|
showLoading('Загрузка изображения...');
|
||||||
|
|
||||||
|
const response = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Upload result:', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
selectedImageUrl = result.url;
|
||||||
|
showPreview(result.url);
|
||||||
|
hideLoading();
|
||||||
|
showSuccess('Изображение успешно загружено!');
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Ошибка загрузки');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
hideLoading();
|
||||||
|
showError('Ошибка загрузки: ' + error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка галереи
|
||||||
|
async function loadGallery() {
|
||||||
|
const galleryContent = document.getElementById('galleryContent');
|
||||||
|
galleryContent.innerHTML = '<div class="loading">Загрузка галереи...</div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/gallery');
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP Error: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('Gallery result:', result);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
const gallery = document.createElement('div');
|
||||||
|
gallery.className = 'gallery';
|
||||||
|
|
||||||
|
const images = result.data || result.images || [];
|
||||||
|
if (images.length === 0) {
|
||||||
|
gallery.innerHTML = '<p style="grid-column: 1/-1; text-align: center; color: #666;">Нет изображений в галерее</p>';
|
||||||
|
} else {
|
||||||
|
images.forEach(image => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'gallery-item';
|
||||||
|
item.innerHTML = `<img src="${image.path}" alt="${image.name}" onerror="this.parentNode.style.display='none'">`;
|
||||||
|
item.onclick = () => selectFromGallery(image.path, item);
|
||||||
|
gallery.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryContent.innerHTML = '';
|
||||||
|
galleryContent.appendChild(gallery);
|
||||||
|
} else {
|
||||||
|
throw new Error(result.error || 'Ошибка получения галереи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gallery error:', error);
|
||||||
|
galleryContent.innerHTML = `<div class="error">Ошибка загрузки галереи: ${error.message}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор из галереи
|
||||||
|
function selectFromGallery(url, element) {
|
||||||
|
// Убираем выделение с других элементов
|
||||||
|
document.querySelectorAll('.gallery-item').forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Выделяем выбранный элемент
|
||||||
|
element.classList.add('selected');
|
||||||
|
|
||||||
|
selectedImageUrl = url;
|
||||||
|
showPreview(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Предпросмотр по URL
|
||||||
|
function previewFromUrl() {
|
||||||
|
const url = document.getElementById('urlInput').value.trim();
|
||||||
|
if (url) {
|
||||||
|
// Проверяем, что это валидный URL
|
||||||
|
try {
|
||||||
|
new URL(url);
|
||||||
|
selectedImageUrl = url;
|
||||||
|
showPreview(url);
|
||||||
|
showSuccess('URL изображения установлен!');
|
||||||
|
} catch (e) {
|
||||||
|
showError('Пожалуйста, введите корректный URL');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showError('Пожалуйста, введите URL изображения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать превью
|
||||||
|
function showPreview(url) {
|
||||||
|
const preview = document.getElementById('preview');
|
||||||
|
const previewImage = document.getElementById('previewImage');
|
||||||
|
|
||||||
|
previewImage.src = url;
|
||||||
|
preview.style.display = 'block';
|
||||||
|
|
||||||
|
previewImage.onload = function() {
|
||||||
|
console.log('Image loaded successfully:', url);
|
||||||
|
};
|
||||||
|
|
||||||
|
previewImage.onerror = function() {
|
||||||
|
showError('Не удалось загрузить изображение по указанному URL');
|
||||||
|
preview.style.display = 'none';
|
||||||
|
selectedImageUrl = '';
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбрать изображение
|
||||||
|
function selectImage() {
|
||||||
|
if (selectedImageUrl) {
|
||||||
|
// Отправляем выбранный URL родительскому окну
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'imageSelected',
|
||||||
|
url: selectedImageUrl,
|
||||||
|
field: fieldName
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
closeEditor();
|
||||||
|
} else {
|
||||||
|
showError('Выберите изображение');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Закрыть редактор
|
||||||
|
function closeEditor() {
|
||||||
|
if (window.parent && window.parent !== window) {
|
||||||
|
window.parent.postMessage({
|
||||||
|
type: 'editorClosed'
|
||||||
|
}, '*');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получить имя папки из поля
|
||||||
|
function getFolderName() {
|
||||||
|
if (fieldName.includes('route') || fieldName.includes('tour')) return 'routes';
|
||||||
|
if (fieldName.includes('guide')) return 'guides';
|
||||||
|
if (fieldName.includes('article')) return 'articles';
|
||||||
|
return 'general';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать ошибку
|
||||||
|
function showError(message) {
|
||||||
|
hideLoading();
|
||||||
|
removeMessages();
|
||||||
|
const errorDiv = document.createElement('div');
|
||||||
|
errorDiv.className = 'error';
|
||||||
|
errorDiv.textContent = message;
|
||||||
|
|
||||||
|
document.querySelector('.container').insertBefore(errorDiv, document.querySelector('.actions'));
|
||||||
|
setTimeout(() => errorDiv.remove(), 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать успех
|
||||||
|
function showSuccess(message) {
|
||||||
|
removeMessages();
|
||||||
|
const successDiv = document.createElement('div');
|
||||||
|
successDiv.className = 'success';
|
||||||
|
successDiv.style.cssText = `
|
||||||
|
color: #155724;
|
||||||
|
background: #d4edda;
|
||||||
|
border: 1px solid #c3e6cb;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
`;
|
||||||
|
successDiv.textContent = message;
|
||||||
|
|
||||||
|
document.querySelector('.container').insertBefore(successDiv, document.querySelector('.actions'));
|
||||||
|
setTimeout(() => successDiv.remove(), 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удалить все сообщения
|
||||||
|
function removeMessages() {
|
||||||
|
document.querySelectorAll('.error, .success').forEach(el => el.remove());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показать загрузку
|
||||||
|
function showLoading(message) {
|
||||||
|
const uploadTab = document.getElementById('upload-tab');
|
||||||
|
uploadTab.innerHTML = `<div class="loading">${message}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Скрыть загрузку
|
||||||
|
function hideLoading() {
|
||||||
|
const uploadTab = document.getElementById('upload-tab');
|
||||||
|
uploadTab.innerHTML = `
|
||||||
|
<div class="upload-zone" onclick="document.getElementById('fileInput').click()">
|
||||||
|
<div class="upload-icon">📁</div>
|
||||||
|
<p><strong>Выберите файл</strong> или перетащите сюда</p>
|
||||||
|
<p style="color: #666; font-size: 12px;">JPG, PNG, GIF (макс. 5МБ)</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="fileInput" class="file-input" accept="image/*">
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Переподключаем обработчики
|
||||||
|
setupUploadHandlers();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enter в поле URL
|
||||||
|
document.getElementById('urlInput').addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
previewFromUrl();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
448
public/image-editor.html
Normal file
448
public/image-editor.html
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Редактор изображений - Korea Tourism</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.image-preview {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 2px dashed #ddd;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.image-gallery {
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.gallery-item {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: transform 0.2s;
|
||||||
|
}
|
||||||
|
.gallery-item:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
.gallery-item.selected {
|
||||||
|
border: 3px solid #007bff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
background: #f8f9fa;
|
||||||
|
transition: border-color 0.3s;
|
||||||
|
}
|
||||||
|
.upload-area:hover, .upload-area.drag-over {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container-fluid py-4">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
|
<h5 class="mb-0">
|
||||||
|
<i class="fas fa-images me-2"></i>
|
||||||
|
Редактор изображений
|
||||||
|
</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<!-- Вкладки -->
|
||||||
|
<ul class="nav nav-tabs" id="imageEditorTabs" role="tablist">
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link active" id="upload-tab" data-bs-toggle="tab" data-bs-target="#upload" type="button">
|
||||||
|
<i class="fas fa-upload me-2"></i>Загрузить
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="gallery-tab" data-bs-toggle="tab" data-bs-target="#gallery" type="button">
|
||||||
|
<i class="fas fa-images me-2"></i>Галерея
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item" role="presentation">
|
||||||
|
<button class="nav-link" id="url-tab" data-bs-toggle="tab" data-bs-target="#url" type="button">
|
||||||
|
<i class="fas fa-link me-2"></i>По ссылке
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="tab-content" id="imageEditorTabsContent">
|
||||||
|
<!-- Вкладка загрузки -->
|
||||||
|
<div class="tab-pane fade show active" id="upload" role="tabpanel">
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="upload-area" id="uploadArea">
|
||||||
|
<i class="fas fa-cloud-upload-alt fa-3x text-muted mb-3"></i>
|
||||||
|
<p class="mb-2">Перетащите файлы сюда или нажмите для выбора</p>
|
||||||
|
<small class="text-muted">Поддерживаются: JPG, PNG, GIF (макс. 5МБ)</small>
|
||||||
|
<input type="file" id="fileInput" class="d-none" multiple accept="image/*">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Прогресс загрузки -->
|
||||||
|
<div id="uploadProgress" class="mt-3" style="display: none;">
|
||||||
|
<div class="progress">
|
||||||
|
<div class="progress-bar" role="progressbar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="text-center">
|
||||||
|
<h6>Предварительный просмотр</h6>
|
||||||
|
<img id="uploadPreview" class="image-preview" src="/images/placeholders/no-image.png" alt="Предварительный просмотр">
|
||||||
|
<div class="mt-2">
|
||||||
|
<small id="imageInfo" class="text-muted"></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка галереи -->
|
||||||
|
<div class="tab-pane fade" id="gallery" role="tabpanel">
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
|
<h6>Загруженные изображения</h6>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="routes">
|
||||||
|
<i class="fas fa-route me-1"></i>Маршруты
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="guides">
|
||||||
|
<i class="fas fa-user-tie me-1"></i>Гиды
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm" data-folder="articles">
|
||||||
|
<i class="fas fa-newspaper me-1"></i>Статьи
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary btn-sm active" data-folder="all">
|
||||||
|
<i class="fas fa-images me-1"></i>Все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-gallery">
|
||||||
|
<div class="row" id="galleryImages">
|
||||||
|
<!-- Изображения будут загружены динамически -->
|
||||||
|
<div class="col-12 text-center py-4">
|
||||||
|
<i class="fas fa-spinner fa-spin fa-2x text-muted"></i>
|
||||||
|
<p class="mt-2 text-muted">Загрузка галереи...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Вкладка URL -->
|
||||||
|
<div class="tab-pane fade" id="url" role="tabpanel">
|
||||||
|
<div class="row mt-3">
|
||||||
|
<div class="col-md-8">
|
||||||
|
<label for="imageUrl" class="form-label">URL изображения</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="url" class="form-control" id="imageUrl" placeholder="https://example.com/image.jpg">
|
||||||
|
<button class="btn btn-primary" type="button" id="loadUrlImage">
|
||||||
|
<i class="fas fa-download me-1"></i>Загрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">Введите прямую ссылку на изображение</small>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="text-center">
|
||||||
|
<h6>Предварительный просмотр</h6>
|
||||||
|
<img id="urlPreview" class="image-preview" src="/images/placeholders/no-image.png" alt="Предварительный просмотр">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer">
|
||||||
|
<div class="d-flex justify-content-between">
|
||||||
|
<div>
|
||||||
|
<strong>Выбранное изображение:</strong>
|
||||||
|
<span id="selectedImagePath" class="text-muted ms-2">Не выбрано</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick="closeImageEditor()">
|
||||||
|
<i class="fas fa-times me-1"></i>Отмена
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="confirmSelection" disabled>
|
||||||
|
<i class="fas fa-check me-1"></i>Выбрать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let selectedImagePath = null;
|
||||||
|
let targetField = null;
|
||||||
|
|
||||||
|
// Инициализация редактора
|
||||||
|
function initImageEditor() {
|
||||||
|
setupUploadEvents();
|
||||||
|
setupGalleryEvents();
|
||||||
|
setupUrlEvents();
|
||||||
|
loadGallery('all');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройка событий загрузки
|
||||||
|
function setupUploadEvents() {
|
||||||
|
const uploadArea = document.getElementById('uploadArea');
|
||||||
|
const fileInput = document.getElementById('fileInput');
|
||||||
|
|
||||||
|
uploadArea.addEventListener('click', () => fileInput.click());
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('drag-over');
|
||||||
|
handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
handleFiles(e.target.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработка файлов
|
||||||
|
function handleFiles(files) {
|
||||||
|
if (files.length > 0) {
|
||||||
|
const file = files[0];
|
||||||
|
if (validateFile(file)) {
|
||||||
|
showPreview(file);
|
||||||
|
uploadFile(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Валидация файла
|
||||||
|
function validateFile(file) {
|
||||||
|
const maxSize = 5 * 1024 * 1024; // 5MB
|
||||||
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif'];
|
||||||
|
|
||||||
|
if (!allowedTypes.includes(file.type)) {
|
||||||
|
alert('Неподдерживаемый тип файла. Используйте JPG, PNG или GIF.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file.size > maxSize) {
|
||||||
|
alert('Файл слишком большой. Максимальный размер: 5МБ.');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показ предварительного просмотра
|
||||||
|
function showPreview(file) {
|
||||||
|
const reader = new FileReader();
|
||||||
|
const preview = document.getElementById('uploadPreview');
|
||||||
|
const info = document.getElementById('imageInfo');
|
||||||
|
|
||||||
|
reader.onload = (e) => {
|
||||||
|
preview.src = e.target.result;
|
||||||
|
info.textContent = `${file.name} (${(file.size / 1024).toFixed(1)} KB)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка файла на сервер
|
||||||
|
async function uploadFile(file) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
const progress = document.getElementById('uploadProgress');
|
||||||
|
const progressBar = progress.querySelector('.progress-bar');
|
||||||
|
|
||||||
|
progress.style.display = 'block';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
selectedImagePath = result.data.path;
|
||||||
|
updateSelectedImage(selectedImagePath);
|
||||||
|
showSuccessMessage('Изображение успешно загружено!');
|
||||||
|
// Обновляем галерею
|
||||||
|
setTimeout(() => loadGallery('all'), 500);
|
||||||
|
} else {
|
||||||
|
alert('Ошибка загрузки: ' + result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка загрузки: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
progress.style.display = 'none';
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройка событий галереи
|
||||||
|
function setupGalleryEvents() {
|
||||||
|
document.querySelectorAll('[data-folder]').forEach(btn => {
|
||||||
|
btn.addEventListener('click', (e) => {
|
||||||
|
document.querySelectorAll('[data-folder]').forEach(b => b.classList.remove('active'));
|
||||||
|
e.target.classList.add('active');
|
||||||
|
loadGallery(e.target.dataset.folder);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка галереи
|
||||||
|
async function loadGallery(folder) {
|
||||||
|
const galleryContainer = document.getElementById('galleryImages');
|
||||||
|
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><i class="fas fa-spinner fa-spin fa-2x text-muted"></i><p class="mt-2 text-muted">Загрузка галереи...</p></div>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/images/gallery?folder=${folder}`);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
renderGallery(result.data);
|
||||||
|
} else {
|
||||||
|
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Ошибка загрузки галереи</p></div>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Ошибка: ' + error.message + '</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение галереи
|
||||||
|
function renderGallery(images) {
|
||||||
|
const galleryContainer = document.getElementById('galleryImages');
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
galleryContainer.innerHTML = '<div class="col-12 text-center py-4"><p class="text-muted">Изображения не найдены</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
galleryContainer.innerHTML = images.map(img => `
|
||||||
|
<div class="col-md-2 col-sm-3 col-4 mb-3">
|
||||||
|
<div class="gallery-item" data-path="${img.path}" onclick="selectGalleryImage('${img.path}')">
|
||||||
|
<img src="${img.path}" class="img-fluid rounded" alt="${img.name}" title="${img.name}">
|
||||||
|
<small class="d-block text-center text-muted mt-1">${img.name}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Выбор изображения из галереи
|
||||||
|
function selectGalleryImage(path) {
|
||||||
|
document.querySelectorAll('.gallery-item').forEach(item => {
|
||||||
|
item.classList.remove('selected');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector(`[data-path="${path}"]`).classList.add('selected');
|
||||||
|
selectedImagePath = path;
|
||||||
|
updateSelectedImage(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Настройка событий URL
|
||||||
|
function setupUrlEvents() {
|
||||||
|
const urlInput = document.getElementById('imageUrl');
|
||||||
|
const loadBtn = document.getElementById('loadUrlImage');
|
||||||
|
const urlPreview = document.getElementById('urlPreview');
|
||||||
|
|
||||||
|
loadBtn.addEventListener('click', () => {
|
||||||
|
const url = urlInput.value.trim();
|
||||||
|
if (url) {
|
||||||
|
urlPreview.src = url;
|
||||||
|
selectedImagePath = url;
|
||||||
|
updateSelectedImage(url);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
urlInput.addEventListener('input', (e) => {
|
||||||
|
const url = e.target.value.trim();
|
||||||
|
if (url && isValidUrl(url)) {
|
||||||
|
urlPreview.src = url;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка валидности URL
|
||||||
|
function isValidUrl(string) {
|
||||||
|
try {
|
||||||
|
new URL(string);
|
||||||
|
return true;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обновление выбранного изображения
|
||||||
|
function updateSelectedImage(path) {
|
||||||
|
document.getElementById('selectedImagePath').textContent = path;
|
||||||
|
document.getElementById('confirmSelection').disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Подтверждение выбора
|
||||||
|
document.getElementById('confirmSelection').addEventListener('click', () => {
|
||||||
|
if (selectedImagePath && window.opener) {
|
||||||
|
// Передаем путь в родительское окно
|
||||||
|
window.opener.postMessage({
|
||||||
|
type: 'imageSelected',
|
||||||
|
path: selectedImagePath,
|
||||||
|
targetField: new URLSearchParams(window.location.search).get('field')
|
||||||
|
}, '*');
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Закрытие редактора
|
||||||
|
function closeImageEditor() {
|
||||||
|
window.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Показ сообщения об успехе
|
||||||
|
function showSuccessMessage(message) {
|
||||||
|
// Простое уведомление
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
|
||||||
|
alert.style.zIndex = '9999';
|
||||||
|
alert.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(alert);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.parentNode.removeChild(alert);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', initImageEditor);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
275
public/image-system-docs.html
Normal file
275
public/image-system-docs.html
Normal file
@@ -0,0 +1,275 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Система управления изображениями - Korea Tourism</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.hero-compact {
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
color: white;
|
||||||
|
padding: 40px 0;
|
||||||
|
}
|
||||||
|
.feature-icon {
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin: 0 auto 20px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
.feature-card {
|
||||||
|
border: none;
|
||||||
|
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
.feature-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
}
|
||||||
|
.code-block {
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e9ecef;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 15px;
|
||||||
|
font-family: monospace;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
.api-endpoint {
|
||||||
|
background: #e3f2fd;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.step-number {
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
margin-right: 15px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Навигация -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary">
|
||||||
|
<div class="container">
|
||||||
|
<a class="navbar-brand" href="/">Korea Tourism</a>
|
||||||
|
<div class="navbar-nav ms-auto">
|
||||||
|
<a class="nav-link" href="/">Главная</a>
|
||||||
|
<a class="nav-link" href="/admin">Админ-панель</a>
|
||||||
|
<a class="nav-link" href="/test-editor">Тест редактора</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="hero-compact">
|
||||||
|
<div class="container text-center">
|
||||||
|
<h1 class="display-5 fw-bold mb-3">🖼️ Система управления изображениями</h1>
|
||||||
|
<p class="lead">Полнофункциональный редактор с возможностями обрезки, поворота и оптимизации</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Функции -->
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card feature-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="feature-icon bg-primary text-white">
|
||||||
|
<i class="fas fa-upload"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">Загрузка и обработка</h5>
|
||||||
|
<p class="card-text">Drag & Drop загрузка с автоматической оптимизацией и конвертацией в JPEG</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card feature-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="feature-icon bg-success text-white">
|
||||||
|
<i class="fas fa-crop"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">Редактирование</h5>
|
||||||
|
<p class="card-text">Обрезка, поворот на 90°, отражение горизонтально и вертикально</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card feature-card h-100">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="feature-icon bg-info text-white">
|
||||||
|
<i class="fas fa-cogs"></i>
|
||||||
|
</div>
|
||||||
|
<h5 class="card-title">API интеграция</h5>
|
||||||
|
<p class="card-text">REST API для интеграции с любыми формами и компонентами</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Инструкции по использованию -->
|
||||||
|
<section class="py-5 bg-light">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="text-center mb-5">📋 Инструкции по использованию</h2>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h4><i class="fas fa-user-cog me-2"></i>Через AdminJS</h4>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
Зайдите в <a href="/admin" target="_blank">админ-панель</a>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
Выберите "Туры" или "Гиды"
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="step-number">3</span>
|
||||||
|
В поле "Image URL" укажите путь к изображению
|
||||||
|
</div>
|
||||||
|
<div class="code-block">
|
||||||
|
Например: /uploads/routes/my-image.jpg
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h4><i class="fas fa-code me-2"></i>Через JavaScript</h4>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="step-number">1</span>
|
||||||
|
Подключите скрипт редактора
|
||||||
|
</div>
|
||||||
|
<div class="code-block">
|
||||||
|
<script src="/js/image-editor.js"></script>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<span class="step-number">2</span>
|
||||||
|
Откройте редактор
|
||||||
|
</div>
|
||||||
|
<div class="code-block">
|
||||||
|
window.openImageEditor({
|
||||||
|
targetFolder: 'routes', // routes, guides, articles
|
||||||
|
}).then(url => {
|
||||||
|
console.log('Сохранено:', url);
|
||||||
|
});
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- API Документация -->
|
||||||
|
<section class="py-5">
|
||||||
|
<div class="container">
|
||||||
|
<h2 class="text-center mb-5">🔌 API Эндпоинты</h2>
|
||||||
|
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5><i class="fas fa-upload me-2 text-primary"></i>Загрузка изображения</h5>
|
||||||
|
<div class="api-endpoint">
|
||||||
|
<strong>POST</strong> /api/images/upload-image
|
||||||
|
</div>
|
||||||
|
<p>Загружает изображение во временную папку</p>
|
||||||
|
<div class="code-block">
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
fetch('/api/images/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
}).then(r => r.json());
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5><i class="fas fa-magic me-2 text-success"></i>Обработка изображения</h5>
|
||||||
|
<div class="api-endpoint">
|
||||||
|
<strong>POST</strong> /api/images/process-image
|
||||||
|
</div>
|
||||||
|
<p>Применяет трансформации и сохраняет финальный файл</p>
|
||||||
|
<div class="code-block">
|
||||||
|
fetch('/api/images/process-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
tempId: 'temp-123',
|
||||||
|
rotation: 90,
|
||||||
|
flipHorizontal: false,
|
||||||
|
cropData: { x: 0, y: 0, width: 300, height: 200 },
|
||||||
|
targetFolder: 'routes'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5><i class="fas fa-list me-2 text-info"></i>Список изображений</h5>
|
||||||
|
<div class="api-endpoint">
|
||||||
|
<strong>GET</strong> /api/images/images/{folder}
|
||||||
|
</div>
|
||||||
|
<p>Возвращает список всех изображений в папке</p>
|
||||||
|
<div class="code-block">
|
||||||
|
// Получить изображения туров
|
||||||
|
fetch('/api/images/images/routes')
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => console.log(data.images));
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<h5><i class="fas fa-trash me-2 text-danger"></i>Удаление изображения</h5>
|
||||||
|
<div class="api-endpoint">
|
||||||
|
<strong>DELETE</strong> /api/images/image
|
||||||
|
</div>
|
||||||
|
<p>Удаляет изображение с сервера</p>
|
||||||
|
<div class="code-block">
|
||||||
|
fetch('/api/images/image', {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
url: '/uploads/routes/image.jpg'
|
||||||
|
})
|
||||||
|
});
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Тестирование -->
|
||||||
|
<section class="py-5 bg-primary text-white">
|
||||||
|
<div class="container text-center">
|
||||||
|
<h2 class="mb-4">🧪 Тестирование системы</h2>
|
||||||
|
<p class="lead mb-4">Попробуйте все возможности редактора изображений</p>
|
||||||
|
<div class="d-flex gap-3 justify-content-center">
|
||||||
|
<a href="/test-editor" class="btn btn-light btn-lg">
|
||||||
|
<i class="fas fa-play me-2"></i>Открыть тест-редактор
|
||||||
|
</a>
|
||||||
|
<a href="/admin" class="btn btn-outline-light btn-lg">
|
||||||
|
<i class="fas fa-cog me-2"></i>Админ-панель
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Footer -->
|
||||||
|
<footer class="bg-dark text-white py-3">
|
||||||
|
<div class="container text-center">
|
||||||
|
<p class="mb-0">© 2025 Korea Tourism Agency - Система управления изображениями</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
13
public/images/placeholders/no-image.svg
Normal file
13
public/images/placeholders/no-image.svg
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- Placeholder для отсутствующих изображений -->
|
||||||
|
<svg width="200" height="200" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="200" height="200" fill="#f8f9fa" stroke="#dee2e6" stroke-width="2"/>
|
||||||
|
<text x="100" y="90" text-anchor="middle" font-family="Arial" font-size="14" fill="#6c757d">
|
||||||
|
Нет изображения
|
||||||
|
</text>
|
||||||
|
<text x="100" y="110" text-anchor="middle" font-family="Arial" font-size="12" fill="#adb5bd">
|
||||||
|
No Image
|
||||||
|
</text>
|
||||||
|
<circle cx="100" cy="130" r="15" fill="#e9ecef"/>
|
||||||
|
<rect x="90" y="125" width="20" height="10" fill="#adb5bd"/>
|
||||||
|
<circle cx="95" cy="128" r="2" fill="#6c757d"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 657 B |
@@ -1,5 +1,96 @@
|
|||||||
/* Korea Tourism Agency Admin Panel Custom Scripts */
|
/* Korea Tourism Agency Admin Panel Custom Scripts */
|
||||||
|
|
||||||
|
// Функция для открытия редактора изображений
|
||||||
|
function openImageEditor(fieldName, currentValue) {
|
||||||
|
const editorUrl = `/image-editor.html?field=${fieldName}¤t=${encodeURIComponent(currentValue || '')}`;
|
||||||
|
const editorWindow = window.open(editorUrl, 'imageEditor', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||||
|
|
||||||
|
// Слушаем сообщения от редактора
|
||||||
|
const messageHandler = (event) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
|
||||||
|
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
|
||||||
|
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
|
||||||
|
if (field) {
|
||||||
|
field.value = event.data.path;
|
||||||
|
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
// Обновляем превью если есть
|
||||||
|
updateImagePreview(fieldName, event.data.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
editorWindow.close();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', messageHandler);
|
||||||
|
|
||||||
|
// Очистка обработчика при закрытии окна
|
||||||
|
const checkClosed = setInterval(() => {
|
||||||
|
if (editorWindow.closed) {
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
clearInterval(checkClosed);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция обновления превью изображения
|
||||||
|
function updateImagePreview(fieldName, imagePath) {
|
||||||
|
const previewId = `${fieldName}_preview`;
|
||||||
|
let preview = document.getElementById(previewId);
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
// Создаем превью если его нет
|
||||||
|
const field = document.querySelector(`input[name="${fieldName}"], input[id="${fieldName}"]`);
|
||||||
|
if (field) {
|
||||||
|
preview = document.createElement('img');
|
||||||
|
preview.id = previewId;
|
||||||
|
preview.className = 'img-thumbnail mt-2';
|
||||||
|
preview.style.maxWidth = '200px';
|
||||||
|
preview.style.maxHeight = '200px';
|
||||||
|
field.parentNode.appendChild(preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
preview.src = imagePath || '/images/placeholders/no-image.png';
|
||||||
|
preview.alt = 'Preview';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция добавления кнопки редактора к полю
|
||||||
|
function addImageEditorButton(field) {
|
||||||
|
const fieldName = field.name || field.id;
|
||||||
|
if (!fieldName) return;
|
||||||
|
|
||||||
|
// Проверяем, не добавлена ли уже кнопка
|
||||||
|
if (field.parentNode.querySelector('.image-editor-btn')) return;
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'input-group';
|
||||||
|
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'btn btn-outline-secondary image-editor-btn';
|
||||||
|
button.innerHTML = '<i class="fas fa-images"></i> Выбрать';
|
||||||
|
button.onclick = () => openImageEditor(fieldName, field.value);
|
||||||
|
|
||||||
|
const buttonWrapper = document.createElement('div');
|
||||||
|
buttonWrapper.className = 'input-group-append';
|
||||||
|
buttonWrapper.appendChild(button);
|
||||||
|
|
||||||
|
// Перестраиваем структуру
|
||||||
|
field.parentNode.insertBefore(wrapper, field);
|
||||||
|
wrapper.appendChild(field);
|
||||||
|
wrapper.appendChild(buttonWrapper);
|
||||||
|
|
||||||
|
// Добавляем превью если есть значение
|
||||||
|
if (field.value) {
|
||||||
|
updateImagePreview(fieldName, field.value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$(document).ready(function() {
|
$(document).ready(function() {
|
||||||
// Initialize tooltips
|
// Initialize tooltips
|
||||||
$('[data-toggle="tooltip"]').tooltip();
|
$('[data-toggle="tooltip"]').tooltip();
|
||||||
@@ -12,6 +103,33 @@ $(document).ready(function() {
|
|||||||
$('.alert').fadeOut('slow');
|
$('.alert').fadeOut('slow');
|
||||||
}, 5000);
|
}, 5000);
|
||||||
|
|
||||||
|
// Добавляем кнопки редактора к полям изображений
|
||||||
|
$('input[type="text"], input[type="url"]').each(function() {
|
||||||
|
const field = this;
|
||||||
|
const fieldName = field.name || field.id || '';
|
||||||
|
|
||||||
|
// Проверяем, относится ли поле к изображениям
|
||||||
|
if (fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
|
||||||
|
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner') ||
|
||||||
|
$(field).closest('label').text().toLowerCase().includes('изображение') ||
|
||||||
|
$(field).closest('label').text().toLowerCase().includes('картинка') ||
|
||||||
|
$(field).closest('label').text().toLowerCase().includes('фото')) {
|
||||||
|
addImageEditorButton(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик для динамически добавляемых полей
|
||||||
|
$(document).on('focus', 'input[type="text"], input[type="url"]', function() {
|
||||||
|
const field = this;
|
||||||
|
const fieldName = field.name || field.id || '';
|
||||||
|
|
||||||
|
if ((fieldName.includes('image') || fieldName.includes('photo') || fieldName.includes('avatar') ||
|
||||||
|
fieldName.includes('picture') || fieldName.includes('thumbnail') || fieldName.includes('banner')) &&
|
||||||
|
!field.parentNode.querySelector('.image-editor-btn')) {
|
||||||
|
addImageEditorButton(field);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Confirm delete actions
|
// Confirm delete actions
|
||||||
$('.btn-delete').on('click', function(e) {
|
$('.btn-delete').on('click', function(e) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
13
public/js/admin-image-loader.js
Normal file
13
public/js/admin-image-loader.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!-- Скрипт для AdminJS страниц -->
|
||||||
|
<script>
|
||||||
|
// Проверяем, находимся ли мы в админ-панели
|
||||||
|
if (window.location.pathname.startsWith('/admin')) {
|
||||||
|
// Загружаем редактор изображений для AdminJS
|
||||||
|
const script = document.createElement('script');
|
||||||
|
script.src = '/js/image-editor.js';
|
||||||
|
script.onload = () => {
|
||||||
|
console.log('Image Editor loaded for AdminJS');
|
||||||
|
};
|
||||||
|
document.head.appendChild(script);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
314
public/js/admin-image-selector-fixed.js
Normal file
314
public/js/admin-image-selector-fixed.js
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
// JavaScript для интеграции редактора изображений в AdminJS
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Функция для открытия редактора изображений
|
||||||
|
function openImageEditor(inputField, fieldName) {
|
||||||
|
const currentValue = inputField.value || '';
|
||||||
|
const editorUrl = `/image-editor-compact.html?field=${fieldName}¤t=${encodeURIComponent(currentValue)}`;
|
||||||
|
|
||||||
|
// Убираем предыдущие модальные окна
|
||||||
|
document.querySelectorAll('.image-editor-modal').forEach(modal => modal.remove());
|
||||||
|
|
||||||
|
// Создаем модальное окно
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'image-editor-modal';
|
||||||
|
modal.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
height: 80%;
|
||||||
|
max-height: 600px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.innerHTML = '✕';
|
||||||
|
closeBtn.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: bold;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = editorUrl;
|
||||||
|
iframe.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Обработчик сообщений от iframe
|
||||||
|
const messageHandler = function(event) {
|
||||||
|
if (event.data.type === 'imageSelected' && event.data.field === fieldName) {
|
||||||
|
console.log('🖼️ Изображение выбрано:', event.data.url);
|
||||||
|
inputField.value = event.data.url;
|
||||||
|
updateImagePreview(inputField, event.data.url);
|
||||||
|
|
||||||
|
// Триггерим событие change для обновления формы
|
||||||
|
const changeEvent = new Event('change', { bubbles: true });
|
||||||
|
inputField.dispatchEvent(changeEvent);
|
||||||
|
|
||||||
|
// Триггерим input событие
|
||||||
|
const inputEvent = new Event('input', { bubbles: true });
|
||||||
|
inputField.dispatchEvent(inputEvent);
|
||||||
|
|
||||||
|
modal.remove();
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
} else if (event.data.type === 'editorClosed') {
|
||||||
|
modal.remove();
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', messageHandler);
|
||||||
|
|
||||||
|
closeBtn.onclick = function() {
|
||||||
|
modal.remove();
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
};
|
||||||
|
|
||||||
|
modal.onclick = function(e) {
|
||||||
|
if (e.target === modal) {
|
||||||
|
modal.remove();
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
content.appendChild(closeBtn);
|
||||||
|
content.appendChild(iframe);
|
||||||
|
modal.appendChild(content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция обновления превью изображения
|
||||||
|
function updateImagePreview(inputField, imagePath) {
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||||
|
if (!fieldContainer) return;
|
||||||
|
|
||||||
|
// Находим или создаем превью
|
||||||
|
let preview = fieldContainer.querySelector('.image-preview');
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
preview = document.createElement('img');
|
||||||
|
preview.className = 'image-preview';
|
||||||
|
preview.style.cssText = `
|
||||||
|
display: block;
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Вставляем превью после кнопки
|
||||||
|
const button = fieldContainer.querySelector('.image-editor-btn');
|
||||||
|
if (button) {
|
||||||
|
const buttonContainer = button.parentNode;
|
||||||
|
buttonContainer.parentNode.insertBefore(preview, buttonContainer.nextSibling);
|
||||||
|
} else {
|
||||||
|
fieldContainer.appendChild(preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePath && imagePath.trim()) {
|
||||||
|
preview.src = imagePath + '?t=' + Date.now(); // Добавляем timestamp для обновления
|
||||||
|
preview.style.display = 'block';
|
||||||
|
preview.onerror = () => {
|
||||||
|
preview.style.display = 'none';
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
preview.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция добавления кнопки редактора к полю
|
||||||
|
function addImageEditorButton(inputField) {
|
||||||
|
const fieldName = inputField.name || inputField.id || 'image';
|
||||||
|
|
||||||
|
// Проверяем, не добавлена ли уже кнопка
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||||
|
if (fieldContainer.querySelector('.image-editor-btn')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем контейнер для кнопки
|
||||||
|
const buttonContainer = document.createElement('div');
|
||||||
|
buttonContainer.style.cssText = `
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Создаем кнопку
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'image-editor-btn';
|
||||||
|
button.innerHTML = '📷 Выбрать';
|
||||||
|
button.style.cssText = `
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
button.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openImageEditor(inputField, fieldName);
|
||||||
|
};
|
||||||
|
|
||||||
|
buttonContainer.appendChild(button);
|
||||||
|
|
||||||
|
// Добавляем контейнер после поля ввода
|
||||||
|
if (inputField.nextSibling) {
|
||||||
|
inputField.parentNode.insertBefore(buttonContainer, inputField.nextSibling);
|
||||||
|
} else {
|
||||||
|
inputField.parentNode.appendChild(buttonContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем превью если есть значение
|
||||||
|
if (inputField.value && inputField.value.trim()) {
|
||||||
|
updateImagePreview(inputField, inputField.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Слушаем изменения в поле для обновления превью
|
||||||
|
inputField.addEventListener('input', () => {
|
||||||
|
updateImagePreview(inputField, inputField.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
inputField.addEventListener('change', () => {
|
||||||
|
updateImagePreview(inputField, inputField.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция проверки, является ли поле полем изображения
|
||||||
|
function isImageField(inputField) {
|
||||||
|
const fieldName = (inputField.name || inputField.id || '').toLowerCase();
|
||||||
|
let labelText = '';
|
||||||
|
|
||||||
|
// Ищем label для поля
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
|
||||||
|
if (fieldContainer) {
|
||||||
|
const label = fieldContainer.querySelector('label, .property-label, h3');
|
||||||
|
if (label) {
|
||||||
|
labelText = label.textContent.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем по имени поля или тексту label
|
||||||
|
return fieldName.includes('image') ||
|
||||||
|
fieldName.includes('photo') ||
|
||||||
|
fieldName.includes('avatar') ||
|
||||||
|
fieldName.includes('picture') ||
|
||||||
|
fieldName.includes('banner') ||
|
||||||
|
fieldName.includes('thumbnail') ||
|
||||||
|
(fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение'))) ||
|
||||||
|
labelText.includes('изображение') ||
|
||||||
|
labelText.includes('картинка') ||
|
||||||
|
labelText.includes('фото') ||
|
||||||
|
labelText.includes('image') ||
|
||||||
|
labelText.includes('picture');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция сканирования и добавления кнопок к полям изображений
|
||||||
|
function scanAndAddImageButtons() {
|
||||||
|
console.log('🔍 Сканирование полей для добавления кнопок редактора изображений...');
|
||||||
|
|
||||||
|
// Более широкий поиск полей ввода
|
||||||
|
const inputFields = document.querySelectorAll('input[type="text"], input[type="url"], input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
|
||||||
|
|
||||||
|
console.log(`📋 Найдено ${inputFields.length} полей ввода`);
|
||||||
|
|
||||||
|
inputFields.forEach((inputField, index) => {
|
||||||
|
const fieldName = inputField.name || inputField.id || `field_${index}`;
|
||||||
|
const isImage = isImageField(inputField);
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||||
|
const hasButton = fieldContainer.querySelector('.image-editor-btn');
|
||||||
|
|
||||||
|
console.log(`🔸 Поле "${fieldName}": isImage=${isImage}, hasButton=${!!hasButton}`);
|
||||||
|
|
||||||
|
if (isImage && !hasButton) {
|
||||||
|
console.log(`➕ Добавляем кнопку для поля "${fieldName}"`);
|
||||||
|
addImageEditorButton(inputField);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке DOM
|
||||||
|
function initialize() {
|
||||||
|
console.log('🚀 Инициализация селектора изображений AdminJS');
|
||||||
|
|
||||||
|
scanAndAddImageButtons();
|
||||||
|
|
||||||
|
// Наблюдаем за изменениями в DOM для динамически добавляемых полей
|
||||||
|
const observer = new MutationObserver((mutations) => {
|
||||||
|
let shouldScan = false;
|
||||||
|
mutations.forEach(mutation => {
|
||||||
|
if (mutation.type === 'childList') {
|
||||||
|
mutation.addedNodes.forEach(node => {
|
||||||
|
if (node.nodeType === 1 && (node.tagName === 'INPUT' || node.querySelector('input'))) {
|
||||||
|
shouldScan = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (shouldScan) {
|
||||||
|
setTimeout(scanAndAddImageButtons, 100);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждем загрузки DOM
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initialize);
|
||||||
|
} else {
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также запускаем через задержки для AdminJS
|
||||||
|
setTimeout(initialize, 1000);
|
||||||
|
setTimeout(initialize, 3000);
|
||||||
|
setTimeout(initialize, 5000);
|
||||||
|
|
||||||
|
})();
|
||||||
300
public/js/admin-image-selector.js
Normal file
300
public/js/admin-image-selector.js
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
// JavaScript для интеграции редактора изображений в AdminJS
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Функция для открытия редактора изображений
|
||||||
|
function openImageEditor(inputField, fieldName) {
|
||||||
|
const currentValue = inputField.value || '';
|
||||||
|
const editorUrl = `/image-editor-compact.html?field=${fieldName}¤t=${encodeURIComponent(currentValue)}`;
|
||||||
|
|
||||||
|
// Убираем предыдущие модальные окна
|
||||||
|
document.querySelectorAll('.image-editor-modal').forEach(modal => modal.remove());
|
||||||
|
|
||||||
|
// Создаем модальное окно
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.className = 'image-editor-modal';
|
||||||
|
modal.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.7);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 700px;
|
||||||
|
height: 80%;
|
||||||
|
max-height: 600px;
|
||||||
|
position: relative;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.3);
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.innerHTML = '✕';
|
||||||
|
closeBtn.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 15px;
|
||||||
|
right: 15px;
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 35px;
|
||||||
|
height: 35px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: bold;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = editorUrl;
|
||||||
|
iframe.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Обработчик закрытия
|
||||||
|
const closeModal = () => {
|
||||||
|
document.body.removeChild(modal);
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
closeBtn.onclick = closeModal;
|
||||||
|
|
||||||
|
// Обработчик сообщений от редактора
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
|
||||||
|
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
|
||||||
|
inputField.value = event.data.path;
|
||||||
|
|
||||||
|
// Триггерим события изменения
|
||||||
|
inputField.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
inputField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
|
// Обновляем превью если есть
|
||||||
|
updateImagePreview(inputField, event.data.path);
|
||||||
|
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
content.appendChild(closeBtn);
|
||||||
|
content.appendChild(iframe);
|
||||||
|
modal.appendChild(content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
|
||||||
|
// Закрытие по клику на фон
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) {
|
||||||
|
closeModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция обновления превью изображения
|
||||||
|
function updateImagePreview(inputField, imagePath) {
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
|
||||||
|
if (!fieldContainer) return;
|
||||||
|
|
||||||
|
// Находим или создаем превью
|
||||||
|
let preview = fieldContainer.querySelector('.image-preview');
|
||||||
|
|
||||||
|
if (!preview) {
|
||||||
|
preview = document.createElement('img');
|
||||||
|
preview.className = 'image-preview';
|
||||||
|
preview.style.cssText = `
|
||||||
|
display: block;
|
||||||
|
max-width: 180px;
|
||||||
|
max-height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
margin-top: 8px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Вставляем превью после кнопки
|
||||||
|
const button = fieldContainer.querySelector('.image-editor-btn');
|
||||||
|
if (button && button.nextSibling) {
|
||||||
|
button.parentNode.insertBefore(preview, button.nextSibling);
|
||||||
|
} else {
|
||||||
|
inputField.parentNode.appendChild(preview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (imagePath && imagePath.trim()) {
|
||||||
|
preview.src = imagePath + '?t=' + Date.now(); // Добавляем timestamp для обновления
|
||||||
|
preview.style.display = 'block';
|
||||||
|
preview.onerror = () => {
|
||||||
|
preview.style.display = 'none';
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
preview.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция добавления кнопки редактора к полю
|
||||||
|
function addImageEditorButton(inputField) {
|
||||||
|
const fieldName = inputField.name || inputField.id || 'image';
|
||||||
|
|
||||||
|
// Проверяем, не добавлена ли уже кнопка
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]') || inputField.parentNode;
|
||||||
|
if (fieldContainer.querySelector('.image-editor-btn')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Создаем контейнер для кнопки и превью
|
||||||
|
const buttonContainer = document.createElement('div');
|
||||||
|
buttonContainer.style.cssText = `
|
||||||
|
margin-top: 8px;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Создаем кнопку
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'image-editor-btn';
|
||||||
|
button.innerHTML = '📷 Выбрать';
|
||||||
|
button.style.cssText = `
|
||||||
|
padding: 6px 12px;
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
white-space: nowrap;
|
||||||
|
`;
|
||||||
|
|
||||||
|
button.onclick = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
openImageEditor(inputField, fieldName);
|
||||||
|
};
|
||||||
|
|
||||||
|
buttonContainer.appendChild(button);
|
||||||
|
|
||||||
|
// Добавляем контейнер после поля ввода
|
||||||
|
if (inputField.nextSibling) {
|
||||||
|
inputField.parentNode.insertBefore(buttonContainer, inputField.nextSibling);
|
||||||
|
} else {
|
||||||
|
inputField.parentNode.appendChild(buttonContainer);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем превью если есть значение
|
||||||
|
if (inputField.value && inputField.value.trim()) {
|
||||||
|
updateImagePreview(inputField, inputField.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Слушаем изменения в поле для обновления превью
|
||||||
|
inputField.addEventListener('input', () => {
|
||||||
|
updateImagePreview(inputField, inputField.value);
|
||||||
|
});
|
||||||
|
|
||||||
|
inputField.addEventListener('change', () => {
|
||||||
|
updateImagePreview(inputField, inputField.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция проверки, является ли поле полем изображения
|
||||||
|
function isImageField(inputField) {
|
||||||
|
const fieldName = (inputField.name || inputField.id || '').toLowerCase();
|
||||||
|
let labelText = '';
|
||||||
|
|
||||||
|
// Ищем label для поля
|
||||||
|
const fieldContainer = inputField.closest('.field, .property-edit, div[data-testid]');
|
||||||
|
if (fieldContainer) {
|
||||||
|
const label = fieldContainer.querySelector('label, .property-label, h3');
|
||||||
|
if (label) {
|
||||||
|
labelText = label.textContent.toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем по имени поля или тексту label
|
||||||
|
return fieldName.includes('image') ||
|
||||||
|
fieldName.includes('photo') ||
|
||||||
|
fieldName.includes('avatar') ||
|
||||||
|
fieldName.includes('picture') ||
|
||||||
|
fieldName.includes('banner') ||
|
||||||
|
fieldName.includes('thumbnail') ||
|
||||||
|
fieldName.includes('url') && (labelText.includes('image') || labelText.includes('изображение')) ||
|
||||||
|
labelText.includes('изображение') ||
|
||||||
|
labelText.includes('картинка') ||
|
||||||
|
labelText.includes('фото') ||
|
||||||
|
labelText.includes('image') ||
|
||||||
|
labelText.includes('picture');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция сканирования и добавления кнопок к полям изображений
|
||||||
|
function scanAndAddImageButtons() {
|
||||||
|
console.log('🔍 Сканирование полей для добавления кнопок редактора изображений...');
|
||||||
|
|
||||||
|
// Более широкий поиск полей ввода
|
||||||
|
const inputFields = document.querySelectorAll('input[type="text"], input[type="url"], input:not([type="hidden"]):not([type="submit"]):not([type="button"])');
|
||||||
|
|
||||||
|
console.log(`📋 Найдено ${inputFields.length} полей ввода`);
|
||||||
|
|
||||||
|
inputFields.forEach((inputField, index) => {
|
||||||
|
const fieldName = inputField.name || inputField.id || `field_${index}`;
|
||||||
|
const isImage = isImageField(inputField);
|
||||||
|
const hasButton = inputField.parentNode.querySelector('.image-editor-btn');
|
||||||
|
|
||||||
|
console.log(`🔸 Поле "${fieldName}": isImage=${isImage}, hasButton=${hasButton}`);
|
||||||
|
|
||||||
|
if (isImage && !hasButton) {
|
||||||
|
console.log(`➕ Добавляем кнопку для поля "${fieldName}"`);
|
||||||
|
addImageEditorButton(inputField);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Инициализация при загрузке DOM
|
||||||
|
function initialize() {
|
||||||
|
console.log('🚀 Инициализация селектора изображений AdminJS');
|
||||||
|
|
||||||
|
scanAndAddImageButtons();
|
||||||
|
|
||||||
|
// Наблюдаем за изменениями в DOM для динамически добавляемых полей
|
||||||
|
const observer = new MutationObserver(() => {
|
||||||
|
scanAndAddImageButtons();
|
||||||
|
});
|
||||||
|
|
||||||
|
observer.observe(document.body, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true
|
||||||
|
});
|
||||||
|
|
||||||
|
// Периодическое сканирование для надежности
|
||||||
|
setInterval(scanAndAddImageButtons, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ждем загрузки DOM
|
||||||
|
if (document.readyState === 'loading') {
|
||||||
|
document.addEventListener('DOMContentLoaded', initialize);
|
||||||
|
} else {
|
||||||
|
initialize();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Также запускаем через задержки для AdminJS
|
||||||
|
setTimeout(initialize, 1000);
|
||||||
|
setTimeout(initialize, 3000);
|
||||||
|
setTimeout(initialize, 5000);
|
||||||
|
|
||||||
|
})();
|
||||||
690
public/js/image-editor.js
Normal file
690
public/js/image-editor.js
Normal file
@@ -0,0 +1,690 @@
|
|||||||
|
/**
|
||||||
|
* Image Editor Modal Component
|
||||||
|
* Предоставляет интерфейс для загрузки, обрезки и редактирования изображений
|
||||||
|
*/
|
||||||
|
|
||||||
|
class ImageEditor {
|
||||||
|
constructor(options = {}) {
|
||||||
|
this.options = {
|
||||||
|
targetFolder: 'routes',
|
||||||
|
aspectRatio: null, // null = свободная обрезка, или например 16/9
|
||||||
|
maxWidth: 1200,
|
||||||
|
maxHeight: 800,
|
||||||
|
...options
|
||||||
|
};
|
||||||
|
|
||||||
|
this.modal = null;
|
||||||
|
this.canvas = null;
|
||||||
|
this.ctx = null;
|
||||||
|
this.image = null;
|
||||||
|
this.imageData = null;
|
||||||
|
this.cropBox = null;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.lastMousePos = { x: 0, y: 0 };
|
||||||
|
this.rotation = 0;
|
||||||
|
this.flipHorizontal = false;
|
||||||
|
this.flipVertical = false;
|
||||||
|
|
||||||
|
this.onSave = options.onSave || (() => {});
|
||||||
|
this.onCancel = options.onCancel || (() => {});
|
||||||
|
|
||||||
|
this.createModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
createModal() {
|
||||||
|
// Создаем модальное окно
|
||||||
|
const modalHTML = `
|
||||||
|
<div class="image-editor-overlay">
|
||||||
|
<div class="image-editor-modal">
|
||||||
|
<div class="image-editor-header">
|
||||||
|
<h3>Редактор изображений</h3>
|
||||||
|
<button class="close-btn">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-editor-body">
|
||||||
|
<!-- Область загрузки -->
|
||||||
|
<div class="upload-area" id="uploadArea">
|
||||||
|
<div class="upload-content">
|
||||||
|
<div class="upload-icon">📷</div>
|
||||||
|
<p>Перетащите изображение сюда или <button class="btn-link" id="selectFileBtn">выберите файл</button></p>
|
||||||
|
<input type="file" id="fileInput" accept="image/*" style="display: none;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Область редактирования -->
|
||||||
|
<div class="editor-area" id="editorArea" style="display: none;">
|
||||||
|
<div class="editor-toolbar">
|
||||||
|
<button class="tool-btn" id="rotateLeftBtn" title="Повернуть влево">↺</button>
|
||||||
|
<button class="tool-btn" id="rotateRightBtn" title="Повернуть вправо">↻</button>
|
||||||
|
<button class="tool-btn" id="flipHorizontalBtn" title="Отразить горизонтально">⟷</button>
|
||||||
|
<button class="tool-btn" id="flipVerticalBtn" title="Отразить вертикально">↕</button>
|
||||||
|
<button class="tool-btn" id="resetCropBtn" title="Сбросить обрезку">⌕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="canvas-container">
|
||||||
|
<canvas id="editorCanvas"></canvas>
|
||||||
|
<div class="crop-overlay" id="cropOverlay">
|
||||||
|
<div class="crop-box" id="cropBox">
|
||||||
|
<div class="crop-handle nw"></div>
|
||||||
|
<div class="crop-handle ne"></div>
|
||||||
|
<div class="crop-handle sw"></div>
|
||||||
|
<div class="crop-handle se"></div>
|
||||||
|
<div class="crop-handle n"></div>
|
||||||
|
<div class="crop-handle s"></div>
|
||||||
|
<div class="crop-handle e"></div>
|
||||||
|
<div class="crop-handle w"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-info">
|
||||||
|
<span id="imageInfo">Размер: 0x0</span>
|
||||||
|
<span id="cropInfo">Обрезка: не выбрана</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="image-editor-footer">
|
||||||
|
<button class="btn btn-secondary" id="cancelBtn">Отмена</button>
|
||||||
|
<button class="btn btn-primary" id="saveBtn" disabled>Сохранить</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Добавляем стили
|
||||||
|
if (!document.getElementById('image-editor-styles')) {
|
||||||
|
const styles = document.createElement('style');
|
||||||
|
styles.id = 'image-editor-styles';
|
||||||
|
styles.textContent = `
|
||||||
|
.image-editor-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 10000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-modal {
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 900px;
|
||||||
|
max-height: 90vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-header {
|
||||||
|
padding: 20px;
|
||||||
|
border-bottom: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 20px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area {
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 40px;
|
||||||
|
text-align: center;
|
||||||
|
background: #f9f9f9;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-area.dragover {
|
||||||
|
border-color: #007bff;
|
||||||
|
background: #e3f2fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-content .upload-icon {
|
||||||
|
font-size: 48px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.upload-content p {
|
||||||
|
margin: 0;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-link {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #007bff;
|
||||||
|
text-decoration: underline;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 12px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn {
|
||||||
|
background: white;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 4px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tool-btn:hover {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.canvas-container {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
background: #f0f0f0;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#editorCanvas {
|
||||||
|
max-width: 100%;
|
||||||
|
max-height: 400px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-box {
|
||||||
|
position: absolute;
|
||||||
|
border: 2px solid #007bff;
|
||||||
|
background: rgba(0, 123, 255, 0.1);
|
||||||
|
pointer-events: all;
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 10px;
|
||||||
|
height: 10px;
|
||||||
|
background: #007bff;
|
||||||
|
border: 1px solid white;
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.crop-handle.nw { top: -5px; left: -5px; cursor: nw-resize; }
|
||||||
|
.crop-handle.ne { top: -5px; right: -5px; cursor: ne-resize; }
|
||||||
|
.crop-handle.sw { bottom: -5px; left: -5px; cursor: sw-resize; }
|
||||||
|
.crop-handle.se { bottom: -5px; right: -5px; cursor: se-resize; }
|
||||||
|
.crop-handle.n { top: -5px; left: 50%; margin-left: -5px; cursor: n-resize; }
|
||||||
|
.crop-handle.s { bottom: -5px; left: 50%; margin-left: -5px; cursor: s-resize; }
|
||||||
|
.crop-handle.e { top: 50%; right: -5px; margin-top: -5px; cursor: e-resize; }
|
||||||
|
.crop-handle.w { top: 50%; left: -5px; margin-top: -5px; cursor: w-resize; }
|
||||||
|
|
||||||
|
.image-info {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 8px;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.image-editor-footer {
|
||||||
|
padding: 20px;
|
||||||
|
border-top: 1px solid #ddd;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
border: 1px solid;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: #f8f9fa;
|
||||||
|
color: #333;
|
||||||
|
border-color: #ddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
border-color: #007bff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
document.head.appendChild(styles);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Добавляем модальное окно в DOM
|
||||||
|
document.body.insertAdjacentHTML('beforeend', modalHTML);
|
||||||
|
this.modal = document.querySelector('.image-editor-overlay');
|
||||||
|
|
||||||
|
this.bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
bindEvents() {
|
||||||
|
// Закрытие модального окна
|
||||||
|
this.modal.querySelector('.close-btn').addEventListener('click', () => this.close());
|
||||||
|
this.modal.querySelector('#cancelBtn').addEventListener('click', () => this.close());
|
||||||
|
|
||||||
|
// Клик по overlay для закрытия
|
||||||
|
this.modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === this.modal) this.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Загрузка файла
|
||||||
|
const fileInput = this.modal.querySelector('#fileInput');
|
||||||
|
const selectFileBtn = this.modal.querySelector('#selectFileBtn');
|
||||||
|
const uploadArea = this.modal.querySelector('#uploadArea');
|
||||||
|
|
||||||
|
selectFileBtn.addEventListener('click', () => fileInput.click());
|
||||||
|
fileInput.addEventListener('change', (e) => this.handleFileSelect(e));
|
||||||
|
|
||||||
|
// Drag & Drop
|
||||||
|
uploadArea.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('dragleave', () => {
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadArea.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadArea.classList.remove('dragover');
|
||||||
|
const files = e.dataTransfer.files;
|
||||||
|
if (files.length > 0) {
|
||||||
|
this.loadImage(files[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инструменты редактирования
|
||||||
|
this.modal.querySelector('#rotateLeftBtn').addEventListener('click', () => this.rotate(-90));
|
||||||
|
this.modal.querySelector('#rotateRightBtn').addEventListener('click', () => this.rotate(90));
|
||||||
|
this.modal.querySelector('#flipHorizontalBtn').addEventListener('click', () => this.flipHorizontal = !this.flipHorizontal, this.redraw());
|
||||||
|
this.modal.querySelector('#flipVerticalBtn').addEventListener('click', () => this.flipVertical = !this.flipVertical, this.redraw());
|
||||||
|
this.modal.querySelector('#resetCropBtn').addEventListener('click', () => this.resetCrop());
|
||||||
|
|
||||||
|
// Сохранение
|
||||||
|
this.modal.querySelector('#saveBtn').addEventListener('click', () => this.save());
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFileSelect(e) {
|
||||||
|
const file = e.target.files[0];
|
||||||
|
if (file) {
|
||||||
|
this.loadImage(file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadImage(file) {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
alert('Пожалуйста, выберите изображение');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', file);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/upload-image', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.imageData = result;
|
||||||
|
this.showEditor();
|
||||||
|
this.loadImageToCanvas(result.tempUrl);
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Ошибка загрузки изображения');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
alert('Ошибка загрузки изображения');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadImageToCanvas(imageUrl) {
|
||||||
|
this.image = new Image();
|
||||||
|
this.image.onload = () => {
|
||||||
|
this.initCanvas();
|
||||||
|
this.initCropBox();
|
||||||
|
this.redraw();
|
||||||
|
this.updateInfo();
|
||||||
|
this.modal.querySelector('#saveBtn').disabled = false;
|
||||||
|
};
|
||||||
|
this.image.src = imageUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
showEditor() {
|
||||||
|
this.modal.querySelector('#uploadArea').style.display = 'none';
|
||||||
|
this.modal.querySelector('#editorArea').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
initCanvas() {
|
||||||
|
this.canvas = this.modal.querySelector('#editorCanvas');
|
||||||
|
this.ctx = this.canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Вычисляем размеры canvas
|
||||||
|
const containerWidth = 600; // максимальная ширина
|
||||||
|
const containerHeight = 400; // максимальная высота
|
||||||
|
|
||||||
|
const imageRatio = this.image.width / this.image.height;
|
||||||
|
const containerRatio = containerWidth / containerHeight;
|
||||||
|
|
||||||
|
if (imageRatio > containerRatio) {
|
||||||
|
this.canvas.width = containerWidth;
|
||||||
|
this.canvas.height = containerWidth / imageRatio;
|
||||||
|
} else {
|
||||||
|
this.canvas.width = containerHeight * imageRatio;
|
||||||
|
this.canvas.height = containerHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.scaleX = this.canvas.width / this.image.width;
|
||||||
|
this.scaleY = this.canvas.height / this.image.height;
|
||||||
|
}
|
||||||
|
|
||||||
|
initCropBox() {
|
||||||
|
const overlay = this.modal.querySelector('#cropOverlay');
|
||||||
|
const cropBox = this.modal.querySelector('#cropBox');
|
||||||
|
|
||||||
|
// Устанавливаем размеры overlay как у canvas
|
||||||
|
const canvasRect = this.canvas.getBoundingClientRect();
|
||||||
|
const containerRect = this.canvas.parentElement.getBoundingClientRect();
|
||||||
|
|
||||||
|
overlay.style.width = `${canvasRect.width}px`;
|
||||||
|
overlay.style.height = `${canvasRect.height}px`;
|
||||||
|
overlay.style.left = `${canvasRect.left - containerRect.left}px`;
|
||||||
|
overlay.style.top = `${canvasRect.top - containerRect.top}px`;
|
||||||
|
|
||||||
|
// Инициализируем crop box (50% от центра)
|
||||||
|
const boxWidth = canvasRect.width * 0.6;
|
||||||
|
const boxHeight = canvasRect.height * 0.6;
|
||||||
|
const boxLeft = (canvasRect.width - boxWidth) / 2;
|
||||||
|
const boxTop = (canvasRect.height - boxHeight) / 2;
|
||||||
|
|
||||||
|
cropBox.style.width = `${boxWidth}px`;
|
||||||
|
cropBox.style.height = `${boxHeight}px`;
|
||||||
|
cropBox.style.left = `${boxLeft}px`;
|
||||||
|
cropBox.style.top = `${boxTop}px`;
|
||||||
|
|
||||||
|
this.cropBox = {
|
||||||
|
x: boxLeft / canvasRect.width,
|
||||||
|
y: boxTop / canvasRect.height,
|
||||||
|
width: boxWidth / canvasRect.width,
|
||||||
|
height: boxHeight / canvasRect.height
|
||||||
|
};
|
||||||
|
|
||||||
|
this.bindCropEvents(cropBox, overlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
bindCropEvents(cropBox, overlay) {
|
||||||
|
let resizing = false;
|
||||||
|
let moving = false;
|
||||||
|
let startPos = { x: 0, y: 0 };
|
||||||
|
let startBox = {};
|
||||||
|
|
||||||
|
// Обработчик для перемещения
|
||||||
|
cropBox.addEventListener('mousedown', (e) => {
|
||||||
|
if (e.target === cropBox) {
|
||||||
|
moving = true;
|
||||||
|
startPos = { x: e.clientX, y: e.clientY };
|
||||||
|
startBox = { ...this.cropBox };
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчик для изменения размера
|
||||||
|
const handles = cropBox.querySelectorAll('.crop-handle');
|
||||||
|
handles.forEach(handle => {
|
||||||
|
handle.addEventListener('mousedown', (e) => {
|
||||||
|
resizing = handle.className.replace('crop-handle ', '');
|
||||||
|
startPos = { x: e.clientX, y: e.clientY };
|
||||||
|
startBox = { ...this.cropBox };
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Обработчики движения и отпускания мыши
|
||||||
|
document.addEventListener('mousemove', (e) => {
|
||||||
|
if (moving || resizing) {
|
||||||
|
this.updateCropBox(e, startPos, startBox, moving ? 'move' : resizing, overlay);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.addEventListener('mouseup', () => {
|
||||||
|
moving = false;
|
||||||
|
resizing = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCropBox(e, startPos, startBox, action, overlay) {
|
||||||
|
const overlayRect = overlay.getBoundingClientRect();
|
||||||
|
const deltaX = (e.clientX - startPos.x) / overlayRect.width;
|
||||||
|
const deltaY = (e.clientY - startPos.y) / overlayRect.height;
|
||||||
|
|
||||||
|
let newBox = { ...startBox };
|
||||||
|
|
||||||
|
if (action === 'move') {
|
||||||
|
newBox.x = Math.max(0, Math.min(1 - startBox.width, startBox.x + deltaX));
|
||||||
|
newBox.y = Math.max(0, Math.min(1 - startBox.height, startBox.y + deltaY));
|
||||||
|
} else {
|
||||||
|
// Изменение размера в зависимости от handle
|
||||||
|
if (action.includes('n')) newBox.y += deltaY, newBox.height -= deltaY;
|
||||||
|
if (action.includes('s')) newBox.height += deltaY;
|
||||||
|
if (action.includes('w')) newBox.x += deltaX, newBox.width -= deltaX;
|
||||||
|
if (action.includes('e')) newBox.width += deltaX;
|
||||||
|
|
||||||
|
// Ограничиваем минимальные размеры
|
||||||
|
if (newBox.width < 0.1) newBox.width = 0.1;
|
||||||
|
if (newBox.height < 0.1) newBox.height = 0.1;
|
||||||
|
|
||||||
|
// Ограничиваем границы
|
||||||
|
if (newBox.x < 0) newBox.x = 0;
|
||||||
|
if (newBox.y < 0) newBox.y = 0;
|
||||||
|
if (newBox.x + newBox.width > 1) newBox.width = 1 - newBox.x;
|
||||||
|
if (newBox.y + newBox.height > 1) newBox.height = 1 - newBox.y;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cropBox = newBox;
|
||||||
|
this.updateCropBoxDisplay(overlay);
|
||||||
|
this.updateInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCropBoxDisplay(overlay) {
|
||||||
|
const cropBoxElement = this.modal.querySelector('#cropBox');
|
||||||
|
const overlayRect = overlay.getBoundingClientRect();
|
||||||
|
|
||||||
|
cropBoxElement.style.left = `${this.cropBox.x * overlayRect.width}px`;
|
||||||
|
cropBoxElement.style.top = `${this.cropBox.y * overlayRect.height}px`;
|
||||||
|
cropBoxElement.style.width = `${this.cropBox.width * overlayRect.width}px`;
|
||||||
|
cropBoxElement.style.height = `${this.cropBox.height * overlayRect.height}px`;
|
||||||
|
}
|
||||||
|
|
||||||
|
rotate(degrees) {
|
||||||
|
this.rotation = (this.rotation + degrees) % 360;
|
||||||
|
this.redraw();
|
||||||
|
this.updateInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
redraw() {
|
||||||
|
this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||||
|
|
||||||
|
this.ctx.save();
|
||||||
|
|
||||||
|
// Перемещаем к центру
|
||||||
|
this.ctx.translate(this.canvas.width / 2, this.canvas.height / 2);
|
||||||
|
|
||||||
|
// Поворот
|
||||||
|
this.ctx.rotate(this.rotation * Math.PI / 180);
|
||||||
|
|
||||||
|
// Отражение
|
||||||
|
this.ctx.scale(this.flipHorizontal ? -1 : 1, this.flipVertical ? -1 : 1);
|
||||||
|
|
||||||
|
// Рисуем изображение
|
||||||
|
this.ctx.drawImage(
|
||||||
|
this.image,
|
||||||
|
-this.canvas.width / 2,
|
||||||
|
-this.canvas.height / 2,
|
||||||
|
this.canvas.width,
|
||||||
|
this.canvas.height
|
||||||
|
);
|
||||||
|
|
||||||
|
this.ctx.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
resetCrop() {
|
||||||
|
// Сброс crop box к исходному состоянию
|
||||||
|
this.cropBox = { x: 0.2, y: 0.2, width: 0.6, height: 0.6 };
|
||||||
|
this.updateCropBoxDisplay(this.modal.querySelector('#cropOverlay'));
|
||||||
|
this.updateInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateInfo() {
|
||||||
|
const imageInfo = this.modal.querySelector('#imageInfo');
|
||||||
|
const cropInfo = this.modal.querySelector('#cropInfo');
|
||||||
|
|
||||||
|
imageInfo.textContent = `Размер: ${this.image.width}x${this.image.height}`;
|
||||||
|
|
||||||
|
const cropWidth = Math.round(this.cropBox.width * this.image.width);
|
||||||
|
const cropHeight = Math.round(this.cropBox.height * this.image.height);
|
||||||
|
cropInfo.textContent = `Обрезка: ${cropWidth}x${cropHeight}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async save() {
|
||||||
|
const saveBtn = this.modal.querySelector('#saveBtn');
|
||||||
|
saveBtn.disabled = true;
|
||||||
|
saveBtn.textContent = 'Сохранение...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const cropData = {
|
||||||
|
x: this.cropBox.x * this.image.width,
|
||||||
|
y: this.cropBox.y * this.image.height,
|
||||||
|
width: this.cropBox.width * this.image.width,
|
||||||
|
height: this.cropBox.height * this.image.height
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch('/api/images/process-image', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
tempId: this.imageData.tempId,
|
||||||
|
rotation: this.rotation,
|
||||||
|
flipHorizontal: this.flipHorizontal,
|
||||||
|
flipVertical: this.flipVertical,
|
||||||
|
cropData,
|
||||||
|
targetFolder: this.options.targetFolder
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
this.onSave(result.url);
|
||||||
|
this.close();
|
||||||
|
} else {
|
||||||
|
alert(result.error || 'Ошибка сохранения');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Сохранить';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Save error:', error);
|
||||||
|
alert('Ошибка сохранения изображения');
|
||||||
|
saveBtn.disabled = false;
|
||||||
|
saveBtn.textContent = 'Сохранить';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
// Удаляем временный файл
|
||||||
|
if (this.imageData && this.imageData.tempId) {
|
||||||
|
fetch(`/api/images/temp-image/${this.imageData.tempId}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
}).catch(console.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.onCancel();
|
||||||
|
if (this.modal) {
|
||||||
|
this.modal.remove();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
show() {
|
||||||
|
if (this.modal) {
|
||||||
|
this.modal.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Глобально доступная функция для открытия редактора
|
||||||
|
window.openImageEditor = function(options = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const editor = new ImageEditor({
|
||||||
|
...options,
|
||||||
|
onSave: (url) => resolve(url),
|
||||||
|
onCancel: () => reject(new Error('Canceled'))
|
||||||
|
});
|
||||||
|
editor.show();
|
||||||
|
});
|
||||||
|
};
|
||||||
103
public/test-editor.html
Normal file
103
public/test-editor.html
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Тест редактора изображений</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 800px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
.test-container {
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
padding: 20px;
|
||||||
|
margin: 20px 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.result-image {
|
||||||
|
max-width: 300px;
|
||||||
|
max-height: 200px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
margin: 10px 0;
|
||||||
|
}
|
||||||
|
.btn {
|
||||||
|
background: #007bff;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 5px;
|
||||||
|
}
|
||||||
|
.btn:hover {
|
||||||
|
background: #0056b3;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Тестирование редактора изображений</h1>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h3>Тест для туров (routes)</h3>
|
||||||
|
<button class="btn" onclick="testImageEditor('routes')">Открыть редактор для туров</button>
|
||||||
|
<div id="routes-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h3>Тест для гидов (guides)</h3>
|
||||||
|
<button class="btn" onclick="testImageEditor('guides')">Открыть редактор для гидов</button>
|
||||||
|
<div id="guides-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="test-container">
|
||||||
|
<h3>Тест для статей (articles)</h3>
|
||||||
|
<button class="btn" onclick="testImageEditor('articles')">Открыть редактор для статей</button>
|
||||||
|
<div id="articles-result"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Подключаем редактор изображений -->
|
||||||
|
<script src="/js/image-editor.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function testImageEditor(targetFolder) {
|
||||||
|
if (typeof window.openImageEditor === 'function') {
|
||||||
|
window.openImageEditor({
|
||||||
|
targetFolder: targetFolder,
|
||||||
|
onSave: (url) => {
|
||||||
|
console.log('Saved image:', url);
|
||||||
|
const resultDiv = document.getElementById(targetFolder + '-result');
|
||||||
|
resultDiv.innerHTML = `
|
||||||
|
<p>Изображение сохранено: <strong>${url}</strong></p>
|
||||||
|
<img src="${url}" alt="Result" class="result-image">
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
}).catch((error) => {
|
||||||
|
if (error.message !== 'Canceled') {
|
||||||
|
console.error('Editor error:', error);
|
||||||
|
alert('Ошибка редактора: ' + error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
alert('Редактор изображений не загружен!');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем доступность API
|
||||||
|
async function checkAPI() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/images/routes');
|
||||||
|
const result = await response.json();
|
||||||
|
console.log('API check:', result);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('API not available:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверяем при загрузке страницы
|
||||||
|
checkAPI();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
191
public/test-image-editor.html
Normal file
191
public/test-image-editor.html
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Тест редактора изображений</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1>Тест интеграции редактора изображений</h1>
|
||||||
|
<p>Этот файл демонстрирует, как редактор изображений будет работать с AdminJS.</p>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Поле изображения маршрута</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<label for="route_image_url" class="form-label">Изображение маршрута</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="route_image_url" name="route_image_url" class="form-control"
|
||||||
|
placeholder="/uploads/routes/example.jpg" value="/uploads/routes/seoul-city-tour.jpg">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="openImageEditor('route_image_url', document.getElementById('route_image_url').value)">
|
||||||
|
<i class="fas fa-images"></i> Выбрать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<img id="route_image_url_preview" src="/uploads/routes/seoul-city-tour.jpg"
|
||||||
|
class="img-thumbnail mt-2" style="max-width: 200px; max-height: 200px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Поле изображения гида</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<label for="guide_image_url" class="form-label">Фотография гида</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input type="text" id="guide_image_url" name="guide_image_url" class="form-control"
|
||||||
|
placeholder="/uploads/guides/example.jpg" value="/uploads/guides/guide-profile.jpg">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" onclick="openImageEditor('guide_image_url', document.getElementById('guide_image_url').value)">
|
||||||
|
<i class="fas fa-images"></i> Выбрать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<img id="guide_image_url_preview" src="/uploads/guides/guide-profile.jpg"
|
||||||
|
class="img-thumbnail mt-2" style="max-width: 200px; max-height: 200px;">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Доступные изображения</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div id="imageList" class="row">
|
||||||
|
<!-- Будет заполнено динамически -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mt-4">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>Как использовать:</h6>
|
||||||
|
<ol>
|
||||||
|
<li>Нажмите кнопку "Выбрать" рядом с полем изображения</li>
|
||||||
|
<li>Откроется редактор изображений в новом окне</li>
|
||||||
|
<li>Выберите изображение из галереи, загрузите новое или укажите URL</li>
|
||||||
|
<li>Нажмите "Выбрать" в редакторе</li>
|
||||||
|
<li>Поле автоматически обновится с выбранным путем</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/js/all.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Функция для открытия редактора изображений
|
||||||
|
function openImageEditor(fieldName, currentValue) {
|
||||||
|
const editorUrl = `/image-editor.html?field=${fieldName}¤t=${encodeURIComponent(currentValue || '')}`;
|
||||||
|
const editorWindow = window.open(editorUrl, 'imageEditor', 'width=1200,height=800,scrollbars=yes,resizable=yes');
|
||||||
|
|
||||||
|
// Слушаем сообщения от редактора
|
||||||
|
const messageHandler = (event) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
|
||||||
|
if (event.data.type === 'imageSelected' && event.data.targetField === fieldName) {
|
||||||
|
const field = document.getElementById(fieldName);
|
||||||
|
const preview = document.getElementById(fieldName + '_preview');
|
||||||
|
|
||||||
|
if (field) {
|
||||||
|
field.value = event.data.path;
|
||||||
|
field.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (preview) {
|
||||||
|
preview.src = event.data.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
editorWindow.close();
|
||||||
|
|
||||||
|
showSuccess(`Изображение обновлено: ${event.data.path}`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', messageHandler);
|
||||||
|
|
||||||
|
// Очистка обработчика при закрытии окна
|
||||||
|
const checkClosed = setInterval(() => {
|
||||||
|
if (editorWindow.closed) {
|
||||||
|
window.removeEventListener('message', messageHandler);
|
||||||
|
clearInterval(checkClosed);
|
||||||
|
}
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Функция показа уведомления об успехе
|
||||||
|
function showSuccess(message) {
|
||||||
|
const alert = document.createElement('div');
|
||||||
|
alert.className = 'alert alert-success alert-dismissible fade show position-fixed top-0 start-50 translate-middle-x mt-3';
|
||||||
|
alert.style.zIndex = '9999';
|
||||||
|
alert.innerHTML = `
|
||||||
|
${message}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(alert);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
if (alert.parentNode) {
|
||||||
|
alert.parentNode.removeChild(alert);
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загрузка списка изображений
|
||||||
|
async function loadImageList() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/gallery?folder=all');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
renderImageList(result.data);
|
||||||
|
} else {
|
||||||
|
document.getElementById('imageList').innerHTML = '<p class="text-muted">Ошибка загрузки изображений</p>';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
document.getElementById('imageList').innerHTML = '<p class="text-muted">Ошибка: ' + error.message + '</p>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отображение списка изображений
|
||||||
|
function renderImageList(images) {
|
||||||
|
const container = document.getElementById('imageList');
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
container.innerHTML = '<p class="text-muted">Изображения не найдены</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = images.slice(0, 12).map(img => `
|
||||||
|
<div class="col-md-2 col-sm-3 col-4 mb-3">
|
||||||
|
<div class="card h-100">
|
||||||
|
<img src="${img.path}" class="card-img-top" style="height: 100px; object-fit: cover;" alt="${img.name}">
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<small class="card-title text-truncate d-block">${img.name}</small>
|
||||||
|
<small class="text-muted">${img.folder}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Загружаем изображения при загрузке страницы
|
||||||
|
document.addEventListener('DOMContentLoaded', loadImageList);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
src/app.js
31
src/app.js
@@ -7,6 +7,7 @@ import compression from 'compression';
|
|||||||
import morgan from 'morgan';
|
import morgan from 'morgan';
|
||||||
import methodOverride from 'method-override';
|
import methodOverride from 'method-override';
|
||||||
import formatters from './helpers/formatters.js';
|
import formatters from './helpers/formatters.js';
|
||||||
|
import SiteSettingsHelper from './helpers/site-settings.js';
|
||||||
import { adminJs, router as adminRouter } from './config/adminjs-simple.js';
|
import { adminJs, router as adminRouter } from './config/adminjs-simple.js';
|
||||||
import { fileURLToPath } from 'url';
|
import { fileURLToPath } from 'url';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
@@ -42,7 +43,7 @@ app.use(helmet({
|
|||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com"],
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"],
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"],
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://code.jquery.com"],
|
scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'", "https://cdn.jsdelivr.net", "https://cdnjs.cloudflare.com", "https://code.jquery.com"],
|
||||||
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
imgSrc: ["'self'", "data:", "https:", "blob:"],
|
||||||
connectSrc: ["'self'"],
|
connectSrc: ["'self'"],
|
||||||
},
|
},
|
||||||
@@ -76,8 +77,8 @@ app.use(session({
|
|||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, '../views'));
|
app.set('views', path.join(__dirname, '../views'));
|
||||||
|
|
||||||
// Global template variables
|
// Global template variables with site settings
|
||||||
app.use((req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство';
|
res.locals.siteName = process.env.SITE_NAME || 'Корея Тур Агентство';
|
||||||
res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи';
|
res.locals.siteDescription = process.env.SITE_DESCRIPTION || 'Откройте для себя красоту Кореи';
|
||||||
res.locals.user = req.session.user || null;
|
res.locals.user = req.session.user || null;
|
||||||
@@ -85,6 +86,14 @@ app.use((req, res, next) => {
|
|||||||
res.locals.currentPath = req.path;
|
res.locals.currentPath = req.path;
|
||||||
res.locals.page = 'home'; // default page
|
res.locals.page = 'home'; // default page
|
||||||
|
|
||||||
|
// Load site settings for templates
|
||||||
|
try {
|
||||||
|
res.locals.siteSettings = await SiteSettingsHelper.getAllSettings();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading site settings for templates:', error);
|
||||||
|
res.locals.siteSettings = {};
|
||||||
|
}
|
||||||
|
|
||||||
// Add all helper functions to template globals
|
// Add all helper functions to template globals
|
||||||
Object.assign(res.locals, formatters);
|
Object.assign(res.locals, formatters);
|
||||||
|
|
||||||
@@ -148,7 +157,10 @@ const toursRouter = (await import('./routes/tours.js')).default;
|
|||||||
const guidesRouter = (await import('./routes/guides.js')).default;
|
const guidesRouter = (await import('./routes/guides.js')).default;
|
||||||
const articlesRouter = (await import('./routes/articles.js')).default;
|
const articlesRouter = (await import('./routes/articles.js')).default;
|
||||||
const apiRouter = (await import('./routes/api.js')).default;
|
const apiRouter = (await import('./routes/api.js')).default;
|
||||||
|
const settingsRouter = (await import('./routes/settings.js')).default;
|
||||||
const ratingsRouter = (await import('./routes/ratings.js')).default;
|
const ratingsRouter = (await import('./routes/ratings.js')).default;
|
||||||
|
const imagesRouter = (await import('./routes/images.js')).default;
|
||||||
|
const crudRouter = (await import('./routes/crud.js')).default;
|
||||||
|
|
||||||
app.use('/', indexRouter);
|
app.use('/', indexRouter);
|
||||||
app.use('/routes', toursRouter);
|
app.use('/routes', toursRouter);
|
||||||
@@ -156,6 +168,9 @@ app.use('/guides', guidesRouter);
|
|||||||
app.use('/articles', articlesRouter);
|
app.use('/articles', articlesRouter);
|
||||||
app.use('/api', apiRouter);
|
app.use('/api', apiRouter);
|
||||||
app.use('/api', ratingsRouter);
|
app.use('/api', ratingsRouter);
|
||||||
|
app.use('/', settingsRouter); // Settings routes (CSS and API)
|
||||||
|
app.use('/api/images', imagesRouter); // Image management routes
|
||||||
|
app.use('/api/crud', crudRouter); // CRUD API routes
|
||||||
|
|
||||||
// Health check endpoint
|
// Health check endpoint
|
||||||
app.get('/health', (req, res) => {
|
app.get('/health', (req, res) => {
|
||||||
@@ -166,6 +181,16 @@ app.get('/health', (req, res) => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Test image editor endpoint
|
||||||
|
app.get('/test-editor', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../public/test-editor.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Image system documentation
|
||||||
|
app.get('/image-docs', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, '../public/image-system-docs.html'));
|
||||||
|
});
|
||||||
|
|
||||||
// Error handling
|
// Error handling
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).render('error', {
|
res.status(404).render('error', {
|
||||||
|
|||||||
271
src/components/ImageEditor.jsx
Normal file
271
src/components/ImageEditor.jsx
Normal file
@@ -0,0 +1,271 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
|
||||||
|
const ImageEditor = ({ record, property, onChange }) => {
|
||||||
|
const [currentValue, setCurrentValue] = useState(record.params[property.name] || '');
|
||||||
|
const [showEditor, setShowEditor] = useState(false);
|
||||||
|
const [images, setImages] = useState([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [activeTab, setActiveTab] = useState('gallery');
|
||||||
|
const [uploadFile, setUploadFile] = useState(null);
|
||||||
|
|
||||||
|
// Загрузка галереи изображений
|
||||||
|
useEffect(() => {
|
||||||
|
if (showEditor) {
|
||||||
|
loadGallery();
|
||||||
|
}
|
||||||
|
}, [showEditor]);
|
||||||
|
|
||||||
|
const loadGallery = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/gallery?folder=all');
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
setImages(result.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading gallery:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageSelect = (imagePath) => {
|
||||||
|
setCurrentValue(imagePath);
|
||||||
|
onChange(property.name, imagePath);
|
||||||
|
setShowEditor(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleFileUpload = async () => {
|
||||||
|
if (!uploadFile) return;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', uploadFile);
|
||||||
|
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/images/upload', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
if (result.success) {
|
||||||
|
handleImageSelect(result.data.path);
|
||||||
|
await loadGallery(); // Обновляем галерею
|
||||||
|
} else {
|
||||||
|
alert('Ошибка загрузки: ' + result.error);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Ошибка загрузки: ' + error.message);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFolderFromPropertyName = () => {
|
||||||
|
const name = property.name.toLowerCase();
|
||||||
|
if (name.includes('route')) return 'routes';
|
||||||
|
if (name.includes('guide')) return 'guides';
|
||||||
|
if (name.includes('article')) return 'articles';
|
||||||
|
return 'general';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<label style={{ fontWeight: 'bold', marginBottom: '8px', display: 'block' }}>
|
||||||
|
{property.label || property.name}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{/* Поле ввода и кнопка */}
|
||||||
|
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '12px' }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={currentValue}
|
||||||
|
onChange={(e) => {
|
||||||
|
setCurrentValue(e.target.value);
|
||||||
|
onChange(property.name, e.target.value);
|
||||||
|
}}
|
||||||
|
placeholder="Путь к изображению"
|
||||||
|
style={{
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginRight: '8px'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowEditor(!showEditor)}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{showEditor ? 'Закрыть' : 'Выбрать'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Предварительный просмотр */}
|
||||||
|
{currentValue && (
|
||||||
|
<div style={{ marginBottom: '12px' }}>
|
||||||
|
<img
|
||||||
|
src={currentValue}
|
||||||
|
alt="Preview"
|
||||||
|
style={{
|
||||||
|
maxWidth: '200px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px'
|
||||||
|
}}
|
||||||
|
onError={(e) => {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Редактор изображений */}
|
||||||
|
{showEditor && (
|
||||||
|
<div style={{
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '16px',
|
||||||
|
backgroundColor: '#f9f9f9'
|
||||||
|
}}>
|
||||||
|
{/* Вкладки */}
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('gallery')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
marginRight: '8px',
|
||||||
|
backgroundColor: activeTab === 'gallery' ? '#007bff' : '#f8f9fa',
|
||||||
|
color: activeTab === 'gallery' ? 'white' : '#495057',
|
||||||
|
border: '1px solid #dee2e6',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Галерея
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setActiveTab('upload')}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: activeTab === 'upload' ? '#007bff' : '#f8f9fa',
|
||||||
|
color: activeTab === 'upload' ? 'white' : '#495057',
|
||||||
|
border: '1px solid #dee2e6',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Загрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Контент вкладки Галерея */}
|
||||||
|
{activeTab === 'gallery' && (
|
||||||
|
<div>
|
||||||
|
{loading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '20px' }}>
|
||||||
|
Загрузка...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div style={{
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: 'repeat(auto-fill, minmax(150px, 1fr))',
|
||||||
|
gap: '12px',
|
||||||
|
maxHeight: '400px',
|
||||||
|
overflowY: 'auto'
|
||||||
|
}}>
|
||||||
|
{images.map((image) => (
|
||||||
|
<div
|
||||||
|
key={image.path}
|
||||||
|
onClick={() => handleImageSelect(image.path)}
|
||||||
|
style={{
|
||||||
|
cursor: 'pointer',
|
||||||
|
border: currentValue === image.path ? '2px solid #007bff' : '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
overflow: 'hidden',
|
||||||
|
backgroundColor: 'white'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={image.path}
|
||||||
|
alt={image.name}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '120px',
|
||||||
|
objectFit: 'cover'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div style={{ padding: '8px', fontSize: '12px' }}>
|
||||||
|
<div style={{ fontWeight: 'bold', marginBottom: '4px' }}>
|
||||||
|
{image.name}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: '#666' }}>
|
||||||
|
{image.folder}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Контент вкладки Загрузить */}
|
||||||
|
{activeTab === 'upload' && (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={(e) => setUploadFile(e.target.files[0])}
|
||||||
|
style={{ marginBottom: '12px' }}
|
||||||
|
/>
|
||||||
|
<div style={{ fontSize: '14px', color: '#666' }}>
|
||||||
|
Поддерживаются: JPG, PNG, GIF (макс. 5МБ)
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadFile && (
|
||||||
|
<div style={{ marginBottom: '16px' }}>
|
||||||
|
<div style={{ fontSize: '14px', marginBottom: '8px' }}>
|
||||||
|
Выбран файл: {uploadFile.name}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleFileUpload}
|
||||||
|
disabled={loading}
|
||||||
|
style={{
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#28a745',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: loading ? 'not-allowed' : 'pointer'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loading ? 'Загрузка...' : 'Загрузить'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageEditor;
|
||||||
130
src/components/ImageSelector.jsx
Normal file
130
src/components/ImageSelector.jsx
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// Простой компонент для выбора изображений, который работает с AdminJS
|
||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const ImageSelector = ({ record, property, onChange }) => {
|
||||||
|
const currentValue = record.params[property.name] || '';
|
||||||
|
|
||||||
|
const openImagePicker = () => {
|
||||||
|
// Создаем модальное окно с iframe для редактора изображений
|
||||||
|
const modal = document.createElement('div');
|
||||||
|
modal.style.cssText = `
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0,0,0,0.8);
|
||||||
|
z-index: 10000;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const content = document.createElement('div');
|
||||||
|
content.style.cssText = `
|
||||||
|
background: white;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 90%;
|
||||||
|
height: 90%;
|
||||||
|
position: relative;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const closeBtn = document.createElement('button');
|
||||||
|
closeBtn.textContent = '✕';
|
||||||
|
closeBtn.style.cssText = `
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: #ff4757;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 1;
|
||||||
|
`;
|
||||||
|
|
||||||
|
const iframe = document.createElement('iframe');
|
||||||
|
iframe.src = `/image-editor.html?field=${property.name}¤t=${encodeURIComponent(currentValue)}`;
|
||||||
|
iframe.style.cssText = `
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
`;
|
||||||
|
|
||||||
|
closeBtn.onclick = () => document.body.removeChild(modal);
|
||||||
|
|
||||||
|
// Слушаем сообщения от iframe
|
||||||
|
const handleMessage = (event) => {
|
||||||
|
if (event.origin !== window.location.origin) return;
|
||||||
|
|
||||||
|
if (event.data.type === 'imageSelected' && event.data.targetField === property.name) {
|
||||||
|
onChange(property.name, event.data.path);
|
||||||
|
document.body.removeChild(modal);
|
||||||
|
window.removeEventListener('message', handleMessage);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('message', handleMessage);
|
||||||
|
|
||||||
|
content.appendChild(closeBtn);
|
||||||
|
content.appendChild(iframe);
|
||||||
|
modal.appendChild(content);
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
};
|
||||||
|
|
||||||
|
return React.createElement('div', null,
|
||||||
|
React.createElement('label', {
|
||||||
|
style: { fontWeight: 'bold', marginBottom: '8px', display: 'block' }
|
||||||
|
}, property.label || property.name),
|
||||||
|
|
||||||
|
React.createElement('div', {
|
||||||
|
style: { display: 'flex', alignItems: 'center', marginBottom: '12px' }
|
||||||
|
},
|
||||||
|
React.createElement('input', {
|
||||||
|
type: 'text',
|
||||||
|
value: currentValue,
|
||||||
|
onChange: (e) => onChange(property.name, e.target.value),
|
||||||
|
placeholder: 'Путь к изображению',
|
||||||
|
style: {
|
||||||
|
flex: 1,
|
||||||
|
padding: '8px 12px',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px',
|
||||||
|
marginRight: '8px'
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
React.createElement('button', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: openImagePicker,
|
||||||
|
style: {
|
||||||
|
padding: '8px 16px',
|
||||||
|
backgroundColor: '#007bff',
|
||||||
|
color: 'white',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '4px',
|
||||||
|
cursor: 'pointer'
|
||||||
|
}
|
||||||
|
}, 'Выбрать')
|
||||||
|
),
|
||||||
|
|
||||||
|
currentValue && React.createElement('img', {
|
||||||
|
src: currentValue,
|
||||||
|
alt: 'Preview',
|
||||||
|
style: {
|
||||||
|
maxWidth: '200px',
|
||||||
|
maxHeight: '200px',
|
||||||
|
objectFit: 'cover',
|
||||||
|
border: '1px solid #ddd',
|
||||||
|
borderRadius: '4px'
|
||||||
|
},
|
||||||
|
onError: (e) => {
|
||||||
|
e.target.style.display = 'none';
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ImageSelector;
|
||||||
@@ -1,9 +1,16 @@
|
|||||||
import AdminJS from 'adminjs';
|
import AdminJS from 'adminjs';
|
||||||
import AdminJSExpress from '@adminjs/express';
|
import AdminJSExpress from '@adminjs/express';
|
||||||
import AdminJSSequelize from '@adminjs/sequelize';
|
import AdminJSSequelize from '@adminjs/sequelize';
|
||||||
|
import uploadFeature from '@adminjs/upload';
|
||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import pkg from 'pg';
|
import pkg from 'pg';
|
||||||
import { Sequelize, DataTypes } from 'sequelize';
|
import { Sequelize, DataTypes } from 'sequelize';
|
||||||
|
import path from 'path';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
const { Pool } = pkg;
|
const { Pool } = pkg;
|
||||||
|
|
||||||
// Регистрируем адаптер Sequelize
|
// Регистрируем адаптер Sequelize
|
||||||
@@ -42,6 +49,7 @@ const Routes = sequelize.define('routes', {
|
|||||||
price: { type: DataTypes.DECIMAL(10, 2) },
|
price: { type: DataTypes.DECIMAL(10, 2) },
|
||||||
duration: { type: DataTypes.INTEGER },
|
duration: { type: DataTypes.INTEGER },
|
||||||
max_group_size: { type: DataTypes.INTEGER },
|
max_group_size: { type: DataTypes.INTEGER },
|
||||||
|
image_url: { type: DataTypes.STRING },
|
||||||
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
|
is_featured: { type: DataTypes.BOOLEAN, defaultValue: false },
|
||||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
||||||
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
|
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW },
|
||||||
@@ -60,6 +68,7 @@ const Guides = sequelize.define('guides', {
|
|||||||
specialization: { type: DataTypes.ENUM('city', 'mountain', 'fishing', 'general') },
|
specialization: { type: DataTypes.ENUM('city', 'mountain', 'fishing', 'general') },
|
||||||
bio: { type: DataTypes.TEXT },
|
bio: { type: DataTypes.TEXT },
|
||||||
experience: { type: DataTypes.INTEGER },
|
experience: { type: DataTypes.INTEGER },
|
||||||
|
image_url: { type: DataTypes.STRING },
|
||||||
hourly_rate: { type: DataTypes.DECIMAL(10, 2) },
|
hourly_rate: { type: DataTypes.DECIMAL(10, 2) },
|
||||||
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
is_active: { type: DataTypes.BOOLEAN, defaultValue: true },
|
||||||
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
|
created_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
|
||||||
@@ -187,6 +196,20 @@ const Holidays = sequelize.define('holidays', {
|
|||||||
tableName: 'holidays'
|
tableName: 'holidays'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Модель настроек сайта
|
||||||
|
const SiteSettings = sequelize.define('site_settings', {
|
||||||
|
id: { type: DataTypes.INTEGER, primaryKey: true, autoIncrement: true },
|
||||||
|
setting_key: { type: DataTypes.STRING, allowNull: false, unique: true },
|
||||||
|
setting_value: { type: DataTypes.TEXT },
|
||||||
|
setting_type: { type: DataTypes.ENUM('text', 'number', 'boolean', 'json', 'color', 'file'), defaultValue: 'text' },
|
||||||
|
description: { type: DataTypes.TEXT },
|
||||||
|
category: { type: DataTypes.STRING, defaultValue: 'general' },
|
||||||
|
updated_at: { type: DataTypes.DATE, defaultValue: DataTypes.NOW }
|
||||||
|
}, {
|
||||||
|
timestamps: false,
|
||||||
|
tableName: 'site_settings'
|
||||||
|
});
|
||||||
|
|
||||||
// Определение связей между моделями
|
// Определение связей между моделями
|
||||||
Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' });
|
Guides.hasOne(GuideSchedules, { foreignKey: 'guide_id' });
|
||||||
GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
|
GuideSchedules.belongsTo(Guides, { foreignKey: 'guide_id' });
|
||||||
@@ -222,8 +245,8 @@ const adminJsOptions = {
|
|||||||
options: {
|
options: {
|
||||||
parent: { name: 'Контент', icon: 'DocumentText' },
|
parent: { name: 'Контент', icon: 'DocumentText' },
|
||||||
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
|
listProperties: ['id', 'title', 'type', 'price', 'duration', 'is_active', 'created_at'],
|
||||||
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active'],
|
editProperties: ['title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active'],
|
||||||
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'is_featured', 'is_active', 'created_at', 'updated_at'],
|
showProperties: ['id', 'title', 'description', 'content', 'type', 'difficulty_level', 'price', 'duration', 'max_group_size', 'image_url', 'is_featured', 'is_active', 'created_at', 'updated_at'],
|
||||||
filterProperties: ['title', 'type', 'is_active'],
|
filterProperties: ['title', 'type', 'is_active'],
|
||||||
properties: {
|
properties: {
|
||||||
title: {
|
title: {
|
||||||
@@ -263,6 +286,10 @@ const adminJsOptions = {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
isRequired: true,
|
isRequired: true,
|
||||||
},
|
},
|
||||||
|
image_url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Изображение тура. Кнопка "Выбрать" будет добавлена автоматически'
|
||||||
|
},
|
||||||
is_featured: { type: 'boolean' },
|
is_featured: { type: 'boolean' },
|
||||||
is_active: { type: 'boolean' },
|
is_active: { type: 'boolean' },
|
||||||
created_at: {
|
created_at: {
|
||||||
@@ -279,8 +306,8 @@ const adminJsOptions = {
|
|||||||
options: {
|
options: {
|
||||||
parent: { name: 'Персонал', icon: 'Users' },
|
parent: { name: 'Персонал', icon: 'Users' },
|
||||||
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
|
listProperties: ['id', 'name', 'email', 'specialization', 'experience', 'hourly_rate', 'is_active'],
|
||||||
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active'],
|
editProperties: ['name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active'],
|
||||||
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'hourly_rate', 'is_active', 'created_at'],
|
showProperties: ['id', 'name', 'email', 'phone', 'languages', 'specialization', 'bio', 'experience', 'image_url', 'hourly_rate', 'is_active', 'created_at'],
|
||||||
filterProperties: ['name', 'specialization', 'is_active'],
|
filterProperties: ['name', 'specialization', 'is_active'],
|
||||||
properties: {
|
properties: {
|
||||||
name: {
|
name: {
|
||||||
@@ -309,6 +336,10 @@ const adminJsOptions = {
|
|||||||
type: 'number',
|
type: 'number',
|
||||||
description: 'Опыт работы в годах',
|
description: 'Опыт работы в годах',
|
||||||
},
|
},
|
||||||
|
image_url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Фотография гида. Кнопка "Выбрать" будет добавлена автоматически'
|
||||||
|
},
|
||||||
hourly_rate: {
|
hourly_rate: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
description: 'Ставка за час в вонах',
|
description: 'Ставка за час в вонах',
|
||||||
@@ -325,7 +356,7 @@ const adminJsOptions = {
|
|||||||
options: {
|
options: {
|
||||||
parent: { name: 'Контент', icon: 'DocumentText' },
|
parent: { name: 'Контент', icon: 'DocumentText' },
|
||||||
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
|
listProperties: ['id', 'title', 'category', 'is_published', 'views', 'created_at'],
|
||||||
editProperties: ['title', 'excerpt', 'content', 'category', 'is_published'],
|
editProperties: ['title', 'excerpt', 'content', 'category', 'image_url', 'is_published'],
|
||||||
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
|
showProperties: ['id', 'title', 'excerpt', 'content', 'category', 'is_published', 'views', 'created_at', 'updated_at'],
|
||||||
filterProperties: ['title', 'category', 'is_published'],
|
filterProperties: ['title', 'category', 'is_published'],
|
||||||
properties: {
|
properties: {
|
||||||
@@ -350,6 +381,10 @@ const adminJsOptions = {
|
|||||||
{ value: 'history', label: 'История' }
|
{ value: 'history', label: 'История' }
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
image_url: {
|
||||||
|
type: 'string',
|
||||||
|
description: 'Изображение статьи. Кнопка "Выбрать" будет добавлена автоматически'
|
||||||
|
},
|
||||||
is_published: { type: 'boolean' },
|
is_published: { type: 'boolean' },
|
||||||
views: {
|
views: {
|
||||||
type: 'number',
|
type: 'number',
|
||||||
@@ -633,6 +668,58 @@ const adminJsOptions = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
resource: SiteSettings,
|
||||||
|
options: {
|
||||||
|
parent: { name: 'Администрирование', icon: 'Settings' },
|
||||||
|
listProperties: ['id', 'setting_key', 'setting_value', 'category', 'updated_at'],
|
||||||
|
editProperties: ['setting_key', 'setting_value', 'setting_type', 'description', 'category'],
|
||||||
|
showProperties: ['id', 'setting_key', 'setting_value', 'setting_type', 'description', 'category', 'updated_at'],
|
||||||
|
filterProperties: ['setting_key', 'category', 'setting_type'],
|
||||||
|
properties: {
|
||||||
|
setting_key: {
|
||||||
|
isTitle: true,
|
||||||
|
isRequired: true,
|
||||||
|
description: 'Уникальный ключ настройки (например: primary_color, hero_background_url)'
|
||||||
|
},
|
||||||
|
setting_value: {
|
||||||
|
type: 'textarea',
|
||||||
|
isRequired: true,
|
||||||
|
description: 'Значение настройки (цвет в HEX, URL изображения, текст и т.д.)'
|
||||||
|
},
|
||||||
|
setting_type: {
|
||||||
|
availableValues: [
|
||||||
|
{ value: 'text', label: 'Текст' },
|
||||||
|
{ value: 'number', label: 'Число' },
|
||||||
|
{ value: 'boolean', label: 'Да/Нет' },
|
||||||
|
{ value: 'json', label: 'JSON' },
|
||||||
|
{ value: 'color', label: 'Цвет (HEX)' },
|
||||||
|
{ value: 'file', label: 'Файл/URL' }
|
||||||
|
],
|
||||||
|
isRequired: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: 'textarea',
|
||||||
|
description: 'Описание назначения этой настройки'
|
||||||
|
},
|
||||||
|
category: {
|
||||||
|
availableValues: [
|
||||||
|
{ value: 'general', label: 'Общие' },
|
||||||
|
{ value: 'theme', label: 'Тема и стили' },
|
||||||
|
{ value: 'colors', label: 'Цвета' },
|
||||||
|
{ value: 'typography', label: 'Типографика' },
|
||||||
|
{ value: 'images', label: 'Изображения' },
|
||||||
|
{ value: 'effects', label: 'Эффекты' },
|
||||||
|
{ value: 'layout', label: 'Макет' }
|
||||||
|
],
|
||||||
|
defaultValue: 'general'
|
||||||
|
},
|
||||||
|
updated_at: {
|
||||||
|
isVisible: { list: true, filter: true, show: true, edit: false },
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
rootPath: '/admin',
|
rootPath: '/admin',
|
||||||
@@ -659,10 +746,15 @@ const adminJsOptions = {
|
|||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
component: false
|
component: false
|
||||||
|
},
|
||||||
|
assets: {
|
||||||
|
styles: ['/css/admin-custom.css'],
|
||||||
|
scripts: ['/js/admin-image-selector-fixed.js']
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Создаем экземпляр AdminJS
|
// Создаем экземпляр AdminJS с componentLoader
|
||||||
|
// Создание AdminJS с конфигурацией
|
||||||
const adminJs = new AdminJS(adminJsOptions);
|
const adminJs = new AdminJS(adminJsOptions);
|
||||||
|
|
||||||
// Настраиваем аутентификацию
|
// Настраиваем аутентификацию
|
||||||
|
|||||||
190
src/helpers/site-settings.js
Normal file
190
src/helpers/site-settings.js
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import db from '../config/database.js';
|
||||||
|
|
||||||
|
class SiteSettingsHelper {
|
||||||
|
static cache = new Map();
|
||||||
|
static lastUpdate = null;
|
||||||
|
static cacheTimeout = 5 * 60 * 1000; // 5 минут
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить все настройки сайта с кешированием
|
||||||
|
*/
|
||||||
|
static async getAllSettings() {
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
// Проверяем кеш
|
||||||
|
if (this.lastUpdate && (now - this.lastUpdate) < this.cacheTimeout && this.cache.size > 0) {
|
||||||
|
return Object.fromEntries(this.cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await db.query('SELECT setting_key, setting_value, setting_type FROM site_settings ORDER BY category, setting_key');
|
||||||
|
|
||||||
|
// Очищаем кеш и заполняем новыми данными
|
||||||
|
this.cache.clear();
|
||||||
|
|
||||||
|
for (const row of result.rows) {
|
||||||
|
let value = row.setting_value;
|
||||||
|
|
||||||
|
// Преобразуем значение в зависимости от типа
|
||||||
|
switch (row.setting_type) {
|
||||||
|
case 'number':
|
||||||
|
value = parseFloat(value) || 0;
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
value = value === 'true' || value === '1';
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
value = {};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.cache.set(row.setting_key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.lastUpdate = now;
|
||||||
|
return Object.fromEntries(this.cache);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading site settings:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить настройку по ключу
|
||||||
|
*/
|
||||||
|
static async getSetting(key, defaultValue = null) {
|
||||||
|
const settings = await this.getAllSettings();
|
||||||
|
return settings[key] !== undefined ? settings[key] : defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Установить настройку
|
||||||
|
*/
|
||||||
|
static async setSetting(key, value, type = 'text', description = '', category = 'general') {
|
||||||
|
try {
|
||||||
|
await db.query(`
|
||||||
|
INSERT INTO site_settings (setting_key, setting_value, setting_type, description, category, updated_at)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, NOW())
|
||||||
|
ON CONFLICT (setting_key)
|
||||||
|
DO UPDATE SET
|
||||||
|
setting_value = EXCLUDED.setting_value,
|
||||||
|
setting_type = EXCLUDED.setting_type,
|
||||||
|
description = EXCLUDED.description,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
updated_at = NOW()
|
||||||
|
`, [key, String(value), type, description, category]);
|
||||||
|
|
||||||
|
// Обновляем кеш
|
||||||
|
this.cache.set(key, value);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error setting site setting:', error);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получить настройки по категории
|
||||||
|
*/
|
||||||
|
static async getSettingsByCategory(category) {
|
||||||
|
try {
|
||||||
|
const result = await db.query(
|
||||||
|
'SELECT setting_key, setting_value, setting_type FROM site_settings WHERE category = $1 ORDER BY setting_key',
|
||||||
|
[category]
|
||||||
|
);
|
||||||
|
|
||||||
|
const settings = {};
|
||||||
|
for (const row of result.rows) {
|
||||||
|
let value = row.setting_value;
|
||||||
|
|
||||||
|
switch (row.setting_type) {
|
||||||
|
case 'number':
|
||||||
|
value = parseFloat(value) || 0;
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
value = value === 'true' || value === '1';
|
||||||
|
break;
|
||||||
|
case 'json':
|
||||||
|
try {
|
||||||
|
value = JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
value = {};
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
settings[row.setting_key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return settings;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading settings by category:', error);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Генерировать CSS переменные из настроек
|
||||||
|
*/
|
||||||
|
static async generateCSSVariables() {
|
||||||
|
const settings = await this.getAllSettings();
|
||||||
|
|
||||||
|
let css = ':root {\n';
|
||||||
|
|
||||||
|
// Цвета
|
||||||
|
if (settings.primary_color) css += ` --primary-color: ${settings.primary_color};\n`;
|
||||||
|
if (settings.secondary_color) css += ` --secondary-color: ${settings.secondary_color};\n`;
|
||||||
|
if (settings.accent_color) css += ` --accent-color: ${settings.accent_color};\n`;
|
||||||
|
if (settings.text_color) css += ` --text-color: ${settings.text_color};\n`;
|
||||||
|
if (settings.background_color) css += ` --background-color: ${settings.background_color};\n`;
|
||||||
|
if (settings.card_background) css += ` --card-background: ${settings.card_background};\n`;
|
||||||
|
|
||||||
|
// Типографика
|
||||||
|
if (settings.font_family_primary) css += ` --font-family-primary: ${settings.font_family_primary};\n`;
|
||||||
|
if (settings.font_family_display) css += ` --font-family-display: ${settings.font_family_display};\n`;
|
||||||
|
if (settings.font_size_base) css += ` --font-size-base: ${settings.font_size_base}px;\n`;
|
||||||
|
if (settings.line_height_base) css += ` --line-height-base: ${settings.line_height_base};\n`;
|
||||||
|
|
||||||
|
// Эффекты
|
||||||
|
if (settings.hero_overlay_opacity) css += ` --hero-overlay-opacity: ${settings.hero_overlay_opacity};\n`;
|
||||||
|
if (settings.hero_overlay_color) css += ` --hero-overlay-color: ${settings.hero_overlay_color};\n`;
|
||||||
|
if (settings.card_shadow) css += ` --card-shadow: ${settings.card_shadow};\n`;
|
||||||
|
if (settings.border_radius) css += ` --border-radius: ${settings.border_radius}px;\n`;
|
||||||
|
if (settings.blur_effect) css += ` --blur-effect: ${settings.blur_effect}px;\n`;
|
||||||
|
|
||||||
|
// Макет
|
||||||
|
if (settings.hero_height_desktop) css += ` --hero-height-desktop: ${settings.hero_height_desktop}vh;\n`;
|
||||||
|
if (settings.hero_height_mobile) css += ` --hero-height-mobile: ${settings.hero_height_mobile}vh;\n`;
|
||||||
|
if (settings.compact_hero_height) css += ` --compact-hero-height: ${settings.compact_hero_height}vh;\n`;
|
||||||
|
if (settings.container_max_width) css += ` --container-max-width: ${settings.container_max_width}px;\n`;
|
||||||
|
if (settings.navbar_height) css += ` --navbar-height: ${settings.navbar_height}px;\n`;
|
||||||
|
|
||||||
|
// Анимации
|
||||||
|
if (settings.animation_duration) css += ` --animation-duration: ${settings.animation_duration}s;\n`;
|
||||||
|
|
||||||
|
css += '}\n';
|
||||||
|
|
||||||
|
// Добавляем пользовательский CSS
|
||||||
|
if (settings.custom_css) {
|
||||||
|
css += '\n/* Пользовательский CSS */\n' + settings.custom_css + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
return css;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистить кеш настроек
|
||||||
|
*/
|
||||||
|
static clearCache() {
|
||||||
|
this.cache.clear();
|
||||||
|
this.lastUpdate = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default SiteSettingsHelper;
|
||||||
653
src/routes/crud.js
Normal file
653
src/routes/crud.js
Normal file
@@ -0,0 +1,653 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import { Op } from 'sequelize';
|
||||||
|
import db from '../config/database.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Middleware для логирования
|
||||||
|
const logRequest = (entity) => (req, res, next) => {
|
||||||
|
console.log(`${new Date().toISOString()} - ${req.method} /api/crud/${entity} - ${JSON.stringify(req.body || req.query).slice(0, 100)}`);
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Общая функция для обработки ошибок
|
||||||
|
const handleError = (res, error, operation = 'operation') => {
|
||||||
|
console.error(`CRUD ${operation} error:`, error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: `Ошибка ${operation}: ${error.message}`
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
// Общая функция для валидации ID
|
||||||
|
const validateId = (id) => {
|
||||||
|
const numId = parseInt(id);
|
||||||
|
if (isNaN(numId) || numId < 1) {
|
||||||
|
throw new Error('Некорректный ID');
|
||||||
|
}
|
||||||
|
return numId;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ROUTES CRUD
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GET /api/crud/routes - получить все маршруты
|
||||||
|
router.get('/routes', logRequest('routes'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
type,
|
||||||
|
difficulty_level,
|
||||||
|
is_active = 'all',
|
||||||
|
search,
|
||||||
|
sort_by = 'id',
|
||||||
|
sort_order = 'ASC'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
// Фильтры
|
||||||
|
if (type) where.type = type;
|
||||||
|
if (difficulty_level) where.difficulty_level = difficulty_level;
|
||||||
|
if (is_active !== 'all') where.is_active = is_active === 'true';
|
||||||
|
if (search) {
|
||||||
|
where[Op.or] = [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } }
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const order = [[sort_by, sort_order.toUpperCase()]];
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
id, title, description, type, difficulty_level,
|
||||||
|
price, duration, max_group_size, image_url,
|
||||||
|
is_featured, is_active, created_at, updated_at
|
||||||
|
FROM routes
|
||||||
|
WHERE ($1::text IS NULL OR type = $1)
|
||||||
|
AND ($2::text IS NULL OR difficulty_level = $2)
|
||||||
|
AND ($3::boolean IS NULL OR is_active = $3)
|
||||||
|
AND ($4::text IS NULL OR (title ILIKE $4 OR description ILIKE $4))
|
||||||
|
ORDER BY ${sort_by} ${sort_order.toUpperCase()}
|
||||||
|
LIMIT $5 OFFSET $6
|
||||||
|
`, [
|
||||||
|
type || null,
|
||||||
|
difficulty_level || null,
|
||||||
|
is_active === 'all' ? null : (is_active === 'true'),
|
||||||
|
search ? `%${search}%` : null,
|
||||||
|
parseInt(limit),
|
||||||
|
offset
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countResult = await db.query(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM routes
|
||||||
|
WHERE ($1::text IS NULL OR type = $1)
|
||||||
|
AND ($2::text IS NULL OR difficulty_level = $2)
|
||||||
|
AND ($3::boolean IS NULL OR is_active = $3)
|
||||||
|
AND ($4::text IS NULL OR (title ILIKE $4 OR description ILIKE $4))
|
||||||
|
`, [
|
||||||
|
type || null,
|
||||||
|
difficulty_level || null,
|
||||||
|
is_active === 'all' ? null : (is_active === 'true'),
|
||||||
|
search ? `%${search}%` : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
const totalPages = Math.ceil(total / parseInt(limit));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения маршрутов');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/crud/routes/:id - получить маршрут по ID
|
||||||
|
router.get('/routes/:id', logRequest('routes'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT * FROM routes WHERE id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Маршрут не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения маршрута');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/crud/routes - создать новый маршрут
|
||||||
|
router.post('/routes', logRequest('routes'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title, description, content, type, difficulty_level,
|
||||||
|
price, duration, max_group_size, image_url,
|
||||||
|
is_featured = false, is_active = true
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Валидация обязательных полей
|
||||||
|
if (!title || !type || !price || !duration || !max_group_size) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Отсутствуют обязательные поля: title, type, price, duration, max_group_size'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
INSERT INTO routes (
|
||||||
|
title, description, content, type, difficulty_level,
|
||||||
|
price, duration, max_group_size, image_url,
|
||||||
|
is_featured, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
title, description, content, type, difficulty_level,
|
||||||
|
price, duration, max_group_size, image_url,
|
||||||
|
is_featured, is_active
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'создания маршрута');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/crud/routes/:id - обновить маршрут
|
||||||
|
router.put('/routes/:id', logRequest('routes'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
const {
|
||||||
|
title, description, content, type, difficulty_level,
|
||||||
|
price, duration, max_group_size, image_url,
|
||||||
|
is_featured, is_active
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
UPDATE routes SET
|
||||||
|
title = COALESCE($1, title),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
content = COALESCE($3, content),
|
||||||
|
type = COALESCE($4, type),
|
||||||
|
difficulty_level = COALESCE($5, difficulty_level),
|
||||||
|
price = COALESCE($6, price),
|
||||||
|
duration = COALESCE($7, duration),
|
||||||
|
max_group_size = COALESCE($8, max_group_size),
|
||||||
|
image_url = COALESCE($9, image_url),
|
||||||
|
is_featured = COALESCE($10, is_featured),
|
||||||
|
is_active = COALESCE($11, is_active),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $12
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
title, description, content, type, difficulty_level,
|
||||||
|
price, duration, max_group_size, image_url,
|
||||||
|
is_featured, is_active, id
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Маршрут не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'обновления маршрута');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/crud/routes/:id - удалить маршрут
|
||||||
|
router.delete('/routes/:id', logRequest('routes'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
DELETE FROM routes WHERE id = $1 RETURNING id, title
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Маршрут не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Маршрут "${result.rows[0].title}" успешно удален`,
|
||||||
|
data: { id: result.rows[0].id }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'удаления маршрута');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GUIDES CRUD
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GET /api/crud/guides - получить всех гидов
|
||||||
|
router.get('/guides', logRequest('guides'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
specialization,
|
||||||
|
is_active = 'all',
|
||||||
|
search,
|
||||||
|
sort_by = 'id',
|
||||||
|
sort_order = 'ASC'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
const where = {};
|
||||||
|
|
||||||
|
if (specialization) where.specialization = specialization;
|
||||||
|
if (is_active !== 'all') where.is_active = is_active === 'true';
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
id, name, email, phone, languages, specialization,
|
||||||
|
bio, experience, image_url, hourly_rate,
|
||||||
|
is_active, created_at, updated_at
|
||||||
|
FROM guides
|
||||||
|
WHERE ($1::text IS NULL OR specialization = $1)
|
||||||
|
AND ($2::boolean IS NULL OR is_active = $2)
|
||||||
|
AND ($3::text IS NULL OR (name ILIKE $3 OR email ILIKE $3 OR bio ILIKE $3))
|
||||||
|
ORDER BY ${sort_by} ${sort_order.toUpperCase()}
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
`, [
|
||||||
|
specialization || null,
|
||||||
|
is_active === 'all' ? null : (is_active === 'true'),
|
||||||
|
search ? `%${search}%` : null,
|
||||||
|
parseInt(limit),
|
||||||
|
offset
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countResult = await db.query(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM guides
|
||||||
|
WHERE ($1::text IS NULL OR specialization = $1)
|
||||||
|
AND ($2::boolean IS NULL OR is_active = $2)
|
||||||
|
AND ($3::text IS NULL OR (name ILIKE $3 OR email ILIKE $3 OR bio ILIKE $3))
|
||||||
|
`, [
|
||||||
|
specialization || null,
|
||||||
|
is_active === 'all' ? null : (is_active === 'true'),
|
||||||
|
search ? `%${search}%` : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
const totalPages = Math.ceil(total / parseInt(limit));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения гидов');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/crud/guides/:id - получить гида по ID
|
||||||
|
router.get('/guides/:id', logRequest('guides'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT * FROM guides WHERE id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Гид не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения гида');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/crud/guides - создать нового гида
|
||||||
|
router.post('/guides', logRequest('guides'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name, email, phone, languages, specialization,
|
||||||
|
bio, experience, image_url, hourly_rate,
|
||||||
|
is_active = true
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Валидация обязательных полей
|
||||||
|
if (!name || !email || !specialization) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Отсутствуют обязательные поля: name, email, specialization'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Преобразовываем languages в массив если передана строка
|
||||||
|
let processedLanguages = languages;
|
||||||
|
if (typeof languages === 'string') {
|
||||||
|
processedLanguages = languages.split(',').map(lang => lang.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
INSERT INTO guides (
|
||||||
|
name, email, phone, languages, specialization,
|
||||||
|
bio, experience, image_url, hourly_rate, is_active
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
name, email, phone, processedLanguages, specialization,
|
||||||
|
bio, experience, image_url, hourly_rate, is_active
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'создания гида');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/crud/guides/:id - обновить гида
|
||||||
|
router.put('/guides/:id', logRequest('guides'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
const {
|
||||||
|
name, email, phone, languages, specialization,
|
||||||
|
bio, experience, image_url, hourly_rate, is_active
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Преобразовываем languages в массив если передана строка
|
||||||
|
let processedLanguages = languages;
|
||||||
|
if (typeof languages === 'string') {
|
||||||
|
processedLanguages = languages.split(',').map(lang => lang.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
UPDATE guides SET
|
||||||
|
name = COALESCE($1, name),
|
||||||
|
email = COALESCE($2, email),
|
||||||
|
phone = COALESCE($3, phone),
|
||||||
|
languages = COALESCE($4, languages),
|
||||||
|
specialization = COALESCE($5, specialization),
|
||||||
|
bio = COALESCE($6, bio),
|
||||||
|
experience = COALESCE($7, experience),
|
||||||
|
image_url = COALESCE($8, image_url),
|
||||||
|
hourly_rate = COALESCE($9, hourly_rate),
|
||||||
|
is_active = COALESCE($10, is_active),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $11
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
name, email, phone, processedLanguages, specialization,
|
||||||
|
bio, experience, image_url, hourly_rate, is_active, id
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Гид не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'обновления гида');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/crud/guides/:id - удалить гида
|
||||||
|
router.delete('/guides/:id', logRequest('guides'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
DELETE FROM guides WHERE id = $1 RETURNING id, name
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Гид не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Гид "${result.rows[0].name}" успешно удален`,
|
||||||
|
data: { id: result.rows[0].id }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'удаления гида');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ARTICLES CRUD
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GET /api/crud/articles - получить все статьи
|
||||||
|
router.get('/articles', logRequest('articles'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
page = 1,
|
||||||
|
limit = 10,
|
||||||
|
category,
|
||||||
|
is_published = 'all',
|
||||||
|
search,
|
||||||
|
sort_by = 'id',
|
||||||
|
sort_order = 'DESC'
|
||||||
|
} = req.query;
|
||||||
|
|
||||||
|
const offset = (parseInt(page) - 1) * parseInt(limit);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
id, title, excerpt, content, category,
|
||||||
|
image_url, author_id, is_published,
|
||||||
|
created_at, updated_at
|
||||||
|
FROM articles
|
||||||
|
WHERE ($1::text IS NULL OR category = $1)
|
||||||
|
AND ($2::boolean IS NULL OR is_published = $2)
|
||||||
|
AND ($3::text IS NULL OR (title ILIKE $3 OR excerpt ILIKE $3 OR content ILIKE $3))
|
||||||
|
ORDER BY ${sort_by} ${sort_order.toUpperCase()}
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
|
`, [
|
||||||
|
category || null,
|
||||||
|
is_published === 'all' ? null : (is_published === 'true'),
|
||||||
|
search ? `%${search}%` : null,
|
||||||
|
parseInt(limit),
|
||||||
|
offset
|
||||||
|
]);
|
||||||
|
|
||||||
|
const countResult = await db.query(`
|
||||||
|
SELECT COUNT(*)
|
||||||
|
FROM articles
|
||||||
|
WHERE ($1::text IS NULL OR category = $1)
|
||||||
|
AND ($2::boolean IS NULL OR is_published = $2)
|
||||||
|
AND ($3::text IS NULL OR (title ILIKE $3 OR excerpt ILIKE $3 OR content ILIKE $3))
|
||||||
|
`, [
|
||||||
|
category || null,
|
||||||
|
is_published === 'all' ? null : (is_published === 'true'),
|
||||||
|
search ? `%${search}%` : null
|
||||||
|
]);
|
||||||
|
|
||||||
|
const total = parseInt(countResult.rows[0].count);
|
||||||
|
const totalPages = Math.ceil(total / parseInt(limit));
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows,
|
||||||
|
pagination: {
|
||||||
|
page: parseInt(page),
|
||||||
|
limit: parseInt(limit),
|
||||||
|
total,
|
||||||
|
totalPages
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения статей');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/crud/articles/:id - получить статью по ID
|
||||||
|
router.get('/articles/:id', logRequest('articles'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
SELECT
|
||||||
|
id, title, excerpt, content, category, image_url,
|
||||||
|
author_id, is_published, created_at, updated_at
|
||||||
|
FROM articles
|
||||||
|
WHERE id = $1
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Статья не найдена'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: result.rows[0]
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения статьи');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/crud/articles - создать новую статью
|
||||||
|
router.post('/articles', logRequest('articles'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title, excerpt, content, category, image_url,
|
||||||
|
author_id, is_published = false
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Валидация обязательных полей
|
||||||
|
if (!title || !content) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Отсутствуют обязательные поля: title, content'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
INSERT INTO articles (
|
||||||
|
title, excerpt, content, category, image_url,
|
||||||
|
author_id, is_published
|
||||||
|
) VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
title, excerpt, content, category, image_url,
|
||||||
|
author_id, is_published
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.status(201).json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'создания статьи');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// PUT /api/crud/articles/:id - обновить статью
|
||||||
|
router.put('/articles/:id', logRequest('articles'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
const {
|
||||||
|
title, excerpt, content, category, image_url,
|
||||||
|
author_id, is_published
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
UPDATE articles SET
|
||||||
|
title = COALESCE($1, title),
|
||||||
|
excerpt = COALESCE($2, excerpt),
|
||||||
|
content = COALESCE($3, content),
|
||||||
|
category = COALESCE($4, category),
|
||||||
|
image_url = COALESCE($5, image_url),
|
||||||
|
author_id = COALESCE($6, author_id),
|
||||||
|
is_published = COALESCE($7, is_published),
|
||||||
|
updated_at = CURRENT_TIMESTAMP
|
||||||
|
WHERE id = $8
|
||||||
|
RETURNING *
|
||||||
|
`, [
|
||||||
|
title, excerpt, content, category, image_url,
|
||||||
|
author_id, is_published, id
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Статья не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true, data: result.rows[0] });
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'обновления статьи');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/crud/articles/:id - удалить статью
|
||||||
|
router.delete('/articles/:id', logRequest('articles'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const id = validateId(req.params.id);
|
||||||
|
|
||||||
|
const result = await db.query(`
|
||||||
|
DELETE FROM articles WHERE id = $1 RETURNING id, title
|
||||||
|
`, [id]);
|
||||||
|
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(404).json({ success: false, error: 'Статья не найдена' });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: `Статья "${result.rows[0].title}" успешно удалена`,
|
||||||
|
data: { id: result.rows[0].id }
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'удаления статьи');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ОБЩИЕ ЭНДПОИНТЫ
|
||||||
|
*/
|
||||||
|
|
||||||
|
// GET /api/crud/stats - общая статистика
|
||||||
|
router.get('/stats', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [routesCount, guidesCount, articlesCount] = await Promise.all([
|
||||||
|
db.query('SELECT COUNT(*) FROM routes WHERE is_active = true'),
|
||||||
|
db.query('SELECT COUNT(*) FROM guides WHERE is_active = true'),
|
||||||
|
db.query('SELECT COUNT(*) FROM articles WHERE is_published = true')
|
||||||
|
]);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
routes: parseInt(routesCount.rows[0].count),
|
||||||
|
guides: parseInt(guidesCount.rows[0].count),
|
||||||
|
articles: parseInt(articlesCount.rows[0].count),
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
handleError(res, error, 'получения статистики');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
246
src/routes/image-upload.js
Normal file
246
src/routes/image-upload.js
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Настройка multer для загрузки файлов
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
const uploadPath = path.join(__dirname, '../../public/uploads/temp');
|
||||||
|
try {
|
||||||
|
await fs.mkdir(uploadPath, { recursive: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error creating upload directory:', error);
|
||||||
|
}
|
||||||
|
cb(null, uploadPath);
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, `temp-${uniqueSuffix}${path.extname(file.originalname)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const fileFilter = (req, file, cb) => {
|
||||||
|
// Проверяем, что это изображение
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Файл должен быть изображением'), false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
fileFilter,
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024 // 10MB
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Загрузка изображения во временную папку
|
||||||
|
*/
|
||||||
|
router.post('/upload-image', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({ error: 'Файл не загружен' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем информацию об изображении
|
||||||
|
const metadata = await sharp(req.file.path).metadata();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
tempId: path.basename(req.file.filename, path.extname(req.file.filename)),
|
||||||
|
filename: req.file.filename,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
size: req.file.size,
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
format: metadata.format,
|
||||||
|
tempUrl: `/uploads/temp/${req.file.filename}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка загрузки файла' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Обработка изображения (поворот, отражение, обрезка)
|
||||||
|
*/
|
||||||
|
router.post('/process-image', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
tempId,
|
||||||
|
rotation = 0,
|
||||||
|
flipHorizontal = false,
|
||||||
|
flipVertical = false,
|
||||||
|
cropData,
|
||||||
|
targetFolder = 'routes' // routes, guides, articles, etc.
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
if (!tempId) {
|
||||||
|
return res.status(400).json({ error: 'Отсутствует ID временного файла' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPath = path.join(__dirname, '../../public/uploads/temp');
|
||||||
|
const targetPath = path.join(__dirname, `../../public/uploads/${targetFolder}`);
|
||||||
|
|
||||||
|
// Создаем целевую папку если её нет
|
||||||
|
await fs.mkdir(targetPath, { recursive: true });
|
||||||
|
|
||||||
|
// Ищем временный файл
|
||||||
|
const tempFiles = await fs.readdir(tempPath);
|
||||||
|
const tempFile = tempFiles.find(file => file.includes(tempId));
|
||||||
|
|
||||||
|
if (!tempFile) {
|
||||||
|
return res.status(404).json({ error: 'Временный файл не найден' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempFilePath = path.join(tempPath, tempFile);
|
||||||
|
|
||||||
|
// Генерируем имя для финального файла
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const finalFileName = `${targetFolder}-${timestamp}.jpg`;
|
||||||
|
const finalFilePath = path.join(targetPath, finalFileName);
|
||||||
|
|
||||||
|
// Обрабатываем изображение
|
||||||
|
let sharpInstance = sharp(tempFilePath);
|
||||||
|
|
||||||
|
// Поворот
|
||||||
|
if (rotation !== 0) {
|
||||||
|
sharpInstance = sharpInstance.rotate(rotation);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Отражение
|
||||||
|
if (flipHorizontal || flipVertical) {
|
||||||
|
sharpInstance = sharpInstance.flip(flipVertical).flop(flipHorizontal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обрезка
|
||||||
|
if (cropData && cropData.width > 0 && cropData.height > 0) {
|
||||||
|
sharpInstance = sharpInstance.extract({
|
||||||
|
left: Math.round(cropData.x),
|
||||||
|
top: Math.round(cropData.y),
|
||||||
|
width: Math.round(cropData.width),
|
||||||
|
height: Math.round(cropData.height)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сжимаем и сохраняем
|
||||||
|
await sharpInstance
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toFile(finalFilePath);
|
||||||
|
|
||||||
|
// Удаляем временный файл
|
||||||
|
try {
|
||||||
|
await fs.unlink(tempFilePath);
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('Не удалось удалить временный файл:', error.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
url: `/uploads/${targetFolder}/${finalFileName}`,
|
||||||
|
filename: finalFileName
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Image processing error:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка обработки изображения' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление временного файла
|
||||||
|
*/
|
||||||
|
router.delete('/temp-image/:tempId', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { tempId } = req.params;
|
||||||
|
const tempPath = path.join(__dirname, '../../public/uploads/temp');
|
||||||
|
|
||||||
|
const tempFiles = await fs.readdir(tempPath);
|
||||||
|
const tempFile = tempFiles.find(file => file.includes(tempId));
|
||||||
|
|
||||||
|
if (tempFile) {
|
||||||
|
await fs.unlink(path.join(tempPath, tempFile));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete temp image error:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка удаления временного файла' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Получение списка изображений в папке
|
||||||
|
*/
|
||||||
|
router.get('/images/:folder', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { folder } = req.params;
|
||||||
|
const imagesPath = path.join(__dirname, `../../public/uploads/${folder}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(imagesPath);
|
||||||
|
const images = files
|
||||||
|
.filter(file => /\.(jpg|jpeg|png|gif|webp)$/i.test(file))
|
||||||
|
.map(file => ({
|
||||||
|
filename: file,
|
||||||
|
url: `/uploads/${folder}/${file}`,
|
||||||
|
path: path.join(imagesPath, file)
|
||||||
|
}));
|
||||||
|
|
||||||
|
res.json({ success: true, images });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
res.json({ success: true, images: [] });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('List images error:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка получения списка изображений' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Удаление изображения
|
||||||
|
*/
|
||||||
|
router.delete('/image', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { url } = req.body;
|
||||||
|
|
||||||
|
if (!url || !url.startsWith('/uploads/')) {
|
||||||
|
return res.status(400).json({ error: 'Некорректный URL изображения' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const imagePath = path.join(__dirname, '../../public', url);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.unlink(imagePath);
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
res.json({ success: true, message: 'Файл уже удален' });
|
||||||
|
} else {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete image error:', error);
|
||||||
|
res.status(500).json({ error: 'Ошибка удаления изображения' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
259
src/routes/images.js
Normal file
259
src/routes/images.js
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import multer from 'multer';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import path from 'path';
|
||||||
|
import fs from 'fs/promises';
|
||||||
|
import { fileURLToPath } from 'url';
|
||||||
|
import { dirname } from 'path';
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
// Настройка multer для загрузки файлов
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: async (req, file, cb) => {
|
||||||
|
const uploadDir = path.join(__dirname, '../../public/uploads');
|
||||||
|
try {
|
||||||
|
await fs.mkdir(uploadDir, { recursive: true });
|
||||||
|
cb(null, uploadDir);
|
||||||
|
} catch (error) {
|
||||||
|
cb(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
const ext = path.extname(file.originalname);
|
||||||
|
cb(null, `image-${uniqueSuffix}${ext}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: 5 * 1024 * 1024, // 5MB
|
||||||
|
},
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
const allowedTypes = /jpeg|jpg|png|gif/;
|
||||||
|
const extname = allowedTypes.test(path.extname(file.originalname).toLowerCase());
|
||||||
|
const mimetype = allowedTypes.test(file.mimetype);
|
||||||
|
|
||||||
|
if (mimetype && extname) {
|
||||||
|
return cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Недопустимый тип файла'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// POST /api/images/upload - загрузка изображения
|
||||||
|
router.post('/upload', upload.single('image'), async (req, res) => {
|
||||||
|
try {
|
||||||
|
if (!req.file) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Файл не был загружен'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const { folder = 'general' } = req.body;
|
||||||
|
const originalPath = req.file.path;
|
||||||
|
const targetDir = path.join(__dirname, '../../public/uploads', folder);
|
||||||
|
|
||||||
|
// Создаем целевую папку если она не существует
|
||||||
|
await fs.mkdir(targetDir, { recursive: true });
|
||||||
|
|
||||||
|
// Генерируем имя файла
|
||||||
|
const ext = path.extname(req.file.originalname);
|
||||||
|
const filename = `${Date.now()}-${Math.round(Math.random() * 1E9)}${ext}`;
|
||||||
|
const targetPath = path.join(targetDir, filename);
|
||||||
|
|
||||||
|
// Оптимизируем изображение с помощью Sharp
|
||||||
|
await sharp(originalPath)
|
||||||
|
.resize(1200, 800, {
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toFile(targetPath);
|
||||||
|
|
||||||
|
// Удаляем временный файл
|
||||||
|
await fs.unlink(originalPath);
|
||||||
|
|
||||||
|
const relativePath = `/uploads/${folder}/${filename}`;
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
path: relativePath,
|
||||||
|
originalName: req.file.originalname,
|
||||||
|
size: req.file.size,
|
||||||
|
folder
|
||||||
|
},
|
||||||
|
message: 'Изображение успешно загружено'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Ошибка загрузки изображения'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/images/gallery - получение списка изображений
|
||||||
|
router.get('/gallery', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { folder = 'all' } = req.query;
|
||||||
|
const uploadsDir = path.join(__dirname, '../../public/uploads');
|
||||||
|
|
||||||
|
let images = [];
|
||||||
|
|
||||||
|
if (folder === 'all') {
|
||||||
|
// Получаем изображения из всех папок
|
||||||
|
const folders = ['routes', 'guides', 'articles', 'general'];
|
||||||
|
|
||||||
|
for (const folderName of folders) {
|
||||||
|
const folderPath = path.join(uploadsDir, folderName);
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(folderPath);
|
||||||
|
const folderImages = files
|
||||||
|
.filter(file => /\.(jpg|jpeg|png|gif)$/i.test(file))
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: `/uploads/${folderName}/${file}`,
|
||||||
|
folder: folderName,
|
||||||
|
fullPath: path.join(folderPath, file)
|
||||||
|
}));
|
||||||
|
images.push(...folderImages);
|
||||||
|
} catch (err) {
|
||||||
|
// Папка может не существовать - это нормально
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Получаем изображения из конкретной папки
|
||||||
|
const folderPath = path.join(uploadsDir, folder);
|
||||||
|
try {
|
||||||
|
const files = await fs.readdir(folderPath);
|
||||||
|
images = files
|
||||||
|
.filter(file => /\.(jpg|jpeg|png|gif)$/i.test(file))
|
||||||
|
.map(file => ({
|
||||||
|
name: file,
|
||||||
|
path: `/uploads/${folder}/${file}`,
|
||||||
|
folder,
|
||||||
|
fullPath: path.join(folderPath, file)
|
||||||
|
}));
|
||||||
|
} catch (err) {
|
||||||
|
// Папка может не существовать
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Получаем статистику файлов
|
||||||
|
const imagesWithStats = await Promise.all(
|
||||||
|
images.map(async (img) => {
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(img.fullPath);
|
||||||
|
return {
|
||||||
|
...img,
|
||||||
|
size: stats.size,
|
||||||
|
created: stats.birthtime,
|
||||||
|
modified: stats.mtime
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
return img;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Сортируем по дате создания (новые сначала)
|
||||||
|
imagesWithStats.sort((a, b) => {
|
||||||
|
if (a.created && b.created) {
|
||||||
|
return new Date(b.created) - new Date(a.created);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: imagesWithStats,
|
||||||
|
total: imagesWithStats.length
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Gallery error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Ошибка получения галереи'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// DELETE /api/images/:folder/:filename - удаление изображения
|
||||||
|
router.delete('/:folder/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { folder, filename } = req.params;
|
||||||
|
const filePath = path.join(__dirname, '../../public/uploads', folder, filename);
|
||||||
|
|
||||||
|
// Проверяем существование файла
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
} catch (err) {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
error: 'Файл не найден'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Удаляем файл
|
||||||
|
await fs.unlink(filePath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Изображение успешно удалено'
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Delete error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Ошибка удаления изображения'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// GET /api/images/info/:folder/:filename - получение информации об изображении
|
||||||
|
router.get('/info/:folder/:filename', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { folder, filename } = req.params;
|
||||||
|
const filePath = path.join(__dirname, '../../public/uploads', folder, filename);
|
||||||
|
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
const metadata = await sharp(filePath).metadata();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
filename,
|
||||||
|
folder,
|
||||||
|
path: `/uploads/${folder}/${filename}`,
|
||||||
|
size: stats.size,
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height,
|
||||||
|
format: metadata.format,
|
||||||
|
created: stats.birthtime,
|
||||||
|
modified: stats.mtime
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Info error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Ошибка получения информации об изображении'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
86
src/routes/settings.js
Normal file
86
src/routes/settings.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import express from 'express';
|
||||||
|
import SiteSettingsHelper from '../helpers/site-settings.js';
|
||||||
|
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Динамический CSS на основе настроек сайта
|
||||||
|
*/
|
||||||
|
router.get('/dynamic-styles.css', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const css = await SiteSettingsHelper.generateCSSVariables();
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/css');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=300'); // Кеш на 5 минут
|
||||||
|
res.send(css);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error generating dynamic CSS:', error);
|
||||||
|
res.status(500).send('/* Error generating dynamic CSS */');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API для получения настроек сайта
|
||||||
|
*/
|
||||||
|
router.get('/api/site-settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await SiteSettingsHelper.getAllSettings();
|
||||||
|
res.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading site settings:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load site settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API для получения настроек по категории
|
||||||
|
*/
|
||||||
|
router.get('/api/site-settings/:category', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { category } = req.params;
|
||||||
|
const settings = await SiteSettingsHelper.getSettingsByCategory(category);
|
||||||
|
res.json(settings);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading site settings by category:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to load site settings' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API для обновления настройки
|
||||||
|
*/
|
||||||
|
router.post('/api/site-settings', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { key, value, type, description, category } = req.body;
|
||||||
|
|
||||||
|
if (!key || value === undefined) {
|
||||||
|
return res.status(400).json({ error: 'Key and value are required' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const success = await SiteSettingsHelper.setSetting(key, value, type, description, category);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
res.json({ success: true, message: 'Setting updated successfully' });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ error: 'Failed to update setting' });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating site setting:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to update setting' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Очистка кеша настроек (для админов)
|
||||||
|
*/
|
||||||
|
router.post('/api/site-settings/clear-cache', async (req, res) => {
|
||||||
|
try {
|
||||||
|
SiteSettingsHelper.clearCache();
|
||||||
|
res.json({ success: true, message: 'Cache cleared successfully' });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error clearing cache:', error);
|
||||||
|
res.status(500).json({ error: 'Failed to clear cache' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default router;
|
||||||
370
test-crud.js
Normal file
370
test-crud.js
Normal file
@@ -0,0 +1,370 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скрипт для тестирования всех CRUD операций
|
||||||
|
* Проверяет создание, чтение, обновление и удаление для Routes, Guides и Articles
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BASE_URL = 'http://localhost:3000/api/crud';
|
||||||
|
|
||||||
|
// Цветной вывод в консоль
|
||||||
|
const colors = {
|
||||||
|
reset: '\x1b[0m',
|
||||||
|
red: '\x1b[31m',
|
||||||
|
green: '\x1b[32m',
|
||||||
|
yellow: '\x1b[33m',
|
||||||
|
blue: '\x1b[34m',
|
||||||
|
magenta: '\x1b[35m',
|
||||||
|
cyan: '\x1b[36m'
|
||||||
|
};
|
||||||
|
|
||||||
|
const log = (color, message) => {
|
||||||
|
console.log(`${colors[color]}${message}${colors.reset}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Утилита для HTTP запросов
|
||||||
|
async function request(method, url, data = null) {
|
||||||
|
const options = {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (data) {
|
||||||
|
options.body = JSON.stringify(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, options);
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: response.status,
|
||||||
|
data: result
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестовые данные
|
||||||
|
const testData = {
|
||||||
|
route: {
|
||||||
|
title: 'Тестовый маршрут CRUD',
|
||||||
|
description: 'Описание тестового маршрута для проверки CRUD операций',
|
||||||
|
content: 'Подробное описание маршрута с инструкциями',
|
||||||
|
type: 'city',
|
||||||
|
difficulty_level: 'easy',
|
||||||
|
price: 25000,
|
||||||
|
duration: 4,
|
||||||
|
max_group_size: 15,
|
||||||
|
image_url: '/uploads/routes/seoul-city-tour.jpg',
|
||||||
|
is_featured: false,
|
||||||
|
is_active: true
|
||||||
|
},
|
||||||
|
guide: {
|
||||||
|
name: 'Тестовый Гид CRUD',
|
||||||
|
email: 'test-guide-crud@example.com',
|
||||||
|
phone: '+82-10-1234-5678',
|
||||||
|
languages: 'Корейский, Английский, Русский',
|
||||||
|
specialization: 'city',
|
||||||
|
bio: 'Опытный гид для тестирования CRUD операций',
|
||||||
|
experience: 3,
|
||||||
|
image_url: '/uploads/guides/guide-profile.jpg',
|
||||||
|
hourly_rate: 30000,
|
||||||
|
is_active: true
|
||||||
|
},
|
||||||
|
article: {
|
||||||
|
title: 'Тестовая статья CRUD',
|
||||||
|
excerpt: 'Краткое описание тестовой статьи',
|
||||||
|
content: 'Полный текст тестовой статьи для проверки CRUD операций',
|
||||||
|
category: 'travel-tips',
|
||||||
|
image_url: '/images/articles/test-article.jpg',
|
||||||
|
author_id: 1,
|
||||||
|
is_published: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Основная функция тестирования
|
||||||
|
async function runCRUDTests() {
|
||||||
|
log('cyan', '🚀 Запуск тестирования CRUD операций...\n');
|
||||||
|
|
||||||
|
const results = {
|
||||||
|
routes: await testEntityCRUD('routes', testData.route),
|
||||||
|
guides: await testEntityCRUD('guides', testData.guide),
|
||||||
|
articles: await testEntityCRUD('articles', testData.article)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Тестируем общие эндпоинты
|
||||||
|
log('blue', '📊 Тестирование общей статистики...');
|
||||||
|
try {
|
||||||
|
const statsResponse = await request('GET', `${BASE_URL}/stats`);
|
||||||
|
if (statsResponse.status === 200 && statsResponse.data.success) {
|
||||||
|
log('green', `✅ Статистика: ${JSON.stringify(statsResponse.data.data)}`);
|
||||||
|
} else {
|
||||||
|
log('red', `❌ Ошибка получения статистики: ${JSON.stringify(statsResponse.data)}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Ошибка получения статистики: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Итоговый отчет
|
||||||
|
log('cyan', '\n📋 Итоговый отчет тестирования:');
|
||||||
|
Object.entries(results).forEach(([entity, result]) => {
|
||||||
|
const status = result.success ? '✅' : '❌';
|
||||||
|
log(result.success ? 'green' : 'red', `${status} ${entity.toUpperCase()}: ${result.message}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalTests = Object.values(results).length;
|
||||||
|
const passedTests = Object.values(results).filter(r => r.success).length;
|
||||||
|
|
||||||
|
log('cyan', `\n🎯 Результат: ${passedTests}/${totalTests} тестов прошли успешно`);
|
||||||
|
|
||||||
|
if (passedTests === totalTests) {
|
||||||
|
log('green', '🎉 Все CRUD операции работают корректно!');
|
||||||
|
} else {
|
||||||
|
log('red', '⚠️ Некоторые операции требуют внимания');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тестирование CRUD для конкретной сущности
|
||||||
|
async function testEntityCRUD(entity, testData) {
|
||||||
|
log('magenta', `\n🔍 Тестирование ${entity.toUpperCase()}...`);
|
||||||
|
|
||||||
|
let createdId = null;
|
||||||
|
const steps = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 1. CREATE - Создание записи
|
||||||
|
log('yellow', '1. CREATE - Создание записи...');
|
||||||
|
const createResponse = await request('POST', `${BASE_URL}/${entity}`, testData);
|
||||||
|
|
||||||
|
if (createResponse.status === 201 && createResponse.data.success) {
|
||||||
|
createdId = createResponse.data.data.id;
|
||||||
|
log('green', `✅ Создание успешно. ID: ${createdId}`);
|
||||||
|
steps.push('CREATE: ✅');
|
||||||
|
} else {
|
||||||
|
log('red', `❌ Ошибка создания: ${JSON.stringify(createResponse.data)}`);
|
||||||
|
steps.push('CREATE: ❌');
|
||||||
|
return { success: false, message: 'Ошибка создания записи', steps };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. READ - Чтение записи по ID
|
||||||
|
log('yellow', '2. READ - Чтение записи по ID...');
|
||||||
|
const readResponse = await request('GET', `${BASE_URL}/${entity}/${createdId}`);
|
||||||
|
|
||||||
|
if (readResponse.status === 200 && readResponse.data.success) {
|
||||||
|
log('green', `✅ Чтение успешно. Заголовок: "${readResponse.data.data.title || readResponse.data.data.name}"`);
|
||||||
|
steps.push('READ: ✅');
|
||||||
|
} else {
|
||||||
|
log('red', `❌ Ошибка чтения: ${JSON.stringify(readResponse.data)}`);
|
||||||
|
steps.push('READ: ❌');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. READ ALL - Чтение всех записей
|
||||||
|
log('yellow', '3. READ ALL - Чтение всех записей...');
|
||||||
|
const readAllResponse = await request('GET', `${BASE_URL}/${entity}?page=1&limit=5`);
|
||||||
|
|
||||||
|
if (readAllResponse.status === 200 && readAllResponse.data.success) {
|
||||||
|
const count = readAllResponse.data.data.length;
|
||||||
|
log('green', `✅ Получено записей: ${count}`);
|
||||||
|
steps.push('READ ALL: ✅');
|
||||||
|
} else {
|
||||||
|
log('red', `❌ Ошибка чтения списка: ${JSON.stringify(readAllResponse.data)}`);
|
||||||
|
steps.push('READ ALL: ❌');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. UPDATE - Обновление записи
|
||||||
|
log('yellow', '4. UPDATE - Обновление записи...');
|
||||||
|
const updateData = entity === 'guides'
|
||||||
|
? { name: testData.name + ' (ОБНОВЛЕНО)' }
|
||||||
|
: { title: testData.title + ' (ОБНОВЛЕНО)' };
|
||||||
|
|
||||||
|
const updateResponse = await request('PUT', `${BASE_URL}/${entity}/${createdId}`, updateData);
|
||||||
|
|
||||||
|
if (updateResponse.status === 200 && updateResponse.data.success) {
|
||||||
|
log('green', '✅ Обновление успешно');
|
||||||
|
steps.push('UPDATE: ✅');
|
||||||
|
} else {
|
||||||
|
log('red', `❌ Ошибка обновления: ${JSON.stringify(updateResponse.data)}`);
|
||||||
|
steps.push('UPDATE: ❌');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. DELETE - Удаление записи
|
||||||
|
log('yellow', '5. DELETE - Удаление записи...');
|
||||||
|
const deleteResponse = await request('DELETE', `${BASE_URL}/${entity}/${createdId}`);
|
||||||
|
|
||||||
|
if (deleteResponse.status === 200 && deleteResponse.data.success) {
|
||||||
|
log('green', `✅ Удаление успешно: ${deleteResponse.data.message}`);
|
||||||
|
steps.push('DELETE: ✅');
|
||||||
|
} else {
|
||||||
|
log('red', `❌ Ошибка удаления: ${JSON.stringify(deleteResponse.data)}`);
|
||||||
|
steps.push('DELETE: ❌');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Проверка удаления
|
||||||
|
log('yellow', '6. VERIFY DELETE - Проверка удаления...');
|
||||||
|
const verifyResponse = await request('GET', `${BASE_URL}/${entity}/${createdId}`);
|
||||||
|
|
||||||
|
if (verifyResponse.status === 404) {
|
||||||
|
log('green', '✅ Запись действительно удалена');
|
||||||
|
steps.push('VERIFY: ✅');
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Запись не была удалена');
|
||||||
|
steps.push('VERIFY: ❌');
|
||||||
|
}
|
||||||
|
|
||||||
|
const successCount = steps.filter(s => s.includes('✅')).length;
|
||||||
|
const isSuccess = successCount === steps.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: isSuccess,
|
||||||
|
message: `${successCount}/6 операций успешно`,
|
||||||
|
steps
|
||||||
|
};
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Критическая ошибка: ${error.message}`);
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: `Критическая ошибка: ${error.message}`,
|
||||||
|
steps: [...steps, 'ERROR: ❌']
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Дополнительные тесты для специфических функций
|
||||||
|
async function testAdvancedFeatures() {
|
||||||
|
log('cyan', '\n🔬 Тестирование расширенных функций...');
|
||||||
|
|
||||||
|
// Тест поиска
|
||||||
|
log('yellow', 'Тест поиска по routes...');
|
||||||
|
try {
|
||||||
|
const searchResponse = await request('GET', `${BASE_URL}/routes?search=seoul&limit=3`);
|
||||||
|
if (searchResponse.status === 200) {
|
||||||
|
log('green', `✅ Поиск работает. Найдено: ${searchResponse.data.data.length} записей`);
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Ошибка поиска');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Ошибка поиска: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест фильтрации
|
||||||
|
log('yellow', 'Тест фильтрации guides по специализации...');
|
||||||
|
try {
|
||||||
|
const filterResponse = await request('GET', `${BASE_URL}/guides?specialization=city&limit=3`);
|
||||||
|
if (filterResponse.status === 200) {
|
||||||
|
log('green', `✅ Фильтрация работает. Найдено: ${filterResponse.data.data.length} гидов`);
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Ошибка фильтрации');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Ошибка фильтрации: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест пагинации
|
||||||
|
log('yellow', 'Тест пагинации для articles...');
|
||||||
|
try {
|
||||||
|
const paginationResponse = await request('GET', `${BASE_URL}/articles?page=1&limit=2`);
|
||||||
|
if (paginationResponse.status === 200) {
|
||||||
|
const pagination = paginationResponse.data.pagination;
|
||||||
|
log('green', `✅ Пагинация работает. Страница ${pagination.page}, всего ${pagination.total} записей`);
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Ошибка пагинации');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Ошибка пагинации: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест валидации
|
||||||
|
async function testValidation() {
|
||||||
|
log('cyan', '\n🛡️ Тестирование валидации...');
|
||||||
|
|
||||||
|
// Тест создания без обязательных полей
|
||||||
|
log('yellow', 'Тест создания маршрута без обязательных полей...');
|
||||||
|
try {
|
||||||
|
const invalidResponse = await request('POST', `${BASE_URL}/routes`, {
|
||||||
|
description: 'Только описание, без заголовка'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (invalidResponse.status === 400) {
|
||||||
|
log('green', '✅ Валидация работает - отклонены невалидные данные');
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Валидация не работает - приняты невалидные данные');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Ошибка тестирования валидации: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Тест обновления несуществующей записи
|
||||||
|
log('yellow', 'Тест обновления несуществующей записи...');
|
||||||
|
try {
|
||||||
|
const notFoundResponse = await request('PUT', `${BASE_URL}/guides/99999`, {
|
||||||
|
name: 'Не существует'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (notFoundResponse.status === 404) {
|
||||||
|
log('green', '✅ Корректно обрабатывается отсутствующая запись');
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Неправильная обработка отсутствующей записи');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Ошибка тестирования несуществующей записи: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск всех тестов
|
||||||
|
async function runAllTests() {
|
||||||
|
try {
|
||||||
|
await runCRUDTests();
|
||||||
|
await testAdvancedFeatures();
|
||||||
|
await testValidation();
|
||||||
|
|
||||||
|
log('cyan', '\n🏁 Тестирование завершено!');
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `💥 Критическая ошибка тестирования: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Проверка доступности сервера
|
||||||
|
async function checkServer() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('http://localhost:3000/health');
|
||||||
|
if (response.ok) {
|
||||||
|
log('green', '✅ Сервер доступен');
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
log('red', '❌ Сервер недоступен');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log('red', `❌ Сервер недоступен: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Главная функция
|
||||||
|
async function main() {
|
||||||
|
log('cyan', '🧪 Система тестирования CRUD API');
|
||||||
|
log('cyan', '====================================\n');
|
||||||
|
|
||||||
|
// Проверяем доступность сервера
|
||||||
|
const serverAvailable = await checkServer();
|
||||||
|
if (!serverAvailable) {
|
||||||
|
log('red', 'Запустите сервер командой: docker-compose up -d');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запускаем тесты
|
||||||
|
await runAllTests();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Запуск если файл выполняется напрямую
|
||||||
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
|
main().catch(error => {
|
||||||
|
log('red', `💥 Неожиданная ошибка: ${error.message}`);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { runCRUDTests, testAdvancedFeatures, testValidation };
|
||||||
@@ -7,10 +7,13 @@
|
|||||||
<meta name="description" content="<%= siteDescription %>">
|
<meta name="description" content="<%= siteDescription %>">
|
||||||
|
|
||||||
<!-- Favicon -->
|
<!-- Favicon -->
|
||||||
<link rel="icon" href="/images/favicon.ico">
|
<link rel="icon" href="<%= siteSettings.favicon_url || '/images/favicon.ico' %>">
|
||||||
|
|
||||||
<!-- Google Fonts -->
|
<!-- Google Fonts -->
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Noto+Sans+KR:wght@300;400;500;700&family=Playfair+Display:wght@400;600;700&display=swap" rel="stylesheet">
|
||||||
|
<% if (siteSettings.google_fonts_url && siteSettings.google_fonts_url.trim()) { %>
|
||||||
|
<link href="<%= siteSettings.google_fonts_url %>" rel="stylesheet">
|
||||||
|
<% } %>
|
||||||
|
|
||||||
<!-- Bootstrap CSS -->
|
<!-- Bootstrap CSS -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
@@ -26,6 +29,8 @@
|
|||||||
|
|
||||||
<!-- Custom CSS -->
|
<!-- Custom CSS -->
|
||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
|
<!-- Dynamic Site Settings CSS -->
|
||||||
|
<link href="/dynamic-styles.css" rel="stylesheet">
|
||||||
|
|
||||||
<!-- Open Graph Meta Tags -->
|
<!-- Open Graph Meta Tags -->
|
||||||
<meta property="og:title" content="<%= title || siteName %>">
|
<meta property="og:title" content="<%= title || siteName %>">
|
||||||
@@ -42,7 +47,9 @@
|
|||||||
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
<nav class="navbar navbar-expand-lg navbar-dark bg-primary fixed-top">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
<a class="navbar-brand" href="/">
|
<a class="navbar-brand" href="/">
|
||||||
<img src="/images/korea-logo.png" alt="Korea Tourism" height="40" class="me-2">
|
<% if (siteSettings.site_logo_url && siteSettings.site_logo_url.trim()) { %>
|
||||||
|
<img src="<%= siteSettings.site_logo_url %>" alt="<%= siteName %>" height="40" class="me-2">
|
||||||
|
<% } %>
|
||||||
<span class="fw-bold"><%= siteName %></span>
|
<span class="fw-bold"><%= siteName %></span>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -188,6 +195,7 @@
|
|||||||
|
|
||||||
<!-- Custom JS -->
|
<!-- Custom JS -->
|
||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
|
<script src="/js/image-editor.js"></script>
|
||||||
|
|
||||||
<!-- Initialize AOS -->
|
<!-- Initialize AOS -->
|
||||||
<script>
|
<script>
|
||||||
|
|||||||
Reference in New Issue
Block a user