feat: Реализован полный CRUD для админ-панели и улучшена функциональность
- Portfolio CRUD: добавление, редактирование, удаление, переключение публикации - Services CRUD: полное управление услугами с возможностью активации/деактивации - Banner system: новая модель Banner с CRUD операциями и аналитикой кликов - Telegram integration: расширенные настройки бота, обнаружение чатов, отправка сообщений - Media management: улучшенная загрузка файлов с оптимизацией изображений и превью - UI improvements: обновлённые админ-панели с rich-text редактором и drag&drop загрузкой - Database: добавлена таблица banners с полями для баннеров и аналитики
151
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# SmartSolTech Website - AI Coding Agent Instructions
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
SmartSolTech is a **Korean tech services company** PWA with an admin panel, portfolio management, Telegram integration, and service calculator. Built with Node.js/Express backend, EJS templating, MongoDB, and modern PWA features.
|
||||||
|
|
||||||
|
## Architecture & Key Patterns
|
||||||
|
|
||||||
|
### 🏗️ Backend Architecture
|
||||||
|
- **Main server**: `server.js` - Express app with comprehensive middleware stack
|
||||||
|
- **Models**: MongoDB with Mongoose ODM - all in `/models/` (User, Portfolio, Service, Contact, SiteSettings)
|
||||||
|
- **Routes**: RESTful API patterns in `/routes/` - separate files per domain
|
||||||
|
- **Authentication**: Dual auth system - JWT tokens for API, sessions for web pages
|
||||||
|
- **Validation**: express-validator middleware in `/middleware/validation.js` with Korean error messages
|
||||||
|
|
||||||
|
### 🔑 Authentication Pattern
|
||||||
|
```javascript
|
||||||
|
// Two authentication strategies:
|
||||||
|
// 1. JWT for API endpoints (authenticateToken)
|
||||||
|
// 2. Session-based for web pages (authenticateSession)
|
||||||
|
// See middleware/auth.js for implementations
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 Data Models Key Features
|
||||||
|
- **Portfolio**: Has virtual `primaryImage` getter, text search indexes, and SEO fields
|
||||||
|
- **Service**: Complex pricing structure with features array (included/not included)
|
||||||
|
- **User**: bcrypt password hashing with pre-save hook and comparePassword method
|
||||||
|
- **All models**: Use timestamps and have soft-delete patterns where applicable
|
||||||
|
|
||||||
|
### 🛣️ Route Organization
|
||||||
|
- **Admin routes** (`/admin`): Session-based auth, server-side rendered EJS views
|
||||||
|
- **API routes** (`/api/*`): JWT auth, JSON responses, rate-limited
|
||||||
|
- **Public routes** (`/`): Mixed auth (optional), EJS templates with PWA support
|
||||||
|
|
||||||
|
## Development Workflow
|
||||||
|
|
||||||
|
### 🚀 Essential Commands
|
||||||
|
```bash
|
||||||
|
# Development with hot reload and file watching
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# Initialize database with admin user and sample data
|
||||||
|
npm run init-db
|
||||||
|
|
||||||
|
# Production build with webpack optimization
|
||||||
|
npm run build
|
||||||
|
|
||||||
|
# Database setup creates:
|
||||||
|
# - Admin user (from .env: ADMIN_EMAIL/ADMIN_PASSWORD)
|
||||||
|
# - Sample services, portfolio items, site settings
|
||||||
|
```
|
||||||
|
|
||||||
|
### 🔧 Environment Setup
|
||||||
|
**Critical `.env` variables**:
|
||||||
|
- `MONGODB_URI` - Database connection
|
||||||
|
- `SESSION_SECRET`, `JWT_SECRET` - Security keys
|
||||||
|
- `ADMIN_EMAIL`, `ADMIN_PASSWORD` - Initial admin account
|
||||||
|
- `TELEGRAM_BOT_TOKEN` - Optional Telegram integration
|
||||||
|
- `NODE_ENV` - Controls error verbosity and security settings
|
||||||
|
|
||||||
|
## Frontend Architecture
|
||||||
|
|
||||||
|
### 🎨 View Layer (EJS Templates)
|
||||||
|
- **Layout system**: `views/layout.ejs` is main wrapper
|
||||||
|
- **Partials**: `views/partials/` for reusable components (navigation, footer)
|
||||||
|
- **Admin panel**: Separate layout `admin/layout.ejs` with different styling
|
||||||
|
- **Internationalization**: Content in Korean, supports locales in `/locales/`
|
||||||
|
|
||||||
|
### 📱 PWA Implementation
|
||||||
|
- **Service Worker**: `public/sw.js` - caches static files, dynamic routes, and API responses
|
||||||
|
- **Manifest**: `public/manifest.json` - app metadata and icons
|
||||||
|
- **Offline-first**: Service worker handles network failures gracefully
|
||||||
|
- **Cache strategy**: Static files cached immediately, dynamic content cached on demand
|
||||||
|
|
||||||
|
### 🎯 Frontend JavaScript Patterns
|
||||||
|
- **Main script**: `public/js/main.js` - handles navigation, scroll effects, form interactions
|
||||||
|
- **Calculator**: `public/js/calculator.js` - complex service pricing calculations
|
||||||
|
- **Libraries**: AOS animations, Tailwind CSS, Socket.io for real-time features
|
||||||
|
|
||||||
|
## Key Integration Points
|
||||||
|
|
||||||
|
### 📧 Communication Systems
|
||||||
|
- **Telegram Bot**: Optional integration for admin notifications (contact forms, orders)
|
||||||
|
- **Email**: Nodemailer for SMTP email sending (contact forms, notifications)
|
||||||
|
- **Real-time**: Socket.io setup for live updates (dashboard, notifications)
|
||||||
|
|
||||||
|
### 🔒 Security Patterns
|
||||||
|
- **Helmet**: CSP headers allowing specific domains (fonts.googleapis.com, cdnjs.cloudflare.com)
|
||||||
|
- **Rate limiting**: Applied to `/api/*` routes (100 requests/15min per IP)
|
||||||
|
- **Input validation**: Korean-language error messages, comprehensive field validation
|
||||||
|
- **File uploads**: Multer with Sharp image processing, stored in `public/uploads/`
|
||||||
|
|
||||||
|
### 🗄️ Database Patterns
|
||||||
|
- **Connection**: Single MongoDB connection with connection pooling
|
||||||
|
- **Indexing**: Text search on Portfolio, compound indexes for performance
|
||||||
|
- **Session storage**: MongoDB session store for persistence
|
||||||
|
|
||||||
|
## Business Logic Specifics
|
||||||
|
|
||||||
|
### 💰 Service Calculator
|
||||||
|
- **Complex pricing**: Base price + features + timeline modifiers
|
||||||
|
- **Multi-step form**: Service selection → customization → contact info → quote
|
||||||
|
- **Integration**: Calculator data feeds into Contact model for lead tracking
|
||||||
|
|
||||||
|
### 🎨 Portfolio Management
|
||||||
|
- **Image handling**: Multiple images per project, primary image logic
|
||||||
|
- **Categories**: Predefined categories (web-development, mobile-app, ui-ux-design, etc.)
|
||||||
|
- **SEO optimization**: Meta fields, structured data for portfolio items
|
||||||
|
|
||||||
|
### 👑 Admin Panel Features
|
||||||
|
- **Dashboard**: Statistics, recent activity, quick actions
|
||||||
|
- **Content management**: CRUD for Portfolio, Services, Site Settings
|
||||||
|
- **Media gallery**: File upload with image optimization
|
||||||
|
- **Contact management**: Lead tracking, status updates, response handling
|
||||||
|
|
||||||
|
## Common Tasks & Patterns
|
||||||
|
|
||||||
|
### 🔧 Adding New Features
|
||||||
|
1. **Model**: Create in `/models/` with proper validation and indexes
|
||||||
|
2. **Routes**: Add to `/routes/` with appropriate auth middleware
|
||||||
|
3. **Views**: EJS templates in `/views/` using layout system
|
||||||
|
4. **API**: JSON endpoints with validation middleware
|
||||||
|
5. **Frontend**: Add to `public/js/` with proper event handling
|
||||||
|
|
||||||
|
### 🐛 Debugging Workflow
|
||||||
|
- **Development**: Use `npm run dev` for auto-restart and detailed logging
|
||||||
|
- **Database**: Check MongoDB connection and sample data with `npm run init-db`
|
||||||
|
- **Authentication**: Test both JWT (API) and session (web) auth flows
|
||||||
|
- **PWA**: Check service worker registration and cache behavior in dev tools
|
||||||
|
|
||||||
|
### 📦 Deployment Considerations
|
||||||
|
- **Environment**: Production requires secure cookies, CSP, and rate limiting
|
||||||
|
- **Database**: Ensure MongoDB indexes are created for performance
|
||||||
|
- **Assets**: Static files served by Express, consider CDN for production
|
||||||
|
- **Monitoring**: Error handling sends different messages based on NODE_ENV
|
||||||
|
|
||||||
|
## File Structure Quick Reference
|
||||||
|
```
|
||||||
|
├── models/ # MongoDB schemas with business logic
|
||||||
|
├── routes/ # Express routes (API + web pages)
|
||||||
|
├── middleware/ # Auth, validation, error handling
|
||||||
|
├── views/ # EJS templates with Korean content
|
||||||
|
├── public/ # Static assets + PWA files
|
||||||
|
├── scripts/ # Database init, dev server, build tools
|
||||||
|
└── server.js # Main Express application
|
||||||
|
```
|
||||||
|
|
||||||
|
## Korean Language Notes
|
||||||
|
- **UI Text**: All user-facing content in Korean
|
||||||
|
- **Error Messages**: Validation errors in Korean (`middleware/validation.js`)
|
||||||
|
- **Admin Interface**: Korean labels and messages throughout admin panel
|
||||||
|
- **SEO Content**: Korean meta descriptions and structured data
|
||||||
132
REPORT.md
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
# 🎯 SmartSolTech - Отчет о Выполненных Исправлениях
|
||||||
|
|
||||||
|
## 📋 Задачи которые были выполнены:
|
||||||
|
|
||||||
|
### 1. 🏠 **Исправление главной страницы**
|
||||||
|
- ✅ **Проблема**: Стили на главной странице были поломаны
|
||||||
|
- ✅ **Решение**:
|
||||||
|
- Полностью переписан `base.css` с принудительными стилями (!important)
|
||||||
|
- Улучшен градиент hero-секции с многослойными эффектами
|
||||||
|
- Добавлены анимации и hover-эффекты для кнопок
|
||||||
|
- Исправлена навигация с backdrop-filter и webkit префиксами
|
||||||
|
- Оптимизирована отзывчивость для мобильных устройств
|
||||||
|
|
||||||
|
### 2. 📐 **Компактные баннеры для внутренних страниц**
|
||||||
|
- ✅ **Проблема**: Hero-баннеры на всех страницах были полноэкранными
|
||||||
|
- ✅ **Решение**:
|
||||||
|
- Создан класс `.hero-section-compact` для внутренних страниц
|
||||||
|
- Уменьшена высота с 100vh до 40vh (50vh максимум)
|
||||||
|
- Обновлены страницы: "О нас", "Услуги"
|
||||||
|
- Сохранен полноэкранный баннер только на главной странице
|
||||||
|
|
||||||
|
### 3. 🖼️ **Система редактирования изображений баннеров**
|
||||||
|
|
||||||
|
#### **A. Backend API (/routes/media.js)**
|
||||||
|
- ✅ Загрузка одного изображения: `POST /media/upload`
|
||||||
|
- ✅ Загрузка нескольких изображений: `POST /media/upload-multiple`
|
||||||
|
- ✅ Удаление изображений: `DELETE /media/:filename`
|
||||||
|
- ✅ Список изображений: `GET /media/list`
|
||||||
|
- ✅ Автоматическая оптимизация изображений с Sharp
|
||||||
|
- ✅ Создание thumbnails (300x200, 800x600, 1200x900)
|
||||||
|
- ✅ Конвертация в WebP для лучшего сжатия
|
||||||
|
- ✅ Безопасность: аутентификация и валидация файлов
|
||||||
|
|
||||||
|
#### **B. Frontend Редактор (/views/admin/banner-editor.ejs)**
|
||||||
|
- ✅ Интуитивный интерфейс с табами для разных страниц
|
||||||
|
- ✅ Drag & Drop загрузка изображений
|
||||||
|
- ✅ Превью изображений с возможностью удаления
|
||||||
|
- ✅ Индикатор прогресса загрузки
|
||||||
|
- ✅ Галерея загруженных изображений
|
||||||
|
- ✅ Мгновенная смена баннеров одним кликом
|
||||||
|
- ✅ Локальное сохранение настроек в localStorage
|
||||||
|
|
||||||
|
#### **C. Интеграция с админкой**
|
||||||
|
- ✅ Новый маршрут: `/admin/banner-editor`
|
||||||
|
- ✅ Аутентификация через админ-сессии
|
||||||
|
- ✅ Интеграция с существующей админ панелью
|
||||||
|
|
||||||
|
## 🔧 Технические улучшения:
|
||||||
|
|
||||||
|
### **CSS архитектура:**
|
||||||
|
```css
|
||||||
|
base.css (16KB) - Принудительные стили и reset
|
||||||
|
main.css (11KB) - Компоненты и анимации + компактные баннеры
|
||||||
|
fixes.css (6KB) - Дополнительные исправления
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Оптимизация изображений:**
|
||||||
|
- Автоматическое создание 4 размеров (thumbnail, medium, large, original)
|
||||||
|
- Конвертация в WebP (экономия до 50% размера)
|
||||||
|
- Максимальный размер файла: 10MB
|
||||||
|
- Поддержка: JPG, PNG, GIF, WebP
|
||||||
|
|
||||||
|
### **Безопасность:**
|
||||||
|
- Валидация типов файлов
|
||||||
|
- Защита от path traversal атак
|
||||||
|
- Аутентификация для всех операций
|
||||||
|
- Автоматическая очистка при ошибках
|
||||||
|
|
||||||
|
## 📊 Результаты тестирования:
|
||||||
|
|
||||||
|
```
|
||||||
|
🏠 ГЛАВНАЯ СТРАНИЦА: ✅ 200 OK (0.015s)
|
||||||
|
🎨 О НАС (компактный): ✅ 200 OK (0.007s)
|
||||||
|
🎨 УСЛУГИ (компактный): ✅ 200 OK (0.009s)
|
||||||
|
📱 ПОРТФОЛИО: ✅ 200 OK (0.012s)
|
||||||
|
🧮 КАЛЬКУЛЯТОР: ✅ 200 OK (0.008s)
|
||||||
|
📸 РЕДАКТОР БАННЕРОВ: ✅ 302 (требует авторизации)
|
||||||
|
🎨 CSS ФАЙЛЫ: ✅ Все загружаются корректно
|
||||||
|
📁 UPLOADS ПАПКА: ✅ Создана и готова
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🚀 Как использовать редактор баннеров:
|
||||||
|
|
||||||
|
### **Шаг 1: Доступ**
|
||||||
|
```
|
||||||
|
URL: http://localhost:3000/admin/banner-editor
|
||||||
|
Требуется: Авторизация в админ панели
|
||||||
|
```
|
||||||
|
|
||||||
|
### **Шаг 2: Загрузка изображений**
|
||||||
|
1. Нажать "Загрузить Изображения"
|
||||||
|
2. Перетащить файлы или выбрать через кнопку
|
||||||
|
3. Посмотреть превью и нажать "Загрузить"
|
||||||
|
4. Система автоматически создаст оптимизированные версии
|
||||||
|
|
||||||
|
### **Шаг 3: Установка баннера**
|
||||||
|
1. Выбрать страницу во вкладках (Главная, О нас, Услуги, Портфолио)
|
||||||
|
2. Нажать "Использовать" на нужном изображении
|
||||||
|
3. Баннер мгновенно обновится
|
||||||
|
4. Настройки сохраняются автоматически
|
||||||
|
|
||||||
|
### **Шаг 4: Управление**
|
||||||
|
- Удаление: кнопка "Удалить" в галерее
|
||||||
|
- Обновление: кнопка "Обновить"
|
||||||
|
- Сброс к градиенту: кнопка "Удалить" на текущем баннере
|
||||||
|
|
||||||
|
## 🎯 Итоговое состояние:
|
||||||
|
|
||||||
|
### ✅ **Что работает:**
|
||||||
|
- Главная страница с исправленными стилями
|
||||||
|
- Компактные баннеры на внутренних страницах
|
||||||
|
- Полнофункциональный редактор изображений
|
||||||
|
- API для загрузки и управления медиа
|
||||||
|
- Автоматическая оптимизация изображений
|
||||||
|
- Безопасная система аутентификации
|
||||||
|
|
||||||
|
### 🔄 **Готово к использованию:**
|
||||||
|
- Сервер: `http://localhost:3000` (PID: 24059)
|
||||||
|
- Админка: `http://localhost:3000/admin`
|
||||||
|
- Редактор: `http://localhost:3000/admin/banner-editor`
|
||||||
|
- Папка загрузок: `/public/uploads/` (готова к использованию)
|
||||||
|
|
||||||
|
### 📈 **Преимущества реализации:**
|
||||||
|
1. **Производительность**: WebP формат + множественные размеры
|
||||||
|
2. **UX**: Drag & Drop + мгновенные превью
|
||||||
|
3. **Безопасность**: Полная валидация + аутентификация
|
||||||
|
4. **Масштабируемость**: Готова к добавлению новых страниц
|
||||||
|
5. **Мобильность**: Отзывчивый дизайн на всех устройствах
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**🎉 Все задачи выполнены успешно! Система готова к продуктивному использованию.**
|
||||||
30
config/database.js
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
const { Sequelize } = require('sequelize');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const sequelize = new Sequelize(process.env.DATABASE_URL || {
|
||||||
|
host: process.env.DB_HOST || 'localhost',
|
||||||
|
port: process.env.DB_PORT || 5432,
|
||||||
|
database: process.env.DB_NAME || 'smartsoltech',
|
||||||
|
username: process.env.DB_USER || 'postgres',
|
||||||
|
password: process.env.DB_PASSWORD || 'password',
|
||||||
|
dialect: 'postgres',
|
||||||
|
logging: process.env.NODE_ENV === 'development' ? console.log : false,
|
||||||
|
pool: {
|
||||||
|
max: 5,
|
||||||
|
min: 0,
|
||||||
|
acquire: 30000,
|
||||||
|
idle: 10000
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test the connection
|
||||||
|
async function testConnection() {
|
||||||
|
try {
|
||||||
|
await sequelize.authenticate();
|
||||||
|
console.log('✓ PostgreSQL connected successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ PostgreSQL connection error:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { sequelize, testConnection };
|
||||||
5
cookies.txt
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
# Netscape HTTP Cookie File
|
||||||
|
# https://curl.se/docs/http-cookies.html
|
||||||
|
# This file was generated by libcurl! Edit at your own risk.
|
||||||
|
|
||||||
|
#HttpOnly_localhost FALSE / FALSE 1761640548 connect.sid s%3Avv-twFS8-lM9AQEsCor7mjMMTS7bSAIi.D1LM3WdMrHiBcGv175fp8L3ORi4gsGQ3RSm9a3H59to
|
||||||
645
locales/en.json
@@ -1,318 +1,331 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
"about": "About",
|
"about": "About",
|
||||||
"services": "Services",
|
"services": "Services",
|
||||||
"portfolio": "Portfolio",
|
"portfolio": "Portfolio",
|
||||||
"contact": "Contact",
|
"contact": "Contact",
|
||||||
"calculator": "Calculator",
|
"calculator": "Calculator",
|
||||||
"admin": "Admin",
|
"admin": "Admin"
|
||||||
"home - SmartSolTech": "navigation.home - SmartSolTech"
|
},
|
||||||
},
|
"hero": {
|
||||||
"hero": {
|
"title": {
|
||||||
"title": {
|
"smart": "Smart",
|
||||||
"smart": "hero.title.smart",
|
"solutions": "Solutions"
|
||||||
"solutions": "hero.title.solutions"
|
},
|
||||||
},
|
"subtitle": "Grow your business with innovative technology",
|
||||||
"subtitle": "Solutions",
|
"description": "Innovative web development, mobile apps, UI/UX design leading your business digital transformation",
|
||||||
"description": "Innovative web development, mobile apps, UI/UX design leading your business digital transformation",
|
"cta": {
|
||||||
"cta_primary": "Start Project",
|
"start": "Get Started",
|
||||||
"cta_secondary": "View Portfolio",
|
"portfolio": "View Portfolio"
|
||||||
"cta": {
|
}
|
||||||
"start": "hero.cta.start",
|
},
|
||||||
"portfolio": "hero.cta.portfolio"
|
"services": {
|
||||||
}
|
"title": {
|
||||||
},
|
"our": "Our",
|
||||||
"services": {
|
"services": "Services"
|
||||||
"title": {
|
},
|
||||||
"our": "services.title.our",
|
"subtitle": "Professional development services to turn your ideas into reality",
|
||||||
"services": "services.title.services"
|
"description": "Digital solutions completed with cutting-edge technology and creative ideas",
|
||||||
},
|
"view_all": "View All Services",
|
||||||
"title_highlight": "Services",
|
"web": {
|
||||||
"description": "Digital solutions completed with cutting-edge technology and creative ideas",
|
"title": "Web Development",
|
||||||
"web_development": {
|
"description": "Responsive websites and web application development",
|
||||||
"title": "Web Development",
|
"price": "From $500"
|
||||||
"description": "Modern and responsive websites and web applications development",
|
},
|
||||||
"price": "$5,000~"
|
"mobile": {
|
||||||
},
|
"title": "Mobile Apps",
|
||||||
"mobile_app": {
|
"description": "iOS and Android native app development",
|
||||||
"title": "Mobile App",
|
"price": "From $1,000"
|
||||||
"description": "Native and cross-platform apps for iOS and Android",
|
},
|
||||||
"price": "$8,000~"
|
"design": {
|
||||||
},
|
"title": "UI/UX Design",
|
||||||
"ui_ux_design": {
|
"description": "User-centered interface and experience design",
|
||||||
"title": "UI/UX Design",
|
"price": "From $300"
|
||||||
"description": "User-centered intuitive and beautiful interface design",
|
},
|
||||||
"price": "$3,000~"
|
"marketing": {
|
||||||
},
|
"title": "Digital Marketing",
|
||||||
"digital_marketing": {
|
"description": "SEO, social media marketing, advertising management",
|
||||||
"title": "Digital Marketing",
|
"price": "From $200"
|
||||||
"description": "Digital marketing through SEO, social media, online advertising",
|
},
|
||||||
"price": "$2,000~"
|
"meta": {
|
||||||
},
|
"title": "Services",
|
||||||
"view_all": "View All Services",
|
"description": "Check out SmartSolTech's professional services. Web development, mobile apps, UI/UX design, digital marketing and other technology solutions.",
|
||||||
"subtitle": "services.subtitle",
|
"keywords": "web development, mobile apps, UI/UX design, digital marketing, technology solutions, SmartSolTech"
|
||||||
"web": {
|
},
|
||||||
"title": "services.web.title",
|
"hero": {
|
||||||
"description": "services.web.description",
|
"title": "Our",
|
||||||
"price": "services.web.price"
|
"title_highlight": "Services",
|
||||||
},
|
"subtitle": "Support business growth with innovative technology"
|
||||||
"mobile": {
|
},
|
||||||
"title": "services.mobile.title",
|
"cards": {
|
||||||
"description": "services.mobile.description",
|
"starting_price": "Starting Price",
|
||||||
"price": "services.mobile.price"
|
"consultation": "consultation",
|
||||||
},
|
"contact": "Contact",
|
||||||
"design": {
|
"calculate_cost": "Calculate Cost",
|
||||||
"title": "services.design.title",
|
"popular": "Popular",
|
||||||
"description": "services.design.description",
|
"coming_soon": "Services Coming Soon",
|
||||||
"price": "services.design.price"
|
"coming_soon_desc": "We'll soon offer various services!"
|
||||||
},
|
},
|
||||||
"marketing": {
|
"process": {
|
||||||
"title": "services.marketing.title",
|
"title": "Project Implementation Process",
|
||||||
"description": "services.marketing.description",
|
"subtitle": "We conduct projects with systematic and professional processes",
|
||||||
"price": "services.marketing.price"
|
"consultation": {
|
||||||
}
|
"title": "Consultation and Planning",
|
||||||
},
|
"description": "Accurately understand customer requirements"
|
||||||
"portfolio": {
|
}
|
||||||
"title": {
|
}
|
||||||
"recent": "portfolio.title.recent",
|
},
|
||||||
"projects": "portfolio.title.projects"
|
"portfolio": {
|
||||||
},
|
"title": {
|
||||||
"title_highlight": "Projects",
|
"recent": "Recent",
|
||||||
"description": "Check out the projects completed for customer success",
|
"projects": "Projects"
|
||||||
"view_details": "View Details",
|
},
|
||||||
"view_all": "View All Portfolio",
|
"subtitle": "Check out successfully completed projects",
|
||||||
"subtitle": "portfolio.subtitle"
|
"description": "Check out the projects completed for customer success",
|
||||||
},
|
"view_details": "View Details",
|
||||||
"calculator": {
|
"view_all": "View All Portfolio",
|
||||||
"title": "Project Cost Calculator",
|
"categories": {
|
||||||
"subtitle": "Select your desired services and requirements to get accurate cost estimates in real time",
|
"all": "All",
|
||||||
"meta": {
|
"web": "Web Development",
|
||||||
"title": "Project Cost Calculator",
|
"mobile": "Mobile Apps",
|
||||||
"description": "Calculate the cost of your web development, mobile app, or design project with our interactive calculator"
|
"uiux": "UI/UX Design"
|
||||||
},
|
},
|
||||||
"cta": {
|
"project_details": "Project Details",
|
||||||
"title": "Check Your Project Estimate",
|
"default": {
|
||||||
"subtitle": "Select your desired services and requirements to calculate costs in real time",
|
"ecommerce": "E-commerce",
|
||||||
"button": "Use Cost Calculator"
|
"title": "E-commerce Platform",
|
||||||
},
|
"description": "Modern online commerce solution with intuitive interface"
|
||||||
"step1": {
|
},
|
||||||
"title": "Step 1: Service Selection",
|
"meta": {
|
||||||
"subtitle": "Please select the services you need (multiple selection allowed)"
|
"title": "Portfolio",
|
||||||
},
|
"description": "Check out SmartSolTech's diverse projects and success stories. Web development, mobile apps, UI/UX design portfolio.",
|
||||||
"step2": {
|
"keywords": "portfolio, web development, mobile apps, UI/UX design, projects, SmartSolTech"
|
||||||
"title": "Step 2: Project Details",
|
}
|
||||||
"subtitle": "Select project complexity and timeline"
|
},
|
||||||
},
|
"calculator": {
|
||||||
"complexity": {
|
"title": "Project Cost Calculator",
|
||||||
"title": "Project Complexity",
|
"subtitle": "Select your desired services and requirements to get accurate cost estimates in real time",
|
||||||
"simple": "Simple",
|
"meta": {
|
||||||
"simple_desc": "Basic features, standard design",
|
"title": "Project Cost Calculator",
|
||||||
"medium": "Medium",
|
"description": "Calculate the cost of your web development, mobile app, or design project with our interactive calculator"
|
||||||
"medium_desc": "Additional features, custom design",
|
},
|
||||||
"complex": "Complex",
|
"cta": {
|
||||||
"complex_desc": "Advanced features, complex integrations"
|
"title": "Check Your Project Estimate",
|
||||||
},
|
"subtitle": "Select your desired services and requirements to calculate costs in real time",
|
||||||
"timeline": {
|
"button": "Use Cost Calculator"
|
||||||
"title": "Development Timeline",
|
}
|
||||||
"standard": "Standard",
|
},
|
||||||
"standard_desc": "Normal development timeframe",
|
"contact": {
|
||||||
"rush": "Rush",
|
"hero": {
|
||||||
"rush_desc": "Fast development (+50%)",
|
"title": "Contact Us",
|
||||||
"extended": "Extended",
|
"subtitle": "We're here to help bring your ideas to life"
|
||||||
"extended_desc": "Flexible development timeline (-20%)"
|
},
|
||||||
},
|
"ready_title": "Ready to Start Your Project?",
|
||||||
"result": {
|
"ready_description": "Turn your ideas into reality. Experts provide the best solutions.",
|
||||||
"title": "Estimate Results",
|
"form": {
|
||||||
"subtitle": "Here's your preliminary project cost estimate",
|
"title": "Project Inquiry",
|
||||||
"estimated_price": "Estimated Price",
|
"name": "Name",
|
||||||
"price_note": "* Final cost may vary based on project details",
|
"email": "Email",
|
||||||
"summary": "Project Summary",
|
"phone": "Phone",
|
||||||
"selected_services": "Selected Services",
|
"message": "Message",
|
||||||
"complexity": "Complexity",
|
"submit": "Send Inquiry",
|
||||||
"timeline": "Timeline",
|
"success": "Inquiry sent successfully",
|
||||||
"get_quote": "Get Accurate Quote",
|
"error": "Error occurred while sending inquiry",
|
||||||
"recalculate": "Recalculate",
|
"service": {
|
||||||
"contact_note": "Contact us for an accurate quote and to discuss project details"
|
"title": "Service Interest",
|
||||||
},
|
"select": "Select service of interest",
|
||||||
"next_step": "Next Step",
|
"web": "Web Development",
|
||||||
"prev_step": "Previous",
|
"mobile": "Mobile App",
|
||||||
"calculate": "Calculate"
|
"design": "UI/UX Design",
|
||||||
},
|
"branding": "Branding",
|
||||||
"contact": {
|
"consulting": "Consulting",
|
||||||
"ready_title": "Ready to Start Your Project?",
|
"other": "Other"
|
||||||
"ready_description": "Turn your ideas into reality. Experts provide the best solutions.",
|
}
|
||||||
"phone_consultation": "Phone Consultation",
|
},
|
||||||
"email_inquiry": "Email Inquiry",
|
"info": {
|
||||||
"telegram_chat": "Telegram Chat",
|
"title": "Contact Information"
|
||||||
"instant_response": "Instant response available",
|
},
|
||||||
"free_consultation": "Free Consultation Application",
|
"phone": {
|
||||||
"form": {
|
"title": "Phone Inquiry",
|
||||||
"name": "Name",
|
"number": "+82-2-1234-5678",
|
||||||
"email": "Email",
|
"hours": "Mon-Fri 9:00-18:00"
|
||||||
"phone": "Phone",
|
},
|
||||||
"service_interest": "Service Interest",
|
"email": {
|
||||||
"service_options": {
|
"title": "Email Inquiry",
|
||||||
"select": "Select Service Interest",
|
"address": "info@smartsoltech.co.kr",
|
||||||
"web_development": "Web Development",
|
"response": "Response within 24 hours"
|
||||||
"mobile_app": "Mobile App",
|
},
|
||||||
"ui_ux_design": "UI/UX Design",
|
"telegram": {
|
||||||
"branding": "Branding",
|
"title": "Telegram",
|
||||||
"consulting": "Consulting",
|
"subtitle": "For quick response"
|
||||||
"other": "Other"
|
},
|
||||||
},
|
"address": {
|
||||||
"message": "Please briefly describe your project",
|
"title": "Office Address",
|
||||||
"submit": "Apply for Consultation",
|
"line1": "123 Teheran-ro, Gangnam-gu",
|
||||||
"title": "contact.form.title",
|
"line2": "Seoul, South Korea"
|
||||||
"service": {
|
},
|
||||||
"select": "contact.form.service.select",
|
"cta": {
|
||||||
"web": "contact.form.service.web",
|
"ready": "Ready?",
|
||||||
"mobile": "contact.form.service.mobile",
|
"start": "Get Started",
|
||||||
"design": "contact.form.service.design",
|
"question": "Have questions?",
|
||||||
"branding": "contact.form.service.branding",
|
"subtitle": "We provide consultation on projects"
|
||||||
"consulting": "contact.form.service.consulting",
|
},
|
||||||
"other": "contact.form.service.other"
|
"meta": {
|
||||||
},
|
"title": "Contact",
|
||||||
"success": "contact.form.success",
|
"description": "Contact us anytime for project inquiries or consultation"
|
||||||
"error": "contact.form.error"
|
}
|
||||||
},
|
},
|
||||||
"cta": {
|
"about": {
|
||||||
"ready": "contact.cta.ready",
|
"hero": {
|
||||||
"start": "contact.cta.start",
|
"title": "About SmartSolTech",
|
||||||
"question": "contact.cta.question",
|
"subtitle": "Creating the future with innovation and technology"
|
||||||
"subtitle": "contact.cta.subtitle"
|
},
|
||||||
},
|
"company": {
|
||||||
"phone": {
|
"title": "Company Information",
|
||||||
"title": "contact.phone.title",
|
"description1": "SmartSolTech is a technology company established in 2020, recognized for expertise in web development, mobile app development, and UI/UX design.",
|
||||||
"number": "contact.phone.number"
|
"description2": "We accurately understand customer needs and provide innovative solutions using the latest technology."
|
||||||
},
|
},
|
||||||
"email": {
|
"stats": {
|
||||||
"title": "contact.email.title",
|
"projects": "Completed Projects",
|
||||||
"address": "contact.email.address"
|
"experience": "Years Experience",
|
||||||
},
|
"clients": "Satisfied Customers"
|
||||||
"telegram": {
|
},
|
||||||
"title": "contact.telegram.title",
|
"mission": {
|
||||||
"subtitle": "contact.telegram.subtitle"
|
"title": "Our Mission",
|
||||||
}
|
"description": "Our mission is to support customer business growth through technology and lead digital innovation."
|
||||||
},
|
},
|
||||||
"about": {
|
"values": {
|
||||||
"hero_title": "About",
|
"innovation": {
|
||||||
"hero_highlight": "SmartSolTech",
|
"title": "Innovation",
|
||||||
"hero_description": "Digital solution specialist leading customer success with innovative technology",
|
"description": "We provide innovative solutions through continuous R&D and adoption of cutting-edge technology."
|
||||||
"overview": {
|
},
|
||||||
"title": "Creating Future with Innovation and Creativity",
|
"quality": {
|
||||||
"description_1": "SmartSolTech is a digital solution specialist established in 2020, supporting customer business success with innovative technology and creative ideas in web development, mobile apps, and UI/UX design.",
|
"title": "Quality",
|
||||||
"description_2": "We don't just provide technology, but understand customer goals and propose optimal solutions to become partners growing together.",
|
"description": "We maintain high quality standards and provide high-quality products that customers can be satisfied with."
|
||||||
"stats": {
|
},
|
||||||
"projects": "100+",
|
"partnership": {
|
||||||
"projects_label": "Completed Projects",
|
"title": "Partnership",
|
||||||
"clients": "50+",
|
"description": "We create the best results through close communication and collaboration with customers."
|
||||||
"clients_label": "Satisfied Customers",
|
}
|
||||||
"experience": "4 years",
|
},
|
||||||
"experience_label": "Industry Experience"
|
"cta": {
|
||||||
},
|
"title": "We'll Grow Together",
|
||||||
"mission": "Our Mission",
|
"subtitle": "Turn your ideas into reality",
|
||||||
"mission_text": "Helping all businesses succeed in the digital age through technology",
|
"button": "Contact Us"
|
||||||
"vision": "Our Vision",
|
},
|
||||||
"vision_text": "Growing as a global digital solution company representing Korea to lead digital innovation for customers worldwide"
|
"meta": {
|
||||||
},
|
"title": "About Us",
|
||||||
"values": {
|
"description": "SmartSolTech is a professional development company that supports customer business growth with innovative technology"
|
||||||
"title": "Core",
|
}
|
||||||
"title_highlight": "Values",
|
},
|
||||||
"description": "Core values pursued by SmartSolTech",
|
"footer": {
|
||||||
"innovation": {
|
"description": "Digital solution specialist leading innovation",
|
||||||
"title": "Innovation",
|
"links": {
|
||||||
"description": "We provide innovative solutions through continuous R&D and adoption of cutting-edge technology."
|
"title": "Quick Links"
|
||||||
},
|
},
|
||||||
"collaboration": {
|
"contact": {
|
||||||
"title": "Collaboration",
|
"title": "Contact",
|
||||||
"description": "We create the best results through close communication and collaboration with customers."
|
"email": "info@smartsoltech.co.kr",
|
||||||
},
|
"phone": "+82-2-1234-5678",
|
||||||
"quality": {
|
"address": "123 Teheran-ro, Gangnam-gu, Seoul"
|
||||||
"title": "Quality",
|
},
|
||||||
"description": "We maintain high quality standards and provide high-quality products that customers can be satisfied with."
|
"copyright": "© 2024 SmartSolTech. All rights reserved."
|
||||||
},
|
},
|
||||||
"growth": {
|
"theme": {
|
||||||
"title": "Growth",
|
"light": "Light Theme",
|
||||||
"description": "We grow together with customers and pursue continuous learning and development."
|
"dark": "Dark Theme",
|
||||||
}
|
"toggle": "Toggle Theme"
|
||||||
},
|
},
|
||||||
"team": {
|
"language": {
|
||||||
"title": "Our",
|
"english": "English",
|
||||||
"title_highlight": "Team",
|
"korean": "한국어",
|
||||||
"description": "Introducing the SmartSolTech team with expertise and passion"
|
"russian": "Русский",
|
||||||
},
|
"kazakh": "Қазақша"
|
||||||
"tech_stack": {
|
},
|
||||||
"title": "Technology",
|
"common": {
|
||||||
"title_highlight": "Stack",
|
"loading": "Loading...",
|
||||||
"description": "We provide the best solutions with cutting-edge technology and proven tools",
|
"error": "Error occurred",
|
||||||
"frontend": "Frontend",
|
"success": "Success",
|
||||||
"backend": "Backend",
|
"view_more": "View More",
|
||||||
"mobile": "Mobile"
|
"back": "Back",
|
||||||
},
|
"next": "Next",
|
||||||
"cta": {
|
"previous": "Previous",
|
||||||
"title": "Become a Partner for Success Together",
|
"view_details": "View Details"
|
||||||
"description": "Take your business to the next level with SmartSolTech",
|
},
|
||||||
"partnership": "Partnership Inquiry",
|
"meta": {
|
||||||
"portfolio": "View Portfolio"
|
"description": "SmartSolTech - Innovative web development, mobile app development, UI/UX design services",
|
||||||
}
|
"keywords": "web development, mobile apps, UI/UX design, Korea",
|
||||||
},
|
"title": "SmartSolTech"
|
||||||
"footer": {
|
},
|
||||||
"company": {
|
"nav": {
|
||||||
"description": "footer.company.description"
|
"home": "Home",
|
||||||
},
|
"about": "About",
|
||||||
"description": "Digital solution specialist leading innovation",
|
"services": "Services",
|
||||||
"quick_links": "Quick Links",
|
"portfolio": "Portfolio",
|
||||||
"services": "Services",
|
"calculator": "Calculator"
|
||||||
"contact_info": "Contact Information",
|
},
|
||||||
"follow_us": "Follow Us",
|
"admin": {
|
||||||
"rights": "All rights reserved.",
|
"login": "Admin Panel Login",
|
||||||
"links": {
|
"dashboard": "Dashboard",
|
||||||
"title": "footer.links.title"
|
"title": "SmartSolTech Admin"
|
||||||
},
|
},
|
||||||
"contact": {
|
"company": {
|
||||||
"title": "footer.contact.title",
|
"name": "SmartSolTech",
|
||||||
"email": "footer.contact.email",
|
"description": "Digital solution specialist leading innovation",
|
||||||
"phone": "footer.contact.phone",
|
"email": "info@smartsoltech.kr",
|
||||||
"address": "footer.contact.address"
|
"phone": "+82-10-1234-5678"
|
||||||
},
|
},
|
||||||
"copyright": "footer.copyright",
|
"errors": {
|
||||||
"privacy": "footer.privacy",
|
"page_not_found": "Page not found",
|
||||||
"terms": "footer.terms"
|
"error_occurred": "Error occurred",
|
||||||
},
|
"title": "Error - SmartSolTech",
|
||||||
"theme": {
|
"default_title": "An Error Occurred",
|
||||||
"light": "Light Theme",
|
"default_message": "A problem occurred while processing the request.",
|
||||||
"dark": "Dark Theme",
|
"back_home": "Back to Home",
|
||||||
"toggle": "Toggle Theme"
|
"go_back": "Go Back",
|
||||||
},
|
"need_help": "Need Help?",
|
||||||
"language": {
|
"help_message": "If the problem persists, please contact us anytime.",
|
||||||
"english": "English",
|
"contact_support": "Contact Support"
|
||||||
"korean": "한국어",
|
},
|
||||||
"russian": "Русский",
|
"pages": {
|
||||||
"kazakh": "Қазақша",
|
"home": "Home page",
|
||||||
"ko": "language.ko"
|
"about": "About us",
|
||||||
},
|
"services": "Services",
|
||||||
"common": {
|
"portfolio": "Portfolio",
|
||||||
"loading": "Loading...",
|
"contact": "Contact",
|
||||||
"error": "Error occurred",
|
"calculator": "Calculator"
|
||||||
"success": "Success",
|
},
|
||||||
"view_more": "View More",
|
"portfolio_page": {
|
||||||
"back": "Back",
|
"title": "Our Portfolio",
|
||||||
"next": "Next",
|
"subtitle": "Discover innovative projects and creative solutions",
|
||||||
"previous": "Previous",
|
"categories": {
|
||||||
"view_details": "common.view_details"
|
"all": "All",
|
||||||
},
|
"web-development": "Web Development",
|
||||||
"undefined - SmartSolTech": "undefined - SmartSolTech",
|
"mobile-app": "Mobile App",
|
||||||
"meta": {
|
"ui-ux-design": "UI/UX Design",
|
||||||
"description": "meta.description",
|
"branding": "Branding",
|
||||||
"keywords": "meta.keywords",
|
"marketing": "Digital Marketing"
|
||||||
"title": "meta.title"
|
},
|
||||||
},
|
"buttons": {
|
||||||
"nav": {
|
"details": "View Details",
|
||||||
"home": "nav.home",
|
"projectDetails": "Project Details",
|
||||||
"about": "nav.about",
|
"loadMore": "Load More Projects",
|
||||||
"services": "nav.services",
|
"contact": "Request Project",
|
||||||
"portfolio": "nav.portfolio",
|
"calculate": "Calculate Cost"
|
||||||
"calculator": "nav.calculator"
|
},
|
||||||
}
|
"empty": {
|
||||||
|
"title": "No portfolio yet",
|
||||||
|
"subtitle": "We'll be showcasing amazing projects soon!"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Be the star of the next project",
|
||||||
|
"subtitle": "Create innovative digital solutions with us"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"featured": "FEATURED",
|
||||||
|
"views": "views",
|
||||||
|
"likes": "likes"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
839
locales/kk.json
@@ -1,318 +1,525 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Басты бет",
|
"home": "Басты бет",
|
||||||
"about": "Біз туралы",
|
"about": "Біз туралы",
|
||||||
"services": "Қызметтер",
|
"services": "Қызметтер",
|
||||||
"portfolio": "Портфолио",
|
"portfolio": "Портфолио",
|
||||||
"contact": "Байланыс",
|
"contact": "Байланыс",
|
||||||
"calculator": "Калькулятор",
|
"calculator": "Калькулятор",
|
||||||
"admin": "Админ",
|
"admin": "Админ",
|
||||||
"home - SmartSolTech": "navigation.home - SmartSolTech"
|
"home - SmartSolTech": "navigation.home - SmartSolTech"
|
||||||
},
|
},
|
||||||
"hero": {
|
"hero": {
|
||||||
"title": {
|
"title": {
|
||||||
"smart": "hero.title.smart",
|
"smart": "hero.title.smart",
|
||||||
"solutions": "hero.title.solutions"
|
"solutions": "hero.title.solutions"
|
||||||
},
|
},
|
||||||
"subtitle": "Шешімдер",
|
"subtitle": "Шешімдер",
|
||||||
"description": "Инновациялық веб-әзірлеу, мобильді қосымшалар, UI/UX дизайн арқылы бизнестің цифрлық трансформациясын жүргіземіз",
|
"description": "Инновациялық веб-әзірлеу, мобильді қосымшалар, UI/UX дизайн арқылы бизнестің цифрлық трансформациясын жүргіземіз",
|
||||||
"cta_primary": "Жобаны бастау",
|
"cta": {
|
||||||
"cta_secondary": "Портфолионы көру",
|
"start": "hero.cta.start",
|
||||||
"cta": {
|
"portfolio": "hero.cta.portfolio"
|
||||||
"start": "hero.cta.start",
|
},
|
||||||
"portfolio": "hero.cta.portfolio"
|
"cta_primary": "Жобаны бастау",
|
||||||
}
|
"cta_secondary": "Портфолионы көру"
|
||||||
},
|
},
|
||||||
"services": {
|
"services": {
|
||||||
"title": {
|
"title": {
|
||||||
"our": "services.title.our",
|
"our": "services.title.our",
|
||||||
"services": "services.title.services"
|
"services": "services.title.services"
|
||||||
},
|
},
|
||||||
"title_highlight": "Қызметтер",
|
"subtitle": "services.subtitle",
|
||||||
"description": "Заманауи технология және шығармашылық идеялармен жасалған цифрлық шешімдер",
|
"description": "Заманауи технология және шығармашылық идеялармен жасалған цифрлық шешімдер",
|
||||||
"web_development": {
|
"view_all": "Барлық қызметтерді көру",
|
||||||
"title": "Веб-әзірлеу",
|
"web": {
|
||||||
"description": "Заманауи және бейімделгіш веб-сайттар мен веб-қосымшаларды әзірлеу",
|
"title": "services.web.title",
|
||||||
"price": "$5,000~"
|
"description": "services.web.description",
|
||||||
},
|
"price": "services.web.price"
|
||||||
"mobile_app": {
|
},
|
||||||
"title": "Мобильді қосымшалар",
|
"mobile": {
|
||||||
"description": "iOS және Android үшін нативті және кросс-платформалық қосымшалар",
|
"title": "services.mobile.title",
|
||||||
"price": "$8,000~"
|
"description": "services.mobile.description",
|
||||||
},
|
"price": "services.mobile.price"
|
||||||
"ui_ux_design": {
|
},
|
||||||
"title": "UI/UX дизайн",
|
"design": {
|
||||||
"description": "Пайдаланушы-орталықты интуитивті және әдемі интерфейс дизайны",
|
"title": "services.design.title",
|
||||||
"price": "$3,000~"
|
"description": "services.design.description",
|
||||||
},
|
"price": "services.design.price"
|
||||||
"digital_marketing": {
|
},
|
||||||
"title": "Цифрлық маркетинг",
|
"marketing": {
|
||||||
"description": "SEO, әлеуметтік медиа, онлайн жарнама арқылы цифрлық маркетинг",
|
"title": "services.marketing.title",
|
||||||
"price": "$2,000~"
|
"description": "services.marketing.description",
|
||||||
},
|
"price": "services.marketing.price"
|
||||||
"view_all": "Барлық қызметтерді көру",
|
},
|
||||||
"subtitle": "services.subtitle",
|
"meta": {
|
||||||
"web": {
|
"title": "Services",
|
||||||
"title": "services.web.title",
|
"description": "Check out SmartSolTech's professional services. Web development, mobile apps, UI/UX design, digital marketing and other technology solutions.",
|
||||||
"description": "services.web.description",
|
"keywords": "web development, mobile apps, UI/UX design, digital marketing, technology solutions, SmartSolTech"
|
||||||
"price": "services.web.price"
|
},
|
||||||
},
|
"hero": {
|
||||||
"mobile": {
|
"title": "Our",
|
||||||
"title": "services.mobile.title",
|
"title_highlight": "Services",
|
||||||
"description": "services.mobile.description",
|
"subtitle": "Support business growth with innovative technology"
|
||||||
"price": "services.mobile.price"
|
},
|
||||||
},
|
"cards": {
|
||||||
"design": {
|
"starting_price": "Starting Price",
|
||||||
"title": "services.design.title",
|
"consultation": "consultation",
|
||||||
"description": "services.design.description",
|
"contact": "Contact",
|
||||||
"price": "services.design.price"
|
"calculate_cost": "Calculate Cost",
|
||||||
},
|
"popular": "Popular",
|
||||||
"marketing": {
|
"coming_soon": "Services Coming Soon",
|
||||||
"title": "services.marketing.title",
|
"coming_soon_desc": "We'll soon offer various services!"
|
||||||
"description": "services.marketing.description",
|
},
|
||||||
"price": "services.marketing.price"
|
"process": {
|
||||||
}
|
"title": "Project Implementation Process",
|
||||||
},
|
"subtitle": "We conduct projects with systematic and professional processes",
|
||||||
"portfolio": {
|
"consultation": {
|
||||||
"title": {
|
"title": "Consultation and Planning",
|
||||||
"recent": "portfolio.title.recent",
|
"description": "Accurately understand customer requirements and"
|
||||||
"projects": "portfolio.title.projects"
|
}
|
||||||
},
|
},
|
||||||
"title_highlight": "Жобалар",
|
"title_highlight": "Қызметтер",
|
||||||
"description": "Тұтынушылардың табысы үшін аяқталған жобаларды тексеріңіз",
|
"web_development": {
|
||||||
"view_details": "Толығырақ",
|
"title": "Веб-әзірлеу",
|
||||||
"view_all": "Барлық портфолионы көру",
|
"description": "Заманауи және бейімделгіш веб-сайттар мен веб-қосымшаларды әзірлеу",
|
||||||
"subtitle": "portfolio.subtitle"
|
"price": "$5,000~"
|
||||||
},
|
},
|
||||||
"calculator": {
|
"mobile_app": {
|
||||||
"title": "Жоба Құнының Калькуляторы",
|
"title": "Мобильді қосымшалар",
|
||||||
"subtitle": "Қажетті қызметтер мен талаптарды таңдап, нақты уақытта дәл бағаны алыңыз",
|
"description": "iOS және Android үшін нативті және кросс-платформалық қосымшалар",
|
||||||
"meta": {
|
"price": "$8,000~"
|
||||||
"title": "Жоба құнының калькуляторы",
|
},
|
||||||
"description": "Веб-әзірлеу, мобильді қосымша немесе дизайн жобасының құнын біздің интерактивті калькулятормен есептеңіз"
|
"ui_ux_design": {
|
||||||
},
|
"title": "UI/UX дизайн",
|
||||||
"cta": {
|
"description": "Пайдаланушы-орталықты интуитивті және әдемі интерфейс дизайны",
|
||||||
"title": "Жобаның бағасын тексеріңіз",
|
"price": "$3,000~"
|
||||||
"subtitle": "Қажетті қызметтер мен талаптарды таңдап, нақты уақытта бағаны есептейміз",
|
},
|
||||||
"button": "Құн калькуляторын пайдалану"
|
"digital_marketing": {
|
||||||
},
|
"title": "Цифрлық маркетинг",
|
||||||
"step1": {
|
"description": "SEO, әлеуметтік медиа, онлайн жарнама арқылы цифрлық маркетинг",
|
||||||
"title": "1-қадам: Қызмет таңдау",
|
"price": "$2,000~"
|
||||||
"subtitle": "Қажетті қызметтерді таңдаңыз (бірнеше таңдауға болады)"
|
}
|
||||||
},
|
},
|
||||||
"step2": {
|
"portfolio": {
|
||||||
"title": "2-қадам: Жоба мәліметтері",
|
"title": {
|
||||||
"subtitle": "Жобаның күрделілігі мен мерзімін таңдаңыз"
|
"recent": "portfolio.title.recent",
|
||||||
},
|
"projects": "portfolio.title.projects",
|
||||||
"complexity": {
|
"our": "Our",
|
||||||
"title": "Жобаның күрделілігі",
|
"portfolio": "Portfolio"
|
||||||
"simple": "Қарапайым",
|
},
|
||||||
"simple_desc": "Негізгі функциялар, стандартты дизайн",
|
"subtitle": "portfolio.subtitle",
|
||||||
"medium": "Орташа",
|
"description": "Тұтынушылардың табысы үшін аяқталған жобаларды тексеріңіз",
|
||||||
"medium_desc": "Қосымша функциялар, жеке дизайн",
|
"view_details": "Толығырақ",
|
||||||
"complex": "Күрделі",
|
"view_all": "Барлық портфолионы көру",
|
||||||
"complex_desc": "Кеңейтілген функциялар, күрделі интеграциялар"
|
"categories": {
|
||||||
},
|
"all": "All",
|
||||||
"timeline": {
|
"web": "Web Development",
|
||||||
"title": "Әзірлеу мерзімі",
|
"mobile": "Mobile Apps",
|
||||||
"standard": "Стандартты",
|
"uiux": "UI/UX Design"
|
||||||
"standard_desc": "Қалыпты әзірлеу мерзімі",
|
},
|
||||||
"rush": "Асығыс",
|
"project_details": "Project Details",
|
||||||
"rush_desc": "Жылдам әзірлеу (+50%)",
|
"default": {
|
||||||
"extended": "Кеңейтілген",
|
"ecommerce": "E-commerce",
|
||||||
"extended_desc": "Икемді әзірлеу мерзімі (-20%)"
|
"title": "E-commerce Platform",
|
||||||
},
|
"description": "Modern online commerce solution with intuitive interface"
|
||||||
"result": {
|
},
|
||||||
"title": "Есептеу нәтижесі",
|
"meta": {
|
||||||
"subtitle": "Міне, сіздің алдын ала жоба құнының бағасы",
|
"title": "Portfolio",
|
||||||
"estimated_price": "Алдын ала баға",
|
"description": "Check out SmartSolTech's diverse projects and success stories. Web development, mobile apps, UI/UX design portfolio.",
|
||||||
"price_note": "* Соңғы құн жоба мәліметтеріне байланысты өзгеруі мүмкін",
|
"keywords": "portfolio, web development, mobile apps, UI/UX design, projects, SmartSolTech",
|
||||||
"summary": "Жоба қорытындысы",
|
"og_title": "Portfolio - SmartSolTech",
|
||||||
"selected_services": "Таңдалған қызметтер",
|
"og_description": "SmartSolTech's diverse projects and success stories"
|
||||||
"complexity": "Күрделілік",
|
},
|
||||||
"timeline": "Мерзім",
|
"title_highlight": "Жобалар",
|
||||||
"get_quote": "Дәл ұсыныс алу",
|
"view_project": "View Project"
|
||||||
"recalculate": "Қайта есептеу",
|
},
|
||||||
"contact_note": "Дәл ұсыныс алу және жоба мәліметтерін талқылау үшін бізбен байланысыңыз"
|
"calculator": {
|
||||||
},
|
"title": "Жоба Құнының Калькуляторы",
|
||||||
"next_step": "Келесі қадам",
|
"subtitle": "Қажетті қызметтер мен талаптарды таңдап, нақты уақытта дәл бағаны алыңыз",
|
||||||
"prev_step": "Артқа",
|
"meta": {
|
||||||
"calculate": "Есептеу"
|
"title": "Жоба құнының калькуляторы",
|
||||||
},
|
"description": "Веб-әзірлеу, мобильді қосымша немесе дизайн жобасының құнын біздің интерактивті калькулятормен есептеңіз"
|
||||||
"contact": {
|
},
|
||||||
"ready_title": "Жобаңызды бастауға дайынсыз ба?",
|
"cta": {
|
||||||
"ready_description": "Идеяларыңызды шындыққа айналдырыңыз. Сарапшылар ең жақсы шешімдерді ұсынады.",
|
"title": "Жобаның бағасын тексеріңіз",
|
||||||
"phone_consultation": "Телефон кеңесі",
|
"subtitle": "Қажетті қызметтер мен талаптарды таңдап, нақты уақытта бағаны есептейміз",
|
||||||
"email_inquiry": "Электрондық пошта сұрауы",
|
"button": "Құн калькуляторын пайдалану"
|
||||||
"telegram_chat": "Telegram чаты",
|
},
|
||||||
"instant_response": "Лезде жауап беру мүмкін",
|
"step1": {
|
||||||
"free_consultation": "Тегін кеңес беру өтініші",
|
"title": "1-қадам: Қызмет таңдау",
|
||||||
"form": {
|
"subtitle": "Қажетті қызметтерді таңдаңыз (бірнеше таңдауға болады)"
|
||||||
"name": "Аты",
|
},
|
||||||
"email": "Электрондық пошта",
|
"step2": {
|
||||||
"phone": "Телефон",
|
"title": "2-қадам: Жоба мәліметтері",
|
||||||
"service_interest": "Қызығатын қызмет",
|
"subtitle": "Жобаның күрделілігі мен мерзімін таңдаңыз"
|
||||||
"service_options": {
|
},
|
||||||
"select": "Қызығатын қызметті таңдаңыз",
|
"complexity": {
|
||||||
"web_development": "Веб-әзірлеу",
|
"title": "Жобаның күрделілігі",
|
||||||
"mobile_app": "Мобильді қосымша",
|
"simple": "Қарапайым",
|
||||||
"ui_ux_design": "UI/UX дизайн",
|
"simple_desc": "Негізгі функциялар, стандартты дизайн",
|
||||||
"branding": "Брендинг",
|
"medium": "Орташа",
|
||||||
"consulting": "Кеңес беру",
|
"medium_desc": "Қосымша функциялар, жеке дизайн",
|
||||||
"other": "Басқа"
|
"complex": "Күрделі",
|
||||||
},
|
"complex_desc": "Кеңейтілген функциялар, күрделі интеграциялар"
|
||||||
"message": "Жобаңыз туралы қысқаша сипаттаңыз",
|
},
|
||||||
"submit": "Кеңес беру үшін өтініш беру",
|
"timeline": {
|
||||||
"title": "contact.form.title",
|
"title": "Әзірлеу мерзімі",
|
||||||
"service": {
|
"standard": "Стандартты",
|
||||||
"select": "contact.form.service.select",
|
"standard_desc": "Қалыпты әзірлеу мерзімі",
|
||||||
"web": "contact.form.service.web",
|
"rush": "Асығыс",
|
||||||
"mobile": "contact.form.service.mobile",
|
"rush_desc": "Жылдам әзірлеу (+50%)",
|
||||||
"design": "contact.form.service.design",
|
"extended": "Кеңейтілген",
|
||||||
"branding": "contact.form.service.branding",
|
"extended_desc": "Икемді әзірлеу мерзімі (-20%)"
|
||||||
"consulting": "contact.form.service.consulting",
|
},
|
||||||
"other": "contact.form.service.other"
|
"result": {
|
||||||
},
|
"title": "Есептеу нәтижесі",
|
||||||
"success": "contact.form.success",
|
"subtitle": "Міне, сіздің алдын ала жоба құнының бағасы",
|
||||||
"error": "contact.form.error"
|
"estimated_price": "Алдын ала баға",
|
||||||
},
|
"price_note": "* Соңғы құн жоба мәліметтеріне байланысты өзгеруі мүмкін",
|
||||||
"cta": {
|
"summary": "Жоба қорытындысы",
|
||||||
"ready": "contact.cta.ready",
|
"selected_services": "Таңдалған қызметтер",
|
||||||
"start": "contact.cta.start",
|
"complexity": "Күрделілік",
|
||||||
"question": "contact.cta.question",
|
"timeline": "Мерзім",
|
||||||
"subtitle": "contact.cta.subtitle"
|
"get_quote": "Дәл ұсыныс алу",
|
||||||
},
|
"recalculate": "Қайта есептеу",
|
||||||
"phone": {
|
"contact_note": "Дәл ұсыныс алу және жоба мәліметтерін талқылау үшін бізбен байланысыңыз"
|
||||||
"title": "contact.phone.title",
|
},
|
||||||
"number": "contact.phone.number"
|
"next_step": "Келесі қадам",
|
||||||
},
|
"prev_step": "Артқа",
|
||||||
"email": {
|
"calculate": "Есептеу"
|
||||||
"title": "contact.email.title",
|
},
|
||||||
"address": "contact.email.address"
|
"contact": {
|
||||||
},
|
"hero": {
|
||||||
"telegram": {
|
"title": "Contact Us",
|
||||||
"title": "contact.telegram.title",
|
"subtitle": "We're here to help bring your ideas to life"
|
||||||
"subtitle": "contact.telegram.subtitle"
|
},
|
||||||
}
|
"ready_title": "Жобаңызды бастауға дайынсыз ба?",
|
||||||
},
|
"ready_description": "Идеяларыңызды шындыққа айналдырыңыз. Сарапшылар ең жақсы шешімдерді ұсынады.",
|
||||||
"about": {
|
"form": {
|
||||||
"hero_title": "Туралы",
|
"title": "contact.form.title",
|
||||||
"hero_highlight": "SmartSolTech",
|
"name": "Аты",
|
||||||
"hero_description": "Инновациялық технологиямен тұтынушылардың табысына жетелейтін цифрлық шешімдер маманы",
|
"email": "Электрондық пошта",
|
||||||
"overview": {
|
"phone": "Телефон",
|
||||||
"title": "Инновация мен шығармашылықпен болашақты құру",
|
"message": "Жобаңыз туралы қысқаша сипаттаңыз",
|
||||||
"description_1": "SmartSolTech - 2020 жылы құрылған цифрлық шешімдер маманы, веб-әзірлеу, мобильді қосымшалар, UI/UX дизайн салаларында инновациялық технология мен шығармашылық идеялар негізінде тұтынушылардың бизнес табысын қолдайды.",
|
"submit": "Кеңес беру үшін өтініш беру",
|
||||||
"description_2": "Біз жай ғана технологияны ұсынып қана қоймаймыз, тұтынушылардың мақсаттарын түсініп, оларға сәйкес оңтайлы шешімдерді ұсынып, бірге өсетін серіктестер болуды мақсат етеміз.",
|
"success": "contact.form.success",
|
||||||
"stats": {
|
"error": "contact.form.error",
|
||||||
"projects": "100+",
|
"service": {
|
||||||
"projects_label": "Аяқталған жобалар",
|
"title": "Service Interest",
|
||||||
"clients": "50+",
|
"select": "contact.form.service.select",
|
||||||
"clients_label": "Қанағаттанған тұтынушылар",
|
"web": "contact.form.service.web",
|
||||||
"experience": "4 жыл",
|
"mobile": "contact.form.service.mobile",
|
||||||
"experience_label": "Саладағы тәжірибе"
|
"design": "contact.form.service.design",
|
||||||
},
|
"branding": "contact.form.service.branding",
|
||||||
"mission": "Біздің миссия",
|
"consulting": "contact.form.service.consulting",
|
||||||
"mission_text": "Технология арқылы барлық бизнестердің цифрлық дәуірде табысқа жетуіне көмектесу",
|
"other": "contact.form.service.other"
|
||||||
"vision": "Біздің көзқарас",
|
},
|
||||||
"vision_text": "Қазақстанды білдіретін жаһандық цифрлық шешімдер компаниясы ретінде өсіп, бүкіл әлемдегі тұтынушылардың цифрлық инновацияларын басқару"
|
"service_interest": "Қызығатын қызмет",
|
||||||
},
|
"service_options": {
|
||||||
"values": {
|
"select": "Қызығатын қызметті таңдаңыз",
|
||||||
"title": "Негізгі",
|
"web_development": "Веб-әзірлеу",
|
||||||
"title_highlight": "Құндылықтар",
|
"mobile_app": "Мобильді қосымша",
|
||||||
"description": "SmartSolTech ұстанатын негізгі құндылықтар",
|
"ui_ux_design": "UI/UX дизайн",
|
||||||
"innovation": {
|
"branding": "Брендинг",
|
||||||
"title": "Инновация",
|
"consulting": "Кеңес беру",
|
||||||
"description": "Үздіксіз зерттеу-әзірлеу және заманауи технологияларды енгізу арқылы инновациялық шешімдерді ұсынамыз."
|
"other": "Басқа"
|
||||||
},
|
}
|
||||||
"collaboration": {
|
},
|
||||||
"title": "Ынтымақтастық",
|
"info": {
|
||||||
"description": "Тұтынушылармен тығыз қарым-қатынас пен ынтымақтастық арқылы ең жақсы нәтижелерді жасаймыз."
|
"title": "Contact Information"
|
||||||
},
|
},
|
||||||
"quality": {
|
"phone": {
|
||||||
"title": "Сапа",
|
"title": "contact.phone.title",
|
||||||
"description": "Жоғары сапа стандарттарын сақтап, тұтынушылар қанағаттана алатын жоғары сапалы өнімдерді ұсынамыз."
|
"number": "contact.phone.number",
|
||||||
},
|
"hours": "Mon-Fri 9:00-18:00"
|
||||||
"growth": {
|
},
|
||||||
"title": "Өсу",
|
"email": {
|
||||||
"description": "Тұтынушылармен бірге өсіп, үздіксіз оқу мен дамуды мақсат етеміз."
|
"title": "contact.email.title",
|
||||||
}
|
"address": "contact.email.address",
|
||||||
},
|
"response": "Response within 24 hours"
|
||||||
"team": {
|
},
|
||||||
"title": "Біздің",
|
"telegram": {
|
||||||
"title_highlight": "Команда",
|
"title": "contact.telegram.title",
|
||||||
"description": "Сараптама мен құлшынысты SmartSolTech командасын таныстырамыз"
|
"subtitle": "contact.telegram.subtitle"
|
||||||
},
|
},
|
||||||
"tech_stack": {
|
"address": {
|
||||||
"title": "Технологиялық",
|
"title": "Office Address",
|
||||||
"title_highlight": "Стек",
|
"line1": "123 Teheran-ro, Gangnam-gu",
|
||||||
"description": "Заманауи технология мен дәлелденген құралдармен ең жақсы шешімдерді ұсынамыз",
|
"line2": "Seoul, South Korea"
|
||||||
"frontend": "Frontend",
|
},
|
||||||
"backend": "Backend",
|
"cta": {
|
||||||
"mobile": "Мобильді"
|
"ready": "contact.cta.ready",
|
||||||
},
|
"start": "contact.cta.start",
|
||||||
"cta": {
|
"question": "contact.cta.question",
|
||||||
"title": "Бірге табысқа жететін серіктес болыңыз",
|
"subtitle": "contact.cta.subtitle"
|
||||||
"description": "SmartSolTech-пен бизнесіңізді келесі деңгейге дамытыңыз",
|
},
|
||||||
"partnership": "Серіктестік сұрауы",
|
"meta": {
|
||||||
"portfolio": "Портфолионы көру"
|
"title": "Contact",
|
||||||
}
|
"description": "Contact us anytime for project inquiries or consultation"
|
||||||
},
|
},
|
||||||
"footer": {
|
"phone_consultation": "Телефон кеңесі",
|
||||||
"company": {
|
"email_inquiry": "Электрондық пошта сұрауы",
|
||||||
"description": "footer.company.description"
|
"telegram_chat": "Telegram чаты",
|
||||||
},
|
"instant_response": "Лезде жауап беру мүмкін",
|
||||||
"description": "Инновацияны басқаратын цифрлық шешімдер маманы",
|
"free_consultation": "Тегін кеңес беру өтініші"
|
||||||
"quick_links": "Жылдам сілтемелер",
|
},
|
||||||
"services": "Қызметтер",
|
"about": {
|
||||||
"contact_info": "Байланыс ақпараты",
|
"hero": {
|
||||||
"follow_us": "Бізді іздеңіз",
|
"title": "About SmartSolTech",
|
||||||
"rights": "Барлық құқықтар сақталған.",
|
"subtitle": "Creating the future with innovation and technology"
|
||||||
"links": {
|
},
|
||||||
"title": "footer.links.title"
|
"company": {
|
||||||
},
|
"title": "Company Information",
|
||||||
"contact": {
|
"description1": "SmartSolTech is a technology company established in 2020, recognized for expertise in web development, mobile app development, and UI/UX design.",
|
||||||
"title": "footer.contact.title",
|
"description2": "We accurately understand customer needs and provide innovative solutions using the latest technology."
|
||||||
"email": "footer.contact.email",
|
},
|
||||||
"phone": "footer.contact.phone",
|
"stats": {
|
||||||
"address": "footer.contact.address"
|
"projects": "Completed Projects",
|
||||||
},
|
"experience": "Years Experience",
|
||||||
"copyright": "footer.copyright",
|
"clients": "Satisfied Customers"
|
||||||
"privacy": "footer.privacy",
|
},
|
||||||
"terms": "footer.terms"
|
"mission": {
|
||||||
},
|
"title": "Our Mission",
|
||||||
"theme": {
|
"description": "Our mission is to support customer business growth through technology and lead digital innovation."
|
||||||
"light": "Ашық тема",
|
},
|
||||||
"dark": "Қараңғы тема",
|
"values": {
|
||||||
"toggle": "Теманы ауыстыру"
|
"innovation": {
|
||||||
},
|
"title": "Инновация",
|
||||||
"language": {
|
"description": "Үздіксіз зерттеу-әзірлеу және заманауи технологияларды енгізу арқылы инновациялық шешімдерді ұсынамыз."
|
||||||
"english": "English",
|
},
|
||||||
"korean": "한국어",
|
"quality": {
|
||||||
"russian": "Русский",
|
"title": "Сапа",
|
||||||
"kazakh": "Қазақша",
|
"description": "Жоғары сапа стандарттарын сақтап, тұтынушылар қанағаттана алатын жоғары сапалы өнімдерді ұсынамыз."
|
||||||
"ko": "language.ko"
|
},
|
||||||
},
|
"partnership": {
|
||||||
"common": {
|
"title": "Partnership",
|
||||||
"loading": "Жүктелуде...",
|
"description": "We create the best results through close communication and collaboration with customers."
|
||||||
"error": "Қате орын алды",
|
},
|
||||||
"success": "Сәтті",
|
"title": "Негізгі",
|
||||||
"view_more": "Көбірек көру",
|
"title_highlight": "Құндылықтар",
|
||||||
"back": "Артқа",
|
"description": "SmartSolTech ұстанатын негізгі құндылықтар",
|
||||||
"next": "Келесі",
|
"collaboration": {
|
||||||
"previous": "Алдыңғы",
|
"title": "Ынтымақтастық",
|
||||||
"view_details": "common.view_details"
|
"description": "Тұтынушылармен тығыз қарым-қатынас пен ынтымақтастық арқылы ең жақсы нәтижелерді жасаймыз."
|
||||||
},
|
},
|
||||||
"undefined - SmartSolTech": "undefined - SmartSolTech",
|
"growth": {
|
||||||
"meta": {
|
"title": "Өсу",
|
||||||
"description": "meta.description",
|
"description": "Тұтынушылармен бірге өсіп, үздіксіз оқу мен дамуды мақсат етеміз."
|
||||||
"keywords": "meta.keywords",
|
}
|
||||||
"title": "meta.title"
|
},
|
||||||
},
|
"cta": {
|
||||||
"nav": {
|
"title": "Бірге табысқа жететін серіктес болыңыз",
|
||||||
"home": "nav.home",
|
"subtitle": "Turn your ideas into reality",
|
||||||
"about": "nav.about",
|
"button": "Contact Us",
|
||||||
"services": "nav.services",
|
"description": "SmartSolTech-пен бизнесіңізді келесі деңгейге дамытыңыз",
|
||||||
"portfolio": "nav.portfolio",
|
"partnership": "Серіктестік сұрауы",
|
||||||
"calculator": "nav.calculator"
|
"portfolio": "Портфолионы көру"
|
||||||
}
|
},
|
||||||
|
"meta": {
|
||||||
|
"title": "About Us",
|
||||||
|
"description": "SmartSolTech is a professional development company that supports customer business growth with innovative technology"
|
||||||
|
},
|
||||||
|
"hero_title": "Туралы",
|
||||||
|
"hero_highlight": "SmartSolTech",
|
||||||
|
"hero_description": "Инновациялық технологиямен тұтынушылардың табысына жетелейтін цифрлық шешімдер маманы",
|
||||||
|
"overview": {
|
||||||
|
"title": "Инновация мен шығармашылықпен болашақты құру",
|
||||||
|
"description_1": "SmartSolTech - 2020 жылы құрылған цифрлық шешімдер маманы, веб-әзірлеу, мобильді қосымшалар, UI/UX дизайн салаларында инновациялық технология мен шығармашылық идеялар негізінде тұтынушылардың бизнес табысын қолдайды.",
|
||||||
|
"description_2": "Біз жай ғана технологияны ұсынып қана қоймаймыз, тұтынушылардың мақсаттарын түсініп, оларға сәйкес оңтайлы шешімдерді ұсынып, бірге өсетін серіктестер болуды мақсат етеміз.",
|
||||||
|
"stats": {
|
||||||
|
"projects": "100+",
|
||||||
|
"projects_label": "Аяқталған жобалар",
|
||||||
|
"clients": "50+",
|
||||||
|
"clients_label": "Қанағаттанған тұтынушылар",
|
||||||
|
"experience": "4 жыл",
|
||||||
|
"experience_label": "Саладағы тәжірибе"
|
||||||
|
},
|
||||||
|
"mission": "Біздің миссия",
|
||||||
|
"mission_text": "Технология арқылы барлық бизнестердің цифрлық дәуірде табысқа жетуіне көмектесу",
|
||||||
|
"vision": "Біздің көзқарас",
|
||||||
|
"vision_text": "Қазақстанды білдіретін жаһандық цифрлық шешімдер компаниясы ретінде өсіп, бүкіл әлемдегі тұтынушылардың цифрлық инновацияларын басқару"
|
||||||
|
},
|
||||||
|
"team": {
|
||||||
|
"title": "Біздің",
|
||||||
|
"title_highlight": "Команда",
|
||||||
|
"description": "Сараптама мен құлшынысты SmartSolTech командасын таныстырамыз"
|
||||||
|
},
|
||||||
|
"tech_stack": {
|
||||||
|
"title": "Технологиялық",
|
||||||
|
"title_highlight": "Стек",
|
||||||
|
"description": "Заманауи технология мен дәлелденген құралдармен ең жақсы шешімдерді ұсынамыз",
|
||||||
|
"frontend": "Frontend",
|
||||||
|
"backend": "Backend",
|
||||||
|
"mobile": "Мобильді"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"description": "Инновацияны басқаратын цифрлық шешімдер маманы",
|
||||||
|
"links": {
|
||||||
|
"title": "footer.links.title",
|
||||||
|
"privacy": "Privacy Policy",
|
||||||
|
"terms": "Terms of Service",
|
||||||
|
"sitemap": "Sitemap"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "footer.contact.title",
|
||||||
|
"email": "footer.contact.email",
|
||||||
|
"phone": "footer.contact.phone",
|
||||||
|
"address": "footer.contact.address"
|
||||||
|
},
|
||||||
|
"copyright": "footer.copyright",
|
||||||
|
"company": {
|
||||||
|
"description": "footer.company.description"
|
||||||
|
},
|
||||||
|
"quick_links": "Жылдам сілтемелер",
|
||||||
|
"services": "Қызметтер",
|
||||||
|
"contact_info": "Байланыс ақпараты",
|
||||||
|
"follow_us": "Бізді іздеңіз",
|
||||||
|
"rights": "Барлық құқықтар сақталған.",
|
||||||
|
"privacy": "footer.privacy",
|
||||||
|
"terms": "footer.terms",
|
||||||
|
"social": {
|
||||||
|
"follow": "Follow Us"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"light": "Ашық тема",
|
||||||
|
"dark": "Қараңғы тема",
|
||||||
|
"toggle": "Теманы ауыстыру"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"english": "English",
|
||||||
|
"korean": "한국어",
|
||||||
|
"russian": "Русский",
|
||||||
|
"kazakh": "Қазақша",
|
||||||
|
"ko": "language.ko"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Жүктелуде...",
|
||||||
|
"error": "Қате орын алды",
|
||||||
|
"success": "Сәтті",
|
||||||
|
"view_more": "Көбірек көру",
|
||||||
|
"back": "Артқа",
|
||||||
|
"next": "Келесі",
|
||||||
|
"previous": "Алдыңғы",
|
||||||
|
"view_details": "common.view_details"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "meta.description",
|
||||||
|
"keywords": "meta.keywords",
|
||||||
|
"title": "meta.title"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "nav.home",
|
||||||
|
"about": "nav.about",
|
||||||
|
"services": "nav.services",
|
||||||
|
"portfolio": "nav.portfolio",
|
||||||
|
"calculator": "nav.calculator"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"login": "Admin Panel Login",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"title": "SmartSolTech Admin",
|
||||||
|
"login_title": "Admin Panel Login",
|
||||||
|
"login_subtitle": "Login to your account to manage the site",
|
||||||
|
"login_button": "Login",
|
||||||
|
"email": "Email",
|
||||||
|
"password": "Password",
|
||||||
|
"email_placeholder": "admin@smartsoltech.com",
|
||||||
|
"password_placeholder": "Enter password",
|
||||||
|
"back_to_site": "Back to site",
|
||||||
|
"dashboard_subtitle": "Overview of main site metrics",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"services": "Services",
|
||||||
|
"contacts": "Messages",
|
||||||
|
"settings": "Settings",
|
||||||
|
"users": "Users",
|
||||||
|
"logout": "Logout",
|
||||||
|
"view_site": "View site",
|
||||||
|
"view_all": "View all",
|
||||||
|
"portfolio_projects": "Projects",
|
||||||
|
"contact_messages": "Messages",
|
||||||
|
"recent_portfolio": "Recent projects",
|
||||||
|
"recent_contacts": "Recent messages",
|
||||||
|
"no_recent_portfolio": "No recent projects",
|
||||||
|
"no_recent_contacts": "No recent messages",
|
||||||
|
"quick_actions": "Quick actions",
|
||||||
|
"add_portfolio": "Add project",
|
||||||
|
"add_service": "Add service",
|
||||||
|
"site_settings": "Site settings",
|
||||||
|
"banner_editor": "Banner Editor",
|
||||||
|
"current_banner": "Current banner",
|
||||||
|
"pages": {
|
||||||
|
"home": "Home page",
|
||||||
|
"about": "About us",
|
||||||
|
"services": "Services",
|
||||||
|
"portfolio": "Portfolio"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"company": {
|
||||||
|
"name": "SmartSolTech",
|
||||||
|
"description": "Digital solution specialist leading innovation",
|
||||||
|
"email": "info@smartsoltech.kr",
|
||||||
|
"phone": "+82-10-1234-5678",
|
||||||
|
"full_name": "SmartSolTech - Innovative Technology Solutions",
|
||||||
|
"tagline": "Future begins here",
|
||||||
|
"address": "Seoul, South Korea",
|
||||||
|
"social": {
|
||||||
|
"telegram": "@smartsoltech"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"page_not_found": "Page not found",
|
||||||
|
"error_occurred": "Error occurred",
|
||||||
|
"title": "Error - SmartSolTech",
|
||||||
|
"default_title": "An Error Occurred",
|
||||||
|
"default_message": "A problem occurred while processing the request.",
|
||||||
|
"back_home": "Back to Home",
|
||||||
|
"go_back": "Go Back",
|
||||||
|
"need_help": "Need Help?",
|
||||||
|
"help_message": "If the problem persists, please contact us anytime.",
|
||||||
|
"contact_support": "Contact Support",
|
||||||
|
"contact_us": "Contact us"
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"home": "Home page",
|
||||||
|
"about": "About us",
|
||||||
|
"services": "Services",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"contact": "Contact",
|
||||||
|
"calculator": "Calculator"
|
||||||
|
},
|
||||||
|
"portfolio_page": {
|
||||||
|
"title": "Our Portfolio",
|
||||||
|
"subtitle": "Discover innovative projects and creative solutions",
|
||||||
|
"categories": {
|
||||||
|
"all": "All",
|
||||||
|
"web-development": "Web Development",
|
||||||
|
"mobile-app": "Mobile App",
|
||||||
|
"ui-ux-design": "UI/UX Design",
|
||||||
|
"branding": "Branding",
|
||||||
|
"marketing": "Digital Marketing"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"details": "View Details",
|
||||||
|
"projectDetails": "Project Details",
|
||||||
|
"loadMore": "Load More Projects",
|
||||||
|
"contact": "Request Project",
|
||||||
|
"calculate": "Calculate Cost"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "No portfolio yet",
|
||||||
|
"subtitle": "We'll be showcasing amazing projects soon!"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Be the star of the next project",
|
||||||
|
"subtitle": "Create innovative digital solutions with us"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"featured": "FEATURED",
|
||||||
|
"views": "views",
|
||||||
|
"likes": "likes"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"undefined - SmartSolTech": "undefined - SmartSolTech"
|
||||||
}
|
}
|
||||||
523
locales/ko.json
@@ -1,196 +1,331 @@
|
|||||||
{
|
{
|
||||||
"undefined - SmartSolTech": "undefined - SmartSolTech",
|
"navigation": {
|
||||||
"meta": {
|
"home": "Home",
|
||||||
"description": "meta.description",
|
"about": "About",
|
||||||
"keywords": "meta.keywords",
|
"services": "Services",
|
||||||
"title": "meta.title"
|
"portfolio": "Portfolio",
|
||||||
},
|
"contact": "Contact",
|
||||||
"navigation": {
|
"calculator": "Calculator",
|
||||||
"home": "navigation.home",
|
"admin": "Admin"
|
||||||
"about": "navigation.about",
|
},
|
||||||
"services": "navigation.services",
|
"hero": {
|
||||||
"portfolio": "navigation.portfolio",
|
"title": {
|
||||||
"calculator": "navigation.calculator",
|
"smart": "Smart",
|
||||||
"contact": "navigation.contact",
|
"solutions": "Solutions"
|
||||||
"home - SmartSolTech": "navigation.home - SmartSolTech"
|
},
|
||||||
},
|
"subtitle": "Grow your business with innovative technology",
|
||||||
"language": {
|
"description": "Innovative web development, mobile apps, UI/UX design leading your business digital transformation",
|
||||||
"ko": "language.ko",
|
"cta": {
|
||||||
"korean": "language.korean",
|
"start": "Get Started",
|
||||||
"english": "language.english",
|
"portfolio": "View Portfolio"
|
||||||
"russian": "language.russian",
|
}
|
||||||
"kazakh": "language.kazakh"
|
},
|
||||||
},
|
"services": {
|
||||||
"theme": {
|
"title": {
|
||||||
"toggle": "theme.toggle"
|
"our": "Our",
|
||||||
},
|
"services": "Services"
|
||||||
"hero": {
|
},
|
||||||
"cta_primary": "hero.cta_primary",
|
"subtitle": "Professional development services to turn your ideas into reality",
|
||||||
"title": {
|
"description": "Digital solutions completed with cutting-edge technology and creative ideas",
|
||||||
"smart": "hero.title.smart",
|
"view_all": "View All Services",
|
||||||
"solutions": "hero.title.solutions"
|
"web": {
|
||||||
},
|
"title": "Web Development",
|
||||||
"subtitle": "hero.subtitle",
|
"description": "Responsive websites and web application development",
|
||||||
"cta": {
|
"price": "From $500"
|
||||||
"start": "hero.cta.start",
|
},
|
||||||
"portfolio": "hero.cta.portfolio"
|
"mobile": {
|
||||||
}
|
"title": "Mobile Apps",
|
||||||
},
|
"description": "iOS and Android native app development",
|
||||||
"services": {
|
"price": "From $1,000"
|
||||||
"title": {
|
},
|
||||||
"our": "services.title.our",
|
"design": {
|
||||||
"services": "services.title.services"
|
"title": "UI/UX Design",
|
||||||
},
|
"description": "User-centered interface and experience design",
|
||||||
"subtitle": "services.subtitle",
|
"price": "From $300"
|
||||||
"web": {
|
},
|
||||||
"title": "services.web.title",
|
"marketing": {
|
||||||
"description": "services.web.description",
|
"title": "Digital Marketing",
|
||||||
"price": "services.web.price"
|
"description": "SEO, social media marketing, advertising management",
|
||||||
},
|
"price": "From $200"
|
||||||
"mobile": {
|
},
|
||||||
"title": "services.mobile.title",
|
"meta": {
|
||||||
"description": "services.mobile.description",
|
"title": "Services",
|
||||||
"price": "services.mobile.price"
|
"description": "Check out SmartSolTech's professional services. Web development, mobile apps, UI/UX design, digital marketing and other technology solutions.",
|
||||||
},
|
"keywords": "web development, mobile apps, UI/UX design, digital marketing, technology solutions, SmartSolTech"
|
||||||
"design": {
|
},
|
||||||
"title": "services.design.title",
|
"hero": {
|
||||||
"description": "services.design.description",
|
"title": "Our",
|
||||||
"price": "services.design.price"
|
"title_highlight": "Services",
|
||||||
},
|
"subtitle": "Support business growth with innovative technology"
|
||||||
"marketing": {
|
},
|
||||||
"title": "services.marketing.title",
|
"cards": {
|
||||||
"description": "services.marketing.description",
|
"starting_price": "Starting Price",
|
||||||
"price": "services.marketing.price"
|
"consultation": "consultation",
|
||||||
},
|
"contact": "Contact",
|
||||||
"view_all": "services.view_all"
|
"calculate_cost": "Calculate Cost",
|
||||||
},
|
"popular": "Popular",
|
||||||
"portfolio": {
|
"coming_soon": "Services Coming Soon",
|
||||||
"title": {
|
"coming_soon_desc": "We'll soon offer various services!"
|
||||||
"recent": "portfolio.title.recent",
|
},
|
||||||
"projects": "portfolio.title.projects"
|
"process": {
|
||||||
},
|
"title": "Project Implementation Process",
|
||||||
"subtitle": "portfolio.subtitle",
|
"subtitle": "We conduct projects with systematic and professional processes",
|
||||||
"view_all": "portfolio.view_all"
|
"consultation": {
|
||||||
},
|
"title": "Consultation and Planning",
|
||||||
"common": {
|
"description": "Accurately understand customer requirements"
|
||||||
"view_details": "common.view_details"
|
}
|
||||||
},
|
}
|
||||||
"calculator": {
|
},
|
||||||
"cta": {
|
"portfolio": {
|
||||||
"title": "calculator.cta.title",
|
"title": {
|
||||||
"subtitle": "calculator.cta.subtitle",
|
"recent": "Recent",
|
||||||
"button": "calculator.cta.button"
|
"projects": "Projects"
|
||||||
},
|
},
|
||||||
"meta": {
|
"subtitle": "Check out successfully completed projects",
|
||||||
"title": "calculator.meta.title",
|
"description": "Check out the projects completed for customer success",
|
||||||
"description": "calculator.meta.description"
|
"view_details": "View Details",
|
||||||
},
|
"view_all": "View All Portfolio",
|
||||||
"title": "calculator.title",
|
"categories": {
|
||||||
"subtitle": "calculator.subtitle",
|
"all": "All",
|
||||||
"step1": {
|
"web": "Web Development",
|
||||||
"title": "calculator.step1.title",
|
"mobile": "Mobile Apps",
|
||||||
"subtitle": "calculator.step1.subtitle"
|
"uiux": "UI/UX Design"
|
||||||
},
|
},
|
||||||
"next_step": "calculator.next_step",
|
"project_details": "Project Details",
|
||||||
"step2": {
|
"default": {
|
||||||
"title": "calculator.step2.title",
|
"ecommerce": "E-commerce",
|
||||||
"subtitle": "calculator.step2.subtitle"
|
"title": "E-commerce Platform",
|
||||||
},
|
"description": "Modern online commerce solution with intuitive interface"
|
||||||
"complexity": {
|
},
|
||||||
"title": "calculator.complexity.title",
|
"meta": {
|
||||||
"simple": "calculator.complexity.simple",
|
"title": "Portfolio",
|
||||||
"simple_desc": "calculator.complexity.simple_desc",
|
"description": "Check out SmartSolTech's diverse projects and success stories. Web development, mobile apps, UI/UX design portfolio.",
|
||||||
"medium": "calculator.complexity.medium",
|
"keywords": "portfolio, web development, mobile apps, UI/UX design, projects, SmartSolTech"
|
||||||
"medium_desc": "calculator.complexity.medium_desc",
|
}
|
||||||
"complex": "calculator.complexity.complex",
|
},
|
||||||
"complex_desc": "calculator.complexity.complex_desc"
|
"calculator": {
|
||||||
},
|
"title": "Project Cost Calculator",
|
||||||
"timeline": {
|
"subtitle": "Select your desired services and requirements to get accurate cost estimates in real time",
|
||||||
"title": "calculator.timeline.title",
|
"meta": {
|
||||||
"standard": "calculator.timeline.standard",
|
"title": "Project Cost Calculator",
|
||||||
"standard_desc": "calculator.timeline.standard_desc",
|
"description": "Calculate the cost of your web development, mobile app, or design project with our interactive calculator"
|
||||||
"rush": "calculator.timeline.rush",
|
},
|
||||||
"rush_desc": "calculator.timeline.rush_desc",
|
"cta": {
|
||||||
"extended": "calculator.timeline.extended",
|
"title": "Check Your Project Estimate",
|
||||||
"extended_desc": "calculator.timeline.extended_desc"
|
"subtitle": "Select your desired services and requirements to calculate costs in real time",
|
||||||
},
|
"button": "Use Cost Calculator"
|
||||||
"prev_step": "calculator.prev_step",
|
}
|
||||||
"calculate": "calculator.calculate",
|
},
|
||||||
"result": {
|
"contact": {
|
||||||
"title": "calculator.result.title",
|
"hero": {
|
||||||
"subtitle": "calculator.result.subtitle",
|
"title": "Contact Us",
|
||||||
"estimated_price": "calculator.result.estimated_price",
|
"subtitle": "We're here to help bring your ideas to life"
|
||||||
"price_note": "calculator.result.price_note",
|
},
|
||||||
"summary": "calculator.result.summary",
|
"ready_title": "Ready to Start Your Project?",
|
||||||
"get_quote": "calculator.result.get_quote",
|
"ready_description": "Turn your ideas into reality. Experts provide the best solutions.",
|
||||||
"recalculate": "calculator.result.recalculate",
|
"form": {
|
||||||
"contact_note": "calculator.result.contact_note",
|
"title": "Project Inquiry",
|
||||||
"selected_services": "선택된 서비스",
|
"name": "Name",
|
||||||
"complexity": "복잡도",
|
"email": "Email",
|
||||||
"timeline": "개발 기간"
|
"phone": "Phone",
|
||||||
}
|
"message": "Message",
|
||||||
},
|
"submit": "Send Inquiry",
|
||||||
"contact": {
|
"success": "Inquiry sent successfully",
|
||||||
"cta": {
|
"error": "Error occurred while sending inquiry",
|
||||||
"ready": "contact.cta.ready",
|
"service": {
|
||||||
"start": "contact.cta.start",
|
"title": "Service Interest",
|
||||||
"question": "contact.cta.question",
|
"select": "Select service of interest",
|
||||||
"subtitle": "contact.cta.subtitle"
|
"web": "Web Development",
|
||||||
},
|
"mobile": "Mobile App",
|
||||||
"phone": {
|
"design": "UI/UX Design",
|
||||||
"title": "contact.phone.title",
|
"branding": "Branding",
|
||||||
"number": "contact.phone.number"
|
"consulting": "Consulting",
|
||||||
},
|
"other": "Other"
|
||||||
"email": {
|
}
|
||||||
"title": "contact.email.title",
|
},
|
||||||
"address": "contact.email.address"
|
"info": {
|
||||||
},
|
"title": "Contact Information"
|
||||||
"telegram": {
|
},
|
||||||
"title": "contact.telegram.title",
|
"phone": {
|
||||||
"subtitle": "contact.telegram.subtitle"
|
"title": "Phone Inquiry",
|
||||||
},
|
"number": "+82-2-1234-5678",
|
||||||
"form": {
|
"hours": "Mon-Fri 9:00-18:00"
|
||||||
"title": "contact.form.title",
|
},
|
||||||
"name": "contact.form.name",
|
"email": {
|
||||||
"email": "contact.form.email",
|
"title": "Email Inquiry",
|
||||||
"phone": "contact.form.phone",
|
"address": "info@smartsoltech.co.kr",
|
||||||
"service": {
|
"response": "Response within 24 hours"
|
||||||
"select": "contact.form.service.select",
|
},
|
||||||
"web": "contact.form.service.web",
|
"telegram": {
|
||||||
"mobile": "contact.form.service.mobile",
|
"title": "Telegram",
|
||||||
"design": "contact.form.service.design",
|
"subtitle": "For quick response"
|
||||||
"branding": "contact.form.service.branding",
|
},
|
||||||
"consulting": "contact.form.service.consulting",
|
"address": {
|
||||||
"other": "contact.form.service.other"
|
"title": "Office Address",
|
||||||
},
|
"line1": "123 Teheran-ro, Gangnam-gu",
|
||||||
"message": "contact.form.message",
|
"line2": "Seoul, South Korea"
|
||||||
"submit": "contact.form.submit",
|
},
|
||||||
"success": "contact.form.success",
|
"cta": {
|
||||||
"error": "contact.form.error"
|
"ready": "Ready?",
|
||||||
}
|
"start": "Get Started",
|
||||||
},
|
"question": "Have questions?",
|
||||||
"footer": {
|
"subtitle": "We provide consultation on projects"
|
||||||
"company": {
|
},
|
||||||
"description": "footer.company.description"
|
"meta": {
|
||||||
},
|
"title": "Contact",
|
||||||
"links": {
|
"description": "Contact us anytime for project inquiries or consultation"
|
||||||
"title": "footer.links.title"
|
}
|
||||||
},
|
},
|
||||||
"contact": {
|
"about": {
|
||||||
"title": "footer.contact.title",
|
"hero": {
|
||||||
"email": "footer.contact.email",
|
"title": "About SmartSolTech",
|
||||||
"phone": "footer.contact.phone",
|
"subtitle": "Creating the future with innovation and technology"
|
||||||
"address": "footer.contact.address"
|
},
|
||||||
},
|
"company": {
|
||||||
"copyright": "footer.copyright",
|
"title": "Company Information",
|
||||||
"privacy": "footer.privacy",
|
"description1": "SmartSolTech is a technology company established in 2020, recognized for expertise in web development, mobile app development, and UI/UX design.",
|
||||||
"terms": "footer.terms"
|
"description2": "We accurately understand customer needs and provide innovative solutions using the latest technology."
|
||||||
},
|
},
|
||||||
"nav": {
|
"stats": {
|
||||||
"home": "nav.home",
|
"projects": "Completed Projects",
|
||||||
"about": "nav.about",
|
"experience": "Years Experience",
|
||||||
"services": "nav.services",
|
"clients": "Satisfied Customers"
|
||||||
"portfolio": "nav.portfolio",
|
},
|
||||||
"calculator": "nav.calculator"
|
"mission": {
|
||||||
}
|
"title": "Our Mission",
|
||||||
|
"description": "Our mission is to support customer business growth through technology and lead digital innovation."
|
||||||
|
},
|
||||||
|
"values": {
|
||||||
|
"innovation": {
|
||||||
|
"title": "Innovation",
|
||||||
|
"description": "We provide innovative solutions through continuous R&D and adoption of cutting-edge technology."
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"title": "Quality",
|
||||||
|
"description": "We maintain high quality standards and provide high-quality products that customers can be satisfied with."
|
||||||
|
},
|
||||||
|
"partnership": {
|
||||||
|
"title": "Partnership",
|
||||||
|
"description": "We create the best results through close communication and collaboration with customers."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "We'll Grow Together",
|
||||||
|
"subtitle": "Turn your ideas into reality",
|
||||||
|
"button": "Contact Us"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"title": "About Us",
|
||||||
|
"description": "SmartSolTech is a professional development company that supports customer business growth with innovative technology"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"footer": {
|
||||||
|
"description": "Digital solution specialist leading innovation",
|
||||||
|
"links": {
|
||||||
|
"title": "Quick Links"
|
||||||
|
},
|
||||||
|
"contact": {
|
||||||
|
"title": "Contact",
|
||||||
|
"email": "info@smartsoltech.co.kr",
|
||||||
|
"phone": "+82-2-1234-5678",
|
||||||
|
"address": "123 Teheran-ro, Gangnam-gu, Seoul"
|
||||||
|
},
|
||||||
|
"copyright": "© 2024 SmartSolTech. All rights reserved."
|
||||||
|
},
|
||||||
|
"theme": {
|
||||||
|
"light": "Light Theme",
|
||||||
|
"dark": "Dark Theme",
|
||||||
|
"toggle": "Toggle Theme"
|
||||||
|
},
|
||||||
|
"language": {
|
||||||
|
"english": "English",
|
||||||
|
"korean": "한국어",
|
||||||
|
"russian": "Русский",
|
||||||
|
"kazakh": "Қазақша"
|
||||||
|
},
|
||||||
|
"common": {
|
||||||
|
"loading": "Loading...",
|
||||||
|
"error": "Error occurred",
|
||||||
|
"success": "Success",
|
||||||
|
"view_more": "View More",
|
||||||
|
"back": "Back",
|
||||||
|
"next": "Next",
|
||||||
|
"previous": "Previous",
|
||||||
|
"view_details": "View Details"
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"description": "SmartSolTech - Innovative web development, mobile app development, UI/UX design services",
|
||||||
|
"keywords": "web development, mobile apps, UI/UX design, Korea",
|
||||||
|
"title": "SmartSolTech"
|
||||||
|
},
|
||||||
|
"nav": {
|
||||||
|
"home": "Home",
|
||||||
|
"about": "About",
|
||||||
|
"services": "Services",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"calculator": "Calculator"
|
||||||
|
},
|
||||||
|
"admin": {
|
||||||
|
"login": "Admin Panel Login",
|
||||||
|
"dashboard": "Dashboard",
|
||||||
|
"title": "SmartSolTech Admin"
|
||||||
|
},
|
||||||
|
"company": {
|
||||||
|
"name": "SmartSolTech",
|
||||||
|
"description": "Digital solution specialist leading innovation",
|
||||||
|
"email": "info@smartsoltech.kr",
|
||||||
|
"phone": "+82-10-1234-5678"
|
||||||
|
},
|
||||||
|
"errors": {
|
||||||
|
"page_not_found": "Page not found",
|
||||||
|
"error_occurred": "Error occurred",
|
||||||
|
"title": "Error - SmartSolTech",
|
||||||
|
"default_title": "An Error Occurred",
|
||||||
|
"default_message": "A problem occurred while processing the request.",
|
||||||
|
"back_home": "Back to Home",
|
||||||
|
"go_back": "Go Back",
|
||||||
|
"need_help": "Need Help?",
|
||||||
|
"help_message": "If the problem persists, please contact us anytime.",
|
||||||
|
"contact_support": "Contact Support"
|
||||||
|
},
|
||||||
|
"pages": {
|
||||||
|
"home": "Home page",
|
||||||
|
"about": "About us",
|
||||||
|
"services": "Services",
|
||||||
|
"portfolio": "Portfolio",
|
||||||
|
"contact": "Contact",
|
||||||
|
"calculator": "Calculator"
|
||||||
|
},
|
||||||
|
"portfolio_page": {
|
||||||
|
"title": "우리의 포트폴리오",
|
||||||
|
"subtitle": "혁신적인 프로젝트와 창의적인 솔루션들을 만나보세요",
|
||||||
|
"categories": {
|
||||||
|
"all": "전체",
|
||||||
|
"web-development": "웹 개발",
|
||||||
|
"mobile-app": "모바일 앱",
|
||||||
|
"ui-ux-design": "UI/UX 디자인",
|
||||||
|
"branding": "브랜딩",
|
||||||
|
"marketing": "디지털 마케팅"
|
||||||
|
},
|
||||||
|
"buttons": {
|
||||||
|
"details": "자세히 보기",
|
||||||
|
"projectDetails": "프로젝트 상세보기",
|
||||||
|
"loadMore": "더 많은 프로젝트 보기",
|
||||||
|
"contact": "프로젝트 문의하기",
|
||||||
|
"calculate": "비용 계산하기"
|
||||||
|
},
|
||||||
|
"empty": {
|
||||||
|
"title": "아직 포트폴리오가 없습니다",
|
||||||
|
"subtitle": "곧 멋진 프로젝트들을 공개할 예정입니다!"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "다음 프로젝트의 주인공이 되어보세요",
|
||||||
|
"subtitle": "우리와 함께 혁신적인 디지털 솔루션을 만들어보세요"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"featured": "추천",
|
||||||
|
"views": "조회수",
|
||||||
|
"likes": "좋아요"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
645
locales/ru.json
@@ -1,318 +1,331 @@
|
|||||||
{
|
{
|
||||||
"navigation": {
|
"navigation": {
|
||||||
"home": "Главная",
|
"home": "Home",
|
||||||
"about": "О нас",
|
"about": "About",
|
||||||
"services": "Услуги",
|
"services": "Services",
|
||||||
"portfolio": "Портфолио",
|
"portfolio": "Portfolio",
|
||||||
"contact": "Контакты",
|
"contact": "Contact",
|
||||||
"calculator": "Калькулятор",
|
"calculator": "Calculator",
|
||||||
"admin": "Админ",
|
"admin": "Admin"
|
||||||
"home - SmartSolTech": "navigation.home - SmartSolTech"
|
},
|
||||||
},
|
"hero": {
|
||||||
"hero": {
|
"title": {
|
||||||
"title": {
|
"smart": "Smart",
|
||||||
"smart": "hero.title.smart",
|
"solutions": "Solutions"
|
||||||
"solutions": "hero.title.solutions"
|
},
|
||||||
},
|
"subtitle": "Grow your business with innovative technology",
|
||||||
"subtitle": "Решения",
|
"description": "Innovative web development, mobile apps, UI/UX design leading your business digital transformation",
|
||||||
"description": "Инновационная веб-разработка, мобильные приложения, UI/UX дизайн для цифровой трансформации вашего бизнеса",
|
"cta": {
|
||||||
"cta_primary": "Начать проект",
|
"start": "Get Started",
|
||||||
"cta_secondary": "Посмотреть портфолио",
|
"portfolio": "View Portfolio"
|
||||||
"cta": {
|
}
|
||||||
"start": "hero.cta.start",
|
},
|
||||||
"portfolio": "hero.cta.portfolio"
|
"services": {
|
||||||
}
|
"title": {
|
||||||
},
|
"our": "Our",
|
||||||
"services": {
|
"services": "Services"
|
||||||
"title": {
|
},
|
||||||
"our": "services.title.our",
|
"subtitle": "Professional development services to turn your ideas into reality",
|
||||||
"services": "services.title.services"
|
"description": "Digital solutions completed with cutting-edge technology and creative ideas",
|
||||||
},
|
"view_all": "View All Services",
|
||||||
"title_highlight": "Услуги",
|
"web": {
|
||||||
"description": "Цифровые решения с использованием передовых технологий и творческих идей",
|
"title": "Web Development",
|
||||||
"web_development": {
|
"description": "Responsive websites and web application development",
|
||||||
"title": "Веб-разработка",
|
"price": "From $500"
|
||||||
"description": "Современные и адаптивные веб-сайты и веб-приложения",
|
},
|
||||||
"price": "$5,000~"
|
"mobile": {
|
||||||
},
|
"title": "Mobile Apps",
|
||||||
"mobile_app": {
|
"description": "iOS and Android native app development",
|
||||||
"title": "Мобильные приложения",
|
"price": "From $1,000"
|
||||||
"description": "Нативные и кроссплатформенные приложения для iOS и Android",
|
},
|
||||||
"price": "$8,000~"
|
"design": {
|
||||||
},
|
"title": "UI/UX Design",
|
||||||
"ui_ux_design": {
|
"description": "User-centered interface and experience design",
|
||||||
"title": "UI/UX дизайн",
|
"price": "From $300"
|
||||||
"description": "Ориентированный на пользователя интуитивный и красивый дизайн интерфейса",
|
},
|
||||||
"price": "$3,000~"
|
"marketing": {
|
||||||
},
|
"title": "Digital Marketing",
|
||||||
"digital_marketing": {
|
"description": "SEO, social media marketing, advertising management",
|
||||||
"title": "Цифровой маркетинг",
|
"price": "From $200"
|
||||||
"description": "Цифровой маркетинг через SEO, социальные сети, онлайн-рекламу",
|
},
|
||||||
"price": "$2,000~"
|
"meta": {
|
||||||
},
|
"title": "Services",
|
||||||
"view_all": "Посмотреть все услуги",
|
"description": "Check out SmartSolTech's professional services. Web development, mobile apps, UI/UX design, digital marketing and other technology solutions.",
|
||||||
"subtitle": "services.subtitle",
|
"keywords": "web development, mobile apps, UI/UX design, digital marketing, technology solutions, SmartSolTech"
|
||||||
"web": {
|
},
|
||||||
"title": "services.web.title",
|
"hero": {
|
||||||
"description": "services.web.description",
|
"title": "Our",
|
||||||
"price": "services.web.price"
|
"title_highlight": "Services",
|
||||||
},
|
"subtitle": "Support business growth with innovative technology"
|
||||||
"mobile": {
|
},
|
||||||
"title": "services.mobile.title",
|
"cards": {
|
||||||
"description": "services.mobile.description",
|
"starting_price": "Starting Price",
|
||||||
"price": "services.mobile.price"
|
"consultation": "consultation",
|
||||||
},
|
"contact": "Contact",
|
||||||
"design": {
|
"calculate_cost": "Calculate Cost",
|
||||||
"title": "services.design.title",
|
"popular": "Popular",
|
||||||
"description": "services.design.description",
|
"coming_soon": "Services Coming Soon",
|
||||||
"price": "services.design.price"
|
"coming_soon_desc": "We'll soon offer various services!"
|
||||||
},
|
},
|
||||||
"marketing": {
|
"process": {
|
||||||
"title": "services.marketing.title",
|
"title": "Project Implementation Process",
|
||||||
"description": "services.marketing.description",
|
"subtitle": "We conduct projects with systematic and professional processes",
|
||||||
"price": "services.marketing.price"
|
"consultation": {
|
||||||
}
|
"title": "Consultation and Planning",
|
||||||
},
|
"description": "Accurately understand customer requirements"
|
||||||
"portfolio": {
|
}
|
||||||
"title": {
|
}
|
||||||
"recent": "portfolio.title.recent",
|
},
|
||||||
"projects": "portfolio.title.projects"
|
"portfolio": {
|
||||||
},
|
"title": {
|
||||||
"title_highlight": "Проекты",
|
"recent": "Recent",
|
||||||
"description": "Ознакомьтесь с проектами, выполненными для успеха клиентов",
|
"projects": "Projects"
|
||||||
"view_details": "Подробнее",
|
},
|
||||||
"view_all": "Посмотреть все портфолио",
|
"subtitle": "Check out successfully completed projects",
|
||||||
"subtitle": "portfolio.subtitle"
|
"description": "Check out the projects completed for customer success",
|
||||||
},
|
"view_details": "View Details",
|
||||||
"calculator": {
|
"view_all": "View All Portfolio",
|
||||||
"title": "Калькулятор Стоимости Проекта",
|
"categories": {
|
||||||
"subtitle": "Выберите нужные услуги и требования для получения точной оценки стоимости в режиме реального времени",
|
"all": "All",
|
||||||
"meta": {
|
"web": "Web Development",
|
||||||
"title": "Калькулятор стоимости проекта",
|
"mobile": "Mobile Apps",
|
||||||
"description": "Рассчитайте стоимость вашего проекта веб-разработки, мобильного приложения или дизайна с помощью нашего интерактивного калькулятора"
|
"uiux": "UI/UX Design"
|
||||||
},
|
},
|
||||||
"cta": {
|
"project_details": "Project Details",
|
||||||
"title": "Узнайте стоимость вашего проекта",
|
"default": {
|
||||||
"subtitle": "Выберите необходимые услуги и требования, и мы рассчитаем стоимость в режиме реального времени",
|
"ecommerce": "E-commerce",
|
||||||
"button": "Использовать калькулятор стоимости"
|
"title": "E-commerce Platform",
|
||||||
},
|
"description": "Modern online commerce solution with intuitive interface"
|
||||||
"step1": {
|
},
|
||||||
"title": "Шаг 1: Выбор услуг",
|
"meta": {
|
||||||
"subtitle": "Выберите необходимые услуги (можно выбрать несколько)"
|
"title": "Portfolio",
|
||||||
},
|
"description": "Check out SmartSolTech's diverse projects and success stories. Web development, mobile apps, UI/UX design portfolio.",
|
||||||
"step2": {
|
"keywords": "portfolio, web development, mobile apps, UI/UX design, projects, SmartSolTech"
|
||||||
"title": "Шаг 2: Детали проекта",
|
}
|
||||||
"subtitle": "Выберите сложность проекта и сроки"
|
},
|
||||||
},
|
"calculator": {
|
||||||
"complexity": {
|
"title": "Project Cost Calculator",
|
||||||
"title": "Сложность проекта",
|
"subtitle": "Select your desired services and requirements to get accurate cost estimates in real time",
|
||||||
"simple": "Простой",
|
"meta": {
|
||||||
"simple_desc": "Базовый функционал, стандартный дизайн",
|
"title": "Project Cost Calculator",
|
||||||
"medium": "Средний",
|
"description": "Calculate the cost of your web development, mobile app, or design project with our interactive calculator"
|
||||||
"medium_desc": "Дополнительные функции, кастомный дизайн",
|
},
|
||||||
"complex": "Сложный",
|
"cta": {
|
||||||
"complex_desc": "Расширенный функционал, интеграции"
|
"title": "Check Your Project Estimate",
|
||||||
},
|
"subtitle": "Select your desired services and requirements to calculate costs in real time",
|
||||||
"timeline": {
|
"button": "Use Cost Calculator"
|
||||||
"title": "Временные рамки",
|
}
|
||||||
"standard": "Стандартные",
|
},
|
||||||
"standard_desc": "Обычные сроки разработки",
|
"contact": {
|
||||||
"rush": "Срочно",
|
"hero": {
|
||||||
"rush_desc": "Ускоренная разработка (+50%)",
|
"title": "Contact Us",
|
||||||
"extended": "Расширенные",
|
"subtitle": "We're here to help bring your ideas to life"
|
||||||
"extended_desc": "Длительная разработка (-20%)"
|
},
|
||||||
},
|
"ready_title": "Ready to Start Your Project?",
|
||||||
"result": {
|
"ready_description": "Turn your ideas into reality. Experts provide the best solutions.",
|
||||||
"title": "Результат расчета",
|
"form": {
|
||||||
"subtitle": "Вот ваша предварительная оценка стоимости проекта",
|
"title": "Project Inquiry",
|
||||||
"estimated_price": "Предварительная стоимость",
|
"name": "Name",
|
||||||
"price_note": "* Окончательная стоимость может варьироваться в зависимости от деталей проекта",
|
"email": "Email",
|
||||||
"summary": "Сводка проекта",
|
"phone": "Phone",
|
||||||
"selected_services": "Выбранные услуги",
|
"message": "Message",
|
||||||
"complexity": "Сложность",
|
"submit": "Send Inquiry",
|
||||||
"timeline": "Временные рамки",
|
"success": "Inquiry sent successfully",
|
||||||
"get_quote": "Получить точное предложение",
|
"error": "Error occurred while sending inquiry",
|
||||||
"recalculate": "Пересчитать",
|
"service": {
|
||||||
"contact_note": "Свяжитесь с нами для получения точного предложения и обсуждения деталей проекта"
|
"title": "Service Interest",
|
||||||
},
|
"select": "Select service of interest",
|
||||||
"next_step": "Следующий шаг",
|
"web": "Web Development",
|
||||||
"prev_step": "Назад",
|
"mobile": "Mobile App",
|
||||||
"calculate": "Рассчитать"
|
"design": "UI/UX Design",
|
||||||
},
|
"branding": "Branding",
|
||||||
"contact": {
|
"consulting": "Consulting",
|
||||||
"ready_title": "Готовы начать свой проект?",
|
"other": "Other"
|
||||||
"ready_description": "Превратите свои идеи в реальность. Эксперты предоставят лучшие решения.",
|
}
|
||||||
"phone_consultation": "Телефонная консультация",
|
},
|
||||||
"email_inquiry": "Запрос по электронной почте",
|
"info": {
|
||||||
"telegram_chat": "Чат в Telegram",
|
"title": "Contact Information"
|
||||||
"instant_response": "Мгновенный ответ доступен",
|
},
|
||||||
"free_consultation": "Заявка на бесплатную консультацию",
|
"phone": {
|
||||||
"form": {
|
"title": "Phone Inquiry",
|
||||||
"name": "Имя",
|
"number": "+82-2-1234-5678",
|
||||||
"email": "Электронная почта",
|
"hours": "Mon-Fri 9:00-18:00"
|
||||||
"phone": "Телефон",
|
},
|
||||||
"service_interest": "Интересующая услуга",
|
"email": {
|
||||||
"service_options": {
|
"title": "Email Inquiry",
|
||||||
"select": "Выберите интересующую услугу",
|
"address": "info@smartsoltech.co.kr",
|
||||||
"web_development": "Веб-разработка",
|
"response": "Response within 24 hours"
|
||||||
"mobile_app": "Мобильное приложение",
|
},
|
||||||
"ui_ux_design": "UI/UX дизайн",
|
"telegram": {
|
||||||
"branding": "Брендинг",
|
"title": "Telegram",
|
||||||
"consulting": "Консалтинг",
|
"subtitle": "For quick response"
|
||||||
"other": "Другое"
|
},
|
||||||
},
|
"address": {
|
||||||
"message": "Кратко опишите ваш проект",
|
"title": "Office Address",
|
||||||
"submit": "Подать заявку на консультацию",
|
"line1": "123 Teheran-ro, Gangnam-gu",
|
||||||
"title": "contact.form.title",
|
"line2": "Seoul, South Korea"
|
||||||
"service": {
|
},
|
||||||
"select": "contact.form.service.select",
|
"cta": {
|
||||||
"web": "contact.form.service.web",
|
"ready": "Ready?",
|
||||||
"mobile": "contact.form.service.mobile",
|
"start": "Get Started",
|
||||||
"design": "contact.form.service.design",
|
"question": "Have questions?",
|
||||||
"branding": "contact.form.service.branding",
|
"subtitle": "We provide consultation on projects"
|
||||||
"consulting": "contact.form.service.consulting",
|
},
|
||||||
"other": "contact.form.service.other"
|
"meta": {
|
||||||
},
|
"title": "Contact",
|
||||||
"success": "contact.form.success",
|
"description": "Contact us anytime for project inquiries or consultation"
|
||||||
"error": "contact.form.error"
|
}
|
||||||
},
|
},
|
||||||
"cta": {
|
"about": {
|
||||||
"ready": "contact.cta.ready",
|
"hero": {
|
||||||
"start": "contact.cta.start",
|
"title": "About SmartSolTech",
|
||||||
"question": "contact.cta.question",
|
"subtitle": "Creating the future with innovation and technology"
|
||||||
"subtitle": "contact.cta.subtitle"
|
},
|
||||||
},
|
"company": {
|
||||||
"phone": {
|
"title": "Company Information",
|
||||||
"title": "contact.phone.title",
|
"description1": "SmartSolTech is a technology company established in 2020, recognized for expertise in web development, mobile app development, and UI/UX design.",
|
||||||
"number": "contact.phone.number"
|
"description2": "We accurately understand customer needs and provide innovative solutions using the latest technology."
|
||||||
},
|
},
|
||||||
"email": {
|
"stats": {
|
||||||
"title": "contact.email.title",
|
"projects": "Completed Projects",
|
||||||
"address": "contact.email.address"
|
"experience": "Years Experience",
|
||||||
},
|
"clients": "Satisfied Customers"
|
||||||
"telegram": {
|
},
|
||||||
"title": "contact.telegram.title",
|
"mission": {
|
||||||
"subtitle": "contact.telegram.subtitle"
|
"title": "Our Mission",
|
||||||
}
|
"description": "Our mission is to support customer business growth through technology and lead digital innovation."
|
||||||
},
|
},
|
||||||
"about": {
|
"values": {
|
||||||
"hero_title": "О",
|
"innovation": {
|
||||||
"hero_highlight": "SmartSolTech",
|
"title": "Innovation",
|
||||||
"hero_description": "Специалист по цифровым решениям, ведущий к успеху клиентов с помощью инновационных технологий",
|
"description": "We provide innovative solutions through continuous R&D and adoption of cutting-edge technology."
|
||||||
"overview": {
|
},
|
||||||
"title": "Создавая будущее с инновациями и креативностью",
|
"quality": {
|
||||||
"description_1": "SmartSolTech - это специалист по цифровым решениям, основанный в 2020 году, поддерживающий успех клиентского бизнеса с помощью инновационных технологий и творческих идей в области веб-разработки, мобильных приложений и UI/UX дизайна.",
|
"title": "Quality",
|
||||||
"description_2": "Мы не просто предоставляем технологии, но понимаем цели клиентов и предлагаем оптимальные решения, чтобы стать партнерами, растущими вместе.",
|
"description": "We maintain high quality standards and provide high-quality products that customers can be satisfied with."
|
||||||
"stats": {
|
},
|
||||||
"projects": "100+",
|
"partnership": {
|
||||||
"projects_label": "Завершенные проекты",
|
"title": "Partnership",
|
||||||
"clients": "50+",
|
"description": "We create the best results through close communication and collaboration with customers."
|
||||||
"clients_label": "Довольные клиенты",
|
}
|
||||||
"experience": "4 года",
|
},
|
||||||
"experience_label": "Опыт в отрасли"
|
"cta": {
|
||||||
},
|
"title": "We'll Grow Together",
|
||||||
"mission": "Наша миссия",
|
"subtitle": "Turn your ideas into reality",
|
||||||
"mission_text": "Помощь всем предприятиям в достижении успеха в цифровую эпоху с помощью технологий",
|
"button": "Contact Us"
|
||||||
"vision": "Наше видение",
|
},
|
||||||
"vision_text": "Рост как глобальной компании цифровых решений, представляющей Корею, для ведения цифровых инноваций для клиентов по всему миру"
|
"meta": {
|
||||||
},
|
"title": "About Us",
|
||||||
"values": {
|
"description": "SmartSolTech is a professional development company that supports customer business growth with innovative technology"
|
||||||
"title": "Основные",
|
}
|
||||||
"title_highlight": "Ценности",
|
},
|
||||||
"description": "Основные ценности, которых придерживается SmartSolTech",
|
"footer": {
|
||||||
"innovation": {
|
"description": "Digital solution specialist leading innovation",
|
||||||
"title": "Инновации",
|
"links": {
|
||||||
"description": "Мы предоставляем инновационные решения через непрерывные исследования и внедрение передовых технологий."
|
"title": "Quick Links"
|
||||||
},
|
},
|
||||||
"collaboration": {
|
"contact": {
|
||||||
"title": "Сотрудничество",
|
"title": "Contact",
|
||||||
"description": "Мы создаем лучшие результаты через тесное общение и сотрудничество с клиентами."
|
"email": "info@smartsoltech.co.kr",
|
||||||
},
|
"phone": "+82-2-1234-5678",
|
||||||
"quality": {
|
"address": "123 Teheran-ro, Gangnam-gu, Seoul"
|
||||||
"title": "Качество",
|
},
|
||||||
"description": "Мы поддерживаем высокие стандарты качества и предоставляем высококачественные продукты, которыми клиенты могут быть довольны."
|
"copyright": "© 2024 SmartSolTech. All rights reserved."
|
||||||
},
|
},
|
||||||
"growth": {
|
"theme": {
|
||||||
"title": "Рост",
|
"light": "Light Theme",
|
||||||
"description": "Мы растем вместе с клиентами и стремимся к непрерывному обучению и развитию."
|
"dark": "Dark Theme",
|
||||||
}
|
"toggle": "Toggle Theme"
|
||||||
},
|
},
|
||||||
"team": {
|
"language": {
|
||||||
"title": "Наша",
|
"english": "English",
|
||||||
"title_highlight": "Команда",
|
"korean": "한국어",
|
||||||
"description": "Представляем команду SmartSolTech с экспертизой и страстью"
|
"russian": "Русский",
|
||||||
},
|
"kazakh": "Қазақша"
|
||||||
"tech_stack": {
|
},
|
||||||
"title": "Технологический",
|
"common": {
|
||||||
"title_highlight": "Стек",
|
"loading": "Loading...",
|
||||||
"description": "Мы предоставляем лучшие решения с передовыми технологиями и проверенными инструментами",
|
"error": "Error occurred",
|
||||||
"frontend": "Frontend",
|
"success": "Success",
|
||||||
"backend": "Backend",
|
"view_more": "View More",
|
||||||
"mobile": "Мобильные"
|
"back": "Back",
|
||||||
},
|
"next": "Next",
|
||||||
"cta": {
|
"previous": "Previous",
|
||||||
"title": "Станьте партнером для совместного успеха",
|
"view_details": "View Details"
|
||||||
"description": "Выведите свой бизнес на следующий уровень с SmartSolTech",
|
},
|
||||||
"partnership": "Запрос о партнерстве",
|
"meta": {
|
||||||
"portfolio": "Посмотреть портфолио"
|
"description": "SmartSolTech - Innovative web development, mobile app development, UI/UX design services",
|
||||||
}
|
"keywords": "web development, mobile apps, UI/UX design, Korea",
|
||||||
},
|
"title": "SmartSolTech"
|
||||||
"footer": {
|
},
|
||||||
"company": {
|
"nav": {
|
||||||
"description": "footer.company.description"
|
"home": "Home",
|
||||||
},
|
"about": "About",
|
||||||
"description": "Специалист по цифровым решениям, ведущий инновации",
|
"services": "Services",
|
||||||
"quick_links": "Быстрые ссылки",
|
"portfolio": "Portfolio",
|
||||||
"services": "Услуги",
|
"calculator": "Calculator"
|
||||||
"contact_info": "Контактная информация",
|
},
|
||||||
"follow_us": "Подписывайтесь",
|
"admin": {
|
||||||
"rights": "Все права защищены.",
|
"login": "Admin Panel Login",
|
||||||
"links": {
|
"dashboard": "Dashboard",
|
||||||
"title": "footer.links.title"
|
"title": "SmartSolTech Admin"
|
||||||
},
|
},
|
||||||
"contact": {
|
"company": {
|
||||||
"title": "footer.contact.title",
|
"name": "SmartSolTech",
|
||||||
"email": "footer.contact.email",
|
"description": "Digital solution specialist leading innovation",
|
||||||
"phone": "footer.contact.phone",
|
"email": "info@smartsoltech.kr",
|
||||||
"address": "footer.contact.address"
|
"phone": "+82-10-1234-5678"
|
||||||
},
|
},
|
||||||
"copyright": "footer.copyright",
|
"errors": {
|
||||||
"privacy": "footer.privacy",
|
"page_not_found": "Page not found",
|
||||||
"terms": "footer.terms"
|
"error_occurred": "Error occurred",
|
||||||
},
|
"title": "Error - SmartSolTech",
|
||||||
"theme": {
|
"default_title": "An Error Occurred",
|
||||||
"light": "Светлая тема",
|
"default_message": "A problem occurred while processing the request.",
|
||||||
"dark": "Темная тема",
|
"back_home": "Back to Home",
|
||||||
"toggle": "Переключить тему"
|
"go_back": "Go Back",
|
||||||
},
|
"need_help": "Need Help?",
|
||||||
"language": {
|
"help_message": "If the problem persists, please contact us anytime.",
|
||||||
"english": "English",
|
"contact_support": "Contact Support"
|
||||||
"korean": "한국어",
|
},
|
||||||
"russian": "Русский",
|
"pages": {
|
||||||
"kazakh": "Қазақша",
|
"home": "Home page",
|
||||||
"ko": "language.ko"
|
"about": "About us",
|
||||||
},
|
"services": "Services",
|
||||||
"common": {
|
"portfolio": "Portfolio",
|
||||||
"loading": "Загрузка...",
|
"contact": "Contact",
|
||||||
"error": "Произошла ошибка",
|
"calculator": "Calculator"
|
||||||
"success": "Успешно",
|
},
|
||||||
"view_more": "Посмотреть еще",
|
"portfolio_page": {
|
||||||
"back": "Назад",
|
"title": "Our Portfolio",
|
||||||
"next": "Далее",
|
"subtitle": "Discover innovative projects and creative solutions",
|
||||||
"previous": "Предыдущий",
|
"categories": {
|
||||||
"view_details": "common.view_details"
|
"all": "All",
|
||||||
},
|
"web-development": "Web Development",
|
||||||
"undefined - SmartSolTech": "undefined - SmartSolTech",
|
"mobile-app": "Mobile App",
|
||||||
"meta": {
|
"ui-ux-design": "UI/UX Design",
|
||||||
"description": "meta.description",
|
"branding": "Branding",
|
||||||
"keywords": "meta.keywords",
|
"marketing": "Digital Marketing"
|
||||||
"title": "meta.title"
|
},
|
||||||
},
|
"buttons": {
|
||||||
"nav": {
|
"details": "View Details",
|
||||||
"home": "nav.home",
|
"projectDetails": "Project Details",
|
||||||
"about": "nav.about",
|
"loadMore": "Load More Projects",
|
||||||
"services": "nav.services",
|
"contact": "Request Project",
|
||||||
"portfolio": "nav.portfolio",
|
"calculate": "Calculate Cost"
|
||||||
"calculator": "nav.calculator"
|
},
|
||||||
}
|
"empty": {
|
||||||
|
"title": "No portfolio yet",
|
||||||
|
"subtitle": "We'll be showcasing amazing projects soon!"
|
||||||
|
},
|
||||||
|
"cta": {
|
||||||
|
"title": "Be the star of the next project",
|
||||||
|
"subtitle": "Create innovative digital solutions with us"
|
||||||
|
},
|
||||||
|
"labels": {
|
||||||
|
"featured": "FEATURED",
|
||||||
|
"views": "views",
|
||||||
|
"likes": "likes"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const User = require('../models/User');
|
const { User } = require('../models');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication middleware
|
* Authentication middleware
|
||||||
@@ -18,7 +18,9 @@ const authenticateToken = async (req, res, next) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
const user = await User.findById(decoded.userId).select('-password');
|
const user = await User.findByPk(decoded.userId, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
@@ -49,7 +51,9 @@ const authenticateSession = async (req, res, next) => {
|
|||||||
return res.redirect('/auth/login');
|
return res.redirect('/auth/login');
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findById(req.session.userId).select('-password');
|
const user = await User.findByPk(req.session.userId, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
req.session.destroy();
|
req.session.destroy();
|
||||||
@@ -115,7 +119,9 @@ const optionalAuth = async (req, res, next) => {
|
|||||||
try {
|
try {
|
||||||
// Check session first
|
// Check session first
|
||||||
if (req.session.userId) {
|
if (req.session.userId) {
|
||||||
const user = await User.findById(req.session.userId).select('-password');
|
const user = await User.findByPk(req.session.userId, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
if (user && user.isActive) {
|
if (user && user.isActive) {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
res.locals.user = user;
|
res.locals.user = user;
|
||||||
@@ -129,7 +135,9 @@ const optionalAuth = async (req, res, next) => {
|
|||||||
|
|
||||||
if (token) {
|
if (token) {
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
const user = await User.findById(decoded.userId).select('-password');
|
const user = await User.findByPk(decoded.userId, {
|
||||||
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
|
||||||
if (user && user.isActive) {
|
if (user && user.isActive) {
|
||||||
req.user = user;
|
req.user = user;
|
||||||
|
|||||||
195
models/Banner.js
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
|
const Banner = sequelize.define('Banner', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: false,
|
||||||
|
validate: {
|
||||||
|
notEmpty: true,
|
||||||
|
len: [1, 200]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
len: [0, 300]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
|
},
|
||||||
|
buttonText: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
len: [0, 50]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
buttonUrl: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
isUrl: true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
image: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Banner background image URL'
|
||||||
|
},
|
||||||
|
mobileImage: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Mobile-optimized banner image URL'
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
type: DataTypes.ENUM('hero', 'secondary', 'footer'),
|
||||||
|
defaultValue: 'hero',
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
order: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: false,
|
||||||
|
comment: 'Display order (lower numbers appear first)'
|
||||||
|
},
|
||||||
|
isActive: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
startDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Banner start display date'
|
||||||
|
},
|
||||||
|
endDate: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: 'Banner end display date'
|
||||||
|
},
|
||||||
|
clickCount: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
impressions: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
defaultValue: 0,
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
targetAudience: {
|
||||||
|
type: DataTypes.ENUM('all', 'mobile', 'desktop'),
|
||||||
|
defaultValue: 'all',
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
backgroundColor: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
|
||||||
|
},
|
||||||
|
comment: 'Hex color code for banner background'
|
||||||
|
},
|
||||||
|
textColor: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
is: /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/
|
||||||
|
},
|
||||||
|
comment: 'Hex color code for banner text'
|
||||||
|
},
|
||||||
|
animation: {
|
||||||
|
type: DataTypes.ENUM('none', 'fade', 'slide', 'zoom'),
|
||||||
|
defaultValue: 'none',
|
||||||
|
allowNull: false
|
||||||
|
},
|
||||||
|
metadata: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
allowNull: true,
|
||||||
|
defaultValue: {},
|
||||||
|
comment: 'Additional banner metadata and settings'
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
tableName: 'banners',
|
||||||
|
timestamps: true,
|
||||||
|
paranoid: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['isActive']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['position']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['order']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['startDate', 'endDate']
|
||||||
|
}
|
||||||
|
]
|
||||||
|
});
|
||||||
|
|
||||||
|
// Virtual field for checking if banner is currently active
|
||||||
|
Banner.prototype.isCurrentlyActive = function() {
|
||||||
|
if (!this.isActive) return false;
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
if (this.startDate && now < this.startDate) return false;
|
||||||
|
if (this.endDate && now > this.endDate) return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to increment click count
|
||||||
|
Banner.prototype.recordClick = async function() {
|
||||||
|
this.clickCount += 1;
|
||||||
|
await this.save();
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Method to increment impressions
|
||||||
|
Banner.prototype.recordImpression = async function() {
|
||||||
|
this.impressions += 1;
|
||||||
|
await this.save();
|
||||||
|
return this;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Static method to get active banners
|
||||||
|
Banner.getActiveBanners = async function(position = null) {
|
||||||
|
const whereClause = {
|
||||||
|
isActive: true
|
||||||
|
};
|
||||||
|
|
||||||
|
if (position) {
|
||||||
|
whereClause.position = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
|
||||||
|
return await this.findAll({
|
||||||
|
where: {
|
||||||
|
...whereClause,
|
||||||
|
[require('sequelize').Op.or]: [
|
||||||
|
{ startDate: null },
|
||||||
|
{ startDate: { [require('sequelize').Op.lte]: now } }
|
||||||
|
],
|
||||||
|
[require('sequelize').Op.or]: [
|
||||||
|
{ endDate: null },
|
||||||
|
{ endDate: { [require('sequelize').Op.gte]: now } }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
order: [['order', 'ASC'], ['createdAt', 'DESC']]
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = Banner;
|
||||||
@@ -1,80 +1,108 @@
|
|||||||
const mongoose = require('mongoose');
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
const contactSchema = new mongoose.Schema({
|
const Contact = sequelize.define('Contact', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
trim: true
|
set(value) {
|
||||||
|
this.setDataValue('name', value.trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
email: {
|
email: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
lowercase: true,
|
validate: {
|
||||||
trim: true
|
isEmail: true
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('email', value.toLowerCase().trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
phone: {
|
phone: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
trim: true
|
allowNull: true,
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('phone', value ? value.trim() : null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
company: {
|
company: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
trim: true
|
allowNull: true,
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('company', value ? value.trim() : null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
subject: {
|
subject: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
trim: true
|
set(value) {
|
||||||
|
this.setDataValue('subject', value.trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
message: {
|
message: {
|
||||||
type: String,
|
type: DataTypes.TEXT,
|
||||||
required: true
|
allowNull: false
|
||||||
},
|
},
|
||||||
serviceInterest: {
|
serviceInterest: {
|
||||||
type: String,
|
type: DataTypes.ENUM('web-development', 'mobile-app', 'ui-ux-design', 'branding', 'consulting', 'other'),
|
||||||
enum: ['web-development', 'mobile-app', 'ui-ux-design', 'branding', 'consulting', 'other']
|
allowNull: true
|
||||||
},
|
},
|
||||||
budget: {
|
budget: {
|
||||||
type: String,
|
type: DataTypes.ENUM('under-1m', '1m-5m', '5m-10m', '10m-20m', '20m-50m', 'over-50m'),
|
||||||
enum: ['under-1m', '1m-5m', '5m-10m', '10m-20m', '20m-50m', 'over-50m']
|
allowNull: true
|
||||||
},
|
},
|
||||||
timeline: {
|
timeline: {
|
||||||
type: String,
|
type: DataTypes.ENUM('asap', '1-month', '1-3-months', '3-6-months', 'flexible'),
|
||||||
enum: ['asap', '1-month', '1-3-months', '3-6-months', 'flexible']
|
allowNull: true
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: DataTypes.ENUM('new', 'in-progress', 'replied', 'closed'),
|
||||||
enum: ['new', 'in-progress', 'replied', 'closed'],
|
defaultValue: 'new'
|
||||||
default: 'new'
|
|
||||||
},
|
},
|
||||||
priority: {
|
priority: {
|
||||||
type: String,
|
type: DataTypes.ENUM('low', 'medium', 'high', 'urgent'),
|
||||||
enum: ['low', 'medium', 'high', 'urgent'],
|
defaultValue: 'medium'
|
||||||
default: 'medium'
|
|
||||||
},
|
},
|
||||||
source: {
|
source: {
|
||||||
type: String,
|
type: DataTypes.ENUM('website', 'telegram', 'email', 'phone', 'referral'),
|
||||||
enum: ['website', 'telegram', 'email', 'phone', 'referral'],
|
defaultValue: 'website'
|
||||||
default: 'website'
|
|
||||||
},
|
},
|
||||||
isRead: {
|
isRead: {
|
||||||
type: Boolean,
|
type: DataTypes.BOOLEAN,
|
||||||
default: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
adminNotes: {
|
adminNotes: {
|
||||||
type: String
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
},
|
},
|
||||||
ipAddress: {
|
ipAddress: {
|
||||||
type: String
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true
|
||||||
},
|
},
|
||||||
userAgent: {
|
userAgent: {
|
||||||
type: String
|
type: DataTypes.TEXT,
|
||||||
|
allowNull: true
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
tableName: 'contacts',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['status', 'createdAt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['isRead', 'createdAt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['email']
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
contactSchema.index({ status: 1, createdAt: -1 });
|
module.exports = Contact;
|
||||||
contactSchema.index({ isRead: 1, createdAt: -1 });
|
|
||||||
contactSchema.index({ email: 1 });
|
|
||||||
|
|
||||||
module.exports = mongoose.model('Contact', contactSchema);
|
|
||||||
@@ -1,107 +1,121 @@
|
|||||||
const mongoose = require('mongoose');
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
const portfolioSchema = new mongoose.Schema({
|
const Portfolio = sequelize.define('Portfolio', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
title: {
|
title: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
trim: true
|
validate: {
|
||||||
|
len: [1, 255]
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('title', value.trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: String,
|
type: DataTypes.TEXT,
|
||||||
required: true
|
allowNull: false
|
||||||
},
|
},
|
||||||
shortDescription: {
|
shortDescription: {
|
||||||
type: String,
|
type: DataTypes.STRING(200),
|
||||||
required: true,
|
allowNull: false
|
||||||
maxlength: 200
|
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
type: String,
|
type: DataTypes.ENUM('web-development', 'mobile-app', 'ui-ux-design', 'branding', 'e-commerce', 'other'),
|
||||||
required: true,
|
allowNull: false
|
||||||
enum: ['web-development', 'mobile-app', 'ui-ux-design', 'branding', 'e-commerce', 'other']
|
},
|
||||||
|
technologies: {
|
||||||
|
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||||
|
defaultValue: []
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: []
|
||||||
},
|
},
|
||||||
technologies: [{
|
|
||||||
type: String,
|
|
||||||
trim: true
|
|
||||||
}],
|
|
||||||
images: [{
|
|
||||||
url: {
|
|
||||||
type: String,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
alt: {
|
|
||||||
type: String,
|
|
||||||
default: ''
|
|
||||||
},
|
|
||||||
isPrimary: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
clientName: {
|
clientName: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
trim: true
|
allowNull: true,
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('clientName', value ? value.trim() : null);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
projectUrl: {
|
projectUrl: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
trim: true
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
isUrl: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
githubUrl: {
|
githubUrl: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
trim: true
|
allowNull: true,
|
||||||
|
validate: {
|
||||||
|
isUrl: true
|
||||||
|
}
|
||||||
},
|
},
|
||||||
status: {
|
status: {
|
||||||
type: String,
|
type: DataTypes.ENUM('completed', 'in-progress', 'planning'),
|
||||||
enum: ['completed', 'in-progress', 'planning'],
|
defaultValue: 'completed'
|
||||||
default: 'completed'
|
|
||||||
},
|
},
|
||||||
featured: {
|
featured: {
|
||||||
type: Boolean,
|
type: DataTypes.BOOLEAN,
|
||||||
default: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
publishedAt: {
|
publishedAt: {
|
||||||
type: Date,
|
type: DataTypes.DATE,
|
||||||
default: Date.now
|
defaultValue: DataTypes.NOW
|
||||||
},
|
},
|
||||||
completedAt: {
|
completedAt: {
|
||||||
type: Date
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true
|
||||||
},
|
},
|
||||||
isPublished: {
|
isPublished: {
|
||||||
type: Boolean,
|
type: DataTypes.BOOLEAN,
|
||||||
default: true
|
defaultValue: true
|
||||||
},
|
},
|
||||||
viewCount: {
|
viewCount: {
|
||||||
type: Number,
|
type: DataTypes.INTEGER,
|
||||||
default: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
likes: {
|
likes: {
|
||||||
type: Number,
|
type: DataTypes.INTEGER,
|
||||||
default: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
type: Number,
|
type: DataTypes.INTEGER,
|
||||||
default: 0
|
defaultValue: 0
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
metaTitle: String,
|
type: DataTypes.JSONB,
|
||||||
metaDescription: String,
|
defaultValue: {}
|
||||||
keywords: [String]
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
tableName: 'portfolios',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['category', 'publishedAt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
fields: ['featured', 'publishedAt']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'gin',
|
||||||
|
fields: ['technologies']
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
// Index for search and sorting
|
|
||||||
portfolioSchema.index({ title: 'text', description: 'text', technologies: 'text' });
|
|
||||||
portfolioSchema.index({ category: 1, publishedAt: -1 });
|
|
||||||
portfolioSchema.index({ featured: -1, publishedAt: -1 });
|
|
||||||
|
|
||||||
// Virtual for primary image
|
// Virtual for primary image
|
||||||
portfolioSchema.virtual('primaryImage').get(function() {
|
Portfolio.prototype.getPrimaryImage = function() {
|
||||||
|
if (!this.images || this.images.length === 0) return null;
|
||||||
const primary = this.images.find(img => img.isPrimary);
|
const primary = this.images.find(img => img.isPrimary);
|
||||||
return primary || (this.images.length > 0 ? this.images[0] : null);
|
return primary || this.images[0];
|
||||||
});
|
};
|
||||||
|
|
||||||
portfolioSchema.set('toJSON', { virtuals: true });
|
module.exports = Portfolio;
|
||||||
|
|
||||||
module.exports = mongoose.model('Portfolio', portfolioSchema);
|
|
||||||
@@ -1,102 +1,96 @@
|
|||||||
const mongoose = require('mongoose');
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
const serviceSchema = new mongoose.Schema({
|
const Service = sequelize.define('Service', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
name: {
|
name: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
trim: true
|
validate: {
|
||||||
|
len: [1, 255]
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('name', value.trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
description: {
|
description: {
|
||||||
type: String,
|
type: DataTypes.TEXT,
|
||||||
required: true
|
allowNull: false
|
||||||
},
|
},
|
||||||
shortDescription: {
|
shortDescription: {
|
||||||
type: String,
|
type: DataTypes.STRING(150),
|
||||||
required: true,
|
allowNull: false
|
||||||
maxlength: 150
|
|
||||||
},
|
},
|
||||||
icon: {
|
icon: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true
|
allowNull: false
|
||||||
},
|
},
|
||||||
category: {
|
category: {
|
||||||
type: String,
|
type: DataTypes.ENUM('development', 'design', 'consulting', 'marketing', 'maintenance'),
|
||||||
required: true,
|
allowNull: false
|
||||||
enum: ['development', 'design', 'consulting', 'marketing', 'maintenance']
|
},
|
||||||
|
features: {
|
||||||
|
type: DataTypes.JSONB,
|
||||||
|
defaultValue: []
|
||||||
},
|
},
|
||||||
features: [{
|
|
||||||
name: String,
|
|
||||||
description: String,
|
|
||||||
included: {
|
|
||||||
type: Boolean,
|
|
||||||
default: true
|
|
||||||
}
|
|
||||||
}],
|
|
||||||
pricing: {
|
pricing: {
|
||||||
basePrice: {
|
type: DataTypes.JSONB,
|
||||||
type: Number,
|
allowNull: false,
|
||||||
required: true,
|
validate: {
|
||||||
min: 0
|
isValidPricing(value) {
|
||||||
},
|
if (!value.basePrice || value.basePrice < 0) {
|
||||||
currency: {
|
throw new Error('Base price must be a positive number');
|
||||||
type: String,
|
}
|
||||||
default: 'KRW'
|
}
|
||||||
},
|
|
||||||
priceType: {
|
|
||||||
type: String,
|
|
||||||
enum: ['fixed', 'hourly', 'project'],
|
|
||||||
default: 'project'
|
|
||||||
},
|
|
||||||
priceRange: {
|
|
||||||
min: Number,
|
|
||||||
max: Number
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
estimatedTime: {
|
estimatedTime: {
|
||||||
min: {
|
type: DataTypes.JSONB,
|
||||||
type: Number,
|
allowNull: false,
|
||||||
required: true
|
validate: {
|
||||||
},
|
isValidTime(value) {
|
||||||
max: {
|
if (!value.min || !value.max || value.min > value.max) {
|
||||||
type: Number,
|
throw new Error('Invalid estimated time range');
|
||||||
required: true
|
}
|
||||||
},
|
}
|
||||||
unit: {
|
|
||||||
type: String,
|
|
||||||
enum: ['hours', 'days', 'weeks', 'months'],
|
|
||||||
default: 'days'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
isActive: {
|
isActive: {
|
||||||
type: Boolean,
|
type: DataTypes.BOOLEAN,
|
||||||
default: true
|
defaultValue: true
|
||||||
},
|
},
|
||||||
featured: {
|
featured: {
|
||||||
type: Boolean,
|
type: DataTypes.BOOLEAN,
|
||||||
default: false
|
defaultValue: false
|
||||||
},
|
},
|
||||||
order: {
|
order: {
|
||||||
type: Number,
|
type: DataTypes.INTEGER,
|
||||||
default: 0
|
defaultValue: 0
|
||||||
|
},
|
||||||
|
tags: {
|
||||||
|
type: DataTypes.ARRAY(DataTypes.STRING),
|
||||||
|
defaultValue: []
|
||||||
},
|
},
|
||||||
portfolio: [{
|
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
|
||||||
ref: 'Portfolio'
|
|
||||||
}],
|
|
||||||
tags: [{
|
|
||||||
type: String,
|
|
||||||
trim: true
|
|
||||||
}],
|
|
||||||
seo: {
|
seo: {
|
||||||
metaTitle: String,
|
type: DataTypes.JSONB,
|
||||||
metaDescription: String,
|
defaultValue: {}
|
||||||
keywords: [String]
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
tableName: 'services',
|
||||||
|
timestamps: true,
|
||||||
|
indexes: [
|
||||||
|
{
|
||||||
|
fields: ['category', 'featured', 'order']
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'gin',
|
||||||
|
fields: ['tags']
|
||||||
|
}
|
||||||
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
serviceSchema.index({ name: 'text', description: 'text', tags: 'text' });
|
module.exports = Service;
|
||||||
serviceSchema.index({ category: 1, featured: -1, order: 1 });
|
|
||||||
|
|
||||||
module.exports = mongoose.model('Service', serviceSchema);
|
|
||||||
@@ -1,116 +1,82 @@
|
|||||||
const mongoose = require('mongoose');
|
const { DataTypes } = require('sequelize');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
const siteSettingsSchema = new mongoose.Schema({
|
const SiteSettings = sequelize.define('SiteSettings', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
siteName: {
|
siteName: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
default: 'SmartSolTech'
|
defaultValue: 'SmartSolTech'
|
||||||
},
|
},
|
||||||
siteDescription: {
|
siteDescription: {
|
||||||
type: String,
|
type: DataTypes.TEXT,
|
||||||
default: 'Innovative technology solutions for modern businesses'
|
defaultValue: 'Innovative technology solutions for modern businesses'
|
||||||
},
|
},
|
||||||
logo: {
|
logo: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
default: '/images/logo.png'
|
defaultValue: '/images/logo.png'
|
||||||
},
|
},
|
||||||
favicon: {
|
favicon: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
default: '/images/favicon.ico'
|
defaultValue: '/images/favicon.ico'
|
||||||
},
|
},
|
||||||
contact: {
|
contact: {
|
||||||
email: {
|
type: DataTypes.JSONB,
|
||||||
type: String,
|
defaultValue: {
|
||||||
default: 'info@smartsoltech.kr'
|
email: 'info@smartsoltech.kr',
|
||||||
},
|
phone: '+82-10-0000-0000',
|
||||||
phone: {
|
address: 'Seoul, South Korea'
|
||||||
type: String,
|
|
||||||
default: '+82-10-0000-0000'
|
|
||||||
},
|
|
||||||
address: {
|
|
||||||
type: String,
|
|
||||||
default: 'Seoul, South Korea'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
social: {
|
social: {
|
||||||
facebook: String,
|
type: DataTypes.JSONB,
|
||||||
twitter: String,
|
defaultValue: {}
|
||||||
linkedin: String,
|
|
||||||
instagram: String,
|
|
||||||
github: String,
|
|
||||||
telegram: String
|
|
||||||
},
|
},
|
||||||
telegram: {
|
telegram: {
|
||||||
botToken: String,
|
type: DataTypes.JSONB,
|
||||||
chatId: String,
|
defaultValue: {
|
||||||
isEnabled: {
|
isEnabled: false
|
||||||
type: Boolean,
|
|
||||||
default: false
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
seo: {
|
seo: {
|
||||||
metaTitle: {
|
type: DataTypes.JSONB,
|
||||||
type: String,
|
defaultValue: {
|
||||||
default: 'SmartSolTech - Technology Solutions'
|
metaTitle: 'SmartSolTech - Technology Solutions',
|
||||||
},
|
metaDescription: 'Professional web development, mobile apps, and digital solutions in Korea',
|
||||||
metaDescription: {
|
keywords: 'web development, mobile apps, UI/UX design, Korea, technology'
|
||||||
type: String,
|
}
|
||||||
default: 'Professional web development, mobile apps, and digital solutions in Korea'
|
|
||||||
},
|
|
||||||
keywords: {
|
|
||||||
type: String,
|
|
||||||
default: 'web development, mobile apps, UI/UX design, Korea, technology'
|
|
||||||
},
|
|
||||||
googleAnalytics: String,
|
|
||||||
googleTagManager: String
|
|
||||||
},
|
},
|
||||||
hero: {
|
hero: {
|
||||||
title: {
|
type: DataTypes.JSONB,
|
||||||
type: String,
|
defaultValue: {
|
||||||
default: 'Smart Technology Solutions'
|
title: 'Smart Technology Solutions',
|
||||||
},
|
subtitle: 'We create innovative digital experiences that drive business growth',
|
||||||
subtitle: {
|
backgroundImage: '/images/hero-bg.jpg',
|
||||||
type: String,
|
ctaText: 'Get Started',
|
||||||
default: 'We create innovative digital experiences that drive business growth'
|
ctaLink: '#contact'
|
||||||
},
|
|
||||||
backgroundImage: {
|
|
||||||
type: String,
|
|
||||||
default: '/images/hero-bg.jpg'
|
|
||||||
},
|
|
||||||
ctaText: {
|
|
||||||
type: String,
|
|
||||||
default: 'Get Started'
|
|
||||||
},
|
|
||||||
ctaLink: {
|
|
||||||
type: String,
|
|
||||||
default: '#contact'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
about: {
|
about: {
|
||||||
title: {
|
type: DataTypes.JSONB,
|
||||||
type: String,
|
defaultValue: {
|
||||||
default: 'About SmartSolTech'
|
title: 'About SmartSolTech',
|
||||||
},
|
description: 'We are a team of passionate developers and designers creating cutting-edge technology solutions.',
|
||||||
description: {
|
image: '/images/about.jpg'
|
||||||
type: String,
|
|
||||||
default: 'We are a team of passionate developers and designers creating cutting-edge technology solutions.'
|
|
||||||
},
|
|
||||||
image: {
|
|
||||||
type: String,
|
|
||||||
default: '/images/about.jpg'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
maintenance: {
|
maintenance: {
|
||||||
isEnabled: {
|
type: DataTypes.JSONB,
|
||||||
type: Boolean,
|
defaultValue: {
|
||||||
default: false
|
isEnabled: false,
|
||||||
},
|
message: 'We are currently performing maintenance. Please check back soon.'
|
||||||
message: {
|
|
||||||
type: String,
|
|
||||||
default: 'We are currently performing maintenance. Please check back soon.'
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
|
tableName: 'site_settings',
|
||||||
timestamps: true
|
timestamps: true
|
||||||
});
|
});
|
||||||
|
|
||||||
module.exports = mongoose.model('SiteSettings', siteSettingsSchema);
|
module.exports = SiteSettings;
|
||||||
@@ -1,75 +1,78 @@
|
|||||||
const mongoose = require('mongoose');
|
const { DataTypes } = require('sequelize');
|
||||||
const bcrypt = require('bcryptjs');
|
const bcrypt = require('bcryptjs');
|
||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
const userSchema = new mongoose.Schema({
|
const User = sequelize.define('User', {
|
||||||
|
id: {
|
||||||
|
type: DataTypes.UUID,
|
||||||
|
defaultValue: DataTypes.UUIDV4,
|
||||||
|
primaryKey: true
|
||||||
|
},
|
||||||
email: {
|
email: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
unique: true,
|
unique: true,
|
||||||
lowercase: true,
|
validate: {
|
||||||
trim: true
|
isEmail: true
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('email', value.toLowerCase().trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
password: {
|
password: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
minlength: 6
|
validate: {
|
||||||
|
len: [6, 255]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
name: {
|
name: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
required: true,
|
allowNull: false,
|
||||||
trim: true
|
validate: {
|
||||||
|
len: [1, 100]
|
||||||
|
},
|
||||||
|
set(value) {
|
||||||
|
this.setDataValue('name', value.trim());
|
||||||
|
}
|
||||||
},
|
},
|
||||||
role: {
|
role: {
|
||||||
type: String,
|
type: DataTypes.ENUM('admin', 'moderator'),
|
||||||
enum: ['admin', 'moderator'],
|
defaultValue: 'admin'
|
||||||
default: 'admin'
|
|
||||||
},
|
},
|
||||||
avatar: {
|
avatar: {
|
||||||
type: String,
|
type: DataTypes.STRING,
|
||||||
default: null
|
allowNull: true
|
||||||
},
|
},
|
||||||
isActive: {
|
isActive: {
|
||||||
type: Boolean,
|
type: DataTypes.BOOLEAN,
|
||||||
default: true
|
defaultValue: true
|
||||||
},
|
},
|
||||||
lastLogin: {
|
lastLogin: {
|
||||||
type: Date,
|
type: DataTypes.DATE,
|
||||||
default: null
|
allowNull: true
|
||||||
},
|
|
||||||
createdAt: {
|
|
||||||
type: Date,
|
|
||||||
default: Date.now
|
|
||||||
},
|
|
||||||
updatedAt: {
|
|
||||||
type: Date,
|
|
||||||
default: Date.now
|
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
tableName: 'users',
|
||||||
});
|
timestamps: true,
|
||||||
|
hooks: {
|
||||||
// Hash password before saving
|
beforeSave: async (user) => {
|
||||||
userSchema.pre('save', async function(next) {
|
if (user.changed('password')) {
|
||||||
if (!this.isModified('password')) return next();
|
const salt = await bcrypt.genSalt(12);
|
||||||
|
user.password = await bcrypt.hash(user.password, salt);
|
||||||
try {
|
}
|
||||||
const salt = await bcrypt.genSalt(12);
|
}
|
||||||
this.password = await bcrypt.hash(this.password, salt);
|
|
||||||
next();
|
|
||||||
} catch (error) {
|
|
||||||
next(error);
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Compare password method
|
// Instance methods
|
||||||
userSchema.methods.comparePassword = async function(candidatePassword) {
|
User.prototype.comparePassword = async function(candidatePassword) {
|
||||||
return bcrypt.compare(candidatePassword, this.password);
|
return bcrypt.compare(candidatePassword, this.password);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update last login
|
User.prototype.updateLastLogin = function() {
|
||||||
userSchema.methods.updateLastLogin = function() {
|
|
||||||
this.lastLogin = new Date();
|
this.lastLogin = new Date();
|
||||||
return this.save();
|
return this.save();
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = mongoose.model('User', userSchema);
|
module.exports = User;
|
||||||
25
models/index.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
const { sequelize } = require('../config/database');
|
||||||
|
|
||||||
|
// Import models
|
||||||
|
const User = require('./User');
|
||||||
|
const Portfolio = require('./Portfolio');
|
||||||
|
const Service = require('./Service');
|
||||||
|
const Contact = require('./Contact');
|
||||||
|
const SiteSettings = require('./SiteSettings');
|
||||||
|
const Banner = require('./Banner');
|
||||||
|
|
||||||
|
// Define associations here if needed
|
||||||
|
// For example:
|
||||||
|
// Service.belongsToMany(Portfolio, { through: 'ServicePortfolio' });
|
||||||
|
// Portfolio.belongsToMany(Service, { through: 'ServicePortfolio' });
|
||||||
|
|
||||||
|
// Export models and sequelize instance
|
||||||
|
module.exports = {
|
||||||
|
sequelize,
|
||||||
|
User,
|
||||||
|
Portfolio,
|
||||||
|
Service,
|
||||||
|
Contact,
|
||||||
|
SiteSettings,
|
||||||
|
Banner
|
||||||
|
};
|
||||||
659
package-lock.json
generated
@@ -9,26 +9,30 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"connect-flash": "^0.1.1",
|
"connect-flash": "^0.1.1",
|
||||||
"connect-mongo": "^5.1.0",
|
"connect-session-sequelize": "^8.0.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-ejs-layouts": "^2.5.1",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"i18n": "^0.15.2",
|
"i18n": "^0.15.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^8.0.3",
|
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-telegram-bot-api": "^0.64.0",
|
"node-telegram-bot-api": "^0.64.0",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"sharp": "^0.33.0",
|
"pg": "^8.16.3",
|
||||||
|
"pg-hstore": "^2.3.4",
|
||||||
|
"sequelize": "^6.37.7",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.4"
|
"socket.io": "^4.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@@ -2127,14 +2131,6 @@
|
|||||||
"make-plural": "^7.0.0"
|
"make-plural": "^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@mongodb-js/saslprep": {
|
|
||||||
"version": "1.3.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.3.2.tgz",
|
|
||||||
"integrity": "sha512-QgA5AySqB27cGTXBFmnpifAi7HxoGUeezwo6p9dI03MuDB6Pp33zgclqVb6oVK3j6I9Vesg0+oojW2XxB59SGg==",
|
|
||||||
"dependencies": {
|
|
||||||
"sparse-bitfield": "^3.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@rollup/plugin-babel": {
|
"node_modules/@rollup/plugin-babel": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
|
||||||
@@ -2305,6 +2301,14 @@
|
|||||||
"@types/node": "*"
|
"@types/node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/debug": {
|
||||||
|
"version": "4.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz",
|
||||||
|
"integrity": "sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/ms": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/eslint": {
|
"node_modules/@types/eslint": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz",
|
||||||
@@ -2343,6 +2347,11 @@
|
|||||||
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/ms": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA=="
|
||||||
|
},
|
||||||
"node_modules/@types/node": {
|
"node_modules/@types/node": {
|
||||||
"version": "24.8.1",
|
"version": "24.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.8.1.tgz",
|
||||||
@@ -2363,18 +2372,10 @@
|
|||||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/@types/webidl-conversions": {
|
"node_modules/@types/validator": {
|
||||||
"version": "7.0.3",
|
"version": "13.15.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/webidl-conversions/-/webidl-conversions-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/validator/-/validator-13.15.3.tgz",
|
||||||
"integrity": "sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA=="
|
"integrity": "sha512-7bcUmDyS6PN3EuD9SlGGOxM77F8WLVsrwkxyWxKnxzmXoequ6c7741QBrANq6htVRGOITJ7z72mTP6Z4XyuG+Q=="
|
||||||
},
|
|
||||||
"node_modules/@types/whatwg-url": {
|
|
||||||
"version": "11.0.5",
|
|
||||||
"resolved": "https://registry.npmjs.org/@types/whatwg-url/-/whatwg-url-11.0.5.tgz",
|
|
||||||
"integrity": "sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/webidl-conversions": "*"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/@webassemblyjs/ast": {
|
"node_modules/@webassemblyjs/ast": {
|
||||||
"version": "1.14.1",
|
"version": "1.14.1",
|
||||||
@@ -2776,17 +2777,6 @@
|
|||||||
"safer-buffer": "~2.1.0"
|
"safer-buffer": "~2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/asn1.js": {
|
|
||||||
"version": "5.4.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz",
|
|
||||||
"integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==",
|
|
||||||
"dependencies": {
|
|
||||||
"bn.js": "^4.0.0",
|
|
||||||
"inherits": "^2.0.1",
|
|
||||||
"minimalistic-assert": "^1.0.0",
|
|
||||||
"safer-buffer": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/assert-plus": {
|
"node_modules/assert-plus": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz",
|
||||||
@@ -2850,6 +2840,16 @@
|
|||||||
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
|
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz",
|
||||||
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="
|
"integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/axios": {
|
||||||
|
"version": "1.12.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/axios/-/axios-1.12.2.tgz",
|
||||||
|
"integrity": "sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==",
|
||||||
|
"dependencies": {
|
||||||
|
"follow-redirects": "^1.15.6",
|
||||||
|
"form-data": "^4.0.4",
|
||||||
|
"proxy-from-env": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/babel-plugin-polyfill-corejs2": {
|
"node_modules/babel-plugin-polyfill-corejs2": {
|
||||||
"version": "0.4.14",
|
"version": "0.4.14",
|
||||||
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.14.tgz",
|
||||||
@@ -2985,11 +2985,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz",
|
||||||
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg=="
|
||||||
},
|
},
|
||||||
"node_modules/bn.js": {
|
|
||||||
"version": "4.12.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
|
|
||||||
"integrity": "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="
|
|
||||||
},
|
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.3",
|
"version": "1.20.3",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz",
|
||||||
@@ -3074,14 +3069,6 @@
|
|||||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/bson": {
|
|
||||||
"version": "6.10.4",
|
|
||||||
"resolved": "https://registry.npmjs.org/bson/-/bson-6.10.4.tgz",
|
|
||||||
"integrity": "sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.20.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
@@ -3377,23 +3364,21 @@
|
|||||||
"node": ">= 0.4.0"
|
"node": ">= 0.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/connect-mongo": {
|
"node_modules/connect-session-sequelize": {
|
||||||
"version": "5.1.0",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/connect-mongo/-/connect-mongo-5.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/connect-session-sequelize/-/connect-session-sequelize-8.0.2.tgz",
|
||||||
"integrity": "sha512-xT0vxQLqyqoUTxPLzlP9a/u+vir0zNkhiy9uAdHjSCcUUf7TS5b55Icw8lVyYFxfemP3Mf9gdwUOgeF3cxCAhw==",
|
"integrity": "sha512-qelSESMV/GWO+w5OIPpDs/a53x/e9BeYWVLqCr5Kvx6tNwNUypR5m3+408oI3pSCCmv7G/iderhvLvcqZgdVmA==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"debug": "^4.3.1",
|
"debug": "^4.4.1"
|
||||||
"kruptein": "^3.0.0"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12.9.0"
|
"node": ">= 22"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"express-session": "^1.17.1",
|
"sequelize": ">= 6.37.7"
|
||||||
"mongodb": ">= 5.1.0 < 7"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/connect-mongo/node_modules/debug": {
|
"node_modules/connect-session-sequelize/node_modules/debug": {
|
||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
@@ -3409,7 +3394,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/connect-mongo/node_modules/ms": {
|
"node_modules/connect-session-sequelize/node_modules/ms": {
|
||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
@@ -3440,9 +3425,9 @@
|
|||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/cookie": {
|
"node_modules/cookie": {
|
||||||
"version": "0.7.1",
|
"version": "0.7.2",
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
||||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
@@ -3459,14 +3444,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cookie-parser/node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/cookie-signature": {
|
"node_modules/cookie-signature": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
|
||||||
@@ -3826,6 +3803,11 @@
|
|||||||
"url": "https://dotenvx.com"
|
"url": "https://dotenvx.com"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dottie": {
|
||||||
|
"version": "2.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/dottie/-/dottie-2.0.6.tgz",
|
||||||
|
"integrity": "sha512-iGCHkfUc5kFekGiqhe8B/mdaurD+lakO9txNnTvKtA6PISrw86LgqHvRzWYPyoE2Ph5aMIrCw9/uko6XHTKCwA=="
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@@ -3934,14 +3916,6 @@
|
|||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/engine.io/node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/engine.io/node_modules/debug": {
|
"node_modules/engine.io/node_modules/debug": {
|
||||||
"version": "4.3.7",
|
"version": "4.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz",
|
||||||
@@ -4277,6 +4251,11 @@
|
|||||||
"url": "https://opencollective.com/express"
|
"url": "https://opencollective.com/express"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express-ejs-layouts": {
|
||||||
|
"version": "2.5.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/express-ejs-layouts/-/express-ejs-layouts-2.5.1.tgz",
|
||||||
|
"integrity": "sha512-IXROv9n3xKga7FowT06n1Qn927JR8ZWDn5Dc9CJQoiiaaDqbhW5PDmWShzbpAa2wjWT1vJqaIM1S6vJwwX11gA=="
|
||||||
|
},
|
||||||
"node_modules/express-rate-limit": {
|
"node_modules/express-rate-limit": {
|
||||||
"version": "7.5.1",
|
"version": "7.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
|
||||||
@@ -4309,14 +4288,6 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/express-session/node_modules/cookie": {
|
|
||||||
"version": "0.7.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
|
|
||||||
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/express-session/node_modules/cookie-signature": {
|
"node_modules/express-session/node_modules/cookie-signature": {
|
||||||
"version": "1.0.7",
|
"version": "1.0.7",
|
||||||
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
"resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
|
||||||
@@ -4334,6 +4305,14 @@
|
|||||||
"node": ">= 8.0.0"
|
"node": ">= 8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/express/node_modules/cookie": {
|
||||||
|
"version": "0.7.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||||
|
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/extend": {
|
"node_modules/extend": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
@@ -4499,6 +4478,25 @@
|
|||||||
"flat": "cli.js"
|
"flat": "cli.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/follow-redirects": {
|
||||||
|
"version": "1.15.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",
|
||||||
|
"integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "individual",
|
||||||
|
"url": "https://github.com/sponsors/RubenVerborgh"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"debug": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/for-each": {
|
"node_modules/for-each": {
|
||||||
"version": "0.3.5",
|
"version": "0.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz",
|
||||||
@@ -5090,6 +5088,14 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/inflection": {
|
||||||
|
"version": "1.13.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/inflection/-/inflection-1.13.4.tgz",
|
||||||
|
"integrity": "sha512-6I/HUDeYFfuNCVS3td055BaXBwKYuzw7K3ExVMStBowKo9oOAMJIXIHvdyR3iboTCp1b+1i5DSkIZTcwIktuDw==",
|
||||||
|
"engines": [
|
||||||
|
"node >= 0.4.0"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/inflight": {
|
"node_modules/inflight": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
|
||||||
@@ -5768,14 +5774,6 @@
|
|||||||
"safe-buffer": "^5.0.1"
|
"safe-buffer": "^5.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kareem": {
|
|
||||||
"version": "2.6.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/kareem/-/kareem-2.6.3.tgz",
|
|
||||||
"integrity": "sha512-C3iHfuGUXK2u8/ipq9LfjFfXFxAZMQJJq7vLS45r3D9Y2xQ/m4S8zaR4zMLFWh9AsNPXmcFfUDhTEO8UIC/V6Q==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=12.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/kind-of": {
|
"node_modules/kind-of": {
|
||||||
"version": "6.0.3",
|
"version": "6.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
|
||||||
@@ -5785,17 +5783,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/kruptein": {
|
|
||||||
"version": "3.0.8",
|
|
||||||
"resolved": "https://registry.npmjs.org/kruptein/-/kruptein-3.0.8.tgz",
|
|
||||||
"integrity": "sha512-0CyalFA0Cjp3jnziMp0u1uLZW2/ouhQ0mEMfYlroBXNe86na1RwAuwBcdRAegeWZNMfQy/G5fN47g/Axjtqrfw==",
|
|
||||||
"dependencies": {
|
|
||||||
"asn1.js": "^5.4.1"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">8"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/leven": {
|
"node_modules/leven": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz",
|
||||||
@@ -5952,11 +5939,6 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/memory-pager": {
|
|
||||||
"version": "1.5.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/memory-pager/-/memory-pager-1.5.0.tgz",
|
|
||||||
"integrity": "sha512-ZS4Bp4r/Zoeq6+NLJpP+0Zzm0pR8whtGPf1XExKLJBAczGMnSi3It14OiNCStjQjM6NU1okjQGSxgEZN8eBYKg=="
|
|
||||||
},
|
|
||||||
"node_modules/merge-descriptors": {
|
"node_modules/merge-descriptors": {
|
||||||
"version": "1.0.3",
|
"version": "1.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
|
||||||
@@ -6090,11 +6072,6 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"url": "https://opencollective.com/webpack"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/minimalistic-assert": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A=="
|
|
||||||
},
|
|
||||||
"node_modules/minimatch": {
|
"node_modules/minimatch": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz",
|
||||||
@@ -6126,86 +6103,25 @@
|
|||||||
"mkdirp": "bin/cmd.js"
|
"mkdirp": "bin/cmd.js"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mongodb": {
|
"node_modules/moment": {
|
||||||
"version": "6.20.0",
|
"version": "2.30.1",
|
||||||
"resolved": "https://registry.npmjs.org/mongodb/-/mongodb-6.20.0.tgz",
|
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||||
"integrity": "sha512-Tl6MEIU3K4Rq3TSHd+sZQqRBoGlFsOgNrH5ltAcFBV62Re3Fd+FcaVf8uSEQFOJ51SDowDVttBTONMfoYWrWlQ==",
|
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||||
|
"engines": {
|
||||||
|
"node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/moment-timezone": {
|
||||||
|
"version": "0.5.48",
|
||||||
|
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz",
|
||||||
|
"integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@mongodb-js/saslprep": "^1.3.0",
|
"moment": "^2.29.4"
|
||||||
"bson": "^6.10.4",
|
|
||||||
"mongodb-connection-string-url": "^3.0.2"
|
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.20.1"
|
"node": "*"
|
||||||
},
|
|
||||||
"peerDependencies": {
|
|
||||||
"@aws-sdk/credential-providers": "^3.188.0",
|
|
||||||
"@mongodb-js/zstd": "^1.1.0 || ^2.0.0",
|
|
||||||
"gcp-metadata": "^5.2.0",
|
|
||||||
"kerberos": "^2.0.1",
|
|
||||||
"mongodb-client-encryption": ">=6.0.0 <7",
|
|
||||||
"snappy": "^7.3.2",
|
|
||||||
"socks": "^2.7.1"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"@aws-sdk/credential-providers": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"@mongodb-js/zstd": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"gcp-metadata": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"kerberos": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"mongodb-client-encryption": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"snappy": {
|
|
||||||
"optional": true
|
|
||||||
},
|
|
||||||
"socks": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mongodb-connection-string-url": {
|
|
||||||
"version": "3.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/mongodb-connection-string-url/-/mongodb-connection-string-url-3.0.2.tgz",
|
|
||||||
"integrity": "sha512-rMO7CGo/9BFwyZABcKAWL8UJwH/Kc2x0g72uhDWzG48URRax5TCIcJ7Rc3RZqffZzO/Gwff/jyKwCU9TN8gehA==",
|
|
||||||
"dependencies": {
|
|
||||||
"@types/whatwg-url": "^11.0.2",
|
|
||||||
"whatwg-url": "^14.1.0 || ^13.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mongoose": {
|
|
||||||
"version": "8.19.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/mongoose/-/mongoose-8.19.1.tgz",
|
|
||||||
"integrity": "sha512-oB7hGQJn4f8aebqE7mhE54EReb5cxVgpCxQCQj0K/cK3q4J3Tg08nFP6sM52nJ4Hlm8jsDnhVYpqIITZUAhckQ==",
|
|
||||||
"dependencies": {
|
|
||||||
"bson": "^6.10.4",
|
|
||||||
"kareem": "2.6.3",
|
|
||||||
"mongodb": "~6.20.0",
|
|
||||||
"mpath": "0.9.0",
|
|
||||||
"mquery": "5.0.0",
|
|
||||||
"ms": "2.1.3",
|
|
||||||
"sift": "17.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=16.20.1"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"type": "opencollective",
|
|
||||||
"url": "https://opencollective.com/mongoose"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mongoose/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
|
||||||
},
|
|
||||||
"node_modules/moo": {
|
"node_modules/moo": {
|
||||||
"version": "0.5.2",
|
"version": "0.5.2",
|
||||||
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
"resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz",
|
||||||
@@ -6237,46 +6153,6 @@
|
|||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/mpath": {
|
|
||||||
"version": "0.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mpath/-/mpath-0.9.0.tgz",
|
|
||||||
"integrity": "sha512-ikJRQTk8hw5DEoFVxHG1Gn9T/xcjtdnOKIU1JTmGjZZlg9LST2mBLmcX3/ICIbgJydT2GOc15RnNy5mHmzfSew==",
|
|
||||||
"engines": {
|
|
||||||
"node": ">=4.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mquery": {
|
|
||||||
"version": "5.0.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/mquery/-/mquery-5.0.0.tgz",
|
|
||||||
"integrity": "sha512-iQMncpmEK8R8ncT8HJGsGc9Dsp8xcgYMVSbs5jgnm1lFHTZqMJTUWTDx1LBO8+mK3tPNZWFLBghQEIOULSTHZg==",
|
|
||||||
"dependencies": {
|
|
||||||
"debug": "4.x"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=14.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mquery/node_modules/debug": {
|
|
||||||
"version": "4.4.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
|
||||||
"dependencies": {
|
|
||||||
"ms": "^2.1.3"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=6.0"
|
|
||||||
},
|
|
||||||
"peerDependenciesMeta": {
|
|
||||||
"supports-color": {
|
|
||||||
"optional": true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/mquery/node_modules/ms": {
|
|
||||||
"version": "2.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
|
||||||
},
|
|
||||||
"node_modules/ms": {
|
"node_modules/ms": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
|
||||||
@@ -6673,6 +6549,98 @@
|
|||||||
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz",
|
||||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
|
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow=="
|
||||||
},
|
},
|
||||||
|
"node_modules/pg": {
|
||||||
|
"version": "8.16.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz",
|
||||||
|
"integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-connection-string": "^2.9.1",
|
||||||
|
"pg-pool": "^3.10.1",
|
||||||
|
"pg-protocol": "^1.10.3",
|
||||||
|
"pg-types": "2.2.0",
|
||||||
|
"pgpass": "1.0.5"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 16.0.0"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"pg-cloudflare": "^1.2.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg-native": ">=3.0.1"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"pg-native": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-cloudflare": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-YgCtzMH0ptvZJslLM1ffsY4EuGaU0cx4XSdXLRFae8bPP4dS5xL1tNB3k2o/N64cHJpwU7dxKli/nZ2lUa5fLg==",
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"node_modules/pg-connection-string": {
|
||||||
|
"version": "2.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.9.1.tgz",
|
||||||
|
"integrity": "sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-hstore": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-hstore/-/pg-hstore-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-N3SGs/Rf+xA1M2/n0JBiXFDVMzdekwLZLAO0g7mpDY9ouX+fDI7jS6kTq3JujmYbtNSJ53TJ0q4G98KVZSM4EA==",
|
||||||
|
"dependencies": {
|
||||||
|
"underscore": "^1.13.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.8.x"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-int8": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-pool": {
|
||||||
|
"version": "3.10.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.10.1.tgz",
|
||||||
|
"integrity": "sha512-Tu8jMlcX+9d8+QVzKIvM/uJtp07PKr82IUOYEphaWcoBhIYkoHpLXN3qO59nAI11ripznDsEzEv8nUxBVWajGg==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"pg": ">=8.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pg-protocol": {
|
||||||
|
"version": "1.10.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.10.3.tgz",
|
||||||
|
"integrity": "sha512-6DIBgBQaTKDJyxnXaLiLR8wBpQQcGWuAESkRBX/t6OwA8YsqP+iVSiond2EDy6Y/dsGk8rh/jtax3js5NeV7JQ=="
|
||||||
|
},
|
||||||
|
"node_modules/pg-types": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
|
||||||
|
"dependencies": {
|
||||||
|
"pg-int8": "1.0.1",
|
||||||
|
"postgres-array": "~2.0.0",
|
||||||
|
"postgres-bytea": "~1.0.0",
|
||||||
|
"postgres-date": "~1.0.4",
|
||||||
|
"postgres-interval": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/pgpass": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
|
||||||
|
"dependencies": {
|
||||||
|
"split2": "^4.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/picocolors": {
|
"node_modules/picocolors": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||||
@@ -6817,6 +6785,41 @@
|
|||||||
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
"integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/postgres-array": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-bytea": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-xy3pmLuQqRBZBXDULy7KbaitYqLcmxigw14Q5sj8QBVLqEwXfeybIKVWiqAXTlcvdvb0+xkOtDbfQMOf4lST1w==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-date": {
|
||||||
|
"version": "1.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
|
||||||
|
"integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/postgres-interval": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"xtend": "^4.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/pretty-bytes": {
|
"node_modules/pretty-bytes": {
|
||||||
"version": "5.6.0",
|
"version": "5.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
|
||||||
@@ -6856,6 +6859,11 @@
|
|||||||
"node": ">= 0.10"
|
"node": ">= 0.10"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/proxy-from-env": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg=="
|
||||||
|
},
|
||||||
"node_modules/psl": {
|
"node_modules/psl": {
|
||||||
"version": "1.15.0",
|
"version": "1.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz",
|
||||||
@@ -7283,6 +7291,11 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/retry-as-promised": {
|
||||||
|
"version": "7.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/retry-as-promised/-/retry-as-promised-7.1.1.tgz",
|
||||||
|
"integrity": "sha512-hMD7odLOt3LkTjcif8aRZqi/hybjpLNgSk5oF5FCowfCjok6LukpN2bDX7R5wDmbgBQFn7YoBxSagmtXHaJYJw=="
|
||||||
|
},
|
||||||
"node_modules/rollup": {
|
"node_modules/rollup": {
|
||||||
"version": "2.79.2",
|
"version": "2.79.2",
|
||||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
"resolved": "https://registry.npmjs.org/rollup/-/rollup-2.79.2.tgz",
|
||||||
@@ -7451,6 +7464,96 @@
|
|||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/sequelize": {
|
||||||
|
"version": "6.37.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/sequelize/-/sequelize-6.37.7.tgz",
|
||||||
|
"integrity": "sha512-mCnh83zuz7kQxxJirtFD7q6Huy6liPanI67BSlbzSYgVNl5eXVdE2CN1FuAeZwG1SNpGsNRCV+bJAVVnykZAFA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/sequelize"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dependencies": {
|
||||||
|
"@types/debug": "^4.1.8",
|
||||||
|
"@types/validator": "^13.7.17",
|
||||||
|
"debug": "^4.3.4",
|
||||||
|
"dottie": "^2.0.6",
|
||||||
|
"inflection": "^1.13.4",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"moment": "^2.29.4",
|
||||||
|
"moment-timezone": "^0.5.43",
|
||||||
|
"pg-connection-string": "^2.6.1",
|
||||||
|
"retry-as-promised": "^7.0.4",
|
||||||
|
"semver": "^7.5.4",
|
||||||
|
"sequelize-pool": "^7.1.0",
|
||||||
|
"toposort-class": "^1.0.1",
|
||||||
|
"uuid": "^8.3.2",
|
||||||
|
"validator": "^13.9.0",
|
||||||
|
"wkx": "^0.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"ibm_db": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mariadb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"mysql2": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"oracledb": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"pg-hstore": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"snowflake-sdk": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"sqlite3": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"tedious": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize-pool": {
|
||||||
|
"version": "7.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/sequelize-pool/-/sequelize-pool-7.1.0.tgz",
|
||||||
|
"integrity": "sha512-G9c0qlIWQSK29pR/5U2JF5dDQeqqHRragoyahj/Nx4KOOQ3CPPfzxnfqFPCSB7x5UgjOgnZ61nSxz+fjDpRlJg==",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize/node_modules/debug": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
|
"dependencies": {
|
||||||
|
"ms": "^2.1.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.0"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"supports-color": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/sequelize/node_modules/ms": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
|
||||||
|
},
|
||||||
"node_modules/serialize-javascript": {
|
"node_modules/serialize-javascript": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
|
||||||
@@ -7661,11 +7764,6 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sift": {
|
|
||||||
"version": "17.1.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/sift/-/sift-17.1.3.tgz",
|
|
||||||
"integrity": "sha512-Rtlj66/b0ICeFzYTuNvX/EF1igRbbnGSvEyT79McoZa/DeGhMyC5pWKOEsZKnpkqtSeovd5FL/bjHWC3CIIvCQ=="
|
|
||||||
},
|
|
||||||
"node_modules/simple-swizzle": {
|
"node_modules/simple-swizzle": {
|
||||||
"version": "0.2.4",
|
"version": "0.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
@@ -7834,12 +7932,12 @@
|
|||||||
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
"deprecated": "Please use @jridgewell/sourcemap-codec instead",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
"node_modules/sparse-bitfield": {
|
"node_modules/split2": {
|
||||||
"version": "3.0.3",
|
"version": "4.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
|
||||||
"integrity": "sha512-kvzhi7vqKTfkh0PZU+2D2PIllw2ymqJKujUcyPMd9Y75Nv4nPbGJZXNhxsgdQab2BmlDct1YnfQCguEvHr7VsQ==",
|
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
|
||||||
"dependencies": {
|
"engines": {
|
||||||
"memory-pager": "^1.0.2"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/sshpk": {
|
"node_modules/sshpk": {
|
||||||
@@ -8257,6 +8355,11 @@
|
|||||||
"node": ">=0.6"
|
"node": ">=0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/toposort-class": {
|
||||||
|
"version": "1.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/toposort-class/-/toposort-class-1.0.1.tgz",
|
||||||
|
"integrity": "sha512-OsLcGGbYF3rMjPUf8oKktyvCiUxSbqMMS39m33MAjLTC1DVIH6x3WSt63/M77ihI09+Sdfk1AXvfhCEeUmC7mg=="
|
||||||
|
},
|
||||||
"node_modules/touch": {
|
"node_modules/touch": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/touch/-/touch-3.1.1.tgz",
|
||||||
@@ -8278,14 +8381,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tr46": {
|
"node_modules/tr46": {
|
||||||
"version": "5.1.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-5.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
||||||
"integrity": "sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==",
|
"integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"punycode": "^2.3.1"
|
"punycode": "^2.1.0"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/tslib": {
|
"node_modules/tslib": {
|
||||||
@@ -8443,6 +8544,11 @@
|
|||||||
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
|
"integrity": "sha512-WxONCrssBM8TSPRqN5EmsjVrsv4A8X12J4ArBiiayv3DyyG3ZlIg6yysuuSYdZsVz3TKcTg2fd//Ujd4CHV1iA==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/underscore": {
|
||||||
|
"version": "1.13.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/underscore/-/underscore-1.13.7.tgz",
|
||||||
|
"integrity": "sha512-GMXzWtsc57XAtguZgaQViUOzs0KTkk8ojr3/xAxXLITqf/3EMwxC0inyETfDFjH/Krbhuep0HNbbjI9i/q3F3g=="
|
||||||
|
},
|
||||||
"node_modules/undici-types": {
|
"node_modules/undici-types": {
|
||||||
"version": "7.14.0",
|
"version": "7.14.0",
|
||||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz",
|
||||||
@@ -8649,12 +8755,10 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/webidl-conversions": {
|
"node_modules/webidl-conversions": {
|
||||||
"version": "7.0.0",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
||||||
"integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==",
|
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
|
||||||
"engines": {
|
"dev": true
|
||||||
"node": ">=12"
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
"node_modules/webpack": {
|
"node_modules/webpack": {
|
||||||
"version": "5.102.1",
|
"version": "5.102.1",
|
||||||
@@ -8835,15 +8939,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/whatwg-url": {
|
"node_modules/whatwg-url": {
|
||||||
"version": "14.2.0",
|
"version": "7.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-14.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
||||||
"integrity": "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==",
|
"integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
|
||||||
|
"dev": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"tr46": "^5.1.0",
|
"lodash.sortby": "^4.7.0",
|
||||||
"webidl-conversions": "^7.0.0"
|
"tr46": "^1.0.1",
|
||||||
},
|
"webidl-conversions": "^4.0.2"
|
||||||
"engines": {
|
|
||||||
"node": ">=18"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/which": {
|
"node_modules/which": {
|
||||||
@@ -8953,6 +9056,14 @@
|
|||||||
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
"integrity": "sha512-CC1bOL87PIWSBhDcTrdeLo6eGT7mCFtrg0uIJtqJUFyK+eJnzl8A1niH56uu7KMa5XFrtiV+AQuHO3n7DsHnLQ==",
|
||||||
"dev": true
|
"dev": true
|
||||||
},
|
},
|
||||||
|
"node_modules/wkx": {
|
||||||
|
"version": "0.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/wkx/-/wkx-0.5.0.tgz",
|
||||||
|
"integrity": "sha512-Xng/d4Ichh8uN4l0FToV/258EjMGU9MGcA0HV2d9B/ZpZB3lqQm7nkOdZdm5GhKtLLhAE7PiVQwN4eN+2YJJUg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/workbox-background-sync": {
|
"node_modules/workbox-background-sync": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-background-sync/-/workbox-background-sync-7.3.0.tgz",
|
||||||
@@ -9072,32 +9183,6 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/workbox-build/node_modules/tr46": {
|
|
||||||
"version": "1.0.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz",
|
|
||||||
"integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"punycode": "^2.1.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/webidl-conversions": {
|
|
||||||
"version": "4.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz",
|
|
||||||
"integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==",
|
|
||||||
"dev": true
|
|
||||||
},
|
|
||||||
"node_modules/workbox-build/node_modules/whatwg-url": {
|
|
||||||
"version": "7.1.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-7.1.0.tgz",
|
|
||||||
"integrity": "sha512-WUu7Rg1DroM7oQvGWfOiAK21n74Gg+T4elXEQYkOhtyLeWiJFoOGLXPKI/9gzIie9CtwVLm8wtw6YJdKyxSjeg==",
|
|
||||||
"dev": true,
|
|
||||||
"dependencies": {
|
|
||||||
"lodash.sortby": "^4.7.0",
|
|
||||||
"tr46": "^1.0.1",
|
|
||||||
"webidl-conversions": "^4.0.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/workbox-cacheable-response": {
|
"node_modules/workbox-cacheable-response": {
|
||||||
"version": "7.3.0",
|
"version": "7.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/workbox-cacheable-response/-/workbox-cacheable-response-7.3.0.tgz",
|
||||||
|
|||||||
13
package.json
@@ -9,7 +9,8 @@
|
|||||||
"dev": "node scripts/dev.js",
|
"dev": "node scripts/dev.js",
|
||||||
"build": "node scripts/build.js",
|
"build": "node scripts/build.js",
|
||||||
"init-db": "node scripts/init-db.js",
|
"init-db": "node scripts/init-db.js",
|
||||||
"test": "echo \"Error: no test specified\" && exit 1"
|
"test": "echo \"Error: no test specified\" && exit 1",
|
||||||
|
"sync-locales": "node scripts/sync-locales.js"
|
||||||
},
|
},
|
||||||
"keywords": [
|
"keywords": [
|
||||||
"pwa",
|
"pwa",
|
||||||
@@ -22,26 +23,30 @@
|
|||||||
"author": "SmartSolTech",
|
"author": "SmartSolTech",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"axios": "^1.12.2",
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
"compression": "^1.7.4",
|
"compression": "^1.7.4",
|
||||||
"connect-flash": "^0.1.1",
|
"connect-flash": "^0.1.1",
|
||||||
"connect-mongo": "^5.1.0",
|
"connect-session-sequelize": "^8.0.2",
|
||||||
"cookie-parser": "^1.4.7",
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^16.3.1",
|
"dotenv": "^16.3.1",
|
||||||
"express": "^4.18.2",
|
"express": "^4.18.2",
|
||||||
|
"express-ejs-layouts": "^2.5.1",
|
||||||
"express-rate-limit": "^7.1.5",
|
"express-rate-limit": "^7.1.5",
|
||||||
"express-session": "^1.17.3",
|
"express-session": "^1.17.3",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
"helmet": "^7.1.0",
|
"helmet": "^7.1.0",
|
||||||
"i18n": "^0.15.2",
|
"i18n": "^0.15.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"mongoose": "^8.0.3",
|
|
||||||
"morgan": "^1.10.0",
|
"morgan": "^1.10.0",
|
||||||
"multer": "^1.4.5-lts.1",
|
"multer": "^1.4.5-lts.1",
|
||||||
"node-telegram-bot-api": "^0.64.0",
|
"node-telegram-bot-api": "^0.64.0",
|
||||||
"nodemailer": "^6.9.7",
|
"nodemailer": "^6.9.7",
|
||||||
"sharp": "^0.33.0",
|
"pg": "^8.16.3",
|
||||||
|
"pg-hstore": "^2.3.4",
|
||||||
|
"sequelize": "^6.37.7",
|
||||||
|
"sharp": "^0.33.5",
|
||||||
"socket.io": "^4.7.4"
|
"socket.io": "^4.7.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
686
public/css/base.css
Normal file
@@ -0,0 +1,686 @@
|
|||||||
|
/* SmartSolTech - Base Styles for Elements */
|
||||||
|
|
||||||
|
/* Ensure proper styling without Tailwind conflicts */
|
||||||
|
.navbar {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
background: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
backdrop-filter: blur(10px) !important;
|
||||||
|
border-bottom: 1px solid #e5e7eb !important;
|
||||||
|
padding: 1rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
color: #3B82F6 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 2rem !important;
|
||||||
|
list-style: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-link:hover,
|
||||||
|
.nav-link.active {
|
||||||
|
color: #3B82F6 !important;
|
||||||
|
background-color: #eff6ff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Base CSS - Исправленные стили для главной страницы */
|
||||||
|
|
||||||
|
/* Reset и базовые стили */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
font-size: 16px !important;
|
||||||
|
scroll-behavior: smooth !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif !important;
|
||||||
|
line-height: 1.6 !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
background-color: #ffffff !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
padding-top: 80px !important; /* Отступ для навигации */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Навигация - исправленная */
|
||||||
|
nav, .navigation {
|
||||||
|
position: fixed !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
z-index: 1000 !important;
|
||||||
|
background: rgba(255, 255, 255, 0.95) !important;
|
||||||
|
-webkit-backdrop-filter: blur(20px) !important;
|
||||||
|
backdrop-filter: blur(20px) !important;
|
||||||
|
border-bottom: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||||
|
height: 80px !important;
|
||||||
|
box-shadow: 0 2px 20px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar, .nav-container {
|
||||||
|
display: flex !important;
|
||||||
|
justify-content: space-between !important;
|
||||||
|
align-items: center !important;
|
||||||
|
padding: 0 2rem !important;
|
||||||
|
max-width: 1200px !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
height: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo, .brand {
|
||||||
|
font-size: 1.75rem !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
color: #1d4ed8 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
letter-spacing: -0.025em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links, .menu {
|
||||||
|
display: flex !important;
|
||||||
|
list-style: none !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
gap: 2.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links li, .menu li {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a, .menu a {
|
||||||
|
color: #374151 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
font-size: 0.95rem !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
padding: 0.75rem 0 !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover, .menu a:hover {
|
||||||
|
color: #1d4ed8 !important;
|
||||||
|
transform: translateY(-1px) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a::after, .menu a::after {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
bottom: 0.5rem !important;
|
||||||
|
left: 0 !important;
|
||||||
|
width: 0 !important;
|
||||||
|
height: 2px !important;
|
||||||
|
background: linear-gradient(45deg, #1d4ed8, #8b5cf6) !important;
|
||||||
|
transition: width 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-links a:hover::after, .menu a:hover::after {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero секция - исправленная */
|
||||||
|
.hero-section {
|
||||||
|
min-height: 100vh !important;
|
||||||
|
background: linear-gradient(135deg, #0f172a 0%, #1e1b4b 25%, #312e81 50%, #7c3aed 75%, #3730a3 100%) !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
text-align: center !important;
|
||||||
|
color: white !important;
|
||||||
|
position: relative !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
margin-top: -80px !important;
|
||||||
|
padding-top: 80px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section::before {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
bottom: 0 !important;
|
||||||
|
background: radial-gradient(circle at 30% 40%, rgba(59, 130, 246, 0.3) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 80% 20%, rgba(139, 92, 246, 0.3) 0%, transparent 50%),
|
||||||
|
radial-gradient(circle at 40% 80%, rgba(16, 185, 129, 0.2) 0%, transparent 50%) !important;
|
||||||
|
z-index: 1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section > div {
|
||||||
|
position: relative !important;
|
||||||
|
z-index: 2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section h1 {
|
||||||
|
font-size: 4rem !important;
|
||||||
|
font-weight: 800 !important;
|
||||||
|
margin-bottom: 1.5rem !important;
|
||||||
|
line-height: 1.1 !important;
|
||||||
|
letter-spacing: -0.025em !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section p {
|
||||||
|
font-size: 1.35rem !important;
|
||||||
|
margin-bottom: 2.5rem !important;
|
||||||
|
opacity: 0.9 !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
margin-left: auto !important;
|
||||||
|
margin-right: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Кнопки - улучшенные */
|
||||||
|
.btn-primary, .btn {
|
||||||
|
display: inline-flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
padding: 1.25rem 2.5rem !important;
|
||||||
|
background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%) !important;
|
||||||
|
color: white !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
border-radius: 50px !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
border: none !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
box-shadow: 0 8px 25px rgba(59, 130, 246, 0.3) !important;
|
||||||
|
position: relative !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover, .btn:hover {
|
||||||
|
transform: translateY(-3px) scale(1.05) !important;
|
||||||
|
box-shadow: 0 15px 35px rgba(59, 130, 246, 0.4) !important;
|
||||||
|
background: linear-gradient(135deg, #2563eb 0%, #7c3aed 100%) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary::before, .btn::before {
|
||||||
|
content: '' !important;
|
||||||
|
position: absolute !important;
|
||||||
|
top: 0 !important;
|
||||||
|
left: -100% !important;
|
||||||
|
width: 100% !important;
|
||||||
|
height: 100% !important;
|
||||||
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent) !important;
|
||||||
|
transition: left 0.6s !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover::before, .btn:hover::before {
|
||||||
|
left: 100% !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Карточки - улучшенные */
|
||||||
|
.card-hover, .card {
|
||||||
|
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1) !important;
|
||||||
|
border-radius: 1.5rem !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
background: white !important;
|
||||||
|
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
border: 1px solid rgba(0, 0, 0, 0.05) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-hover:hover, .card:hover {
|
||||||
|
transform: translateY(-12px) scale(1.02) !important;
|
||||||
|
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.15) !important;
|
||||||
|
border-color: rgba(59, 130, 246, 0.2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Секции */
|
||||||
|
section {
|
||||||
|
padding: 6rem 1rem !important;
|
||||||
|
position: relative !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container, .max-w-7xl {
|
||||||
|
max-width: 1200px !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
padding-left: 1rem !important;
|
||||||
|
padding-right: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Заголовки */
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
color: #1f2937 !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 2.75rem !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
margin-bottom: 0.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Параграфы и текст */
|
||||||
|
p {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
line-height: 1.7 !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Утилитарные классы Tailwind - принудительные */
|
||||||
|
.text-center {
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-white {
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-white {
|
||||||
|
background-color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-gray-50 {
|
||||||
|
background-color: #f9fafb !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-lg {
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rounded-2xl {
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.shadow-lg {
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-4 {
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mb-8 {
|
||||||
|
margin-bottom: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.p-8 {
|
||||||
|
padding: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flex {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid {
|
||||||
|
display: grid !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.items-center {
|
||||||
|
align-items: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.justify-center {
|
||||||
|
justify-content: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Грид системы */
|
||||||
|
.grid-cols-1 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-2 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-3 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-4 {
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.gap-8 {
|
||||||
|
gap: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Отзывчивость - улучшенная */
|
||||||
|
@media (max-width: 1024px) {
|
||||||
|
.grid-cols-4 {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section h1 {
|
||||||
|
font-size: 3.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.nav-links, .menu {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
padding-top: 60px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
nav, .navigation {
|
||||||
|
height: 60px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
margin-top: -60px !important;
|
||||||
|
padding-top: 60px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
section {
|
||||||
|
padding: 4rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section h1 {
|
||||||
|
font-size: 2.75rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section p {
|
||||||
|
font-size: 1.1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-cols-2, .grid-cols-3, .grid-cols-4 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary, .btn {
|
||||||
|
padding: 1rem 2rem !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-title {
|
||||||
|
font-size: 3.5rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
line-height: 1.2 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-subtitle {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
margin-bottom: 2rem !important;
|
||||||
|
color: rgba(255, 255, 255, 0.9) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Buttons */
|
||||||
|
.btn {
|
||||||
|
display: inline-block !important;
|
||||||
|
padding: 0.75rem 1.5rem !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
border: none !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary {
|
||||||
|
background: linear-gradient(135deg, #3B82F6, #1D4ED8) !important;
|
||||||
|
color: white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-primary:hover {
|
||||||
|
transform: translateY(-2px) !important;
|
||||||
|
box-shadow: 0 10px 25px rgba(59, 130, 246, 0.3) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary {
|
||||||
|
background: transparent !important;
|
||||||
|
color: white !important;
|
||||||
|
border: 2px solid white !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-secondary:hover {
|
||||||
|
background: white !important;
|
||||||
|
color: #3B82F6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Cards */
|
||||||
|
.card {
|
||||||
|
background: white !important;
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
padding: 2rem !important;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-4px) !important;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sections */
|
||||||
|
.section {
|
||||||
|
padding: 4rem 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 2.5rem !important;
|
||||||
|
font-weight: 700 !important;
|
||||||
|
text-align: center !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
font-size: 1.125rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
max-width: 600px !important;
|
||||||
|
margin: 0 auto 3rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Container */
|
||||||
|
.container {
|
||||||
|
max-width: 1200px !important;
|
||||||
|
margin: 0 auto !important;
|
||||||
|
padding: 0 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Grid Layouts */
|
||||||
|
.grid-2 {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)) !important;
|
||||||
|
gap: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-3 {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)) !important;
|
||||||
|
gap: 2rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-4 {
|
||||||
|
display: grid !important;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)) !important;
|
||||||
|
gap: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Language Selector */
|
||||||
|
.language-selector {
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
gap: 0.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-selector a {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
text-decoration: none !important;
|
||||||
|
padding: 0.25rem 0.5rem !important;
|
||||||
|
border-radius: 0.25rem !important;
|
||||||
|
font-size: 0.875rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-selector a:hover {
|
||||||
|
color: #3B82F6 !important;
|
||||||
|
background-color: #f3f4f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Menu */
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: none !important;
|
||||||
|
background: none !important;
|
||||||
|
border: none !important;
|
||||||
|
font-size: 1.5rem !important;
|
||||||
|
color: #6b7280 !important;
|
||||||
|
cursor: pointer !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Mobile Styles */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-title {
|
||||||
|
font-size: 2.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mobile-menu-button {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav {
|
||||||
|
display: none !important;
|
||||||
|
position: absolute !important;
|
||||||
|
top: 100% !important;
|
||||||
|
left: 0 !important;
|
||||||
|
right: 0 !important;
|
||||||
|
background: white !important;
|
||||||
|
flex-direction: column !important;
|
||||||
|
padding: 1rem !important;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav.show {
|
||||||
|
display: flex !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section {
|
||||||
|
padding: 2rem 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 2rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: #1f2937 !important;
|
||||||
|
color: white !important;
|
||||||
|
padding: 3rem 2rem 1rem !important;
|
||||||
|
text-align: center !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.footer p {
|
||||||
|
color: #d1d5db !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Service Icons */
|
||||||
|
.service-icon {
|
||||||
|
width: 80px !important;
|
||||||
|
height: 80px !important;
|
||||||
|
background: linear-gradient(135deg, #3B82F6, #8B5CF6) !important;
|
||||||
|
border-radius: 50% !important;
|
||||||
|
display: flex !important;
|
||||||
|
align-items: center !important;
|
||||||
|
justify-content: center !important;
|
||||||
|
font-size: 2rem !important;
|
||||||
|
color: white !important;
|
||||||
|
margin: 0 auto 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Portfolio Items */
|
||||||
|
.portfolio-item {
|
||||||
|
background: white !important;
|
||||||
|
border-radius: 1rem !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05) !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-item:hover {
|
||||||
|
transform: translateY(-4px) !important;
|
||||||
|
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-image {
|
||||||
|
width: 100% !important;
|
||||||
|
height: 200px !important;
|
||||||
|
object-fit: cover !important;
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-content {
|
||||||
|
padding: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-title {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
color: #1f2937 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.portfolio-description {
|
||||||
|
color: #6b7280 !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form Styles */
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.5rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
display: block !important;
|
||||||
|
font-weight: 500 !important;
|
||||||
|
margin-bottom: 0.5rem !important;
|
||||||
|
color: #374151 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input,
|
||||||
|
.form-textarea,
|
||||||
|
.form-select {
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0.75rem !important;
|
||||||
|
border: 2px solid #e5e7eb !important;
|
||||||
|
border-radius: 0.5rem !important;
|
||||||
|
font-size: 1rem !important;
|
||||||
|
transition: all 0.3s ease !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-input:focus,
|
||||||
|
.form-textarea:focus,
|
||||||
|
.form-select:focus {
|
||||||
|
outline: none !important;
|
||||||
|
border-color: #3B82F6 !important;
|
||||||
|
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1) !important;
|
||||||
|
}
|
||||||
@@ -297,8 +297,8 @@ html.dark {
|
|||||||
/* High contrast mode */
|
/* High contrast mode */
|
||||||
@media (prefers-contrast: high) {
|
@media (prefers-contrast: high) {
|
||||||
.dark {
|
.dark {
|
||||||
--tw-bg-opacity: 1;
|
color: white !important;
|
||||||
--tw-text-opacity: 1;
|
background-color: black !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dark .border {
|
.dark .border {
|
||||||
|
|||||||
@@ -272,6 +272,35 @@ html {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* iOS Style Theme Toggle */
|
||||||
|
.theme-toggle-slider {
|
||||||
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Theme toggle background states */
|
||||||
|
input#theme-toggle:checked + label > div {
|
||||||
|
background-color: #4F46E5 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
input#theme-toggle + label > div {
|
||||||
|
background-color: #D1D5DB;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode adjustments for toggle */
|
||||||
|
.dark input#theme-toggle + label > div {
|
||||||
|
background-color: #374151;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark input#theme-toggle:checked + label > div {
|
||||||
|
background-color: #6366F1 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for icons */
|
||||||
|
.theme-sun-icon,
|
||||||
|
.theme-moon-icon {
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
/* Dark mode support (if needed) */
|
/* Dark mode support (if needed) */
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
.auto-dark {
|
.auto-dark {
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
/* SmartSolTech - Main Styles */
|
/* SmartSolTech - Main Styles */
|
||||||
|
|
||||||
|
/* Tailwind Base (if not loading properly) */
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap');
|
||||||
|
|
||||||
/* CSS Reset and Base */
|
/* CSS Reset and Base */
|
||||||
* {
|
* {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
@@ -7,10 +10,26 @@
|
|||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-behavior: smooth;
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
color: #1f2937;
|
color: #1f2937;
|
||||||
|
background-color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Root Variables */
|
||||||
|
:root {
|
||||||
|
--primary-color: #3B82F6;
|
||||||
|
--secondary-color: #8B5CF6;
|
||||||
|
--accent-color: #10B981;
|
||||||
|
--text-dark: #1f2937;
|
||||||
|
--text-light: #6b7280;
|
||||||
|
--bg-light: #f9fafb;
|
||||||
|
--border-color: #e5e7eb;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Utility Classes */
|
/* Utility Classes */
|
||||||
@@ -65,6 +84,8 @@ body {
|
|||||||
color: #3b82f6;
|
color: #3b82f6;
|
||||||
background-color: #eff6ff;
|
background-color: #eff6ff;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.mobile-menu {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +568,39 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#final-price {
|
#final-price {
|
||||||
font-size: 2.5rem;
|
font-size: 2rem;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hero секции - компактные для внутренних страниц */
|
||||||
|
.hero-section-compact {
|
||||||
|
min-height: 40vh !important;
|
||||||
|
max-height: 50vh !important;
|
||||||
|
padding: 4rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section-compact h1 {
|
||||||
|
font-size: 3rem !important;
|
||||||
|
margin-bottom: 1rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section-compact p {
|
||||||
|
font-size: 1.125rem !important;
|
||||||
|
opacity: 0.9 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-section-compact {
|
||||||
|
min-height: 30vh !important;
|
||||||
|
padding: 3rem 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section-compact h1 {
|
||||||
|
font-size: 2.5rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Полноэкранный Hero только для главной */
|
||||||
|
.hero-section {
|
||||||
|
min-height: 100vh !important;
|
||||||
}
|
}
|
||||||
BIN
public/favicon.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
public/images/icon-144x144.png
Normal file
|
After Width: | Height: | Size: 692 B |
BIN
public/images/icon-192x192.png
Normal file
|
After Width: | Height: | Size: 834 B |
4
public/images/icons/icon-192x192.png
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="192" height="192" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="192" height="192" fill="#3b82f6" rx="32"/>
|
||||||
|
<text x="96" y="110" text-anchor="middle" font-family="Arial" font-size="48" font-weight="bold" fill="white">ST</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 251 B |
4
public/images/logo.png
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="40" height="40" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="40" height="40" fill="#3b82f6" rx="8"/>
|
||||||
|
<text x="20" y="26" text-anchor="middle" font-family="Arial" font-size="14" font-weight="bold" fill="white">ST</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 245 B |
4
public/images/portfolio/corporate-1.jpg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="400" height="300" fill="#f3e8ff"/>
|
||||||
|
<text x="200" y="150" text-anchor="middle" font-family="Arial" font-size="16" fill="#7c3aed">Corporate Website</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 242 B |
4
public/images/portfolio/ecommerce-1.jpg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="400" height="300" fill="#f3f4f6"/>
|
||||||
|
<text x="200" y="150" text-anchor="middle" font-family="Arial" font-size="16" fill="#6b7280">E-commerce Project</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 243 B |
4
public/images/portfolio/fitness-1.jpg
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
<svg width="400" height="300" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<rect width="400" height="300" fill="#e0f2fe"/>
|
||||||
|
<text x="200" y="150" text-anchor="middle" font-family="Arial" font-size="16" fill="#0369a1">Fitness App Project</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 244 B |
47
public/sw.js
@@ -1,21 +1,24 @@
|
|||||||
// Service Worker for SmartSolTech PWA
|
// Service Worker for SmartSolTech PWA
|
||||||
const CACHE_NAME = 'smartsoltech-v1.0.0';
|
const CACHE_NAME = 'smartsoltech-v1.0.1';
|
||||||
const STATIC_CACHE_NAME = 'smartsoltech-static-v1.0.0';
|
const STATIC_CACHE_NAME = 'smartsoltech-static-v1.0.1';
|
||||||
const DYNAMIC_CACHE_NAME = 'smartsoltech-dynamic-v1.0.0';
|
const DYNAMIC_CACHE_NAME = 'smartsoltech-dynamic-v1.0.1';
|
||||||
|
|
||||||
// Files to cache immediately
|
// Files to cache immediately
|
||||||
const STATIC_FILES = [
|
const STATIC_FILES = [
|
||||||
'/',
|
'/',
|
||||||
'/css/main.css',
|
'/css/main.css',
|
||||||
|
'/css/fixes.css',
|
||||||
|
'/css/dark-theme.css',
|
||||||
'/js/main.js',
|
'/js/main.js',
|
||||||
'/images/logo.png',
|
'/images/logo.png',
|
||||||
'/images/icon-192x192.png',
|
'/images/icon-192x192.png',
|
||||||
'/images/icon-512x512.png',
|
'/images/icon-144x144.png',
|
||||||
'/manifest.json',
|
'/manifest.json',
|
||||||
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
|
'https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap',
|
||||||
'https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css',
|
'https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css',
|
||||||
'https://cdnjs.cloudflare.com/ajax/libs/aos/2.3.4/aos.css',
|
'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css',
|
||||||
'https://cdnjs.cloudflare.com/ajax/libs/aos/2.3.4/aos.js'
|
'https://unpkg.com/aos@2.3.1/dist/aos.css',
|
||||||
|
'https://unpkg.com/aos@2.3.1/dist/aos.js'
|
||||||
];
|
];
|
||||||
|
|
||||||
// Routes to cache dynamically
|
// Routes to cache dynamically
|
||||||
@@ -155,17 +158,25 @@ async function networkFirst(request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function staleWhileRevalidate(request) {
|
async function staleWhileRevalidate(request) {
|
||||||
const cache = await caches.open(DYNAMIC_CACHE_NAME);
|
try {
|
||||||
const cachedResponse = await cache.match(request);
|
const cache = await caches.open(DYNAMIC_CACHE_NAME);
|
||||||
|
const cachedResponse = await cache.match(request);
|
||||||
const fetchPromise = fetch(request).then(networkResponse => {
|
|
||||||
if (networkResponse.ok) {
|
const fetchPromise = fetch(request).then(networkResponse => {
|
||||||
cache.put(request, networkResponse.clone());
|
if (networkResponse && networkResponse.ok) {
|
||||||
}
|
cache.put(request, networkResponse.clone());
|
||||||
return networkResponse;
|
}
|
||||||
});
|
return networkResponse;
|
||||||
|
}).catch(error => {
|
||||||
return cachedResponse || fetchPromise;
|
console.log('staleWhileRevalidate fetch failed:', error);
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
return cachedResponse || fetchPromise || new Response('Not available', { status: 503 });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('staleWhileRevalidate error:', error);
|
||||||
|
return new Response('Service unavailable', { status: 503 });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper functions
|
// Helper functions
|
||||||
|
|||||||
1159
routes/admin.js
388
routes/api/admin.js
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const multer = require('multer');
|
||||||
|
const path = require('path');
|
||||||
|
const { Portfolio, Service, Contact, User } = require('../../models');
|
||||||
|
|
||||||
|
// Multer configuration for file uploads
|
||||||
|
const storage = multer.diskStorage({
|
||||||
|
destination: (req, file, cb) => {
|
||||||
|
cb(null, 'public/uploads/');
|
||||||
|
},
|
||||||
|
filename: (req, file, cb) => {
|
||||||
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
||||||
|
cb(null, file.fieldname + '-' + uniqueSuffix + path.extname(file.originalname));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const upload = multer({
|
||||||
|
storage: storage,
|
||||||
|
fileFilter: (req, file, cb) => {
|
||||||
|
if (file.mimetype.startsWith('image/')) {
|
||||||
|
cb(null, true);
|
||||||
|
} else {
|
||||||
|
cb(new Error('Only image files are allowed!'), false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
limits: {
|
||||||
|
fileSize: 10 * 1024 * 1024 // 10MB
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Authentication middleware
|
||||||
|
const requireAuth = (req, res, next) => {
|
||||||
|
if (!req.session.user) {
|
||||||
|
return res.status(401).json({ success: false, message: 'Authentication required' });
|
||||||
|
}
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Portfolio API Routes
|
||||||
|
router.post('/portfolio', requireAuth, upload.array('images', 10), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
clientName,
|
||||||
|
projectUrl,
|
||||||
|
githubUrl,
|
||||||
|
technologies,
|
||||||
|
featured,
|
||||||
|
isPublished
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Process uploaded images
|
||||||
|
const images = req.files ? req.files.map((file, index) => ({
|
||||||
|
url: `/uploads/${file.filename}`,
|
||||||
|
alt: `${title} image ${index + 1}`,
|
||||||
|
isPrimary: index === 0
|
||||||
|
})) : [];
|
||||||
|
|
||||||
|
// Parse technologies
|
||||||
|
let techArray = [];
|
||||||
|
if (technologies) {
|
||||||
|
try {
|
||||||
|
techArray = JSON.parse(technologies);
|
||||||
|
} catch (e) {
|
||||||
|
techArray = technologies.split(',').map(t => t.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const portfolio = await Portfolio.create({
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
description,
|
||||||
|
category,
|
||||||
|
clientName,
|
||||||
|
projectUrl: projectUrl || null,
|
||||||
|
githubUrl: githubUrl || null,
|
||||||
|
technologies: techArray,
|
||||||
|
images,
|
||||||
|
featured: featured === 'on',
|
||||||
|
isPublished: isPublished === 'on',
|
||||||
|
status: 'completed',
|
||||||
|
publishedAt: isPublished === 'on' ? new Date() : null
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, portfolio });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Portfolio creation error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.patch('/portfolio/:id', requireAuth, upload.array('images', 10), async (req, res) => {
|
||||||
|
try {
|
||||||
|
const portfolio = await Portfolio.findByPk(req.params.id);
|
||||||
|
if (!portfolio) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Portfolio not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = { ...req.body };
|
||||||
|
|
||||||
|
// Handle checkboxes
|
||||||
|
updates.featured = updates.featured === 'on';
|
||||||
|
updates.isPublished = updates.isPublished === 'on';
|
||||||
|
|
||||||
|
// Process technologies
|
||||||
|
if (updates.technologies) {
|
||||||
|
try {
|
||||||
|
updates.technologies = JSON.parse(updates.technologies);
|
||||||
|
} catch (e) {
|
||||||
|
updates.technologies = updates.technologies.split(',').map(t => t.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process new images
|
||||||
|
if (req.files && req.files.length > 0) {
|
||||||
|
const newImages = req.files.map((file, index) => ({
|
||||||
|
url: `/uploads/${file.filename}`,
|
||||||
|
alt: `${updates.title || portfolio.title} image ${index + 1}`,
|
||||||
|
isPrimary: index === 0 && (!portfolio.images || portfolio.images.length === 0)
|
||||||
|
}));
|
||||||
|
|
||||||
|
updates.images = [...(portfolio.images || []), ...newImages];
|
||||||
|
}
|
||||||
|
|
||||||
|
await portfolio.update(updates);
|
||||||
|
res.json({ success: true, portfolio });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Portfolio update error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/portfolio/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const portfolio = await Portfolio.findByPk(req.params.id);
|
||||||
|
if (!portfolio) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Portfolio not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await portfolio.destroy();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Portfolio deletion error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Services API Routes
|
||||||
|
router.post('/services', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
shortDescription,
|
||||||
|
icon,
|
||||||
|
category,
|
||||||
|
features,
|
||||||
|
pricing,
|
||||||
|
estimatedTime,
|
||||||
|
isActive,
|
||||||
|
featured,
|
||||||
|
tags
|
||||||
|
} = req.body;
|
||||||
|
|
||||||
|
// Parse arrays
|
||||||
|
let featuresArray = [];
|
||||||
|
let tagsArray = [];
|
||||||
|
let pricingObj = {};
|
||||||
|
|
||||||
|
if (features) {
|
||||||
|
try {
|
||||||
|
featuresArray = JSON.parse(features);
|
||||||
|
} catch (e) {
|
||||||
|
featuresArray = features.split(',').map(f => f.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tags) {
|
||||||
|
try {
|
||||||
|
tagsArray = JSON.parse(tags);
|
||||||
|
} catch (e) {
|
||||||
|
tagsArray = tags.split(',').map(t => t.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pricing) {
|
||||||
|
try {
|
||||||
|
pricingObj = JSON.parse(pricing);
|
||||||
|
} catch (e) {
|
||||||
|
pricingObj = { basePrice: pricing };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await Service.create({
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
shortDescription,
|
||||||
|
icon,
|
||||||
|
category,
|
||||||
|
features: featuresArray,
|
||||||
|
pricing: pricingObj,
|
||||||
|
estimatedTime,
|
||||||
|
isActive: isActive === 'on',
|
||||||
|
featured: featured === 'on',
|
||||||
|
tags: tagsArray
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({ success: true, service });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service creation error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/services/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const service = await Service.findByPk(req.params.id);
|
||||||
|
if (!service) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Service not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await service.destroy();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Service deletion error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Contacts API Routes
|
||||||
|
router.patch('/contacts/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contact = await Contact.findByPk(req.params.id);
|
||||||
|
if (!contact) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Contact not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await contact.update(req.body);
|
||||||
|
res.json({ success: true, contact });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Contact update error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.delete('/contacts/:id', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contact = await Contact.findByPk(req.params.id);
|
||||||
|
if (!contact) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Contact not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
await contact.destroy();
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Contact deletion error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Telegram notification for contact
|
||||||
|
router.post('/contacts/:id/telegram', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const contact = await Contact.findByPk(req.params.id);
|
||||||
|
if (!contact) {
|
||||||
|
return res.status(404).json({ success: false, message: 'Contact not found' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send Telegram notification
|
||||||
|
const telegramService = require('../../services/telegram');
|
||||||
|
const result = await telegramService.sendContactNotification(contact);
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
res.json({ success: true });
|
||||||
|
} else {
|
||||||
|
res.status(500).json({ success: false, message: result.message || result.error });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Telegram notification error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Telegram connection
|
||||||
|
router.post('/telegram/test', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { botToken, chatId } = req.body;
|
||||||
|
|
||||||
|
// Temporarily set up telegram service with provided credentials
|
||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
// Test bot info
|
||||||
|
const botResponse = await axios.get(`https://api.telegram.org/bot${botToken}/getMe`);
|
||||||
|
|
||||||
|
// Test sending a message
|
||||||
|
const testMessage = '✅ Telegram bot подключен успешно!\n\nЭто тестовое сообщение от SmartSolTech Admin Panel.';
|
||||||
|
await axios.post(`https://api.telegram.org/bot${botToken}/sendMessage`, {
|
||||||
|
chat_id: chatId,
|
||||||
|
text: testMessage,
|
||||||
|
parse_mode: 'Markdown'
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
bot: botResponse.data.result,
|
||||||
|
message: 'Test message sent successfully'
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Telegram test error:', error);
|
||||||
|
let message = 'Connection failed';
|
||||||
|
|
||||||
|
if (error.response?.data?.description) {
|
||||||
|
message = error.response.data.description;
|
||||||
|
} else if (error.message) {
|
||||||
|
message = error.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(400).json({ success: false, message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Settings API
|
||||||
|
const { SiteSettings } = require('../../models');
|
||||||
|
|
||||||
|
router.get('/settings', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const settings = await SiteSettings.findOne() || {};
|
||||||
|
res.json({ success: true, settings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Settings fetch error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
router.post('/settings', requireAuth, upload.fields([
|
||||||
|
{ name: 'logo', maxCount: 1 },
|
||||||
|
{ name: 'favicon', maxCount: 1 }
|
||||||
|
]), async (req, res) => {
|
||||||
|
try {
|
||||||
|
let settings = await SiteSettings.findOne();
|
||||||
|
if (!settings) {
|
||||||
|
settings = await SiteSettings.create({});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updates = {};
|
||||||
|
|
||||||
|
// Handle nested objects
|
||||||
|
Object.keys(req.body).forEach(key => {
|
||||||
|
if (key.includes('.')) {
|
||||||
|
const [parent, child] = key.split('.');
|
||||||
|
if (!updates[parent]) updates[parent] = {};
|
||||||
|
updates[parent][child] = req.body[key];
|
||||||
|
} else {
|
||||||
|
updates[key] = req.body[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Handle file uploads
|
||||||
|
if (req.files.logo) {
|
||||||
|
updates.logo = `/uploads/${req.files.logo[0].filename}`;
|
||||||
|
}
|
||||||
|
if (req.files.favicon) {
|
||||||
|
updates.favicon = `/uploads/${req.files.favicon[0].filename}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update existing settings with new values
|
||||||
|
Object.keys(updates).forEach(key => {
|
||||||
|
if (typeof updates[key] === 'object' && updates[key] !== null) {
|
||||||
|
settings[key] = { ...settings[key], ...updates[key] };
|
||||||
|
} else {
|
||||||
|
settings[key] = updates[key];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await settings.save();
|
||||||
|
|
||||||
|
res.json({ success: true, settings });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Settings update error:', error);
|
||||||
|
res.status(500).json({ success: false, message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
@@ -2,7 +2,7 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const jwt = require('jsonwebtoken');
|
const jwt = require('jsonwebtoken');
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const User = require('../models/User');
|
const { User } = require('../models');
|
||||||
|
|
||||||
// Login validation rules
|
// Login validation rules
|
||||||
const loginValidation = [
|
const loginValidation = [
|
||||||
@@ -25,7 +25,12 @@ router.post('/login', loginValidation, async (req, res) => {
|
|||||||
const { email, password } = req.body;
|
const { email, password } = req.body;
|
||||||
|
|
||||||
// Find user
|
// Find user
|
||||||
const user = await User.findOne({ email, isActive: true });
|
const user = await User.findOne({
|
||||||
|
where: {
|
||||||
|
email: email,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
|
});
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(401).json({
|
return res.status(401).json({
|
||||||
success: false,
|
success: false,
|
||||||
@@ -109,8 +114,9 @@ router.get('/me', async (req, res) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const user = await User.findById(req.session.user.id)
|
const user = await User.findByPk(req.session.user.id, {
|
||||||
.select('-password');
|
attributes: { exclude: ['password'] }
|
||||||
|
});
|
||||||
|
|
||||||
if (!user || !user.isActive) {
|
if (!user || !user.isActive) {
|
||||||
req.session.destroy();
|
req.session.destroy();
|
||||||
@@ -163,7 +169,7 @@ router.put('/change-password', [
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { currentPassword, newPassword } = req.body;
|
const { currentPassword, newPassword } = req.body;
|
||||||
const user = await User.findById(req.session.user.id);
|
const user = await User.findByPk(req.session.user.id);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
|
|||||||
@@ -1,13 +1,15 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Service = require('../models/Service');
|
const { Service } = require('../models');
|
||||||
|
|
||||||
// Get all services for calculator
|
// Get all services for calculator
|
||||||
router.get('/services', async (req, res) => {
|
router.get('/services', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const services = await Service.find({ isActive: true })
|
const services = await Service.findAll({
|
||||||
.select('name pricing category features estimatedTime')
|
where: { isActive: true },
|
||||||
.sort({ category: 1, name: 1 });
|
attributes: ['id', 'name', 'pricing', 'category', 'features', 'estimatedTime'],
|
||||||
|
order: [['category', 'ASC'], ['name', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
const servicesByCategory = services.reduce((acc, service) => {
|
const servicesByCategory = services.reduce((acc, service) => {
|
||||||
if (!acc[service.category]) {
|
if (!acc[service.category]) {
|
||||||
@@ -50,9 +52,11 @@ router.post('/calculate', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get selected services details
|
// Get selected services details
|
||||||
const services = await Service.find({
|
const services = await Service.findAll({
|
||||||
_id: { $in: selectedServices },
|
where: {
|
||||||
isActive: true
|
id: selectedServices,
|
||||||
|
isActive: true
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
if (services.length !== selectedServices.length) {
|
if (services.length !== selectedServices.length) {
|
||||||
|
|||||||
@@ -2,14 +2,8 @@ const express = require('express');
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const { body, validationResult } = require('express-validator');
|
const { body, validationResult } = require('express-validator');
|
||||||
const nodemailer = require('nodemailer');
|
const nodemailer = require('nodemailer');
|
||||||
const Contact = require('../models/Contact');
|
const { Contact } = require('../models');
|
||||||
const TelegramBot = require('node-telegram-bot-api');
|
const telegramService = require('../services/telegram');
|
||||||
|
|
||||||
// Initialize Telegram bot if token is provided
|
|
||||||
let bot = null;
|
|
||||||
if (process.env.TELEGRAM_BOT_TOKEN) {
|
|
||||||
bot = new TelegramBot(process.env.TELEGRAM_BOT_TOKEN, { polling: false });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Contact form validation
|
// Contact form validation
|
||||||
const contactValidation = [
|
const contactValidation = [
|
||||||
@@ -48,7 +42,7 @@ router.post('/submit', contactValidation, async (req, res) => {
|
|||||||
await sendEmailNotification(contact);
|
await sendEmailNotification(contact);
|
||||||
|
|
||||||
// Send Telegram notification
|
// Send Telegram notification
|
||||||
await sendTelegramNotification(contact);
|
await telegramService.sendNewContactAlert(contact);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -108,7 +102,10 @@ router.post('/estimate', [
|
|||||||
|
|
||||||
// Send notifications
|
// Send notifications
|
||||||
await sendEmailNotification(contact);
|
await sendEmailNotification(contact);
|
||||||
await sendTelegramNotification(contact);
|
await telegramService.sendCalculatorQuote({
|
||||||
|
...contactData,
|
||||||
|
services: services.map(s => ({ name: s, price: 0 })) // Simplified for now
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -170,42 +167,7 @@ async function sendEmailNotification(contact) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Helper function to send Telegram notification
|
// Telegram notifications now handled by telegramService
|
||||||
async function sendTelegramNotification(contact) {
|
|
||||||
if (!bot || !process.env.TELEGRAM_CHAT_ID) {
|
|
||||||
console.log('Telegram configuration not provided, skipping Telegram notification');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const message = `
|
|
||||||
🔔 *New Contact Form Submission*
|
|
||||||
|
|
||||||
👤 *Name:* ${contact.name}
|
|
||||||
📧 *Email:* ${contact.email}
|
|
||||||
📱 *Phone:* ${contact.phone || 'Not provided'}
|
|
||||||
🏢 *Company:* ${contact.company || 'Not provided'}
|
|
||||||
📝 *Subject:* ${contact.subject}
|
|
||||||
|
|
||||||
💬 *Message:*
|
|
||||||
${contact.message}
|
|
||||||
|
|
||||||
📍 *Source:* ${contact.source}
|
|
||||||
🕐 *Time:* ${contact.createdAt.toLocaleString()}
|
|
||||||
|
|
||||||
[View in Admin Panel](${process.env.SITE_URL}/admin/contacts/${contact._id})
|
|
||||||
`;
|
|
||||||
|
|
||||||
await bot.sendMessage(process.env.TELEGRAM_CHAT_ID, message, {
|
|
||||||
parse_mode: 'Markdown',
|
|
||||||
disable_web_page_preview: true
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log('Telegram notification sent successfully');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Telegram notification error:', error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper function to calculate project estimate
|
// Helper function to calculate project estimate
|
||||||
function calculateProjectEstimate(services, projectType, timeline) {
|
function calculateProjectEstimate(services, projectType, timeline) {
|
||||||
|
|||||||
103
routes/index.js
@@ -1,20 +1,23 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Portfolio = require('../models/Portfolio');
|
const { Portfolio, Service, SiteSettings } = require('../models');
|
||||||
const Service = require('../models/Service');
|
const { Op } = require('sequelize');
|
||||||
const SiteSettings = require('../models/SiteSettings');
|
|
||||||
|
|
||||||
// Home page
|
// Home page
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const [settings, featuredPortfolio, featuredServices] = await Promise.all([
|
const [settings, featuredPortfolio, featuredServices] = await Promise.all([
|
||||||
SiteSettings.findOne() || {},
|
SiteSettings.findOne() || {},
|
||||||
Portfolio.find({ featured: true, isPublished: true })
|
Portfolio.findAll({
|
||||||
.sort({ order: 1, createdAt: -1 })
|
where: { featured: true, isPublished: true },
|
||||||
.limit(6),
|
order: [['order', 'ASC'], ['createdAt', 'DESC']],
|
||||||
Service.find({ featured: true, isActive: true })
|
limit: 6
|
||||||
.sort({ order: 1 })
|
}),
|
||||||
.limit(4)
|
Service.findAll({
|
||||||
|
where: { featured: true, isActive: true },
|
||||||
|
order: [['order', 'ASC']],
|
||||||
|
limit: 4
|
||||||
|
})
|
||||||
]);
|
]);
|
||||||
|
|
||||||
res.render('index', {
|
res.render('index', {
|
||||||
@@ -28,6 +31,7 @@ router.get('/', async (req, res) => {
|
|||||||
console.error('Home page error:', error);
|
console.error('Home page error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -47,6 +51,7 @@ router.get('/about', async (req, res) => {
|
|||||||
console.error('About page error:', error);
|
console.error('About page error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,20 +70,28 @@ router.get('/portfolio', async (req, res) => {
|
|||||||
query.category = category;
|
query.category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [portfolio, total, categories] = await Promise.all([
|
const [settings, portfolio, total, categories] = await Promise.all([
|
||||||
Portfolio.find(query)
|
SiteSettings.findOne() || {},
|
||||||
.sort({ featured: -1, publishedAt: -1 })
|
Portfolio.findAll({
|
||||||
.skip(skip)
|
where: query,
|
||||||
.limit(limit),
|
order: [['featured', 'DESC'], ['publishedAt', 'DESC']],
|
||||||
Portfolio.countDocuments(query),
|
offset: skip,
|
||||||
Portfolio.distinct('category', { isPublished: true })
|
limit: limit
|
||||||
|
}),
|
||||||
|
Portfolio.count({ where: query }),
|
||||||
|
Portfolio.findAll({
|
||||||
|
where: { isPublished: true },
|
||||||
|
attributes: ['category'],
|
||||||
|
group: ['category']
|
||||||
|
}).then(results => results.map(r => r.category))
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
|
||||||
res.render('portfolio', {
|
res.render('portfolio', {
|
||||||
title: 'Portfolio - SmartSolTech',
|
title: 'Portfolio - SmartSolTech',
|
||||||
portfolio,
|
settings: settings || {},
|
||||||
|
portfolioItems: portfolio,
|
||||||
categories,
|
categories,
|
||||||
currentCategory: category || 'all',
|
currentCategory: category || 'all',
|
||||||
pagination: {
|
pagination: {
|
||||||
@@ -93,6 +106,7 @@ router.get('/portfolio', async (req, res) => {
|
|||||||
console.error('Portfolio page error:', error);
|
console.error('Portfolio page error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -101,11 +115,15 @@ router.get('/portfolio', async (req, res) => {
|
|||||||
// Portfolio detail page
|
// Portfolio detail page
|
||||||
router.get('/portfolio/:id', async (req, res) => {
|
router.get('/portfolio/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const portfolio = await Portfolio.findById(req.params.id);
|
const [settings, portfolio] = await Promise.all([
|
||||||
|
SiteSettings.findOne() || {},
|
||||||
|
Portfolio.findByPk(req.params.id)
|
||||||
|
]);
|
||||||
|
|
||||||
if (!portfolio || !portfolio.isPublished) {
|
if (!portfolio || !portfolio.isPublished) {
|
||||||
return res.status(404).render('404', {
|
return res.status(404).render('404', {
|
||||||
title: '404 - Project Not Found',
|
title: '404 - Project Not Found',
|
||||||
|
settings: settings || {},
|
||||||
message: 'The requested project was not found'
|
message: 'The requested project was not found'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -115,14 +133,19 @@ router.get('/portfolio/:id', async (req, res) => {
|
|||||||
await portfolio.save();
|
await portfolio.save();
|
||||||
|
|
||||||
// Get related projects
|
// Get related projects
|
||||||
const relatedProjects = await Portfolio.find({
|
const relatedProjects = await Portfolio.findAll({
|
||||||
_id: { $ne: portfolio._id },
|
where: {
|
||||||
category: portfolio.category,
|
id: { [Op.ne]: portfolio.id },
|
||||||
isPublished: true
|
category: portfolio.category,
|
||||||
}).limit(3);
|
isPublished: true
|
||||||
|
},
|
||||||
|
order: [['publishedAt', 'DESC']],
|
||||||
|
limit: 3
|
||||||
|
});
|
||||||
|
|
||||||
res.render('portfolio-detail', {
|
res.render('portfolio-detail', {
|
||||||
title: `${portfolio.title} - Portfolio - SmartSolTech`,
|
title: `${portfolio.title} - Portfolio - SmartSolTech`,
|
||||||
|
settings: settings || {},
|
||||||
portfolio,
|
portfolio,
|
||||||
relatedProjects,
|
relatedProjects,
|
||||||
currentPage: 'portfolio'
|
currentPage: 'portfolio'
|
||||||
@@ -131,6 +154,7 @@ router.get('/portfolio/:id', async (req, res) => {
|
|||||||
console.error('Portfolio detail error:', error);
|
console.error('Portfolio detail error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -139,14 +163,22 @@ router.get('/portfolio/:id', async (req, res) => {
|
|||||||
// Services page
|
// Services page
|
||||||
router.get('/services', async (req, res) => {
|
router.get('/services', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const services = await Service.find({ isActive: true })
|
const [settings, services, categories] = await Promise.all([
|
||||||
.sort({ featured: -1, order: 1 })
|
SiteSettings.findOne() || {},
|
||||||
.populate('portfolio', 'title images');
|
Service.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
const categories = await Service.distinct('category', { isActive: true });
|
order: [['featured', 'DESC'], ['order', 'ASC']]
|
||||||
|
}),
|
||||||
|
Service.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
attributes: ['category'],
|
||||||
|
group: ['category']
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
res.render('services', {
|
res.render('services', {
|
||||||
title: 'Services - SmartSolTech',
|
title: 'Services - SmartSolTech',
|
||||||
|
settings: settings || {},
|
||||||
services,
|
services,
|
||||||
categories,
|
categories,
|
||||||
currentPage: 'services'
|
currentPage: 'services'
|
||||||
@@ -155,6 +187,7 @@ router.get('/services', async (req, res) => {
|
|||||||
console.error('Services page error:', error);
|
console.error('Services page error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -163,12 +196,18 @@ router.get('/services', async (req, res) => {
|
|||||||
// Calculator page
|
// Calculator page
|
||||||
router.get('/calculator', async (req, res) => {
|
router.get('/calculator', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const services = await Service.find({ isActive: true })
|
const [settings, services] = await Promise.all([
|
||||||
.select('name pricing category')
|
SiteSettings.findOne() || {},
|
||||||
.sort({ category: 1, name: 1 });
|
Service.findAll({
|
||||||
|
where: { isActive: true },
|
||||||
|
attributes: ['id', 'name', 'pricing', 'category'],
|
||||||
|
order: [['category', 'ASC'], ['name', 'ASC']]
|
||||||
|
})
|
||||||
|
]);
|
||||||
|
|
||||||
res.render('calculator', {
|
res.render('calculator', {
|
||||||
title: 'Project Calculator - SmartSolTech',
|
title: 'Project Calculator - SmartSolTech',
|
||||||
|
settings: settings || {},
|
||||||
services,
|
services,
|
||||||
currentPage: 'calculator'
|
currentPage: 'calculator'
|
||||||
});
|
});
|
||||||
@@ -176,6 +215,7 @@ router.get('/calculator', async (req, res) => {
|
|||||||
console.error('Calculator page error:', error);
|
console.error('Calculator page error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -195,6 +235,7 @@ router.get('/contact', async (req, res) => {
|
|||||||
console.error('Contact page error:', error);
|
console.error('Contact page error:', error);
|
||||||
res.status(500).render('error', {
|
res.status(500).render('error', {
|
||||||
title: 'Error',
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: 'Something went wrong'
|
message: 'Something went wrong'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
444
routes/media.js
@@ -280,50 +280,210 @@ router.delete('/:filename', requireAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// List uploaded images
|
// List uploaded images with advanced filtering and search
|
||||||
router.get('/list', requireAuth, async (req, res) => {
|
router.get('/list', requireAuth, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const uploadPath = path.join(__dirname, '../public/uploads');
|
const uploadPath = path.join(__dirname, '../public/uploads');
|
||||||
|
|
||||||
|
// Create uploads directory if it doesn't exist
|
||||||
|
try {
|
||||||
|
await fs.mkdir(uploadPath, { recursive: true });
|
||||||
|
} catch (mkdirError) {
|
||||||
|
// Directory might already exist, continue
|
||||||
|
}
|
||||||
|
|
||||||
const page = parseInt(req.query.page) || 1;
|
const page = parseInt(req.query.page) || 1;
|
||||||
const limit = parseInt(req.query.limit) || 20;
|
const limit = parseInt(req.query.limit) || 24;
|
||||||
|
const search = req.query.search?.toLowerCase() || '';
|
||||||
|
const sortBy = req.query.sortBy || 'date'; // date, name, size
|
||||||
|
const sortOrder = req.query.sortOrder || 'desc'; // asc, desc
|
||||||
|
const fileType = req.query.fileType || 'all'; // all, image, video, document
|
||||||
|
|
||||||
const files = await fs.readdir(uploadPath);
|
let files;
|
||||||
const imageFiles = files.filter(file =>
|
try {
|
||||||
/\.(jpg|jpeg|png|gif|webp)$/i.test(file)
|
files = await fs.readdir(uploadPath);
|
||||||
);
|
} catch (readdirError) {
|
||||||
|
if (readdirError.code === 'ENOENT') {
|
||||||
|
return res.json({
|
||||||
|
success: true,
|
||||||
|
images: [],
|
||||||
|
pagination: {
|
||||||
|
current: 1,
|
||||||
|
total: 0,
|
||||||
|
limit,
|
||||||
|
totalItems: 0,
|
||||||
|
hasNext: false,
|
||||||
|
hasPrev: false
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
search: '',
|
||||||
|
sortBy: 'date',
|
||||||
|
sortOrder: 'desc',
|
||||||
|
fileType: 'all'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw readdirError;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter by file type
|
||||||
|
let filteredFiles = files;
|
||||||
|
if (fileType !== 'all') {
|
||||||
|
const typePatterns = {
|
||||||
|
image: /\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i,
|
||||||
|
video: /\.(mp4|webm|avi|mov|mkv|wmv|flv)$/i,
|
||||||
|
document: /\.(pdf|doc|docx|txt|rtf|odt)$/i
|
||||||
|
};
|
||||||
|
|
||||||
|
const pattern = typePatterns[fileType];
|
||||||
|
if (pattern) {
|
||||||
|
filteredFiles = files.filter(file => pattern.test(file));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Only show supported media files
|
||||||
|
filteredFiles = files.filter(file =>
|
||||||
|
/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?|mp4|webm|avi|mov|mkv|pdf|doc|docx)$/i.test(file)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const total = imageFiles.length;
|
// Apply search filter
|
||||||
const totalPages = Math.ceil(total / limit);
|
if (search) {
|
||||||
const start = (page - 1) * limit;
|
filteredFiles = filteredFiles.filter(file =>
|
||||||
const end = start + limit;
|
file.toLowerCase().includes(search)
|
||||||
|
);
|
||||||
const paginatedFiles = imageFiles.slice(start, end);
|
}
|
||||||
|
|
||||||
const imagesWithStats = await Promise.all(
|
// Get file stats and create file objects
|
||||||
paginatedFiles.map(async (file) => {
|
const filesWithStats = await Promise.all(
|
||||||
|
filteredFiles.map(async (file) => {
|
||||||
try {
|
try {
|
||||||
const filePath = path.join(uploadPath, file);
|
const filePath = path.join(uploadPath, file);
|
||||||
const stats = await fs.stat(filePath);
|
const stats = await fs.stat(filePath);
|
||||||
|
return { file, stats, filePath };
|
||||||
return {
|
|
||||||
filename: file,
|
|
||||||
url: `/uploads/${file}`,
|
|
||||||
size: stats.size,
|
|
||||||
modified: stats.mtime,
|
|
||||||
isImage: true
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`Error getting stats for ${file}:`, error);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
const validImages = imagesWithStats.filter(img => img !== null);
|
const validFiles = filesWithStats.filter(item => item !== null);
|
||||||
|
|
||||||
|
// Sort files
|
||||||
|
validFiles.sort((a, b) => {
|
||||||
|
let aValue, bValue;
|
||||||
|
|
||||||
|
switch (sortBy) {
|
||||||
|
case 'name':
|
||||||
|
aValue = a.file.toLowerCase();
|
||||||
|
bValue = b.file.toLowerCase();
|
||||||
|
break;
|
||||||
|
case 'size':
|
||||||
|
aValue = a.stats.size;
|
||||||
|
bValue = b.stats.size;
|
||||||
|
break;
|
||||||
|
case 'date':
|
||||||
|
default:
|
||||||
|
aValue = a.stats.mtime;
|
||||||
|
bValue = b.stats.mtime;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortOrder === 'asc') {
|
||||||
|
return aValue > bValue ? 1 : -1;
|
||||||
|
} else {
|
||||||
|
return aValue < bValue ? 1 : -1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const total = validFiles.length;
|
||||||
|
const totalPages = Math.ceil(total / limit);
|
||||||
|
const start = (page - 1) * limit;
|
||||||
|
const end = start + limit;
|
||||||
|
|
||||||
|
const paginatedFiles = validFiles.slice(start, end);
|
||||||
|
|
||||||
|
const filesWithDetails = await Promise.all(
|
||||||
|
paginatedFiles.map(async ({ file, stats, filePath }) => {
|
||||||
|
try {
|
||||||
|
const ext = path.extname(file).toLowerCase();
|
||||||
|
let fileDetails = {
|
||||||
|
filename: file,
|
||||||
|
url: `/uploads/${file}`,
|
||||||
|
size: stats.size,
|
||||||
|
uploadedAt: stats.birthtime || stats.mtime,
|
||||||
|
modifiedAt: stats.mtime,
|
||||||
|
extension: ext,
|
||||||
|
isImage: false,
|
||||||
|
isVideo: false,
|
||||||
|
isDocument: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Determine file type and get additional info
|
||||||
|
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i.test(file)) {
|
||||||
|
fileDetails.isImage = true;
|
||||||
|
fileDetails.mimetype = `image/${ext.replace('.', '')}`;
|
||||||
|
|
||||||
|
// Get image dimensions
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(filePath).metadata();
|
||||||
|
fileDetails.dimensions = {
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height
|
||||||
|
};
|
||||||
|
fileDetails.format = metadata.format;
|
||||||
|
} catch (sharpError) {
|
||||||
|
console.warn(`Could not get image metadata for ${file}`);
|
||||||
|
}
|
||||||
|
} else if (/\.(mp4|webm|avi|mov|mkv|wmv|flv)$/i.test(file)) {
|
||||||
|
fileDetails.isVideo = true;
|
||||||
|
fileDetails.mimetype = `video/${ext.replace('.', '')}`;
|
||||||
|
} else if (/\.(pdf|doc|docx|txt|rtf|odt)$/i.test(file)) {
|
||||||
|
fileDetails.isDocument = true;
|
||||||
|
fileDetails.mimetype = `application/${ext.replace('.', '')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate thumbnail for images
|
||||||
|
if (fileDetails.isImage && !file.includes('-thumbnail.')) {
|
||||||
|
const thumbnailPath = path.join(uploadPath, `${path.parse(file).name}-thumbnail.webp`);
|
||||||
|
try {
|
||||||
|
await fs.access(thumbnailPath);
|
||||||
|
fileDetails.thumbnail = `/uploads/${path.basename(thumbnailPath)}`;
|
||||||
|
} catch {
|
||||||
|
// Thumbnail doesn't exist, create it
|
||||||
|
try {
|
||||||
|
await sharp(filePath)
|
||||||
|
.resize(200, 150, {
|
||||||
|
fit: 'cover',
|
||||||
|
withoutEnlargement: false
|
||||||
|
})
|
||||||
|
.webp({ quality: 80 })
|
||||||
|
.toFile(thumbnailPath);
|
||||||
|
fileDetails.thumbnail = `/uploads/${path.basename(thumbnailPath)}`;
|
||||||
|
} catch (thumbError) {
|
||||||
|
console.warn(`Could not create thumbnail for ${file}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return fileDetails;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error getting details for ${file}:`, error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const validMedia = filesWithDetails.filter(item => item !== null);
|
||||||
|
|
||||||
|
// Calculate storage stats
|
||||||
|
const totalSize = validFiles.reduce((sum, file) => sum + file.stats.size, 0);
|
||||||
|
const imageCount = validMedia.filter(f => f.isImage).length;
|
||||||
|
const videoCount = validMedia.filter(f => f.isVideo).length;
|
||||||
|
const documentCount = validMedia.filter(f => f.isDocument).length;
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
images: validImages,
|
files: validMedia,
|
||||||
pagination: {
|
pagination: {
|
||||||
current: page,
|
current: page,
|
||||||
total: totalPages,
|
total: totalPages,
|
||||||
@@ -331,15 +491,243 @@ router.get('/list', requireAuth, async (req, res) => {
|
|||||||
totalItems: total,
|
totalItems: total,
|
||||||
hasNext: page < totalPages,
|
hasNext: page < totalPages,
|
||||||
hasPrev: page > 1
|
hasPrev: page > 1
|
||||||
|
},
|
||||||
|
filters: {
|
||||||
|
search,
|
||||||
|
sortBy,
|
||||||
|
sortOrder,
|
||||||
|
fileType
|
||||||
|
},
|
||||||
|
stats: {
|
||||||
|
totalFiles: total,
|
||||||
|
totalSize,
|
||||||
|
imageCount,
|
||||||
|
videoCount,
|
||||||
|
documentCount,
|
||||||
|
formattedSize: formatFileSize(totalSize)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('List images error:', error);
|
console.error('List media error:', error);
|
||||||
res.status(500).json({
|
res.status(500).json({
|
||||||
success: false,
|
success: false,
|
||||||
message: 'Error listing images'
|
message: 'Error listing media files'
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Create folder structure
|
||||||
|
router.post('/folder', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { folderName } = req.body;
|
||||||
|
|
||||||
|
if (!folderName || !folderName.trim()) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Folder name is required'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize folder name
|
||||||
|
const sanitizedName = folderName.trim().replace(/[^a-zA-Z0-9-_]/g, '-');
|
||||||
|
const folderPath = path.join(__dirname, '../public/uploads', sanitizedName);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.mkdir(folderPath, { recursive: true });
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Folder created successfully',
|
||||||
|
folderName: sanitizedName,
|
||||||
|
folderPath: `/uploads/${sanitizedName}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'EEXIST') {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Folder already exists'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Create folder error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error creating folder'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get media file info
|
||||||
|
router.get('/info/:filename', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid filename'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(__dirname, '../public/uploads', filename);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stats = await fs.stat(filePath);
|
||||||
|
const ext = path.extname(filename).toLowerCase();
|
||||||
|
|
||||||
|
let fileInfo = {
|
||||||
|
filename,
|
||||||
|
url: `/uploads/${filename}`,
|
||||||
|
size: stats.size,
|
||||||
|
formattedSize: formatFileSize(stats.size),
|
||||||
|
uploadedAt: stats.birthtime || stats.mtime,
|
||||||
|
modifiedAt: stats.mtime,
|
||||||
|
extension: ext,
|
||||||
|
mimetype: getMimeType(ext)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Get additional info for images
|
||||||
|
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp|tiff?)$/i.test(filename)) {
|
||||||
|
try {
|
||||||
|
const metadata = await sharp(filePath).metadata();
|
||||||
|
fileInfo.dimensions = {
|
||||||
|
width: metadata.width,
|
||||||
|
height: metadata.height
|
||||||
|
};
|
||||||
|
fileInfo.format = metadata.format;
|
||||||
|
fileInfo.hasAlpha = metadata.hasAlpha;
|
||||||
|
fileInfo.density = metadata.density;
|
||||||
|
} catch (sharpError) {
|
||||||
|
console.warn(`Could not get image metadata for ${filename}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
fileInfo
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'File not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get file info error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error getting file information'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize image
|
||||||
|
router.post('/resize/:filename', requireAuth, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const filename = req.params.filename;
|
||||||
|
const { width, height, quality = 85 } = req.body;
|
||||||
|
|
||||||
|
if (!width && !height) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Width or height must be specified'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security check
|
||||||
|
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||||
|
return res.status(400).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Invalid filename'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalPath = path.join(__dirname, '../public/uploads', filename);
|
||||||
|
const nameWithoutExt = path.parse(filename).name;
|
||||||
|
const resizedPath = path.join(
|
||||||
|
path.dirname(originalPath),
|
||||||
|
`${nameWithoutExt}-${width || 'auto'}x${height || 'auto'}.webp`
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let sharpInstance = sharp(originalPath);
|
||||||
|
|
||||||
|
if (width && height) {
|
||||||
|
sharpInstance = sharpInstance.resize(parseInt(width), parseInt(height), {
|
||||||
|
fit: 'cover'
|
||||||
|
});
|
||||||
|
} else if (width) {
|
||||||
|
sharpInstance = sharpInstance.resize(parseInt(width));
|
||||||
|
} else {
|
||||||
|
sharpInstance = sharpInstance.resize(null, parseInt(height));
|
||||||
|
}
|
||||||
|
|
||||||
|
await sharpInstance
|
||||||
|
.webp({ quality: parseInt(quality) })
|
||||||
|
.toFile(resizedPath);
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
success: true,
|
||||||
|
message: 'Image resized successfully',
|
||||||
|
originalFile: filename,
|
||||||
|
resizedFile: path.basename(resizedPath),
|
||||||
|
resizedUrl: `/uploads/${path.basename(resizedPath)}`
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error.code === 'ENOENT') {
|
||||||
|
return res.status(404).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Original file not found'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Resize image error:', error);
|
||||||
|
res.status(500).json({
|
||||||
|
success: false,
|
||||||
|
message: 'Error resizing image'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMimeType(ext) {
|
||||||
|
const mimeTypes = {
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.gif': 'image/gif',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.bmp': 'image/bmp',
|
||||||
|
'.tiff': 'image/tiff',
|
||||||
|
'.tif': 'image/tiff',
|
||||||
|
'.mp4': 'video/mp4',
|
||||||
|
'.webm': 'video/webm',
|
||||||
|
'.avi': 'video/x-msvideo',
|
||||||
|
'.mov': 'video/quicktime',
|
||||||
|
'.mkv': 'video/x-matroska',
|
||||||
|
'.pdf': 'application/pdf',
|
||||||
|
'.doc': 'application/msword',
|
||||||
|
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
|
||||||
|
};
|
||||||
|
|
||||||
|
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Portfolio = require('../models/Portfolio');
|
const { Portfolio } = require('../models');
|
||||||
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
// Get all portfolio items
|
// Get all portfolio items
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
@@ -13,28 +14,37 @@ router.get('/', async (req, res) => {
|
|||||||
const featured = req.query.featured;
|
const featured = req.query.featured;
|
||||||
|
|
||||||
// Build query
|
// Build query
|
||||||
let query = { isPublished: true };
|
let whereClause = { isPublished: true };
|
||||||
|
|
||||||
if (category && category !== 'all') {
|
if (category && category !== 'all') {
|
||||||
query.category = category;
|
whereClause.category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (featured === 'true') {
|
if (featured === 'true') {
|
||||||
query.featured = true;
|
whereClause.featured = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (search) {
|
if (search) {
|
||||||
query.$text = { $search: search };
|
whereClause = {
|
||||||
|
...whereClause,
|
||||||
|
[Op.or]: [
|
||||||
|
{ title: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ description: { [Op.iLike]: `%${search}%` } },
|
||||||
|
{ shortDescription: { [Op.iLike]: `%${search}%` } }
|
||||||
|
]
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get portfolio items
|
// Get portfolio items
|
||||||
const [portfolio, total] = await Promise.all([
|
const [portfolio, total] = await Promise.all([
|
||||||
Portfolio.find(query)
|
Portfolio.findAll({
|
||||||
.sort({ featured: -1, publishedAt: -1 })
|
where: whereClause,
|
||||||
.skip(skip)
|
order: [['featured', 'DESC'], ['publishedAt', 'DESC']],
|
||||||
.limit(limit)
|
offset: skip,
|
||||||
.select('title shortDescription category technologies images status publishedAt viewCount'),
|
limit: limit,
|
||||||
Portfolio.countDocuments(query)
|
attributes: ['id', 'title', 'shortDescription', 'category', 'technologies', 'images', 'status', 'publishedAt', 'viewCount']
|
||||||
|
}),
|
||||||
|
Portfolio.count({ where: whereClause })
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const totalPages = Math.ceil(total / limit);
|
const totalPages = Math.ceil(total / limit);
|
||||||
@@ -63,7 +73,7 @@ router.get('/', async (req, res) => {
|
|||||||
// Get single portfolio item
|
// Get single portfolio item
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const portfolio = await Portfolio.findById(req.params.id);
|
const portfolio = await Portfolio.findByPk(req.params.id);
|
||||||
|
|
||||||
if (!portfolio || !portfolio.isPublished) {
|
if (!portfolio || !portfolio.isPublished) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -77,13 +87,15 @@ router.get('/:id', async (req, res) => {
|
|||||||
await portfolio.save();
|
await portfolio.save();
|
||||||
|
|
||||||
// Get related projects
|
// Get related projects
|
||||||
const relatedProjects = await Portfolio.find({
|
const relatedProjects = await Portfolio.findAll({
|
||||||
_id: { $ne: portfolio._id },
|
where: {
|
||||||
category: portfolio.category,
|
id: { [Op.ne]: portfolio.id },
|
||||||
isPublished: true
|
category: portfolio.category,
|
||||||
})
|
isPublished: true
|
||||||
.select('title shortDescription images')
|
},
|
||||||
.limit(4);
|
attributes: ['id', 'title', 'shortDescription', 'images'],
|
||||||
|
limit: 3
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -131,7 +143,7 @@ router.get('/meta/categories', async (req, res) => {
|
|||||||
// Like portfolio item
|
// Like portfolio item
|
||||||
router.post('/:id/like', async (req, res) => {
|
router.post('/:id/like', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const portfolio = await Portfolio.findById(req.params.id);
|
const portfolio = await Portfolio.findByPk(req.params.id);
|
||||||
|
|
||||||
if (!portfolio || !portfolio.isPublished) {
|
if (!portfolio || !portfolio.isPublished) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -162,21 +174,22 @@ router.get('/search/:term', async (req, res) => {
|
|||||||
const searchTerm = req.params.term;
|
const searchTerm = req.params.term;
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
const portfolio = await Portfolio.find({
|
const portfolio = await Portfolio.findAll({
|
||||||
$and: [
|
where: {
|
||||||
{ isPublished: true },
|
[Op.and]: [
|
||||||
{
|
{ isPublished: true },
|
||||||
$or: [
|
{
|
||||||
{ title: { $regex: searchTerm, $options: 'i' } },
|
[Op.or]: [
|
||||||
{ description: { $regex: searchTerm, $options: 'i' } },
|
{ title: { [Op.iLike]: `%${searchTerm}%` } },
|
||||||
{ technologies: { $in: [new RegExp(searchTerm, 'i')] } }
|
{ description: { [Op.iLike]: `%${searchTerm}%` } },
|
||||||
]
|
{ technologies: { [Op.contains]: [searchTerm] } }
|
||||||
}
|
]
|
||||||
]
|
}
|
||||||
})
|
]
|
||||||
.select('title shortDescription category images')
|
},
|
||||||
.sort({ featured: -1, publishedAt: -1 })
|
attributes: ['id', 'title', 'shortDescription', 'images', 'category'],
|
||||||
.limit(limit);
|
limit: limit
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const Service = require('../models/Service');
|
const { Service, Portfolio } = require('../models');
|
||||||
const Portfolio = require('../models/Portfolio');
|
const { Op } = require('sequelize');
|
||||||
|
|
||||||
// Get all services
|
// Get all services
|
||||||
router.get('/', async (req, res) => {
|
router.get('/', async (req, res) => {
|
||||||
@@ -9,19 +9,20 @@ router.get('/', async (req, res) => {
|
|||||||
const category = req.query.category;
|
const category = req.query.category;
|
||||||
const featured = req.query.featured;
|
const featured = req.query.featured;
|
||||||
|
|
||||||
let query = { isActive: true };
|
let whereClause = { isActive: true };
|
||||||
|
|
||||||
if (category && category !== 'all') {
|
if (category && category !== 'all') {
|
||||||
query.category = category;
|
whereClause.category = category;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (featured === 'true') {
|
if (featured === 'true') {
|
||||||
query.featured = true;
|
whereClause.featured = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const services = await Service.find(query)
|
const services = await Service.findAll({
|
||||||
.populate('portfolio', 'title images')
|
where: whereClause,
|
||||||
.sort({ featured: -1, order: 1 });
|
order: [['featured', 'DESC'], ['order', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -39,8 +40,7 @@ router.get('/', async (req, res) => {
|
|||||||
// Get single service
|
// Get single service
|
||||||
router.get('/:id', async (req, res) => {
|
router.get('/:id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const service = await Service.findById(req.params.id)
|
const service = await Service.findByPk(req.params.id);
|
||||||
.populate('portfolio', 'title shortDescription images category');
|
|
||||||
|
|
||||||
if (!service || !service.isActive) {
|
if (!service || !service.isActive) {
|
||||||
return res.status(404).json({
|
return res.status(404).json({
|
||||||
@@ -50,13 +50,14 @@ router.get('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get related services
|
// Get related services
|
||||||
const relatedServices = await Service.find({
|
const relatedServices = await Service.findAll({
|
||||||
_id: { $ne: service._id },
|
where: {
|
||||||
category: service.category,
|
id: { [Op.ne]: service.id },
|
||||||
isActive: true
|
category: service.category,
|
||||||
})
|
isActive: true
|
||||||
.select('name shortDescription icon pricing')
|
},
|
||||||
.limit(3);
|
limit: 3
|
||||||
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
@@ -107,21 +108,21 @@ router.get('/search/:term', async (req, res) => {
|
|||||||
const searchTerm = req.params.term;
|
const searchTerm = req.params.term;
|
||||||
const limit = parseInt(req.query.limit) || 10;
|
const limit = parseInt(req.query.limit) || 10;
|
||||||
|
|
||||||
const services = await Service.find({
|
const services = await Service.findAll({
|
||||||
$and: [
|
where: {
|
||||||
{ isActive: true },
|
[Op.and]: [
|
||||||
{
|
{ isActive: true },
|
||||||
$or: [
|
{
|
||||||
{ name: { $regex: searchTerm, $options: 'i' } },
|
[Op.or]: [
|
||||||
{ description: { $regex: searchTerm, $options: 'i' } },
|
{ name: { [Op.iLike]: `%${searchTerm}%` } },
|
||||||
{ tags: { $in: [new RegExp(searchTerm, 'i')] } }
|
{ description: { [Op.iLike]: `%${searchTerm}%` } },
|
||||||
]
|
{ tags: { [Op.contains]: [searchTerm] } }
|
||||||
}
|
]
|
||||||
]
|
}
|
||||||
})
|
]
|
||||||
.select('name shortDescription icon pricing category')
|
},
|
||||||
.sort({ featured: -1, order: 1 })
|
limit: limit
|
||||||
.limit(limit);
|
});
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ function startDevelopmentServer() {
|
|||||||
console.log('');
|
console.log('');
|
||||||
|
|
||||||
const nodemonArgs = [
|
const nodemonArgs = [
|
||||||
'--script', NODEMON_CONFIG.script,
|
NODEMON_CONFIG.script,
|
||||||
'--ext', NODEMON_CONFIG.ext,
|
'--ext', NODEMON_CONFIG.ext,
|
||||||
'--ignore', NODEMON_CONFIG.ignore.join(','),
|
'--ignore', NODEMON_CONFIG.ignore.join(','),
|
||||||
'--watch', NODEMON_CONFIG.watch.join(','),
|
'--watch', NODEMON_CONFIG.watch.join(','),
|
||||||
|
|||||||
@@ -2,32 +2,29 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Database initialization script for SmartSolTech
|
* Database initialization script for SmartSolTech
|
||||||
* Creates initial admin user and sample data
|
* Creates initial admin user and sample data for PostgreSQL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const mongoose = require('mongoose');
|
const { sequelize } = require('../config/database');
|
||||||
const bcrypt = require('bcryptjs');
|
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
// Import models
|
// Import models
|
||||||
const User = require('../models/User');
|
const { User, Service, Portfolio, SiteSettings } = require('../models');
|
||||||
const Service = require('../models/Service');
|
|
||||||
const Portfolio = require('../models/Portfolio');
|
|
||||||
const SiteSettings = require('../models/SiteSettings');
|
|
||||||
|
|
||||||
// Configuration
|
// Configuration
|
||||||
const MONGODB_URI = process.env.MONGODB_URI || 'mongodb://localhost:27017/smartsoltech';
|
|
||||||
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@smartsoltech.kr';
|
const ADMIN_EMAIL = process.env.ADMIN_EMAIL || 'admin@smartsoltech.kr';
|
||||||
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456';
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || 'admin123456';
|
||||||
|
|
||||||
async function initializeDatabase() {
|
async function initializeDatabase() {
|
||||||
try {
|
try {
|
||||||
console.log('🔄 Connecting to MongoDB...');
|
console.log('🔄 Connecting to PostgreSQL...');
|
||||||
await mongoose.connect(MONGODB_URI, {
|
await sequelize.authenticate();
|
||||||
useNewUrlParser: true,
|
console.log('✅ Connected to PostgreSQL');
|
||||||
useUnifiedTopology: true,
|
|
||||||
});
|
// Sync database (create tables)
|
||||||
console.log('✅ Connected to MongoDB');
|
console.log('🔄 Syncing database schema...');
|
||||||
|
await sequelize.sync({ force: false });
|
||||||
|
console.log('✅ Database schema synchronized');
|
||||||
|
|
||||||
// Create admin user
|
// Create admin user
|
||||||
await createAdminUser();
|
await createAdminUser();
|
||||||
@@ -50,7 +47,7 @@ async function initializeDatabase() {
|
|||||||
console.error('❌ Database initialization failed:', error);
|
console.error('❌ Database initialization failed:', error);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
} finally {
|
} finally {
|
||||||
await mongoose.connection.close();
|
await sequelize.close();
|
||||||
console.log('🔌 Database connection closed');
|
console.log('🔌 Database connection closed');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
}
|
}
|
||||||
@@ -58,14 +55,16 @@ async function initializeDatabase() {
|
|||||||
|
|
||||||
async function createAdminUser() {
|
async function createAdminUser() {
|
||||||
try {
|
try {
|
||||||
const existingAdmin = await User.findOne({ email: ADMIN_EMAIL });
|
const existingAdmin = await User.findOne({
|
||||||
|
where: { email: ADMIN_EMAIL }
|
||||||
|
});
|
||||||
|
|
||||||
if (existingAdmin) {
|
if (existingAdmin) {
|
||||||
console.log('👤 Admin user already exists, skipping...');
|
console.log('👤 Admin user already exists, skipping...');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const adminUser = new User({
|
const adminUser = await User.create({
|
||||||
name: 'Administrator',
|
name: 'Administrator',
|
||||||
email: ADMIN_EMAIL,
|
email: ADMIN_EMAIL,
|
||||||
password: ADMIN_PASSWORD,
|
password: ADMIN_PASSWORD,
|
||||||
@@ -73,7 +72,6 @@ async function createAdminUser() {
|
|||||||
isActive: true
|
isActive: true
|
||||||
});
|
});
|
||||||
|
|
||||||
await adminUser.save();
|
|
||||||
console.log('✅ Admin user created successfully');
|
console.log('✅ Admin user created successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error creating admin user:', error);
|
console.error('❌ Error creating admin user:', error);
|
||||||
@@ -83,7 +81,7 @@ async function createAdminUser() {
|
|||||||
|
|
||||||
async function createSampleServices() {
|
async function createSampleServices() {
|
||||||
try {
|
try {
|
||||||
const existingServices = await Service.countDocuments();
|
const existingServices = await Service.count();
|
||||||
|
|
||||||
if (existingServices > 0) {
|
if (existingServices > 0) {
|
||||||
console.log('🛠️ Services already exist, skipping...');
|
console.log('🛠️ Services already exist, skipping...');
|
||||||
@@ -243,7 +241,7 @@ async function createSampleServices() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
await Service.insertMany(services);
|
await Service.bulkCreate(services);
|
||||||
console.log('✅ Sample services created successfully');
|
console.log('✅ Sample services created successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error creating sample services:', error);
|
console.error('❌ Error creating sample services:', error);
|
||||||
@@ -253,7 +251,7 @@ async function createSampleServices() {
|
|||||||
|
|
||||||
async function createSamplePortfolio() {
|
async function createSamplePortfolio() {
|
||||||
try {
|
try {
|
||||||
const existingPortfolio = await Portfolio.countDocuments();
|
const existingPortfolio = await Portfolio.count();
|
||||||
|
|
||||||
if (existingPortfolio > 0) {
|
if (existingPortfolio > 0) {
|
||||||
console.log('🎨 Portfolio items already exist, skipping...');
|
console.log('🎨 Portfolio items already exist, skipping...');
|
||||||
@@ -416,7 +414,7 @@ async function createSamplePortfolio() {
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
await Portfolio.insertMany(portfolioItems);
|
await Portfolio.bulkCreate(portfolioItems);
|
||||||
console.log('✅ Sample portfolio items created successfully');
|
console.log('✅ Sample portfolio items created successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error creating sample portfolio:', error);
|
console.error('❌ Error creating sample portfolio:', error);
|
||||||
@@ -433,7 +431,7 @@ async function createSiteSettings() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const settings = new SiteSettings({
|
const settings = await SiteSettings.create({
|
||||||
siteName: 'SmartSolTech',
|
siteName: 'SmartSolTech',
|
||||||
siteDescription: '혁신적인 기술 솔루션으로 비즈니스의 성장을 지원합니다',
|
siteDescription: '혁신적인 기술 솔루션으로 비즈니스의 성장을 지원합니다',
|
||||||
logo: '/images/logo.png',
|
logo: '/images/logo.png',
|
||||||
@@ -476,7 +474,6 @@ async function createSiteSettings() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
await settings.save();
|
|
||||||
console.log('✅ Site settings created successfully');
|
console.log('✅ Site settings created successfully');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('❌ Error creating site settings:', error);
|
console.error('❌ Error creating site settings:', error);
|
||||||
|
|||||||
115
scripts/sync-locales.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const localesDir = path.join(__dirname, '..', 'locales');
|
||||||
|
const files = fs.readdirSync(localesDir).filter(f => f.endsWith('.json'));
|
||||||
|
|
||||||
|
// priority order for falling back when filling missing translations
|
||||||
|
const priority = ['en.json', 'ko.json', 'ru.json', 'kk.json'];
|
||||||
|
|
||||||
|
function readJSON(file) {
|
||||||
|
try {
|
||||||
|
return JSON.parse(fs.readFileSync(path.join(localesDir, file), 'utf8'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to read', file, e.message);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flatten(obj, prefix = '') {
|
||||||
|
const res = {};
|
||||||
|
for (const k of Object.keys(obj)) {
|
||||||
|
const val = obj[k];
|
||||||
|
const key = prefix ? `${prefix}.${k}` : k;
|
||||||
|
if (val && typeof val === 'object' && !Array.isArray(val)) {
|
||||||
|
Object.assign(res, flatten(val, key));
|
||||||
|
} else {
|
||||||
|
res[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
function unflatten(flat) {
|
||||||
|
const res = {};
|
||||||
|
for (const flatKey of Object.keys(flat)) {
|
||||||
|
const parts = flatKey.split('.');
|
||||||
|
let cur = res;
|
||||||
|
for (let i = 0; i < parts.length; i++) {
|
||||||
|
const p = parts[i];
|
||||||
|
if (i === parts.length - 1) {
|
||||||
|
cur[p] = flat[flatKey];
|
||||||
|
} else {
|
||||||
|
cur[p] = cur[p] || {};
|
||||||
|
cur = cur[p];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
// load all
|
||||||
|
const data = {};
|
||||||
|
for (const f of files) {
|
||||||
|
data[f] = readJSON(f);
|
||||||
|
}
|
||||||
|
|
||||||
|
// use en.json as canonical key set if exists, else merge all keys
|
||||||
|
let canonical = {};
|
||||||
|
if (files.includes('en.json')) {
|
||||||
|
canonical = flatten(data['en.json']);
|
||||||
|
} else {
|
||||||
|
for (const f of files) {
|
||||||
|
canonical = Object.assign(canonical, flatten(data[f]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFallback(key, currentFile) {
|
||||||
|
for (const p of priority) {
|
||||||
|
if (p === currentFile) continue;
|
||||||
|
if (!files.includes(p)) continue;
|
||||||
|
const flat = flatten(data[p]);
|
||||||
|
if (flat[key] && typeof flat[key] === 'string' && flat[key].trim()) return flat[key];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let report = {};
|
||||||
|
for (const f of files) {
|
||||||
|
const flat = flatten(data[f]);
|
||||||
|
report[f] = { added: 0, marked: 0 };
|
||||||
|
const out = {};
|
||||||
|
for (const key of Object.keys(canonical)) {
|
||||||
|
if (flat.hasOwnProperty(key)) {
|
||||||
|
out[key] = flat[key];
|
||||||
|
} else {
|
||||||
|
const fb = getFallback(key, f);
|
||||||
|
if (fb) {
|
||||||
|
out[key] = fb;
|
||||||
|
} else {
|
||||||
|
out[key] = '[TRANSLATE] ' + canonical[key];
|
||||||
|
report[f].marked++;
|
||||||
|
}
|
||||||
|
report[f].added++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// also keep any extra keys present in this file but not in canonical
|
||||||
|
for (const key of Object.keys(flat)) {
|
||||||
|
if (!out.hasOwnProperty(key)) {
|
||||||
|
out[key] = flat[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// write back
|
||||||
|
const nested = unflatten(out);
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(path.join(localesDir, f), JSON.stringify(nested, null, 2), 'utf8');
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to write', f, e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Locale sync report:');
|
||||||
|
for (const f of files) {
|
||||||
|
console.log(`- ${f}: added ${report[f].added} keys, ${report[f].marked} marked for translation`);
|
||||||
|
}
|
||||||
|
console.log('Done. Review files in locales/ and replace [TRANSLATE] placeholders with correct translations.');
|
||||||
134
server.js
@@ -1,27 +1,54 @@
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const mongoose = require('mongoose');
|
const { sequelize, testConnection } = require('./config/database');
|
||||||
const session = require('express-session');
|
const session = require('express-session');
|
||||||
const MongoStore = require('connect-mongo');
|
const SequelizeStore = require('connect-session-sequelize')(session.Store);
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const helmet = require('helmet');
|
const helmet = require('helmet');
|
||||||
const compression = require('compression');
|
const compression = require('compression');
|
||||||
const cors = require('cors');
|
const cors = require('cors');
|
||||||
const morgan = require('morgan');
|
const morgan = require('morgan');
|
||||||
const rateLimit = require('express-rate-limit');
|
const rateLimit = require('express-rate-limit');
|
||||||
|
const i18n = require('i18n');
|
||||||
require('dotenv').config();
|
require('dotenv').config();
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
|
|
||||||
|
// Настройка i18n
|
||||||
|
i18n.configure({
|
||||||
|
locales: ['ko', 'en', 'ru', 'kk'],
|
||||||
|
defaultLocale: 'ru',
|
||||||
|
directory: path.join(__dirname, 'locales'),
|
||||||
|
objectNotation: true,
|
||||||
|
updateFiles: false,
|
||||||
|
syncFiles: false
|
||||||
|
});
|
||||||
|
|
||||||
|
// i18n middleware
|
||||||
|
app.use(i18n.init);
|
||||||
|
|
||||||
|
// Middleware для передачи переменных в шаблоны
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
const currentLang = req.session?.language || req.getLocale() || 'ru';
|
||||||
|
req.setLocale(currentLang);
|
||||||
|
|
||||||
|
res.locals.locale = currentLang;
|
||||||
|
res.locals.__ = res.__;
|
||||||
|
res.locals.theme = req.session?.theme || 'light';
|
||||||
|
res.locals.currentLanguage = currentLang;
|
||||||
|
res.locals.currentPage = req.path.split('/')[1] || 'home';
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Security middleware
|
// Security middleware
|
||||||
app.use(helmet({
|
app.use(helmet({
|
||||||
contentSecurityPolicy: {
|
contentSecurityPolicy: {
|
||||||
directives: {
|
directives: {
|
||||||
defaultSrc: ["'self'"],
|
defaultSrc: ["'self'"],
|
||||||
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdnjs.cloudflare.com"],
|
styleSrc: ["'self'", "'unsafe-inline'", "https://fonts.googleapis.com", "https://cdnjs.cloudflare.com", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://cdn.tailwindcss.com"],
|
||||||
fontSrc: ["'self'", "https://fonts.gstatic.com"],
|
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://cdnjs.cloudflare.com"],
|
||||||
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com"],
|
scriptSrc: ["'self'", "'unsafe-inline'", "https://cdnjs.cloudflare.com", "https://unpkg.com", "https://cdn.tailwindcss.com"],
|
||||||
imgSrc: ["'self'", "data:", "https:"],
|
imgSrc: ["'self'", "data:", "https:"],
|
||||||
connectSrc: ["'self'", "ws:", "wss:"]
|
connectSrc: ["'self'", "ws:", "wss:", "https://cdnjs.cloudflare.com", "https://cdn.jsdelivr.net", "https://unpkg.com", "https://fonts.googleapis.com", "https://fonts.gstatic.com", "https://cdn.tailwindcss.com"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -48,23 +75,30 @@ app.use('/uploads', express.static(path.join(__dirname, 'public/uploads')));
|
|||||||
app.set('view engine', 'ejs');
|
app.set('view engine', 'ejs');
|
||||||
app.set('views', path.join(__dirname, 'views'));
|
app.set('views', path.join(__dirname, 'views'));
|
||||||
|
|
||||||
// Database connection
|
// Layout engine
|
||||||
mongoose.connect(process.env.MONGODB_URI || 'mongodb://localhost:27017/smartsoltech', {
|
const expressLayouts = require('express-ejs-layouts');
|
||||||
useNewUrlParser: true,
|
app.use(expressLayouts);
|
||||||
useUnifiedTopology: true,
|
app.set('layout', 'layout'); // Default layout for main site
|
||||||
})
|
app.set('layout extractScripts', true);
|
||||||
.then(() => console.log('✓ MongoDB connected'))
|
app.set('layout extractStyles', true);
|
||||||
.catch(err => console.error('✗ MongoDB connection error:', err));
|
|
||||||
|
// Database connection and testing
|
||||||
|
testConnection();
|
||||||
|
|
||||||
|
// Session store configuration
|
||||||
|
const sessionStore = new SequelizeStore({
|
||||||
|
db: sequelize,
|
||||||
|
tableName: 'sessions',
|
||||||
|
checkExpirationInterval: 15 * 60 * 1000, // 15 minutes
|
||||||
|
expiration: 7 * 24 * 60 * 60 * 1000 // 7 days
|
||||||
|
});
|
||||||
|
|
||||||
// Session configuration
|
// Session configuration
|
||||||
app.use(session({
|
app.use(session({
|
||||||
secret: process.env.SESSION_SECRET || 'your-secret-key',
|
secret: process.env.SESSION_SECRET || 'your-secret-key',
|
||||||
resave: false,
|
resave: false,
|
||||||
saveUninitialized: false,
|
saveUninitialized: false,
|
||||||
store: MongoStore.create({
|
store: sessionStore,
|
||||||
mongoUrl: process.env.MONGODB_URI || 'mongodb://localhost:27017/smartsoltech',
|
|
||||||
touchAfter: 24 * 3600 // lazy session update
|
|
||||||
}),
|
|
||||||
cookie: {
|
cookie: {
|
||||||
secure: process.env.NODE_ENV === 'production',
|
secure: process.env.NODE_ENV === 'production',
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
@@ -80,8 +114,32 @@ app.use('/api/services', require('./routes/services'));
|
|||||||
app.use('/api/calculator', require('./routes/calculator'));
|
app.use('/api/calculator', require('./routes/calculator'));
|
||||||
app.use('/api/contact', require('./routes/contact'));
|
app.use('/api/contact', require('./routes/contact'));
|
||||||
app.use('/api/media', require('./routes/media'));
|
app.use('/api/media', require('./routes/media'));
|
||||||
|
app.use('/api/admin', require('./routes/api/admin'));
|
||||||
app.use('/admin', require('./routes/admin'));
|
app.use('/admin', require('./routes/admin'));
|
||||||
|
|
||||||
|
// Language switching routes
|
||||||
|
app.get('/lang/:language', (req, res) => {
|
||||||
|
const { language } = req.params;
|
||||||
|
const supportedLanguages = ['ko', 'en', 'ru', 'kk'];
|
||||||
|
|
||||||
|
if (supportedLanguages.includes(language)) {
|
||||||
|
req.setLocale(language);
|
||||||
|
req.session.language = language;
|
||||||
|
}
|
||||||
|
|
||||||
|
const referer = req.get('Referer') || '/';
|
||||||
|
res.redirect(referer);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Theme switching routes
|
||||||
|
app.get('/theme/:theme', (req, res) => {
|
||||||
|
const { theme } = req.params;
|
||||||
|
if (['light', 'dark'].includes(theme)) {
|
||||||
|
req.session.theme = theme;
|
||||||
|
}
|
||||||
|
res.json({ success: true, theme: req.session.theme });
|
||||||
|
});
|
||||||
|
|
||||||
// PWA Service Worker
|
// PWA Service Worker
|
||||||
app.get('/sw.js', (req, res) => {
|
app.get('/sw.js', (req, res) => {
|
||||||
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
|
res.sendFile(path.join(__dirname, 'public', 'sw.js'));
|
||||||
@@ -95,27 +153,47 @@ app.get('/manifest.json', (req, res) => {
|
|||||||
// Error handling middleware
|
// Error handling middleware
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err.stack);
|
console.error(err.stack);
|
||||||
res.status(500).json({
|
res.status(500).render('error', {
|
||||||
success: false,
|
title: 'Error',
|
||||||
|
settings: {},
|
||||||
message: process.env.NODE_ENV === 'production'
|
message: process.env.NODE_ENV === 'production'
|
||||||
? 'Something went wrong!'
|
? 'Something went wrong!'
|
||||||
: err.message
|
: err.message,
|
||||||
|
currentPage: 'error'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use((req, res) => {
|
app.use((req, res) => {
|
||||||
res.status(404).render('404', {
|
res.status(404).render('error', {
|
||||||
title: '404 - Страница не найдена',
|
title: '404 - 페이지를 찾을 수 없습니다',
|
||||||
message: 'Запрашиваемая страница не найдена'
|
settings: {},
|
||||||
|
message: '요청하신 페이지를 찾을 수 없습니다',
|
||||||
|
currentPage: 'error'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
app.listen(PORT, () => {
|
// Sync database and start server
|
||||||
console.log(`🚀 Server running on port ${PORT}`);
|
async function startServer() {
|
||||||
console.log(`🌐 Visit: http://localhost:${PORT}`);
|
try {
|
||||||
});
|
// Sync all models with database
|
||||||
|
await sequelize.sync({ force: false });
|
||||||
|
console.log('✓ Database synchronized');
|
||||||
|
|
||||||
|
// Create session table
|
||||||
|
await sessionStore.sync();
|
||||||
|
console.log('✓ Session store synchronized');
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`🚀 Server running on port ${PORT}`);
|
||||||
|
console.log(`🌐 Visit: http://localhost:${PORT}`);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('✗ Failed to start server:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = app;
|
startServer();
|
||||||
374
services/telegram.js
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
const axios = require('axios');
|
||||||
|
|
||||||
|
class TelegramService {
|
||||||
|
constructor() {
|
||||||
|
this.botToken = process.env.TELEGRAM_BOT_TOKEN;
|
||||||
|
this.chatId = process.env.TELEGRAM_CHAT_ID;
|
||||||
|
this.baseUrl = `https://api.telegram.org/bot${this.botToken}`;
|
||||||
|
this.isEnabled = !!(this.botToken && this.chatId);
|
||||||
|
this.chats = new Map(); // Store chat information
|
||||||
|
this.botInfo = null; // Store bot information
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update bot token and reinitialize
|
||||||
|
updateBotToken(newToken) {
|
||||||
|
this.botToken = newToken;
|
||||||
|
this.baseUrl = `https://api.telegram.org/bot${this.botToken}`;
|
||||||
|
this.isEnabled = !!(this.botToken && this.chatId);
|
||||||
|
this.botInfo = null; // Reset bot info
|
||||||
|
return this.testConnection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update default chat ID
|
||||||
|
updateChatId(newChatId) {
|
||||||
|
this.chatId = newChatId;
|
||||||
|
this.isEnabled = !!(this.botToken && this.chatId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get bot information
|
||||||
|
async getBotInfo() {
|
||||||
|
if (this.botInfo) return { success: true, bot: this.botInfo };
|
||||||
|
|
||||||
|
if (!this.botToken) {
|
||||||
|
return { success: false, message: 'Bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/getMe`);
|
||||||
|
this.botInfo = response.data.result;
|
||||||
|
return { success: true, bot: this.botInfo };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get bot info error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updates (for getting chat IDs and managing chats)
|
||||||
|
async getUpdates() {
|
||||||
|
if (!this.botToken) {
|
||||||
|
return { success: false, message: 'Bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/getUpdates`);
|
||||||
|
const updates = response.data.result;
|
||||||
|
|
||||||
|
// Extract unique chats
|
||||||
|
const chats = new Map();
|
||||||
|
updates.forEach(update => {
|
||||||
|
if (update.message) {
|
||||||
|
const chat = update.message.chat;
|
||||||
|
chats.set(chat.id, {
|
||||||
|
id: chat.id,
|
||||||
|
type: chat.type,
|
||||||
|
title: chat.title || `${chat.first_name || ''} ${chat.last_name || ''}`.trim(),
|
||||||
|
username: chat.username || null,
|
||||||
|
first_name: chat.first_name || null,
|
||||||
|
last_name: chat.last_name || null,
|
||||||
|
description: chat.description || null,
|
||||||
|
invite_link: chat.invite_link || null,
|
||||||
|
pinned_message: chat.pinned_message || null,
|
||||||
|
permissions: chat.permissions || null,
|
||||||
|
slow_mode_delay: chat.slow_mode_delay || null
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update internal chat storage
|
||||||
|
chats.forEach((chat, id) => {
|
||||||
|
this.chats.set(id, chat);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
updates,
|
||||||
|
chats: Array.from(chats.values()),
|
||||||
|
totalUpdates: updates.length
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get updates error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get chat information
|
||||||
|
async getChat(chatId) {
|
||||||
|
if (!this.botToken) {
|
||||||
|
return { success: false, message: 'Bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/getChat`, {
|
||||||
|
params: { chat_id: chatId }
|
||||||
|
});
|
||||||
|
const chat = response.data.result;
|
||||||
|
|
||||||
|
// Store in local cache
|
||||||
|
this.chats.set(chatId, chat);
|
||||||
|
|
||||||
|
return { success: true, chat };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get chat error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get chat administrators
|
||||||
|
async getChatAdministrators(chatId) {
|
||||||
|
if (!this.botToken) {
|
||||||
|
return { success: false, message: 'Bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/getChatAdministrators`, {
|
||||||
|
params: { chat_id: chatId }
|
||||||
|
});
|
||||||
|
return { success: true, administrators: response.data.result };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Get chat administrators error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get available chats (cached)
|
||||||
|
getAvailableChats() {
|
||||||
|
return Array.from(this.chats.values()).map(chat => ({
|
||||||
|
id: chat.id,
|
||||||
|
title: chat.title || chat.username || `${chat.first_name || ''} ${chat.last_name || ''}`.trim(),
|
||||||
|
type: chat.type,
|
||||||
|
username: chat.username
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendMessage(text, options = {}) {
|
||||||
|
const chatId = options.chat_id || this.chatId;
|
||||||
|
|
||||||
|
if (!this.botToken) {
|
||||||
|
console.warn('Telegram bot token is not configured');
|
||||||
|
return { success: false, message: 'Telegram bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chatId) {
|
||||||
|
console.warn('Telegram chat ID is not specified');
|
||||||
|
return { success: false, message: 'Telegram chat ID not specified' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${this.baseUrl}/sendMessage`, {
|
||||||
|
chat_id: chatId,
|
||||||
|
text: text,
|
||||||
|
parse_mode: 'HTML',
|
||||||
|
disable_web_page_preview: false,
|
||||||
|
disable_notification: false,
|
||||||
|
...options
|
||||||
|
});
|
||||||
|
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Telegram send message error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send message to multiple chats
|
||||||
|
async sendBroadcastMessage(text, chatIds = [], options = {}) {
|
||||||
|
if (!this.botToken) {
|
||||||
|
return { success: false, message: 'Telegram bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!chatIds || chatIds.length === 0) {
|
||||||
|
return { success: false, message: 'No chat IDs specified' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = [];
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
for (const chatId of chatIds) {
|
||||||
|
try {
|
||||||
|
const result = await this.sendMessage(text, {
|
||||||
|
...options,
|
||||||
|
chat_id: chatId
|
||||||
|
});
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
results.push({ chatId, success: true, messageId: result.data.result.message_id });
|
||||||
|
} else {
|
||||||
|
errors.push({ chatId, error: result.error || result.message });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delay between messages to avoid rate limiting
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
} catch (error) {
|
||||||
|
errors.push({ chatId, error: error.message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: errors.length === 0,
|
||||||
|
results,
|
||||||
|
errors,
|
||||||
|
totalSent: results.length,
|
||||||
|
totalFailed: errors.length
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send custom message with advanced options
|
||||||
|
async sendCustomMessage({
|
||||||
|
text,
|
||||||
|
chatIds = [],
|
||||||
|
parseMode = 'HTML',
|
||||||
|
disableWebPagePreview = false,
|
||||||
|
disableNotification = false,
|
||||||
|
replyMarkup = null
|
||||||
|
}) {
|
||||||
|
const targetChats = chatIds.length > 0 ? chatIds : [this.chatId];
|
||||||
|
|
||||||
|
return await this.sendBroadcastMessage(text, targetChats, {
|
||||||
|
parse_mode: parseMode,
|
||||||
|
disable_web_page_preview: disableWebPagePreview,
|
||||||
|
disable_notification: disableNotification,
|
||||||
|
reply_markup: replyMarkup
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendContactNotification(contact) {
|
||||||
|
const message = this.formatContactMessage(contact);
|
||||||
|
return await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendNewContactAlert(contact) {
|
||||||
|
const message = `🔔 <b>Новый запрос с сайта!</b>\n\n` +
|
||||||
|
`👤 <b>Клиент:</b> ${contact.name}\n` +
|
||||||
|
`📧 <b>Email:</b> ${contact.email}\n` +
|
||||||
|
`📱 <b>Телефон:</b> ${contact.phone || 'Не указан'}\n` +
|
||||||
|
`💼 <b>Услуга:</b> ${contact.serviceInterest || 'Общий запрос'}\n` +
|
||||||
|
`💰 <b>Бюджет:</b> ${contact.budget || 'Не указан'}\n` +
|
||||||
|
`⏱️ <b>Сроки:</b> ${contact.timeline || 'Не указаны'}\n\n` +
|
||||||
|
`💬 <b>Сообщение:</b>\n${contact.message}\n\n` +
|
||||||
|
`🕐 <b>Время:</b> ${new Date(contact.createdAt).toLocaleString('ru-RU')}\n\n` +
|
||||||
|
`🔗 <a href="${process.env.BASE_URL || 'http://localhost:3000'}/admin/contacts/${contact.id}">Открыть в админ-панели</a>`;
|
||||||
|
|
||||||
|
return await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendPortfolioNotification(portfolio) {
|
||||||
|
const message = `📁 <b>Новый проект добавлен в портфолио</b>\n\n` +
|
||||||
|
`🏷️ <b>Название:</b> ${portfolio.title}\n` +
|
||||||
|
`📂 <b>Категория:</b> ${portfolio.category}\n` +
|
||||||
|
`👤 <b>Клиент:</b> ${portfolio.clientName || 'Не указан'}\n` +
|
||||||
|
`🌐 <b>URL:</b> ${portfolio.projectUrl || 'Не указан'}\n` +
|
||||||
|
`⭐ <b>Рекомендуемый:</b> ${portfolio.featured ? 'Да' : 'Нет'}\n` +
|
||||||
|
`📅 <b>Время:</b> ${new Date(portfolio.createdAt).toLocaleString('ru-RU')}\n\n` +
|
||||||
|
`🔗 <a href="${process.env.BASE_URL || 'http://localhost:3000'}/portfolio/${portfolio.id}">Посмотреть проект</a>`;
|
||||||
|
|
||||||
|
return await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendServiceNotification(service) {
|
||||||
|
const message = `⚙️ <b>Новая услуга добавлена</b>\n\n` +
|
||||||
|
`🏷️ <b>Название:</b> ${service.name}\n` +
|
||||||
|
`📂 <b>Категория:</b> ${service.category}\n` +
|
||||||
|
`💰 <b>Стоимость:</b> ${service.pricing?.basePrice ? `от $${service.pricing.basePrice}` : 'По запросу'}\n` +
|
||||||
|
`⏱️ <b>Время выполнения:</b> ${service.estimatedTime || 'Не указано'}\n` +
|
||||||
|
`⭐ <b>Рекомендуемая:</b> ${service.featured ? 'Да' : 'Нет'}\n` +
|
||||||
|
`📅 <b>Время:</b> ${new Date(service.createdAt).toLocaleString('ru-RU')}\n\n` +
|
||||||
|
`🔗 <a href="${process.env.BASE_URL || 'http://localhost:3000'}/services">Посмотреть услуги</a>`;
|
||||||
|
|
||||||
|
return await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
async sendCalculatorQuote(calculatorData) {
|
||||||
|
const totalCost = calculatorData.services?.reduce((sum, service) => sum + (service.price || 0), 0) || 0;
|
||||||
|
|
||||||
|
const message = `💰 <b>Новый расчет стоимости</b>\n\n` +
|
||||||
|
`👤 <b>Клиент:</b> ${calculatorData.name || 'Не указан'}\n` +
|
||||||
|
`📧 <b>Email:</b> ${calculatorData.email || 'Не указан'}\n` +
|
||||||
|
`📱 <b>Телефон:</b> ${calculatorData.phone || 'Не указан'}\n\n` +
|
||||||
|
`🛠️ <b>Выбранные услуги:</b>\n${this.formatServices(calculatorData.services)}\n` +
|
||||||
|
`💵 <b>Общая стоимость:</b> $${totalCost}\n\n` +
|
||||||
|
`📅 <b>Время:</b> ${new Date().toLocaleString('ru-RU')}`;
|
||||||
|
|
||||||
|
return await this.sendMessage(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
formatContactMessage(contact) {
|
||||||
|
return `📞 <b>Уведомление о контакте</b>\n\n` +
|
||||||
|
`👤 <b>Клиент:</b> ${contact.name}\n` +
|
||||||
|
`📧 <b>Email:</b> ${contact.email}\n` +
|
||||||
|
`📱 <b>Телефон:</b> ${contact.phone || 'Не указан'}\n` +
|
||||||
|
`💼 <b>Услуга:</b> ${contact.serviceInterest || 'Общий запрос'}\n` +
|
||||||
|
`📊 <b>Статус:</b> ${this.getStatusText(contact.status)}\n` +
|
||||||
|
`⚡ <b>Приоритет:</b> ${this.getPriorityText(contact.priority)}\n\n` +
|
||||||
|
`💬 <b>Сообщение:</b>\n${contact.message}\n\n` +
|
||||||
|
`🔗 <a href="${process.env.BASE_URL || 'http://localhost:3000'}/admin/contacts/${contact.id}">Открыть в админ-панели</a>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatServices(services) {
|
||||||
|
if (!services || services.length === 0) return 'Не выбрано';
|
||||||
|
|
||||||
|
return services.map(service =>
|
||||||
|
`• ${service.name} - $${service.price || 0}`
|
||||||
|
).join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusText(status) {
|
||||||
|
const statusMap = {
|
||||||
|
'new': '🆕 Новое',
|
||||||
|
'in_progress': '⏳ В работе',
|
||||||
|
'completed': '✅ Завершено'
|
||||||
|
};
|
||||||
|
return statusMap[status] || status;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPriorityText(priority) {
|
||||||
|
const priorityMap = {
|
||||||
|
'low': '🟢 Низкий',
|
||||||
|
'medium': '🟡 Средний',
|
||||||
|
'high': '🔴 Высокий'
|
||||||
|
};
|
||||||
|
return priorityMap[priority] || priority;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test connection and update bot info
|
||||||
|
async testConnection() {
|
||||||
|
if (!this.botToken) {
|
||||||
|
return { success: false, message: 'Telegram bot token not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`${this.baseUrl}/getMe`);
|
||||||
|
this.botInfo = response.data.result;
|
||||||
|
|
||||||
|
// Also get updates to discover available chats
|
||||||
|
await this.getUpdates();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
bot: this.botInfo,
|
||||||
|
availableChats: this.getAvailableChats()
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Telegram connection test error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Webhook setup (for future use)
|
||||||
|
async setWebhook(webhookUrl) {
|
||||||
|
if (!this.isEnabled) {
|
||||||
|
return { success: false, message: 'Telegram bot not configured' };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.post(`${this.baseUrl}/setWebhook`, {
|
||||||
|
url: webhookUrl
|
||||||
|
});
|
||||||
|
return { success: true, data: response.data };
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Telegram webhook setup error:', error.response?.data || error.message);
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = new TelegramService();
|
||||||
@@ -18,14 +18,14 @@
|
|||||||
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section - Компактный -->
|
||||||
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 py-20 hero-section">
|
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 hero-section-compact">
|
||||||
<div class="absolute inset-0 bg-black opacity-50 dark:opacity-70"></div>
|
<div class="absolute inset-0 bg-black opacity-50 dark:opacity-70"></div>
|
||||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
<h1 class="text-5xl md:text-6xl font-bold text-white mb-6">
|
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||||
<%- __('about.hero.title') %>
|
<%- __('about.hero.title') %>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl text-gray-300 dark:text-gray-200 max-w-3xl mx-auto">
|
<p class="text-lg text-gray-300 dark:text-gray-200 max-w-2xl mx-auto">
|
||||||
<%- __('about.hero.subtitle') %>
|
<%- __('about.hero.subtitle') %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
664
views/admin/banner-editor.ejs
Normal file
@@ -0,0 +1,664 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Редактор Баннеров - SmartSolTech Admin</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.upload-zone {
|
||||||
|
border: 2px dashed #d1d5db;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
.upload-zone.dragover {
|
||||||
|
border-color: #3b82f6;
|
||||||
|
background-color: #eff6ff;
|
||||||
|
}
|
||||||
|
.image-preview {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
.image-preview:hover .overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.3s ease;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<!-- Admin Header -->
|
||||||
|
<header class="bg-white shadow-sm border-b">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
SmartSolTech Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
|
||||||
|
</span>
|
||||||
|
<a href="/" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
|
Посмотреть сайт
|
||||||
|
</a>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Admin Sidebar -->
|
||||||
|
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3"></i>
|
||||||
|
Панель управления
|
||||||
|
</a>
|
||||||
|
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-3"></i>
|
||||||
|
Портфолио
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-3"></i>
|
||||||
|
Услуги
|
||||||
|
</a>
|
||||||
|
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-3"></i>
|
||||||
|
Сообщения
|
||||||
|
</a>
|
||||||
|
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-images mr-3"></i>
|
||||||
|
Медиа
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-3"></i>
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fab fa-telegram mr-3"></i>
|
||||||
|
Telegram Bot
|
||||||
|
</a>
|
||||||
|
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
|
||||||
|
<i class="fas fa-paint-brush mr-3"></i>
|
||||||
|
Редактор баннеров
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-paint-brush mr-3 text-blue-600"></i>
|
||||||
|
Редактор Баннеров
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Создание и редактирование баннеров для сайта</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Controls -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">Инструменты</h3>
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<button id="refresh-images" class="px-4 py-2 bg-gray-500 text-white rounded hover:bg-gray-600 transition-colors">
|
||||||
|
<i class="fas fa-sync-alt mr-2"></i>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button id="upload-modal-btn" class="px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
Загрузить Изображения
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Banner Editor -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<!-- Banner Types Tabs -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<nav class="-mb-px flex space-x-8">
|
||||||
|
<button class="banner-tab active py-2 px-1 border-b-2 border-blue-500 font-medium text-sm text-blue-600" data-page="home">
|
||||||
|
Главная страница
|
||||||
|
</button>
|
||||||
|
<button class="banner-tab py-2 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" data-page="about">
|
||||||
|
О нас
|
||||||
|
</button>
|
||||||
|
<button class="banner-tab py-2 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" data-page="services">
|
||||||
|
Услуги
|
||||||
|
</button>
|
||||||
|
<button class="banner-tab py-2 px-1 border-b-2 border-transparent font-medium text-sm text-gray-500 hover:text-gray-700 hover:border-gray-300" data-page="portfolio">
|
||||||
|
Портфолио
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Current Banner Display -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6 mb-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||||
|
Текущий баннер: <span id="current-page">Главная страница</span>
|
||||||
|
</h2>
|
||||||
|
<div id="current-banner" class="relative">
|
||||||
|
<div class="w-full h-64 bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 rounded-lg flex items-center justify-center">
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<h3 class="text-4xl font-bold mb-2">Текущий Баннер</h3>
|
||||||
|
<p class="text-xl opacity-90">Нажмите на изображение ниже, чтобы заменить</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4 flex justify-between items-center">
|
||||||
|
<div class="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
<span id="banner-info">Используется CSS градиент</span>
|
||||||
|
</div>
|
||||||
|
<button id="remove-banner" class="px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm" style="display: none;">
|
||||||
|
<i class="fas fa-trash mr-1"></i>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Gallery -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">
|
||||||
|
Галерея изображений
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div id="loading" class="text-center py-8" style="display: none;">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500"></div>
|
||||||
|
<p class="mt-2 text-gray-600 dark:text-gray-400">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="images-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
|
||||||
|
<!-- Изображения будут загружены динамически -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="no-images" class="text-center py-8" style="display: none;">
|
||||||
|
<i class="fas fa-images text-4xl text-gray-400 mb-4"></i>
|
||||||
|
<p class="text-gray-600 dark:text-gray-400">Нет загруженных изображений</p>
|
||||||
|
<button class="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors" onclick="document.getElementById('upload-modal-btn').click()">
|
||||||
|
Загрузить первое изображение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Modal -->
|
||||||
|
<div id="upload-modal" class="fixed inset-0 bg-black bg-opacity-50 z-50 hidden">
|
||||||
|
<div class="flex items-center justify-center min-h-screen p-4">
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg max-w-2xl w-full max-h-[90vh] overflow-y-auto">
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 dark:text-white">Загрузить изображения</h3>
|
||||||
|
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Zone -->
|
||||||
|
<div id="upload-zone" class="upload-zone rounded-lg p-8 text-center mb-6">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-4xl text-gray-400 mb-2"></i>
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 mb-2">
|
||||||
|
Перетащите изображения сюда или нажмите для выбора
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Поддерживаются: JPG, PNG, GIF, WebP (максимум 10MB каждое)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file-input" multiple accept="image/*" class="hidden">
|
||||||
|
<button type="button" onclick="document.getElementById('file-input').click()" class="px-6 py-2 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors">
|
||||||
|
Выбрать файлы
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Progress -->
|
||||||
|
<div id="upload-progress" class="hidden mb-6">
|
||||||
|
<div class="bg-gray-200 rounded-full h-2 mb-2">
|
||||||
|
<div id="progress-bar" class="bg-blue-500 h-2 rounded-full transition-all duration-300" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<p id="progress-text" class="text-sm text-gray-600 dark:text-gray-400 text-center">Загрузка...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Preview Area -->
|
||||||
|
<div id="preview-area" class="grid grid-cols-2 md:grid-cols-3 gap-4 mb-6" style="display: none;">
|
||||||
|
<!-- Previews will be added here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Button -->
|
||||||
|
<div class="flex justify-end">
|
||||||
|
<button id="upload-btn" class="px-6 py-2 bg-green-500 text-white rounded hover:bg-green-600 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" disabled>
|
||||||
|
<i class="fas fa-upload mr-2"></i>
|
||||||
|
Загрузить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Scripts -->
|
||||||
|
<script>
|
||||||
|
class BannerEditor {
|
||||||
|
constructor() {
|
||||||
|
this.currentPage = 'home';
|
||||||
|
this.selectedFiles = [];
|
||||||
|
this.bannerSettings = {
|
||||||
|
home: { type: 'gradient', image: null },
|
||||||
|
about: { type: 'gradient', image: null },
|
||||||
|
services: { type: 'gradient', image: null },
|
||||||
|
portfolio: { type: 'gradient', image: null }
|
||||||
|
};
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.loadImages();
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.loadBannerSettings();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Tab switching
|
||||||
|
document.querySelectorAll('.banner-tab').forEach(tab => {
|
||||||
|
tab.addEventListener('click', (e) => {
|
||||||
|
const page = e.target.dataset.page;
|
||||||
|
this.switchPage(page);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload modal
|
||||||
|
document.getElementById('upload-modal-btn').addEventListener('click', () => {
|
||||||
|
document.getElementById('upload-modal').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('close-modal').addEventListener('click', () => {
|
||||||
|
this.closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// File upload
|
||||||
|
const fileInput = document.getElementById('file-input');
|
||||||
|
const uploadZone = document.getElementById('upload-zone');
|
||||||
|
|
||||||
|
fileInput.addEventListener('change', (e) => {
|
||||||
|
this.handleFiles(e.target.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Drag and drop
|
||||||
|
uploadZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.add('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('dragleave', () => {
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('dragover');
|
||||||
|
this.handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload button
|
||||||
|
document.getElementById('upload-btn').addEventListener('click', () => {
|
||||||
|
this.uploadFiles();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh images
|
||||||
|
document.getElementById('refresh-images').addEventListener('click', () => {
|
||||||
|
this.loadImages();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
switchPage(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
|
||||||
|
// Update active tab
|
||||||
|
document.querySelectorAll('.banner-tab').forEach(tab => {
|
||||||
|
tab.classList.remove('active', 'border-blue-500', 'text-blue-600');
|
||||||
|
tab.classList.add('border-transparent', 'text-gray-500');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.querySelector(`[data-page="${page}"]`).classList.add('active', 'border-blue-500', 'text-blue-600');
|
||||||
|
document.querySelector(`[data-page="${page}"]`).classList.remove('border-transparent', 'text-gray-500');
|
||||||
|
|
||||||
|
// Update current page display
|
||||||
|
const pageNames = {
|
||||||
|
home: 'Главная страница',
|
||||||
|
about: 'О нас',
|
||||||
|
services: 'Услуги',
|
||||||
|
portfolio: 'Портфолио'
|
||||||
|
};
|
||||||
|
|
||||||
|
document.getElementById('current-page').textContent = pageNames[page];
|
||||||
|
this.updateCurrentBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateCurrentBanner() {
|
||||||
|
const banner = this.bannerSettings[this.currentPage];
|
||||||
|
const bannerElement = document.getElementById('current-banner').querySelector('div');
|
||||||
|
const infoElement = document.getElementById('banner-info');
|
||||||
|
const removeBtn = document.getElementById('remove-banner');
|
||||||
|
|
||||||
|
if (banner.image) {
|
||||||
|
bannerElement.style.backgroundImage = `url(${banner.image})`;
|
||||||
|
bannerElement.style.backgroundSize = 'cover';
|
||||||
|
bannerElement.style.backgroundPosition = 'center';
|
||||||
|
bannerElement.innerHTML = '';
|
||||||
|
infoElement.textContent = `Изображение: ${banner.image.split('/').pop()}`;
|
||||||
|
removeBtn.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
bannerElement.style.backgroundImage = '';
|
||||||
|
bannerElement.className = 'w-full h-64 bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 rounded-lg flex items-center justify-center';
|
||||||
|
bannerElement.innerHTML = `
|
||||||
|
<div class="text-center text-white">
|
||||||
|
<h3 class="text-4xl font-bold mb-2">Текущий Баннер</h3>
|
||||||
|
<p class="text-xl opacity-90">Нажмите на изображение ниже, чтобы заменить</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
infoElement.textContent = 'Используется CSS градиент';
|
||||||
|
removeBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadImages() {
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const grid = document.getElementById('images-grid');
|
||||||
|
const noImages = document.getElementById('no-images');
|
||||||
|
|
||||||
|
loading.style.display = 'block';
|
||||||
|
grid.innerHTML = '';
|
||||||
|
noImages.style.display = 'none';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/media/list');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.images.length > 0) {
|
||||||
|
grid.innerHTML = '';
|
||||||
|
data.images.forEach(image => {
|
||||||
|
this.addImageToGrid(image);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
noImages.style.display = 'block';
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading images:', error);
|
||||||
|
this.showNotification('Ошибка загрузки изображений', 'error');
|
||||||
|
} finally {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
addImageToGrid(image) {
|
||||||
|
const grid = document.getElementById('images-grid');
|
||||||
|
const imageElement = document.createElement('div');
|
||||||
|
imageElement.className = 'image-preview bg-gray-200 rounded-lg cursor-pointer';
|
||||||
|
|
||||||
|
imageElement.innerHTML = `
|
||||||
|
<img src="${image.url}" alt="${image.filename}" class="w-full h-32 object-cover rounded-lg">
|
||||||
|
<div class="overlay">
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="use-image px-3 py-1 bg-blue-500 text-white rounded hover:bg-blue-600 transition-colors text-sm" data-url="${image.url}">
|
||||||
|
<i class="fas fa-check mr-1"></i>
|
||||||
|
Использовать
|
||||||
|
</button>
|
||||||
|
<button class="delete-image px-3 py-1 bg-red-500 text-white rounded hover:bg-red-600 transition-colors text-sm" data-filename="${image.filename}">
|
||||||
|
<i class="fas fa-trash mr-1"></i>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="p-2">
|
||||||
|
<p class="text-xs text-gray-600 truncate">${image.filename}</p>
|
||||||
|
<p class="text-xs text-gray-500">${this.formatFileSize(image.size)}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add event listeners
|
||||||
|
imageElement.querySelector('.use-image').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.setImageAsBanner(image.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
imageElement.querySelector('.delete-image').addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.deleteImage(image.filename);
|
||||||
|
});
|
||||||
|
|
||||||
|
grid.appendChild(imageElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
setImageAsBanner(imageUrl) {
|
||||||
|
this.bannerSettings[this.currentPage] = {
|
||||||
|
type: 'image',
|
||||||
|
image: imageUrl
|
||||||
|
};
|
||||||
|
this.updateCurrentBanner();
|
||||||
|
this.saveBannerSettings();
|
||||||
|
this.showNotification('Баннер обновлен!', 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteImage(filename) {
|
||||||
|
if (!confirm('Вы уверены, что хотите удалить это изображение?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/media/${filename}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.loadImages();
|
||||||
|
this.showNotification('Изображение удалено', 'success');
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete image');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting image:', error);
|
||||||
|
this.showNotification('Ошибка удаления изображения', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
handleFiles(files) {
|
||||||
|
this.selectedFiles = Array.from(files).filter(file => {
|
||||||
|
if (file.type.startsWith('image/')) {
|
||||||
|
if (file.size <= 10 * 1024 * 1024) { // 10MB
|
||||||
|
return true;
|
||||||
|
} else {
|
||||||
|
this.showNotification(`Файл ${file.name} слишком большой (максимум 10MB)`, 'error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.showNotification(`Файл ${file.name} не является изображением`, 'error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.showPreviews();
|
||||||
|
document.getElementById('upload-btn').disabled = this.selectedFiles.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
showPreviews() {
|
||||||
|
const previewArea = document.getElementById('preview-area');
|
||||||
|
previewArea.innerHTML = '';
|
||||||
|
previewArea.style.display = this.selectedFiles.length > 0 ? 'grid' : 'none';
|
||||||
|
|
||||||
|
this.selectedFiles.forEach((file, index) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = (e) => {
|
||||||
|
const preview = document.createElement('div');
|
||||||
|
preview.className = 'relative';
|
||||||
|
preview.innerHTML = `
|
||||||
|
<img src="${e.target.result}" alt="${file.name}" class="w-full h-24 object-cover rounded">
|
||||||
|
<button class="absolute top-1 right-1 bg-red-500 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs hover:bg-red-600" onclick="bannerEditor.removePreview(${index})">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
<p class="text-xs text-gray-600 mt-1 truncate">${file.name}</p>
|
||||||
|
`;
|
||||||
|
previewArea.appendChild(preview);
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
removePreview(index) {
|
||||||
|
this.selectedFiles.splice(index, 1);
|
||||||
|
this.showPreviews();
|
||||||
|
document.getElementById('upload-btn').disabled = this.selectedFiles.length === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFiles() {
|
||||||
|
if (this.selectedFiles.length === 0) return;
|
||||||
|
|
||||||
|
const progressContainer = document.getElementById('upload-progress');
|
||||||
|
const progressBar = document.getElementById('progress-bar');
|
||||||
|
const progressText = document.getElementById('progress-text');
|
||||||
|
const uploadBtn = document.getElementById('upload-btn');
|
||||||
|
|
||||||
|
progressContainer.classList.remove('hidden');
|
||||||
|
uploadBtn.disabled = true;
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
this.selectedFiles.forEach(file => {
|
||||||
|
formData.append('images', file);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percentComplete = (e.loaded / e.total) * 100;
|
||||||
|
progressBar.style.width = percentComplete + '%';
|
||||||
|
progressText.textContent = `Загрузка... ${Math.round(percentComplete)}%`;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
if (response.success) {
|
||||||
|
this.showNotification('Изображения загружены успешно!', 'success');
|
||||||
|
this.closeModal();
|
||||||
|
this.loadImages();
|
||||||
|
} else {
|
||||||
|
throw new Error(response.message);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
throw new Error('Network error');
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', '/media/upload-multiple');
|
||||||
|
xhr.send(formData);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Upload error:', error);
|
||||||
|
this.showNotification('Ошибка загрузки изображений', 'error');
|
||||||
|
} finally {
|
||||||
|
progressContainer.classList.add('hidden');
|
||||||
|
uploadBtn.disabled = false;
|
||||||
|
progressBar.style.width = '0%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
document.getElementById('upload-modal').classList.add('hidden');
|
||||||
|
this.selectedFiles = [];
|
||||||
|
document.getElementById('preview-area').style.display = 'none';
|
||||||
|
document.getElementById('upload-btn').disabled = true;
|
||||||
|
document.getElementById('upload-progress').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBannerSettings() {
|
||||||
|
const saved = localStorage.getItem('bannerSettings');
|
||||||
|
if (saved) {
|
||||||
|
this.bannerSettings = JSON.parse(saved);
|
||||||
|
}
|
||||||
|
this.updateCurrentBanner();
|
||||||
|
}
|
||||||
|
|
||||||
|
saveBannerSettings() {
|
||||||
|
localStorage.setItem('bannerSettings', JSON.stringify(this.bannerSettings));
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 Bytes';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
// Simple notification - you can enhance this with a proper notification system
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||||
|
type === 'success' ? 'bg-green-500' :
|
||||||
|
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||||
|
}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize when page loads
|
||||||
|
let bannerEditor;
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
bannerEditor = new BannerEditor();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
117
views/admin/contacts/list.ejs
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<!-- Contacts List -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-2"></i>
|
||||||
|
Управление сообщениями
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<!-- Status Filter -->
|
||||||
|
<select id="statusFilter" class="border-gray-300 rounded-md shadow-sm text-sm">
|
||||||
|
<option value="all" <%= currentStatus === 'all' ? 'selected' : '' %>>Все статусы</option>
|
||||||
|
<option value="new" <%= currentStatus === 'new' ? 'selected' : '' %>>Новые</option>
|
||||||
|
<option value="in_progress" <%= currentStatus === 'in_progress' ? 'selected' : '' %>>В работе</option>
|
||||||
|
<option value="completed" <%= currentStatus === 'completed' ? 'selected' : '' %>>Завершенные</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<ul role="list" class="divide-y divide-gray-200">
|
||||||
|
<% if (contacts && contacts.length > 0) { %>
|
||||||
|
<% contacts.forEach(contact => { %>
|
||||||
|
<li>
|
||||||
|
<a href="/admin/contacts/<%= contact.id %>" class="block hover:bg-gray-50">
|
||||||
|
<div class="px-4 py-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<% if (!contact.isRead) { %>
|
||||||
|
<div class="h-2 w-2 bg-blue-600 rounded-full"></div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="h-2 w-2"></div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4 min-w-0 flex-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
<%= contact.name %>
|
||||||
|
</p>
|
||||||
|
<% if (contact.serviceInterest) { %>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= contact.serviceInterest %>
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center mt-1">
|
||||||
|
<p class="text-sm text-gray-500 truncate">
|
||||||
|
<%= contact.email %>
|
||||||
|
</p>
|
||||||
|
<span class="mx-2 text-gray-300">•</span>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<%= new Date(contact.createdAt).toLocaleDateString('ru-RU') %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1 truncate">
|
||||||
|
<%= contact.message.substring(0, 100) %>...
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<!-- Status Badge -->
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium
|
||||||
|
<%= contact.status === 'new' ? 'bg-green-100 text-green-800' :
|
||||||
|
contact.status === 'in_progress' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-gray-100 text-gray-800' %>">
|
||||||
|
<%= contact.status === 'new' ? 'Новое' :
|
||||||
|
contact.status === 'in_progress' ? 'В работе' : 'Завершено' %>
|
||||||
|
</span>
|
||||||
|
<!-- Priority -->
|
||||||
|
<% if (contact.priority === 'high') { %>
|
||||||
|
<i class="fas fa-exclamation-triangle text-red-500"></i>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
<% } else { %>
|
||||||
|
<li>
|
||||||
|
<div class="px-4 py-8 text-center">
|
||||||
|
<i class="fas fa-envelope text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<p class="text-gray-500">Сообщения не найдены</p>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if (pagination && pagination.total > 1) { %>
|
||||||
|
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div class="flex-1 flex justify-between sm:hidden">
|
||||||
|
<% if (pagination.hasPrev) { %>
|
||||||
|
<a href="?page=<%= pagination.current - 1 %>&status=<%= currentStatus %>" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Предыдущая
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% if (pagination.hasNext) { %>
|
||||||
|
<a href="?page=<%= pagination.current + 1 %>&status=<%= currentStatus %>" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Следующая
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('statusFilter').addEventListener('change', function() {
|
||||||
|
const status = this.value;
|
||||||
|
const url = new URL(window.location);
|
||||||
|
url.searchParams.set('status', status);
|
||||||
|
url.searchParams.delete('page'); // Reset to first page
|
||||||
|
window.location.href = url.toString();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
219
views/admin/contacts/view.ejs
Normal file
@@ -0,0 +1,219 @@
|
|||||||
|
<!-- Contact Details -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-2"></i>
|
||||||
|
Детали сообщения
|
||||||
|
</h3>
|
||||||
|
<a href="/admin/contacts" class="bg-gray-300 hover:bg-gray-400 text-gray-800 font-bold py-2 px-4 rounded">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i>
|
||||||
|
Назад к списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">Информация о контакте</h4>
|
||||||
|
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Имя</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= contact.name %></dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Email</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<a href="mailto:<%= contact.email %>" class="text-blue-600 hover:text-blue-800">
|
||||||
|
<%= contact.email %>
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (contact.phone) { %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Телефон</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<a href="tel:<%= contact.phone %>" class="text-blue-600 hover:text-blue-800">
|
||||||
|
<%= contact.phone %>
|
||||||
|
</a>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Дата создания</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
|
<%= new Date(contact.createdAt).toLocaleString('ru-RU') %>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Project Details -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">Детали проекта</h4>
|
||||||
|
|
||||||
|
<dl class="space-y-3">
|
||||||
|
<% if (contact.serviceInterest) { %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Интересующая услуга</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800">
|
||||||
|
<%= contact.serviceInterest %>
|
||||||
|
</span>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (contact.budget) { %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Бюджет</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= contact.budget %></dd>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (contact.timeline) { %>
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Временные рамки</dt>
|
||||||
|
<dd class="mt-1 text-sm text-gray-900"><%= contact.timeline %></dd>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Статус</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<select id="statusSelect" data-contact-id="<%= contact.id %>"
|
||||||
|
class="border-gray-300 rounded-md shadow-sm text-sm">
|
||||||
|
<option value="new" <%= contact.status === 'new' ? 'selected' : '' %>>Новое</option>
|
||||||
|
<option value="in_progress" <%= contact.status === 'in_progress' ? 'selected' : '' %>>В работе</option>
|
||||||
|
<option value="completed" <%= contact.status === 'completed' ? 'selected' : '' %>>Завершено</option>
|
||||||
|
</select>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<dt class="text-sm font-medium text-gray-500">Приоритет</dt>
|
||||||
|
<dd class="mt-1">
|
||||||
|
<select id="prioritySelect" data-contact-id="<%= contact.id %>"
|
||||||
|
class="border-gray-300 rounded-md shadow-sm text-sm">
|
||||||
|
<option value="low" <%= contact.priority === 'low' ? 'selected' : '' %>>Низкий</option>
|
||||||
|
<option value="medium" <%= contact.priority === 'medium' ? 'selected' : '' %>>Средний</option>
|
||||||
|
<option value="high" <%= contact.priority === 'high' ? 'selected' : '' %>>Высокий</option>
|
||||||
|
</select>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message -->
|
||||||
|
<div class="mt-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-3">Сообщение</h4>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<p class="text-sm text-gray-700 whitespace-pre-wrap"><%= contact.message %></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="mt-6 flex space-x-3">
|
||||||
|
<button onclick="sendTelegramNotification('<%= contact.id %>')"
|
||||||
|
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
<i class="fab fa-telegram-plane mr-1"></i>
|
||||||
|
Отправить в Telegram
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<a href="mailto:<%= contact.email %>?subject=Re: <%= encodeURIComponent(contact.serviceInterest || 'Ваш запрос') %>"
|
||||||
|
class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
<i class="fas fa-reply mr-1"></i>
|
||||||
|
Ответить по email
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<button onclick="deleteContact('<%= contact.id %>')"
|
||||||
|
class="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
<i class="fas fa-trash mr-1"></i>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Update status
|
||||||
|
document.getElementById('statusSelect').addEventListener('change', function() {
|
||||||
|
updateContactField('status', this.value, this.dataset.contactId);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update priority
|
||||||
|
document.getElementById('prioritySelect').addEventListener('change', function() {
|
||||||
|
updateContactField('priority', this.value, this.dataset.contactId);
|
||||||
|
});
|
||||||
|
|
||||||
|
function updateContactField(field, value, contactId) {
|
||||||
|
fetch(`/api/admin/contacts/${contactId}`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ [field]: value })
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (!data.success) {
|
||||||
|
alert('Ошибка при обновлении контакта');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при обновлении контакта');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendTelegramNotification(contactId) {
|
||||||
|
fetch(`/api/admin/contacts/${contactId}/telegram`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
alert('Уведомление отправлено в Telegram');
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при отправке уведомления');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при отправке уведомления');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteContact(contactId) {
|
||||||
|
if (confirm('Вы уверены, что хотите удалить это сообщение?')) {
|
||||||
|
fetch(`/api/admin/contacts/${contactId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
window.location.href = '/admin/contacts';
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при удалении сообщения');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при удалении сообщения');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
323
views/admin/dashboard.ejs
Normal file
@@ -0,0 +1,323 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><%= title %> - SmartSolTech Admin</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<!-- Admin Header -->
|
||||||
|
<header class="bg-white shadow-sm border-b">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
SmartSolTech Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
|
||||||
|
</span>
|
||||||
|
<a href="/" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
|
Посмотреть сайт
|
||||||
|
</a>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Admin Sidebar -->
|
||||||
|
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3"></i>
|
||||||
|
Панель управления
|
||||||
|
</a>
|
||||||
|
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-3"></i>
|
||||||
|
Портфолио
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-3"></i>
|
||||||
|
Услуги
|
||||||
|
</a>
|
||||||
|
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-3"></i>
|
||||||
|
Сообщения
|
||||||
|
</a>
|
||||||
|
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-images mr-3"></i>
|
||||||
|
Медиа
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-3"></i>
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fab fa-telegram mr-3"></i>
|
||||||
|
Telegram Bot
|
||||||
|
</a>
|
||||||
|
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-paint-brush mr-3"></i>
|
||||||
|
Редактор баннеров
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3 text-blue-600"></i>
|
||||||
|
Панель управления
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Обзор основных показателей сайта</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||||
|
<!-- Portfolio Projects -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-briefcase text-blue-600 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Проекты
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= stats.portfolioCount || 0 %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="/admin/portfolio" class="font-medium text-blue-600 hover:text-blue-500">
|
||||||
|
Посмотреть всё
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-cog text-green-600 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Услуги
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= stats.servicesCount || 0 %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="/admin/services" class="font-medium text-green-600 hover:text-green-500">
|
||||||
|
Посмотреть всё
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Messages -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-envelope text-purple-600 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Сообщения
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= stats.contactsCount || 0 %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="/admin/contacts" class="font-medium text-purple-600 hover:text-purple-500">
|
||||||
|
Посмотреть всё
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users -->
|
||||||
|
<div class="bg-white overflow-hidden shadow rounded-lg">
|
||||||
|
<div class="p-5">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-users text-orange-600 text-2xl"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ml-5 w-0 flex-1">
|
||||||
|
<dl>
|
||||||
|
<dt class="text-sm font-medium text-gray-500 truncate">
|
||||||
|
Пользователи
|
||||||
|
</dt>
|
||||||
|
<dd class="text-lg font-medium text-gray-900">
|
||||||
|
<%= stats.usersCount || 0 %>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 px-5 py-3">
|
||||||
|
<div class="text-sm">
|
||||||
|
<a href="/admin/users" class="font-medium text-orange-600 hover:text-orange-500">
|
||||||
|
Посмотреть всё
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Activity -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Recent Portfolio Projects -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
|
Последние проекты
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<% if (recentPortfolio && recentPortfolio.length > 0) { %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% recentPortfolio.forEach(function(project) { %>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-briefcase text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
<%= project.title %>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
<%= project.category %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
|
||||||
|
<%= project.status %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="text-gray-500 text-sm">Нет недавних проектов</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Contact Messages -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900">
|
||||||
|
Последние сообщения
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<% if (recentContacts && recentContacts.length > 0) { %>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<% recentContacts.forEach(function(contact) { %>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<i class="fas fa-envelope text-purple-600"></i>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 truncate">
|
||||||
|
<%= contact.name %>
|
||||||
|
</p>
|
||||||
|
<p class="text-sm text-gray-500 truncate">
|
||||||
|
<%= contact.email %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<%= contact.status %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<p class="text-gray-500 text-sm">Нет недавних сообщений</p>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Actions -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-medium text-gray-900 mb-4">
|
||||||
|
Быстрые действия
|
||||||
|
</h3>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
|
<a href="/admin/portfolio/new"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
Добавить проект
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services/new"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-green-600 hover:bg-green-700">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
Добавить услугу
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-gray-600 hover:bg-gray-700">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
Настройки сайта
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
views/admin/error.ejs
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<%- include('layout', { title: title, user: user }) %>
|
||||||
|
|
||||||
|
<div class="max-w-lg mx-auto text-center">
|
||||||
|
<div class="mb-8">
|
||||||
|
<i class="fas fa-exclamation-triangle text-red-500 text-6xl mb-4"></i>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 mb-2">Ошибка</h1>
|
||||||
|
<p class="text-gray-600"><%= message %></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<a href="/admin/dashboard"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Вернуться к панели управления
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<a href="/" class="text-blue-600 hover:text-blue-500 text-sm">
|
||||||
|
Перейти на главную страницу сайта
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
104
views/admin/layout.ejs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="<%= currentLanguage %>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title><%= title %> - SmartSolTech Admin</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.admin-sidebar {
|
||||||
|
min-height: calc(100vh - 64px);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<!-- Admin Header -->
|
||||||
|
<header class="bg-white shadow-sm border-b">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
SmartSolTech Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
|
||||||
|
</span>
|
||||||
|
<a href="/" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
|
Посмотреть сайт
|
||||||
|
</a>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Admin Sidebar -->
|
||||||
|
<aside class="w-64 bg-white shadow-sm admin-sidebar">
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3"></i>
|
||||||
|
Панель управления
|
||||||
|
</a>
|
||||||
|
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-3"></i>
|
||||||
|
Портфолио
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-3"></i>
|
||||||
|
Услуги
|
||||||
|
</a>
|
||||||
|
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-3"></i>
|
||||||
|
Сообщения
|
||||||
|
</a>
|
||||||
|
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-images mr-3"></i>
|
||||||
|
Медиа
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-3"></i>
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fab fa-telegram mr-3"></i>
|
||||||
|
Telegram Bot
|
||||||
|
</a>
|
||||||
|
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-paint-brush mr-3"></i>
|
||||||
|
Редактор баннеров
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<%- body %>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
81
views/admin/login.ejs
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
о<!DOCTYPE html>
|
||||||
|
<html lang="<%= currentLanguage %>">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Вход в админ панель - SmartSolTech</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-50 flex items-center justify-center min-h-screen">
|
||||||
|
<div class="max-w-md w-full space-y-8">
|
||||||
|
<div>
|
||||||
|
<div class="mx-auto h-12 w-12 flex items-center justify-center rounded-full bg-blue-100">
|
||||||
|
<i class="fas fa-lock text-blue-600 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<h2 class="mt-6 text-center text-3xl font-extrabold text-gray-900">
|
||||||
|
Вход в админ панель
|
||||||
|
</h2>
|
||||||
|
<p class="mt-2 text-center text-sm text-gray-600">
|
||||||
|
Войдите в свой аккаунт для управления сайтом
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="mt-8 space-y-6" action="/admin/login" method="POST">
|
||||||
|
<% if (typeof error !== 'undefined') { %>
|
||||||
|
<div class="bg-red-50 border border-red-200 text-red-600 px-4 py-3 rounded">
|
||||||
|
<i class="fas fa-exclamation-triangle mr-2"></i>
|
||||||
|
<%= error %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<div class="rounded-md shadow-sm space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="email" class="block text-sm font-medium text-gray-700">
|
||||||
|
Email
|
||||||
|
</label>
|
||||||
|
<input id="email" name="email" type="email" required
|
||||||
|
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="admin@smartsoltech.com">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="password" class="block text-sm font-medium text-gray-700">
|
||||||
|
Пароль
|
||||||
|
</label>
|
||||||
|
<input id="password" name="password" type="password" required
|
||||||
|
class="mt-1 appearance-none relative block w-full px-3 py-2 border border-gray-300 placeholder-gray-500 text-gray-900 rounded-md focus:outline-none focus:ring-blue-500 focus:border-blue-500 focus:z-10 sm:text-sm"
|
||||||
|
placeholder="Введите пароль">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<button type="submit"
|
||||||
|
class="group relative w-full flex justify-center py-2 px-4 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
<span class="absolute left-0 inset-y-0 flex items-center pl-3">
|
||||||
|
<i class="fas fa-sign-in-alt text-blue-500 group-hover:text-blue-400"></i>
|
||||||
|
</span>
|
||||||
|
Войти
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-center">
|
||||||
|
<a href="/" class="text-blue-600 hover:text-blue-500 text-sm">
|
||||||
|
<i class="fas fa-arrow-left mr-1"></i>
|
||||||
|
Вернуться на сайт
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
848
views/admin/media.ejs
Normal file
@@ -0,0 +1,848 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Медиа Галерея - SmartSolTech Admin</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<!-- Admin Header -->
|
||||||
|
<header class="bg-white shadow-sm border-b">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
SmartSolTech Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
|
||||||
|
</span>
|
||||||
|
<a href="/" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
|
Посмотреть сайт
|
||||||
|
</a>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Admin Sidebar -->
|
||||||
|
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3"></i>
|
||||||
|
Панель управления
|
||||||
|
</a>
|
||||||
|
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-3"></i>
|
||||||
|
Портфолио
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-3"></i>
|
||||||
|
Услуги
|
||||||
|
</a>
|
||||||
|
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-3"></i>
|
||||||
|
Сообщения
|
||||||
|
</a>
|
||||||
|
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
|
||||||
|
<i class="fas fa-images mr-3"></i>
|
||||||
|
Медиа
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-3"></i>
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-paint-brush mr-3"></i>
|
||||||
|
Редактор баннеров
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-images mr-3 text-blue-600"></i>
|
||||||
|
Медиа Галерея
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Управление изображениями и файлами сайта</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button id="refresh-btn" class="bg-gray-500 hover:bg-gray-600 text-white px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-sync-alt mr-2"></i>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
<button id="upload-btn" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-upload mr-2"></i>
|
||||||
|
Загрузить файлы
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Zone -->
|
||||||
|
<div id="upload-zone" class="bg-white shadow rounded-lg p-8 border-2 border-dashed border-gray-300 text-center" style="display: none;">
|
||||||
|
<div class="mb-4">
|
||||||
|
<i class="fas fa-cloud-upload-alt text-6xl text-gray-400 mb-4"></i>
|
||||||
|
<p class="text-xl text-gray-600 mb-2">Перетащите файлы сюда или нажмите для выбора</p>
|
||||||
|
<p class="text-gray-500">Поддерживаются: JPG, PNG, GIF, SVG (максимум 10MB каждый)</p>
|
||||||
|
</div>
|
||||||
|
<input type="file" id="file-input" multiple accept="image/*" class="hidden">
|
||||||
|
<button type="button" onclick="document.getElementById('file-input').click()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg">
|
||||||
|
Выбрать файлы
|
||||||
|
</button>
|
||||||
|
<button id="cancel-upload" class="bg-gray-500 hover:bg-gray-600 text-white px-6 py-3 rounded-lg ml-3">
|
||||||
|
Отмена
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upload Progress -->
|
||||||
|
<div id="upload-progress" class="bg-white shadow rounded-lg p-6" style="display: none;">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">Загрузка файлов</h3>
|
||||||
|
<div class="space-y-3" id="progress-list">
|
||||||
|
<!-- Progress items will be added here -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter and Search -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex flex-col md:flex-row md:items-center md:justify-between space-y-4 md:space-y-0">
|
||||||
|
<div class="flex space-x-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Тип файла</label>
|
||||||
|
<select id="file-type-filter" class="border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
<option value="">Все типы</option>
|
||||||
|
<option value="image/jpeg">JPEG</option>
|
||||||
|
<option value="image/png">PNG</option>
|
||||||
|
<option value="image/gif">GIF</option>
|
||||||
|
<option value="image/svg+xml">SVG</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Размер</label>
|
||||||
|
<select id="size-filter" class="border border-gray-300 rounded-lg px-3 py-2">
|
||||||
|
<option value="">Любой размер</option>
|
||||||
|
<option value="small">Маленький (< 1MB)</option>
|
||||||
|
<option value="medium">Средний (1-5MB)</option>
|
||||||
|
<option value="large">Большой (> 5MB)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Поиск</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="search-input" placeholder="Поиск по имени файла..." class="border border-gray-300 rounded-lg px-3 py-2 pr-10 w-64">
|
||||||
|
<i class="fas fa-search absolute right-3 top-3 text-gray-400"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Grid -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex justify-between items-center mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">Файлы</h3>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span id="file-count" class="text-sm text-gray-600">Загрузка...</span>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button id="grid-view" class="p-2 text-gray-600 hover:text-gray-900 border border-gray-300 rounded">
|
||||||
|
<i class="fas fa-th-large"></i>
|
||||||
|
</button>
|
||||||
|
<button id="list-view" class="p-2 text-gray-600 hover:text-gray-900 border border-gray-300 rounded">
|
||||||
|
<i class="fas fa-list"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div id="loading" class="text-center py-12">
|
||||||
|
<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-blue-600"></div>
|
||||||
|
<p class="mt-2 text-gray-600">Загрузка медиа файлов...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div id="empty-state" class="text-center py-12" style="display: none;">
|
||||||
|
<i class="fas fa-images text-6xl text-gray-400 mb-4"></i>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900 mb-2">Нет загруженных файлов</h3>
|
||||||
|
<p class="text-gray-600 mb-6">Начните с загрузки ваших первых изображений</p>
|
||||||
|
<button onclick="document.getElementById('upload-btn').click()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg">
|
||||||
|
<i class="fas fa-upload mr-2"></i>
|
||||||
|
Загрузить файлы
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Grid -->
|
||||||
|
<div id="media-grid" class="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-4">
|
||||||
|
<!-- Media items will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media List -->
|
||||||
|
<div id="media-list" class="space-y-4" style="display: none;">
|
||||||
|
<!-- List items will be loaded here -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<div id="pagination" class="mt-8 flex justify-center" style="display: none;">
|
||||||
|
<nav class="flex space-x-2">
|
||||||
|
<button id="prev-page" class="px-3 py-2 bg-gray-200 text-gray-600 rounded hover:bg-gray-300 disabled:opacity-50">
|
||||||
|
<i class="fas fa-chevron-left"></i>
|
||||||
|
</button>
|
||||||
|
<div id="page-numbers" class="flex space-x-2">
|
||||||
|
<!-- Page numbers will be added here -->
|
||||||
|
</div>
|
||||||
|
<button id="next-page" class="px-3 py-2 bg-gray-200 text-gray-600 rounded hover:bg-gray-300 disabled:opacity-50">
|
||||||
|
<i class="fas fa-chevron-right"></i>
|
||||||
|
</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Media Preview Modal -->
|
||||||
|
<div id="preview-modal" class="fixed inset-0 bg-black bg-opacity-75 z-50 flex items-center justify-center" style="display: none;">
|
||||||
|
<div class="bg-white rounded-lg shadow-lg max-w-4xl max-h-[90vh] w-full mx-4 overflow-hidden">
|
||||||
|
<div class="p-4 border-b flex justify-between items-center">
|
||||||
|
<h3 id="modal-title" class="text-lg font-semibold text-gray-900">Предпросмотр файла</h3>
|
||||||
|
<button id="close-modal" class="text-gray-400 hover:text-gray-600">
|
||||||
|
<i class="fas fa-times text-xl"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="p-6">
|
||||||
|
<div class="flex flex-col lg:flex-row space-y-6 lg:space-y-0 lg:space-x-6">
|
||||||
|
<div class="flex-1">
|
||||||
|
<img id="modal-image" src="" alt="" class="w-full h-auto rounded-lg shadow">
|
||||||
|
</div>
|
||||||
|
<div class="w-full lg:w-80">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Имя файла</label>
|
||||||
|
<input id="modal-filename" type="text" class="w-full border border-gray-300 rounded-lg px-3 py-2" readonly>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">URL</label>
|
||||||
|
<div class="flex">
|
||||||
|
<input id="modal-url" type="text" class="flex-1 border border-gray-300 rounded-l-lg px-3 py-2" readonly>
|
||||||
|
<button onclick="copyToClipboard()" class="bg-gray-100 border border-l-0 border-gray-300 rounded-r-lg px-3 py-2 hover:bg-gray-200">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Размер</label>
|
||||||
|
<p id="modal-size" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Тип</label>
|
||||||
|
<p id="modal-type" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Ширина</label>
|
||||||
|
<p id="modal-width" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Высота</label>
|
||||||
|
<p id="modal-height" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Загружено</label>
|
||||||
|
<p id="modal-date" class="text-sm text-gray-600">-</p>
|
||||||
|
</div>
|
||||||
|
<div class="border-t pt-4 space-y-3">
|
||||||
|
<button onclick="downloadFile()" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-download mr-2"></i>
|
||||||
|
Скачать
|
||||||
|
</button>
|
||||||
|
<button onclick="deleteFile()" class="w-full bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg">
|
||||||
|
<i class="fas fa-trash mr-2"></i>
|
||||||
|
Удалить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
class MediaGallery {
|
||||||
|
constructor() {
|
||||||
|
this.currentFiles = [];
|
||||||
|
this.filteredFiles = [];
|
||||||
|
this.currentView = 'grid';
|
||||||
|
this.currentPage = 1;
|
||||||
|
this.itemsPerPage = 24;
|
||||||
|
this.currentFile = null;
|
||||||
|
|
||||||
|
this.init();
|
||||||
|
}
|
||||||
|
|
||||||
|
init() {
|
||||||
|
this.setupEventListeners();
|
||||||
|
this.loadMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
setupEventListeners() {
|
||||||
|
// Upload button
|
||||||
|
document.getElementById('upload-btn').addEventListener('click', () => {
|
||||||
|
this.showUploadZone();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Cancel upload
|
||||||
|
document.getElementById('cancel-upload').addEventListener('click', () => {
|
||||||
|
this.hideUploadZone();
|
||||||
|
});
|
||||||
|
|
||||||
|
// File input
|
||||||
|
document.getElementById('file-input').addEventListener('change', (e) => {
|
||||||
|
this.handleFiles(e.target.files);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Refresh button
|
||||||
|
document.getElementById('refresh-btn').addEventListener('click', () => {
|
||||||
|
this.loadMedia();
|
||||||
|
});
|
||||||
|
|
||||||
|
// View toggle
|
||||||
|
document.getElementById('grid-view').addEventListener('click', () => {
|
||||||
|
this.setView('grid');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('list-view').addEventListener('click', () => {
|
||||||
|
this.setView('list');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filters
|
||||||
|
document.getElementById('file-type-filter').addEventListener('change', () => {
|
||||||
|
this.applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('size-filter').addEventListener('change', () => {
|
||||||
|
this.applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('search-input').addEventListener('input', () => {
|
||||||
|
this.applyFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Modal
|
||||||
|
document.getElementById('close-modal').addEventListener('click', () => {
|
||||||
|
this.closeModal();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Upload zone drag and drop
|
||||||
|
const uploadZone = document.getElementById('upload-zone');
|
||||||
|
uploadZone.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.add('border-blue-500', 'bg-blue-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('dragleave', () => {
|
||||||
|
uploadZone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||||
|
});
|
||||||
|
|
||||||
|
uploadZone.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
uploadZone.classList.remove('border-blue-500', 'bg-blue-50');
|
||||||
|
this.handleFiles(e.dataTransfer.files);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadMedia() {
|
||||||
|
try {
|
||||||
|
document.getElementById('loading').style.display = 'block';
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
document.getElementById('media-grid').style.display = 'none';
|
||||||
|
|
||||||
|
const response = await fetch('/api/media/list');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
this.currentFiles = data.images || [];
|
||||||
|
this.applyFilters();
|
||||||
|
} else {
|
||||||
|
throw new Error(data.message || 'Failed to load media');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error loading media:', error);
|
||||||
|
this.showError('Ошибка загрузки медиа файлов');
|
||||||
|
} finally {
|
||||||
|
document.getElementById('loading').style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
applyFilters() {
|
||||||
|
const typeFilter = document.getElementById('file-type-filter').value;
|
||||||
|
const sizeFilter = document.getElementById('size-filter').value;
|
||||||
|
const searchQuery = document.getElementById('search-input').value.toLowerCase();
|
||||||
|
|
||||||
|
this.filteredFiles = this.currentFiles.filter(file => {
|
||||||
|
// Type filter
|
||||||
|
if (typeFilter && file.mimetype !== typeFilter) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Size filter
|
||||||
|
if (sizeFilter) {
|
||||||
|
const sizeInMB = file.size / (1024 * 1024);
|
||||||
|
if (sizeFilter === 'small' && sizeInMB >= 1) return false;
|
||||||
|
if (sizeFilter === 'medium' && (sizeInMB < 1 || sizeInMB > 5)) return false;
|
||||||
|
if (sizeFilter === 'large' && sizeInMB <= 5) return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (searchQuery && !file.filename.toLowerCase().includes(searchQuery)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.updateFileCount();
|
||||||
|
this.renderMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFileCount() {
|
||||||
|
const total = this.currentFiles.length;
|
||||||
|
const filtered = this.filteredFiles.length;
|
||||||
|
const countText = filtered === total ?
|
||||||
|
`${total} файлов` :
|
||||||
|
`${filtered} из ${total} файлов`;
|
||||||
|
|
||||||
|
document.getElementById('file-count').textContent = countText;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderMedia() {
|
||||||
|
if (this.filteredFiles.length === 0) {
|
||||||
|
document.getElementById('empty-state').style.display = 'block';
|
||||||
|
document.getElementById('media-grid').style.display = 'none';
|
||||||
|
document.getElementById('media-list').style.display = 'none';
|
||||||
|
document.getElementById('pagination').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('empty-state').style.display = 'none';
|
||||||
|
|
||||||
|
const startIndex = (this.currentPage - 1) * this.itemsPerPage;
|
||||||
|
const endIndex = startIndex + this.itemsPerPage;
|
||||||
|
const pageFiles = this.filteredFiles.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
if (this.currentView === 'grid') {
|
||||||
|
this.renderGrid(pageFiles);
|
||||||
|
} else {
|
||||||
|
this.renderList(pageFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updatePagination();
|
||||||
|
}
|
||||||
|
|
||||||
|
renderGrid(files) {
|
||||||
|
document.getElementById('media-grid').style.display = 'grid';
|
||||||
|
document.getElementById('media-list').style.display = 'none';
|
||||||
|
|
||||||
|
const grid = document.getElementById('media-grid');
|
||||||
|
grid.innerHTML = files.map(file => `
|
||||||
|
<div class="group relative bg-gray-100 rounded-lg overflow-hidden cursor-pointer hover:shadow-lg transition-shadow"
|
||||||
|
onclick="mediaGallery.openModal('${file.filename}')">
|
||||||
|
<div class="aspect-square">
|
||||||
|
<img src="${file.url}" alt="${file.filename}"
|
||||||
|
class="w-full h-full object-cover">
|
||||||
|
</div>
|
||||||
|
<div class="absolute inset-0 bg-black bg-opacity-0 group-hover:bg-opacity-25 transition-opacity flex items-center justify-center">
|
||||||
|
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button class="bg-white bg-opacity-90 text-gray-800 px-3 py-2 rounded-lg mr-2"
|
||||||
|
onclick="event.stopPropagation(); mediaGallery.downloadFile('${file.filename}')">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
<button class="bg-red-500 bg-opacity-90 text-white px-3 py-2 rounded-lg"
|
||||||
|
onclick="event.stopPropagation(); mediaGallery.deleteFile('${file.filename}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 bg-black bg-opacity-75 text-white p-2">
|
||||||
|
<p class="text-xs truncate">${file.filename}</p>
|
||||||
|
<p class="text-xs text-gray-300">${this.formatFileSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
renderList(files) {
|
||||||
|
document.getElementById('media-grid').style.display = 'none';
|
||||||
|
document.getElementById('media-list').style.display = 'block';
|
||||||
|
|
||||||
|
const list = document.getElementById('media-list');
|
||||||
|
list.innerHTML = files.map(file => `
|
||||||
|
<div class="flex items-center p-4 bg-gray-50 rounded-lg hover:bg-gray-100 cursor-pointer"
|
||||||
|
onclick="mediaGallery.openModal('${file.filename}')">
|
||||||
|
<div class="w-16 h-16 flex-shrink-0 mr-4">
|
||||||
|
<img src="${file.url}" alt="${file.filename}"
|
||||||
|
class="w-full h-full object-cover rounded">
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 truncate">${file.filename}</h4>
|
||||||
|
<p class="text-sm text-gray-500">${this.formatFileSize(file.size)} • ${file.mimetype}</p>
|
||||||
|
<p class="text-xs text-gray-400">${new Date(file.uploadedAt).toLocaleDateString('ru-RU')}</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<button class="text-blue-600 hover:text-blue-800 p-2"
|
||||||
|
onclick="event.stopPropagation(); mediaGallery.downloadFile('${file.filename}')">
|
||||||
|
<i class="fas fa-download"></i>
|
||||||
|
</button>
|
||||||
|
<button class="text-red-600 hover:text-red-800 p-2"
|
||||||
|
onclick="event.stopPropagation(); mediaGallery.deleteFile('${file.filename}')">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
setView(view) {
|
||||||
|
this.currentView = view;
|
||||||
|
|
||||||
|
// Update button states
|
||||||
|
document.getElementById('grid-view').classList.toggle('bg-blue-600', view === 'grid');
|
||||||
|
document.getElementById('grid-view').classList.toggle('text-white', view === 'grid');
|
||||||
|
document.getElementById('list-view').classList.toggle('bg-blue-600', view === 'list');
|
||||||
|
document.getElementById('list-view').classList.toggle('text-white', view === 'list');
|
||||||
|
|
||||||
|
this.renderMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
showUploadZone() {
|
||||||
|
document.getElementById('upload-zone').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
hideUploadZone() {
|
||||||
|
document.getElementById('upload-zone').style.display = 'none';
|
||||||
|
document.getElementById('file-input').value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async handleFiles(files) {
|
||||||
|
const validFiles = Array.from(files).filter(file => {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
this.showError(`${file.name} не является изображением`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
this.showError(`${file.name} слишком большой (максимум 10MB)`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length === 0) return;
|
||||||
|
|
||||||
|
this.hideUploadZone();
|
||||||
|
await this.uploadFiles(validFiles);
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFiles(files) {
|
||||||
|
const progressContainer = document.getElementById('upload-progress');
|
||||||
|
const progressList = document.getElementById('progress-list');
|
||||||
|
|
||||||
|
progressContainer.style.display = 'block';
|
||||||
|
progressList.innerHTML = '';
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const progressItem = this.createProgressItem(file);
|
||||||
|
progressList.appendChild(progressItem);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.uploadSingleFile(file, progressItem);
|
||||||
|
} catch (error) {
|
||||||
|
this.updateProgressItem(progressItem, 'error', error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
progressContainer.style.display = 'none';
|
||||||
|
this.loadMedia();
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
createProgressItem(file) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'flex items-center justify-between p-3 bg-gray-50 rounded';
|
||||||
|
div.innerHTML = `
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<i class="fas fa-image text-gray-400"></i>
|
||||||
|
<span class="text-sm text-gray-900">${file.name}</span>
|
||||||
|
<span class="text-xs text-gray-500">${this.formatFileSize(file.size)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-32 bg-gray-200 rounded-full h-2">
|
||||||
|
<div class="bg-blue-600 h-2 rounded-full progress-bar" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-600 status">0%</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateProgressItem(item, status, message = '') {
|
||||||
|
const statusElement = item.querySelector('.status');
|
||||||
|
const progressBar = item.querySelector('.progress-bar');
|
||||||
|
|
||||||
|
if (status === 'error') {
|
||||||
|
statusElement.textContent = 'Ошибка';
|
||||||
|
statusElement.className = 'text-sm text-red-600 status';
|
||||||
|
progressBar.className = 'bg-red-600 h-2 rounded-full progress-bar';
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
} else if (status === 'success') {
|
||||||
|
statusElement.textContent = 'Готово';
|
||||||
|
statusElement.className = 'text-sm text-green-600 status';
|
||||||
|
progressBar.className = 'bg-green-600 h-2 rounded-full progress-bar';
|
||||||
|
progressBar.style.width = '100%';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadSingleFile(file, progressItem) {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('images', file);
|
||||||
|
|
||||||
|
const xhr = new XMLHttpRequest();
|
||||||
|
const progressBar = progressItem.querySelector('.progress-bar');
|
||||||
|
const status = progressItem.querySelector('.status');
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
xhr.upload.addEventListener('progress', (e) => {
|
||||||
|
if (e.lengthComputable) {
|
||||||
|
const percentComplete = (e.loaded / e.total) * 100;
|
||||||
|
progressBar.style.width = percentComplete + '%';
|
||||||
|
status.textContent = Math.round(percentComplete) + '%';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('load', () => {
|
||||||
|
if (xhr.status === 200) {
|
||||||
|
const response = JSON.parse(xhr.responseText);
|
||||||
|
if (response.success) {
|
||||||
|
this.updateProgressItem(progressItem, 'success');
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error(response.message));
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
reject(new Error('Upload failed'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.addEventListener('error', () => {
|
||||||
|
reject(new Error('Network error'));
|
||||||
|
});
|
||||||
|
|
||||||
|
xhr.open('POST', '/api/media/upload-multiple');
|
||||||
|
xhr.send(formData);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
openModal(filename) {
|
||||||
|
const file = this.currentFiles.find(f => f.filename === filename);
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
this.currentFile = file;
|
||||||
|
|
||||||
|
document.getElementById('modal-title').textContent = file.filename;
|
||||||
|
document.getElementById('modal-image').src = file.url;
|
||||||
|
document.getElementById('modal-filename').value = file.filename;
|
||||||
|
document.getElementById('modal-url').value = window.location.origin + file.url;
|
||||||
|
document.getElementById('modal-size').textContent = this.formatFileSize(file.size);
|
||||||
|
document.getElementById('modal-type').textContent = file.mimetype;
|
||||||
|
document.getElementById('modal-date').textContent = new Date(file.uploadedAt).toLocaleDateString('ru-RU');
|
||||||
|
|
||||||
|
// Load image to get dimensions
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
document.getElementById('modal-width').textContent = img.width + 'px';
|
||||||
|
document.getElementById('modal-height').textContent = img.height + 'px';
|
||||||
|
};
|
||||||
|
img.src = file.url;
|
||||||
|
|
||||||
|
document.getElementById('preview-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
closeModal() {
|
||||||
|
document.getElementById('preview-modal').style.display = 'none';
|
||||||
|
this.currentFile = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteFile(filename) {
|
||||||
|
if (!confirm(`Вы уверены, что хотите удалить файл "${filename}"?`)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/media/${filename}`, {
|
||||||
|
method: 'DELETE'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
this.showSuccess('Файл удален');
|
||||||
|
this.loadMedia();
|
||||||
|
if (this.currentFile && this.currentFile.filename === filename) {
|
||||||
|
this.closeModal();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
throw new Error('Failed to delete file');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error deleting file:', error);
|
||||||
|
this.showError('Ошибка удаления файла');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadFile(filename) {
|
||||||
|
const file = filename ?
|
||||||
|
this.currentFiles.find(f => f.filename === filename) :
|
||||||
|
this.currentFile;
|
||||||
|
|
||||||
|
if (!file) return;
|
||||||
|
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.href = file.url;
|
||||||
|
link.download = file.filename;
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePagination() {
|
||||||
|
const totalPages = Math.ceil(this.filteredFiles.length / this.itemsPerPage);
|
||||||
|
|
||||||
|
if (totalPages <= 1) {
|
||||||
|
document.getElementById('pagination').style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('pagination').style.display = 'block';
|
||||||
|
|
||||||
|
// Update prev/next buttons
|
||||||
|
document.getElementById('prev-page').disabled = this.currentPage === 1;
|
||||||
|
document.getElementById('next-page').disabled = this.currentPage === totalPages;
|
||||||
|
|
||||||
|
// Update page numbers
|
||||||
|
const pageNumbers = document.getElementById('page-numbers');
|
||||||
|
pageNumbers.innerHTML = '';
|
||||||
|
|
||||||
|
for (let i = 1; i <= totalPages; i++) {
|
||||||
|
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.className = `px-3 py-2 rounded ${
|
||||||
|
i === this.currentPage ? 'bg-blue-600 text-white' : 'bg-gray-200 text-gray-600 hover:bg-gray-300'
|
||||||
|
}`;
|
||||||
|
button.textContent = i;
|
||||||
|
button.onclick = () => this.goToPage(i);
|
||||||
|
pageNumbers.appendChild(button);
|
||||||
|
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.className = 'px-2 py-2 text-gray-400';
|
||||||
|
span.textContent = '...';
|
||||||
|
pageNumbers.appendChild(span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page) {
|
||||||
|
this.currentPage = page;
|
||||||
|
this.renderMedia();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatFileSize(bytes) {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
showError(message) {
|
||||||
|
this.showNotification(message, 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
showSuccess(message) {
|
||||||
|
this.showNotification(message, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
showNotification(message, type = 'info') {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed top-4 right-4 px-6 py-3 rounded-lg text-white z-50 ${
|
||||||
|
type === 'success' ? 'bg-green-500' :
|
||||||
|
type === 'error' ? 'bg-red-500' : 'bg-blue-500'
|
||||||
|
}`;
|
||||||
|
notification.textContent = message;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.remove();
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global functions for modal
|
||||||
|
function copyToClipboard() {
|
||||||
|
const urlInput = document.getElementById('modal-url');
|
||||||
|
urlInput.select();
|
||||||
|
document.execCommand('copy');
|
||||||
|
mediaGallery.showSuccess('URL скопирован в буфер обмена');
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFile() {
|
||||||
|
mediaGallery.downloadFile();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteFile() {
|
||||||
|
if (mediaGallery.currentFile) {
|
||||||
|
mediaGallery.deleteFile(mediaGallery.currentFile.filename);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize
|
||||||
|
let mediaGallery;
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
mediaGallery = new MediaGallery();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
776
views/admin/portfolio/add.ejs
Normal file
@@ -0,0 +1,776 @@
|
|||||||
|
<!-- Add Portfolio Item -->
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-6 py-4 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-plus-circle mr-2 text-blue-600"></i>
|
||||||
|
Добавить новый проект
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Заполните информацию о проекте для добавления в портфолио</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button type="button" onclick="saveDraft()" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Сохранить черновик
|
||||||
|
</button>
|
||||||
|
<a href="/admin/portfolio" class="inline-flex items-center px-3 py-2 border border-gray-300 shadow-sm text-sm leading-4 font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
|
Назад к списку
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="portfolioForm" class="p-6">
|
||||||
|
<!-- Progress indicator -->
|
||||||
|
<div class="mb-8">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex items-center text-blue-600 relative">
|
||||||
|
<div class="rounded-full h-8 w-8 bg-blue-600 text-white flex items-center justify-center text-sm font-medium">1</div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-blue-600">Основная информация</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 mx-4 h-1 bg-gray-200"></div>
|
||||||
|
<div class="flex items-center text-gray-500">
|
||||||
|
<div class="rounded-full h-8 w-8 bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">2</div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-500">Медиа и изображения</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 mx-4 h-1 bg-gray-200"></div>
|
||||||
|
<div class="flex items-center text-gray-500">
|
||||||
|
<div class="rounded-full h-8 w-8 bg-gray-200 text-gray-600 flex items-center justify-center text-sm font-medium">3</div>
|
||||||
|
<span class="ml-3 text-sm font-medium text-gray-500">Публикация</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Шаг 1: Основная информация -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-info-circle mr-2 text-blue-600"></i>
|
||||||
|
Основная информация о проекте
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<!-- Title -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="title" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Название проекта *
|
||||||
|
<span class="text-gray-500 font-normal">(отображается в портфолио)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="title" id="title" required
|
||||||
|
placeholder="Например: Интернет-магазин электроники TechStore"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Используйте описательное название, которое четко передает суть проекта</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Short Description -->
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="shortDescription" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Краткое описание *
|
||||||
|
<span class="text-gray-500 font-normal">(1-2 предложения)</span>
|
||||||
|
</label>
|
||||||
|
<textarea name="shortDescription" id="shortDescription" rows="2" required
|
||||||
|
placeholder="Современный интернет-магазин с удобной системой каталогов и интеграцией платежных систем"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"
|
||||||
|
maxlength="200"></textarea>
|
||||||
|
<div class="mt-1 flex justify-between text-sm text-gray-500">
|
||||||
|
<span>Отображается в превью проекта</span>
|
||||||
|
<span id="shortDescCount">0/200</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Category -->
|
||||||
|
<div>
|
||||||
|
<label for="category" class="block text-sm font-medium text-gray-700 mb-2">Категория *</label>
|
||||||
|
<select name="category" id="category" required
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
<option value="">Выберите категорию</option>
|
||||||
|
<% categories.forEach(cat => { %>
|
||||||
|
<option value="<%= cat %>"><%= getCategoryName(cat) %></option>
|
||||||
|
<% }); %>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Duration -->
|
||||||
|
<div>
|
||||||
|
<label for="duration" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Длительность проекта
|
||||||
|
<span class="text-gray-500 font-normal">(в днях)</span>
|
||||||
|
</label>
|
||||||
|
<input type="number" name="duration" id="duration" min="1" max="365"
|
||||||
|
placeholder="30"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Client Name -->
|
||||||
|
<div>
|
||||||
|
<label for="clientName" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Название клиента
|
||||||
|
<span class="text-gray-500 font-normal">(необязательно)</span>
|
||||||
|
</label>
|
||||||
|
<input type="text" name="clientName" id="clientName"
|
||||||
|
placeholder="ООО «ТехноСтор»"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Demo URL -->
|
||||||
|
<div>
|
||||||
|
<label for="demoUrl" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Ссылка на демо
|
||||||
|
<span class="text-gray-500 font-normal">(живая версия)</span>
|
||||||
|
</label>
|
||||||
|
<input type="url" name="demoUrl" id="demoUrl"
|
||||||
|
placeholder="https://techstore.example.com"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- GitHub URL -->
|
||||||
|
<div>
|
||||||
|
<label for="githubUrl" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
GitHub репозиторий
|
||||||
|
<span class="text-gray-500 font-normal">(если публичный)</span>
|
||||||
|
</label>
|
||||||
|
<input type="url" name="githubUrl" id="githubUrl"
|
||||||
|
placeholder="https://github.com/username/project"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Шаг 2: Детальное описание и технологии -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-align-left mr-2 text-blue-600"></i>
|
||||||
|
Описание и технические детали
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<div class="mb-6">
|
||||||
|
<label for="description" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Подробное описание проекта *
|
||||||
|
</label>
|
||||||
|
<div id="descriptionEditor" class="min-h-40 border border-gray-300 rounded-lg"></div>
|
||||||
|
<textarea name="description" id="description" style="display: none;" required></textarea>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">Опишите задачи, решения и результаты проекта</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technologies -->
|
||||||
|
<div>
|
||||||
|
<label for="technologies" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Используемые технологии *
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="text" id="technologyInput"
|
||||||
|
placeholder="Введите технологию и нажмите Enter"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
<div class="absolute right-2 top-2">
|
||||||
|
<button type="button" onclick="addTechnology()" class="text-blue-600 hover:text-blue-800">
|
||||||
|
<i class="fas fa-plus"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="technologiesList" class="mt-3 flex flex-wrap gap-2"></div>
|
||||||
|
<input type="hidden" name="technologies" id="technologiesHidden">
|
||||||
|
|
||||||
|
<!-- Популярные технологии для быстрого добавления -->
|
||||||
|
<div class="mt-4">
|
||||||
|
<p class="text-sm text-gray-600 mb-2">Популярные технологии:</p>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<% const popularTechs = ['React', 'Vue.js', 'Node.js', 'Express.js', 'MongoDB', 'PostgreSQL', 'MySQL', 'JavaScript', 'TypeScript', 'HTML5', 'CSS3', 'Tailwind CSS', 'Bootstrap', 'Webpack', 'Docker', 'Git', 'AWS', 'Figma', 'Photoshop']; %>
|
||||||
|
<% popularTechs.forEach(tech => { %>
|
||||||
|
<button type="button" onclick="addTechnologyFromList('<%= tech %>')"
|
||||||
|
class="px-2 py-1 text-xs border border-gray-300 rounded-md text-gray-600 hover:bg-gray-100">
|
||||||
|
<%= tech %>
|
||||||
|
</button>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Шаг 3: Медиа контент -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-images mr-2 text-blue-600"></i>
|
||||||
|
Изображения и медиа контент
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Main Image Upload -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-3">
|
||||||
|
Изображения проекта
|
||||||
|
<span class="text-red-500">*</span>
|
||||||
|
</label>
|
||||||
|
<div id="dropZone" class="relative border-2 border-dashed border-gray-300 rounded-lg p-8 text-center hover:border-blue-500 transition-colors">
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="mx-auto w-16 h-16 text-gray-400">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="images" class="cursor-pointer">
|
||||||
|
<span class="text-blue-600 font-medium hover:text-blue-500">Загрузить изображения</span>
|
||||||
|
<input id="images" name="images" type="file" class="sr-only" multiple accept="image/*">
|
||||||
|
</label>
|
||||||
|
<span class="text-gray-600"> или перетащите файлы сюда</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
PNG, JPG, WEBP до 10MB каждый. Первое изображение будет использоваться как главное.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image Preview and Management -->
|
||||||
|
<div id="imagePreviewContainer" style="display: none;">
|
||||||
|
<div class="flex items-center justify-between mb-4">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900">Загруженные изображения</h5>
|
||||||
|
<button type="button" onclick="clearAllImages()" class="text-red-600 hover:text-red-800 text-sm">
|
||||||
|
<i class="fas fa-trash mr-1"></i>
|
||||||
|
Удалить все
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div id="imagePreview" class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4"></div>
|
||||||
|
<p class="mt-2 text-sm text-gray-500">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Перетаскивайте изображения для изменения порядка. Первое изображение будет главным.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Шаг 4: Настройки публикации -->
|
||||||
|
<div class="bg-gray-50 rounded-lg p-6">
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-cog mr-2 text-blue-600"></i>
|
||||||
|
Настройки публикации
|
||||||
|
</h4>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Publication Options -->
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-6">
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input id="isPublished" name="isPublished" type="checkbox"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<label for="isPublished" class="text-sm font-medium text-gray-900">Опубликовать проект</label>
|
||||||
|
<p class="text-sm text-gray-500">Проект будет виден посетителям сайта</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-start">
|
||||||
|
<div class="flex items-center h-5">
|
||||||
|
<input id="featured" name="featured" type="checkbox"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<label for="featured" class="text-sm font-medium text-gray-900">Рекомендуемый проект</label>
|
||||||
|
<p class="text-sm text-gray-500">Проект будет показан в топе портфолио</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEO Fields -->
|
||||||
|
<div class="border-t border-gray-200 pt-6">
|
||||||
|
<h5 class="text-sm font-medium text-gray-900 mb-4">SEO настройки (необязательно)</h5>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="seoTitle" class="block text-sm font-medium text-gray-700 mb-1">SEO заголовок</label>
|
||||||
|
<input type="text" name="seoTitle" id="seoTitle" maxlength="60"
|
||||||
|
placeholder="Будет использован заголовок проекта"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Рекомендуется до 60 символов</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="seoDescription" class="block text-sm font-medium text-gray-700 mb-1">SEO описание</label>
|
||||||
|
<textarea name="seoDescription" id="seoDescription" rows="2" maxlength="160"
|
||||||
|
placeholder="Будет использовано краткое описание"
|
||||||
|
class="block w-full border-gray-300 rounded-lg shadow-sm focus:ring-blue-500 focus:border-blue-500 sm:text-sm"></textarea>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Рекомендуется до 160 символов</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex justify-between items-center pt-6 border-t border-gray-200">
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button type="button" onclick="previewProject()" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-eye mr-2"></i>
|
||||||
|
Предпросмотр
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<a href="/admin/portfolio" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Отмена
|
||||||
|
</a>
|
||||||
|
<button type="button" onclick="saveDraft()" class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Сохранить как черновик
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="submitBtn" class="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
<i class="fas fa-check mr-2"></i>
|
||||||
|
Сохранить проект
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Include Rich Text Editor -->
|
||||||
|
<link href="https://cdn.quilljs.com/1.3.6/quill.snow.css" rel="stylesheet">
|
||||||
|
<script src="https://cdn.quilljs.com/1.3.6/quill.min.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let quillEditor;
|
||||||
|
let selectedTechnologies = [];
|
||||||
|
let uploadedImages = [];
|
||||||
|
|
||||||
|
// Initialize page
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
initializeRichTextEditor();
|
||||||
|
initializeImageUpload();
|
||||||
|
initializeTechnologyInput();
|
||||||
|
initializeFormValidation();
|
||||||
|
setupCharacterCounters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initialize Rich Text Editor
|
||||||
|
function initializeRichTextEditor() {
|
||||||
|
quillEditor = new Quill('#descriptionEditor', {
|
||||||
|
theme: 'snow',
|
||||||
|
placeholder: 'Опишите проект: цели, задачи, решения, результаты...',
|
||||||
|
modules: {
|
||||||
|
toolbar: [
|
||||||
|
[{ 'header': [1, 2, 3, false] }],
|
||||||
|
['bold', 'italic', 'underline', 'strike'],
|
||||||
|
['blockquote', 'code-block'],
|
||||||
|
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
|
||||||
|
[{ 'script': 'sub'}, { 'script': 'super' }],
|
||||||
|
[{ 'indent': '-1'}, { 'indent': '+1' }],
|
||||||
|
['link', 'image'],
|
||||||
|
['clean']
|
||||||
|
]
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync with hidden textarea
|
||||||
|
quillEditor.on('text-change', function() {
|
||||||
|
document.getElementById('description').value = quillEditor.root.innerHTML;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Technology management
|
||||||
|
function initializeTechnologyInput() {
|
||||||
|
const input = document.getElementById('technologyInput');
|
||||||
|
|
||||||
|
input.addEventListener('keypress', function(e) {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addTechnology();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTechnology() {
|
||||||
|
const input = document.getElementById('technologyInput');
|
||||||
|
const technology = input.value.trim();
|
||||||
|
|
||||||
|
if (technology && !selectedTechnologies.includes(technology)) {
|
||||||
|
selectedTechnologies.push(technology);
|
||||||
|
input.value = '';
|
||||||
|
updateTechnologiesList();
|
||||||
|
updateTechnologiesInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTechnologyFromList(tech) {
|
||||||
|
if (!selectedTechnologies.includes(tech)) {
|
||||||
|
selectedTechnologies.push(tech);
|
||||||
|
updateTechnologiesList();
|
||||||
|
updateTechnologiesInput();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTechnology(index) {
|
||||||
|
selectedTechnologies.splice(index, 1);
|
||||||
|
updateTechnologiesList();
|
||||||
|
updateTechnologiesInput();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTechnologiesList() {
|
||||||
|
const container = document.getElementById('technologiesList');
|
||||||
|
container.innerHTML = selectedTechnologies.map((tech, index) => `
|
||||||
|
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
|
||||||
|
${tech}
|
||||||
|
<button type="button" onclick="removeTechnology(${index})" class="ml-2 text-blue-600 hover:text-blue-800">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTechnologiesInput() {
|
||||||
|
document.getElementById('technologiesHidden').value = JSON.stringify(selectedTechnologies);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Image upload functionality
|
||||||
|
function initializeImageUpload() {
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
const fileInput = document.getElementById('images');
|
||||||
|
|
||||||
|
// Drag and drop functionality
|
||||||
|
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, preventDefaults, false);
|
||||||
|
document.body.addEventListener(eventName, preventDefaults, false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragenter', 'dragover'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => dropZone.classList.add('border-blue-500', 'bg-blue-50'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
['dragleave', 'drop'].forEach(eventName => {
|
||||||
|
dropZone.addEventListener(eventName, () => dropZone.classList.remove('border-blue-500', 'bg-blue-50'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
dropZone.addEventListener('drop', handleDrop, false);
|
||||||
|
fileInput.addEventListener('change', (e) => handleFiles(e.target.files), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preventDefaults(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDrop(e) {
|
||||||
|
const dt = e.dataTransfer;
|
||||||
|
const files = dt.files;
|
||||||
|
handleFiles(files);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFiles(files) {
|
||||||
|
const validFiles = Array.from(files).filter(file => {
|
||||||
|
if (!file.type.startsWith('image/')) {
|
||||||
|
showNotification('Можно загружать только изображения', 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (file.size > 10 * 1024 * 1024) {
|
||||||
|
showNotification(`Файл ${file.name} слишком большой (максимум 10MB)`, 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (validFiles.length === 0) return;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
showLoadingState();
|
||||||
|
|
||||||
|
for (const file of validFiles) {
|
||||||
|
await uploadSingleImage(file);
|
||||||
|
}
|
||||||
|
|
||||||
|
hideLoadingState();
|
||||||
|
updateImagePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadSingleImage(file) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onload = function(e) {
|
||||||
|
const imageData = {
|
||||||
|
file: file,
|
||||||
|
url: e.target.result,
|
||||||
|
name: file.name,
|
||||||
|
size: file.size
|
||||||
|
};
|
||||||
|
uploadedImages.push(imageData);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateImagePreview() {
|
||||||
|
const container = document.getElementById('imagePreview');
|
||||||
|
const previewContainer = document.getElementById('imagePreviewContainer');
|
||||||
|
|
||||||
|
if (uploadedImages.length === 0) {
|
||||||
|
previewContainer.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewContainer.style.display = 'block';
|
||||||
|
container.innerHTML = uploadedImages.map((img, index) => `
|
||||||
|
<div class="relative group" data-index="${index}">
|
||||||
|
<div class="aspect-w-16 aspect-h-9 rounded-lg overflow-hidden border-2 ${index === 0 ? 'border-blue-500' : 'border-gray-200'}">
|
||||||
|
<img src="${img.url}" alt="${img.name}" class="w-full h-32 object-cover">
|
||||||
|
</div>
|
||||||
|
${index === 0 ? '<div class="absolute top-2 left-2 bg-blue-600 text-white text-xs px-2 py-1 rounded">Главное</div>' : ''}
|
||||||
|
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity space-x-1">
|
||||||
|
${index > 0 ? '<button type="button" onclick="moveImage(' + index + ', -1)" class="bg-white text-gray-600 rounded-full w-6 h-6 flex items-center justify-center text-xs shadow hover:bg-gray-50" title="Переместить влево"><i class="fas fa-chevron-left"></i></button>' : ''}
|
||||||
|
${index < uploadedImages.length - 1 ? '<button type="button" onclick="moveImage(' + index + ', 1)" class="bg-white text-gray-600 rounded-full w-6 h-6 flex items-center justify-center text-xs shadow hover:bg-gray-50" title="Переместить вправо"><i class="fas fa-chevron-right"></i></button>' : ''}
|
||||||
|
<button type="button" onclick="removeImage(${index})" class="bg-red-600 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs shadow hover:bg-red-700" title="Удалить">
|
||||||
|
<i class="fas fa-times"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 text-xs text-gray-600 truncate">${img.name}</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function moveImage(index, direction) {
|
||||||
|
const newIndex = index + direction;
|
||||||
|
if (newIndex >= 0 && newIndex < uploadedImages.length) {
|
||||||
|
[uploadedImages[index], uploadedImages[newIndex]] = [uploadedImages[newIndex], uploadedImages[index]];
|
||||||
|
updateImagePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeImage(index) {
|
||||||
|
uploadedImages.splice(index, 1);
|
||||||
|
updateImagePreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearAllImages() {
|
||||||
|
if (confirm('Удалить все загруженные изображения?')) {
|
||||||
|
uploadedImages = [];
|
||||||
|
updateImagePreview();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Character counters
|
||||||
|
function setupCharacterCounters() {
|
||||||
|
const shortDescInput = document.getElementById('shortDescription');
|
||||||
|
const shortDescCounter = document.getElementById('shortDescCount');
|
||||||
|
|
||||||
|
shortDescInput.addEventListener('input', function() {
|
||||||
|
shortDescCounter.textContent = `${this.value.length}/200`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Form validation and submission
|
||||||
|
function initializeFormValidation() {
|
||||||
|
const form = document.getElementById('portfolioForm');
|
||||||
|
form.addEventListener('submit', handleFormSubmit);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFormSubmit(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
if (!validateForm()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
|
||||||
|
// Add text fields
|
||||||
|
const textFields = ['title', 'shortDescription', 'category', 'clientName', 'demoUrl', 'githubUrl', 'seoTitle', 'seoDescription', 'duration'];
|
||||||
|
textFields.forEach(field => {
|
||||||
|
const value = document.getElementById(field)?.value;
|
||||||
|
if (value) formData.append(field, value);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add description from Quill editor
|
||||||
|
formData.append('description', quillEditor.root.innerHTML);
|
||||||
|
|
||||||
|
// Add technologies
|
||||||
|
formData.append('technologies', JSON.stringify(selectedTechnologies));
|
||||||
|
|
||||||
|
// Add checkboxes
|
||||||
|
formData.append('featured', document.getElementById('featured').checked);
|
||||||
|
formData.append('isPublished', document.getElementById('isPublished').checked);
|
||||||
|
|
||||||
|
// Add images
|
||||||
|
uploadedImages.forEach((img, index) => {
|
||||||
|
formData.append('images', img.file);
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
showSubmitLoading();
|
||||||
|
|
||||||
|
const response = await fetch('/admin/portfolio/add', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
showNotification('Проект успешно создан!', 'success');
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = '/admin/portfolio';
|
||||||
|
}, 1500);
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Ошибка при создании проекта', 'error');
|
||||||
|
hideSubmitLoading();
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showNotification('Ошибка при создании проекта', 'error');
|
||||||
|
hideSubmitLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateForm() {
|
||||||
|
const errors = [];
|
||||||
|
|
||||||
|
// Required fields validation
|
||||||
|
const title = document.getElementById('title').value.trim();
|
||||||
|
const shortDescription = document.getElementById('shortDescription').value.trim();
|
||||||
|
const category = document.getElementById('category').value;
|
||||||
|
const description = quillEditor.getText().trim();
|
||||||
|
|
||||||
|
if (!title) errors.push('Введите название проекта');
|
||||||
|
if (!shortDescription) errors.push('Введите краткое описание');
|
||||||
|
if (!category) errors.push('Выберите категорию');
|
||||||
|
if (!description) errors.push('Введите подробное описание');
|
||||||
|
if (selectedTechnologies.length === 0) errors.push('Добавьте хотя бы одну технологию');
|
||||||
|
if (uploadedImages.length === 0) errors.push('Загрузите хотя бы одно изображение');
|
||||||
|
|
||||||
|
if (errors.length > 0) {
|
||||||
|
showNotification('Заполните все обязательные поля:\n' + errors.join('\n'), 'error');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save as draft
|
||||||
|
async function saveDraft() {
|
||||||
|
// Set isPublished to false
|
||||||
|
document.getElementById('isPublished').checked = false;
|
||||||
|
|
||||||
|
// Submit form
|
||||||
|
await handleFormSubmit({ preventDefault: () => {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Preview functionality
|
||||||
|
function previewProject() {
|
||||||
|
const title = document.getElementById('title').value;
|
||||||
|
const shortDescription = document.getElementById('shortDescription').value;
|
||||||
|
const category = document.getElementById('category').value;
|
||||||
|
|
||||||
|
if (!title || !shortDescription || !category) {
|
||||||
|
showNotification('Заполните основную информацию для предпросмотра', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open preview in new window/modal
|
||||||
|
const previewData = {
|
||||||
|
title,
|
||||||
|
shortDescription,
|
||||||
|
description: quillEditor.root.innerHTML,
|
||||||
|
category,
|
||||||
|
technologies: selectedTechnologies,
|
||||||
|
images: uploadedImages.map(img => img.url)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Here you can implement preview modal or new window
|
||||||
|
console.log('Preview data:', previewData);
|
||||||
|
showNotification('Функция предпросмотра будет реализована позже', 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI helpers
|
||||||
|
function showSubmitLoading() {
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Сохранение...';
|
||||||
|
btn.disabled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSubmitLoading() {
|
||||||
|
const btn = document.getElementById('submitBtn');
|
||||||
|
btn.innerHTML = '<i class="fas fa-check mr-2"></i>Сохранить проект';
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoadingState() {
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
dropZone.innerHTML = '<div class="text-center"><i class="fas fa-spinner fa-spin text-2xl text-blue-600 mb-2"></i><p class="text-blue-600">Обработка изображений...</p></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoadingState() {
|
||||||
|
const dropZone = document.getElementById('dropZone');
|
||||||
|
dropZone.innerHTML = `
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="mx-auto w-16 h-16 text-gray-400">
|
||||||
|
<svg fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="images" class="cursor-pointer">
|
||||||
|
<span class="text-blue-600 font-medium hover:text-blue-500">Загрузить изображения</span>
|
||||||
|
<input id="images" name="images" type="file" class="sr-only" multiple accept="image/*">
|
||||||
|
</label>
|
||||||
|
<span class="text-gray-600"> или перетащите файлы сюда</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
PNG, JPG, WEBP до 10MB каждый. Первое изображение будет использоваться как главное.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Re-initialize file input
|
||||||
|
document.getElementById('images').addEventListener('change', (e) => handleFiles(e.target.files), false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
// Create notification
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed top-4 right-4 z-50 px-6 py-4 rounded-lg shadow-lg text-white max-w-md transform transition-all duration-300 translate-x-full`;
|
||||||
|
|
||||||
|
switch(type) {
|
||||||
|
case 'success':
|
||||||
|
notification.classList.add('bg-green-600');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
notification.classList.add('bg-red-600');
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
notification.classList.add('bg-yellow-600');
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
notification.classList.add('bg-blue-600');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="flex items-start">
|
||||||
|
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : type === 'warning' ? 'fa-exclamation-triangle' : 'fa-info-circle'} mr-3 mt-0.5"></i>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium">${type.charAt(0).toUpperCase() + type.slice(1)}</div>
|
||||||
|
<div class="mt-1 text-sm opacity-90 whitespace-pre-line">${message}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Show notification
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('translate-x-full');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Hide notification
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.body.contains(notification)) {
|
||||||
|
document.body.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
358
views/admin/portfolio/list.ejs
Normal file
@@ -0,0 +1,358 @@
|
|||||||
|
<!-- Portfolio List -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-2"></i>
|
||||||
|
Управление портфолио
|
||||||
|
</h3>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Всего проектов: <%= portfolio ? portfolio.length : 0 %>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<div class="flex rounded-md shadow-sm">
|
||||||
|
<input type="text" id="searchInput" placeholder="Поиск проектов..."
|
||||||
|
class="block w-full rounded-l-md border-gray-300 focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm">
|
||||||
|
<button type="button" onclick="searchProjects()"
|
||||||
|
class="relative -ml-px inline-flex items-center px-3 py-2 rounded-r-md border border-l-0 border-gray-300 bg-gray-50 text-gray-500 hover:bg-gray-100 focus:outline-none focus:ring-1 focus:ring-indigo-500 focus:border-indigo-500">
|
||||||
|
<i class="fas fa-search"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<select id="categoryFilter" onchange="filterByCategory()" class="rounded-md border-gray-300 py-2 pl-3 pr-10 text-base focus:border-indigo-500 focus:outline-none focus:ring-indigo-500 sm:text-sm">
|
||||||
|
<option value="">Все категории</option>
|
||||||
|
<option value="web-development">Веб-разработка</option>
|
||||||
|
<option value="mobile-app">Мобильное приложение</option>
|
||||||
|
<option value="ui-ux-design">UI/UX дизайн</option>
|
||||||
|
<option value="e-commerce">E-commerce</option>
|
||||||
|
<option value="enterprise">Корпоративное</option>
|
||||||
|
<option value="other">Другое</option>
|
||||||
|
</select>
|
||||||
|
<a href="/admin/portfolio/add" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium inline-flex items-center">
|
||||||
|
<i class="fas fa-plus mr-2"></i>
|
||||||
|
Добавить проект
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<ul role="list" class="divide-y divide-gray-200">
|
||||||
|
<% if (portfolio && portfolio.length > 0) { %>
|
||||||
|
<% portfolio.forEach(item => { %>
|
||||||
|
<li class="portfolio-item" data-category="<%= item.category %>" data-title="<%= item.title.toLowerCase() %>">
|
||||||
|
<div class="px-4 py-4">
|
||||||
|
<div class="flex items-start justify-between">
|
||||||
|
<div class="flex items-start space-x-4">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<% if (item.images && item.images.length > 0) { %>
|
||||||
|
<img class="h-16 w-16 rounded-lg object-cover border border-gray-200" src="<%= item.images[0].url %>" alt="<%= item.title %>">
|
||||||
|
<% } else { %>
|
||||||
|
<div class="h-16 w-16 rounded-lg bg-gray-100 border border-gray-200 flex items-center justify-center">
|
||||||
|
<i class="fas fa-image text-gray-400 text-xl"></i>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h4 class="text-base font-medium text-gray-900 truncate"><%= item.title %></h4>
|
||||||
|
<div class="ml-3 flex items-center space-x-2">
|
||||||
|
<% if (item.featured) { %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<i class="fas fa-star mr-1"></i>
|
||||||
|
Рекомендуемое
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= item.isPublished ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800' %>">
|
||||||
|
<i class="fas <%= item.isPublished ? 'fa-check-circle' : 'fa-clock' %> mr-1"></i>
|
||||||
|
<%= item.isPublished ? 'Опубликовано' : 'Черновик' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-600 line-clamp-2"><%= item.shortDescription || 'Описание не указано' %></p>
|
||||||
|
<div class="mt-2 flex items-center text-sm text-gray-500 space-x-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-folder mr-1"></i>
|
||||||
|
<span class="capitalize"><%= item.category.replace('-', ' ') %></span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calendar mr-1"></i>
|
||||||
|
<%= new Date(item.createdAt).toLocaleDateString('ru-RU') %>
|
||||||
|
</div>
|
||||||
|
<% if (item.viewCount && item.viewCount > 0) { %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-eye mr-1"></i>
|
||||||
|
<%= item.viewCount %> просмотров
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
<% if (item.technologies && item.technologies.length > 0) { %>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-code mr-1"></i>
|
||||||
|
<%= item.technologies.slice(0, 2).join(', ') %><%= item.technologies.length > 2 ? '...' : '' %>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-1 ml-4">
|
||||||
|
<% if (item.isPublished) { %>
|
||||||
|
<a href="/portfolio/<%= item.id %>" target="_blank"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-blue-600 hover:bg-blue-50 transition-colors">
|
||||||
|
<i class="fas fa-external-link-alt text-sm"></i>
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<button onclick="togglePublish('<%= item.id %>', '<%= item.isPublished %>')"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-green-600 hover:bg-green-50 transition-colors"
|
||||||
|
title="<%= item.isPublished ? 'Скрыть' : 'Опубликовать' %>">
|
||||||
|
<i class="fas <%= item.isPublished ? 'fa-eye-slash' : 'fa-eye' %> text-sm"></i>
|
||||||
|
</button>
|
||||||
|
<a href="/admin/portfolio/edit/<%= item.id %>"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
|
||||||
|
title="Редактировать">
|
||||||
|
<i class="fas fa-edit text-sm"></i>
|
||||||
|
</a>
|
||||||
|
<button onclick="duplicatePortfolio('<%= item.id %>')"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-yellow-600 hover:bg-yellow-50 transition-colors"
|
||||||
|
title="Дублировать">
|
||||||
|
<i class="fas fa-copy text-sm"></i>
|
||||||
|
</button>
|
||||||
|
<button onclick="deletePortfolio('<%= item.id %>', '<%= item.title %>')"
|
||||||
|
class="inline-flex items-center justify-center w-8 h-8 rounded-md text-gray-400 hover:text-red-600 hover:bg-red-50 transition-colors"
|
||||||
|
title="Удалить">
|
||||||
|
<i class="fas fa-trash text-sm"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
<% } else { %>
|
||||||
|
<li>
|
||||||
|
<div class="px-4 py-8 text-center">
|
||||||
|
<i class="fas fa-briefcase text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<p class="text-gray-500">Проекты не найдены</p>
|
||||||
|
<a href="/admin/portfolio/add" class="mt-2 inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
Добавить первый проект
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if (pagination && pagination.total > 1) { %>
|
||||||
|
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div class="flex-1 flex justify-between sm:hidden">
|
||||||
|
<% if (pagination.hasPrev) { %>
|
||||||
|
<a href="?page=<%= pagination.current - 1 %>" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Предыдущая
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% if (pagination.hasNext) { %>
|
||||||
|
<a href="?page=<%= pagination.current + 1 %>" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Следующая
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function deletePortfolio(id, title) {
|
||||||
|
if (confirm(`Вы уверены, что хотите удалить проект "${title}"?\n\nЭто действие нельзя отменить.`)) {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
const originalContent = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin text-sm"></i>';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
fetch(`/admin/portfolio/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showNotification('Проект успешно удален', 'success');
|
||||||
|
// Плавное удаление элемента
|
||||||
|
const listItem = button.closest('li');
|
||||||
|
listItem.style.opacity = '0.5';
|
||||||
|
listItem.style.transform = 'scale(0.95)';
|
||||||
|
setTimeout(() => {
|
||||||
|
listItem.remove();
|
||||||
|
updateProjectCount();
|
||||||
|
}, 300);
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Ошибка при удалении проекта', 'error');
|
||||||
|
button.innerHTML = originalContent;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showNotification('Ошибка при удалении проекта', 'error');
|
||||||
|
button.innerHTML = originalContent;
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePublish(id, currentStatus) {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
const originalContent = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin text-sm"></i>';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
fetch(`/admin/portfolio/${id}/toggle-publish`, {
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
showNotification(data.message, 'success');
|
||||||
|
// Обновляем интерфейс
|
||||||
|
const listItem = button.closest('li');
|
||||||
|
const statusSpan = listItem.querySelector('.inline-flex');
|
||||||
|
const newStatus = data.isPublished;
|
||||||
|
|
||||||
|
// Обновляем иконку кнопки
|
||||||
|
button.innerHTML = `<i class="fas ${newStatus ? 'fa-eye-slash' : 'fa-eye'} text-sm"></i>`;
|
||||||
|
button.title = newStatus ? 'Скрыть' : 'Опубликовать';
|
||||||
|
|
||||||
|
// Обновляем статус
|
||||||
|
statusSpan.className = `inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${newStatus ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-800'}`;
|
||||||
|
statusSpan.innerHTML = `<i class="fas ${newStatus ? 'fa-check-circle' : 'fa-clock'} mr-1"></i>${newStatus ? 'Опубликовано' : 'Черновик'}`;
|
||||||
|
|
||||||
|
button.disabled = false;
|
||||||
|
} else {
|
||||||
|
showNotification(data.message || 'Ошибка при изменении статуса', 'error');
|
||||||
|
button.innerHTML = originalContent;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
showNotification('Ошибка при изменении статуса', 'error');
|
||||||
|
button.innerHTML = originalContent;
|
||||||
|
button.disabled = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function duplicatePortfolio(id) {
|
||||||
|
if (confirm('Создать копию этого проекта?')) {
|
||||||
|
const button = event.target.closest('button');
|
||||||
|
const originalContent = button.innerHTML;
|
||||||
|
button.innerHTML = '<i class="fas fa-spinner fa-spin text-sm"></i>';
|
||||||
|
button.disabled = true;
|
||||||
|
|
||||||
|
// Здесь можно добавить API для дублирования
|
||||||
|
showNotification('Функция дублирования будет добавлена позже', 'info');
|
||||||
|
button.innerHTML = originalContent;
|
||||||
|
button.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function searchProjects() {
|
||||||
|
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
|
||||||
|
const items = document.querySelectorAll('.portfolio-item');
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const title = item.dataset.title;
|
||||||
|
if (title.includes(searchTerm)) {
|
||||||
|
item.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateProjectCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterByCategory() {
|
||||||
|
const selectedCategory = document.getElementById('categoryFilter').value;
|
||||||
|
const items = document.querySelectorAll('.portfolio-item');
|
||||||
|
|
||||||
|
items.forEach(item => {
|
||||||
|
const category = item.dataset.category;
|
||||||
|
if (!selectedCategory || category === selectedCategory) {
|
||||||
|
item.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
item.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateProjectCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateProjectCount() {
|
||||||
|
const visibleItems = document.querySelectorAll('.portfolio-item[style="display: block"], .portfolio-item:not([style*="display: none"])').length;
|
||||||
|
const totalItems = document.querySelectorAll('.portfolio-item').length;
|
||||||
|
const countElement = document.querySelector('h3 + p');
|
||||||
|
if (countElement) {
|
||||||
|
countElement.textContent = `Показано проектов: ${visibleItems} из ${totalItems}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
// Создаем уведомление
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-md shadow-lg text-white max-w-sm transform transition-all duration-300 translate-x-full`;
|
||||||
|
|
||||||
|
switch(type) {
|
||||||
|
case 'success':
|
||||||
|
notification.classList.add('bg-green-600');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
notification.classList.add('bg-red-600');
|
||||||
|
break;
|
||||||
|
case 'info':
|
||||||
|
notification.classList.add('bg-blue-600');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
notification.classList.add('bg-gray-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : 'fa-info-circle'} mr-2"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
// Показываем уведомление
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('translate-x-full');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
// Скрываем уведомление через 3 секунды
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
document.body.removeChild(notification);
|
||||||
|
}, 300);
|
||||||
|
}, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Обработчик для поиска по Enter
|
||||||
|
document.getElementById('searchInput').addEventListener('keyup', function(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
searchProjects();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Инициализация
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
updateProjectCount();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
121
views/admin/services/list.ejs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<!-- Services List -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-2"></i>
|
||||||
|
Управление услугами
|
||||||
|
</h3>
|
||||||
|
<a href="/admin/services/add" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
<i class="fas fa-plus mr-1"></i>
|
||||||
|
Добавить услугу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow overflow-hidden sm:rounded-md">
|
||||||
|
<ul role="list" class="divide-y divide-gray-200">
|
||||||
|
<% if (services && services.length > 0) { %>
|
||||||
|
<% services.forEach(service => { %>
|
||||||
|
<li>
|
||||||
|
<div class="px-4 py-4 flex items-center justify-between">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="flex-shrink-0 h-10 w-10">
|
||||||
|
<div class="h-10 w-10 rounded-full bg-blue-100 flex items-center justify-center">
|
||||||
|
<i class="<%= service.icon || 'fas fa-cog' %> text-blue-600"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ml-4">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="text-sm font-medium text-gray-900"><%= service.name %></div>
|
||||||
|
<% if (service.featured) { %>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800">
|
||||||
|
<i class="fas fa-star mr-1"></i>
|
||||||
|
Рекомендуемая
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
<% if (!service.isActive) { %>
|
||||||
|
<span class="ml-2 inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
Неактивна
|
||||||
|
</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<%= service.category %> •
|
||||||
|
<% if (service.pricing && service.pricing.basePrice) { %>
|
||||||
|
от $<%= service.pricing.basePrice %>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-2">
|
||||||
|
<a href="/services#<%= service.id %>" target="_blank" class="text-blue-600 hover:text-blue-900">
|
||||||
|
<i class="fas fa-external-link-alt"></i>
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services/edit/<%= service.id %>" class="text-indigo-600 hover:text-indigo-900">
|
||||||
|
<i class="fas fa-edit"></i>
|
||||||
|
</a>
|
||||||
|
<button onclick="deleteService('<%= service.id %>')" class="text-red-600 hover:text-red-900">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% }) %>
|
||||||
|
<% } else { %>
|
||||||
|
<li>
|
||||||
|
<div class="px-4 py-8 text-center">
|
||||||
|
<i class="fas fa-cog text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<p class="text-gray-500">Услуги не найдены</p>
|
||||||
|
<a href="/admin/services/add" class="mt-2 inline-block bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
Добавить первую услугу
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
<% } %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Pagination -->
|
||||||
|
<% if (pagination && pagination.total > 1) { %>
|
||||||
|
<div class="bg-white px-4 py-3 flex items-center justify-between border-t border-gray-200 sm:px-6">
|
||||||
|
<div class="flex-1 flex justify-between sm:hidden">
|
||||||
|
<% if (pagination.hasPrev) { %>
|
||||||
|
<a href="?page=<%= pagination.current - 1 %>" class="relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Предыдущая
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% if (pagination.hasNext) { %>
|
||||||
|
<a href="?page=<%= pagination.current + 1 %>" class="ml-3 relative inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
Следующая
|
||||||
|
</a>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function deleteService(id) {
|
||||||
|
if (confirm('Вы уверены, что хотите удалить эту услугу?')) {
|
||||||
|
fetch(`/api/admin/services/${id}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.then(response => response.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.success) {
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при удалении услуги');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при удалении услуги');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
350
views/admin/settings.ejs
Normal file
@@ -0,0 +1,350 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Настройки сайта - SmartSolTech Admin</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<!-- Admin Header -->
|
||||||
|
<header class="bg-white shadow-sm border-b">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
SmartSolTech Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
|
||||||
|
</span>
|
||||||
|
<a href="/" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
|
Посмотреть сайт
|
||||||
|
</a>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Admin Sidebar -->
|
||||||
|
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3"></i>
|
||||||
|
Панель управления
|
||||||
|
</a>
|
||||||
|
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-3"></i>
|
||||||
|
Портфолио
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-3"></i>
|
||||||
|
Услуги
|
||||||
|
</a>
|
||||||
|
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-3"></i>
|
||||||
|
Сообщения
|
||||||
|
</a>
|
||||||
|
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-images mr-3"></i>
|
||||||
|
Медиа
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
|
||||||
|
<i class="fas fa-cogs mr-3"></i>
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fab fa-telegram mr-3"></i>
|
||||||
|
Telegram Bot
|
||||||
|
</a>
|
||||||
|
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-paint-brush mr-3"></i>
|
||||||
|
Редактор баннеров
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||||
|
<i class="fas fa-cogs mr-3 text-blue-600"></i>
|
||||||
|
Настройки сайта
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Управление основными параметрами сайта</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Site Settings -->
|
||||||
|
<div class="bg-white shadow rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:px-6 border-b border-gray-200">
|
||||||
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
Настройки сайта
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form id="settingsForm" class="p-6">
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Basic Settings -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">Основные настройки</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="siteName" class="block text-sm font-medium text-gray-700">Название сайта</label>
|
||||||
|
<input type="text" name="siteName" id="siteName"
|
||||||
|
value="<%= settings.siteName || 'SmartSolTech' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="siteDescription" class="block text-sm font-medium text-gray-700">Описание сайта</label>
|
||||||
|
<textarea name="siteDescription" id="siteDescription" rows="3"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"><%= settings.siteDescription || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="logo" class="block text-sm font-medium text-gray-700">Логотип</label>
|
||||||
|
<input type="file" name="logo" id="logo" accept="image/*"
|
||||||
|
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||||
|
<% if (settings.logo) { %>
|
||||||
|
<img src="<%= settings.logo %>" alt="Current logo" class="mt-2 h-16 w-auto">
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="favicon" class="block text-sm font-medium text-gray-700">Favicon</label>
|
||||||
|
<input type="file" name="favicon" id="favicon" accept="image/*"
|
||||||
|
class="mt-1 block w-full text-sm text-gray-500 file:mr-4 file:py-2 file:px-4 file:rounded-full file:border-0 file:text-sm file:font-semibold file:bg-blue-50 file:text-blue-700 hover:file:bg-blue-100">
|
||||||
|
<% if (settings.favicon) { %>
|
||||||
|
<img src="<%= settings.favicon %>" alt="Current favicon" class="mt-2 h-8 w-8">
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Information -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">Контактная информация</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="contactEmail" class="block text-sm font-medium text-gray-700">Email</label>
|
||||||
|
<input type="email" name="contact.email" id="contactEmail"
|
||||||
|
value="<%= settings.contact?.email || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="contactPhone" class="block text-sm font-medium text-gray-700">Телефон</label>
|
||||||
|
<input type="tel" name="contact.phone" id="contactPhone"
|
||||||
|
value="<%= settings.contact?.phone || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<label for="contactAddress" class="block text-sm font-medium text-gray-700">Адрес</label>
|
||||||
|
<textarea name="contact.address" id="contactAddress" rows="2"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"><%= settings.contact?.address || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Media -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">Социальные сети</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="socialFacebook" class="block text-sm font-medium text-gray-700">Facebook</label>
|
||||||
|
<input type="url" name="social.facebook" id="socialFacebook"
|
||||||
|
value="<%= settings.social?.facebook || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="socialTwitter" class="block text-sm font-medium text-gray-700">Twitter</label>
|
||||||
|
<input type="url" name="social.twitter" id="socialTwitter"
|
||||||
|
value="<%= settings.social?.twitter || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="socialInstagram" class="block text-sm font-medium text-gray-700">Instagram</label>
|
||||||
|
<input type="url" name="social.instagram" id="socialInstagram"
|
||||||
|
value="<%= settings.social?.instagram || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="socialLinkedin" class="block text-sm font-medium text-gray-700">LinkedIn</label>
|
||||||
|
<input type="url" name="social.linkedin" id="socialLinkedin"
|
||||||
|
value="<%= settings.social?.linkedin || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Telegram Bot Settings -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">Telegram Bot</h4>
|
||||||
|
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<label for="telegramBotToken" class="block text-sm font-medium text-gray-700">Bot Token</label>
|
||||||
|
<input type="text" name="telegram.botToken" id="telegramBotToken"
|
||||||
|
value="<%= settings.telegram?.botToken || '' %>"
|
||||||
|
placeholder="123456789:AABBccDDeeFFggHHiiJJkkLLmmNNooP"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">Получите токен у @BotFather</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="telegramChatId" class="block text-sm font-medium text-gray-700">Chat ID</label>
|
||||||
|
<input type="text" name="telegram.chatId" id="telegramChatId"
|
||||||
|
value="<%= settings.telegram?.chatId || '' %>"
|
||||||
|
placeholder="-123456789"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">ID чата для уведомлений</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sm:col-span-2">
|
||||||
|
<button type="button" id="testTelegram" class="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md text-sm font-medium">
|
||||||
|
<i class="fab fa-telegram-plane mr-1"></i>
|
||||||
|
Проверить соединение
|
||||||
|
</button>
|
||||||
|
<div id="telegramStatus" class="mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- SEO Settings -->
|
||||||
|
<div>
|
||||||
|
<h4 class="text-lg font-medium text-gray-900 mb-4">SEO настройки</h4>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="seoTitle" class="block text-sm font-medium text-gray-700">Meta Title</label>
|
||||||
|
<input type="text" name="seo.title" id="seoTitle"
|
||||||
|
value="<%= settings.seo?.title || '' %>"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="seoDescription" class="block text-sm font-medium text-gray-700">Meta Description</label>
|
||||||
|
<textarea name="seo.description" id="seoDescription" rows="3"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500"><%= settings.seo?.description || '' %></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="seoKeywords" class="block text-sm font-medium text-gray-700">Keywords</label>
|
||||||
|
<input type="text" name="seo.keywords" id="seoKeywords"
|
||||||
|
value="<%= settings.seo?.keywords || '' %>"
|
||||||
|
placeholder="веб-разработка, мобильные приложения, дизайн"
|
||||||
|
class="mt-1 block w-full border-gray-300 rounded-md shadow-sm focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Button -->
|
||||||
|
<div class="mt-8 flex justify-end">
|
||||||
|
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded">
|
||||||
|
<i class="fas fa-save mr-1"></i>
|
||||||
|
Сохранить настройки
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.getElementById('settingsForm').addEventListener('submit', async function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(this);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
alert('Настройки успешно сохранены');
|
||||||
|
location.reload();
|
||||||
|
} else {
|
||||||
|
alert('Ошибка при сохранении настроек: ' + data.message);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error:', error);
|
||||||
|
alert('Ошибка при сохранении настроек');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Test Telegram connection
|
||||||
|
document.getElementById('testTelegram').addEventListener('click', async function() {
|
||||||
|
const botToken = document.getElementById('telegramBotToken').value;
|
||||||
|
const chatId = document.getElementById('telegramChatId').value;
|
||||||
|
const statusDiv = document.getElementById('telegramStatus');
|
||||||
|
|
||||||
|
if (!botToken || !chatId) {
|
||||||
|
statusDiv.innerHTML = '<p class="text-red-600">Пожалуйста, заполните Bot Token и Chat ID</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disabled = true;
|
||||||
|
this.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i> Проверка...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/admin/telegram/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({ botToken, chatId })
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success) {
|
||||||
|
statusDiv.innerHTML = '<p class="text-green-600"><i class="fas fa-check mr-1"></i> Соединение установлено успешно!</p>';
|
||||||
|
} else {
|
||||||
|
statusDiv.innerHTML = `<p class="text-red-600"><i class="fas fa-times mr-1"></i> Ошибка: ${data.message}</p>`;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
statusDiv.innerHTML = '<p class="text-red-600"><i class="fas fa-times mr-1"></i> Ошибка соединения</p>';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.disabled = false;
|
||||||
|
this.innerHTML = '<i class="fab fa-telegram-plane mr-1"></i> Проверить соединение';
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
885
views/admin/telegram.ejs
Normal file
@@ -0,0 +1,885 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Telegram Bot - SmartSolTech Admin</title>
|
||||||
|
|
||||||
|
<!-- Tailwind CSS -->
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link rel="stylesheet" href="/css/main.css">
|
||||||
|
<link rel="stylesheet" href="/css/fixes.css">
|
||||||
|
</head>
|
||||||
|
<body class="bg-gray-100">
|
||||||
|
<!-- Admin Header -->
|
||||||
|
<header class="bg-white shadow-sm border-b">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex justify-between items-center h-16">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<h1 class="text-xl font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-2"></i>
|
||||||
|
SmartSolTech Admin
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-4">
|
||||||
|
<span class="text-sm text-gray-600">
|
||||||
|
Добро пожаловать, <%= user ? user.name : 'Admin' %>!
|
||||||
|
</span>
|
||||||
|
<a href="/" class="text-gray-500 hover:text-gray-700">
|
||||||
|
<i class="fas fa-external-link-alt mr-1"></i>
|
||||||
|
Посмотреть сайт
|
||||||
|
</a>
|
||||||
|
<form action="/admin/logout" method="post" class="inline">
|
||||||
|
<button type="submit" class="text-red-600 hover:text-red-800">
|
||||||
|
<i class="fas fa-sign-out-alt mr-1"></i>
|
||||||
|
Выход
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex">
|
||||||
|
<!-- Admin Sidebar -->
|
||||||
|
<aside class="w-64 bg-white shadow-sm admin-sidebar min-h-screen">
|
||||||
|
<nav class="mt-5 px-2">
|
||||||
|
<div class="space-y-1">
|
||||||
|
<a href="/admin/dashboard" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-tachometer-alt mr-3"></i>
|
||||||
|
Панель управления
|
||||||
|
</a>
|
||||||
|
<a href="/admin/portfolio" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-briefcase mr-3"></i>
|
||||||
|
Портфолио
|
||||||
|
</a>
|
||||||
|
<a href="/admin/services" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cog mr-3"></i>
|
||||||
|
Услуги
|
||||||
|
</a>
|
||||||
|
<a href="/admin/contacts" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-envelope mr-3"></i>
|
||||||
|
Сообщения
|
||||||
|
</a>
|
||||||
|
<a href="/admin/media" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-images mr-3"></i>
|
||||||
|
Медиа
|
||||||
|
</a>
|
||||||
|
<a href="/admin/settings" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-cogs mr-3"></i>
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
<a href="/admin/telegram" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md bg-blue-100 text-blue-700">
|
||||||
|
<i class="fab fa-telegram mr-3"></i>
|
||||||
|
Telegram Bot
|
||||||
|
</a>
|
||||||
|
<a href="/admin/banner-editor" class="group flex items-center px-2 py-2 text-sm font-medium rounded-md text-gray-600 hover:bg-gray-50 hover:text-gray-900">
|
||||||
|
<i class="fas fa-paint-brush mr-3"></i>
|
||||||
|
Редактор баннеров
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<main class="flex-1 p-8">
|
||||||
|
<div class="space-y-6">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900 flex items-center">
|
||||||
|
<i class="fab fa-telegram mr-3 text-blue-500"></i>
|
||||||
|
Telegram Bot
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-gray-600">Настройка и управление уведомлениями через Telegram</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<div class="w-3 h-3 rounded-full <%= botConfigured ? 'bg-green-500' : 'bg-red-500' %> mr-2"></div>
|
||||||
|
<span class="text-sm font-medium <%= botConfigured ? 'text-green-700' : 'text-red-700' %>">
|
||||||
|
<%= botConfigured ? 'Подключен' : 'Не настроен' %>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bot Configuration -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-cog mr-2 text-blue-500"></i>
|
||||||
|
Конфигурация бота
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form id="bot-config-form" class="space-y-6">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Bot Token -->
|
||||||
|
<div>
|
||||||
|
<label for="botToken" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Токен бота *
|
||||||
|
</label>
|
||||||
|
<div class="relative">
|
||||||
|
<input type="password" id="botToken" name="botToken"
|
||||||
|
value="<%= botToken %>"
|
||||||
|
placeholder="1234567890:ABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||||
|
class="block w-full pr-10 border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<button type="button" onclick="toggleTokenVisibility()"
|
||||||
|
class="absolute inset-y-0 right-0 pr-3 flex items-center">
|
||||||
|
<i id="token-eye" class="fas fa-eye text-gray-400"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Получите токен от <a href="https://t.me/BotFather" target="_blank" class="text-blue-600 underline">@BotFather</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Default Chat ID -->
|
||||||
|
<div>
|
||||||
|
<label for="chatId" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
ID чата по умолчанию
|
||||||
|
</label>
|
||||||
|
<input type="text" id="chatId" name="chatId"
|
||||||
|
value="<%= chatId %>"
|
||||||
|
placeholder="-1001234567890"
|
||||||
|
class="block w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Оставьте пустым, если будете выбирать чат из списка
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex space-x-3">
|
||||||
|
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-save mr-2"></i>
|
||||||
|
Сохранить настройки
|
||||||
|
</button>
|
||||||
|
<button type="button" onclick="getBotInfo()" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-info-circle mr-2"></i>
|
||||||
|
Получить информацию о боте
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="config-result" class="mt-4 hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<% if (botConfigured) { %>
|
||||||
|
<!-- Bot Information -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-robot mr-2 text-green-500"></i>
|
||||||
|
Информация о боте
|
||||||
|
</h3>
|
||||||
|
<button onclick="refreshBotInfo()" class="text-blue-600 hover:text-blue-800 text-sm">
|
||||||
|
<i class="fas fa-refresh mr-1"></i>
|
||||||
|
Обновить
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="bot-info-container">
|
||||||
|
<% if (botInfo) { %>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">Имя бота</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">@<%= botInfo.username %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">Отображаемое имя</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900"><%= botInfo.first_name %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">ID бота</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900"><%= botInfo.id %></div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">Может читать сообщения</div>
|
||||||
|
<div class="text-lg font-semibold <%= botInfo.can_read_all_group_messages ? 'text-green-600' : 'text-red-600' %>">
|
||||||
|
<%= botInfo.can_read_all_group_messages ? 'Да' : 'Нет' %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center py-4 text-gray-500">
|
||||||
|
<i class="fas fa-robot text-4xl text-gray-300 mb-2"></i>
|
||||||
|
<p>Настройте токен бота для получения информации</p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Available Chats -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<div class="flex items-center justify-between mb-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900">
|
||||||
|
<i class="fas fa-comments mr-2 text-blue-500"></i>
|
||||||
|
Доступные чаты
|
||||||
|
</h3>
|
||||||
|
<button onclick="discoverChats()" class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1 rounded text-sm transition-colors">
|
||||||
|
<i class="fas fa-search mr-1"></i>
|
||||||
|
Найти чаты
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="available-chats-container">
|
||||||
|
<% if (availableChats && availableChats.length > 0) { %>
|
||||||
|
<div class="grid gap-3">
|
||||||
|
<% availableChats.forEach(chat => { %>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas <%= chat.type === 'group' || chat.type === 'supergroup' ? 'fa-users' : 'fa-user' %> text-blue-600 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900"><%= chat.title %></div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<%= chat.type %> • ID: <%= chat.id %>
|
||||||
|
<% if (chat.username) { %>• @<%= chat.username %><% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="selectChat('<%= chat.id %>', '<%= chat.title %>')"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm">
|
||||||
|
Выбрать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<i class="fas fa-comments text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<p class="mb-2">Чаты не найдены</p>
|
||||||
|
<p class="text-sm">Отправьте боту сообщение или добавьте его в группу, затем нажмите "Найти чаты"</p>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<!-- Message Sender -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-paper-plane mr-2 text-purple-500"></i>
|
||||||
|
Отправить сообщение
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<form id="send-message-form" class="space-y-6">
|
||||||
|
<!-- Message Content -->
|
||||||
|
<div>
|
||||||
|
<label for="messageText" class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Текст сообщения *
|
||||||
|
</label>
|
||||||
|
<textarea id="messageText" name="message" rows="4" required
|
||||||
|
placeholder="Введите сообщение..."
|
||||||
|
class="block w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recipients -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Получатели
|
||||||
|
</label>
|
||||||
|
<div id="recipients-container" class="space-y-2">
|
||||||
|
<% if (availableChats && availableChats.length > 0) { %>
|
||||||
|
<% availableChats.forEach(chat => { %>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" name="chatIds" value="<%= chat.id %>"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm text-gray-900">
|
||||||
|
<%= chat.title %>
|
||||||
|
<span class="text-gray-500">(<%= chat.type %>)</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<% }); %>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Сообщение будет отправлено в чат по умолчанию
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Message Options -->
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<h4 class="text-sm font-medium text-gray-900 mb-3">Настройки сообщения</h4>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" name="disableWebPagePreview"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm text-gray-900">Отключить предпросмотр ссылок</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" name="disableNotification"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm text-gray-900">Тихое уведомление</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="mt-4">
|
||||||
|
<label for="parseMode" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
|
Формат текста
|
||||||
|
</label>
|
||||||
|
<select name="parseMode" id="parseMode"
|
||||||
|
class="block w-full border border-gray-300 rounded-md px-3 py-2 text-sm">
|
||||||
|
<option value="HTML">HTML</option>
|
||||||
|
<option value="Markdown">Markdown</option>
|
||||||
|
<option value="">Обычный текст</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Submit Buttons -->
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<button type="button" onclick="previewMessage()"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-eye mr-2"></i>
|
||||||
|
Предпросмотр
|
||||||
|
</button>
|
||||||
|
<div class="space-x-3">
|
||||||
|
<button type="button" onclick="testConnection()"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50">
|
||||||
|
<i class="fas fa-vial mr-2"></i>
|
||||||
|
Тест соединения
|
||||||
|
</button>
|
||||||
|
<button type="submit" id="sendMessageBtn"
|
||||||
|
class="inline-flex items-center px-6 py-2 border border-transparent text-sm font-medium rounded-md text-white bg-blue-600 hover:bg-blue-700">
|
||||||
|
<i class="fas fa-paper-plane mr-2"></i>
|
||||||
|
Отправить сообщение
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div id="send-result" class="mt-4 hidden"></div>
|
||||||
|
</div>
|
||||||
|
<!-- Bot Status and Controls -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
<!-- Connection Test -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
<i class="fas fa-plug mr-2 text-green-500"></i>
|
||||||
|
Проверка подключения
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-600 mb-4">
|
||||||
|
Отправить тестовое сообщение для проверки работоспособности бота.
|
||||||
|
</p>
|
||||||
|
<button id="test-connection" class="w-full bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-vial mr-2"></i>
|
||||||
|
Тестировать подключение
|
||||||
|
</button>
|
||||||
|
<div id="test-result" class="mt-4 hidden"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Send Custom Message -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-4">
|
||||||
|
<i class="fas fa-paper-plane mr-2 text-blue-500"></i>
|
||||||
|
Отправить сообщение
|
||||||
|
</h3>
|
||||||
|
<form id="send-message-form">
|
||||||
|
<div class="mb-4">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-2">
|
||||||
|
Сообщение
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="custom-message"
|
||||||
|
rows="4"
|
||||||
|
class="w-full border border-gray-300 rounded-lg px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
placeholder="Введите текст сообщения..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
|
||||||
|
<i class="fas fa-send mr-2"></i>
|
||||||
|
Отправить
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<div id="send-result" class="mt-4 hidden"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Notification Settings -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-bell mr-2 text-purple-500"></i>
|
||||||
|
Настройки уведомлений
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
|
<!-- Notification Types -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-4">Типы уведомлений</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-envelope mr-3 text-blue-500"></i>
|
||||||
|
<span class="text-sm text-gray-900">Новые обращения</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-calculator mr-3 text-green-500"></i>
|
||||||
|
<span class="text-sm text-gray-900">Расчеты стоимости</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-briefcase mr-3 text-purple-500"></i>
|
||||||
|
<span class="text-sm text-gray-900">Новые проекты</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-cog mr-3 text-orange-500"></i>
|
||||||
|
<span class="text-sm text-gray-900">Новые услуги</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-3 h-3 bg-green-500 rounded-full"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bot Information -->
|
||||||
|
<div>
|
||||||
|
<h4 class="font-medium text-gray-900 mb-4">Информация о боте</h4>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Статус:</span>
|
||||||
|
<span class="text-sm font-medium text-green-600">Активен</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Токен:</span>
|
||||||
|
<span class="text-sm font-mono text-gray-800">••••••••••</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Chat ID:</span>
|
||||||
|
<span class="text-sm font-mono text-gray-800">••••••••••</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-between">
|
||||||
|
<span class="text-sm text-gray-600">Последнее уведомление:</span>
|
||||||
|
<span class="text-sm text-gray-600">Недавно</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Recent Notifications -->
|
||||||
|
<div class="bg-white shadow rounded-lg p-6">
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 mb-6">
|
||||||
|
<i class="fas fa-history mr-2 text-gray-500"></i>
|
||||||
|
Недавние уведомления
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<i class="fas fa-inbox text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<p>Уведомления будут отображаться здесь после отправки</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- JavaScript -->
|
||||||
|
<script src="/js/main.js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// Bot Configuration
|
||||||
|
document.getElementById('bot-config-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const data = Object.fromEntries(formData);
|
||||||
|
|
||||||
|
try {
|
||||||
|
showLoading('Сохранение настроек...');
|
||||||
|
|
||||||
|
const response = await fetch('/admin/telegram/configure', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showResult('config-result', result.message, 'success');
|
||||||
|
// Update bot info if available
|
||||||
|
if (result.botInfo) {
|
||||||
|
updateBotInfo(result.botInfo);
|
||||||
|
}
|
||||||
|
if (result.availableChats) {
|
||||||
|
updateAvailableChats(result.availableChats);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showResult('config-result', result.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showResult('config-result', 'Ошибка при сохранении настроек', 'error');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send message form
|
||||||
|
document.getElementById('send-message-form').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const message = formData.get('message');
|
||||||
|
const chatIds = formData.getAll('chatIds');
|
||||||
|
const parseMode = formData.get('parseMode');
|
||||||
|
const disableWebPagePreview = formData.has('disableWebPagePreview');
|
||||||
|
const disableNotification = formData.has('disableNotification');
|
||||||
|
|
||||||
|
if (!message.trim()) {
|
||||||
|
showResult('send-result', 'Введите текст сообщения', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = {
|
||||||
|
message: message.trim(),
|
||||||
|
chatIds: chatIds.length > 0 ? chatIds : [],
|
||||||
|
parseMode,
|
||||||
|
disableWebPagePreview,
|
||||||
|
disableNotification
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
const btn = document.getElementById('sendMessageBtn');
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Отправка...';
|
||||||
|
|
||||||
|
const response = await fetch('/admin/telegram/send', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
},
|
||||||
|
body: JSON.stringify(data)
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showResult('send-result', result.message, 'success');
|
||||||
|
document.getElementById('messageText').value = '';
|
||||||
|
} else {
|
||||||
|
showResult('send-result', result.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showResult('send-result', 'Ошибка при отправке сообщения', 'error');
|
||||||
|
} finally {
|
||||||
|
const btn = document.getElementById('sendMessageBtn');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-paper-plane mr-2"></i>Отправить сообщение';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function toggleTokenVisibility() {
|
||||||
|
const input = document.getElementById('botToken');
|
||||||
|
const eye = document.getElementById('token-eye');
|
||||||
|
|
||||||
|
if (input.type === 'password') {
|
||||||
|
input.type = 'text';
|
||||||
|
eye.className = 'fas fa-eye-slash text-gray-400';
|
||||||
|
} else {
|
||||||
|
input.type = 'password';
|
||||||
|
eye.className = 'fas fa-eye text-gray-400';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBotInfo() {
|
||||||
|
try {
|
||||||
|
showLoading('Получение информации о боте...');
|
||||||
|
|
||||||
|
const response = await fetch('/admin/telegram/info');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
updateBotInfo(result.botInfo);
|
||||||
|
updateAvailableChats(result.availableChats);
|
||||||
|
showResult('config-result', 'Информация о боте обновлена', 'success');
|
||||||
|
} else {
|
||||||
|
showResult('config-result', result.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showResult('config-result', 'Ошибка при получении информации о боте', 'error');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBotInfo() {
|
||||||
|
await getBotInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function discoverChats() {
|
||||||
|
try {
|
||||||
|
showLoading('Поиск доступных чатов...');
|
||||||
|
|
||||||
|
const response = await fetch('/admin/telegram/info');
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success && result.availableChats) {
|
||||||
|
updateAvailableChats(result.availableChats);
|
||||||
|
showNotification('Найдено чатов: ' + result.availableChats.length, 'success');
|
||||||
|
} else {
|
||||||
|
showNotification('Чаты не найдены', 'warning');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showNotification('Ошибка при поиске чатов', 'error');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectChat(chatId, chatTitle) {
|
||||||
|
document.getElementById('chatId').value = chatId;
|
||||||
|
showNotification(`Выбран чат: ${chatTitle}`, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testConnection() {
|
||||||
|
try {
|
||||||
|
showLoading('Тестирование соединения...');
|
||||||
|
|
||||||
|
const response = await fetch('/admin/telegram/test', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (result.success) {
|
||||||
|
showResult('send-result', 'Соединение успешно! Тестовое сообщение отправлено.', 'success');
|
||||||
|
} else {
|
||||||
|
showResult('send-result', result.message, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showResult('send-result', 'Ошибка при тестировании соединения', 'error');
|
||||||
|
} finally {
|
||||||
|
hideLoading();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewMessage() {
|
||||||
|
const message = document.getElementById('messageText').value;
|
||||||
|
const parseMode = document.getElementById('parseMode').value;
|
||||||
|
|
||||||
|
if (!message.trim()) {
|
||||||
|
showNotification('Введите текст сообщения для предпросмотра', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simple preview modal (you can enhance this)
|
||||||
|
const preview = window.open('', 'preview', 'width=400,height=300');
|
||||||
|
preview.document.write(`
|
||||||
|
<html>
|
||||||
|
<head><title>Предпросмотр сообщения</title></head>
|
||||||
|
<body style="font-family: Arial, sans-serif; padding: 20px;">
|
||||||
|
<h3>Предпросмотр сообщения (${parseMode || 'Обычный текст'})</h3>
|
||||||
|
<div style="border: 1px solid #ccc; padding: 10px; background: #f9f9f9;">
|
||||||
|
${parseMode === 'HTML' ? message : message.replace(/\n/g, '<br>')}
|
||||||
|
</div>
|
||||||
|
<button onclick="window.close()" style="margin-top: 10px;">Закрыть</button>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// UI Helper functions
|
||||||
|
function updateBotInfo(botInfo) {
|
||||||
|
const container = document.getElementById('bot-info-container');
|
||||||
|
if (!botInfo) return;
|
||||||
|
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">Имя бота</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">@${botInfo.username}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">Отображаемое имя</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">${botInfo.first_name}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">ID бота</div>
|
||||||
|
<div class="text-lg font-semibold text-gray-900">${botInfo.id}</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-gray-50 p-4 rounded-lg">
|
||||||
|
<div class="text-sm text-gray-600">Может читать сообщения</div>
|
||||||
|
<div class="text-lg font-semibold ${botInfo.can_read_all_group_messages ? 'text-green-600' : 'text-red-600'}">
|
||||||
|
${botInfo.can_read_all_group_messages ? 'Да' : 'Нет'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAvailableChats(chats) {
|
||||||
|
const container = document.getElementById('available-chats-container');
|
||||||
|
if (!chats || chats.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-center py-8 text-gray-500">
|
||||||
|
<i class="fas fa-comments text-4xl text-gray-300 mb-4"></i>
|
||||||
|
<p class="mb-2">Чаты не найдены</p>
|
||||||
|
<p class="text-sm">Отправьте боту сообщение или добавьте его в группу, затем нажмите "Найти чаты"</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const chatsHtml = chats.map(chat => `
|
||||||
|
<div class="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||||
|
<div class="flex items-center space-x-3">
|
||||||
|
<div class="w-8 h-8 bg-blue-100 rounded-full flex items-center justify-center">
|
||||||
|
<i class="fas ${chat.type === 'group' || chat.type === 'supergroup' ? 'fa-users' : 'fa-user'} text-blue-600 text-sm"></i>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div class="font-medium text-gray-900">${chat.title}</div>
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
${chat.type} • ID: ${chat.id}
|
||||||
|
${chat.username ? '• @' + chat.username : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="selectChat('${chat.id}', '${chat.title}')"
|
||||||
|
class="text-blue-600 hover:text-blue-800 text-sm">
|
||||||
|
Выбрать
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = `<div class="grid gap-3">${chatsHtml}</div>`;
|
||||||
|
|
||||||
|
// Also update recipients list
|
||||||
|
updateRecipientsList(chats);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRecipientsList(chats) {
|
||||||
|
const container = document.getElementById('recipients-container');
|
||||||
|
if (!chats || chats.length === 0) {
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="text-sm text-gray-500">
|
||||||
|
<i class="fas fa-info-circle mr-1"></i>
|
||||||
|
Сообщение будет отправлено в чат по умолчанию
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientsHtml = chats.map(chat => `
|
||||||
|
<label class="flex items-center">
|
||||||
|
<input type="checkbox" name="chatIds" value="${chat.id}"
|
||||||
|
class="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded">
|
||||||
|
<span class="ml-2 text-sm text-gray-900">
|
||||||
|
${chat.title}
|
||||||
|
<span class="text-gray-500">(${chat.type})</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
container.innerHTML = recipientsHtml;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showResult(elementId, message, type) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
const className = type === 'success' ? 'bg-green-100 text-green-800' :
|
||||||
|
type === 'warning' ? 'bg-yellow-100 text-yellow-800' :
|
||||||
|
'bg-red-100 text-red-800';
|
||||||
|
const icon = type === 'success' ? 'fa-check' :
|
||||||
|
type === 'warning' ? 'fa-exclamation-triangle' :
|
||||||
|
'fa-times';
|
||||||
|
|
||||||
|
element.className = `mt-4 p-3 rounded-lg ${className}`;
|
||||||
|
element.innerHTML = `<i class="fas ${icon} mr-2"></i>${message}`;
|
||||||
|
element.classList.remove('hidden');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
element.classList.add('hidden');
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showNotification(message, type = 'info') {
|
||||||
|
const notification = document.createElement('div');
|
||||||
|
notification.className = `fixed top-4 right-4 z-50 px-4 py-3 rounded-lg shadow-lg text-white max-w-sm transform transition-all duration-300 translate-x-full`;
|
||||||
|
|
||||||
|
switch(type) {
|
||||||
|
case 'success':
|
||||||
|
notification.classList.add('bg-green-600');
|
||||||
|
break;
|
||||||
|
case 'error':
|
||||||
|
notification.classList.add('bg-red-600');
|
||||||
|
break;
|
||||||
|
case 'warning':
|
||||||
|
notification.classList.add('bg-yellow-600');
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
notification.classList.add('bg-blue-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
notification.innerHTML = `
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas ${type === 'success' ? 'fa-check-circle' : type === 'error' ? 'fa-exclamation-circle' : type === 'warning' ? 'fa-exclamation-triangle' : 'fa-info-circle'} mr-2"></i>
|
||||||
|
<span>${message}</span>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(notification);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.remove('translate-x-full');
|
||||||
|
}, 100);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
notification.classList.add('translate-x-full');
|
||||||
|
setTimeout(() => {
|
||||||
|
if (document.body.contains(notification)) {
|
||||||
|
document.body.removeChild(notification);
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
}, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLoading(message) {
|
||||||
|
// Create or show loading overlay
|
||||||
|
let overlay = document.getElementById('loading-overlay');
|
||||||
|
if (!overlay) {
|
||||||
|
overlay = document.createElement('div');
|
||||||
|
overlay.id = 'loading-overlay';
|
||||||
|
overlay.className = 'fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50';
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="bg-white p-6 rounded-lg shadow-lg">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<i class="fas fa-spinner fa-spin text-blue-600 text-xl mr-3"></i>
|
||||||
|
<span id="loading-message">${message}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
} else {
|
||||||
|
document.getElementById('loading-message').textContent = message;
|
||||||
|
overlay.style.display = 'flex';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() {
|
||||||
|
const overlay = document.getElementById('loading-overlay');
|
||||||
|
if (overlay) {
|
||||||
|
overlay.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
<!-- Calculator Header -->
|
<!-- Calculator Header -->
|
||||||
<section class="relative bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-800 dark:to-purple-800 py-20 mt-16 hero-section">
|
<section class="relative bg-gradient-to-r from-blue-600 to-purple-600 dark:from-blue-800 dark:to-purple-800 mt-16 hero-section-compact">
|
||||||
<div class="absolute inset-0 bg-black opacity-20"></div>
|
<div class="absolute inset-0 bg-black opacity-20"></div>
|
||||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
|
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||||
|
|||||||
@@ -19,7 +19,7 @@
|
|||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 py-20 hero-section">
|
<section class="relative bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 dark:from-gray-900 dark:via-blue-900 dark:to-purple-900 hero-section-compact">
|
||||||
<div class="absolute inset-0 bg-black opacity-50 dark:opacity-70"></div>
|
<div class="absolute inset-0 bg-black opacity-50 dark:opacity-70"></div>
|
||||||
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
||||||
<h1 class="text-5xl md:text-6xl font-bold text-white mb-6">
|
<h1 class="text-5xl md:text-6xl font-bold text-white mb-6">
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>오류 - SmartSolTech</title>
|
<title><%= title || __('errors.title') %></title>
|
||||||
|
|
||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
<meta name="theme-color" content="#3B82F6">
|
<meta name="theme-color" content="#3B82F6">
|
||||||
@@ -14,9 +14,11 @@
|
|||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
|
<link href="/css/fixes.css" rel="stylesheet">
|
||||||
|
<link href="/css/dark-theme.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50">
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation', { settings: settings || {}, currentPage: 'error' }) %>
|
||||||
|
|
||||||
<!-- Error Section -->
|
<!-- Error Section -->
|
||||||
<section class="min-h-screen flex items-center justify-center py-20">
|
<section class="min-h-screen flex items-center justify-center py-20">
|
||||||
@@ -30,12 +32,12 @@
|
|||||||
|
|
||||||
<!-- Error Title -->
|
<!-- Error Title -->
|
||||||
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
<h1 class="text-4xl md:text-5xl font-bold text-gray-900 mb-6">
|
||||||
<%= title || '오류가 발생했습니다' %>
|
<%= title || __('errors.default_title') %>
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<!-- Error Message -->
|
<!-- Error Message -->
|
||||||
<p class="text-xl text-gray-600 mb-8 leading-relaxed">
|
<p class="text-xl text-gray-600 mb-8 leading-relaxed">
|
||||||
<%= message || '요청을 처리하는 중 문제가 발생했습니다.' %>
|
<%= message || __('errors.default_message') %>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Action Buttons -->
|
<!-- Action Buttons -->
|
||||||
@@ -43,37 +45,53 @@
|
|||||||
<a href="/"
|
<a href="/"
|
||||||
class="bg-blue-600 text-white px-8 py-3 rounded-full hover:bg-blue-700 transition-colors font-semibold">
|
class="bg-blue-600 text-white px-8 py-3 rounded-full hover:bg-blue-700 transition-colors font-semibold">
|
||||||
<i class="fas fa-home mr-2"></i>
|
<i class="fas fa-home mr-2"></i>
|
||||||
홈으로 돌아가기
|
<%= __('errors.back_home') %>
|
||||||
</a>
|
</a>
|
||||||
<button onclick="history.back()"
|
<button onclick="history.back()"
|
||||||
class="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-full hover:bg-blue-600 hover:text-white transition-colors font-semibold">
|
class="border-2 border-blue-600 text-blue-600 px-8 py-3 rounded-full hover:bg-blue-600 hover:text-white transition-colors font-semibold">
|
||||||
<i class="fas fa-arrow-left mr-2"></i>
|
<i class="fas fa-arrow-left mr-2"></i>
|
||||||
이전 페이지로
|
<%= __('errors.go_back') %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Help Section -->
|
<!-- Help Section -->
|
||||||
<div class="mt-12 pt-8 border-t border-gray-200">
|
<div class="mt-12 pt-8 border-t border-gray-200">
|
||||||
<h3 class="text-lg font-semibold text-gray-900 mb-4">도움이 필요하신가요?</h3>
|
<h3 class="text-lg font-semibold text-gray-900 mb-4"><%= __('errors.need_help') %></h3>
|
||||||
<p class="text-gray-600 mb-6">
|
<p class="text-gray-600 mb-6">
|
||||||
문제가 지속되면 언제든지 저희에게 연락해 주세요.
|
<%= __('errors.help_message') %>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
<a href="/contact"
|
<a href="/contact"
|
||||||
class="text-blue-600 hover:text-blue-700 font-semibold">
|
class="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
<i class="fas fa-envelope mr-2"></i>
|
<i class="fas fa-envelope mr-2"></i>
|
||||||
문의하기
|
<%= __('errors.contact_support') %>
|
||||||
</a>
|
</a>
|
||||||
|
<% if (settings && settings.contact && settings.contact.email) { %>
|
||||||
|
<a href="mailto:<%= settings.contact.email %>"
|
||||||
|
class="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
|
<i class="fas fa-at mr-2"></i>
|
||||||
|
<%= settings.contact.email %>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
<a href="mailto:info@smartsoltech.kr"
|
<a href="mailto:info@smartsoltech.kr"
|
||||||
class="text-blue-600 hover:text-blue-700 font-semibold">
|
class="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
<i class="fas fa-at mr-2"></i>
|
<i class="fas fa-at mr-2"></i>
|
||||||
info@smartsoltech.kr
|
info@smartsoltech.kr
|
||||||
</a>
|
</a>
|
||||||
|
<% } %>
|
||||||
|
<% if (settings && settings.contact && settings.contact.phone) { %>
|
||||||
|
<a href="tel:<%= settings.contact.phone %>"
|
||||||
|
class="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
|
<i class="fas fa-phone mr-2"></i>
|
||||||
|
<%= settings.contact.phone %>
|
||||||
|
</a>
|
||||||
|
<% } else { %>
|
||||||
<a href="tel:+82-10-1234-5678"
|
<a href="tel:+82-10-1234-5678"
|
||||||
class="text-blue-600 hover:text-blue-700 font-semibold">
|
class="text-blue-600 hover:text-blue-700 font-semibold">
|
||||||
<i class="fas fa-phone mr-2"></i>
|
<i class="fas fa-phone mr-2"></i>
|
||||||
+82-10-1234-5678
|
+82-10-1234-5678
|
||||||
</a>
|
</a>
|
||||||
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
118
views/index.ejs
@@ -3,7 +3,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title><%- __(title || 'meta.title') %></title>
|
<title><%- title || 'SmartSolTech - Innovative Technology Solutions' %></title>
|
||||||
|
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="<%- __('meta.description') %>">
|
<meta name="description" content="<%- __('meta.description') %>">
|
||||||
@@ -17,15 +17,39 @@
|
|||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
<meta name="theme-color" content="#3B82F6">
|
<meta name="theme-color" content="#3B82F6">
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
<link rel="apple-touch-icon" href="/images/icons/icon-192x192.png">
|
<link rel="apple-touch-icon" href="/images/icon-192x192.png">
|
||||||
|
|
||||||
<!-- Styles -->
|
<!-- Custom CSS (load first) -->
|
||||||
<link href="https://cdn.jsdelivr.net/npm/tailwindcss@2.2.19/dist/tailwind.min.css" rel="stylesheet">
|
<link href="/css/base.css" rel="stylesheet">
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
|
||||||
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
|
||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
<link href="/css/fixes.css" rel="stylesheet">
|
<link href="/css/fixes.css" rel="stylesheet">
|
||||||
<link href="/css/dark-theme.css" rel="stylesheet">
|
|
||||||
|
<!-- Tailwind CSS via CDN -->
|
||||||
|
<script src="https://cdn.tailwindcss.com?plugins=forms,typography,aspect-ratio"></script>
|
||||||
|
|
||||||
|
<!-- Font Awesome -->
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet" integrity="sha512-DTOQO9RWCH3ppGqcWaEA1BIZOC6xxalwEsw9c2QQeAIftl+Vegovlnee1c9QX4TctnWMn13TZye+giMm8e2LwA==" crossorigin="anonymous">
|
||||||
|
|
||||||
|
<!-- AOS Animation Library -->
|
||||||
|
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Tailwind Configuration -->
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'primary': '#3B82F6',
|
||||||
|
'secondary': '#8B5CF6',
|
||||||
|
'accent': '#10B981',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
@@ -337,64 +361,34 @@
|
|||||||
<!-- Scripts -->
|
<!-- Scripts -->
|
||||||
<script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script>
|
<script src="https://unpkg.com/aos@2.3.1/dist/aos.js"></script>
|
||||||
<script src="/js/main.js"></script>
|
<script src="/js/main.js"></script>
|
||||||
|
|
||||||
|
<!-- Initialize AOS -->
|
||||||
<script>
|
<script>
|
||||||
// Initialize AOS
|
|
||||||
AOS.init({
|
|
||||||
duration: 800,
|
|
||||||
once: true,
|
|
||||||
offset: 100
|
|
||||||
});
|
|
||||||
|
|
||||||
// Blob animation
|
|
||||||
const style = document.createElement('style');
|
|
||||||
style.textContent = `
|
|
||||||
@keyframes blob {
|
|
||||||
0% { transform: translate(0px, 0px) scale(1); }
|
|
||||||
33% { transform: translate(30px, -50px) scale(1.1); }
|
|
||||||
66% { transform: translate(-20px, 20px) scale(0.9); }
|
|
||||||
100% { transform: translate(0px, 0px) scale(1); }
|
|
||||||
}
|
|
||||||
.animate-blob {
|
|
||||||
animation: blob 7s infinite;
|
|
||||||
}
|
|
||||||
.animation-delay-2000 {
|
|
||||||
animation-delay: 2s;
|
|
||||||
}
|
|
||||||
.animation-delay-4000 {
|
|
||||||
animation-delay: 4s;
|
|
||||||
}
|
|
||||||
`;
|
|
||||||
document.head.appendChild(style);
|
|
||||||
|
|
||||||
// Contact form handler
|
|
||||||
document.getElementById('quick-contact-form').addEventListener('submit', function(e) {
|
|
||||||
e.preventDefault();
|
|
||||||
const formData = new FormData(this);
|
|
||||||
|
|
||||||
fetch('/contact', {
|
|
||||||
method: 'POST',
|
|
||||||
body: formData
|
|
||||||
})
|
|
||||||
.then(response => response.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.success) {
|
|
||||||
alert('<%- __("contact.form.success") %>');
|
|
||||||
this.reset();
|
|
||||||
} else {
|
|
||||||
alert('<%- __("contact.form.error") %>');
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(error => {
|
|
||||||
console.error('Error:', error);
|
|
||||||
alert('<%- __("contact.form.error") %>');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// Theme initialization
|
|
||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
const theme = localStorage.getItem('theme') || 'light';
|
if (typeof AOS !== 'undefined') {
|
||||||
document.documentElement.className = theme === 'dark' ? 'dark' : '';
|
AOS.init({
|
||||||
|
duration: 800,
|
||||||
|
easing: 'ease-in-out',
|
||||||
|
once: true
|
||||||
|
});
|
||||||
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- PWA Service Worker -->
|
||||||
|
<script>
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js')
|
||||||
|
.then(registration => {
|
||||||
|
console.log('SW registered: ', registration);
|
||||||
|
})
|
||||||
|
.catch(registrationError => {
|
||||||
|
console.log('SW registration failed: ', registrationError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -35,14 +35,39 @@
|
|||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
||||||
|
|
||||||
<!-- CSS -->
|
<!-- Tailwind CSS (newer version with CDN Play) -->
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/tailwindcss/2.2.19/tailwind.min.css" rel="stylesheet">
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<link rel="stylesheet" href="/css/main.css">
|
|
||||||
|
|
||||||
<!-- Animation Library -->
|
<!-- Font Awesome -->
|
||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/aos/2.3.4/aos.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- AOS Animation Library -->
|
||||||
|
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Custom CSS -->
|
||||||
|
<link href="/css/base.css" rel="stylesheet">
|
||||||
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
|
<link href="/css/fixes.css" rel="stylesheet">
|
||||||
|
|
||||||
|
<!-- Tailwind Configuration -->
|
||||||
|
<script>
|
||||||
|
tailwind.config = {
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
'primary': '#3B82F6',
|
||||||
|
'secondary': '#8B5CF6',
|
||||||
|
'accent': '#10B981',
|
||||||
|
},
|
||||||
|
fontFamily: {
|
||||||
|
'sans': ['Inter', 'system-ui', 'sans-serif'],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-gray-50 font-inter">
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<!-- Navigation -->
|
<!-- Navigation -->
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<span class="ml-2 text-xl font-bold">SmartSolTech</span>
|
<span class="ml-2 text-xl font-bold">SmartSolTech</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-gray-300 dark:text-gray-400 mb-4">
|
<p class="text-gray-300 dark:text-gray-400 mb-4">
|
||||||
<%- __('footer.company.description') %>
|
<%= __('footer.company.description') %>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<!-- Social Links -->
|
<!-- Social Links -->
|
||||||
@@ -70,34 +70,34 @@
|
|||||||
|
|
||||||
<!-- Quick Links -->
|
<!-- Quick Links -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold mb-4"><%- __('footer.links.title') %></h3>
|
<h3 class="text-lg font-semibold mb-4"><%= __('footer.links.title') %></h3>
|
||||||
<ul class="space-y-2">
|
<ul class="space-y-2">
|
||||||
<li><a href="/" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.home') %></a></li>
|
<li><a href="/" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.home') %></a></li>
|
||||||
<li><a href="/about" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.about') %></a></li>
|
<li><a href="/about" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.about') %></a></li>
|
||||||
<li><a href="/services" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.services') %></a></li>
|
<li><a href="/services" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.services') %></a></li>
|
||||||
<li><a href="/portfolio" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.portfolio') %></a></li>
|
<li><a href="/portfolio" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.portfolio') %></a></li>
|
||||||
<li><a href="/calculator" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%- __('nav.calculator') %></a></li>
|
<li><a href="/calculator" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 transition-colors"><%= __('nav.calculator') %></a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Contact Info -->
|
<!-- Contact Info -->
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-semibold mb-4"><%- __('footer.contact.title') %></h3>
|
<h3 class="text-lg font-semibold mb-4"><%= __('footer.contact.title') %></h3>
|
||||||
<ul class="space-y-2 text-gray-300 dark:text-gray-400">
|
<ul class="space-y-2 text-gray-300 dark:text-gray-400">
|
||||||
<li class="flex items-center">
|
<li class="flex items-center">
|
||||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 8l7.89 4.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="mailto:<%= settings && settings.contact ? settings.contact.email : __('footer.contact.email') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
|
<a href="mailto:<%= settings && settings.contact && settings.contact.email ? settings.contact.email : __('footer.contact.email') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
|
||||||
<%= settings && settings.contact ? settings.contact.email : __('footer.contact.email') %>
|
<%= settings && settings.contact && settings.contact.email ? settings.contact.email : __('footer.contact.email') %>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-center">
|
<li class="flex items-center">
|
||||||
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="h-5 w-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<a href="tel:<%= settings && settings.contact ? settings.contact.phone : __('footer.contact.phone') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
|
<a href="tel:<%= settings && settings.contact && settings.contact.phone ? settings.contact.phone : __('footer.contact.phone') %>" class="hover:text-white dark:hover:text-gray-200 transition-colors">
|
||||||
<%= settings && settings.contact ? settings.contact.phone : __('footer.contact.phone') %>
|
<%= settings && settings.contact && settings.contact.phone ? settings.contact.phone : __('footer.contact.phone') %>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start">
|
<li class="flex items-start">
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span><%= settings && settings.contact ? settings.contact.address : __('footer.contact.address') %></span>
|
<span><%= settings && settings.contact && settings.contact.address ? settings.contact.address : __('footer.contact.address') %></span>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,11 +114,11 @@
|
|||||||
<!-- Bottom Section -->
|
<!-- Bottom Section -->
|
||||||
<div class="border-t border-gray-800 dark:border-gray-700 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
|
<div class="border-t border-gray-800 dark:border-gray-700 mt-8 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||||
<p class="text-gray-300 dark:text-gray-400 text-sm">
|
<p class="text-gray-300 dark:text-gray-400 text-sm">
|
||||||
<%- __('footer.copyright', { year: new Date().getFullYear() }) %>
|
<%= __('footer.copyright').replace('{{year}}', new Date().getFullYear()) %>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex space-x-6 mt-4 md:mt-0">
|
<div class="flex space-x-6 mt-4 md:mt-0">
|
||||||
<a href="/privacy" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%- __('footer.privacy') %></a>
|
<a href="/privacy" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%= __('footer.privacy') %></a>
|
||||||
<a href="/terms" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%- __('footer.terms') %></a>
|
<a href="/terms" class="text-gray-300 dark:text-gray-400 hover:text-white dark:hover:text-gray-200 text-sm transition-colors"><%= __('footer.terms') %></a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,88 +13,69 @@
|
|||||||
<div class="hidden md:flex items-center space-x-6">
|
<div class="hidden md:flex items-center space-x-6">
|
||||||
<!-- Navigation Links -->
|
<!-- Navigation Links -->
|
||||||
<a href="/" class="<%= currentPage === 'home' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
<a href="/" class="<%= currentPage === 'home' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
||||||
<%- __('navigation.home') %>
|
<%= __('navigation.home') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/about" class="<%= currentPage === 'about' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
<a href="/about" class="<%= currentPage === 'about' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
||||||
<%- __('navigation.about') %>
|
<%= __('navigation.about') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/services" class="<%= currentPage === 'services' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
<a href="/services" class="<%= currentPage === 'services' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
||||||
<%- __('navigation.services') %>
|
<%= __('navigation.services') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/portfolio" class="<%= currentPage === 'portfolio' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
<a href="/portfolio" class="<%= currentPage === 'portfolio' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
||||||
<%- __('navigation.portfolio') %>
|
<%= __('navigation.portfolio') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/calculator" class="<%= currentPage === 'calculator' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
<a href="/calculator" class="<%= currentPage === 'calculator' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
||||||
<%- __('navigation.calculator') %>
|
<%= __('navigation.calculator') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/contact" class="<%= currentPage === 'contact' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
<a href="/contact" class="<%= currentPage === 'contact' ? 'text-blue-600 border-b-2 border-blue-600' : 'text-gray-700 dark:text-gray-300 hover:text-blue-600' %> px-3 py-2 text-sm font-medium transition-colors nav-link">
|
||||||
<%- __('navigation.contact') %>
|
<%= __('navigation.contact') %>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Language Dropdown -->
|
<!-- Language Dropdown -->
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<button class="flex items-center text-gray-700 dark:text-gray-300 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors" id="language-dropdown">
|
<button class="flex items-center text-gray-700 dark:text-gray-300 hover:text-blue-600 px-3 py-2 text-sm font-medium transition-colors" id="language-dropdown">
|
||||||
<i class="fas fa-globe mr-2"></i>
|
<%
|
||||||
<%- __('language.' + currentLanguage) %>
|
let currentFlag = '🇰🇷';
|
||||||
|
if (currentLanguage === 'en') currentFlag = '🇺🇸';
|
||||||
|
else if (currentLanguage === 'ru') currentFlag = '🇷🇺';
|
||||||
|
else if (currentLanguage === 'kk') currentFlag = '🇰🇿';
|
||||||
|
%>
|
||||||
|
<span class="mr-2"><%= currentFlag %></span>
|
||||||
|
<%= __('language.' + currentLanguage) %>
|
||||||
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
<i class="fas fa-chevron-down ml-1 text-xs"></i>
|
||||||
</button>
|
</button>
|
||||||
<div class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border dark:border-gray-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
<div class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border dark:border-gray-700 opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200">
|
||||||
<a href="/lang/ko" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg">
|
<a href="/lang/ko" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg">
|
||||||
🇰🇷 <%- __('language.korean') %>
|
🇰🇷 <%= __('language.korean') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/lang/en" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
<a href="/lang/en" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
🇺🇸 <%- __('language.english') %>
|
🇺🇸 <%= __('language.english') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/lang/ru" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
<a href="/lang/ru" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
||||||
🇷🇺 <%- __('language.russian') %>
|
🇷🇺 <%= __('language.russian') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/lang/kk" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg">
|
<a href="/lang/kk" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg">
|
||||||
🇰🇿 <%- __('language.kazakh') %>
|
🇰🇿 <%= __('language.kazakh') %>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Theme Toggle -->
|
<!-- iOS Style Theme Toggle -->
|
||||||
<button id="theme-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors" title="<%- __('theme.toggle') %>">
|
<div class="relative inline-block" title="<%= __('theme.toggle') %>">
|
||||||
<i class="fas fa-sun dark:hidden"></i>
|
<input type="checkbox" id="theme-toggle" class="sr-only">
|
||||||
<i class="fas fa-moon hidden dark:block"></i>
|
<label for="theme-toggle" class="flex items-center cursor-pointer">
|
||||||
</button>
|
<div class="relative w-12 h-6 bg-gray-300 dark:bg-gray-600 rounded-full transition-colors duration-200">
|
||||||
|
<div class="absolute top-0.5 left-0.5 w-5 h-5 bg-white rounded-full shadow-md transform transition-transform duration-200 flex items-center justify-center theme-toggle-slider">
|
||||||
<!-- CTA Button -->
|
<i class="fas fa-sun text-yellow-500 text-xs theme-sun-icon"></i>
|
||||||
<a href="/contact" class="bg-blue-600 text-white px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors btn-primary">
|
<i class="fas fa-moon text-blue-500 text-xs hidden theme-moon-icon"></i>
|
||||||
<%- __('hero.cta_primary') %>
|
</div>
|
||||||
</a>
|
</div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile menu button -->
|
<!-- Mobile menu button -->
|
||||||
<div class="md:hidden flex items-center space-x-2">
|
<div class="md:hidden flex items-center space-x-2">
|
||||||
<!-- Mobile Theme Toggle -->
|
|
||||||
<button id="mobile-theme-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
|
|
||||||
<i class="fas fa-sun dark:hidden"></i>
|
|
||||||
<i class="fas fa-moon hidden dark:block"></i>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- Mobile Language Toggle -->
|
|
||||||
<div class="relative">
|
|
||||||
<button id="mobile-language-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
|
|
||||||
<i class="fas fa-globe"></i>
|
|
||||||
</button>
|
|
||||||
<div id="mobile-language-menu" class="absolute right-0 mt-2 w-40 bg-white dark:bg-gray-800 rounded-lg shadow-lg border dark:border-gray-700 hidden">
|
|
||||||
<a href="/lang/ko" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-t-lg">
|
|
||||||
🇰🇷 <%- __('language.korean') %>
|
|
||||||
</a>
|
|
||||||
<a href="/lang/en" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
||||||
🇺🇸 <%- __('language.english') %>
|
|
||||||
</a>
|
|
||||||
<a href="/lang/ru" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700">
|
|
||||||
🇷🇺 <%- __('language.russian') %>
|
|
||||||
</a>
|
|
||||||
<a href="/lang/kk" class="block px-4 py-2 text-sm text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-b-lg">
|
|
||||||
🇰🇿 <%- __('language.kazakh') %>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Hamburger Menu -->
|
<!-- Hamburger Menu -->
|
||||||
<button id="mobile-menu-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
|
<button id="mobile-menu-toggle" class="p-2 text-gray-700 dark:text-gray-300 hover:text-blue-600 transition-colors">
|
||||||
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -109,30 +90,23 @@
|
|||||||
<div id="mobile-menu" class="md:hidden hidden bg-white dark:bg-gray-900 border-t dark:border-gray-700">
|
<div id="mobile-menu" class="md:hidden hidden bg-white dark:bg-gray-900 border-t dark:border-gray-700">
|
||||||
<div class="px-2 pt-2 pb-3 space-y-1">
|
<div class="px-2 pt-2 pb-3 space-y-1">
|
||||||
<a href="/" class="<%= currentPage === 'home' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
<a href="/" class="<%= currentPage === 'home' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
||||||
<%- __('navigation.home') %>
|
<%= __('navigation.home') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/about" class="<%= currentPage === 'about' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
<a href="/about" class="<%= currentPage === 'about' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
||||||
<%- __('navigation.about') %>
|
<%= __('navigation.about') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/services" class="<%= currentPage === 'services' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
<a href="/services" class="<%= currentPage === 'services' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
||||||
<%- __('navigation.services') %>
|
<%= __('navigation.services') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/portfolio" class="<%= currentPage === 'portfolio' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
<a href="/portfolio" class="<%= currentPage === 'portfolio' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
||||||
<%- __('navigation.portfolio') %>
|
<%= __('navigation.portfolio') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/calculator" class="<%= currentPage === 'calculator' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
<a href="/calculator" class="<%= currentPage === 'calculator' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
||||||
<%- __('navigation.calculator') %>
|
<%= __('navigation.calculator') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/contact" class="<%= currentPage === 'contact' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
<a href="/contact" class="<%= currentPage === 'contact' ? 'bg-blue-50 dark:bg-blue-900 text-blue-600' : 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700' %> block px-3 py-2 rounded-md text-base font-medium">
|
||||||
<%- __('navigation.contact') %>
|
<%= __('navigation.contact') %>
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Mobile CTA -->
|
|
||||||
<div class="pt-4 pb-2">
|
|
||||||
<a href="/contact" class="block w-full bg-blue-600 text-white text-center px-4 py-2 rounded-lg text-sm font-medium hover:bg-blue-700 transition-colors">
|
|
||||||
<%- __('hero.cta_primary') %>
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -141,40 +115,53 @@
|
|||||||
document.addEventListener('DOMContentLoaded', function() {
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
// Theme Management
|
// Theme Management
|
||||||
const themeToggle = document.getElementById('theme-toggle');
|
const themeToggle = document.getElementById('theme-toggle');
|
||||||
const mobileThemeToggle = document.getElementById('mobile-theme-toggle');
|
|
||||||
const html = document.documentElement;
|
const html = document.documentElement;
|
||||||
|
const slider = document.querySelector('.theme-toggle-slider');
|
||||||
|
const sunIcon = document.querySelector('.theme-sun-icon');
|
||||||
|
const moonIcon = document.querySelector('.theme-moon-icon');
|
||||||
|
|
||||||
// Get current theme
|
// Get current theme
|
||||||
const currentTheme = localStorage.getItem('theme') ||
|
const currentTheme = localStorage.getItem('theme') ||
|
||||||
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
||||||
|
|
||||||
// Apply theme
|
// Apply theme and update toggle
|
||||||
if (currentTheme === 'dark') {
|
function applyTheme(isDark) {
|
||||||
html.classList.add('dark');
|
|
||||||
} else {
|
|
||||||
html.classList.remove('dark');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme toggle handlers
|
|
||||||
function toggleTheme() {
|
|
||||||
const isDark = html.classList.contains('dark');
|
|
||||||
if (isDark) {
|
if (isDark) {
|
||||||
html.classList.remove('dark');
|
|
||||||
localStorage.setItem('theme', 'light');
|
|
||||||
fetch('/theme/light');
|
|
||||||
} else {
|
|
||||||
html.classList.add('dark');
|
html.classList.add('dark');
|
||||||
localStorage.setItem('theme', 'dark');
|
themeToggle.checked = true;
|
||||||
fetch('/theme/dark');
|
if (slider) {
|
||||||
|
slider.style.transform = 'translateX(24px)';
|
||||||
|
slider.style.backgroundColor = '#374151';
|
||||||
|
}
|
||||||
|
if (sunIcon) sunIcon.classList.add('hidden');
|
||||||
|
if (moonIcon) moonIcon.classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
html.classList.remove('dark');
|
||||||
|
themeToggle.checked = false;
|
||||||
|
if (slider) {
|
||||||
|
slider.style.transform = 'translateX(0)';
|
||||||
|
slider.style.backgroundColor = '#ffffff';
|
||||||
|
}
|
||||||
|
if (sunIcon) sunIcon.classList.remove('hidden');
|
||||||
|
if (moonIcon) moonIcon.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (themeToggle) {
|
// Initial theme application
|
||||||
themeToggle.addEventListener('click', toggleTheme);
|
applyTheme(currentTheme === 'dark');
|
||||||
|
|
||||||
|
// Theme toggle handler
|
||||||
|
function toggleTheme() {
|
||||||
|
const isDark = html.classList.contains('dark');
|
||||||
|
const newTheme = isDark ? 'light' : 'dark';
|
||||||
|
|
||||||
|
applyTheme(!isDark);
|
||||||
|
localStorage.setItem('theme', newTheme);
|
||||||
|
fetch(`/theme/${newTheme}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mobileThemeToggle) {
|
if (themeToggle) {
|
||||||
mobileThemeToggle.addEventListener('click', toggleTheme);
|
themeToggle.addEventListener('change', toggleTheme);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile Menu Management
|
// Mobile Menu Management
|
||||||
@@ -187,19 +174,22 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mobile Language Menu
|
// Desktop Language Dropdown Management
|
||||||
const mobileLanguageToggle = document.getElementById('mobile-language-toggle');
|
const languageDropdown = document.getElementById('language-dropdown');
|
||||||
const mobileLanguageMenu = document.getElementById('mobile-language-menu');
|
const languageMenu = languageDropdown ? languageDropdown.nextElementSibling : null;
|
||||||
|
|
||||||
if (mobileLanguageToggle && mobileLanguageMenu) {
|
if (languageDropdown && languageMenu) {
|
||||||
mobileLanguageToggle.addEventListener('click', function() {
|
languageDropdown.addEventListener('click', function(e) {
|
||||||
mobileLanguageMenu.classList.toggle('hidden');
|
e.preventDefault();
|
||||||
|
languageMenu.classList.toggle('opacity-0');
|
||||||
|
languageMenu.classList.toggle('invisible');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Close language menu when clicking outside
|
// Close language menu when clicking outside
|
||||||
document.addEventListener('click', function(event) {
|
document.addEventListener('click', function(event) {
|
||||||
if (!mobileLanguageToggle.contains(event.target) && !mobileLanguageMenu.contains(event.target)) {
|
if (!languageDropdown.contains(event.target) && !languageMenu.contains(event.target)) {
|
||||||
mobileLanguageMenu.classList.add('hidden');
|
languageMenu.classList.add('opacity-0');
|
||||||
|
languageMenu.classList.add('invisible');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
@@ -29,8 +29,9 @@
|
|||||||
<link href="https://unpkg.com/swiper@8/swiper-bundle.min.css" rel="stylesheet">
|
<link href="https://unpkg.com/swiper@8/swiper-bundle.min.css" rel="stylesheet">
|
||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
<link href="/css/fixes.css" rel="stylesheet">
|
<link href="/css/fixes.css" rel="stylesheet">
|
||||||
|
<link href="/css/dark-theme.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
<!-- Portfolio Header -->
|
<!-- Portfolio Header -->
|
||||||
|
|||||||
@@ -1,17 +1,17 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>포트폴리오 - SmartSolTech</title>
|
<title><%- __('portfolio.meta.title') %> - SmartSolTech</title>
|
||||||
|
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="SmartSolTech의 다양한 프로젝트와 성공 사례를 확인해보세요. 웹 개발, 모바일 앱, UI/UX 디자인 포트폴리오.">
|
<meta name="description" content="<%- __('portfolio.meta.description') %>">
|
||||||
<meta name="keywords" content="포트폴리오, 웹 개발, 모바일 앱, UI/UX 디자인, 프로젝트, SmartSolTech">
|
<meta name="keywords" content="<%- __('portfolio.meta.keywords') %>">
|
||||||
|
|
||||||
<!-- Open Graph -->
|
<!-- Open Graph -->
|
||||||
<meta property="og:title" content="포트폴리오 - SmartSolTech">
|
<meta property="og:title" content="<%- __('portfolio.meta.og_title') %>">
|
||||||
<meta property="og:description" content="SmartSolTech의 다양한 프로젝트와 성공 사례">
|
<meta property="og:description" content="<%- __('portfolio.meta.og_description') %>">
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
|
|
||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
@@ -24,31 +24,33 @@
|
|||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
|
<link href="/css/fixes.css" rel="stylesheet">
|
||||||
|
<link href="/css/dark-theme.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
<!-- Portfolio Hero Section -->
|
<!-- Portfolio Hero Section -->
|
||||||
<section class="hero-section bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800 pt-20">
|
<section class="hero-section-compact bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800">
|
||||||
<div class="container mx-auto px-4 py-20 text-center text-white">
|
<div class="container mx-auto px-4 py-20 text-center text-white">
|
||||||
<h1 class="text-5xl md:text-6xl font-bold mb-6" data-aos="fade-up">
|
<h1 class="text-5xl md:text-6xl font-bold mb-6" data-aos="fade-up">
|
||||||
우리의 <span class="text-yellow-300">포트폴리오</span>
|
<%- __('portfolio_page.title') %>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl md:text-2xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
|
<p class="text-xl md:text-2xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
|
||||||
혁신적인 프로젝트와 창의적인 솔루션들을 만나보세요
|
<%- __('portfolio_page.subtitle') %>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-wrap justify-center gap-4" data-aos="fade-up" data-aos-delay="400">
|
<div class="flex flex-wrap justify-center gap-4" data-aos="fade-up" data-aos-delay="400">
|
||||||
<button class="filter-btn bg-white text-blue-600 px-6 py-3 rounded-full font-semibold hover:bg-blue-50 transition-colors active" data-filter="all">
|
<button class="filter-btn bg-white text-blue-600 px-6 py-3 rounded-full font-semibold hover:bg-blue-50 transition-colors active" data-filter="all">
|
||||||
전체
|
<%- __('portfolio_page.categories.all') %>
|
||||||
</button>
|
</button>
|
||||||
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="web-development">
|
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="web-development">
|
||||||
웹 개발
|
<%- __('portfolio_page.categories.web-development') %>
|
||||||
</button>
|
</button>
|
||||||
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="mobile-app">
|
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="mobile-app">
|
||||||
모바일 앱
|
<%- __('portfolio_page.categories.mobile-app') %>
|
||||||
</button>
|
</button>
|
||||||
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="ui-ux-design">
|
<button class="filter-btn bg-blue-700 text-white px-6 py-3 rounded-full font-semibold hover:bg-blue-800 transition-colors" data-filter="ui-ux-design">
|
||||||
UI/UX 디자인
|
<%- __('portfolio_page.categories.ui-ux-design') %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -88,7 +90,7 @@
|
|||||||
<% if (item.featured) { %>
|
<% if (item.featured) { %>
|
||||||
<div class="absolute top-4 right-4">
|
<div class="absolute top-4 right-4">
|
||||||
<span class="bg-yellow-500 text-white px-2 py-1 rounded-full text-xs font-bold">
|
<span class="bg-yellow-500 text-white px-2 py-1 rounded-full text-xs font-bold">
|
||||||
<i class="fas fa-star"></i> FEATURED
|
<i class="fas fa-star"></i> <%- __('portfolio_page.labels.featured') %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -96,7 +98,7 @@
|
|||||||
<!-- Overlay -->
|
<!-- Overlay -->
|
||||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 hover:opacity-100 transition-opacity duration-300 flex items-center justify-center">
|
||||||
<a href="/portfolio/<%= item._id %>" class="bg-white text-gray-900 px-6 py-3 rounded-full font-semibold hover:bg-gray-100 transition-colors">
|
<a href="/portfolio/<%= item._id %>" class="bg-white text-gray-900 px-6 py-3 rounded-full font-semibold hover:bg-gray-100 transition-colors">
|
||||||
자세히 보기
|
<%- __('portfolio_page.buttons.details') %>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -149,7 +151,7 @@
|
|||||||
<!-- Action Button -->
|
<!-- Action Button -->
|
||||||
<div class="mt-4 pt-4 border-t border-gray-100">
|
<div class="mt-4 pt-4 border-t border-gray-100">
|
||||||
<a href="/portfolio/<%= item._id %>" class="block w-full bg-blue-600 text-white text-center py-2 rounded-lg hover:bg-blue-700 transition-colors font-semibold">
|
<a href="/portfolio/<%= item._id %>" class="block w-full bg-blue-600 text-white text-center py-2 rounded-lg hover:bg-blue-700 transition-colors font-semibold">
|
||||||
프로젝트 상세보기
|
<%- __('portfolio_page.buttons.projectDetails') %>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,8 +160,8 @@
|
|||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="col-span-full text-center py-12">
|
<div class="col-span-full text-center py-12">
|
||||||
<i class="fas fa-folder-open text-6xl text-gray-300 mb-4"></i>
|
<i class="fas fa-folder-open text-6xl text-gray-300 mb-4"></i>
|
||||||
<h3 class="text-2xl font-bold text-gray-500 mb-2">아직 포트폴리오가 없습니다</h3>
|
<h3 class="text-2xl font-bold text-gray-500 mb-2"><%- __('portfolio_page.empty.title') %></h3>
|
||||||
<p class="text-gray-400">곧 멋진 프로젝트들을 공개할 예정입니다!</p>
|
<p class="text-gray-400"><%- __('portfolio_page.empty.subtitle') %></p>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,7 +170,7 @@
|
|||||||
<% if (portfolioItems && portfolioItems.length >= 9) { %>
|
<% if (portfolioItems && portfolioItems.length >= 9) { %>
|
||||||
<div class="text-center mt-12" data-aos="fade-up">
|
<div class="text-center mt-12" data-aos="fade-up">
|
||||||
<button id="load-more-btn" class="bg-blue-600 text-white px-8 py-3 rounded-full hover:bg-blue-700 transition-colors font-semibold">
|
<button id="load-more-btn" class="bg-blue-600 text-white px-8 py-3 rounded-full hover:bg-blue-700 transition-colors font-semibold">
|
||||||
더 많은 프로젝트 보기
|
<%- __('portfolio_page.buttons.loadMore') %>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -179,17 +181,17 @@
|
|||||||
<section class="section-padding bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
<section class="section-padding bg-gradient-to-r from-blue-600 to-purple-600 text-white">
|
||||||
<div class="container mx-auto px-4 text-center">
|
<div class="container mx-auto px-4 text-center">
|
||||||
<h2 class="text-3xl md:text-4xl font-bold mb-6" data-aos="fade-up">
|
<h2 class="text-3xl md:text-4xl font-bold mb-6" data-aos="fade-up">
|
||||||
다음 프로젝트의 주인공이 되어보세요
|
<%- __('portfolio_page.cta.title') %>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
|
<p class="text-xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
|
||||||
우리와 함께 혁신적인 디지털 솔루션을 만들어보세요
|
<%- __('portfolio_page.cta.subtitle') %>
|
||||||
</p>
|
</p>
|
||||||
<div class="flex flex-col sm:flex-row gap-4 justify-center" data-aos="fade-up" data-aos-delay="400">
|
<div class="flex flex-col sm:flex-row gap-4 justify-center" data-aos="fade-up" data-aos-delay="400">
|
||||||
<a href="/contact" class="bg-white text-blue-600 px-8 py-3 rounded-full hover:bg-gray-100 transition-colors font-semibold">
|
<a href="/contact" class="bg-white text-blue-600 px-8 py-3 rounded-full hover:bg-gray-100 transition-colors font-semibold">
|
||||||
프로젝트 문의하기
|
<%- __('portfolio_page.buttons.contact') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/calculator" class="border-2 border-white text-white px-8 py-3 rounded-full hover:bg-white hover:text-blue-600 transition-colors font-semibold">
|
<a href="/calculator" class="border-2 border-white text-white px-8 py-3 rounded-full hover:bg-white hover:text-blue-600 transition-colors font-semibold">
|
||||||
비용 계산하기
|
<%- __('portfolio_page.buttons.calculate') %>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -266,30 +268,30 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Category name mapping
|
// Category name mapping - uses server-side localization
|
||||||
function getCategoryName(category) {
|
function getCategoryName(category) {
|
||||||
const categoryNames = {
|
const categoryNames = {
|
||||||
'web-development': '웹 개발',
|
'web-development': '<%- __("portfolio_page.categories.web-development") %>',
|
||||||
'mobile-app': '모바일 앱',
|
'mobile-app': '<%- __("portfolio_page.categories.mobile-app") %>',
|
||||||
'ui-ux-design': 'UI/UX 디자인',
|
'ui-ux-design': '<%- __("portfolio_page.categories.ui-ux-design") %>',
|
||||||
'branding': '브랜딩',
|
'branding': '<%- __("portfolio_page.categories.branding") %>',
|
||||||
'marketing': '디지털 마케팅'
|
'marketing': '<%- __("portfolio_page.categories.marketing") %>'
|
||||||
};
|
};
|
||||||
return categoryNames[category] || category;
|
return categoryNames[category] || category;
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<%
|
<%
|
||||||
// Helper function for category names
|
// Helper function for category names - uses i18n
|
||||||
function getCategoryName(category) {
|
function getCategoryName(category) {
|
||||||
const categoryNames = {
|
const categoryMap = {
|
||||||
'web-development': '웹 개발',
|
'web-development': 'portfolio_page.categories.web-development',
|
||||||
'mobile-app': '모바일 앱',
|
'mobile-app': 'portfolio_page.categories.mobile-app',
|
||||||
'ui-ux-design': 'UI/UX 디자인',
|
'ui-ux-design': 'portfolio_page.categories.ui-ux-design',
|
||||||
'branding': '브랜딩',
|
'branding': 'portfolio_page.categories.branding',
|
||||||
'marketing': '디지털 마케팅'
|
'marketing': 'portfolio_page.categories.marketing'
|
||||||
};
|
};
|
||||||
return categoryNames[category] || category;
|
return categoryMap[category] ? __(categoryMap[category]) : category;
|
||||||
}
|
}
|
||||||
%>
|
%>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="ko">
|
<html lang="<%= locale || 'ko' %>" class="<%= theme === 'dark' ? 'dark' : '' %>">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>서비스 - SmartSolTech</title>
|
<title><%- __('services.meta.title') %> - SmartSolTech</title>
|
||||||
|
|
||||||
<!-- SEO Meta Tags -->
|
<!-- SEO Meta Tags -->
|
||||||
<meta name="description" content="SmartSolTech의 전문 서비스를 확인하세요. 웹 개발, 모바일 앱, UI/UX 디자인, 디지털 마케팅 등 다양한 기술 솔루션을 제공합니다.">
|
<meta name="description" content="<%- __('services.meta.description') %>">
|
||||||
<meta name="keywords" content="웹 개발, 모바일 앱, UI/UX 디자인, 디지털 마케팅, 기술 솔루션, SmartSolTech">
|
<meta name="keywords" content="<%- __('services.meta.keywords') %>">
|
||||||
|
|
||||||
<!-- PWA -->
|
<!-- PWA -->
|
||||||
<meta name="theme-color" content="#3B82F6">
|
<meta name="theme-color" content="#3B82F6">
|
||||||
@@ -19,18 +19,20 @@
|
|||||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
|
||||||
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
<link href="https://unpkg.com/aos@2.3.1/dist/aos.css" rel="stylesheet">
|
||||||
<link href="/css/main.css" rel="stylesheet">
|
<link href="/css/main.css" rel="stylesheet">
|
||||||
|
<link href="/css/fixes.css" rel="stylesheet">
|
||||||
|
<link href="/css/dark-theme.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body class="font-sans dark:bg-gray-900 dark:text-gray-100">
|
||||||
<%- include('partials/navigation') %>
|
<%- include('partials/navigation') %>
|
||||||
|
|
||||||
<!-- Services Hero Section -->
|
<!-- Services Hero Section - Компактный -->
|
||||||
<section class="hero-section bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800 pt-20">
|
<section class="hero-section-compact bg-gradient-to-br from-blue-600 via-purple-600 to-blue-800">
|
||||||
<div class="container mx-auto px-4 py-20 text-center text-white">
|
<div class="container mx-auto px-4 py-16 text-center text-white">
|
||||||
<h1 class="text-5xl md:text-6xl font-bold mb-6" data-aos="fade-up">
|
<h1 class="text-4xl md:text-5xl font-bold mb-4" data-aos="fade-up">
|
||||||
우리의 <span class="text-yellow-300">서비스</span>
|
<%- __('services.hero.title') %> <span class="text-yellow-300"><%- __('services.hero.title_highlight') %></span>
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-xl md:text-2xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
|
<p class="text-xl md:text-2xl mb-8 opacity-90" data-aos="fade-up" data-aos-delay="200">
|
||||||
혁신적인 기술로 비즈니스의 성장을 지원합니다
|
<%- __('services.hero.subtitle') %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -62,9 +64,9 @@
|
|||||||
<!-- Pricing -->
|
<!-- Pricing -->
|
||||||
<% if (service.pricing) { %>
|
<% if (service.pricing) { %>
|
||||||
<div class="mb-6">
|
<div class="mb-6">
|
||||||
<div class="text-sm text-gray-500 mb-1">시작가격</div>
|
<div class="text-sm text-gray-500 mb-1"><%- __('services.cards.starting_price') %></div>
|
||||||
<div class="text-2xl font-bold text-blue-600">
|
<div class="text-2xl font-bold text-blue-600">
|
||||||
<%= service.pricing.basePrice ? service.pricing.basePrice.toLocaleString() : '상담' %>
|
<%= service.pricing.basePrice ? service.pricing.basePrice.toLocaleString() : __('services.cards.consultation') %>
|
||||||
<% if (service.pricing.basePrice) { %>
|
<% if (service.pricing.basePrice) { %>
|
||||||
<span class="text-sm text-gray-500">원~</span>
|
<span class="text-sm text-gray-500">원~</span>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -82,11 +84,11 @@
|
|||||||
<div class="flex flex-col gap-3">
|
<div class="flex flex-col gap-3">
|
||||||
<a href="/contact?service=<%= service._id %>"
|
<a href="/contact?service=<%= service._id %>"
|
||||||
class="block w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white text-center py-3 rounded-lg font-semibold hover:from-blue-700 hover:to-purple-700 transition-all duration-300">
|
class="block w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white text-center py-3 rounded-lg font-semibold hover:from-blue-700 hover:to-purple-700 transition-all duration-300">
|
||||||
문의하기
|
<%- __('services.cards.contact') %>
|
||||||
</a>
|
</a>
|
||||||
<a href="/calculator?service=<%= service._id %>"
|
<a href="/calculator?service=<%= service._id %>"
|
||||||
class="block w-full border-2 border-blue-600 text-blue-600 text-center py-3 rounded-lg font-semibold hover:bg-blue-600 hover:text-white transition-all duration-300">
|
class="block w-full border-2 border-blue-600 text-blue-600 text-center py-3 rounded-lg font-semibold hover:bg-blue-600 hover:text-white transition-all duration-300">
|
||||||
비용 계산하기
|
<%- __('services.cards.calculate_cost') %>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -94,7 +96,7 @@
|
|||||||
<% if (service.featured) { %>
|
<% if (service.featured) { %>
|
||||||
<div class="absolute top-4 right-4">
|
<div class="absolute top-4 right-4">
|
||||||
<span class="bg-yellow-500 text-white px-2 py-1 rounded-full text-xs font-bold">
|
<span class="bg-yellow-500 text-white px-2 py-1 rounded-full text-xs font-bold">
|
||||||
<i class="fas fa-star"></i> 인기
|
<i class="fas fa-star"></i> <%- __('services.cards.popular') %>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
@@ -103,8 +105,8 @@
|
|||||||
<% } else { %>
|
<% } else { %>
|
||||||
<div class="col-span-full text-center py-12">
|
<div class="col-span-full text-center py-12">
|
||||||
<i class="fas fa-cogs text-6xl text-gray-300 mb-4"></i>
|
<i class="fas fa-cogs text-6xl text-gray-300 mb-4"></i>
|
||||||
<h3 class="text-2xl font-bold text-gray-500 mb-2">서비스 준비 중</h3>
|
<h3 class="text-2xl font-bold text-gray-500 mb-2"><%- __('services.cards.coming_soon') %></h3>
|
||||||
<p class="text-gray-400">곧 다양한 서비스를 제공할 예정입니다!</p>
|
<p class="text-gray-400"><%- __('services.cards.coming_soon_desc') %></p>
|
||||||
</div>
|
</div>
|
||||||
<% } %>
|
<% } %>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,10 +118,10 @@
|
|||||||
<div class="container mx-auto px-4">
|
<div class="container mx-auto px-4">
|
||||||
<div class="text-center mb-16" data-aos="fade-up">
|
<div class="text-center mb-16" data-aos="fade-up">
|
||||||
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
<h2 class="text-3xl md:text-4xl font-bold text-gray-900 mb-4">
|
||||||
프로젝트 진행 과정
|
<%- __('services.process.title') %>
|
||||||
</h2>
|
</h2>
|
||||||
<p class="text-xl text-gray-600">
|
<p class="text-lg text-gray-600 mb-12 max-w-3xl mx-auto">
|
||||||
체계적이고 전문적인 프로세스로 프로젝트를 진행합니다
|
<%- __('services.process.subtitle') %>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||