feat: Реализован полный CRUD для админ-панели и улучшена функциональность

- Portfolio CRUD: добавление, редактирование, удаление, переключение публикации
- Services CRUD: полное управление услугами с возможностью активации/деактивации
- Banner system: новая модель Banner с CRUD операциями и аналитикой кликов
- Telegram integration: расширенные настройки бота, обнаружение чатов, отправка сообщений
- Media management: улучшенная загрузка файлов с оптимизацией изображений и превью
- UI improvements: обновлённые админ-панели с rich-text редактором и drag&drop загрузкой
- Database: добавлена таблица banners с полями для баннеров и аналитики
This commit is contained in:
2025-10-22 20:32:16 +09:00
parent 150891b29d
commit 9477ff6de0
69 changed files with 11451 additions and 2321 deletions

151
.github/copilot-instructions.md vendored Normal file
View 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
View 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
View 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
View 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

View File

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

View File

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

View File

@@ -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": "좋아요"
}
}
} }

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 692 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 834 B

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

View 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

View 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

View 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

View File

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

File diff suppressed because it is too large Load Diff

388
routes/api/admin.js Normal file
View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(','),

View File

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

@@ -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
View 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();

View File

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

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

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

View 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
View 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
View 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
View 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
View 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
View 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">Маленький (&lt; 1MB)</option>
<option value="medium">Средний (1-5MB)</option>
<option value="large">Большой (&gt; 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>

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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') %>

View File

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

View File

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

View File

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

View File

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

View File

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