Compare commits
50 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| eee067f0ca | |||
| 2941b14b38 | |||
| 888bb2d87b | |||
| 029812c6a4 | |||
| 6a60d67b29 | |||
| b0350abfbe | |||
| ec83b97c25 | |||
| 86b8968dce | |||
| 90a96b4125 | |||
| 64295b689b | |||
| 75198ed00f | |||
| 01e8226c2b | |||
| 4d5090d76c | |||
| 02c6cb680b | |||
| 86f37dde2d | |||
| 9c793bad1b | |||
| ba7b757541 | |||
| edb7eabd18 | |||
| d8349a0936 | |||
| 60867af69c | |||
| 35ba568d97 | |||
| f06968eb46 | |||
| 075f29cde1 | |||
| e8a655d54c | |||
| 6ba49127aa | |||
| 2084acb078 | |||
| d1e45c7686 | |||
| b4574f9df1 | |||
| 8dfe7e8b4a | |||
| bdf3ba2483 | |||
| b98d1f65d3 | |||
| 787f90b5cf | |||
| d2b77d4553 | |||
| eb5b8fbf26 | |||
| 1bb91820d0 | |||
| 9dd4b5f067 | |||
| 5c34816359 | |||
| 5eb2f5220b | |||
| 318075d686 | |||
| ba2e3b9545 | |||
| 508eb8b981 | |||
| cc3d0a8b07 | |||
| 63d486f48d | |||
| 42549eb116 | |||
| 894415e3ac | |||
| e4a1fcfd25 | |||
| 4e80d525db | |||
| 8d30480bdc | |||
| 4cf8543c82 | |||
| bffdf0ec6c |
@@ -0,0 +1,20 @@
|
||||
# Нормализовать окончания строк: хранить LF в репозитории
|
||||
* text=auto
|
||||
|
||||
# Явно указать текстовые файлы — Git будет применять конвертацию
|
||||
*.go text
|
||||
*.mod text
|
||||
*.sum text
|
||||
*.txt text
|
||||
*.md text
|
||||
*.json text
|
||||
*.yml text
|
||||
*.yaml text
|
||||
|
||||
# Бинарные файлы — не трогать окончания строк
|
||||
*.png binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.gif binary
|
||||
*.zip binary
|
||||
*.exe binary
|
||||
@@ -0,0 +1,69 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Repo overview
|
||||
|
||||
Docker Compose hosting for 4 websites (yalarba.ru, begushiybashkir.ru, easysite102.ru, valitovgaziz.ru).
|
||||
All infrastructure lives under `main_dc/`. Root `package.json` is vestigial — do not use it.
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
main_dc/
|
||||
docker-compose.yml -- single compose file orchestrating everything
|
||||
Makefile -- the primary dev/ops interface; use `make` not raw docker
|
||||
.env -- shared env: domains, email, api_es port
|
||||
BB/api_bb/ -- Go REST API (GORM+Chi), port 7777, DB: db_bb (5433)
|
||||
BB/bbvue/ -- Vue 3 + Vite frontend for begushiybashkir.ru
|
||||
yalarba/api_tp/ -- Go REST API (GORM+Chi), port 8888, DB: db (5432)
|
||||
yalarba/api_es/ -- Go REST API (GORM+Chi), port 8088, DB: db (5432)
|
||||
yalarba/api_yal/ -- Go REST API (GORM+Chi), port 8787, DB: db (5432)
|
||||
yalarba/easySite/easySite/ -- Nuxt 4 SPA for easysite102.ru
|
||||
yalarba/serv_spa/spa/vue/ -- Vue 3 + Vite SPA for yalarba.ru
|
||||
valitovgaziz/analytics/ -- Node.js (Express) analytics server, port 9999
|
||||
valitovgaziz/html/ -- static HTML for valitovgaziz.ru
|
||||
nginx/ -- nginx with automatic HTTP↔HTTPS switching
|
||||
certbot/ -- Let's Encrypt cert management
|
||||
stubSite/ -- placeholder site while building
|
||||
```
|
||||
|
||||
## Developer commands (always run from `main_dc/`)
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `make all` | Full cycle: down → git pull → build --no-cache → up -d → watch |
|
||||
| `make <svc>` | Full cycle for one service, e.g. `make api_bb`, `make nginx`, `make es`, `make analytics` |
|
||||
| `make bbvue` | Rebuild Vue frontend (calls `npm run build` in `BB/bbvue/`) |
|
||||
| `make vue_bb` | git pull + npm cache clean + bbvue build + watch |
|
||||
| `make wn` | `watch -n2 docker ps` — monitor containers |
|
||||
| `make bb_db` | `psql -U postgres -d bb_db` inside db_bb container |
|
||||
|
||||
All `build_*` targets use `--no-cache`.
|
||||
All full-cycle targets follow: `stop_<svc> → git → build_<svc> → start_<svc> → wn`.
|
||||
|
||||
## Frontend dev (outside compose)
|
||||
|
||||
```bash
|
||||
cd main_dc/BB/bbvue && npm run dev # Vite dev server
|
||||
cd main_dc/BB/bbvue && npm run lint # ESLint --fix
|
||||
cd main_dc/BB/bbvue && npm run format # Prettier --write src/
|
||||
|
||||
cd main_dc/yalarba/serv_spa/spa/vue && npm run dev # Vite dev (yalarba SPA)
|
||||
|
||||
cd main_dc/yalarba/easySite/easySite && npm run dev # Nuxt dev
|
||||
cd main_dc/yalarba/easySite/easySite && npm run build # Nuxt build
|
||||
```
|
||||
|
||||
## Service quirks
|
||||
|
||||
- **Nginx SSL**: `switch-config.sh` is all-or-nothing — HTTPS only activates when *every* domain has a cert. Until then, SSL port redirects back to HTTP.
|
||||
- **`yalarba/serv_spa/spa/`**: Dockerfile is incomplete (build stage only, no runtime). The `spa/vue/` package.json includes express/pg deps despite being a Vite SPA — likely unused or legacy. The nginx compose mounts `yalarba/serv_spa/spa/vue/dist`.
|
||||
- **`api_yal`** is the only container that runs as non-root. Runs on port 8787.
|
||||
- **`api_es`** port is configurable via `API_ES_APP_PORT` in `.env` (default 8088). All other API ports are hardcoded.
|
||||
- **Databases**: `db` (port 5432) is shared between api_tp, api_es, api_yal. `db_bb` (port 5433) is dedicated to api_bb.
|
||||
- **GORM auto-migration**: All Go APIs use GORM auto-migrate at startup — no manual migration tooling.
|
||||
- **Keycloak** referenced in Makefile targets but absent from docker-compose.yml — likely not deployed.
|
||||
- **`api_yal/testrunner`**: standalone Go test runner binary (not containerized), for running integration test suites.
|
||||
|
||||
## Docs convention
|
||||
|
||||
READMEs and documentation are primarily in Russian. See `documentation/` for Makefile, Docker, restart, and LLM info docs.
|
||||
@@ -38,4 +38,4 @@ yalarba.ru on vue3.js (pinia) need to redevelop on nuxt.js
|
||||
1. Написать документацию к api всех сайтов
|
||||
2. Доработать begushiybashkir.ru && easysite102.rr
|
||||
|
||||
# документация находиться в директории documentation в корне проекта
|
||||
# документация находится в директории documentation в корне проекта
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# LLM Information
|
||||
|
||||
## Current LLM Configuration
|
||||
|
||||
Based on system analysis conducted on 2026-04-16, the following LLM (Large Language Model) is being used:
|
||||
|
||||
### Model Details
|
||||
- **Model Name**: `sourcecraft_model`
|
||||
- **Current Mode**: Architect (`architect`)
|
||||
- **Mode Display Name**: 🏗️ Architect
|
||||
- **System**: SourceCraft Code Assistant Agent
|
||||
|
||||
### Environment Context
|
||||
- **Operating System**: Windows 11
|
||||
- **Default Shell**: C:\WINDOWS\system32\cmd.exe
|
||||
- **Workspace Directory**: d:/artifacts/tp
|
||||
- **User Time Zone**: Asia/Yekaterinburg (UTC+5:00)
|
||||
|
||||
### Capabilities
|
||||
The SourceCraft Code Assistant Agent is an experienced technical leader with capabilities including:
|
||||
- Information gathering and context analysis
|
||||
- Detailed planning and task breakdown
|
||||
- Code writing and modification
|
||||
- System operations and command execution
|
||||
- File management and editing
|
||||
- Web development and debugging
|
||||
|
||||
### Modes Available
|
||||
The system supports multiple specialized modes:
|
||||
1. **🏗️ Architect** (current) - Planning, design, and strategy
|
||||
2. **💻 Code** - Code writing, modification, and refactoring
|
||||
3. **❓ Ask** - Explanations, documentation, and technical questions
|
||||
4. **🪲 Debug** - Troubleshooting and error diagnosis
|
||||
5. **🪃 Orchestrator** - Complex multi-step project coordination
|
||||
|
||||
### Project Context
|
||||
The current workspace contains a Docker-based hosting solution for multiple websites:
|
||||
- yalarba.ru
|
||||
- begushiybashkir.ru
|
||||
- easysite102.ru
|
||||
- valitovgaziz.ru
|
||||
|
||||
The project includes backend APIs in Go, frontend applications in Vue.js/Nuxt.js, and various supporting services (nginx, certbot).
|
||||
|
||||
### Analysis Method
|
||||
This information was gathered through:
|
||||
1. System environment details inspection
|
||||
2. File structure analysis
|
||||
3. Configuration file review (package.json, README.md)
|
||||
4. Current mode and model identification from system metadata
|
||||
|
||||
### Last Updated
|
||||
2026-04-16T15:25:15.218Z
|
||||
@@ -12,5 +12,3 @@ ALL_DOMAINS=yalarba.ru,www.yalarba.ru,valitovgaziz.ru,www.valitovgaziz.ru,easysi
|
||||
KEYCLOAK_ADMIN_PASSWORD=your_secure_password
|
||||
KEYCLOAK_DB_PASSWORD=your_secure_db_password
|
||||
|
||||
# API_ES port
|
||||
API_ES_APP_PORT=8088
|
||||
@@ -3,13 +3,12 @@ FROM golang:1.26.0-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Копируем go.mod и go.sum
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Копируем исходный код
|
||||
# Копируем весь исходный код
|
||||
COPY . .
|
||||
|
||||
# Скачиваем зависимости
|
||||
RUN go mod tidy && go mod download
|
||||
|
||||
# Компилируем БЕЗ CGO
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/main ./cmd/main.go
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
||||
github.com/golang-migrate/migrate/v4 v4.18.2
|
||||
golang.org/x/crypto v0.43.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.31.0
|
||||
@@ -15,8 +16,12 @@ require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/hashicorp/errwrap v1.1.0 // indirect
|
||||
github.com/hashicorp/go-multierror v1.1.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
github.com/lib/pq v1.10.9 // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/sys v0.37.0 // indirect
|
||||
)
|
||||
|
||||
@@ -32,7 +32,8 @@ func (a *App) Initialize() error {
|
||||
|
||||
// Инициализация базы данных
|
||||
dbConfig := &database.Config{
|
||||
URL: a.cfg.DatabaseURL,
|
||||
URL: a.cfg.DatabaseURL,
|
||||
Schema: a.cfg.DBSchema,
|
||||
}
|
||||
a.db = database.NewDatabase(dbConfig)
|
||||
|
||||
@@ -46,11 +47,6 @@ func (a *App) Initialize() error {
|
||||
return err
|
||||
}
|
||||
|
||||
// Выполнение миграций
|
||||
if err := a.db.Migrate(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Настройка роутера
|
||||
router := routes.SetupRouter(a.db.DB, a.cfg)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
type Config struct {
|
||||
Port string
|
||||
DatabaseURL string
|
||||
DBSchema string
|
||||
StaticURL string `env:"STATIC_URL" envDefault:"http://localhost:8080"`
|
||||
JWTSecret string `env:"JWT_SECRET,required"`
|
||||
|
||||
@@ -34,6 +35,7 @@ func Load() *Config {
|
||||
return &Config{
|
||||
Port: port,
|
||||
DatabaseURL: databaseURL,
|
||||
DBSchema: getEnv("DB_SCHEMA", "public"),
|
||||
JWTSecret: jwtSecret,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"api_bb/migrations"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/golang-migrate/migrate/v4"
|
||||
migratepg "github.com/golang-migrate/migrate/v4/database/postgres"
|
||||
"github.com/golang-migrate/migrate/v4/source/iofs"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
gormpg "gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"api_bb/pkg/logger"
|
||||
@@ -17,26 +23,34 @@ type Database struct {
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
URL string
|
||||
URL string
|
||||
Schema string
|
||||
}
|
||||
|
||||
func NewDatabase(cfg *Config) *Database {
|
||||
if cfg.Schema == "" {
|
||||
cfg.Schema = "public"
|
||||
}
|
||||
return &Database{
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
// Connect устанавливает соединение с базой данных
|
||||
func (d *Database) Connect() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
// Логирование попытки подключения к БД
|
||||
zapLogger.Info("attempting to connect to database",
|
||||
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
||||
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
|
||||
zap.String("schema", d.cfg.Schema),
|
||||
)
|
||||
|
||||
db, err := gorm.Open(postgres.Open(d.cfg.URL), &gorm.Config{})
|
||||
dsn := d.cfg.URL
|
||||
if d.cfg.Schema != "public" {
|
||||
dsn = dsn + fmt.Sprintf(" search_path=%s", d.cfg.Schema)
|
||||
}
|
||||
|
||||
db, err := gorm.Open(gormpg.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
zapLogger.Error("failed to connect to database",
|
||||
zap.Error(err),
|
||||
@@ -47,7 +61,21 @@ func (d *Database) Connect() error {
|
||||
|
||||
d.DB = db
|
||||
|
||||
// Логирование успешного подключения к БД
|
||||
zapLogger.Info("Configure connection pool")
|
||||
sqlDB, err := d.DB.DB()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get underlying sql.DB: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(25)
|
||||
sqlDB.SetMaxIdleConns(10)
|
||||
sqlDB.SetConnMaxLifetime(30 * time.Minute)
|
||||
|
||||
zapLogger.Info("Run database migrations")
|
||||
if err := d.runMigrations(sqlDB); err != nil {
|
||||
return fmt.Errorf("failed to run migrations: %w", err)
|
||||
}
|
||||
zapLogger.Info("Migrations completed successfully")
|
||||
|
||||
zapLogger.Info("successfully connected to database",
|
||||
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
||||
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
|
||||
@@ -56,7 +84,32 @@ func (d *Database) Connect() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Ping проверяет соединение с базой данных
|
||||
func (d *Database) runMigrations(sqlDB *sql.DB) error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
source, err := iofs.New(migrations.FS, ".")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migration source: %w", err)
|
||||
}
|
||||
|
||||
driver, err := migratepg.WithInstance(sqlDB, &migratepg.Config{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create postgres driver: %w", err)
|
||||
}
|
||||
|
||||
m, err := migrate.NewWithInstance("iofs", source, "postgres", driver)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create migrate instance: %w", err)
|
||||
}
|
||||
|
||||
if err := m.Up(); err != nil && err != migrate.ErrNoChange {
|
||||
zapLogger.Error("Migration error", zap.Error(err))
|
||||
return fmt.Errorf("failed to apply migrations: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (d *Database) Ping() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
@@ -75,7 +128,6 @@ func (d *Database) Ping() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close закрывает соединение с базой данных
|
||||
func (d *Database) Close() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
@@ -99,11 +151,7 @@ func (d *Database) Close() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Вспомогательные функции для работы с DSN
|
||||
|
||||
// ExtractHostFromDSN извлекает хост из DSN строки
|
||||
func ExtractHostFromDSN(dsn string) string {
|
||||
// Простая реализация для PostgreSQL DSN
|
||||
parts := strings.Split(dsn, " ")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "host=") {
|
||||
@@ -113,9 +161,7 @@ func ExtractHostFromDSN(dsn string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// ExtractDBNameFromDSN извлекает имя базы данных из DSN строки
|
||||
func ExtractDBNameFromDSN(dsn string) string {
|
||||
// Простая реализация для PostgreSQL DSN
|
||||
parts := strings.Split(dsn, " ")
|
||||
for _, part := range parts {
|
||||
if strings.HasPrefix(part, "dbname=") {
|
||||
@@ -125,9 +171,7 @@ func ExtractDBNameFromDSN(dsn string) string {
|
||||
return "unknown"
|
||||
}
|
||||
|
||||
// MaskPassword маскирует пароль в DSN строке для безопасного логирования
|
||||
func MaskPassword(dsn string) string {
|
||||
// Простая реализация - заменяет пароль на ***
|
||||
parts := strings.Split(dsn, " ")
|
||||
for i, part := range parts {
|
||||
if strings.HasPrefix(part, "password=") {
|
||||
@@ -136,4 +180,4 @@ func MaskPassword(dsn string) string {
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"go.uber.org/zap"
|
||||
|
||||
"api_bb/internal/models"
|
||||
"api_bb/pkg/logger"
|
||||
)
|
||||
|
||||
// Migrate выполняет автоматические миграции для всех моделей
|
||||
func (d *Database) Migrate() error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
zapLogger.Info("starting database migration")
|
||||
|
||||
// Список всех моделей для миграции
|
||||
models := []interface{}{
|
||||
&models.User{},
|
||||
&models.News{},
|
||||
&models.Comment{},
|
||||
&models.Review{},
|
||||
&models.UserStats{},
|
||||
&models.Workout{},
|
||||
&models.Achievement{},
|
||||
&models.Event{},
|
||||
&models.EventRegistration{},
|
||||
&models.PersonalBest{},
|
||||
&models.TrainingPlan{},
|
||||
&models.EmailVerification{},
|
||||
// Добавьте другие модели здесь
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
modelName := getModelName(model)
|
||||
zapLogger.Debug("migrating model", zap.String("model", modelName))
|
||||
|
||||
if err := d.DB.AutoMigrate(model); err != nil {
|
||||
zapLogger.Error("failed to migrate model",
|
||||
zap.String("model", modelName),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zapLogger.Info("database migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateModels выполняет миграции для конкретных моделей
|
||||
func (d *Database) MigrateModels(models ...interface{}) error {
|
||||
zapLogger := logger.Get()
|
||||
|
||||
zapLogger.Info("starting migration for specific models",
|
||||
zap.Int("model_count", len(models)),
|
||||
)
|
||||
|
||||
for _, model := range models {
|
||||
modelName := getModelName(model)
|
||||
zapLogger.Debug("migrating model", zap.String("model", modelName))
|
||||
|
||||
if err := d.DB.AutoMigrate(model); err != nil {
|
||||
zapLogger.Error("failed to migrate model",
|
||||
zap.String("model", modelName),
|
||||
zap.Error(err),
|
||||
)
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zapLogger.Info("models migration completed successfully")
|
||||
return nil
|
||||
}
|
||||
|
||||
// getModelName возвращает имя модели для логирования
|
||||
func getModelName(model interface{}) string {
|
||||
switch model.(type) {
|
||||
case *models.User:
|
||||
return "User"
|
||||
case *models.News:
|
||||
return "News"
|
||||
case *models.Comment:
|
||||
return "Comment"
|
||||
case *models.Review:
|
||||
return "Reviews"
|
||||
case *models.UserStats:
|
||||
return "Статистика Пользователя"
|
||||
case *models.Workout:
|
||||
return "Тренировки пользователя"
|
||||
case *models.Achievement:
|
||||
return "Достижения пользователя"
|
||||
case *models.Event:
|
||||
return "Событие"
|
||||
case *models.EventRegistration:
|
||||
return "Администрирование события"
|
||||
case *models.PersonalBest:
|
||||
return "Персональные достижения"
|
||||
case *models.TrainingPlan:
|
||||
return "Тренировочный план"
|
||||
case *models.EmailVerification:
|
||||
return "Верификация email"
|
||||
default:
|
||||
return "Unknown"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
DROP TABLE IF EXISTS galleries CASCADE;
|
||||
DROP TABLE IF EXISTS email_verifications CASCADE;
|
||||
DROP TABLE IF EXISTS training_workouts CASCADE;
|
||||
DROP TABLE IF EXISTS training_plans CASCADE;
|
||||
DROP TABLE IF EXISTS personal_bests CASCADE;
|
||||
DROP TABLE IF EXISTS event_registrations CASCADE;
|
||||
DROP TABLE IF EXISTS events CASCADE;
|
||||
DROP TABLE IF EXISTS achievements CASCADE;
|
||||
DROP TABLE IF EXISTS workouts CASCADE;
|
||||
DROP TABLE IF EXISTS user_stats CASCADE;
|
||||
DROP TABLE IF EXISTS reviews CASCADE;
|
||||
DROP TABLE IF EXISTS comments CASCADE;
|
||||
DROP TABLE IF EXISTS news CASCADE;
|
||||
DROP TABLE IF EXISTS users CASCADE;
|
||||
@@ -0,0 +1,217 @@
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
password VARCHAR(255) NOT NULL,
|
||||
first_name VARCHAR(255) NOT NULL,
|
||||
last_name VARCHAR(255) NOT NULL,
|
||||
avatar VARCHAR(255),
|
||||
phone VARCHAR(255),
|
||||
experience VARCHAR(255),
|
||||
goals VARCHAR(255),
|
||||
newsletter BOOLEAN DEFAULT FALSE,
|
||||
role VARCHAR(255) DEFAULT 'user',
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
email_verified BOOLEAN DEFAULT FALSE,
|
||||
verified_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_deleted_at ON users(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS news (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
excerpt VARCHAR(500) NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
image VARCHAR(255),
|
||||
category VARCHAR(20) NOT NULL,
|
||||
views BIGINT DEFAULT 0,
|
||||
author_id BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_news_deleted_at ON news(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
content TEXT NOT NULL,
|
||||
news_id BIGINT NOT NULL,
|
||||
author_id BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS reviews (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ,
|
||||
rating BIGINT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
achievement VARCHAR(255),
|
||||
distance VARCHAR(50),
|
||||
improvement VARCHAR(100),
|
||||
trainings BIGINT DEFAULT 0,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
author_id BIGINT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_reviews_deleted_at ON reviews(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_stats (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
total_distance DECIMAL(10,2) DEFAULT 0,
|
||||
total_time BIGINT DEFAULT 0,
|
||||
avg_pace VARCHAR(20),
|
||||
workouts_count BIGINT DEFAULT 0,
|
||||
current_streak BIGINT DEFAULT 0,
|
||||
longest_streak BIGINT DEFAULT 0,
|
||||
weekly_distance DECIMAL(8,2) DEFAULT 0,
|
||||
monthly_distance DECIMAL(8,2) DEFAULT 0,
|
||||
best_5k VARCHAR(20),
|
||||
best_10k VARCHAR(20),
|
||||
best_half VARCHAR(20),
|
||||
best_marathon VARCHAR(20),
|
||||
last_workout TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_user_stats_user_id ON user_stats(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS workouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
distance_km DECIMAL(5,2) NOT NULL,
|
||||
duration_min BIGINT NOT NULL,
|
||||
pace VARCHAR(20),
|
||||
calories BIGINT DEFAULT 0,
|
||||
notes TEXT,
|
||||
date TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_workouts_user_id ON workouts(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_workouts_date ON workouts(date);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS achievements (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
result VARCHAR(100),
|
||||
distance VARCHAR(50),
|
||||
date TIMESTAMPTZ NOT NULL,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
badge_image VARCHAR(500),
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_achievements_user_id ON achievements(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
date TIMESTAMPTZ NOT NULL,
|
||||
location VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(50) NOT NULL,
|
||||
distance VARCHAR(50),
|
||||
participants_count BIGINT DEFAULT 0,
|
||||
max_participants BIGINT DEFAULT 0,
|
||||
registration_open BOOLEAN DEFAULT TRUE,
|
||||
image VARCHAR(500),
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS event_registrations (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
event_id BIGINT NOT NULL,
|
||||
status VARCHAR(50) DEFAULT 'pending',
|
||||
notes TEXT,
|
||||
result_time VARCHAR(20),
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS personal_bests (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
distance_type VARCHAR(20) NOT NULL,
|
||||
time VARCHAR(20) NOT NULL,
|
||||
pace VARCHAR(20),
|
||||
date TIMESTAMPTZ NOT NULL,
|
||||
verified BOOLEAN DEFAULT FALSE,
|
||||
event_name VARCHAR(255),
|
||||
location VARCHAR(255),
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_personal_bests_user_id ON personal_bests(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_plans (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
weeks BIGINT NOT NULL DEFAULT 12,
|
||||
workouts_per_week BIGINT NOT NULL DEFAULT 3,
|
||||
target_distance VARCHAR(50),
|
||||
target_date TIMESTAMPTZ,
|
||||
current_week BIGINT DEFAULT 1,
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_plans_user_id ON training_plans(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS training_workouts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
plan_id BIGINT NOT NULL,
|
||||
week BIGINT NOT NULL,
|
||||
day BIGINT NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
description TEXT,
|
||||
distance_km DECIMAL(5,2),
|
||||
duration_min BIGINT,
|
||||
completed BOOLEAN DEFAULT FALSE,
|
||||
completed_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_training_workouts_plan_id ON training_workouts(plan_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS email_verifications (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
user_id BIGINT NOT NULL,
|
||||
token VARCHAR(100) NOT NULL,
|
||||
email VARCHAR(255) NOT NULL,
|
||||
type VARCHAR(20) NOT NULL,
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
used BOOLEAN DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ,
|
||||
deleted_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verifications_user_id ON email_verifications(user_id);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_email_verifications_token ON email_verifications(token);
|
||||
CREATE INDEX IF NOT EXISTS idx_email_verifications_deleted_at ON email_verifications(deleted_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS galleries (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(255) NOT NULL,
|
||||
description TEXT,
|
||||
image_path VARCHAR(500) NOT NULL,
|
||||
category VARCHAR(20) NOT NULL,
|
||||
author_id BIGINT NOT NULL,
|
||||
event_date TIMESTAMPTZ,
|
||||
views BIGINT DEFAULT 0,
|
||||
likes BIGINT DEFAULT 0,
|
||||
created_at TIMESTAMPTZ,
|
||||
updated_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_galleries_author_id ON galleries(author_id);
|
||||
@@ -0,0 +1,6 @@
|
||||
package migrations
|
||||
|
||||
import "embed"
|
||||
|
||||
//go:embed *.sql
|
||||
var FS embed.FS
|
||||
@@ -30,7 +30,7 @@
|
||||
│ Docker Compose Cluster │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_ES │ │
|
||||
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_YAL │ │
|
||||
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
|
||||
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||
│ │ │ │ │ │
|
||||
|
||||
@@ -139,6 +139,9 @@ easysite_build:
|
||||
easysite_start:
|
||||
docker compose up easysite -d && docker ps
|
||||
|
||||
# all
|
||||
easysite: easysite_stop git easysite_build easysite_start easysite_logs
|
||||
|
||||
# Мониторинг системных ресурсов
|
||||
top:
|
||||
htop
|
||||
@@ -165,21 +168,6 @@ restart_analytics:
|
||||
# Полный цикл обновления analytics
|
||||
analytics: stop_analitics git build_analititcs start_analytics wn
|
||||
|
||||
# Остановка api_es
|
||||
stop_api_es:
|
||||
docker compose down api_es
|
||||
|
||||
# Пересборка api_es
|
||||
build_api_es:
|
||||
docker compose build api_es --no-cache
|
||||
|
||||
# Запуск api_es
|
||||
start_api_es:
|
||||
docker compose up api_es -d
|
||||
|
||||
# Полный цикл обновления api_es
|
||||
api_es: stop_api_es git build_api_es start_api_es wn
|
||||
|
||||
# Остановка certbot
|
||||
stop_cerbot:
|
||||
docker compose down certbot
|
||||
@@ -195,26 +183,32 @@ start_certbot:
|
||||
# Полный цикл обновления certbot
|
||||
certbot: stop_cerbot git build_certbot start_certbot wat
|
||||
|
||||
# Сборка фронтенда valitovgaziz
|
||||
valitovgaziz_build_spa: git
|
||||
cd valitovgaziz && npm run build
|
||||
|
||||
# Остановка valitovgaziz
|
||||
stop_valitovgaziz:
|
||||
docker compose down valitovgaziz
|
||||
|
||||
# Пересборка valitovgaziz
|
||||
build_valitovgaziz:
|
||||
docker compose build valitovgaziz --no-cache
|
||||
|
||||
# Запуск valitovgaziz
|
||||
start_valitovgaziz:
|
||||
docker compose up valitovgaziz -d
|
||||
|
||||
# Полный цикл обновления valitovgaziz
|
||||
valitovgaziz: stop_valitovgaziz git build_valitovgaziz start_valitovgaziz wn
|
||||
|
||||
# Сборка SPA + полный цикл обновления valitovgaziz
|
||||
vue_site: valitovgaziz_build_spa stop_valitovgaziz build_valitovgaziz start_valitovgaziz wn
|
||||
|
||||
# Мониторинг состояния контейнеров каждые 2 секунды
|
||||
wn:
|
||||
watch -n 2 'docker ps'
|
||||
|
||||
# Остановка api_tp
|
||||
stop_api_tp:
|
||||
docker compose down api_tp
|
||||
|
||||
# Пересборка api_tp
|
||||
build_api_tp:
|
||||
docker compose build api_tp --no-cache
|
||||
|
||||
# Запуск api_tp
|
||||
start_api_tp:
|
||||
docker compose up api_tp -d
|
||||
|
||||
# Полный цикл обновления api_tp
|
||||
api_tp: stop_api_tp git build_api_tp start_api_tp wn
|
||||
|
||||
|
||||
# Остановка api_yal
|
||||
stop_api_yal:
|
||||
docker compose down api_yal
|
||||
@@ -228,4 +222,19 @@ start_api_yal:
|
||||
docker compose up api_yal -d
|
||||
|
||||
# Полный цикл обновления api_yal
|
||||
api_yal: stop_api_yal git build_api_yal start_api_yal wn
|
||||
api_yal: stop_api_yal git build_api_yal start_api_yal wn
|
||||
|
||||
# Остановка yalarba-nuxt
|
||||
stop_yalarba:
|
||||
docker compose down yalarba
|
||||
|
||||
# Пересборка yalarba-nuxt
|
||||
build_yalarba:
|
||||
docker compose build yalarba --no-cache
|
||||
|
||||
# Запуск yalarba-nuxt
|
||||
start_yalarba:
|
||||
docker compose up yalarba -d
|
||||
|
||||
# Полный цикл обновления yalarba-nuxt
|
||||
yalarba: stop_yalarba git build_yalarba start_yalarba wn
|
||||
@@ -0,0 +1,223 @@
|
||||
Текущие проблемы
|
||||
1. Деплой в 5 шагов: локальный код → push → SSH → pull → make (ручной, медленный)
|
||||
2. Добавление нового сайта требует правки 7+ файлов: .env, docker-compose.yml, nginx-http.conf, nginx-ssl.conf, switch-config.sh, init-certbot.sh, checkRenewCerts.sh, Makefile — легко забыть что-то
|
||||
3. HTTPS all-or-nothing: если у одного домена нет сертификата — все сайты падают на HTTP
|
||||
4. Certbot: дублирование кода на каждый домен в init-certbot.sh, checkRenewCerts.sh и 5 отдельных renew-скриптов
|
||||
5. Makefile растёт — на каждый сервис 4-5 целей
|
||||
Предложение
|
||||
Фаза 1: CI/CD через GitHub Actions
|
||||
Создать .github/workflows/deploy.yml:
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'main_dc/**'Текущие проблемы
|
||||
1. Деплой в 5 шагов: локальный код → push → SSH → pull → make (ручной, медленный)
|
||||
2. Добавление нового сайта требует правки 7+ файлов: .env, docker-compose.yml, nginx-http.conf, nginx-ssl.conf, switch-config.sh, init-certbot.sh, checkRenewCerts.sh, Makefile — легко забыть что-то
|
||||
3. HTTPS all-or-nothing: если у одного домена нет сертификата — все сайты падают на HTTP
|
||||
4. Certbot: дублирование кода на каждый домен в init-certbot.sh, checkRenewCerts.sh и 5 отдельных renew-скриптов
|
||||
5. Makefile растёт — на каждый сервис 4-5 целей
|
||||
Предложение
|
||||
Фаза 1: CI/CD через GitHub Actions
|
||||
Создать .github/workflows/deploy.yml:
|
||||
name: Deploy
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'main_dc/**'
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
cd /home/valitovgaziz/tp
|
||||
git pull origin main
|
||||
# Определить какие сервисы изменились и пересобрать только их
|
||||
# Или просто: make all
|
||||
Преимущества: push → авто-деплой за ~1 мин, без SSH вручную.
|
||||
Фаза 2: Единый источник истины — sites.yml
|
||||
Ввести файл main_dc/sites.yml со списком всех сайтов:
|
||||
sites:
|
||||
yalarba:
|
||||
domain: yalarba.ru
|
||||
aliases: [www.yalarba.ru]
|
||||
type: spa
|
||||
root: /usr/share/nginx/yalarba/html
|
||||
api: http://api_tp/
|
||||
api_yal: http://api_yal/
|
||||
|
||||
valitovgaziz:
|
||||
domain: valitovgaziz.ru
|
||||
aliases: [www.valitovgaziz.ru]
|
||||
type: container
|
||||
upstream: http://valitovgaziz/
|
||||
api: http://analytics:3000/
|
||||
|
||||
easysite102:
|
||||
domain: easysite102.ru
|
||||
aliases: [www.easysite102.ru]
|
||||
type: container
|
||||
upstream: http://easysite:3000
|
||||
api: http://api_yal:8787/
|
||||
|
||||
begushiybashkir:
|
||||
domain: begushiybashkir.ru
|
||||
aliases: [www.begushiybashkir.ru]
|
||||
type: spa
|
||||
root: /usr/share/nginx/begushiybashkir/html
|
||||
api: http://api_bb:8080/
|
||||
Скрипт-генератор (generate-configs.sh) на основе sites.yml создаёт:
|
||||
- nginx-http.conf — HTTP-блоки
|
||||
- nginx-ssl.conf — HTTPS-блоки для каждого домена, каждый независимо проверяет свой сертификат
|
||||
- nginx-partial-ssl.conf — комбинированный: HTTPS где есть серт, HTTP где нет
|
||||
- certbot-domains.txt — список доменов для certbot
|
||||
- .env — переменные окружения
|
||||
Добавление нового сайта: 1 правка в sites.yml → ./generate-configs.sh → git commit.
|
||||
Фаза 3: Умный nginx — per-domain HTTPS
|
||||
Убрать all-or-nothing switch-config.sh. Вместо этого nginx грузит все конфиги через include:
|
||||
/etc/nginx/conf.d/
|
||||
├── 00-http-default.conf # HTTP на 80
|
||||
├── 10-yalarba-ssl.conf # HTTPS, если есть серт
|
||||
├── 20-valitovgaziz-ssl.conf # HTTPS, если есть серт
|
||||
├── 30-easysite102-ssl.conf # HTTPS, если есть серт
|
||||
└── common/ # Общие настройки
|
||||
Каждый файл SSL генерируется с if-проверкой наличия сертификата:
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name valitovgaziz.ru www.valitovgaziz.ru;
|
||||
ssl_certificate /etc/letsencrypt/live/valitovgaziz.ru/fullchain.pem;
|
||||
...
|
||||
}
|
||||
А HTTP-сервер делает return 301 https://$host$request_uri только для тех доменов, у кого есть сертификат. Остальные работают по HTTP.
|
||||
Фаза 4: Certbot — один скрипт для всех
|
||||
Вместо 5 renew-скриптов:
|
||||
# /opt/renew-all.sh — единый скрипт
|
||||
certbot renew --webroot -w /var/www/certbot
|
||||
Certbot сам знает все домены, для которых получал сертификаты. --webroot с одним -w работает для всех. Убрать:
|
||||
- renewBegushiyBAshkirLatin.sh, renewBegushiyBashkir.sh, renewEasysite102.sh, renewValitovGazizCert.sh, renewYalarbaCert.sh
|
||||
- Заменить checkRenewCerts.sh на вызов certbot renew
|
||||
Фаза 5: Makefile — авто-детект изменений
|
||||
Оставить базовые цели, но добавить:
|
||||
deploy: git
|
||||
@echo "Detecting changes..."
|
||||
@git diff --name-only HEAD~1 HEAD | grep -o 'main_dc/[^/]*/' | sort -u | while read dir; do \
|
||||
service=$$(basename $$dir); \
|
||||
if grep -q "^ $$service:" docker-compose.yml; then \
|
||||
make stop_$$service build_$$service start_$$service; \
|
||||
fi \
|
||||
done
|
||||
Или ещё проще: GitHub Actions сам определяет какие сервисы изменились и запускает только их make цели.
|
||||
Дорожная карта
|
||||
Шаг Что делаем Эффект
|
||||
1 sites.yml + generate-configs.sh Добавление сайта = 1 файл
|
||||
2 Переход на per-domain HTTPS в nginx Один сайт без серта не ломает другие
|
||||
3 Упрощение certbot: единый certbot renew Удалить 5 скриптов
|
||||
4 GitHub Actions deploy workflow push → авто-деплой
|
||||
5 Makefile: deploy с авто-детектом Быстрый частичный деплой
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Deploy via SSH
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.HOST }}
|
||||
username: ${{ secrets.USER }}
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
script: |
|
||||
cd /home/valitovgaziz/tp
|
||||
git pull origin main
|
||||
# Определить какие сервисы изменились и пересобрать только их
|
||||
# Или просто: make all
|
||||
Преимущества: push → авто-деплой за ~1 мин, без SSH вручную.
|
||||
Фаза 2: Единый источник истины — sites.yml
|
||||
Ввести файл main_dc/sites.yml со списком всех сайтов:
|
||||
sites:
|
||||
yalarba:
|
||||
domain: yalarba.ru
|
||||
aliases: [www.yalarba.ru]
|
||||
type: spa
|
||||
root: /usr/share/nginx/yalarba/html
|
||||
api: http://api_tp/
|
||||
api_yal: http://api_yal/
|
||||
|
||||
valitovgaziz:
|
||||
domain: valitovgaziz.ru
|
||||
aliases: [www.valitovgaziz.ru]
|
||||
type: container
|
||||
upstream: http://valitovgaziz/
|
||||
api: http://analytics:3000/
|
||||
|
||||
easysite102:
|
||||
domain: easysite102.ru
|
||||
aliases: [www.easysite102.ru]
|
||||
type: container
|
||||
upstream: http://easysite:3000
|
||||
api: http://api_yal:8787/
|
||||
|
||||
begushiybashkir:
|
||||
domain: begushiybashkir.ru
|
||||
aliases: [www.begushiybashkir.ru]
|
||||
type: spa
|
||||
root: /usr/share/nginx/begushiybashkir/html
|
||||
api: http://api_bb:8080/
|
||||
Скрипт-генератор (generate-configs.sh) на основе sites.yml создаёт:
|
||||
- nginx-http.conf — HTTP-блоки
|
||||
- nginx-ssl.conf — HTTPS-блоки для каждого домена, каждый независимо проверяет свой сертификат
|
||||
- nginx-partial-ssl.conf — комбинированный: HTTPS где есть серт, HTTP где нет
|
||||
- certbot-domains.txt — список доменов для certbot
|
||||
- .env — переменные окружения
|
||||
Добавление нового сайта: 1 правка в sites.yml → ./generate-configs.sh → git commit.
|
||||
Фаза 3: Умный nginx — per-domain HTTPS
|
||||
Убрать all-or-nothing switch-config.sh. Вместо этого nginx грузит все конфиги через include:
|
||||
/etc/nginx/conf.d/
|
||||
├── 00-http-default.conf # HTTP на 80
|
||||
├── 10-yalarba-ssl.conf # HTTPS, если есть серт
|
||||
├── 20-valitovgaziz-ssl.conf # HTTPS, если есть серт
|
||||
├── 30-easysite102-ssl.conf # HTTPS, если есть серт
|
||||
└── common/ # Общие настройки
|
||||
Каждый файл SSL генерируется с if-проверкой наличия сертификата:
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name valitovgaziz.ru www.valitovgaziz.ru;
|
||||
ssl_certificate /etc/letsencrypt/live/valitovgaziz.ru/fullchain.pem;
|
||||
...
|
||||
}
|
||||
А HTTP-сервер делает return 301 https://$host$request_uri только для тех доменов, у кого есть сертификат. Остальные работают по HTTP.
|
||||
Фаза 4: Certbot — один скрипт для всех
|
||||
Вместо 5 renew-скриптов:
|
||||
# /opt/renew-all.sh — единый скрипт
|
||||
certbot renew --webroot -w /var/www/certbot
|
||||
Certbot сам знает все домены, для которых получал сертификаты. --webroot с одним -w работает для всех. Убрать:
|
||||
- renewBegushiyBAshkirLatin.sh, renewBegushiyBashkir.sh, renewEasysite102.sh, renewValitovGazizCert.sh, renewYalarbaCert.sh
|
||||
- Заменить checkRenewCerts.sh на вызов certbot renew
|
||||
Фаза 5: Makefile — авто-детект изменений
|
||||
Оставить базовые цели, но добавить:
|
||||
deploy: git
|
||||
@echo "Detecting changes..."
|
||||
@git diff --name-only HEAD~1 HEAD | grep -o 'main_dc/[^/]*/' | sort -u | while read dir; do \
|
||||
service=$$(basename $$dir); \
|
||||
if grep -q "^ $$service:" docker-compose.yml; then \
|
||||
make stop_$$service build_$$service start_$$service; \
|
||||
fi \
|
||||
done
|
||||
Или ещё проще: GitHub Actions сам определяет какие сервисы изменились и запускает только их make цели.
|
||||
Дорожная карта
|
||||
Шаг Что делаем Эффект
|
||||
1 sites.yml + generate-configs.sh Добавление сайта = 1 файл
|
||||
2 Переход на per-domain HTTPS в nginx Один сайт без серта не ломает другие
|
||||
3 Упрощение certbot: единый certbot renew Удалить 5 скриптов
|
||||
4 GitHub Actions deploy workflow push → авто-деплой
|
||||
5 Makefile: deploy с авто-детектом Быстрый частичный деплой
|
||||
@@ -19,7 +19,8 @@ check_local_cert() {
|
||||
fi
|
||||
|
||||
# Преобразуем дату истечения в UNIX-время
|
||||
expiry_unix=$(date -d "$expiry_date" +%s)
|
||||
# expiry_unix=$(date -d "$expiry_date" +%s)
|
||||
expiry_unix=$(date -D "%b %d %H:%M:%S %Y %Z" -d "$expiry_date" +%s 2>/dev/null)
|
||||
|
||||
# Текущая дата в UNIX-времени
|
||||
current_unix=$(date +%s)
|
||||
|
||||
@@ -43,30 +43,27 @@ services:
|
||||
- certbot_data:/etc/letsencrypt
|
||||
- certbot_www:/var/www/certbot
|
||||
- ./stubSite:/usr/share/nginx/stub/html
|
||||
- ./yalarba/serv_spa/spa/vue/dist:/usr/share/nginx/yalarba/html
|
||||
- ./valitovgaziz/html:/usr/share/nginx/valitovgaziz/html
|
||||
- ./BB/bbvue/dist:/usr/share/nginx/begushiybashkir/html
|
||||
- analytics_logs:/var/log/analytics:ro
|
||||
networks:
|
||||
- web-network
|
||||
- internal
|
||||
- app-network
|
||||
- bb-network
|
||||
depends_on:
|
||||
easysite:
|
||||
condition: service_healthy
|
||||
api_es:
|
||||
condition: service_healthy
|
||||
certbot:
|
||||
condition: service_healthy
|
||||
api_tp:
|
||||
condition: service_healthy
|
||||
api_bb:
|
||||
condition: service_healthy
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
api_yal:
|
||||
condition: service_healthy
|
||||
yalarba:
|
||||
condition: service_healthy
|
||||
valitovgaziz:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
|
||||
interval: 30s
|
||||
@@ -101,41 +98,24 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# REST API app on Golang (Gorm, Chi) бизнес логика приложения yalarba.ru. Работает с БД на PostgresQL db:db_tp
|
||||
api_tp:
|
||||
# Vue 3 SPA для valitovgaziz.ru
|
||||
valitovgaziz:
|
||||
build:
|
||||
context: ./yalarba/api_tp
|
||||
context: ./valitovgaziz
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "8888:8080"
|
||||
container_name: api_tp
|
||||
container_name: valitovgaziz
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
# Database connection settings
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_NAME: mydb
|
||||
APP_PORT: 8080
|
||||
networks:
|
||||
- app-network
|
||||
- web-network
|
||||
depends_on:
|
||||
analytics:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD",
|
||||
"wget",
|
||||
"--no-verbose",
|
||||
"--tries=1",
|
||||
"--spider",
|
||||
"http://localhost:8080/health",
|
||||
]
|
||||
test: ["CMD", "wget", "--spider", "http://localhost/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# PostgresQL DB база данных для храниния информации приложений Yalarba.ru && Easysite102.ru
|
||||
db:
|
||||
@@ -159,7 +139,7 @@ services:
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# REST API on Golang (Gorm, Chi) логика обработки информации для сайта БегущийБашкир Работает с БД db_bb on PostgresQL
|
||||
# REST API on Golang (Gorm, Chi) логика обработки информации для сайта БегущийБашкир
|
||||
api_bb:
|
||||
build:
|
||||
context: ./BB/api_bb
|
||||
@@ -169,22 +149,22 @@ services:
|
||||
container_name: api_bb
|
||||
restart: unless-stopped
|
||||
depends_on:
|
||||
db_bb:
|
||||
db:
|
||||
condition: service_healthy
|
||||
env_file:
|
||||
- ./BB/api_bb/.env
|
||||
volumes:
|
||||
- api_bb_uploads:/app/uploads
|
||||
environment:
|
||||
# Database connection settings
|
||||
DB_HOST: db_bb
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_NAME: bb_db
|
||||
DB_SCHEMA: bb
|
||||
APP_PORT: 8080
|
||||
networks:
|
||||
- bb-network
|
||||
- app-network
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
@@ -199,31 +179,10 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# PostgresQL DB база данных для работы сайта Бегущий Башкир
|
||||
db_bb:
|
||||
image: postgres:15-alpine
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5433:5432"
|
||||
container_name: db_bb
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_DB: bb_db
|
||||
volumes:
|
||||
- db_bb_data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- bb-network
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_es REST API app
|
||||
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_yal REST API app
|
||||
easysite:
|
||||
build:
|
||||
context: ./yalarba/easySite/easySite
|
||||
context: ./yalarba/easySite
|
||||
dockerfile: Dockerfile
|
||||
container_name: easysite
|
||||
restart: unless-stopped
|
||||
@@ -233,6 +192,7 @@ services:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
NUXT_PUBLIC_API_BASE: /api/v1
|
||||
networks:
|
||||
- web-network
|
||||
- app-network
|
||||
@@ -242,34 +202,6 @@ services:
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# REST API приложение для easysite102.ru тут бизнес логика и система для обращения к PostgresQL БД (тоже сервис db:db_tp)
|
||||
api_es:
|
||||
build:
|
||||
context: ./yalarba/api_es
|
||||
dockerfile: Dockerfile
|
||||
container_name: api_es
|
||||
restart: unless-stopped
|
||||
env_file:
|
||||
- ./yalarba/api_es/.env
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
DB_HOST: db
|
||||
DB_PORT: 5432
|
||||
DB_USER: postgres
|
||||
DB_PASSWORD: postgres
|
||||
DB_NAME: mydb
|
||||
APP_PORT: ${API_ES_APP_PORT}
|
||||
networks:
|
||||
- app-network
|
||||
- web-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "http://localhost:8088/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
# REST API app on Golang для api_yal сервиса
|
||||
api_yal:
|
||||
build:
|
||||
@@ -300,12 +232,32 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
# Nuxt 4 SPA для yalarba.ru
|
||||
yalarba:
|
||||
build:
|
||||
context: ./yalarba/yalarba-nuxt
|
||||
dockerfile: Dockerfile
|
||||
container_name: yalarba
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
HOST: 0.0.0.0
|
||||
PORT: 3000
|
||||
NUXT_PUBLIC_API_BASE: /api/v1
|
||||
NUXT_PUBLIC_APP_URL: https://yalarba.ru
|
||||
networks:
|
||||
- web-network
|
||||
- app-network
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "http://localhost:3000/api/health"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
|
||||
volumes:
|
||||
certbot_data: # volume для данных Certbot
|
||||
certbot_www: # volume для данных Certbot
|
||||
db_tp_data: # Volume для данных БД yalarba.ru
|
||||
db_bb_data: # Volume для данных БД Бегущий башкир
|
||||
api_bb_uploads: # Volume для загружаемых файлов бегущий башкир
|
||||
analytics_logs: # Volume для логов аналитики
|
||||
analytics_data: # Volume для данных аналитики
|
||||
@@ -317,8 +269,6 @@ networks:
|
||||
driver: bridge
|
||||
app-network:
|
||||
driver: bridge
|
||||
bb-network:
|
||||
driver: bridge
|
||||
|
||||
# Эта опция автоматически удаляет orphans (Не используемые контейнеры)
|
||||
x-remove-orphans: true
|
||||
@@ -41,7 +41,7 @@
|
||||
│ • certbot - SSL сертификаты │
|
||||
│ • analytics - Статистика (Node.js) │
|
||||
│ • api_tp - API yalarba.ru (Go) │
|
||||
│ • api_es - API easysite102.ru (Go) │
|
||||
│ • api_yal - API easysite102.ru (Go) │
|
||||
│ • api_bb - API Бегущий Башкир (Go) │
|
||||
│ • easysite - SPA (Nuxt.js) │
|
||||
│ • db - PostgreSQL (yalarba/easy) │
|
||||
@@ -74,7 +74,7 @@
|
||||
|-------|-----|----------------|---------------|
|
||||
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` |
|
||||
| `valitovgaziz.ru` | Статический сайт | - | `/usr/share/nginx/valitovgaziz/html` |
|
||||
| `easysite102.ru` | SPA (Nuxt.js) | `easysite:3000` + `api_es:8088` | Прокси |
|
||||
| `easysite102.ru` | SPA (Nuxt.js) | `easysite:3000` + `api_yal:8787` | Прокси |
|
||||
| `begushiybashkir.ru` | SPA (Vue) | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
||||
| `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
```
|
||||
EMAIL=admin@example.com # Для Let's Encrypt
|
||||
ALL_DOMAINS=yalarba.ru,valitovgaziz.ru... # Все домены для SSL
|
||||
API_ES_APP_PORT=8088 # Порт API easysite
|
||||
# API_ES убран, используется api_yal:8787
|
||||
```
|
||||
|
||||
### Сервисные
|
||||
@@ -141,14 +141,14 @@ STAGING=0 # 1 для тестового режима Let's Encrypt
|
||||
| certbot | Проверка файла сертификата | - | 30s |
|
||||
| analytics | `http://localhost:3000/health` | 3000 | 30s |
|
||||
| api_tp | `http://localhost:8080/health` | 8080 | 30s |
|
||||
| api_es | `http://localhost:8088/health` | 8088 | 30s |
|
||||
| api_yal | `http://localhost:8787/health` | 8787 | 30s |
|
||||
| api_bb | `http://localhost:8080/api/health` | 8080 | 30s |
|
||||
| easysite | `http://localhost:3000/api/health` | 3000 | 30s |
|
||||
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
|
||||
|
||||
### Зависимости запуска
|
||||
Nginx запускается только после подтверждения здоровья:
|
||||
- `easysite`, `api_es`, `certbot`, `api_tp`, `api_bb`, `analytics`
|
||||
- `easysite`, `api_yal`, `certbot`, `api_tp`, `api_bb`, `analytics`
|
||||
|
||||
## Волумы
|
||||
|
||||
|
||||
@@ -103,55 +103,35 @@ server {
|
||||
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
|
||||
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: Корневая (SPA приложение)
|
||||
# ЛОКАЦИЯ: Nuxt 4 SSR приложение
|
||||
# ============================================
|
||||
location / {
|
||||
# Директория со скомпилированным Vue/React приложением
|
||||
root /usr/share/nginx/yalarba/html;
|
||||
# Проксирование к Nuxt.js SSR серверу
|
||||
proxy_pass http://yalarba:3000;
|
||||
|
||||
# Файл по умолчанию
|
||||
index index.html;
|
||||
|
||||
# Логика SPA роутинга:
|
||||
# 1. Пробуем найти точный файл ($uri)
|
||||
# 2. Пробуем найти директорию ($uri/)
|
||||
# 3. Если не нашли - отдаем index.html
|
||||
# Это позволяет клиентскому роутингу работать корректно
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: REST API Backend
|
||||
# ============================================
|
||||
location /api/ {
|
||||
# Проксирование всех запросов к API на Golang сервис
|
||||
proxy_pass http://api_tp/; # Контейнер Docker
|
||||
|
||||
# Передача оригинальных заголовков от клиента
|
||||
# Полный набор заголовков для корректной работы приложения
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Увеличенные таймауты для длительных операций (10 минут)
|
||||
# Длинные таймауты
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
}
|
||||
|
||||
location /auth/ {
|
||||
# Проксирование всех запросов к API на Golang сервис
|
||||
proxy_pass http://api_yal/; # Контейнер Docker
|
||||
|
||||
# Передача оригинальных заголовков от клиента
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: REST API (api_yal)
|
||||
# ============================================
|
||||
location /api/v1/ {
|
||||
proxy_pass http://api_yal:8787;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Увеличенные таймауты для длительных операций (10 минут)
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
@@ -175,48 +155,39 @@ server {
|
||||
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
|
||||
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: Статический сайт
|
||||
# ЛОКАЦИЯ: Проксирование к Vue SPA контейнеру
|
||||
# ============================================
|
||||
location / {
|
||||
# Статические HTML файлы
|
||||
root /usr/share/nginx/valitovgaziz/html;
|
||||
index index.html;
|
||||
|
||||
# Стандартная логика для статических сайтов
|
||||
try_files $uri $uri/ /index.html;
|
||||
proxy_pass http://valitovgaziz/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: API для аналитики
|
||||
# ============================================
|
||||
location /api/ {
|
||||
# Проксирование на Node.js сервис аналитики
|
||||
proxy_pass http://analytics:3000/;
|
||||
|
||||
# Базовые заголовки прокси
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# ========================================
|
||||
# НАСТРОЙКИ CORS (Cross-Origin Resource Sharing)
|
||||
# ========================================
|
||||
# Разрешаем запросы с ЛЮБОГО домена (*)
|
||||
# Внимание: "*" может быть небезопасно в production
|
||||
add_header Access-Control-Allow-Origin "*" always;
|
||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" always;
|
||||
add_header Access-Control-Allow-Headers "Content-Type, Authorization" always;
|
||||
add_header Access-Control-Allow-Credentials "true" always;
|
||||
|
||||
# Обработка предварительных OPTIONS запросов (preflight)
|
||||
# Браузеры отправляют такие запросы перед основными
|
||||
if ($request_method = OPTIONS) {
|
||||
# 204 - No Content (успешный пустой ответ)
|
||||
return 204;
|
||||
}
|
||||
|
||||
# Стандартные таймауты для API аналитики
|
||||
proxy_connect_timeout 30s;
|
||||
proxy_send_timeout 30s;
|
||||
proxy_read_timeout 30s;
|
||||
@@ -260,43 +231,28 @@ server {
|
||||
}
|
||||
|
||||
# ============================================
|
||||
# ЛОКАЦИЯ: API Backend для Easysite
|
||||
# ЛОКАЦИЯ: API Backend для Easysite (api_yal)
|
||||
# ============================================
|
||||
location /api/ {
|
||||
# Отдельный API endpoint для backend
|
||||
proxy_pass http://api_es:8088/;
|
||||
location /api/v1/ {
|
||||
proxy_pass http://api_yal:8787;
|
||||
|
||||
# Заголовки прокси
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
|
||||
# Таймауты как у основного приложения
|
||||
proxy_connect_timeout 600;
|
||||
proxy_send_timeout 600;
|
||||
proxy_read_timeout 600;
|
||||
|
||||
# ========================================
|
||||
# ДЕТАЛЬНЫЕ НАСТРОЙКИ CORS ДЛЯ OPTIONS
|
||||
# ========================================
|
||||
if ($request_method = OPTIONS ) {
|
||||
# Динамический заголовок Origin из запроса
|
||||
if ($request_method = OPTIONS) {
|
||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
|
||||
|
||||
# Подробный список разрешенных заголовков
|
||||
add_header 'Access-Control-Allow-Headers' 'DNT,X-Mx-ReqToken,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Authorization';
|
||||
|
||||
# Время кэширования preflight ответа (20 дней)
|
||||
add_header 'Access-Control-Max-Age' 1728000;
|
||||
|
||||
# Пустой ответ для OPTIONS
|
||||
add_header 'Content-Length' 0;
|
||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||
|
||||
# Возвращаем 204 без тела ответа
|
||||
return 204;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules
|
||||
analytics
|
||||
src
|
||||
package.json
|
||||
package-lock.json
|
||||
jsconfig.json
|
||||
vite.config.js
|
||||
index.html
|
||||
@@ -0,0 +1,4 @@
|
||||
FROM nginx:alpine
|
||||
COPY dist /usr/share/nginx/html
|
||||
EXPOSE 80
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,62 +0,0 @@
|
||||
# ValitovGaziz - Персональный сайт и портфолио
|
||||
|
||||

|
||||

|
||||

|
||||
|
||||
<div align="center">
|
||||
|
||||
🌐 **Live Demo**: [valitovgaziz.ru](https://valitovgaziz.ru) |
|
||||
💼 **Портфолио** |
|
||||
🚀 **Проекты** |
|
||||
👥 **Команда мечты**
|
||||
|
||||
</div>
|
||||
|
||||
## 📋 О проекте
|
||||
|
||||
Персональный сайт-портфолио Гализа Валитова - технологического предпринимателя и Fullstack-разработчика. Сайт представляет профессиональный профиль, проекты и возможности для сотрудничества.
|
||||
|
||||
### 🎯 Основные разделы:
|
||||
- **Обо мне** - профессиональный профиль и подход к работе
|
||||
- **Проекты** - текущие и завершенные разработки
|
||||
- **Команда мечты** - приглашение к сотрудничеству
|
||||
- **Yalarba.ru** - флагманский Travel Tech проект
|
||||
- **Навыки** - технический стек и экспертиза
|
||||
- **Опыт работы** - карьерный путь
|
||||
|
||||
## 🛠 Технологический стек
|
||||
|
||||
### Frontend
|
||||
- **HTML5** - семантическая разметка
|
||||
- **CSS3** - кастомные стили и анимации
|
||||
- **JavaScript (ES6+)** - интерактивность и логика
|
||||
- **Vue3.js** - современный фронтенд фреймворк
|
||||
- **Nuxt.js 4** - SSR/SSG приложения
|
||||
|
||||
### Backend (Analytics Server)
|
||||
- **Node.js** - серверная платформа
|
||||
- **Express.js** - веб-фреймворк
|
||||
- **Helmet** - безопасность HTTP заголовков
|
||||
- **CORS** - кросс-доменные запросы
|
||||
- **Compression** - сжатие ответов
|
||||
- **Morgan** - логирование запросов
|
||||
|
||||
### Базы данных и инфраструктура
|
||||
- **PostgreSQL** - реляционная БД
|
||||
- **Docker** - контейнеризация
|
||||
- **Docker Swarm** - оркестрация
|
||||
|
||||
## 🚀 Быстрый старт
|
||||
|
||||
### Предварительные требования
|
||||
- Node.js 18+
|
||||
- npm или yarn
|
||||
- Современный браузер
|
||||
|
||||
### Установка и запуск
|
||||
|
||||
1. **Клонирование репозитория**
|
||||
```bash
|
||||
git clone https://github.com/valitovgaziz/valitovgaziz.ru.git
|
||||
cd valitovgaziz.ru
|
||||
@@ -1,274 +0,0 @@
|
||||
# Документация: ValitovGaziz.ru
|
||||
|
||||
## Обзор проекта
|
||||
|
||||
**ValitovGaziz.ru** — это персональный сайт-портфолио Гализа Валитова, технологического предпринимателя и Fullstack-разработчика. Сайт представляет собой профессиональную визитную карточку, демонстрирующую навыки, проекты и возможности для сотрудничества.
|
||||
|
||||
### Основные характеристики
|
||||
- **Современный дизайн** с адаптивной версткой
|
||||
- **Темная/светлая тема** с автоматическим определением системных предпочтений
|
||||
- **Интерактивные элементы** для вовлечения пользователей
|
||||
- **Цифровой фон** с анимациями в стиле "матрицы"
|
||||
- **Полностью статический** (без серверного рендеринга)
|
||||
- **Оптимизирован для SEO** и доступности
|
||||
|
||||
---
|
||||
|
||||
## Структура файлов
|
||||
|
||||
```
|
||||
valitovgaziz.ru/
|
||||
├── index.html # Главная страница
|
||||
├── style.css # Основной файл стилей
|
||||
├── scripts.js # Основные скрипты
|
||||
├── darkThemeToggle.js # Переключение темной темы
|
||||
├── digital_background.js # Создание цифрового фона
|
||||
├── analytics.js # Пользовательская аналитика
|
||||
├── README.md # Документация проекта
|
||||
├── images/ # Изображения и иконки
|
||||
│ ├── ValitovGaziz/ # Фотографии
|
||||
│ └── favicon/ # Иконки и логотипы
|
||||
└── style/ # Стилевые файлы
|
||||
├── about.css
|
||||
├── darkTheme.css
|
||||
├── digital_background.css
|
||||
├── footer.css
|
||||
├── hero_section.css
|
||||
├── links_style.css
|
||||
├── repository_section.css
|
||||
├── saveContactsButtonStyle.css
|
||||
├── skill_section.css
|
||||
├── social_link.css
|
||||
└── yalarba_investmen.css
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Структура сайта
|
||||
|
||||
### 1. Hero Section (Заголовок)
|
||||
**Файлы:** `hero_section.css`, `digital_background.css`
|
||||
- Главный заголовок с приветствием
|
||||
- Кнопки действий "Обсудить сотрудничество" и "Написать мне"
|
||||
- Социальные ссылки (Telegram, VK)
|
||||
- Кнопка переключения темы
|
||||
- Анимированный цифровой фон
|
||||
|
||||
### 2. Обо мне (About Section)
|
||||
**Файлы:** `about.css`
|
||||
- Фотография профиля
|
||||
- Описание профессионального подхода
|
||||
- Ключевые компетенции:
|
||||
- Техническое видение
|
||||
- Бизнес-ориентация
|
||||
- Практический подход
|
||||
- Мотивация
|
||||
|
||||
### 3. О репозитории (Repository Section)
|
||||
**Файлы:** `repository_section.css`
|
||||
- Сетка проектов (3 карточки):
|
||||
1. ValitovGaziz.ru
|
||||
2. Yalarba.ru
|
||||
3. BegushiyBashkir.ru
|
||||
- Информация о текущей работе
|
||||
- Ссылки на GitHub и проекты
|
||||
|
||||
### 4. Команда мечты (Team Section)
|
||||
- Приглашение к сотрудничеству
|
||||
- Роли для найма:
|
||||
- Программисты
|
||||
- Дизайнеры
|
||||
- Аналитики
|
||||
- Продавцы-стратеги
|
||||
- Преимущества участия
|
||||
- Кнопка "Присоединиться к команде"
|
||||
|
||||
### 5. Yalarba.ru (Travel Tech Project)
|
||||
**Файлы:** `yalarba_investmen.css`
|
||||
- Описание флагманского проекта
|
||||
- Технологический стек
|
||||
- Статистика и ценностное предложение
|
||||
- Инвестиционные возможности
|
||||
|
||||
### 6. Навыки (Skills Section)
|
||||
**Файлы:** `skill_section.css`
|
||||
- Карточки навыков с уровнями:
|
||||
- Golang (Продвинутый)
|
||||
- JavaScript (Продвинутый)
|
||||
- Vue3 (Средний)
|
||||
- Nuxt (Средний)
|
||||
- PostgreSQL (Средний)
|
||||
- Docker (Средний)
|
||||
- Java (Начинающий)
|
||||
- Spring Framework (Начинающий)
|
||||
|
||||
### 7. Опыт работы и образование
|
||||
- Таймлайн профессионального опыта
|
||||
- Образование и курсы
|
||||
- Языки
|
||||
|
||||
### 8. Контакты
|
||||
**Файлы:** `saveContactsButtonStyle.css`
|
||||
- Контактная информация
|
||||
- Кнопка "Сохранить контакт" (vCard формат)
|
||||
- Социальные сети и мессенджеры
|
||||
|
||||
### 9. Футер
|
||||
**Файлы:** `footer.css`
|
||||
- Технологии
|
||||
- Контакты
|
||||
- Сообщество
|
||||
- Авторские права
|
||||
|
||||
---
|
||||
|
||||
## Технические особенности
|
||||
|
||||
### Темная тема
|
||||
**Файлы:** `darkTheme.css`, `darkThemeToggle.js`
|
||||
- Автоматическое определение системных предпочтений
|
||||
- Сохранение выбора в localStorage
|
||||
- Полная поддержка всех элементов интерфейса
|
||||
|
||||
### Цифровой фон
|
||||
**Файлы:** `digital_background.css`, `digital_background.js`
|
||||
- Анимированные двоичные потоки (бинарный дождь)
|
||||
- Плавающие элементы кода
|
||||
- Точки соединений и линии передачи данных
|
||||
- Адаптация под текущую тему
|
||||
|
||||
### Аналитика
|
||||
**Файл:** `analytics.js`
|
||||
- Пользовательская система сбора данных
|
||||
- Отслеживание событий и кликов
|
||||
- Очередь с автосохранением в localStorage
|
||||
- Отправка данных на сервер при возможности
|
||||
- Отслеживание видимости секций
|
||||
|
||||
### Ссылки
|
||||
**Файл:** `links_style.css`
|
||||
- Анимированные внешние ссылки с иконками
|
||||
- Индикация внутренних/внешних ссылок
|
||||
- Адаптация под тему
|
||||
|
||||
---
|
||||
|
||||
## Технологический стек
|
||||
|
||||
### Frontend
|
||||
- **HTML5** — семантическая разметка
|
||||
- **CSS3** — Grid, Flexbox, CSS Variables, анимации
|
||||
- **JavaScript (ES6+)** — нативный JS без фреймворков
|
||||
- **CSS Grid Layout** — основная система верстки
|
||||
|
||||
### Особенности CSS
|
||||
- CSS Custom Properties (переменные) для тем
|
||||
- CSS Grid для сложных макетов
|
||||
- CSS Flexbox для простых выравниваний
|
||||
- CSS Animations для интерактивности
|
||||
- Media Queries для адаптивности
|
||||
|
||||
### JavaScript функциональность
|
||||
- Динамическое переключение тем
|
||||
- Создание интерактивного фона
|
||||
- Обработка форм и кнопок
|
||||
- Сохранение контактов в vCard формате
|
||||
- Интеграция с Telegram API
|
||||
|
||||
---
|
||||
|
||||
## SEO и доступность
|
||||
|
||||
### Мета-теги
|
||||
- Полный набор meta-тегов для SEO
|
||||
- Ключевые слова для IT-специалистов и предпринимателей
|
||||
- Атрибуты для доступности (alt, aria)
|
||||
|
||||
### Оптимизация
|
||||
- Ленивая загрузка изображений
|
||||
- Минификация CSS и JS
|
||||
- Оптимизированные шрифты
|
||||
- Быстрая загрузка страницы
|
||||
|
||||
### Адаптивность
|
||||
- Mobile-first подход
|
||||
- 4 точки останова:
|
||||
- < 480px (мобильные)
|
||||
- 480px - 768px (планшеты)
|
||||
- 769px - 1024px (ноутбуки)
|
||||
- > 1024px (десктопы)
|
||||
|
||||
---
|
||||
|
||||
## Интеграции
|
||||
|
||||
### Telegram
|
||||
- Отправка сообщений через Telegram Bot API
|
||||
- Обработка кнопок "Написать мне"
|
||||
- Форма для отправки предложений
|
||||
|
||||
### vCard
|
||||
- Генерация контактов в формате vCard
|
||||
- Автоматическое скачивание контакта
|
||||
|
||||
---
|
||||
|
||||
## Рекомендации по развитию
|
||||
|
||||
### Для добавления нового раздела:
|
||||
1. Создайте HTML структуру в `index.html`
|
||||
2. Добавьте стили в соответствующий CSS файл
|
||||
3. Подключите через `@import` в `style.css`
|
||||
4. Добавьте поддержку темной темы
|
||||
5. Интегрируйте с аналитикой
|
||||
|
||||
### Для модификации существующего:
|
||||
1. Найдите соответствующий CSS файл
|
||||
2. Внесите изменения с учетом адаптивности
|
||||
3. Проверьте поддержку темной темы
|
||||
4. Протестируйте на разных устройствах
|
||||
|
||||
---
|
||||
|
||||
## Производительность
|
||||
|
||||
### Рекомендации по оптимизации:
|
||||
1. **Изображения:** Используйте WebP формат с JPEG/PNG fallback
|
||||
2. **Шрифты:** Локальное хранение системных шрифтов
|
||||
3. **JavaScript:** Дефер загрузки скриптов
|
||||
4. **CSS:** Критический CSS в head
|
||||
|
||||
### Мониторинг:
|
||||
- Встроенная аналитика отслеживает загрузку страниц
|
||||
- Google Analytics можно подключить через `analytics.js`
|
||||
- Рекомендуется использовать Lighthouse для аудита
|
||||
|
||||
---
|
||||
|
||||
## Поддержка браузеров
|
||||
|
||||
- **Chrome** 60+
|
||||
- **Firefox** 55+
|
||||
- **Safari** 12+
|
||||
- **Edge** 79+
|
||||
- **iOS Safari** 12+
|
||||
- **Android Chrome** 60+
|
||||
|
||||
---
|
||||
|
||||
## Лицензия
|
||||
|
||||
Проект распространяется под лицензией MIT. Все изображения и контент защищены авторскими правами Гализа Валитова.
|
||||
|
||||
---
|
||||
|
||||
## Контакты для поддержки
|
||||
|
||||
- **Телеграм:** [@valitovgaziz](https://t.me/valitovgaziz)
|
||||
- **Email:** valitovgaziz@yandex.ru
|
||||
- **GitHub:** [valitovgaziz](https://github.com/valitovgaziz)
|
||||
- **Сайт:** [valitovgaziz.ru](https://valitovgaziz.ru)
|
||||
|
||||
---
|
||||
|
||||
*Последнее обновление документации: 2025*
|
||||
@@ -1,270 +0,0 @@
|
||||
// analytics.js - собственный счетчик аналитики для браузера
|
||||
class CustomAnalytics {
|
||||
constructor() {
|
||||
this.endpoint = 'https://valitovgaziz.ru/api/analytics'; // Ваш endpoint для сбора данных
|
||||
this.queue = [];
|
||||
this.isOnline = navigator.onLine;
|
||||
this.sessionId = this.getSessionId();
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
// Загружаем сохраненные данные из localStorage
|
||||
this.loadFromStorage();
|
||||
|
||||
// Отслеживание событий
|
||||
this.trackPageView();
|
||||
this.setupEventListeners();
|
||||
|
||||
// Периодическая отправка данных
|
||||
setInterval(() => this.flushQueue(), 30000);
|
||||
|
||||
// Отслеживание онлайн/офлайн статуса
|
||||
window.addEventListener('online', () => {
|
||||
this.isOnline = true;
|
||||
this.flushQueue();
|
||||
});
|
||||
window.addEventListener('offline', () => {
|
||||
this.isOnline = false;
|
||||
});
|
||||
|
||||
// Отправка данных перед закрытием страницы
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.trackEvent('page', 'unload');
|
||||
this.flushQueueSync();
|
||||
});
|
||||
}
|
||||
|
||||
trackPageView() {
|
||||
const data = {
|
||||
type: 'pageview',
|
||||
url: window.location.href,
|
||||
referrer: document.referrer,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
screen: `${screen.width}x${screen.height}`,
|
||||
language: navigator.language,
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.addToQueue(data);
|
||||
}
|
||||
|
||||
trackEvent(category, action, label = null, value = null) {
|
||||
const data = {
|
||||
type: 'event',
|
||||
category,
|
||||
action,
|
||||
label,
|
||||
value,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.addToQueue(data);
|
||||
}
|
||||
|
||||
trackClick(element, context = 'unknown') {
|
||||
const data = {
|
||||
type: 'click',
|
||||
element: element.tagName,
|
||||
text: element.textContent?.substring(0, 100),
|
||||
context,
|
||||
timestamp: new Date().toISOString(),
|
||||
url: window.location.href,
|
||||
sessionId: this.sessionId
|
||||
};
|
||||
this.addToQueue(data);
|
||||
}
|
||||
|
||||
addToQueue(data) {
|
||||
this.queue.push(data);
|
||||
|
||||
// Сохраняем в localStorage
|
||||
this.saveToStorage();
|
||||
|
||||
// Отправляем сразу если онлайн и очередь большая
|
||||
if (this.isOnline && this.queue.length >= 3) {
|
||||
this.flushQueue();
|
||||
}
|
||||
}
|
||||
|
||||
async flushQueue() {
|
||||
if (!this.isOnline || this.queue.length === 0) return;
|
||||
|
||||
const batch = [...this.queue];
|
||||
|
||||
try {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
events: batch,
|
||||
sessionId: this.sessionId
|
||||
}),
|
||||
keepalive: true // Позволяет отправлять данные даже при закрытии страницы
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Удаляем отправленные данные из очереди
|
||||
this.queue = this.queue.filter(item => !batch.includes(item));
|
||||
this.saveToStorage();
|
||||
}
|
||||
} catch (error) {
|
||||
console.log('Analytics offline, storing locally');
|
||||
}
|
||||
}
|
||||
|
||||
flushQueueSync() {
|
||||
if (this.queue.length === 0) return;
|
||||
|
||||
// Используем sendBeacon для надежной отправки при закрытии страницы
|
||||
const data = JSON.stringify({
|
||||
events: this.queue,
|
||||
sessionId: this.sessionId
|
||||
});
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon(this.endpoint, data);
|
||||
}
|
||||
}
|
||||
|
||||
getSessionId() {
|
||||
let sessionId = localStorage.getItem('ga_session_id');
|
||||
const now = Date.now();
|
||||
|
||||
if (!sessionId) {
|
||||
sessionId = 'sess_' + now + '_' + Math.random().toString(36).substr(2, 9);
|
||||
localStorage.setItem('ga_session_id', sessionId);
|
||||
localStorage.setItem('ga_session_start', now);
|
||||
}
|
||||
|
||||
// Обновляем время последней активности
|
||||
localStorage.setItem('ga_last_activity', now);
|
||||
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
saveToStorage() {
|
||||
try {
|
||||
localStorage.setItem('ga_queue', JSON.stringify(this.queue));
|
||||
} catch (e) {
|
||||
console.warn('Cannot save analytics to localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
loadFromStorage() {
|
||||
try {
|
||||
const stored = localStorage.getItem('ga_queue');
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (Array.isArray(parsed)) {
|
||||
this.queue = parsed;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Cannot load analytics from localStorage');
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Отслеживание кликов по кнопкам
|
||||
document.addEventListener('click', (e) => {
|
||||
if (e.target.matches('button, .btn, a[href]')) {
|
||||
const context = e.target.closest('.section') ?
|
||||
e.target.closest('.section').querySelector('h2')?.textContent || 'unknown' :
|
||||
'global';
|
||||
this.trackClick(e.target, context);
|
||||
|
||||
// Специальные события для кнопок сотрудничества
|
||||
if (e.target.textContent.includes('сотрудничество') || e.target.textContent.includes('Написать')) {
|
||||
this.trackEvent('conversion', 'contact_click', e.target.textContent.trim());
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Отслеживание отправки форм
|
||||
document.addEventListener('submit', (e) => {
|
||||
this.trackEvent('form', 'submit', e.target.id || 'unknown');
|
||||
});
|
||||
|
||||
// Отслеживание видимости секций
|
||||
this.setupSectionTracking();
|
||||
|
||||
// Отслеживание внешних ссылок
|
||||
document.addEventListener('click', (e) => {
|
||||
const link = e.target.closest('a[href]');
|
||||
if (link && link.hostname !== window.location.hostname) {
|
||||
this.trackEvent('outbound', 'click', link.href);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupSectionTracking() {
|
||||
const sections = document.querySelectorAll('.section');
|
||||
const observedSections = new Set();
|
||||
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting && entry.intersectionRatio >= 0.5) {
|
||||
const sectionId = entry.target.id ||
|
||||
entry.target.querySelector('h2')?.textContent?.substring(0, 50) ||
|
||||
'unknown_section';
|
||||
|
||||
if (!observedSections.has(sectionId)) {
|
||||
observedSections.add(sectionId);
|
||||
this.trackEvent('content', 'section_view', sectionId);
|
||||
}
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: [0.5],
|
||||
rootMargin: '0px 0px -10% 0px'
|
||||
});
|
||||
|
||||
sections.forEach(section => {
|
||||
observer.observe(section);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Инициализация при полной загрузке DOM
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.analytics = new CustomAnalytics();
|
||||
|
||||
// Глобальные функции для ручного отслеживания
|
||||
window.trackEvent = (category, action, label, value) => {
|
||||
if (window.analytics) {
|
||||
window.analytics.trackEvent(category, action, label, value);
|
||||
}
|
||||
};
|
||||
|
||||
// Отслеживание специальных событий для вашего сайта
|
||||
const specialButtons = document.querySelectorAll('[onclick*="sendMessageTelegram"]');
|
||||
specialButtons.forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
trackEvent('business', 'telegram_click', btn.textContent.trim());
|
||||
});
|
||||
});
|
||||
|
||||
// Отслеживание просмотра ключевых элементов
|
||||
const keyElements = document.querySelectorAll('.hero, .team-section, .yalarba-section');
|
||||
const elementObserver = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
const elementType = entry.target.className.split(' ')[0];
|
||||
trackEvent('engagement', `${elementType}_viewed`);
|
||||
elementObserver.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, { threshold: 0.3 });
|
||||
|
||||
keyElements.forEach(el => elementObserver.observe(el));
|
||||
});
|
||||
|
||||
// Fallback для старых браузеров
|
||||
if (!window.Promise) {
|
||||
console.warn('Custom analytics requires Promise support');
|
||||
window.trackEvent = function () { };
|
||||
}
|
||||
@@ -1,590 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="description"
|
||||
content="Блог Валитова Газиза - мысли, проекты, обновления и размышления о разработке и предпринимательстве">
|
||||
<title>Блог | ValitovGaziz - Мысли и обновления</title>
|
||||
<link rel="icon" href="./images/favicon/code_orange.png">
|
||||
<link rel="stylesheet" href="style/blog.css" />
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<!-- Кнопка переключения темы -->
|
||||
<button class="theme-toggle" onclick="toggleTheme()">
|
||||
🌙 Темная тема
|
||||
</button>
|
||||
|
||||
<!-- Навигация -->
|
||||
<nav class="blog-nav">
|
||||
<div class="blog-nav-container">
|
||||
<a href="index.html" class="blog-nav-logo">ValitovGaziz</a>
|
||||
<a href="index.html" class="blog-nav-link">← На главную</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Заголовок блога -->
|
||||
<header class="blog-header">
|
||||
<div class="blog-header-content">
|
||||
<h1 class="blog-title">Блог</h1>
|
||||
<p class="blog-subtitle">Мысли, проекты и обновления из мира разработки и предпринимательства</p>
|
||||
<div class="blog-meta">
|
||||
<span class="blog-meta-item">📝 Личный блог</span>
|
||||
<span class="blog-meta-item">🔄 Регулярные обновления</span>
|
||||
<span class="blog-meta-item">🎯 Фокус на содержании</span>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="blog-container">
|
||||
<!-- Кнопка для мобильного меню (скрыта на десктопе) -->
|
||||
<button class="blog-sidebar-toggle" onclick="toggleSidebar()">
|
||||
📂 Меню блога
|
||||
</button>
|
||||
|
||||
<!-- Основное содержание блога - ЛЕВАЯ КОЛОНКА (70%) -->
|
||||
<div class="blog-content">
|
||||
<!-- Пример записи блога -->
|
||||
<article class="blog-post" id="post1">
|
||||
<header class="blog-post-header">
|
||||
<span class="blog-post-category">Проекты</span>
|
||||
<h2 class="blog-post-title">Новый этап развития Yalarba.ru</h2>
|
||||
<div class="blog-post-meta">
|
||||
<time datetime="2024-03-15">15 марта 2024</time>
|
||||
<span>•</span>
|
||||
<span>5 минут чтения</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-post-content">
|
||||
<p>Сегодня хочу поделиться важным обновлением по проекту Yalarba.ru. Мы завершили переход на новую
|
||||
архитектуру и готовимся к запуску нескольких ключевых функций, которые существенно улучшат
|
||||
пользовательский опыт.</p>
|
||||
|
||||
<h3>Что изменилось:</h3>
|
||||
<ul>
|
||||
<li>Полностью переработанный интерфейс поиска маршрутов</li>
|
||||
<li>Интеграция с картографическими сервисами</li>
|
||||
<li>Улучшенная система рекомендаций</li>
|
||||
<li>Подготовка к мобильному приложению</li>
|
||||
</ul>
|
||||
|
||||
<p>Этот этап занял больше времени, чем планировалось, но результат того стоит. Особенно горжусь тем,
|
||||
как команда справилась с техническими вызовами.</p>
|
||||
|
||||
<blockquote class="blog-quote">
|
||||
"Технологии должны решать реальные проблемы людей, а не создавать новые"
|
||||
</blockquote>
|
||||
|
||||
<p>В ближайших планах — запуск бета-тестирования новых функций и привлечение первых партнеров из
|
||||
туристической отрасли.</p>
|
||||
</div>
|
||||
|
||||
<footer class="blog-post-footer">
|
||||
<div class="blog-post-tags">
|
||||
<a href="#" class="blog-tag">#Yalarba</a>
|
||||
<a href="#" class="blog-tag">#TravelTech</a>
|
||||
<a href="#" class="blog-tag">#Разработка</a>
|
||||
</div>
|
||||
<button onclick="sendMessageTelegram()" class="blog-comment-btn">
|
||||
💬 Обсудить
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<!-- Вторая запись -->
|
||||
<article class="blog-post" id="post2">
|
||||
<header class="blog-post-header">
|
||||
<span class="blog-post-category">Разработка</span>
|
||||
<h2 class="blog-post-title">Переход с Vue 2 на Vue 3: опыт и выводы</h2>
|
||||
<div class="blog-post-meta">
|
||||
<time datetime="2024-03-10">10 марта 2024</time>
|
||||
<span>•</span>
|
||||
<span>7 минут чтения</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-post-content">
|
||||
<p>После нескольких месяцев работы с Vue 3 в продакшене хочу поделиться наблюдениями о переходе с
|
||||
Vue 2.</p>
|
||||
|
||||
<h3>Основные преимущества:</h3>
|
||||
<ol>
|
||||
<li><strong>Composition API</strong> — действительно улучшает переиспользование кода</li>
|
||||
<li><strong>Улучшенная производительность</strong> — заметный прирост в больших приложениях</li>
|
||||
<li><strong>TypeScript поддержка</strong> — наконец-то полноценная интеграция</li>
|
||||
<li><strong>Меньший размер бандла</strong> — tree-shaking работает лучше</li>
|
||||
</ol>
|
||||
|
||||
<h3>Сложности перехода:</h3>
|
||||
<p>Не всё прошло гладко. Некоторые библиотеки ещё не обновились, пришлось искать альтернативы или
|
||||
писать собственные решения. Также Composition API требует изменения мышления, особенно для
|
||||
разработчиков, долго работавших с Options API.</p>
|
||||
|
||||
<p>В целом, переход оправдан. Особенно для новых проектов — рекомендую сразу начинать с Vue 3.</p>
|
||||
</div>
|
||||
|
||||
<footer class="blog-post-footer">
|
||||
<div class="blog-post-tags">
|
||||
<a href="#" class="blog-tag">#Vue3</a>
|
||||
<a href="#" class="blog-tag">#Frontend</a>
|
||||
<a href="#" class="blog-tag">#JavaScript</a>
|
||||
</div>
|
||||
<button onclick="sendMessageTelegram()" class="blog-comment-btn">
|
||||
💬 Обсудить
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<!-- Третья запись -->
|
||||
<article class="blog-post" id="post3">
|
||||
<header class="blog-post-header">
|
||||
<span class="blog-post-category">Мысли</span>
|
||||
<h2 class="blog-post-title">О важности сообщества в разработке</h2>
|
||||
<div class="blog-post-meta">
|
||||
<time datetime="2024-03-05">5 марта 2024</time>
|
||||
<span>•</span>
|
||||
<span>4 минуты чтения</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-post-content">
|
||||
<p>В последнее время всё чаще задумываюсь о том, насколько важно окружение для профессионального
|
||||
роста. Особенно в IT, где технологии меняются так быстро.</p>
|
||||
|
||||
<p>Когда работаешь один, легко застрять в своих паттернах, не замечать новые подходы или повторять
|
||||
одни и те же ошибки. Сообщество — это не только нетворкинг, это:</p>
|
||||
|
||||
<ul>
|
||||
<li><strong>Обратная связь</strong> — свежий взгляд на твои решения</li>
|
||||
<li><strong>Совместное обучение</strong> — каждый знает что-то, чего не знаешь ты</li>
|
||||
<li><strong>Поддержка</strong> — особенно важна в сложные периоды</li>
|
||||
<li><strong>Вдохновение</strong> — видеть успехи других мотивирует</li>
|
||||
</ul>
|
||||
|
||||
<p>Именно поэтому я решил больше инвестировать в развитие сообщества вокруг своих проектов. Если вы
|
||||
читаете это — возможно, нам стоит пообщаться :)</p>
|
||||
</div>
|
||||
|
||||
<footer class="blog-post-footer">
|
||||
<div class="blog-post-tags">
|
||||
<a href="#" class="blog-tag">#Сообщество</a>
|
||||
<a href="#" class="blog-tag">#Развитие</a>
|
||||
<a href="#" class="blog-tag">#IT</a>
|
||||
</div>
|
||||
<button onclick="sendMessageTelegram()" class="blog-comment-btn">
|
||||
💬 Присоединиться
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<!-- Четвёртая запись -->
|
||||
<article class="blog-post" id="post4">
|
||||
<header class="blog-post-header">
|
||||
<span class="blog-post-category">Проекты</span>
|
||||
<h2 class="blog-post-title">EasySite & YalArba: Текущее состояние и роадмап развития</h2>
|
||||
<div class="blog-post-meta">
|
||||
<time datetime="2024-03-20">20 марта 2024</time>
|
||||
<span>•</span>
|
||||
<span>6 минут чтения</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-post-content">
|
||||
<p>С момента запуска первых версий <strong>EasySite102.ru</strong> и <strong>YalArba.ru</strong>
|
||||
прошло
|
||||
несколько месяцев интенсивной разработки. Хочу поделиться текущим состоянием проекта,
|
||||
достигнутыми
|
||||
результатами и планами на ближайшее будущее.</p>
|
||||
|
||||
<h3>🎯 Суть проекта сегодня</h3>
|
||||
<p>Мы строим полноценную экосистему для туристического рынка:</p>
|
||||
<ul>
|
||||
<li><strong>EasySite (B2B)</strong> — конструктор сайтов для владельцев отелей, санаториев,
|
||||
ресторанов и
|
||||
достопримечательностей</li>
|
||||
<li><strong>YalArba (B2C)</strong> — агрегатор для туристов с поиском, отзывами, маршрутами и
|
||||
системой
|
||||
бронирования</li>
|
||||
</ul>
|
||||
|
||||
<h3>✅ Что уже работает (стабильно в продакшене)</h3>
|
||||
<ul>
|
||||
<li><strong>JWT-аутентификация</strong> — безопасный вход для всех типов пользователей</li>
|
||||
<li><strong>Полностью контейнеризованная инфраструктура</strong> — Docker, Docker Compose</li>
|
||||
<li><strong>SSL шифрование</strong> — HTTPS на всех доменах через Let's Encrypt</li>
|
||||
<li><strong>Базовая аналитика</strong> — отслеживание посещений и пользовательского поведения
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h3>🛠️ Технологический стек (актуальный)</h3>
|
||||
<div class="tech-stack">
|
||||
<div class="tech-item">
|
||||
<strong>Frontend:</strong> Nuxt.js 3 (EasySite), Vue 3 + Composition API (YalArba)
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<strong>Backend:</strong> Go (Golang) с использованием GORM, Chi
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<strong>База данных:</strong> PostgreSQL (раздельные инстансы для разных сервисов)
|
||||
</div>
|
||||
<div class="tech-item">
|
||||
<strong>Инфраструктура:</strong> Docker, Nginx, система автоматического обновления SSL
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3>📊 API-архитектура</h3>
|
||||
<p>Проект построен по микросервисной архитектуре:</p>
|
||||
<ul>
|
||||
<li><strong>EasySite API:</strong> <code>localhost:8088/docs</code> (управление сайтами)</li>
|
||||
<li><strong>YalArba API:</strong> <code>localhost:8888/docs</code> (поиск и бронирование)</li>
|
||||
<li><strong>Auth Service:</strong> централизованная аутентификация</li>
|
||||
</ul>
|
||||
|
||||
<blockquote class="blog-quote">
|
||||
"Статус проекта на 20.03.2026: 🟢 Активная разработка. Основная функциональность работает, идёт
|
||||
наполнение
|
||||
контентом и привлечение первых пользователей."
|
||||
</blockquote>
|
||||
|
||||
<h3>📅 Роадмап развития (2026 год)</h3>
|
||||
<p>Приоритеты на ближайшие месяцы:</p>
|
||||
|
||||
<h4>Q3 2026 (Июль–Сентябрь)</h4>
|
||||
<ul>
|
||||
<li><strong>Платежная система</strong> — интеграция с ЮKassa, Tinkoff</li>
|
||||
<li><strong>Мультиязычность</strong> — поддержка английского и башкирского языков</li>
|
||||
<li><strong>API для партнеров</strong> — возможность интеграции сторонних сервисов</li>
|
||||
<li><strong>Система кэширования</strong> — Redis для повышения производительности</li>
|
||||
</ul>
|
||||
|
||||
<h4>Q4 2026 (Октябрь–Декабрь)</h4>
|
||||
<ul>
|
||||
<li><strong>Мобильные приложения</strong> — iOS и Android (React Native)</li>
|
||||
<li><strong>Система рекомендаций</strong> — AI-based подборки на основе поведения</li>
|
||||
<li><strong>Масштабирование инфраструктуры</strong> — переход на Kubernetes</li>
|
||||
<li><strong>Реферальная программа</strong> — для владельцев и туристов</li>
|
||||
</ul>
|
||||
|
||||
<h3>👥 Командная ситуация</h3>
|
||||
<p>Сейчас проект развивается силами небольшой команды (2 человека). Мы активно ищем:</p>
|
||||
<ul>
|
||||
<li><strong>Frontend-разработчиков</strong> (Vue 3, Nuxt.js)</li>
|
||||
<li><strong>Дизайнеров UI/UX</strong></li>
|
||||
<li><strong>Маркетологов</strong> для продвижения в туристической нише</li>
|
||||
<li><strong>Контент-менеджеров</strong> для наполнения платформы</li>
|
||||
</ul>
|
||||
|
||||
<h3>🎯 Когда ждать полноценного запуска?</h3>
|
||||
<p><strong>Бета-версия с основной функциональностью</strong> уже доступна по адресам:</p>
|
||||
<ul>
|
||||
<li><a href="https://easysite102.ru" target="_blank">easysite102.ru</a> (для владельцев)</li>
|
||||
<li><a href="https://yalarba.ru" target="_blank">yalarba.ru</a> (для туристов)</li>
|
||||
</ul>
|
||||
|
||||
<p><strong>Полноценный запуск</strong> с платежами и мобильным приложением планируется на
|
||||
<strong>сентябрь
|
||||
2026</strong>.
|
||||
</p>
|
||||
|
||||
<p><strong>Масштабирование на весь Урал и Поволжье</strong> — цель на <strong>2026 год</strong>.</p>
|
||||
|
||||
<h3>💬 Как можно поучаствовать?</h3>
|
||||
<p>Проект открыт для сотрудничества в разных форматах:</p>
|
||||
<ul>
|
||||
<li><strong>Технические специалисты</strong> — присоединяйтесь к разработке (удаленно)</li>
|
||||
<li><strong>Владельцы туристических объектов</strong> — создайте свой сайт на EasySite</li>
|
||||
<li><strong>Инвесторы и партнеры</strong> — обсуждаем стратегическое сотрудничество</li>
|
||||
<li><strong>Тестировщики</strong> — помогайте улучшать пользовательский опыт</li>
|
||||
</ul>
|
||||
|
||||
<p>Если вас заинтересовал проект — давайте обсудим возможности сотрудничества!</p>
|
||||
</div>
|
||||
|
||||
<footer class="blog-post-footer">
|
||||
<div class="blog-post-tags">
|
||||
<a href="#" class="blog-tag">#EasySite</a>
|
||||
<a href="#" class="blog-tag">#YalArba</a>
|
||||
<a href="#" class="blog-tag">#Туризм</a>
|
||||
<a href="#" class="blog-tag">#Разработка</a>
|
||||
<a href="#" class="blog-tag">#Стартап</a>
|
||||
</div>
|
||||
<button onclick="sendMessageTelegram()" class="blog-comment-btn">
|
||||
💬 Обсудить проект
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
<!-- Пятая запись (новая) -->
|
||||
<article class="blog-post" id="post5">
|
||||
<header class="blog-post-header">
|
||||
<span class="blog-post-category">Мысли</span>
|
||||
<h2 class="blog-post-title">Зачем я создаю YalArba: история и миссия</h2>
|
||||
<div class="blog-post-meta">
|
||||
<time datetime="2024-03-25">25 марта 2024</time>
|
||||
<span>•</span>
|
||||
<span>8 минут чтения</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="blog-post-content">
|
||||
<p>Эта история началась в 2017 году, когда я работал на заводе УМПО и параллельно учился в УКСиВТ.
|
||||
Зимой
|
||||
захотелось отдохнуть — съездить куда-нибудь на машине или просто развеяться в парке. Я, конечно,
|
||||
полез в
|
||||
интернет искать сайты и информацию. И ни на одном сайте не смог найти маршрут или место, куда
|
||||
можно сходить
|
||||
бесплатно.</p>
|
||||
|
||||
<p>Везде мне продавали туры, гостиницы, ещё много вариантов, которые для меня, простого рабочего,
|
||||
совершенно не
|
||||
имели никакой ценности. Пришлось искать через знакомых, через группы, куда можно поехать на
|
||||
отдых с
|
||||
корзинкой, бутербродами, на своей машине.</p>
|
||||
|
||||
<blockquote class="blog-quote">
|
||||
«После этого случая мне сильно захотелось создать приложение, которое приводило бы людей к
|
||||
простому и
|
||||
быстрому решению по отдыху. Особенно это ценно для рабочих, у которых нет особой насмотренности,
|
||||
много
|
||||
возможностей и ресурсов для отдыха вдали от дома или за границей.»
|
||||
</blockquote>
|
||||
|
||||
<h3>Социальность проекта</h3>
|
||||
<p>Большая часть услуг будет бесплатной для всех, включая предпринимателей. Потому что я сам работал
|
||||
на заводе и
|
||||
был всегда (большую часть времени) за станком. Но остальная жизнь тогда больше походила на
|
||||
несистематизированные пьянки и гулянки. В то время это было интересно, сейчас это совершенно не
|
||||
вписывается
|
||||
в моё мировоззрение.</p>
|
||||
|
||||
<p>Мне кажется, в те годы мне не хватало широты взгляда, в общем, некому было подсказать, что
|
||||
отдыхать можно
|
||||
по-другому. Что есть много исторических мест, памятников природы. Я просто не видел альтернативы
|
||||
своему
|
||||
образу отдыха.</p>
|
||||
|
||||
<h3>Миссия сегодня</h3>
|
||||
<p>Сейчас я надеюсь, что смогу предоставить эту альтернативу. Зумеры, конечно, уже меньше подвержены
|
||||
старым
|
||||
способам отдыха (алкоголь употребляют меньше). Но я хочу добавить приложение (веб-портал),
|
||||
которое сможет
|
||||
подсказать, подкинуть идею, что отдых может быть более культурным, не таким дорогим. И главное —
|
||||
недалеко от
|
||||
дома. В рамках района, области или страны.</p>
|
||||
|
||||
<p>И я уверен, что это будет работать и в других странах. Ведь везде есть просто очень занятые люди,
|
||||
всё
|
||||
внимание которых направлено на работу и дом. В этом я вижу мейнстрим, большую цель для своего
|
||||
приложения.
|
||||
</p>
|
||||
|
||||
<h3>Международный потенциал</h3>
|
||||
<p>Через это же приложение можно будет привлекать самостоятельных туристов в нашу страну — через
|
||||
рекламу,
|
||||
распространение в другие страны. Открывать наши места отдыха не только для внутреннего туриста,
|
||||
но и для
|
||||
иностранного (выборочно, конечно).</p>
|
||||
|
||||
<h3>Бизнес-модель</h3>
|
||||
<p>Основная прибыль в этом проекте спрятана в количестве пользователей, которые будут пользоваться
|
||||
порталом
|
||||
(приложением). Конечно, приложение должно буквально делать за пользователя часть работы по
|
||||
поиску, подбору,
|
||||
исследованию и выбору маршрутов отдыха — чтобы получить наилучший результат.</p>
|
||||
|
||||
<h3>Личный путь</h3>
|
||||
<p>Поставленная высокая цель помогает мне добиваться высоких результатов в жизни. Для реализации
|
||||
проекта я
|
||||
выучил несколько языков программирования, английский язык, добился от себя внятных установок на
|
||||
жизнь,
|
||||
развил в себе планирование и смог познакомиться с невероятным количеством людей. Каждый новый
|
||||
рубль,
|
||||
потраченный на этом пути, будет воздан.</p>
|
||||
|
||||
<h3>Инвестиции или самостоятельная разработка?</h3>
|
||||
<p>Часто ловлю себя на мысли: а нужны ли мне инвестиции? И да, я часто и с большой уверенностью
|
||||
говорю: да!
|
||||
Нужны. На сервер, на человеко-часы, на заказные части программы. С другой стороны, передо мной
|
||||
часто
|
||||
возникает дилемма — хочется сделать всё самому.</p>
|
||||
|
||||
<p>Это, конечно, ошибка, которая уже стоила мне пары лет в разработке и ещё аукнется большим
|
||||
количеством
|
||||
времени, потраченным на разработку приложения самостоятельно. Я всё ещё на что-то надеюсь, что
|
||||
как-то смогу
|
||||
завершить приложение (я смогу). Просто это будет не так пафосно и круто, как хотелось бы. И
|
||||
дальше, конечно,
|
||||
встанет вопрос о том, как же его продавать (продвигать). Здесь уже заложены некоторые
|
||||
маркетинговые фишки и
|
||||
ходы для создания нового рынка и выхода на существующие.</p>
|
||||
|
||||
<p>В данный момент больше стараюсь уделять время семье и дому. Но часть моих усилий всегда
|
||||
направлена на работу
|
||||
над проектом. Конкретно сейчас работаю над блогом для проекта, хотя, казалось бы, должен
|
||||
вгрызаться в
|
||||
реализацию серверного приложения на Golang (gorm, chi).</p>
|
||||
|
||||
<p>Но я верю, что этот блог — тоже часть пути. Часть истории, которую я хочу рассказать. Чтобы
|
||||
другие, кто,
|
||||
возможно, оказался в похожей ситуации, знали: альтернатива есть. И мы её создаём.</p>
|
||||
</div>
|
||||
|
||||
<footer class="blog-post-footer">
|
||||
<div class="blog-post-tags">
|
||||
<a href="#" class="blog-tag">#История</a>
|
||||
<a href="#" class="blog-tag">#Миссия</a>
|
||||
<a href="#" class="blog-tag">#СоциальныйПроект</a>
|
||||
<a href="#" class="blog-tag">#Туризм</a>
|
||||
<a href="#" class="blog-tag">#Развитие</a>
|
||||
</div>
|
||||
<button onclick="sendMessageTelegram()" class="blog-comment-btn">
|
||||
💬 Обсудить идею
|
||||
</button>
|
||||
</footer>
|
||||
</article>
|
||||
|
||||
</div>
|
||||
|
||||
|
||||
|
||||
<!-- Боковая панель - ПРАВАЯ КОЛОНКА (30%) -->
|
||||
<aside class="blog-sidebar">
|
||||
<div class="blog-sidebar-section">
|
||||
<h3>О блоге</h3>
|
||||
<p>Здесь я делюсь мыслями о разработке, обновлениями проектов и размышлениями о технологическом
|
||||
предпринимательстве.</p>
|
||||
</div>
|
||||
|
||||
<div class="blog-sidebar-section">
|
||||
<h3>Категории</h3>
|
||||
<ul class="blog-categories">
|
||||
<li><a href="#" class="blog-category">Проекты</a></li>
|
||||
<li><a href="#" class="blog-category">Разработка</a></li>
|
||||
<li><a href="#" class="blog-category">Предпринимательство</a></li>
|
||||
<li><a href="#" class="blog-category">Мысли</a></li>
|
||||
<li><a href="#" class="blog-category">Обновления</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="blog-sidebar-section">
|
||||
<h3>Последние записи</h3>
|
||||
<ul class="blog-recent">
|
||||
<li><a href="#post5">Зачем я создаю YalArba: история и миссия</a></li>
|
||||
<li><a href="#post4">EasySite & YalArba: состояние и планы от Январь 2026</a></li>
|
||||
<li><a href="#post1">Новые возможности Yalarba.ru</a></li>
|
||||
<li><a href="#post2">Переход на Vue 3</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</aside>
|
||||
</main>
|
||||
|
||||
<!-- Пагинация -->
|
||||
<div class="blog-pagination">
|
||||
<a href="#" class="blog-pagination-btn blog-pagination-prev">← Назад</a>
|
||||
<span class="blog-pagination-current">Страница 1 из 4</span>
|
||||
<a href="#" class="blog-pagination-btn blog-pagination-next">Вперед →</a>
|
||||
</div>
|
||||
|
||||
<!-- Футер блога -->
|
||||
<footer class="blog-footer">
|
||||
<div class="blog-footer-content">
|
||||
<p>© 2024 Блог Валитова Газиза. Все записи — личные размышления и опыт.</p>
|
||||
<p>
|
||||
<a href="index.html">На главную</a> •
|
||||
<a href="https://t.me/valitovgaziz">Telegram</a> •
|
||||
<a href="mailto:valitovgaziz@yandex.ru">Email</a>
|
||||
</p>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Функция для переключения темы
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
|
||||
if (document.body.classList.contains('dark-mode')) {
|
||||
btn.textContent = '☀️ Светлая тема';
|
||||
localStorage.setItem('blog-theme', 'dark');
|
||||
} else {
|
||||
btn.textContent = '🌙 Темная тема';
|
||||
localStorage.setItem('blog-theme', 'light');
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка сохраненной темы
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
const savedTheme = localStorage.getItem('blog-theme');
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.classList.add('dark-mode');
|
||||
btn.textContent = '☀️ Светлая тема';
|
||||
} else {
|
||||
btn.textContent = '🌙 Темная тема';
|
||||
}
|
||||
|
||||
// Адаптация для мобильных устройств
|
||||
if (window.innerWidth < 768) {
|
||||
const sidebar = document.querySelector('.blog-sidebar');
|
||||
const toggleBtn = document.querySelector('.blog-sidebar-toggle');
|
||||
|
||||
toggleBtn.style.display = 'block';
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Функция для отправки сообщения в Telegram
|
||||
function sendMessageTelegram() {
|
||||
window.open('https://t.me/valitovgaziz', '_blank');
|
||||
}
|
||||
|
||||
// Функция для переключения сайдбара на мобильных
|
||||
function toggleSidebar() {
|
||||
const sidebar = document.querySelector('.blog-sidebar');
|
||||
sidebar.style.display = sidebar.style.display === 'block' ? 'none' : 'block';
|
||||
}
|
||||
|
||||
// Обработчик изменения размера окна
|
||||
window.addEventListener('resize', function () {
|
||||
const sidebar = document.querySelector('.blog-sidebar');
|
||||
const toggleBtn = document.querySelector('.blog-sidebar-toggle');
|
||||
|
||||
if (window.innerWidth >= 768) {
|
||||
sidebar.style.display = 'block';
|
||||
toggleBtn.style.display = 'none';
|
||||
} else {
|
||||
toggleBtn.style.display = 'block';
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Плавная прокрутка для якорных ссылок
|
||||
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
|
||||
anchor.addEventListener('click', function (e) {
|
||||
e.preventDefault();
|
||||
const targetId = this.getAttribute('href');
|
||||
if (targetId === '#') return;
|
||||
|
||||
const targetElement = document.querySelector(targetId);
|
||||
if (targetElement) {
|
||||
window.scrollTo({
|
||||
top: targetElement.offsetTop - 100,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
// Закрываем сайдбар на мобильных
|
||||
if (window.innerWidth < 768) {
|
||||
const sidebar = document.querySelector('.blog-sidebar');
|
||||
sidebar.style.display = 'none';
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,25 +0,0 @@
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
|
||||
if (document.body.classList.contains('dark-mode')) {
|
||||
btn.textContent = '☀️ Светлая тема';
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
btn.textContent = '🌙 Темная тема';
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
}
|
||||
|
||||
// Загрузка темы при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.classList.add('dark-mode');
|
||||
btn.textContent = '☀️ Светлая тема';
|
||||
} else {
|
||||
btn.textContent = '🌙 Темная тема';
|
||||
}
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
// Digital Background Initialization
|
||||
// Обновляем функцию для интеграции с темной темой
|
||||
function updateBackgroundForTheme() {
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
const binaryDigits = document.querySelectorAll('.binary-digit');
|
||||
const floatingCode = document.querySelectorAll('.floating-code');
|
||||
const connectionNodes = document.querySelectorAll('.connection-node');
|
||||
const dataFlows = document.querySelectorAll('.data-flow');
|
||||
|
||||
// Обновляем цвета элементов в реальном времени
|
||||
const accentColor = isDarkMode ? 'rgba(41, 128, 185, 0.8)' : 'rgba(0, 123, 255, 0.8)';
|
||||
|
||||
binaryDigits.forEach(digit => {
|
||||
digit.style.color = accentColor;
|
||||
});
|
||||
|
||||
floatingCode.forEach(code => {
|
||||
code.style.color = accentColor;
|
||||
});
|
||||
|
||||
connectionNodes.forEach(node => {
|
||||
node.style.background = accentColor;
|
||||
});
|
||||
|
||||
dataFlows.forEach(flow => {
|
||||
flow.style.background = `linear-gradient(90deg, transparent, ${accentColor}, transparent)`;
|
||||
});
|
||||
}
|
||||
|
||||
// Create binary rain effect
|
||||
function createBinaryRain() {
|
||||
const container = document.createElement('div');
|
||||
container.className = 'binary-rain';
|
||||
document.body.appendChild(container);
|
||||
|
||||
// Создаем больше потоков для полного покрытия
|
||||
for (let i = 0; i < 15; i++) { // Увеличиваем количество потоков
|
||||
setTimeout(() => {
|
||||
createBinaryStream(container);
|
||||
}, i * 150);
|
||||
}
|
||||
}
|
||||
|
||||
function createBinaryStream(container) {
|
||||
const stream = document.createElement('div');
|
||||
stream.className = 'binary-stream';
|
||||
// Распределяем потоки по всей ширине экрана
|
||||
const left = Math.random() * 100;
|
||||
stream.style.left = `${left}%`;
|
||||
stream.style.position = 'absolute';
|
||||
stream.style.width = 'auto';
|
||||
|
||||
// Создаем больше цифр в каждом потоке
|
||||
for (let i = 0; i < 15; i++) {
|
||||
const digit = document.createElement('div');
|
||||
digit.className = 'binary-digit';
|
||||
digit.textContent = Math.random() > 0.5 ? '1' : '0';
|
||||
digit.style.position = 'absolute';
|
||||
digit.style.left = '0';
|
||||
digit.style.top = `${-i * 50}px`; // Увеличиваем расстояние между цифрами
|
||||
digit.style.animationDuration = `${2 + Math.random() * 3}s`; // Быстрее анимация
|
||||
digit.style.animationDelay = `${i * 0.15}s`;
|
||||
digit.style.opacity = `${0.3 + Math.random() * 0.7}`; // Разная прозрачность
|
||||
digit.style.fontSize = `${12 + Math.random() * 8}px`; // Разный размер шрифта
|
||||
stream.appendChild(digit);
|
||||
}
|
||||
|
||||
container.appendChild(stream);
|
||||
}
|
||||
|
||||
// Create floating code elements
|
||||
function createFloatingCode() {
|
||||
const symbols = ['{', '}', '<>', '();', '[]', '</>', '=>', '&&', 'function', 'const', 'let', 'var', 'class', 'import', 'export', 'return'];
|
||||
const classes = ['code-bracket', 'code-parenthesis', 'code-brace', 'code-tag'];
|
||||
|
||||
// Создаем больше плавающих элементов
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const symbol = symbols[Math.floor(Math.random() * symbols.length)];
|
||||
const element = document.createElement('div');
|
||||
element.className = `floating-code ${classes[Math.floor(Math.random() * classes.length)]}`;
|
||||
element.textContent = symbol;
|
||||
element.style.left = `${Math.random() * 100}%`;
|
||||
element.style.top = `${Math.random() * 100}%`;
|
||||
element.style.animationDuration = `${20 + Math.random() * 20}s`;
|
||||
element.style.fontSize = `${10 + Math.random() * 6}px`;
|
||||
element.style.opacity = `${0.05 + Math.random() * 0.1}`;
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
}
|
||||
|
||||
// Create connection nodes
|
||||
function createConnectionNodes() {
|
||||
for (let i = 0; i < 20; i++) {
|
||||
const node = document.createElement('div');
|
||||
node.className = 'connection-node';
|
||||
node.style.left = `${Math.random() * 100}%`;
|
||||
node.style.top = `${Math.random() * 100}%`;
|
||||
node.style.animationDelay = `${Math.random() * 4}s`;
|
||||
node.style.width = `${4 + Math.random() * 6}px`;
|
||||
node.style.height = node.style.width;
|
||||
document.body.appendChild(node);
|
||||
}
|
||||
}
|
||||
|
||||
// Create data flow lines
|
||||
function createDataFlows() {
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const flow = document.createElement('div');
|
||||
flow.className = 'data-flow';
|
||||
flow.style.top = `${Math.random() * 100}%`;
|
||||
flow.style.width = `${40 + Math.random() * 50}%`;
|
||||
flow.style.left = `${-Math.random() * 30}%`;
|
||||
flow.style.animationDuration = `${5 + Math.random() * 10}s`;
|
||||
flow.style.animationDelay = `${Math.random() * 8}s`;
|
||||
flow.style.height = `${1 + Math.random() * 2}px`;
|
||||
document.body.appendChild(flow);
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize digital background with theme integration
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Сначала создаем элементы
|
||||
createBinaryRain();
|
||||
createFloatingCode();
|
||||
createConnectionNodes();
|
||||
createDataFlows();
|
||||
|
||||
// Затем настраиваем наблюдение за темой
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.attributeName === 'class') {
|
||||
setTimeout(updateBackgroundForTheme, 100); // Небольшая задержка для применения стилей
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
attributes: true,
|
||||
attributeFilter: ['class']
|
||||
});
|
||||
|
||||
// Инициализируем цвета при загрузке
|
||||
setTimeout(updateBackgroundForTheme, 500);
|
||||
});
|
||||
|
||||
// Также обновляем тему при переключении
|
||||
function toggleTheme() {
|
||||
document.body.classList.toggle('dark-mode');
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
|
||||
if (document.body.classList.contains('dark-mode')) {
|
||||
btn.textContent = '☀️ Светлая тема';
|
||||
localStorage.setItem('theme', 'dark');
|
||||
} else {
|
||||
btn.textContent = '🌙 Темная тема';
|
||||
localStorage.setItem('theme', 'light');
|
||||
}
|
||||
|
||||
// Обновляем фон после переключения темы
|
||||
setTimeout(updateBackgroundForTheme, 100);
|
||||
}
|
||||
|
||||
// Загрузка темы при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const savedTheme = localStorage.getItem('theme');
|
||||
const btn = document.querySelector('.theme-toggle');
|
||||
|
||||
if (savedTheme === 'dark') {
|
||||
document.body.classList.add('dark-mode');
|
||||
btn.textContent = '☀️ Светлая тема';
|
||||
} else {
|
||||
btn.textContent = '🌙 Темная тема';
|
||||
}
|
||||
});
|
||||
|
Before Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
|
Before Width: | Height: | Size: 4.7 MiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-telegram"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M15 10l-4 4l6 6l4 -16l-18 7l4 2l2 6l3 -4" /></svg>
|
||||
|
Before Width: | Height: | Size: 364 B |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="icon icon-tabler icons-tabler-outline icon-tabler-brand-vk"><path stroke="none" d="M0 0h24v24H0z" fill="none"/><path d="M14 19h-4a8 8 0 0 1 -8 -8v-5h4v5a4 4 0 0 0 4 4h0v-9h4v4.5l.03 0a4.531 4.531 0 0 0 3.97 -4.496h4l-.342 1.711a6.858 6.858 0 0 1 -3.658 4.789h0a5.34 5.34 0 0 1 3.566 4.111l.434 2.389h0h-4a4.531 4.531 0 0 0 -3.97 -4.496v4.5z" /></svg>
|
||||
|
Before Width: | Height: | Size: 538 B |
|
Before Width: | Height: | Size: 329 B |
|
Before Width: | Height: | Size: 1.2 KiB |
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,681 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="keywords" content="
|
||||
Fullstack-разработчик, Fullstack developer, Backend разработка, Frontend разработка, Веб-разработка, Программист Java, Программист Golang, Vue3.js разработка, JavaScript разработчик, Разработка веб-приложений, Создание сайтов, Микросервисная архитектура, REST API, PostgreSQL, Docker, Системное проектирование, Поиск тимейтов, Нетворкинг разработчиков, IT сообщество, Open-source проекты, Присоединиться к команде, Команда мечты, Рекрутинг разработчиков, Поиск программистов, Поиск дизайнеров, Поиск аналитиков, Удаленная команда, Профессиональный рост в IT, Совместная разработка, Технический предприниматель, Стартап партнерство, Инвестиции в IT, Соучредитель проекта, Бизнес-партнер, Tech Lead, Развитие проекта, Стратегическое партнерство, Венчурные инвестиции, Digital-продукты, Монетизация проектов, Travel Tech, Туристическая платформа, Планирование путешествий, Yalarba.ru, Туризм Башкортостан, Разработка платформы, Экосистема проектов, Маркетплейс туризма, Сайт-визитка разработчика, Портфолио программиста, Удаленная работа, Фриланс, Аутсорс разработка, Создание продукта с нуля, Agile разработка, Управление IT проектами, Цифровая трансформация
|
||||
" />
|
||||
|
||||
<link rel="icon" href="./images/favicon/code_orange.png" />
|
||||
<link rel="stylesheet" href="style.css" />
|
||||
<script src="scripts.js"></script>
|
||||
<script src="darkThemeToggle.js"></script>
|
||||
<script src="digital_background.js"></script>
|
||||
<script src="JavaScript/analytics.js"></script>
|
||||
<title>ValitovGaziz - Предприниматель - Fullstack-разработчик</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<header class="hero">
|
||||
<div class="hero-content">
|
||||
<div class="hero-text">
|
||||
<h1>ВАЛИТОВ ГАЗИЗ</h1>
|
||||
<h3 class="hero-subtitle">
|
||||
Технологический предриниматель & Fullstack-разработчик
|
||||
</h3>
|
||||
<p class="hero-description">
|
||||
Создаю цифровое решение для отдыха. Развиваю проект
|
||||
<strong>Yalarba.ru</strong> — платформу, которая меняет подход к
|
||||
путешествиям по Башкортостану.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<button onclick="sendMessageTelegram()" class="btn btn-primary">
|
||||
Обсудить сотрудничество
|
||||
</button>
|
||||
<button onclick="sendMessageTelegram()" class="btn btn-secondary">
|
||||
Написать мне
|
||||
</button>
|
||||
<a href="blog.html" class="btn btn-secondary">
|
||||
📝 Читать блог
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Кнопка переключения темы -->
|
||||
<button class="theme-toggle" onclick="toggleTheme()">
|
||||
🌙 Темная тема
|
||||
</button>
|
||||
|
||||
<div class="social_links_block">
|
||||
<div class="social_link_block">
|
||||
<h4>Подписывайтесь в ВК</h4>
|
||||
<a href="https://vk.com" target="_blank">
|
||||
<div class="social_link">
|
||||
<img src="./images/favicon/brand-vk.svg" alt="VK - вконтакте" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
<div class="social_link_block">
|
||||
<h4>Пишите в телеграм</h4>
|
||||
<a href="https://t.me/valitovgaziz" target="_blank">
|
||||
<div class="social_link">
|
||||
<img src="./images/favicon/brand-telegram.svg" alt="телеграмм" />
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="section about">
|
||||
<div class="about-valitovgaziz-photo-box">
|
||||
<img src="./images/ValitovGaziz/valitovgaziz3.jpg" alt="Valitov Gaziz" id="valitovgaziz-photo-img"
|
||||
loading="lazy" />
|
||||
</div>
|
||||
<div class="about-text">
|
||||
<h2>Технический предприниматель и Fullstack-разработчик</h2>
|
||||
<ul>
|
||||
<li>г. Кумерау, 1985 год родиля</li>
|
||||
<li>1992 - 2002 г. Кумертау, БРГИ 3</li>
|
||||
<li>2002 - 2005 г.Уфа, УГАТУ, специальность "Сварочное производство"</li>
|
||||
<li>2005 - 2009 Росстовская область, СКВО, служба в армии по контракту</li>
|
||||
<li>2009 - 2012 г. Кумертау, станочник "Токарь-расточник" КумАПП</li>
|
||||
<li>2012 -2015 село Старосубхангулово, ремонт электроники. ООО "БААС - сервис" владелец</li>
|
||||
<li>2015 - 2020 г. Уфа, учеба в УКСиВТ "Техник по Информационным Системам"</li>
|
||||
<li>с 2021 самообучние и работа над проектом Ял Арба, владелец</li>
|
||||
</ul>
|
||||
<div class="resume-block">
|
||||
<a href="resume/resume.html" id="resume-link" target="_blank">resume</a>
|
||||
</div>
|
||||
<p>
|
||||
Мой подход:
|
||||
<strong>"Технологии как инструмент для решения реальных проблем"</strong>. Именно этот принцип лежит в основе
|
||||
моего флагманского проекта
|
||||
<a href="https://yalarba.ru" target="_blank">
|
||||
Yalarba.ru
|
||||
</a> <a href="https://easysite102.ru" target="_blank">
|
||||
easysite102.ru
|
||||
</a> —
|
||||
платформы, которая упрощает планирование путешествий и открывает новые
|
||||
возможности для туризма.
|
||||
</p>
|
||||
|
||||
<div class="entrepreneur-highlights">
|
||||
<div class="highlight-item">
|
||||
<h4>🎯 Техническое видение</h4>
|
||||
<p>
|
||||
Создаю архитектуру, которая масштабируется и адаптируется под
|
||||
растущие потребности бизнеса
|
||||
</p>
|
||||
</div>
|
||||
<div class="highlight-item">
|
||||
<h4>💡 Бизнес-ориентация</h4>
|
||||
<p>
|
||||
Фокусируюсь на создании ценности для пользователей и устойчивых
|
||||
бизнес-моделях
|
||||
</p>
|
||||
</div>
|
||||
<div class="highlight-item">
|
||||
<h4>🚀 Практический подход</h4>
|
||||
<p>
|
||||
От прототипа к продукту: быстрое тестирование гипотез и
|
||||
итерационная разработка
|
||||
</p>
|
||||
</div>
|
||||
<div class="highlight-item">
|
||||
<h4>❤️🔥 Меня мотивирует</h4>
|
||||
<p>
|
||||
Процесс создания проекта с большой пользой многим людям - это то,
|
||||
что по-настоящему подпитывает меня, давая энергию для ежедневного
|
||||
стремления к лучшему будущему.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- НОВАЯ СЕКЦИЯ: О репозитории -->
|
||||
<div class="section repository">
|
||||
<h2>
|
||||
👨💻 О репозитории
|
||||
<a href="https://github.com/valitovgaziz" class="link-style-none" target="_blank">
|
||||
ValitovGaziz-GitHub.com
|
||||
</a>
|
||||
</h2>
|
||||
<p>
|
||||
Добро пожаловать! Этот репозиторий — моё цифровое портфолио и
|
||||
пространство для экспериментов.
|
||||
</p>
|
||||
|
||||
<div class="projects-grid">
|
||||
<div class="project-card">
|
||||
<h3>
|
||||
🌐
|
||||
<a href="https://valitovgaziz.ru" class="link-style-none" target="_blank">ValitovGaziz.ru</a>
|
||||
</h3>
|
||||
<p>
|
||||
Сайт-визитка, который вы сейчас просматриваете. Здесь собрана
|
||||
информация о моих навыках, проектах и способах связи.
|
||||
</p>
|
||||
</div>
|
||||
<div class="project-card">
|
||||
<h3>
|
||||
🏞️
|
||||
<a href="https://yalarba.ru" class="link-style-none" target="_blank">Yalarba.ru</a>
|
||||
</h3>
|
||||
<p>
|
||||
Платформа для туризма по Башкортостану. Помогает путешественникам
|
||||
открывать новые места и планировать маршруты.
|
||||
</p>
|
||||
</div>
|
||||
<div class="project-card">
|
||||
<h3>
|
||||
🏃♂️
|
||||
<a href="https://BegushiyBashkir.ru" class="link-style-none" target="_blank">BegushiyBashkir.ru</a>
|
||||
</h3>
|
||||
<p>
|
||||
Сайт бегового клуба "Бегущий Башкир", основанного моим другом
|
||||
<a href="https://t.me/zagir_aminev">Аминевым Загиром.</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="current-info">
|
||||
<h3>
|
||||
Что сейчас в работе?
|
||||
<a href="https://easysite102.ru" class="link-style-none" target="_blank"
|
||||
title="Конструктор сайтов для туристических объектов">easysite102.ru</a>
|
||||
</h3>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Разрабатываю:</strong> easysite102.ru - как часть экосистемы
|
||||
YalArba.ru.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Открыт к сотрудничеству:</strong> Участвую в разработке
|
||||
open-source проектов.
|
||||
</li>
|
||||
<li><strong>Нужна помощь:</strong> В развитии моих проектов.</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<strong>Задавайте вопросы</strong> по моим проектам или всему, в чём
|
||||
могу быть полезен.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- НОВАЯ СЕКЦИЯ: Команда мечты -->
|
||||
<div class="section team-section">
|
||||
<div class="team-header">
|
||||
<h2>🚀 Ищем тимейтов для роста и прорыва</h2>
|
||||
<p class="team-tagline">
|
||||
Создаем digital-будущее вместе через разработку цифровых решений
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="team-content">
|
||||
<div class="team-mission">
|
||||
<h3>💫 Наша миссия</h3>
|
||||
<p>
|
||||
Мы строим сообщество профессионалов, которые через технологии
|
||||
создают реальную пользу для людей. Это не коммерческая работа — это
|
||||
возможность расти, решая сложные задачи и открывая новые горизонты.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="team-roles">
|
||||
<h3>👥 Кого мы ищем:</h3>
|
||||
<div class="roles-grid">
|
||||
<div class="role-card">
|
||||
<h4>💻 Программисты</h4>
|
||||
<p>
|
||||
Fullstack, Backend, Frontend, Mobile — все, кто готов строить
|
||||
масштабируемые решения
|
||||
</p>
|
||||
</div>
|
||||
<div class="role-card">
|
||||
<h4>🎨 Дизайнеры</h4>
|
||||
<p>UI/UX, продуктовые дизайнеры, креативные мыслители</p>
|
||||
</div>
|
||||
<div class="role-card">
|
||||
<h4>📊 Аналитики</h4>
|
||||
<p>
|
||||
Ищем аналитика (системного и бизнес‑аналитика) для анализа
|
||||
процессов, сбора требований и перевода бизнес‑потребностей в
|
||||
технические решения.
|
||||
</p>
|
||||
</div>
|
||||
<div class="role-card">
|
||||
<h4>🚀 Продавцы-стратеги</h4>
|
||||
<p>Кто понимает, как digital-продукты меняют рынки</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="team-value">
|
||||
<h3>🎯 Что получаете взамен:</h3>
|
||||
<ul>
|
||||
<li>
|
||||
✅ <strong>Реальный опыт</strong> — задачи уровня коммерческих
|
||||
проектов
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>Профессиональный рост</strong> — следующий уровень
|
||||
навыков гарантирован
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>Нетворкинг</strong> — сообщество сильных специалистов
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>Портфолио</strong> — проекты, которые впечатляют
|
||||
работодателей
|
||||
</li>
|
||||
<li>
|
||||
✅ <strong>Горизонтальное развитие</strong> — возможность
|
||||
пробовать себя в смежных ролях
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="team-challenge">
|
||||
<h3>⚡ Уровень сложности:</h3>
|
||||
<p>
|
||||
Спектр задач достаточно высок — решая их, вы гарантированно
|
||||
подниметесь на следующую ступень развития. Мы работаем с
|
||||
технологиями, которые определяют будущее: микросервисы, AI/ML,
|
||||
масштабируемые архитектуры, современный UX и бизнес-модели.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="team-cta">
|
||||
<h3>Готовы расти вместе?</h3>
|
||||
<p>
|
||||
Если вы ищете не просто проект, а сообщество для профессионального
|
||||
прорыва — давайте знакомиться!
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="sendMessageTelegram()">
|
||||
Присоединиться к команде
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ОБНОВЛЕННАЯ СЕКЦИЯ: Yalarba -->
|
||||
<div id="yalarba-invest" class="section yalarba-section">
|
||||
<div class="yalarba-header">
|
||||
<h2>
|
||||
🚀 <a href="https://yalarba.ru" target="_blank">Yalarba.ru</a> —
|
||||
Travel Tech проект
|
||||
</h2>
|
||||
<p class="yalarba-tagline">
|
||||
Платформа для планирования путешествий нового поколения
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="yalarba-content">
|
||||
<div class="yalarba-stats">
|
||||
<div class="stat">
|
||||
<h3>❤️</h3>
|
||||
<p>проект, рожденный от любви к краю</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<h3>🤝</h3>
|
||||
<p>открыт для помощи и сотрудничества</p>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<h3>🚀</h3>
|
||||
<p>готов к росту с правильной командой</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="yalarba-value">
|
||||
<h3>Технологический стек проекта:</h3>
|
||||
<ul>
|
||||
<li>✅ Микросервисная архитектура на Golang (Gorm, Chi)</li>
|
||||
<li>✅ Современный фронтенд на Nuxt.js 4, Vue3.js</li>
|
||||
<li>✅ Оптимизированная база данных PostgreSQL</li>
|
||||
<li>
|
||||
✅ Контейнеризация и легкое масштабирование через Docker, Docker
|
||||
Swarm
|
||||
</li>
|
||||
<li>✅ Полный цикл разработки от идеи до продукта</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="investment-cta">
|
||||
<h3>Инвестиционные возможности</h3>
|
||||
<p>
|
||||
Проект открыт для стратегических партнерств и инвестиций. Если вас
|
||||
заинтересовала платформа, давайте обсудим перспективы
|
||||
сотрудничества.
|
||||
</p>
|
||||
<button class="btn btn-primary" onclick="sendMessageTelegram()">
|
||||
Обсудить детали
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Опыт работы</h2>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<h3>Основатель и Tech Lead - Yalarba.ru</h3>
|
||||
<p><strong>2020 — настоящее время</strong> (5+ лет)</p>
|
||||
<p>
|
||||
Разработка и продвижение инновационной платформы для планирования
|
||||
путешествий с полным циклом разработки:
|
||||
</p>
|
||||
<ul>
|
||||
<li>Создание архитектуры микросервисов на Nuxt.js 4 и Golang</li>
|
||||
<li>Разработка современного фронтенда на Nuxt.js 4 & Vue3.js</li>
|
||||
<li>Проектирование и оптимизация баз данных PostgreSQL</li>
|
||||
<li>Внедрение Docker и контейнеризации для масштабирования</li>
|
||||
<li>Управление проектом, планирование развития продукта</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="timeline-item">
|
||||
<h3>Fullstack-разработчик (Проектная работа)</h3>
|
||||
<p><strong>2017 — настоящее время</strong> (7+ лет)</p>
|
||||
<p>Участие в различных IT-проектах:</p>
|
||||
<ul>
|
||||
<li>Разработка лендинг-страниц и сайтов-визиток</li>
|
||||
<li>Создание маркетплейсов и туристических агрегаторов</li>
|
||||
<li>
|
||||
Проектирование REST API на Golang (gorm, chi), PostgresQL, https
|
||||
</li>
|
||||
<li>Разработка фронтенда на Nuxt.js 4 (vue3.js)</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Обновленная секция навыков в index.html -->
|
||||
<div class="section">
|
||||
<h2>Навыки</h2>
|
||||
<div class="skills-container">
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">Golang</h3>
|
||||
<span class="skill-level advanced">Продвинутый</span>
|
||||
</div>
|
||||
<p class="skill-description">
|
||||
Высокопроизводительные backend сервисы
|
||||
</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> 2+ лет коммерческой разработки, REST API,
|
||||
best practices
|
||||
</div>
|
||||
<div class="skill-growth">
|
||||
Concurrency patterns, advanced Go features
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">JavaScript</h3>
|
||||
<span class="skill-level advanced">Продвинутый</span>
|
||||
</div>
|
||||
<p class="skill-description">
|
||||
Fullstack разработка, современный ES6+
|
||||
</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> 3+ лет коммерческой разработки, Vue.js,
|
||||
Node.js
|
||||
</div>
|
||||
<div class="skill-growth">
|
||||
TypeScript, advanced patterns, performance optimization
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">Vue3</h3>
|
||||
<span class="skill-level intermediate">Средний</span>
|
||||
</div>
|
||||
<p class="skill-description">
|
||||
Современный фронтенд с Composition API
|
||||
</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> Разработка SPA приложений, Vue Router, Pinia
|
||||
</div>
|
||||
<div class="skill-growth">Vue 3 advanced patterns, testing, SSR</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">Nuxt</h3>
|
||||
<span class="skill-level intermediate">Средний</span>
|
||||
</div>
|
||||
<p class="skill-description">SSR/SSG приложения на Vue.js</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> Nuxt 3, server-side rendering, static site
|
||||
generation
|
||||
</div>
|
||||
<div class="skill-growth">Nuxt 4, advanced caching strategies</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">PostgreSQL</h3>
|
||||
<span class="skill-level intermediate">Средний</span>
|
||||
</div>
|
||||
<p class="skill-description">
|
||||
Реляционные базы данных, оптимизация запросов
|
||||
</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> Проектирование схем, индексы, сложные запросы
|
||||
</div>
|
||||
<div class="skill-growth">
|
||||
Advanced SQL, partitioning, replication
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">Docker</h3>
|
||||
<span class="skill-level intermediate">Средний</span>
|
||||
</div>
|
||||
<p class="skill-description">Контейнеризация приложений</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> Docker Compose, multi-stage builds,
|
||||
оптимизация образов
|
||||
</div>
|
||||
<div class="skill-growth">
|
||||
Kubernetes, Docker Swarm, orchestration
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">Java</h3>
|
||||
<span class="skill-level beginner">Начинающий</span>
|
||||
</div>
|
||||
<p class="skill-description">
|
||||
Backend разработка микросервисов и enterprise приложений
|
||||
</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> Коммерческая разработка 2+ лет, Spring
|
||||
Framework, Hibernate
|
||||
</div>
|
||||
<div class="skill-growth">
|
||||
Углубление в Spring Boot 3, reactive programming
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="skill-card">
|
||||
<div class="skill-header">
|
||||
<h3 class="skill-name">Spring Framework</h3>
|
||||
<span class="skill-level beginner">Начинающий</span>
|
||||
</div>
|
||||
<p class="skill-description">
|
||||
Создание масштабируемых enterprise приложений
|
||||
</p>
|
||||
<div class="skill-acquisition">
|
||||
<strong>Опыт:</strong> Spring Boot, Spring Security, Spring Data,
|
||||
Spring MVC
|
||||
</div>
|
||||
<div class="skill-growth">
|
||||
Изучение Spring Cloud, микросервисная архитектура
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Образование</h2>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<h3>УКСИВТ</h3>
|
||||
<p>Уфимский колледж статистики и информатики</p>
|
||||
<p>Техник по информационным системам</p>
|
||||
<p><strong>2016 - 2020</strong></p>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<h3>
|
||||
Автономная некоммерческая организация высшего образования
|
||||
«Университет Иннополис»
|
||||
</h3>
|
||||
<p>Java enterprise, Java enterprise developer</p>
|
||||
<p><strong>2021 - 2021</strong></p>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<h3>МТИ - Московский технологический институт.</h3>
|
||||
<p>Разработка программного обеспечения</p>
|
||||
<p><strong>2025 - ></strong></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Курсы и сертификаты</h2>
|
||||
<ul>
|
||||
<li>2024: Управление проектами (Skillbox, Эффективный руководитель)</li>
|
||||
<li>2022: Java Full Stack Developer (JetBrains Academy)</li>
|
||||
<li>2021: Java Enterprise developer (Университет Иннополис)</li>
|
||||
<li>
|
||||
2020: Управление по Agile: Scrum, Kanban, Lean (Нетология-групп)
|
||||
</li>
|
||||
<li>2019: English intermediate (Frog-school)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Языки</h2>
|
||||
<ul>
|
||||
<li>Башкирский — Родной</li>
|
||||
<li>Русский — C1 (Продвинутый)</li>
|
||||
<li>Английский — B2 (Средне-продвинутый)</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="section">
|
||||
<h2>Контакты</h2>
|
||||
<p>
|
||||
Всегда рад новым знакомствам и интересным предложениям о сотрудничестве.
|
||||
</p>
|
||||
<div class="contact-info">
|
||||
<p>
|
||||
📱 Телеграм:
|
||||
<a href="https://t.me/valitovgaziz" target="_blank">@valitovgaziz</a>
|
||||
</p>
|
||||
<p>
|
||||
📧 Email:
|
||||
<a href="mailto:valitovgaziz@yandex.ru" target="_blank">valitovgaziz@yandex.ru</a>
|
||||
</p>
|
||||
<p>
|
||||
📞 Телефон:
|
||||
<a href="tel:+79625439343" target="_blank">+7(962)543-93-43</a>
|
||||
</p>
|
||||
</div>
|
||||
<button id="saveContactBtn" onclick="saveContact()">
|
||||
📇 Сохранить контакт
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<footer>
|
||||
<div class="footer-links">
|
||||
<div class="footer-section">
|
||||
<h4>Технологии:</h4>
|
||||
<div class="two-column-grid">
|
||||
<div class="footer-box">
|
||||
<ul>
|
||||
<li>FrontEnd:</li>
|
||||
<li>BackEnd:</li>
|
||||
<li>DataBase:</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-box">
|
||||
<ul>
|
||||
<li>Vue3.js Nuxt.js</li>
|
||||
<li>Golang (Gorm, Chi)</li>
|
||||
<li>PostgresQL</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h4>Контакты:</h4>
|
||||
<div class="two-column-grid">
|
||||
<div class="footer-box">
|
||||
<ul>
|
||||
<li>Telegram:</li>
|
||||
<li>Phone:</li>
|
||||
<li>Email:</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-box">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://t.me/valitovgaziz" target="_blank">@valitovgaziz</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="tel:+79625439343" target="_blank">8 (962) 543-93-43</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="mailto:valitovgaziz@yandex.ru" target="_blank">valitovgaziz@yandex.ru</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-section">
|
||||
<h4>Сообщество:</h4>
|
||||
<div class="two-column-grid">
|
||||
<div class="footer-box">
|
||||
<ul>
|
||||
<li>Telegram channel:</li>
|
||||
<li>Telegram channel:</li>
|
||||
<li>VK group:</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="footer-box">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="https://t.me/ValitovGaziz_Ufa" target="_blank">Мои новости</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://t.me/+oYymS0r6qG9lYWJi" target="_blank">YalArba.ru team</a>
|
||||
</li>
|
||||
<li>
|
||||
<a href="https://vk.com/club222248484?from=groups" target="_blank">ЯлАрба | Путевозитель</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer-end-text">
|
||||
<p>Уфа Ufa Өфө 2025 © Created by Valitov Gaziz</p>
|
||||
</div>
|
||||
</footer>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,261 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="./style/main.css" />
|
||||
<title>Валитов Газиз Камилевич · Резюме</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div class="resume-card">
|
||||
<!-- header -->
|
||||
<div class="header">
|
||||
<h1 class="name">Валитов Газиз Камилевич</h1>
|
||||
<div class="subhead">
|
||||
<span class="subhead-item"><i>📅</i> 40 лет (27.10.1985)</span>
|
||||
<span class="subhead-item"><i>📍</i> Уфа, готов к переезду/командировкам</span>
|
||||
<span class="subhead-item"><i>📞</i> <a href="tel:+79625439343">+7 (962) 5439343</a></span>
|
||||
<span class="subhead-item"><i>✉️</i> <a
|
||||
href="mailto:valitovgaziz@gmail.com">valitovgaziz@yandex.ru</a></span>
|
||||
<span class="subhead-item"><i>📱</i> telegram: @valitovgaziz</span>
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem;">
|
||||
<span class="badge">гражданство РФ</span>
|
||||
<span class="badge">разрешение на работу РФ</span>
|
||||
</div>
|
||||
|
||||
<div class="job-title">Программист / Руководитель проектов</div>
|
||||
|
||||
<div class="specialization-block">
|
||||
<span>Специализации:</span>
|
||||
<div class="spec-list">
|
||||
<div class="spec-item">Руководитель проектов</div>
|
||||
<div class="spec-item">CIO (Директор по ИТ)</div>
|
||||
<div class="spec-item">Программист-разработчик</div>
|
||||
<div class="spec-item">Руководитель группы разработки</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 1rem; display: flex; flex-wrap: wrap; gap: 1rem 2rem;">
|
||||
<span>✅ Полная занятость / частичная / проектная</span>
|
||||
<span>✅ Полный день / сменный / гибкий / удалёнка</span>
|
||||
<span>🚌 Время в пути до 1 часа</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- main two column -->
|
||||
<div class="grid-2">
|
||||
<!-- left column: skills, languages, образование, водительские -->
|
||||
<div class="sidebar">
|
||||
<!-- Ключевые навыки (из списка) -->
|
||||
<div class="section-title">Ключевые навыки</div>
|
||||
<div class="skill-tags">
|
||||
<span class="skill-tag">Английский B2</span><span class="skill-tag">Linux</span><span
|
||||
class="skill-tag">Adobe Photoshop</span>
|
||||
<span class="skill-tag">CorelDRAW</span><span class="skill-tag">C</span><span
|
||||
class="skill-tag">Figma</span>
|
||||
<span class="skill-tag">Git</span><span class="skill-tag">SQL</span><span
|
||||
class="skill-tag">Agile</span>
|
||||
<span class="skill-tag">Java</span><span class="skill-tag">ООП</span><span
|
||||
class="skill-tag">Управление персоналом</span>
|
||||
<span class="skill-tag">Atlassian Jira</span><span class="skill-tag">Spring Framework</span><span
|
||||
class="skill-tag">JUnit</span>
|
||||
<span class="skill-tag">PostgreSQL</span><span class="skill-tag">XPath</span><span
|
||||
class="skill-tag">Go</span>
|
||||
<span class="skill-tag">Intellij IDEA</span><span class="skill-tag">Spring MVC</span><span
|
||||
class="skill-tag">MySQL</span>
|
||||
<span class="skill-tag">Internet Marketing</span><span class="skill-tag">Грамотная речь</span>
|
||||
<span class="skill-tag">Организаторские навыки</span><span class="skill-tag">Обучение
|
||||
персонала</span>
|
||||
<span class="skill-tag">Разработка ПО</span><span class="skill-tag">Agile Project Management</span>
|
||||
</div>
|
||||
|
||||
<!-- Знание языков -->
|
||||
<div class="section-title">Языки</div>
|
||||
<div class="lang-item">
|
||||
<span class="lang-name">Башкирский</span>
|
||||
<span class="lang-level">родной</span>
|
||||
</div>
|
||||
<div class="lang-item">
|
||||
<span class="lang-name">Русский</span>
|
||||
<span class="lang-level">C1 —
|
||||
продвинутый</span>
|
||||
</div>
|
||||
<div class="lang-item">
|
||||
<span class="lang-name">Английский</span>
|
||||
<span class="lang-level">B1 - средний</span>
|
||||
</div>
|
||||
|
||||
<!-- Образование -->
|
||||
<div class="section-title">Образование</div>
|
||||
<div style="margin-bottom: 1rem;">
|
||||
<div><strong style="color: #0b3b5c;">2020 · УКСИВТ</strong> — техник по информационным системам
|
||||
</div>
|
||||
<div style="margin-top: 0.5rem;"><strong style="color: #0b3b5c;">2004 · УГАТУ</strong> —
|
||||
Автоматизация технологических систем, сварочное производство (неоконч. высшее)</div>
|
||||
</div>
|
||||
|
||||
<!-- курсы, повышение квалификации -->
|
||||
<div class="section-title">Повышение квалификации</div>
|
||||
<ul style="list-style-type: none; padding-left: 0;">
|
||||
<li style="margin-bottom: 0.5rem;">🔹 <strong>2024</strong> Skillbox — Эффективный руководитель
|
||||
(управление проектами)</li>
|
||||
<li style="margin-bottom: 0.5rem;">🔹 <strong>2022</strong> JetBrains Academy — Java FullStack
|
||||
Developer</li>
|
||||
<li style="margin-bottom: 0.5rem;">🔹 <strong>2021</strong> Университет Иннополис — java-программист
|
||||
</li>
|
||||
<li style="margin-bottom: 0.5rem;">🔹 <strong>2020</strong> Нетология — Управление по Agile: Scrum,
|
||||
Kanban, Lean</li>
|
||||
<li style="margin-bottom: 0.5rem;">🔹 <strong>2019</strong> Frog-school — English Intermediate</li>
|
||||
<li style="margin-bottom: 0.5rem;">🔹 <strong>2019</strong> Школа студия телерадио (ГУП ТРК
|
||||
"Башкортостан") — телерадиоведущий</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<!-- правый столбец: опыт работы и обо мне -->
|
||||
<div class="main-content">
|
||||
<!-- Опыт работы 12 лет 1 месяц -->
|
||||
<div class="section-title">Опыт работы — 12 лет 1 месяц</div>
|
||||
<!-- Август 2023 — Март 2024 -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ООО "ИКЦ Ял Арба"</span>
|
||||
<span class="job-period">Ноябрь 2022 — настоящее время (30 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Директор</div>
|
||||
<div class="job-desc">Наем персонала, руководитель группы разработки, маркетинг, продажи,
|
||||
разрбаотка, написание кода.</div>
|
||||
</div>
|
||||
|
||||
<!-- Август 2021 — Октябрь 2021 -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ИП Сафаров Я.Р., Уфа</span>
|
||||
<span class="job-period">Авг 2021 — Окт 2021 (3 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Программист 1С</div>
|
||||
<div class="job-desc">Разработка не типовых конфигураций для платформы 1С ERP.</div>
|
||||
</div>
|
||||
|
||||
<!-- Апрель 2019 — Октябрь 2019 -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ГУП ТРК "Башкортостан" БСТ (СМИ)</span>
|
||||
<span class="job-period">Апр 2019 — Окт 2019 (7 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Инженер</div>
|
||||
<div class="job-desc">Звукозапись, обслуживание кинокамер. Командировки в районы в качестве
|
||||
звукового оператора. Сопровождение и ведение записи на реал тайм проекте "Республика лайв".
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Май 2017 — Июль 2017 -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ООО "ЭРУДИТ", Старосубхангулово</span>
|
||||
<span class="job-period">Май 2017 — Июль 2017 (3 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Сетевой администратор</div>
|
||||
<div class="job-desc"></div>
|
||||
</div>
|
||||
|
||||
<!-- Февраль 2017 — Май 2017 (ПАО УМПО) -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ПАО "УМПО", Уфа</span>
|
||||
<span class="job-period">Фев 2017 — Май 2017 (4 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Токарь-расточник (5 разряд)</div>
|
||||
<div class="job-desc">Цех по производству деталей редуктора Ка-32. Обработка на
|
||||
координатно-расточном станке 1964г. Квалитеты до 6, точность менее ±0.01мм, шероховатость 5
|
||||
класс. Чтение чертежей и техпроцесса.</div>
|
||||
</div>
|
||||
|
||||
<!-- Июнь 2016 — Октябрь 2016 ООО "ПФО Вертикаль" -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ООО "ПФО Вертикаль" (аутстафф), Уфа</span>
|
||||
<span class="job-period">Июнь 2016 — Окт 2016 (5 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Токарь-расточник (5 разряд)</div>
|
||||
<div class="job-desc">Инструментальный цех, ночные смены. Координатно-расточной станок, высокая
|
||||
точность (до ±0.01мм), 6 квалитет.</div>
|
||||
</div>
|
||||
|
||||
<!-- Май 2013 — Окт 2015 ООО "БААС-сервис" -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ООО "БААС-сервис", Старосубхангулово</span>
|
||||
<span class="job-period">Май 2013 — Окт 2015 (2 года 6 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Директор (услуги населению: фото/видео, ремонт)</div>
|
||||
<div class="job-desc">Управление персоналом (8 чел), учет наличности, отчетность ИФНС, обучение.
|
||||
Софт, диагностика железа, заправка картриджей, фото/видеосъемка, дизайн.</div>
|
||||
</div>
|
||||
|
||||
<!-- Март 2009 — Окт 2012 ОАО "КумаПП" -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ОАО "КумаПП", Кумертау</span>
|
||||
<span class="job-period">Март 2009 — Окт 2012 (3 года 8 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Токарь-расточник (5 разряд)</div>
|
||||
<div class="job-desc">Расточка деталей вертолета (втулки, качалки, рессоры). Координатно-расточные
|
||||
работы, допуски до ±0.01мм, квалитет 6, шероховатость 5 класс.</div>
|
||||
</div>
|
||||
|
||||
<!-- Авг 2008 — Окт 2008 ООО НОП "Мега-Щит" -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">ООО НОП "Мега-Щит", Ханты-Мансийск</span>
|
||||
<span class="job-period">Авг 2008 — Окт 2008 (3 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Охранник</div>
|
||||
<div class="job-desc">КПП, контроль пропускного режима, досмотр.</div>
|
||||
</div>
|
||||
|
||||
<!-- Май 2005 — Июль 2008 Вооруженные силы РФ -->
|
||||
<div class="job-entry">
|
||||
<div class="job-header">
|
||||
<span class="job-company">Вооруженные силы РФ</span>
|
||||
<span class="job-period">Май 2005 — Июль 2008 (3 года 3 мес)</span>
|
||||
</div>
|
||||
<div class="job-position">Командир отделения</div>
|
||||
<div class="job-desc">Командование отделением.</div>
|
||||
</div>
|
||||
|
||||
<!-- Обо мне -->
|
||||
<div class="section-title">Обо мне</div>
|
||||
<div class="about-text">
|
||||
Программист, коммуникатор, компанейский человек, всегда за положительный движ.
|
||||
</div>
|
||||
<!-- Дополнительные строки из резюме: Уфа, гражданство и пр уже вверху -->
|
||||
<div
|
||||
style="font-size: 0.9rem; color: #1f3f55; margin-top: 1rem; background: #f1f7fd; padding: 0.8rem; border-radius: 12px;">
|
||||
<span style="font-weight:600;">Дополнительно:</span> linux, Figma, SQL, Agile, Jira, Java, Spring,
|
||||
Go, интернет-маркетинг, командная работа, менторство.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- нижний колонтитул (страница 4) -->
|
||||
<div style="background-color: #f4f9ff; padding: 1.5rem 2.5rem; border-top: 1px solid #bfd5e6;">
|
||||
<div style="display: flex; flex-wrap: wrap; gap: 2rem; justify-content: space-between;">
|
||||
<div>
|
||||
<span style="font-weight:600;">🏠 Проживание:</span> Уфа · готов к переезду / командировкам
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-weight:600;">📄 обновлено:</span> февраль 2026 года
|
||||
</div>
|
||||
<div>
|
||||
<span style="font-weight:600;">📞 +7 (962) 543 - 93 - 43</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1,297 +0,0 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: #eef2f5;
|
||||
font-family: 'Segoe UI', Roboto, system-ui, -apple-system, sans-serif;
|
||||
line-height: 1.5;
|
||||
color: #1a2634;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.resume-card {
|
||||
max-width: 1100px;
|
||||
margin: 0 auto;
|
||||
background-color: #ffffff;
|
||||
box-shadow: 0 10px 25px rgba(0, 35, 70, 0.1);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
border-top: 6px solid #0b3b5c;
|
||||
}
|
||||
|
||||
/* header section */
|
||||
.header {
|
||||
background-color: #f9fcff;
|
||||
padding: 2rem 2.5rem 1.5rem 2.5rem;
|
||||
border-bottom: 1px solid #d9e4ed;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 2.8rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.5px;
|
||||
color: #0b3b5c;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.subhead {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1.5rem 2.5rem;
|
||||
margin-top: 0.75rem;
|
||||
color: #2f4858;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.subhead-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.subhead-item i {
|
||||
width: 20px;
|
||||
color: #1e5a7a;
|
||||
font-weight: 400;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.badge {
|
||||
background-color: #e1edf7;
|
||||
color: #0b3b5c;
|
||||
padding: 0.3rem 1rem;
|
||||
border-radius: 30px;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
display: inline-block;
|
||||
margin-right: 10px;
|
||||
margin-bottom: 6px;
|
||||
border: 1px solid #b9d1e4;
|
||||
}
|
||||
|
||||
.job-title {
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
color: #1d4e6b;
|
||||
margin: 1rem 0 0.5rem 0;
|
||||
border-bottom: 2px solid #b0c8da;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.specialization-block {
|
||||
background: #e9f0f7;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 40px;
|
||||
margin: 0.5rem 0 0 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.specialization-block span {
|
||||
font-weight: 600;
|
||||
color: #0b3b5c;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.spec-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem 1.5rem;
|
||||
}
|
||||
|
||||
.spec-item {
|
||||
color: #1e3b4f;
|
||||
border-left: 3px solid #1e5a7a;
|
||||
padding-left: 10px;
|
||||
font-size: 0.98rem;
|
||||
}
|
||||
|
||||
.grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 2.2fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
/* sidebar */
|
||||
.sidebar {
|
||||
background-color: #f7fafd;
|
||||
padding: 2rem 1.8rem;
|
||||
border-right: 1px solid #cddeec;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 2rem 2rem 2rem 0.5rem;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 1.35rem;
|
||||
font-weight: 600;
|
||||
color: #0b3b5c;
|
||||
border-bottom: 2px solid #b6d0e2;
|
||||
padding-bottom: 6px;
|
||||
margin: 1.8rem 0 1.2rem 0;
|
||||
letter-spacing: -0.2px;
|
||||
}
|
||||
|
||||
.section-title:first-of-type {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
margin-bottom: 0.75rem;
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
width: 100px;
|
||||
font-weight: 500;
|
||||
color: #1e5a7a;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: #152b39;
|
||||
}
|
||||
|
||||
.skill-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-tag {
|
||||
background: white;
|
||||
border: 1px solid #b0c5d6;
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 30px;
|
||||
font-size: 0.85rem;
|
||||
color: #0f3a52;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 1px 2px rgba(0, 20, 40, 0.05);
|
||||
}
|
||||
|
||||
.lang-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 1px dashed #b8cbd9;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.lang-name {
|
||||
font-weight: 600;
|
||||
color: #0f3f5c;
|
||||
}
|
||||
|
||||
.lang-level {
|
||||
color: #1d5b81;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* опыт */
|
||||
.job-entry {
|
||||
margin-bottom: 1.8rem;
|
||||
}
|
||||
|
||||
.job-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.job-company {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
color: #0b3b5c;
|
||||
}
|
||||
|
||||
.job-period {
|
||||
background: #dbe7f2;
|
||||
padding: 0.2rem 1rem;
|
||||
border-radius: 30px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #103a52;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.job-position {
|
||||
font-weight: 600;
|
||||
color: #1f5777;
|
||||
margin: 0.15rem 0 0.4rem 0;
|
||||
font-size: 1.05rem;
|
||||
}
|
||||
|
||||
.job-desc {
|
||||
color: #1e333f;
|
||||
font-size: 0.93rem;
|
||||
margin-left: 0.2rem;
|
||||
padding-left: 0.8rem;
|
||||
border-left: 3px solid #9bb7d0;
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.compact-mb {
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
hr {
|
||||
border: 0;
|
||||
border-top: 1px solid #c9dae7;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
background: #f0f6fc;
|
||||
padding: 1.5rem 2rem;
|
||||
border-radius: 50px 8px 50px 8px;
|
||||
color: #103c58;
|
||||
font-size: 1.1rem;
|
||||
border-left: 6px solid #1f6a92;
|
||||
margin: 2rem 0 1rem;
|
||||
}
|
||||
|
||||
.footer-note {
|
||||
font-size: 0.8rem;
|
||||
color: #5e778a;
|
||||
text-align: right;
|
||||
border-top: 1px solid #b9cfdf;
|
||||
padding-top: 0.8rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #1b5f89;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px dotted #8eb1c7;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
border-bottom: 2px solid #0b3b5c;
|
||||
}
|
||||
|
||||
@media (max-width: 750px) {
|
||||
.grid-2 {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 2rem 1.5rem;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
border-right: 0;
|
||||
}
|
||||
|
||||
.name {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
function saveContact() {
|
||||
// Создаем содержимое vCard (VCF)
|
||||
const vCardData = `BEGIN:VCARD
|
||||
VERSION:3.0
|
||||
FN:Валитов Газиз Камилевич
|
||||
N:Валитов;Газиз;Камилевич
|
||||
ORG:FREELANCE
|
||||
TITLE:FULLSTACK_DEVELOPER
|
||||
TEL;TYPE=MOBILE:+79279238823
|
||||
TEL;TYPE=MOBILE:+79044513441
|
||||
TEL;TYPE=MOBILE:+79625439243
|
||||
EMAIL;TYPE=HOME:valitovgaziz@gmail.com
|
||||
EMAIL;TYPE=WORK:valitovgaziz@yandex.ru
|
||||
URL:https://valitovgaziz.ru
|
||||
URL:https://t.me/valitovgaziz
|
||||
URL:https://vk.ru/id378105199
|
||||
BDAY:1985-10-27
|
||||
END:VCARD`;
|
||||
|
||||
// Создаем Blob (бинарный объект) с данными vCard
|
||||
const blob = new Blob([vCardData], { type: 'text/vcard' });
|
||||
|
||||
// Создаем URL для скачивания
|
||||
const url = URL.createObjectURL(blob);
|
||||
|
||||
// Создаем временную ссылку для скачивания
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = 'valitovgaziz.vcf'; // Имя файла
|
||||
link.click();
|
||||
|
||||
// Освобождаем память
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
function loadTermSheet() {
|
||||
// Create a temporary anchor element
|
||||
const link = document.createElement('a');
|
||||
|
||||
// Set correct relative path to the PDF file
|
||||
link.href = './assets/docs/TermSheet.pdf';
|
||||
|
||||
// Set download attribute with filename
|
||||
link.download = 'TermSheet.pdf';
|
||||
|
||||
// Append to body to make it work in some browsers
|
||||
document.body.appendChild(link);
|
||||
|
||||
// Trigger the download
|
||||
link.click();
|
||||
|
||||
// Clean up
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
|
||||
// Обработчик для кнопки "Запросить презентацию"
|
||||
function sendMessageTelegram() {
|
||||
// Проверяем, поддерживает ли браузер диалоги
|
||||
if (typeof window.orientation !== 'undefined' && !window.confirm) {
|
||||
// Для мобильных браузеров без поддержки prompt - открываем Telegram напрямую
|
||||
window.open('https://t.me/valitovgaziz', '_blank');
|
||||
return;
|
||||
}
|
||||
|
||||
const message = prompt("Опишите, пожалуйста, ваше предложение или вопрос. Я свяжусь с вами в ближайшее время:");
|
||||
if (message) {
|
||||
const BOT_TOKEN = "8470085635:AAEPZcsN3n-3FkMdr7DzxbiQ3q8mXZTGwug";
|
||||
const CHAT_ID = "559861569";
|
||||
|
||||
// Используем FormData вместо JSON (более надежно)
|
||||
const formData = new FormData();
|
||||
formData.append('chat_id', CHAT_ID);
|
||||
formData.append('text', `📥 Новое сообщение с сайта ValitovGaziz:\n\n${message}`);
|
||||
formData.append('parse_mode', 'HTML');
|
||||
|
||||
// Альтернативный URL
|
||||
fetch(`https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.ok) {
|
||||
alert("Сообщение успешно отправлено! Я свяжусь с вами в ближайшее время.");
|
||||
} else {
|
||||
console.error('Telegram API Error:', data);
|
||||
alert("Ошибка: " + (data.description || 'Неизвестная ошибка'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error("Ошибка:", error);
|
||||
alert("Произошла ошибка сети. Попробуйте позже или свяжитесь со мной напрямую.");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Универсальный обработчик для кнопок
|
||||
function setupButtonHandlers() {
|
||||
const buttons = document.querySelectorAll('button[onclick*="sendMessageTelegram"]');
|
||||
|
||||
buttons.forEach(button => {
|
||||
// Удаляем старые обработчики
|
||||
button.removeAttribute('onclick');
|
||||
|
||||
// Добавляем универсальные обработчики
|
||||
button.addEventListener('click', handleTelegramButtonClick);
|
||||
button.addEventListener('touchstart', handleTelegramButtonClick, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
// Обработчик кликов для Telegram кнопок
|
||||
function handleTelegramButtonClick(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
// Для touch-событий, предотвращаем повторное срабатывание
|
||||
if (event.type === 'touchstart') {
|
||||
const now = Date.now();
|
||||
if (this.lastTouch && (now - this.lastTouch) < 500) {
|
||||
return;
|
||||
}
|
||||
this.lastTouch = now;
|
||||
}
|
||||
|
||||
sendMessageTelegram();
|
||||
}
|
||||
|
||||
// Инициализация при загрузке страницы
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
setupButtonHandlers();
|
||||
});
|
||||
@@ -1,464 +0,0 @@
|
||||
@import url("./style/digital_background.css");
|
||||
@import url("./style/saveContactsButtonStyle.css");
|
||||
@import url("./style/darkTheme.css");
|
||||
@import url("./style/about.css");
|
||||
@import url("./style/social_link.css");
|
||||
@import url("./style/hero_section.css");
|
||||
@import url("./style/yalarba_investmen.css");
|
||||
@import url("./style/footer.css");
|
||||
@import url("./style/repository_section.css");
|
||||
@import url("./style/links_style.css");
|
||||
@import url("./style/skill_section.css");
|
||||
|
||||
/* style.css - обновленный */
|
||||
:root {
|
||||
--primary: #a9e299;
|
||||
--secondary: #63c1ff;
|
||||
--light: #ecf0f1;
|
||||
--dark: #36304d;
|
||||
--success: #2ecc71;
|
||||
--border-radius: 12px;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
transition: background-color 0.3s ease, color 0.3s ease,
|
||||
border-color 0.3s ease;
|
||||
}
|
||||
|
||||
html {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
min-width: 300px;
|
||||
max-width: 1200px;
|
||||
margin: 10px auto 5px auto;
|
||||
padding: 10px 20px;
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr auto;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Улучшенная сетка для header */
|
||||
header {
|
||||
background-color: var(--primary);
|
||||
color: var(--dark);
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
justify-self: end;
|
||||
padding: 8px 12px;
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: var(--transition);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.social_links_block {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
/* Улучшенная сетка для секций */
|
||||
.section {
|
||||
background: rgb(226, 240, 241);
|
||||
padding: 2rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.entrepreneur-highlights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.highlight-item {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
/* Сетка для команды */
|
||||
.team-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.team-header {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.role-card {
|
||||
background: rgba(255, 255, 255, 0.7);
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
transition: var(--transition);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.role-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Сетка для Yalarba секции */
|
||||
.yalarba-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.yalarba-stats {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
justify-items: center;
|
||||
}
|
||||
|
||||
.yalarba-value ul {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
/* Сетка для контактов */
|
||||
.contact-info {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
/* Сетка для футера */
|
||||
footer {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
text-align: center;
|
||||
padding: 1em 0 0 0;
|
||||
color: var(--dark);
|
||||
font-size: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
padding: 1em;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.two-column-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
border-left: 1px solid black;
|
||||
grid-template-rows: auto; /* Явно указываем одну строку */
|
||||
grid-auto-flow: row; /* Запрещаем автоматическое создание новых строк */
|
||||
}
|
||||
|
||||
/* Кнопки */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: var(--transition);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: white;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* Timeline улучшения */
|
||||
.timeline {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
position: relative;
|
||||
padding-left: 30px;
|
||||
}
|
||||
|
||||
.timeline:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 10px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--secondary);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.timeline-item:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -30px;
|
||||
top: 5px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
background: var(--primary);
|
||||
border: 2px solid var(--secondary);
|
||||
}
|
||||
|
||||
/* Адаптация для мелких экранов */
|
||||
@media (max-width: 768px) {
|
||||
body {
|
||||
padding: 5px 10px;
|
||||
margin: 5px auto;
|
||||
}
|
||||
|
||||
header {
|
||||
grid-template-columns: 1fr;
|
||||
grid-template-rows: auto auto auto;
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
justify-self: center;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.about {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.about-valitovgaziz-photo-box img {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.yalarba-stats {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.two-column-grid {
|
||||
border-left: none;
|
||||
border-top: 1px solid black;
|
||||
padding-top: 1rem;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
padding-left: 20px;
|
||||
}
|
||||
|
||||
.timeline:before {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.timeline-item:before {
|
||||
left: -20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптация для очень маленьких экранов */
|
||||
@media (max-width: 480px) {
|
||||
body {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 1.5rem 1rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.projects-grid,
|
||||
.roles-grid,
|
||||
.entrepreneur-highlights {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.yalarba-stats {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skills-container {
|
||||
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшения для планшетов */
|
||||
@media (min-width: 769px) and (max-width: 1024px) {
|
||||
.about {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.roles-grid {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.entrepreneur-highlights {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
/* Анимации и улучшения UX */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.section {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
.project-card,
|
||||
.role-card,
|
||||
.highlight-item {
|
||||
animation: fadeIn 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Улучшения доступности */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшения фокуса для доступности */
|
||||
button:focus,
|
||||
a:focus {
|
||||
outline: 2px solid var(--secondary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Улучшения для темной темы */
|
||||
body.dark-mode .highlight-item,
|
||||
body.dark-mode .role-card {
|
||||
background: var(--dark-card);
|
||||
}
|
||||
|
||||
body.dark-mode .project-card:hover,
|
||||
body.dark-mode .role-card:hover,
|
||||
body.dark-mode .highlight-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
.about {
|
||||
display: flex;
|
||||
width: inherit;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.about-valitovgaziz-photo-box {
|
||||
width: fit-content;
|
||||
height: fit-content;
|
||||
@media (max-width: 768px) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.about-valitovgaziz-photo-box img {
|
||||
width: 100%;
|
||||
height: auto;
|
||||
object-fit: cover;
|
||||
border-radius: 1em;
|
||||
-webkit-box-shadow: 4px 4px 8px 9px rgba(34, 60, 80, 0.2);
|
||||
-moz-box-shadow: 4px 4px 8px 9px rgba(34, 60, 80, 0.2);
|
||||
box-shadow: 4px 4px 8px 9px rgba(34, 60, 80, 0.2);
|
||||
}
|
||||
|
||||
#about-valitovgaziz-photo-img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
/* Сетка для about секции */
|
||||
.about {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.about-valitovgaziz-photo-box {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.about-text {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@@ -1,617 +0,0 @@
|
||||
/* Базовые стили для блога */
|
||||
:root {
|
||||
--primary: #9ab09492;
|
||||
--secondary: #3498db;
|
||||
--accent: #2ecc71;
|
||||
--light: #f8f9fa;
|
||||
--dark: #1a252f;
|
||||
--gray: #6c757d;
|
||||
--light-gray: #e9ecef;
|
||||
--border-radius: 12px;
|
||||
--shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-hover: 0 8px 15px rgba(0, 0, 0, 0.15);
|
||||
--transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: var(--dark);
|
||||
background-color: var(--light);
|
||||
transition: var(--transition);
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* Стили для темной темы */
|
||||
body.dark-mode {
|
||||
background-color: #121212;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-nav {
|
||||
background-color: #1e1e1e;
|
||||
border-bottom: 1px solid #333;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-header {
|
||||
background: linear-gradient(135deg, #2c3e50 0%, #1a252f 100%);
|
||||
}
|
||||
|
||||
body.dark-mode .blog-container,
|
||||
body.dark-mode .blog-sidebar,
|
||||
body.dark-mode .blog-post {
|
||||
background-color: #1e1e1e;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-sidebar-section,
|
||||
body.dark-mode .blog-post-content,
|
||||
body.dark-mode .blog-post-footer {
|
||||
border-color: #333;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-quote {
|
||||
background-color: #2d2d2d;
|
||||
border-left-color: var(--secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .blog-tag {
|
||||
background-color: #2c3e50;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-pagination-btn {
|
||||
background-color: #2c3e50;
|
||||
color: #e0e0e0;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-footer {
|
||||
background-color: #1a252f;
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
/* Кнопка переключения темы */
|
||||
.theme-toggle {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
z-index: 1000;
|
||||
padding: 8px 16px;
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 20px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: var(--transition);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
/* Навигация */
|
||||
.blog-nav {
|
||||
background-color: white;
|
||||
border-bottom: 1px solid var(--light-gray);
|
||||
padding: 1rem 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.blog-nav-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.blog-nav-logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: var(--dark);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.blog-nav-link {
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.blog-nav-link:hover {
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
/* Заголовок блога */
|
||||
.blog-header {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, var(--secondary) 100%);
|
||||
padding: 4rem 2rem;
|
||||
text-align: center;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blog-header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.blog-title {
|
||||
font-size: 3rem;
|
||||
margin-bottom: 1rem;
|
||||
text-shadow: 2px 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.blog-subtitle {
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 2rem;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.blog-meta {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-meta-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Основной контейнер - ИСПРАВЛЕНЫ ПРОПОРЦИИ */
|
||||
.blog-container {
|
||||
max-width: 1200px;
|
||||
margin: 3rem auto;
|
||||
padding: 0 2rem;
|
||||
display: grid;
|
||||
/* Основная колонка 70%, боковая 30% */
|
||||
grid-template-columns: 1fr 280px;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
/* Боковая панель - компактная */
|
||||
.blog-sidebar {
|
||||
position: sticky;
|
||||
top: 100px;
|
||||
height: fit-content;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.blog-sidebar-section {
|
||||
background-color: white;
|
||||
border-radius: var(--border-radius);
|
||||
padding: 1.25rem;
|
||||
margin-bottom: 1.25rem;
|
||||
box-shadow: var(--shadow);
|
||||
transition: var(--transition);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.blog-sidebar-section h3 {
|
||||
margin-bottom: 0.75rem;
|
||||
color: var(--dark);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.blog-sidebar-section p {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.blog-categories {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.blog-categories li {
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.blog-category {
|
||||
display: block;
|
||||
padding: 0.4rem 0.75rem;
|
||||
color: var(--gray);
|
||||
text-decoration: none;
|
||||
border-radius: 6px;
|
||||
transition: var(--transition);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.blog-category:hover {
|
||||
background-color: var(--light-gray);
|
||||
color: var(--dark);
|
||||
}
|
||||
|
||||
.blog-recent {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.blog-recent li {
|
||||
margin-bottom: 0.8rem;
|
||||
padding-bottom: 0.8rem;
|
||||
border-bottom: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
.blog-recent li:last-child {
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 0;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.blog-recent a {
|
||||
color: var(--dark);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
font-size: 0.9rem;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-recent a:hover {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Основное содержание - шире */
|
||||
.blog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2.5rem;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
background-color: white;
|
||||
border-radius: var(--border-radius);
|
||||
overflow: hidden;
|
||||
box-shadow: var(--shadow);
|
||||
transition: var(--transition);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.blog-post:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: var(--shadow-hover);
|
||||
}
|
||||
|
||||
.blog-post-header {
|
||||
padding: 2rem 2rem 1rem;
|
||||
}
|
||||
|
||||
.blog-post-category {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--gray);
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
font-size: 1.8rem;
|
||||
margin-bottom: 1rem;
|
||||
color: var(--dark);
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.blog-post-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--gray);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.blog-post-content {
|
||||
padding: 0 2rem 1rem;
|
||||
border-bottom: 1px solid var(--light-gray);
|
||||
}
|
||||
|
||||
.blog-post-content p {
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.blog-post-content h3 {
|
||||
margin: 1.5rem 0 1rem;
|
||||
color: var(--dark);
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.blog-post-content ul,
|
||||
.blog-post-content ol {
|
||||
margin: 1rem 0 1.5rem 1.5rem;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-post-content li {
|
||||
margin-bottom: 0.75rem;
|
||||
font-size: 1.05rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-quote {
|
||||
margin: 1.5rem 0;
|
||||
padding: 1.5rem;
|
||||
background-color: var(--light-gray);
|
||||
border-left: 4px solid var(--secondary);
|
||||
font-style: italic;
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.blog-post-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-post-tags {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.blog-tag {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 0.75rem;
|
||||
background-color: var(--light-gray);
|
||||
color: var(--gray);
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.blog-tag:hover {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.blog-comment-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
transition: var(--transition);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.blog-comment-btn:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Пагинация */
|
||||
.blog-pagination {
|
||||
max-width: 1200px;
|
||||
margin: 3rem auto 4rem;
|
||||
padding: 0 2rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.blog-pagination-btn {
|
||||
padding: 0.75rem 1.5rem;
|
||||
background-color: white;
|
||||
color: var(--dark);
|
||||
text-decoration: none;
|
||||
border-radius: var(--border-radius);
|
||||
transition: var(--transition);
|
||||
border: 1px solid var(--light-gray);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.blog-pagination-btn:hover {
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
border-color: var(--secondary);
|
||||
}
|
||||
|
||||
.blog-pagination-current {
|
||||
color: var(--gray);
|
||||
}
|
||||
|
||||
/* Футер */
|
||||
.blog-footer {
|
||||
background-color: var(--dark);
|
||||
color: white;
|
||||
padding: 3rem 0;
|
||||
margin-top: 4rem;
|
||||
}
|
||||
|
||||
.blog-footer-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blog-footer-content p {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.blog-footer-content a {
|
||||
color: var(--light-gray);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
}
|
||||
|
||||
.blog-footer-content a:hover {
|
||||
color: var(--secondary);
|
||||
}
|
||||
|
||||
/* Кнопка для мобильного меню */
|
||||
.blog-sidebar-toggle {
|
||||
display: none;
|
||||
width: 100%;
|
||||
padding: 1rem;
|
||||
background-color: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
margin-bottom: 1rem;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 1100px) {
|
||||
.blog-container {
|
||||
grid-template-columns: 1fr 240px;
|
||||
gap: 2rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 992px) {
|
||||
.blog-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.blog-sidebar {
|
||||
position: static;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-title {
|
||||
font-size: 2.5rem;
|
||||
}
|
||||
|
||||
.blog-container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.blog-nav-container {
|
||||
padding: 0 1.5rem;
|
||||
}
|
||||
|
||||
.blog-header {
|
||||
padding: 3rem 1.5rem;
|
||||
}
|
||||
|
||||
.blog-meta {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-post-header,
|
||||
.blog-post-content,
|
||||
.blog-post-footer {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
font-size: 1.6rem;
|
||||
}
|
||||
|
||||
.blog-pagination {
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.blog-sidebar-toggle {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.blog-sidebar {
|
||||
display: none;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.blog-sidebar.show {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.blog-title {
|
||||
font-size: 2rem;
|
||||
}
|
||||
|
||||
.blog-post-title {
|
||||
font-size: 1.4rem;
|
||||
}
|
||||
|
||||
.blog-container {
|
||||
padding: 0 1rem;
|
||||
}
|
||||
|
||||
.blog-post-header,
|
||||
.blog-post-content,
|
||||
.blog-post-footer {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.blog-post-footer {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.blog-quote {
|
||||
padding: 1rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Улучшения для темной темы */
|
||||
body.dark-mode .blog-sidebar-section {
|
||||
background-color: #2d2d2d;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-category {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-category:hover {
|
||||
background-color: #3d3d3d;
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-recent a {
|
||||
color: #b0b0b0;
|
||||
}
|
||||
|
||||
body.dark-mode .blog-recent a:hover {
|
||||
color: var(--secondary);
|
||||
}
|
||||
@@ -1,246 +0,0 @@
|
||||
/* Переменные для темной темы */
|
||||
:root {
|
||||
--dark-bg: #1a252f;
|
||||
--dark-text: #ecf0f1;
|
||||
--dark-card: #2c3e50;
|
||||
--dark-border: #34495e;
|
||||
--dark-secondary: #2980b9;
|
||||
}
|
||||
|
||||
/* Кнопка переключения темы */
|
||||
header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* В darkTheme.css - добавьте системные предпочтения */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--dark-bg: #0a0a0a;
|
||||
--dark-text: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
padding: 8px 12px;
|
||||
background: var(--secondary);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: all 0.3s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
background: var(--dark-secondary);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Стили для темной темы */
|
||||
body.dark-mode {
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode header {
|
||||
background-color: var(--dark-bg);
|
||||
color: var(--dark-text);
|
||||
background: linear-gradient(135deg, var(--dark-bg) 0%, #1a535c 100%);
|
||||
}
|
||||
|
||||
body.dark-mode .section {
|
||||
background: var(--dark-card);
|
||||
color: var(--dark-text);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
border: 1px solid var(--dark-border);
|
||||
}
|
||||
|
||||
body.dark-mode .contact-info a {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .skill-tag {
|
||||
background-color: var(--dark-border);
|
||||
color: var(--dark-text);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
body.dark-mode footer {
|
||||
color: var(--dark-text);
|
||||
background-color: var(--dark-card);
|
||||
}
|
||||
|
||||
body.dark-mode h1,
|
||||
body.dark-mode h2,
|
||||
body.dark-mode h3 {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .project-link {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .project-link:hover {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-primary {
|
||||
background-color: var(--dark-secondary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-primary:hover {
|
||||
background-color: #3498db;
|
||||
}
|
||||
|
||||
body.dark-mode .btn-secondary {
|
||||
background-color: transparent;
|
||||
color: var(--dark-text);
|
||||
border: 2px solid var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .btn-secondary:hover {
|
||||
background-color: var(--dark-text);
|
||||
color: var(--dark-bg);
|
||||
}
|
||||
|
||||
body.dark-mode .yalarba-section {
|
||||
background: linear-gradient(135deg, var(--dark-card) 0%, #34495e 100%);
|
||||
border-left: 5px solid var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .investment-cta {
|
||||
background-color: var(--dark-border);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .timeline:before {
|
||||
background: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .timeline-item:before {
|
||||
background: var(--dark-card);
|
||||
border: 2px solid var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .highlight {
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
/* Темная тема для социальных ссылок */
|
||||
body.dark-mode .social_link {
|
||||
background-color: var(--dark-card);
|
||||
box-shadow: 0px 0px 14px 0px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Темная тема для фото */
|
||||
body.dark-mode .about-valitovgaziz-photo-box img {
|
||||
box-shadow: 4px 4px 8px 9px rgba(0, 0, 0, 0.3);
|
||||
filter: brightness(0.9);
|
||||
}
|
||||
|
||||
/* ТЕМНАЯ ТЕМА ДЛЯ СЕКЦИИ "О РЕПОЗИТОРИИ" */
|
||||
body.dark-mode .projects-grid {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
body.dark-mode .project-card {
|
||||
background: var(--dark-card);
|
||||
color: var(--dark-text);
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.3);
|
||||
border-left: 4px solid var(--dark-secondary);
|
||||
border: 1px solid var(--dark-border);
|
||||
}
|
||||
|
||||
body.dark-mode .project-card h3 {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .current-info {
|
||||
color: var(--dark-text);
|
||||
border: 1px solid var(--dark-border);
|
||||
}
|
||||
|
||||
body.dark-mode .current-info h3 {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .current-info ul {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .current-info strong {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Темная тема для контактной секции */
|
||||
body.dark-mode .contact-info p {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .contact-info a {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode #saveContactBtn {
|
||||
background: var(--dark-card);
|
||||
color: var(--dark-secondary);
|
||||
border: 2px solid var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode #saveContactBtn:hover {
|
||||
background: var(--dark-secondary);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Темная тема для футера */
|
||||
body.dark-mode .footer-box {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .footer-box a {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .footer-box ul {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Темная тема для hero section */
|
||||
body.dark-mode .hero {
|
||||
background: linear-gradient(135deg, var(--dark-bg) 0%, #1a535c 100%);
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .hero-description {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .hero-subtitle {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
/* Темная тема для секции "Обо мне" */
|
||||
body.dark-mode .about {
|
||||
background: var(--dark-card);
|
||||
}
|
||||
|
||||
body.dark-mode .about-text {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .entrepreneur-highlights {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
|
||||
body.dark-mode .highlight-item h4 {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode .highlight-item p {
|
||||
color: var(--dark-text);
|
||||
}
|
||||
@@ -1,226 +0,0 @@
|
||||
/* Digital Background for Software Development Website */
|
||||
/* Интеграция с существующей системой тем */
|
||||
|
||||
/* Используем переменные из darkTheme.css */
|
||||
:root {
|
||||
/* Light Theme Colors - интегрируем с существующими переменными */
|
||||
--bg-primary-light: #f8f9fa;
|
||||
--bg-secondary-light: #e9ecef;
|
||||
--accent-primary-light: #007bff;
|
||||
--accent-secondary-light: #6c757d;
|
||||
--text-primary-light: #212529;
|
||||
--particle-color-light: rgba(0, 123, 255, 0.1);
|
||||
|
||||
/* Dark Theme Colors - используем переменные из darkTheme.css */
|
||||
--bg-primary-dark: var(--dark-bg, #1a252f);
|
||||
--bg-secondary-dark: var(--dark-card, #2c3e50);
|
||||
--accent-primary-dark: var(--dark-secondary, #2980b9);
|
||||
--accent-secondary-dark: var(--dark-border, #34495e);
|
||||
--text-primary-dark: var(--dark-text, #ecf0f1);
|
||||
--particle-color-dark: rgba(41, 128, 185, 0.15);
|
||||
|
||||
/* Current Theme - defaults to light */
|
||||
--bg-primary: var(--bg-primary-light);
|
||||
--bg-secondary: var(--bg-secondary-light);
|
||||
--accent-primary: var(--accent-primary-light);
|
||||
--accent-secondary: var(--accent-secondary-light);
|
||||
--text-primary: var(--text-primary-light);
|
||||
--particle-color: var(--particle-color-light);
|
||||
}
|
||||
|
||||
/* Интеграция с существующей темной темой */
|
||||
body.dark-mode {
|
||||
--bg-primary: var(--bg-primary-dark);
|
||||
--bg-secondary: var(--bg-secondary-dark);
|
||||
--accent-primary: var(--accent-primary-dark);
|
||||
--accent-secondary: var(--accent-secondary-dark);
|
||||
--text-primary: var(--text-primary-dark);
|
||||
--particle-color: var(--particle-color-dark);
|
||||
}
|
||||
|
||||
/* Base Body Styles */
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%);
|
||||
color: var(--text-primary);
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
overflow-x: hidden;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Animated Background Elements */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 80%, var(--particle-color) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, var(--particle-color) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 40%, var(--particle-color) 0%, transparent 50%);
|
||||
animation: backgroundPulse 8s ease-in-out infinite;
|
||||
z-index: -3;
|
||||
}
|
||||
|
||||
/* Binary Code Rain Effect - ИСПРАВЛЕННЫЙ СТИЛЬ */
|
||||
.binary-rain {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
z-index: -1;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.binary-digit {
|
||||
position: absolute;
|
||||
color: var(--accent-primary);
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
animation: fall linear infinite;
|
||||
text-shadow: 0 0 5px currentColor;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Circuit Board Grid */
|
||||
.circuit-grid {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-image:
|
||||
linear-gradient(var(--accent-secondary) 1px, transparent 1px),
|
||||
linear-gradient(90deg, var(--accent-secondary) 1px, transparent 1px);
|
||||
background-size: 40px 40px;
|
||||
opacity: 0.03;
|
||||
z-index: -2;
|
||||
animation: gridMove 20s linear infinite;
|
||||
}
|
||||
|
||||
/* Floating Code Elements */
|
||||
.floating-code {
|
||||
position: fixed;
|
||||
font-family: 'Courier New', monospace;
|
||||
color: var(--accent-primary);
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.code-bracket { animation: float 15s ease-in-out infinite; }
|
||||
.code-parenthesis { animation: float 18s ease-in-out infinite reverse; }
|
||||
.code-brace { animation: float 20s ease-in-out infinite; }
|
||||
.code-tag { animation: float 16s ease-in-out infinite reverse; }
|
||||
|
||||
/* Connection Nodes */
|
||||
.connection-node {
|
||||
position: fixed;
|
||||
background: var(--accent-primary);
|
||||
border-radius: 50%;
|
||||
opacity: 0.2;
|
||||
z-index: -1;
|
||||
animation: nodePulse 4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Data Flow Lines */
|
||||
.data-flow {
|
||||
position: fixed;
|
||||
background: linear-gradient(90deg, transparent, var(--accent-primary), transparent);
|
||||
opacity: 0.1;
|
||||
z-index: -1;
|
||||
animation: dataFlow 6s linear infinite;
|
||||
}
|
||||
|
||||
/* ОБЯЗАТЕЛЬНО: Убедимся что основной контент поверх фона */
|
||||
header, .section, footer {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes backgroundPulse {
|
||||
0%, 100% { opacity: 0.5; }
|
||||
50% { opacity: 0.8; }
|
||||
}
|
||||
|
||||
@keyframes fall {
|
||||
to {
|
||||
transform: translateY(100vh);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% {
|
||||
transform: translate(0, 0) rotate(0deg);
|
||||
}
|
||||
25% {
|
||||
transform: translate(20px, 20px) rotate(5deg);
|
||||
}
|
||||
50% {
|
||||
transform: translate(-15px, 30px) rotate(-5deg);
|
||||
}
|
||||
75% {
|
||||
transform: translate(10px, -10px) rotate(3deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes gridMove {
|
||||
0% { transform: translate(0, 0); }
|
||||
100% { transform: translate(20px, 20px); }
|
||||
}
|
||||
|
||||
@keyframes nodePulse {
|
||||
0%, 100% { transform: scale(1); opacity: 0.2; }
|
||||
50% { transform: scale(1.3); opacity: 0.4; }
|
||||
}
|
||||
|
||||
@keyframes dataFlow {
|
||||
0% { transform: translateX(-100%); opacity: 0; }
|
||||
50% { opacity: 0.3; }
|
||||
100% { transform: translateX(100%); opacity: 0; }
|
||||
}
|
||||
|
||||
/* Interactive Elements */
|
||||
.connection-node:hover {
|
||||
opacity: 0.6;
|
||||
transform: scale(1.5);
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
/* Performance Optimizations */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.binary-digit,
|
||||
.floating-code,
|
||||
.connection-node,
|
||||
.data-flow,
|
||||
body::before {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive Adjustments */
|
||||
@media (max-width: 768px) {
|
||||
.binary-digit {
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.circuit-grid {
|
||||
background-size: 20px 20px;
|
||||
}
|
||||
|
||||
.floating-code {
|
||||
font-size: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Убедимся что кнопка переключения темы всегда поверх всего */
|
||||
.theme-toggle {
|
||||
z-index: 1000 !important;
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
footer {
|
||||
text-align: center;
|
||||
padding: 1em 0 0 0;
|
||||
color: var(--dark);
|
||||
font-size: 0.9rem;
|
||||
border-radius: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
padding: 1em;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 2rem;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.footer-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-section h4 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.two-column-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
border-left: 1px solid black;
|
||||
}
|
||||
|
||||
.footer-box {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
.footer-box ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.footer-box li {
|
||||
margin-bottom: 0.3rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.footer-box a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.footer-box a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.footer-end-text {
|
||||
margin: 2rem 0 3rem 0;
|
||||
position: relative;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* Адаптивность для мобильных устройств */
|
||||
@media (max-width: 768px) {
|
||||
.footer-links {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.two-column-grid {
|
||||
gap: 0.5rem;
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
/* Hero Section Styles */
|
||||
.hero {
|
||||
background: linear-gradient(135deg, var(--primary) 0%, #2fe892 100%);
|
||||
padding: 4rem 2rem;
|
||||
margin-bottom: 2rem;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
grid-column: 1 / -1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.hero-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.hero-subtitle {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: #137c5c; /* Яркий акцентный цвет */
|
||||
}
|
||||
|
||||
.hero-description {
|
||||
font-size: 1.1rem;
|
||||
margin-bottom: 2rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.btn {
|
||||
background-color: var(--secondary);
|
||||
display: inline-block;
|
||||
padding: 0.8rem 1.5rem;
|
||||
border-radius: 5px;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
color: white;
|
||||
border: 2px solid white;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background-color: #2980b9;
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
flex: 0 0 300px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-image img {
|
||||
width: 100%;
|
||||
max-width: 300px;
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.resume-block {
|
||||
justify-self: left;
|
||||
}
|
||||
|
||||
#resume-link {
|
||||
color: #2980b9;
|
||||
|
||||
/* Адаптивность для героя */
|
||||
@media (max-width: 768px) {
|
||||
.hero-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Адаптивность для героя */
|
||||
@media (max-width: 768px) {
|
||||
.hero-content {
|
||||
flex-direction: column;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/* Добавьте в style.css */
|
||||
a {
|
||||
color: var(--secondary);
|
||||
text-decoration: none;
|
||||
transition: var(--transition);
|
||||
font-weight: 500;
|
||||
position: relative;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
a:not(.btn):after {
|
||||
content: "↗";
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
font-size: 0.8em;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
a:not(.btn):hover {
|
||||
color: #2980b9;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
a:not(.btn):hover:after {
|
||||
opacity: 1;
|
||||
right: -2px;
|
||||
}
|
||||
|
||||
/* Для внутренних ссылок (без внешней иконки) */
|
||||
a[href*="valitovgaziz.ru"]:after,
|
||||
a[href*="#"]:after {
|
||||
content: "→";
|
||||
}
|
||||
|
||||
/* Для темной темы */
|
||||
body.dark-mode a:not(.btn) {
|
||||
color: var(--dark-secondary);
|
||||
}
|
||||
|
||||
body.dark-mode a:not(.btn):hover {
|
||||
color: #3498db;
|
||||
}
|
||||
|
||||
/* Для ссылок в футере */
|
||||
.footer-box a {
|
||||
color: inherit;
|
||||
transition: var(--transition);
|
||||
border-bottom: 1px dotted transparent;
|
||||
}
|
||||
|
||||
.footer-box a:hover {
|
||||
border-bottom-color: currentColor;
|
||||
}
|
||||
|
||||
/* Для ссылок в hero-секции */
|
||||
.hero a {
|
||||
color: #ffd166; /* Акцентный цвет из hero-секции */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.hero a:hover {
|
||||
color: #ffb347;
|
||||
}
|
||||
|
||||
/* Для ссылок в карточках проектов */
|
||||
.project-card a {
|
||||
color: var(--dark-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.project-card a:hover {
|
||||
color: var(--secondary);
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
|
||||
.current-info {
|
||||
margin: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 1rem;
|
||||
}
|
||||
|
||||
.highlight-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
/* Сетка для проектов */
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.project-card {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
border-left: 4px solid var(--secondary);
|
||||
transition: var(--transition);
|
||||
display: grid;
|
||||
grid-template-rows: auto 1fr;
|
||||
gap: 1rem;
|
||||
height: 80%;
|
||||
}
|
||||
|
||||
.project-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 15px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
@@ -1,18 +0,0 @@
|
||||
#saveContactBtn {
|
||||
padding: 10px 20px;
|
||||
background: white;
|
||||
color: #2541b2;
|
||||
border: 2px solid #2541b2;
|
||||
border-radius: 6px;
|
||||
font-size: 15px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
#saveContactBtn:hover {
|
||||
background: #2541b2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#saveContactBtn.dark-mode {
|
||||
background: --dark-card;
|
||||
}
|
||||
@@ -1,136 +0,0 @@
|
||||
/* [file name]: skill_section.css */
|
||||
.skills-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
background: linear-gradient(135deg, var(--secondary) 0%, #2980b9 100%);
|
||||
color: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
transition: var(--transition);
|
||||
display: grid;
|
||||
grid-template-rows: auto auto 1fr auto;
|
||||
gap: 0.8rem;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 4px;
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.skill-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 8px 25px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.skill-header {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr auto;
|
||||
align-items: start;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: bold;
|
||||
margin: 0;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.skill-level {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
padding: 0.3rem 0.8rem;
|
||||
border-radius: 20px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: bold;
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.skill-description {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.4;
|
||||
opacity: 0.9;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.skill-acquisition {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
padding: 0.8rem;
|
||||
border-radius: 8px;
|
||||
font-size: 0.85rem;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.skill-growth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
color: #e8f4fc;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-growth::before {
|
||||
content: '🚀';
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
/* Уровни навыков */
|
||||
.skill-level.beginner { background: rgba(231, 76, 60, 0.8); }
|
||||
.skill-level.intermediate { background: rgba(241, 196, 15, 0.8); }
|
||||
.skill-level.advanced { background: rgba(46, 204, 113, 0.8); }
|
||||
.skill-level.expert { background: rgba(52, 152, 219, 0.8); }
|
||||
|
||||
/* Темная тема */
|
||||
body.dark-mode .skill-card {
|
||||
background: linear-gradient(135deg, var(--dark-card) 0%, #34495e 100%);
|
||||
border: 1px solid var(--dark-border);
|
||||
}
|
||||
|
||||
body.dark-mode .skill-level {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
body.dark-mode .skill-acquisition {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
/* Адаптивность */
|
||||
@media (max-width: 768px) {
|
||||
.skills-container {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.skill-card {
|
||||
padding: 1.2rem;
|
||||
}
|
||||
|
||||
.skill-header {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.3rem;
|
||||
}
|
||||
|
||||
.skill-level {
|
||||
justify-self: start;
|
||||
margin-top: 0.3rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.skills-container {
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
.social_links_block {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.social_link_block {
|
||||
width: fit-content;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.social_links_block h4 {
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.social_link {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
-webkit-box-shadow: 0px 0px 14px 0px rgba(34, 60, 80, 0.2);
|
||||
-moz-box-shadow: 0px 0px 14px 0px rgba(34, 60, 80, 0.2);
|
||||
box-shadow: 0px 0px 14px 0px rgba(34, 60, 80, 0.2);
|
||||
}
|
||||
|
||||
.social_link a {
|
||||
width: fit-content;
|
||||
height: auto;
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
/* Yalarba Investment Section */
|
||||
.yalarba-section {
|
||||
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
|
||||
border-left: 5px solid var(--secondary);
|
||||
}
|
||||
|
||||
.yalarba-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.yalarba-tagline {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.yalarba-stats {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
margin: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.stat {
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat h3 {
|
||||
font-size: 2.5rem;
|
||||
color: var(--secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.yalarba-value ul {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.yalarba-value li {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.investment-cta {
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
padding: 2rem;
|
||||
background-color: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Valitov Gaziz | Технологический предприниматель</title>
|
||||
<meta name="description" content="Валитов Газиз — технологический предприниматель и fullstack-разработчик. Создатель Yalarba.ru, EasySite102.ru." />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "my_site",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"vue": "^3.5.22",
|
||||
"vue-router": "^4.5.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"vite": "^7.1.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<template>
|
||||
<div id="app" :class="{ 'menu-open': mobileMenuOpen }">
|
||||
<TheHeader @toggle-theme="toggleTheme" @toggle-menu="toggleMenu" :theme="theme" :menu-open="mobileMenuOpen" />
|
||||
<main class="main-content">
|
||||
<router-view />
|
||||
</main>
|
||||
<TheFooter />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import TheHeader from './components/TheHeader.vue'
|
||||
import TheFooter from './components/TheFooter.vue'
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
components: {
|
||||
TheHeader,
|
||||
TheFooter,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
theme: 'dark',
|
||||
mobileMenuOpen: false,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
const saved = localStorage.getItem('theme')
|
||||
if (saved) {
|
||||
this.theme = saved
|
||||
}
|
||||
document.documentElement.setAttribute('data-theme', this.theme)
|
||||
},
|
||||
methods: {
|
||||
toggleTheme() {
|
||||
this.theme = this.theme === 'dark' ? 'light' : 'dark'
|
||||
localStorage.setItem('theme', this.theme)
|
||||
document.documentElement.setAttribute('data-theme', this.theme)
|
||||
},
|
||||
toggleMenu() {
|
||||
this.mobileMenuOpen = !this.mobileMenuOpen
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.main-content {
|
||||
min-height: calc(100vh - 160px);
|
||||
padding-top: 70px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
:root {
|
||||
--bg: #ffffff;
|
||||
--bg-secondary: #f5f7fa;
|
||||
--text: #1a1a2e;
|
||||
--text-secondary: #4a4a6a;
|
||||
--accent: #2563eb;
|
||||
--accent-hover: #1d4ed8;
|
||||
--card-bg: #ffffff;
|
||||
--border: #e2e8f0;
|
||||
--header-bg: rgba(255, 255, 255, 0.9);
|
||||
--skill-bg: #eef2ff;
|
||||
--tag-bg: #e0e7ff;
|
||||
--tag-text: #3730a3;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.1);
|
||||
--gradient-hero: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
--max-width: 1100px;
|
||||
--radius: 12px;
|
||||
}
|
||||
|
||||
[data-theme="dark"] {
|
||||
--bg: #0f172a;
|
||||
--bg-secondary: #1e293b;
|
||||
--text: #e2e8f0;
|
||||
--text-secondary: #94a3b8;
|
||||
--accent: #60a5fa;
|
||||
--accent-hover: #3b82f6;
|
||||
--card-bg: #1e293b;
|
||||
--border: #334155;
|
||||
--header-bg: rgba(15, 23, 42, 0.9);
|
||||
--skill-bg: #1e293b;
|
||||
--tag-bg: #1e293b;
|
||||
--tag-text: #93c5fd;
|
||||
--shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
--shadow-lg: 0 10px 30px rgba(0, 0, 0, 0.4);
|
||||
--gradient-hero: linear-gradient(135deg, #1e3a5f 0%, #2d1b69 100%);
|
||||
}
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
transition: background-color 0.3s, color 0.3s;
|
||||
}
|
||||
|
||||
a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: var(--max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.section {
|
||||
padding: 80px 0;
|
||||
}
|
||||
|
||||
.section:nth-child(even) {
|
||||
background-color: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 48px;
|
||||
text-align: center;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.section-title::after {
|
||||
content: '';
|
||||
display: block;
|
||||
width: 60px;
|
||||
height: 4px;
|
||||
background: var(--accent);
|
||||
margin: 12px auto 0;
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
border-radius: var(--radius);
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 12px 28px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: var(--accent-hover);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--accent);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.fade-in {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
transition: opacity 0.6s ease, transform 0.6s ease;
|
||||
}
|
||||
|
||||
.fade-in.visible {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.section {
|
||||
padding: 48px 0;
|
||||
}
|
||||
.section-title {
|
||||
font-size: 1.5rem;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 205 KiB |
@@ -0,0 +1,71 @@
|
||||
<template>
|
||||
<footer class="footer">
|
||||
<div class="container footer-content">
|
||||
<div class="footer-info">
|
||||
<p>Уфа · Ufa · Өфө</p>
|
||||
<p>© 2026 Valitov Gaziz</p>
|
||||
</div>
|
||||
<div class="footer-links">
|
||||
<a href="https://t.me/valitovgaziz" target="_blank" rel="noopener noreferrer" class="footer-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
</a>
|
||||
<a href="https://vk.com/valitovgaziz" target="_blank" rel="noopener noreferrer" class="footer-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"><path d="M15.684 0H8.316C2.879 0 0 2.879 0 8.316v7.368C0 21.121 2.879 24 8.316 24h7.368C21.121 24 24 21.121 24 15.684V8.316C24 2.879 21.121 0 15.684 0zm3.6 16.535h-1.651c-.849 0-1.08-.595-1.728-1.289-.566-.604-1.05-1.097-1.89-1.097-.97 0-1.4.513-1.4 1.316v1.07c0 .456-.178.72-1.076.72-1.583 0-3.308-1.088-4.33-2.539-1.443-1.875-1.83-3.36-1.83-3.636 0-.177.151-.343.448-.343h1.652c.468 0 .64.227.82.735.633 1.937 1.699 3.633 2.345 3.633.22 0 .306-.11.306-.576v-2.22c-.111-1.048-.672-1.124-.672-1.504 0-.242.168-.44.45-.44h2.578c.347 0 .46.196.46.573v2.176c0 .346.149.44.254.44.224 0 .38-.094.598-.317.604-.7 1.118-1.847 1.118-1.847.088-.215.224-.423.513-.423h1.651c.493 0 .597.252.493.619-.307.953-1.577 2.764-1.577 2.764-.168.257-.224.381 0 .638.168.224.739.672 1.126 1.09.392.423.672.747.784.985.196.44-.056.735-.54.735z"/></svg>
|
||||
</a>
|
||||
<a href="mailto:valitovgaziz@yandex.ru" class="footer-link">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13L2 4"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TheFooter',
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.footer {
|
||||
background: var(--bg-secondary);
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 32px 0;
|
||||
margin-top: 80px;
|
||||
}
|
||||
|
||||
.footer-content {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-info p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.footer-links {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.footer-link {
|
||||
color: var(--text-secondary);
|
||||
transition: color 0.2s;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.footer-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.footer-content {
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,178 @@
|
||||
<template>
|
||||
<header class="header">
|
||||
<div class="container header-container">
|
||||
<router-link to="/" class="logo">Valitov<span>Gaziz</span></router-link>
|
||||
<nav class="nav" :class="{ 'nav-open': menuOpen }">
|
||||
<router-link to="/" class="nav-link" @click="$emit('toggle-menu')">Главная</router-link>
|
||||
<a href="#about" class="nav-link" @click="closeMenu">Обо мне</a>
|
||||
<a href="#projects" class="nav-link" @click="closeMenu">Проекты</a>
|
||||
<a href="#experience" class="nav-link" @click="closeMenu">Опыт</a>
|
||||
<a href="#skills" class="nav-link" @click="closeMenu">Навыки</a>
|
||||
<a href="#contact" class="nav-link" @click="closeMenu">Контакты</a>
|
||||
<router-link to="/blog" class="nav-link" @click="$emit('toggle-menu')">Блог</router-link>
|
||||
</nav>
|
||||
<div class="header-actions">
|
||||
<button class="theme-toggle" @click="$emit('toggle-theme')" :aria-label="theme === 'dark' ? 'Светлая тема' : 'Тёмная тема'">
|
||||
<svg v-if="theme === 'dark'" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg>
|
||||
<svg v-else width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/></svg>
|
||||
</button>
|
||||
<button class="burger" @click="$emit('toggle-menu')" :aria-label="menuOpen ? 'Закрыть меню' : 'Открыть меню'">
|
||||
<span></span><span></span><span></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'TheHeader',
|
||||
props: {
|
||||
theme: { type: String, required: true },
|
||||
menuOpen: { type: Boolean, default: false },
|
||||
},
|
||||
emits: ['toggle-theme', 'toggle-menu'],
|
||||
methods: {
|
||||
closeMenu() {
|
||||
if (this.menuOpen) {
|
||||
this.$emit('toggle-menu')
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--header-bg);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
transition: background 0.3s;
|
||||
}
|
||||
|
||||
.header-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 70px;
|
||||
}
|
||||
|
||||
.logo {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
color: var(--text);
|
||||
letter-spacing: -0.5px;
|
||||
}
|
||||
|
||||
.logo span {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.nav {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.nav-link:hover,
|
||||
.router-link-exact-active {
|
||||
color: var(--accent);
|
||||
background: var(--skill-bg);
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.theme-toggle {
|
||||
background: none;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.theme-toggle:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.burger {
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.burger span {
|
||||
display: block;
|
||||
width: 24px;
|
||||
height: 2px;
|
||||
background: var(--text);
|
||||
border-radius: 2px;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.menu-open .burger span:nth-child(1) {
|
||||
transform: rotate(45deg) translate(5px, 5px);
|
||||
}
|
||||
|
||||
.menu-open .burger span:nth-child(2) {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.menu-open .burger span:nth-child(3) {
|
||||
transform: rotate(-45deg) translate(5px, -5px);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.nav {
|
||||
position: fixed;
|
||||
top: 70px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: var(--bg);
|
||||
flex-direction: column;
|
||||
padding: 20px;
|
||||
gap: 4px;
|
||||
transform: translateY(-100%);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: all 0.3s;
|
||||
border-bottom: 1px solid var(--border);
|
||||
z-index: 999;
|
||||
}
|
||||
|
||||
.nav-open {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
}
|
||||
|
||||
.burger {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,11 @@
|
||||
import './assets/main.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,20 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import Home from '../views/Home.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: Home,
|
||||
},
|
||||
{
|
||||
path: '/blog',
|
||||
name: 'Blog',
|
||||
component: () => import('../views/Blog.vue'),
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,181 @@
|
||||
<template>
|
||||
<div class="blog-page">
|
||||
<section class="blog-hero">
|
||||
<div class="container">
|
||||
<h1 class="blog-title">Блог</h1>
|
||||
<p class="blog-subtitle">Мысли, идеи и заметки о разработке, технологиях и предпринимательстве</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<div class="container">
|
||||
<div class="blog-list">
|
||||
<article v-for="(post, index) in posts" :key="index" class="blog-post card" :class="{ 'fade-in': true, 'visible': true }">
|
||||
<div class="post-meta">
|
||||
<span class="post-date">{{ post.date }}</span>
|
||||
<span class="post-read-time">{{ post.readTime }}</span>
|
||||
</div>
|
||||
<h2 class="post-title">{{ post.title }}</h2>
|
||||
<p class="post-excerpt">{{ post.excerpt }}</p>
|
||||
<blockquote v-if="post.quote" class="post-quote">
|
||||
{{ post.quote }}
|
||||
</blockquote>
|
||||
<p v-if="post.additional" class="post-excerpt">{{ post.additional }}</p>
|
||||
<div class="post-tags">
|
||||
<span v-for="(tag, tIndex) in post.tags" :key="tIndex" class="post-tag">{{ tag }}</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'BlogView',
|
||||
data() {
|
||||
return {
|
||||
posts: [
|
||||
{
|
||||
date: '20 марта 2024',
|
||||
readTime: '6 мин чтения',
|
||||
title: 'EasySite & YalArba: текущее состояние и планы развития',
|
||||
excerpt: 'EasySite (B2B) — конструктор сайтов для отелей, санаториев, ресторанов. YalArba (B2C) — агрегатор для туристов с поиском, отзывами и системой бронирования.',
|
||||
additional: 'Уже работают: JWT-авторизация, Docker-инфраструктура, SSL (Let\'s Encrypt), базовая аналитика. В бете: easysite102.ru и yalarba.ru.',
|
||||
tags: ['EasySite', 'YalArba', 'Туризм', 'Стартап'],
|
||||
},
|
||||
{
|
||||
date: '25 марта 2024',
|
||||
readTime: '8 мин чтения',
|
||||
title: 'Почему я создаю YalArba: история и миссия',
|
||||
excerpt: 'История началась в 2017 году — работа на заводе и учёба не могли затмить желания создать что-то полезное. Не найдя бесплатных маршрутов для путешествий онлайн, решил сделать решение сам.',
|
||||
additional: 'Большинство сервисов будут бесплатными — хочется предоставить доступные альтернативы для всех. Бизнес-модель строится на ценности от количества пользователей.',
|
||||
quote: 'Технологии должны решать реальные проблемы людей, а не создавать новые.',
|
||||
tags: ['История', 'Миссия', 'Социальный проект', 'Туризм'],
|
||||
},
|
||||
{
|
||||
date: '15 марта 2024',
|
||||
readTime: '5 мин чтения',
|
||||
title: 'Новый этап развития Yalarba.ru',
|
||||
excerpt: 'Завершён переход на новую архитектуру. Реализованы: обновлённый интерфейс поиска маршрутов, интеграция картографических сервисов, улучшенная система рекомендаций, подготовка мобильного приложения.',
|
||||
tags: ['Yalarba', 'TravelTech', 'Разработка'],
|
||||
},
|
||||
{
|
||||
date: '10 марта 2024',
|
||||
readTime: '7 мин чтения',
|
||||
title: 'Переход с Vue 2 на Vue 3: опыт и выводы',
|
||||
excerpt: 'Ключевые преимущества: Composition API, улучшенная производительность, поддержка TypeScript, меньший размер бандла. Миграция прошла гладко, но потребовала внимания к деталям.',
|
||||
tags: ['Vue3', 'Фронтенд', 'JavaScript'],
|
||||
},
|
||||
{
|
||||
date: '5 марта 2024',
|
||||
readTime: '4 мин чтения',
|
||||
title: 'О важности сообщества в разработке',
|
||||
excerpt: 'Профессиональный рост через сообщество: обратная связь, совместное обучение, поддержка и вдохновение. Нетворкинг и обмен опытом — ключ к развитию.',
|
||||
tags: ['Сообщество', 'Разработка', 'IT'],
|
||||
},
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.blog-hero {
|
||||
padding: 80px 0 40px;
|
||||
text-align: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.blog-title {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 800;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.blog-subtitle {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.blog-list {
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.blog-post {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.post-meta {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 12px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.post-date {
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.post-read-time {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.post-excerpt {
|
||||
font-size: 0.95rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.post-quote {
|
||||
border-left: 3px solid var(--accent);
|
||||
padding: 12px 20px;
|
||||
margin: 16px 0;
|
||||
font-style: italic;
|
||||
color: var(--text-secondary);
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.post-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.post-tag {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.blog-title {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.post-title {
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,771 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<section class="hero">
|
||||
<div class="hero-bg"></div>
|
||||
<div class="container hero-content">
|
||||
<div class="hero-text">
|
||||
<p class="hero-greeting">Привет, я</p>
|
||||
<h1 class="hero-name">Валитов Газиз</h1>
|
||||
<p class="hero-title">Технологический предприниматель & Fullstack-разработчик</p>
|
||||
<p class="hero-desc">
|
||||
Создаю цифровые продукты, которые меняют жизнь людей к лучшему.
|
||||
Основатель проектов Yalarba.ru и EasySite102.ru.
|
||||
</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="https://t.me/valitovgaziz" target="_blank" rel="noopener noreferrer" class="btn btn-primary">
|
||||
Написать в Telegram
|
||||
</a>
|
||||
<router-link to="/blog" class="btn btn-outline">
|
||||
Читать блог
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="hero-social">
|
||||
<a href="https://t.me/valitovgaziz" target="_blank" rel="noopener noreferrer" class="social-link" title="Telegram">@valitovgaziz</a>
|
||||
<a href="https://vk.com/valitovgaziz" target="_blank" rel="noopener noreferrer" class="social-link" title="VK">vk.com/valitovgaziz</a>
|
||||
<a href="mailto:valitovgaziz@yandex.ru" class="social-link" title="Email">valitovgaziz@yandex.ru</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-image">
|
||||
<div class="hero-photo">
|
||||
<img src="/src/assets/photo.jpg" alt="Valitov Gaziz" class="photo-img" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="about" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Обо мне</h2>
|
||||
<div class="about-content">
|
||||
<div class="about-text">
|
||||
<p class="about-paragraph">
|
||||
Родился в городе Кумертау в 1985 году. Окончил УГАТУ (Уфимский государственный авиационный технический университет), прошёл службу в армии, работал на производстве.
|
||||
</p>
|
||||
<p class="about-paragraph">
|
||||
С 2015 года в IT — прошёл путь от техника до основателя собственного технологического проекта. За плечами опыт работы с Go, Vue 3, Nuxt.js, PostgreSQL, Docker и другими современными технологиями.
|
||||
</p>
|
||||
<p class="about-paragraph">
|
||||
Моя миссия — создавать продукты, которые приносят реальную пользу людям и делают туризм в Башкортостане доступнее и удобнее.
|
||||
</p>
|
||||
</div>
|
||||
<div class="about-highlights">
|
||||
<div class="highlight-card">
|
||||
<div class="highlight-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/></svg>
|
||||
</div>
|
||||
<h4>Техническое видение</h4>
|
||||
<p>Создаю масштабируемую архитектуру, выбираю правильные инструменты под задачи</p>
|
||||
</div>
|
||||
<div class="highlight-card">
|
||||
<div class="highlight-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>
|
||||
</div>
|
||||
<h4>Бизнес-ориентация</h4>
|
||||
<p>Фокус на пользовательской ценности и устойчивых бизнес-моделях</p>
|
||||
</div>
|
||||
<div class="highlight-card">
|
||||
<div class="highlight-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
|
||||
</div>
|
||||
<h4>Практический подход</h4>
|
||||
<p>От прототипа до продукта — быстрое тестирование гипотез и итеративное развитие</p>
|
||||
</div>
|
||||
<div class="highlight-card">
|
||||
<div class="highlight-icon">
|
||||
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"/></svg>
|
||||
</div>
|
||||
<h4>Мотивация</h4>
|
||||
<p>Создание проекта, который приносит пользу многим людям</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="projects" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Проекты</h2>
|
||||
<div class="projects-grid">
|
||||
<div class="project-card card">
|
||||
<div class="project-header">
|
||||
<h3 class="project-name">Yalarba.ru</h3>
|
||||
<span class="project-status">Активный</span>
|
||||
</div>
|
||||
<p class="project-desc">
|
||||
Туристическая платформа для Республики Башкортостан. Помогает путешественникам открывать новые места, строить маршруты и планировать поездки.
|
||||
</p>
|
||||
<div class="project-tech">
|
||||
<span class="tech-tag">Go</span>
|
||||
<span class="tech-tag">Nuxt.js 4</span>
|
||||
<span class="tech-tag">PostgreSQL</span>
|
||||
<span class="tech-tag">Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-card card">
|
||||
<div class="project-header">
|
||||
<h3 class="project-name">EasySite102.ru</h3>
|
||||
<span class="project-status">Бета</span>
|
||||
</div>
|
||||
<p class="project-desc">
|
||||
Конструктор сайтов для бизнеса в сфере туризма: отелей, санаториев, ресторанов. Часть экосистемы YalArba.
|
||||
</p>
|
||||
<div class="project-tech">
|
||||
<span class="tech-tag">Vue 3</span>
|
||||
<span class="tech-tag">Nuxt.js</span>
|
||||
<span class="tech-tag">Go</span>
|
||||
<span class="tech-tag">PostgreSQL</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="project-card card">
|
||||
<div class="project-header">
|
||||
<h3 class="project-name">BegushiyBashkir.ru</h3>
|
||||
<span class="project-status">Активный</span>
|
||||
</div>
|
||||
<p class="project-desc">
|
||||
Беговой клуб. Сайт для бегового сообщества, основанного другом и партнёром Аминевым Загиром.
|
||||
</p>
|
||||
<div class="project-tech">
|
||||
<span class="tech-tag">Vue 3</span>
|
||||
<span class="tech-tag">Go</span>
|
||||
<span class="tech-tag">PostgreSQL</span>
|
||||
<span class="tech-tag">Docker</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="experience" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Опыт работы</h2>
|
||||
<div class="timeline">
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content card">
|
||||
<div class="timeline-period">2020 — настоящее время</div>
|
||||
<h3 class="timeline-title">Основатель и Tech Lead</h3>
|
||||
<p class="timeline-company">Yalarba.ru</p>
|
||||
<ul class="timeline-duties">
|
||||
<li>Микросервисы на Go + Nuxt.js 4</li>
|
||||
<li>Проектирование и оптимизация PostgreSQL</li>
|
||||
<li>Docker-инфраструктура, управление продуктом</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="timeline-item">
|
||||
<div class="timeline-dot"></div>
|
||||
<div class="timeline-content card">
|
||||
<div class="timeline-period">2017 — настоящее время</div>
|
||||
<h3 class="timeline-title">Fullstack-разработчик (Контракты)</h3>
|
||||
<p class="timeline-company">Фриланс / Проектная работа</p>
|
||||
<ul class="timeline-duties">
|
||||
<li>REST API на Go (GORM, Chi)</li>
|
||||
<li>Фронтенд на Nuxt.js 4 / Vue 3</li>
|
||||
<li>Посадочные страницы, интеграции</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="education" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Образование</h2>
|
||||
<div class="education-grid">
|
||||
<div class="edu-card card">
|
||||
<div class="edu-year">2025 — н.в.</div>
|
||||
<h4>МТИ — Московский технологический институт</h4>
|
||||
<p>Разработка программного обеспечения</p>
|
||||
</div>
|
||||
<div class="edu-card card">
|
||||
<div class="edu-year">2021</div>
|
||||
<h4>Университет Иннополис</h4>
|
||||
<p>Java Enterprise Developer</p>
|
||||
</div>
|
||||
<div class="edu-card card">
|
||||
<div class="edu-year">2016 — 2020</div>
|
||||
<h4>Уфимский колледж статистики и информатики</h4>
|
||||
<p>Техник по информационным системам</p>
|
||||
</div>
|
||||
<div class="edu-card card">
|
||||
<div class="edu-year">2002 — 2005</div>
|
||||
<h4>УГАТУ</h4>
|
||||
<p>Технология сварочного производства</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="skills" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Навыки</h2>
|
||||
<div class="skills-grid">
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">Golang</span>
|
||||
<span class="skill-level">Продвинутый</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 90%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">JavaScript</span>
|
||||
<span class="skill-level">Продвинутый</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 85%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">Vue 3</span>
|
||||
<span class="skill-level">Средний</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 70%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">Nuxt.js</span>
|
||||
<span class="skill-level">Средний</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 65%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">PostgreSQL</span>
|
||||
<span class="skill-level">Средний</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 70%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">Docker</span>
|
||||
<span class="skill-level">Средний</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 70%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">Java</span>
|
||||
<span class="skill-level">Начальный</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 40%"></div></div>
|
||||
</div>
|
||||
<div class="skill-card card">
|
||||
<div class="skill-header">
|
||||
<span class="skill-name">Spring Framework</span>
|
||||
<span class="skill-level">Начальный</span>
|
||||
</div>
|
||||
<div class="skill-bar"><div class="skill-fill" style="width: 35%"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="contact" class="section">
|
||||
<div class="container">
|
||||
<h2 class="section-title">Контакты</h2>
|
||||
<div class="contact-content">
|
||||
<p class="contact-text">Открыт к общению, сотрудничеству и новым проектам. Пишите!</p>
|
||||
<div class="contact-links">
|
||||
<a href="https://t.me/valitovgaziz" target="_blank" rel="noopener noreferrer" class="contact-item">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="currentColor"><path d="M11.944 0A12 12 0 0 0 0 12a12 12 0 0 0 12 12 12 12 0 0 0 12-12A12 12 0 0 0 12 0a12 12 0 0 0-.056 0zm4.962 7.224c.1-.002.321.023.465.14a.506.506 0 0 1 .171.325c.016.093.036.306.02.472-.18 1.898-.962 6.502-1.36 8.627-.168.9-.499 1.201-.82 1.23-.696.065-1.225-.46-1.9-.902-1.056-.693-1.653-1.124-2.678-1.8-1.185-.78-.417-1.21.258-1.91.177-.184 3.247-2.977 3.307-3.23.007-.032.014-.15-.056-.212s-.174-.041-.249-.024c-.106.024-1.793 1.14-5.061 3.345-.48.33-.913.49-1.302.48-.428-.008-1.252-.241-1.865-.44-.752-.245-1.349-.374-1.297-.789.027-.216.325-.437.893-.663 3.498-1.524 5.83-2.529 6.998-3.014 3.332-1.386 4.025-1.627 4.476-1.635z"/></svg>
|
||||
<span>@valitovgaziz</span>
|
||||
</a>
|
||||
<a href="mailto:valitovgaziz@yandex.ru" class="contact-item">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="M22 4L12 13L2 4"/></svg>
|
||||
<span>valitovgaziz@yandex.ru</span>
|
||||
</a>
|
||||
<a href="tel:+79625439343" class="contact-item">
|
||||
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"/></svg>
|
||||
<span>+7 (962) 543-93-43</span>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'HomeView',
|
||||
mounted() {
|
||||
this.setupScrollObserver()
|
||||
},
|
||||
beforeUnmount() {
|
||||
if (this.observer) {
|
||||
this.observer.disconnect()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
setupScrollObserver() {
|
||||
this.observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('visible')
|
||||
}
|
||||
})
|
||||
},
|
||||
{ threshold: 0.1 }
|
||||
)
|
||||
document.querySelectorAll('.fade-in').forEach((el) => {
|
||||
this.observer.observe(el)
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.hero {
|
||||
position: relative;
|
||||
min-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hero-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--gradient-hero);
|
||||
opacity: 0.05;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.hero-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 60px;
|
||||
align-items: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
|
||||
.hero-greeting {
|
||||
font-size: 1.1rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 800;
|
||||
line-height: 1.1;
|
||||
margin-bottom: 16px;
|
||||
letter-spacing: -1px;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 1.2rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 20px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 1rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.7;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.hero-social {
|
||||
display: flex;
|
||||
gap: 24px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.social-link {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.social-link:hover {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-photo {
|
||||
width: 320px;
|
||||
height: 320px;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 4px solid var(--accent);
|
||||
box-shadow: var(--shadow-lg);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.photo-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.photo-placeholder {
|
||||
color: var(--text-secondary);
|
||||
opacity: 0.4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.about-content {
|
||||
display: grid;
|
||||
gap: 48px;
|
||||
}
|
||||
|
||||
.about-paragraph {
|
||||
font-size: 1.1rem;
|
||||
line-height: 1.8;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.about-highlights {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.highlight-card {
|
||||
text-align: center;
|
||||
padding: 32px 20px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.highlight-card:hover {
|
||||
transform: translateY(-4px);
|
||||
}
|
||||
|
||||
.highlight-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 16px;
|
||||
background: var(--skill-bg);
|
||||
color: var(--accent);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.highlight-card h4 {
|
||||
font-size: 1rem;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.highlight-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.project-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.project-name {
|
||||
font-size: 1.2rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.project-status {
|
||||
font-size: 0.75rem;
|
||||
padding: 4px 10px;
|
||||
border-radius: 20px;
|
||||
background: var(--tag-bg);
|
||||
color: var(--tag-text);
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.project-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.project-tech {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tech-tag {
|
||||
font-size: 0.8rem;
|
||||
padding: 4px 12px;
|
||||
border-radius: 6px;
|
||||
background: var(--skill-bg);
|
||||
color: var(--accent);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.timeline {
|
||||
position: relative;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.timeline::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 20px;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2px;
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
position: relative;
|
||||
padding-left: 56px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.timeline-dot {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
top: 24px;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent);
|
||||
border: 4px solid var(--bg);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.timeline-period {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 700;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.timeline-company {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.timeline-duties {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.timeline-duties li {
|
||||
position: relative;
|
||||
padding-left: 20px;
|
||||
margin-bottom: 6px;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.timeline-duties li::before {
|
||||
content: '—';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.education-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 20px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.edu-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.edu-year {
|
||||
font-size: 0.85rem;
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.edu-card h4 {
|
||||
font-size: 0.95rem;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.edu-card p {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 16px;
|
||||
max-width: 700px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.skill-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
font-weight: 600;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.skill-level {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.skill-bar {
|
||||
height: 8px;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skill-fill {
|
||||
height: 100%;
|
||||
background: var(--gradient-hero);
|
||||
border-radius: 4px;
|
||||
transition: width 1s ease;
|
||||
}
|
||||
|
||||
.contact-content {
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.contact-text {
|
||||
font-size: 1.1rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.contact-links {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.contact-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
border-radius: var(--radius);
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
font-size: 1rem;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.contact-item:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
.contact-item svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.hero-content {
|
||||
grid-template-columns: 1fr;
|
||||
text-align: center;
|
||||
gap: 32px;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 2.2rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-social {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.hero-photo {
|
||||
width: 200px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.about-highlights {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.projects-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.education-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.skills-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.about-highlights {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 1.8rem;
|
||||
}
|
||||
|
||||
.hero-buttons {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,19 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
vue(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: 3002
|
||||
}
|
||||
})
|
||||
@@ -1,12 +0,0 @@
|
||||
# DB environment variabels
|
||||
DB_HOST=localhost
|
||||
DB_PORT=5432
|
||||
DB_USER=postgres
|
||||
DB_PASSWORD=postgres
|
||||
DB_NAME=mydb
|
||||
APP_PORT=8080
|
||||
JWT_SECRET=secret
|
||||
UPLOAD_PATH=./storage/uploads
|
||||
ENVIRONMENT=development
|
||||
LOG_LEVEL=debug
|
||||
API_ES_APP_PORT=8088
|
||||
@@ -1,20 +0,0 @@
|
||||
FROM golang:1.25.1-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Устанавливаем зависимости для компиляции
|
||||
RUN apk add --no-cache gcc musl-dev
|
||||
|
||||
# Копируем go.mod и go.sum
|
||||
COPY go.mod go.sum ./
|
||||
RUN go mod download
|
||||
|
||||
# Копируем исходный код
|
||||
COPY . .
|
||||
|
||||
# Компилируем БЕЗ CGO (указываем путь к main.go)
|
||||
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/main ./cmd/main.go
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["./bin/main"]
|
||||
@@ -1,71 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"net/http"
|
||||
|
||||
"api_es/internal/config"
|
||||
"api_es/internal/database"
|
||||
"api_es/internal/router"
|
||||
"api_es/pkg/logger"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func main() {
|
||||
// Загрузка конфигурации приложения из файлов окружения или конфигурационных файлов
|
||||
// Конфигурация включает параметры БД, уровень логирования, порт приложения и т.д.
|
||||
cfg := config.Load()
|
||||
|
||||
// Инициализация логгера с указанным уровнем логирования и окружением (dev/prod)
|
||||
// Логгер будет настроен соответствующим образом для заданного окружения
|
||||
logger.Init(cfg.LogLevel, cfg.Environment)
|
||||
|
||||
// Получение инстанса логгера для использования во всем приложении
|
||||
zapLogger := logger.Get()
|
||||
|
||||
// Логирование старта приложения с указанием используемого стека технологий
|
||||
zapLogger.Info("Start api_es REST API on stack Golang (gorm, chi) and PostgresDB connect")
|
||||
|
||||
// Инициализация подключения к базе данных PostgreSQL с использованием параметров из конфигурации
|
||||
// Возвращается объект gorm.DB для работы с ORM
|
||||
db, err := database.NewPostgresConnection(cfg)
|
||||
if err != nil {
|
||||
// Критическая ошибка подключения к БД - приложение не может работать без БД
|
||||
zapLogger.Panic("Failed to connect to database:", zap.Error(err))
|
||||
}
|
||||
|
||||
// Получение низкоуровневого объекта *sql.DB из gorm.DB для выполнения операций,
|
||||
// не поддерживаемых напрямую gorm (например, Ping)
|
||||
sqlDB, err := db.DB()
|
||||
if err != nil {
|
||||
// Ошибка получения инстанса БД, но приложение может продолжить работу
|
||||
zapLogger.Error("failed to get database instance", zap.Error(err))
|
||||
}
|
||||
|
||||
// Проверка доступности базы данных через ping-запрос
|
||||
// Убеждаемся, что соединение активно и БД отвечает
|
||||
if err := sqlDB.Ping(); err != nil {
|
||||
zapLogger.Error("database ping failed", zap.Error(err))
|
||||
}
|
||||
|
||||
// Успешная проверка соединения с БД
|
||||
zapLogger.Info("database ping successful")
|
||||
|
||||
// Настройка маршрутизатора (роутера) для обработки HTTP-запросов
|
||||
// Передаем подключение к БД и конфигурацию для инициализации обработчиков
|
||||
zapLogger.Info("setup router")
|
||||
r := router.SetupRouter(db, cfg)
|
||||
|
||||
// Запуск HTTP-сервера на порту, указанном в конфигурации
|
||||
// Сервер начинает прослушивать входящие соединения
|
||||
zapLogger.Info("Server starting on port %s", zap.String("AppPort", cfg.AppPort))
|
||||
log.Printf("Server starting on port %s", cfg.AppPort)
|
||||
|
||||
// Запуск HTTP-сервера с указанным роутером
|
||||
// ListenAndServe блокирует выполнение и обрабатывает входящие запросы
|
||||
// В случае ошибки запуска сервера, логируем ошибку и завершаем приложение
|
||||
if err := http.ListenAndServe(":"+cfg.AppPort, r); err != nil {
|
||||
log.Fatal("Failed to start server:", err)
|
||||
}
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
# Документация REST API сервиса "Travel Platform"
|
||||
|
||||
## Общая информация
|
||||
API сервиса для управления туристическими объектами (отели, санатории, достопримечательности и др.) с системой аутентификации пользователей, отзывами и фильтрацией.
|
||||
|
||||
## Базовый URL
|
||||
`http://localhost:8080` (или другой хост/порт в зависимости от конфигурации)
|
||||
|
||||
---
|
||||
|
||||
## Модели данных
|
||||
|
||||
### Пользователь (User)
|
||||
**Поля:**
|
||||
- `id` - уникальный идентификатор
|
||||
- `email` - электронная почта (уникальный)
|
||||
- `password_hash` - хеш пароля
|
||||
- `full_name`, `first_name`, `last_name` - имя пользователя
|
||||
- `phone`, `city` - контактная информация
|
||||
- `organization_*` - бизнес-данные для владельцев
|
||||
- `is_active`, `is_verified`, `role` - статус и права доступа
|
||||
|
||||
### Объект (Object)
|
||||
**Типы объектов:**
|
||||
- `hotel` - отель
|
||||
- `sanatorium` - санаторий
|
||||
- `guest_house` - гостевой дом
|
||||
- `tour` - тур
|
||||
- `restaurant` - ресторан
|
||||
- `museum` - музей
|
||||
- `landmark` - достопримечательность
|
||||
- `event` - мероприятие
|
||||
- `route` - маршрут
|
||||
|
||||
**Статусы объектов:**
|
||||
- `draft` - черновик
|
||||
- `moderation` - на модерации
|
||||
- `active` - активен
|
||||
- `inactive` - неактивен
|
||||
- `rejected` - отклонен
|
||||
|
||||
### Отзыв (Review)
|
||||
- Оценка от 1 до 5 звезд
|
||||
- Текстовый отзыв
|
||||
- Связь с объектом и автором
|
||||
|
||||
---
|
||||
|
||||
## Аутентификация и авторизация
|
||||
|
||||
### Система токенов:
|
||||
- **Access Token** - для доступа к защищенным ресурсам
|
||||
- **Refresh Token** - для обновления access token
|
||||
- **Token Type**: Bearer
|
||||
- Токены передаются в заголовке `Authorization: Bearer <token>`
|
||||
|
||||
### Роли пользователей:
|
||||
1. **user** - обычный пользователь
|
||||
2. **moderator** - модератор
|
||||
3. **admin** - администратор
|
||||
|
||||
---
|
||||
|
||||
## Эндпоинты API
|
||||
|
||||
### 1. Проверка работоспособности
|
||||
**GET /health**
|
||||
**GET /check**
|
||||
*Проверка доступности сервиса*
|
||||
|
||||
### 2. Аутентификация
|
||||
|
||||
#### Регистрация пользователя
|
||||
**POST /auth/register**
|
||||
*Создание нового аккаунта*
|
||||
|
||||
**Тело запроса (UserRegisterRequest):**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123",
|
||||
"full_name": "Иван Иванов",
|
||||
"phone": "+79991234567",
|
||||
"city": "Москва"
|
||||
}
|
||||
```
|
||||
|
||||
#### Вход в систему
|
||||
**POST /auth/login**
|
||||
*Получение токенов доступа*
|
||||
|
||||
**Тело запроса (AuthRequest):**
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
"password": "password123"
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ (AuthResponse):**
|
||||
```json
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs...",
|
||||
"token_type": "Bearer",
|
||||
"expires_in": 3600,
|
||||
"user": {
|
||||
"id": 1,
|
||||
"email": "user@example.com",
|
||||
"full_name": "Иван Иванов"
|
||||
// ... остальные поля UserResponse
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Обновление токена
|
||||
**POST /auth/refresh**
|
||||
*Получение нового access token по refresh token*
|
||||
|
||||
**Тело запроса (RefreshTokenRequest):**
|
||||
```json
|
||||
{
|
||||
"refresh_token": "eyJhbGciOiJIUzI1NiIs..."
|
||||
}
|
||||
```
|
||||
|
||||
#### Выход из системы
|
||||
**POST /auth/logout**
|
||||
*Инвалидация токенов*
|
||||
|
||||
### 3. Профиль пользователя
|
||||
|
||||
#### Получение профиля
|
||||
**GET /users/profile**
|
||||
*Требуется аутентификация*
|
||||
*Получение данных текущего пользователя*
|
||||
|
||||
#### Обновление профиля
|
||||
**PUT /users/profile**
|
||||
*Требуется аутентификация*
|
||||
*Обновление данных пользователя*
|
||||
|
||||
### 4. Управление пользователями (Admin)
|
||||
|
||||
#### Список пользователей
|
||||
**GET /users**
|
||||
*Требуется роль admin*
|
||||
*Получение списка всех пользователей*
|
||||
|
||||
#### Получение пользователя по ID
|
||||
**GET /users/{id}**
|
||||
*Требуется роль admin*
|
||||
*Получение данных конкретного пользователя*
|
||||
|
||||
---
|
||||
|
||||
## Фильтрация и пагинация
|
||||
|
||||
Для эндпоинтов списков объектов поддерживается фильтрация через `ObjectFilter`:
|
||||
|
||||
**Параметры запроса:**
|
||||
- `search` - текстовый поиск
|
||||
- `type` - тип объекта (hotel, sanatorium и т.д.)
|
||||
- `city` - город
|
||||
- `min_price`, `max_price` - диапазон цен
|
||||
- `min_rating` - минимальный рейтинг
|
||||
- `status` - статус объекта
|
||||
- `owner_id` - ID владельца
|
||||
- `page` - номер страницы (начинается с 1)
|
||||
- `page_size` - количество элементов на странице (1-100)
|
||||
- `sort_by` - поле сортировки (title, price, rating, city, created_at)
|
||||
- `sort_order` - порядок сортировки (asc, desc)
|
||||
|
||||
**Пример запроса:**
|
||||
```
|
||||
GET /objects?city=Москва&min_price=1000&max_price=5000&page=1&page_size=20&sort_by=price&sort_order=asc
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Формат ответа с пагинацией
|
||||
|
||||
Для списков возвращается `PaginatedResponse`:
|
||||
|
||||
```json
|
||||
{
|
||||
"data": [...], // массив объектов
|
||||
"total": 150, // общее количество
|
||||
"page": 1, // текущая страница
|
||||
"page_size": 20, // элементов на странице
|
||||
"total_pages": 8 // всего страниц
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Обработка ошибок
|
||||
|
||||
Сервис использует стандартные HTTP статусы:
|
||||
- `200` - успешный запрос
|
||||
- `201` - создан новый ресурс
|
||||
- `400` - ошибка валидации
|
||||
- `401` - неавторизован
|
||||
- `403` - доступ запрещен
|
||||
- `404` - ресурс не найден
|
||||
- `500` - внутренняя ошибка сервера
|
||||
|
||||
---
|
||||
|
||||
## Следующие шаги (планируемые эндпоинты)
|
||||
|
||||
На основе моделей данных ожидаются следующие API:
|
||||
|
||||
### Управление объектами:
|
||||
- `GET /objects` - список объектов с фильтрацией
|
||||
- `GET /objects/{id}` - получение объекта
|
||||
- `POST /objects` - создание объекта (требуется аутентификация)
|
||||
- `PUT /objects/{id}` - обновление объекта
|
||||
- `DELETE /objects/{id}` - удаление объекта
|
||||
|
||||
### Управление отзывами:
|
||||
- `GET /objects/{id}/reviews` - отзывы объекта
|
||||
- `POST /reviews` - создание отзыва
|
||||
- `PUT /reviews/{id}` - обновление отзыва
|
||||
- `DELETE /reviews/{id}` - удаление отзыва
|
||||
|
||||
### Модерация:
|
||||
- `GET /moderation/objects` - объекты на модерации
|
||||
- `POST /moderation/objects/{id}/approve` - утвердить объект
|
||||
- `POST /moderation/objects/{id}/reject` - отклонить объект
|
||||
|
||||
### Отчеты и аналитика:
|
||||
- `GET /reports/popular-objects` - популярные объекты
|
||||
- `GET /reports/user-activity` - активность пользователей
|
||||
- `GET /reviews/revenue` - аналитика доходов
|
||||
|
||||
---
|
||||
|
||||
## Технические детали
|
||||
|
||||
### База данных:
|
||||
- Используется GORM (Go ORM)
|
||||
- Поддерживаются миграции
|
||||
- Soft delete для основных сущностей
|
||||
|
||||
### Логирование:
|
||||
- Структурированное логирование через Zap
|
||||
- Логирование маршрутов при запуске
|
||||
- Middleware для логирования запросов
|
||||
|
||||
### Конфигурация:
|
||||
- Централизованная конфигурация через `config.Config`
|
||||
- Поддержка разных окружений
|
||||
|
||||
### Middleware:
|
||||
- Аутентификация (`AuthMiddleware`)
|
||||
- Авторизация по ролям (`AdminMiddleware`)
|
||||
- Логирование
|
||||
- Recovery от паник
|
||||
@@ -1,34 +0,0 @@
|
||||
module api_es
|
||||
|
||||
go 1.25.1
|
||||
|
||||
require (
|
||||
github.com/go-chi/chi/v5 v5.2.3
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/gorm v1.25.10
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||
github.com/go-playground/locales v0.14.1 // indirect
|
||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||
github.com/leodido/go-urn v1.4.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
golang.org/x/sys v0.36.0 // indirect
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/go-chi/cors v1.2.2
|
||||
github.com/go-playground/validator/v10 v10.28.0
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
go.uber.org/zap v1.27.0
|
||||
golang.org/x/crypto v0.42.0
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
@@ -1,62 +0,0 @@
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10 h1:zyueNbySn/z8mJZHLt6IPw0KoZsiQNszIpU+bX4+ZK0=
|
||||
github.com/gabriel-vasile/mimetype v1.4.10/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE=
|
||||
github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops=
|
||||
github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE=
|
||||
github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58=
|
||||
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.28.0 h1:Q7ibns33JjyW48gHkuFT91qX48KG0ktULL6FgHdG688=
|
||||
github.com/go-playground/validator/v10 v10.28.0/go.mod h1:GoI6I1SjPBh9p7ykNE/yj3fFYbyDOpwMn5KXd+m2hUU=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
|
||||
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.6.0 h1:SWJzexBzPL5jb0GEsrPMLIsi/3jOo7RHlzTjcAeDrPY=
|
||||
github.com/jackc/pgx/v5 v5.6.0/go.mod h1:DNZ/vlrUnhWCoFGxHAG8U2ljioxukquj7utPDgtQdTw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
|
||||
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
|
||||
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
|
||||
golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
|
||||
golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
|
||||
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
|
||||
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
|
||||
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
|
||||
golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gorm.io/driver/postgres v1.6.0 h1:2dxzU8xJ+ivvqTRph34QX+WrRaJlmfyPqXmoGVjMBa4=
|
||||
gorm.io/driver/postgres v1.6.0/go.mod h1:vUw0mrGgrTK+uPHEhAdV4sfFELrByKVGnaVRkXDhtWo=
|
||||
gorm.io/gorm v1.25.10 h1:dQpO+33KalOA+aFYGlK+EfxcI5MbO7EP2yYygwh9h+s=
|
||||
gorm.io/gorm v1.25.10/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8=
|
||||
@@ -1,42 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DBHost string
|
||||
DBPort string
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
JWTSecret string
|
||||
ServerPort string
|
||||
UploadPath string
|
||||
LogLevel string
|
||||
Environment string
|
||||
AppPort string
|
||||
}
|
||||
|
||||
func Load() *Config {
|
||||
return &Config{
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnv("DB_PORT", "5432"),
|
||||
DBUser: getEnv("DB_USER", "postgres"),
|
||||
DBPassword: getEnv("DB_PASSWORD", "postgres"),
|
||||
DBName: getEnv("DB_NAME", "mydb"),
|
||||
JWTSecret: getEnv("JWT_SECRET", "secret"),
|
||||
ServerPort: getEnv("SERVER_PORT", "8080"),
|
||||
UploadPath: getEnv("UPLOAD_PATH", "./storage/uploads"),
|
||||
LogLevel: getEnv("LOG_LEVEL", "debug"),
|
||||
Environment: getEnv("ENVIRONMENT", "development"),
|
||||
AppPort: getEnv("APP_PORT", "8088"),
|
||||
}
|
||||
}
|
||||
|
||||
func getEnv(key, defaultValue string) string {
|
||||
if value := os.Getenv(key); value != "" {
|
||||
return value
|
||||
}
|
||||
return defaultValue
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"api_es/internal/models"
|
||||
"api_es/pkg/logger"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SeedInitialData(db *gorm.DB) error {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("start fill init data")
|
||||
// Создание базовых удобств
|
||||
amenities := []models.Amenity{
|
||||
{Name: "Wi-Fi", Category: "basic", Icon: "wifi"},
|
||||
{Name: "Парковка", Category: "basic", Icon: "parking"},
|
||||
{Name: "Бассейн", Category: "comfort", Icon: "pool"},
|
||||
// ... другие удобства
|
||||
}
|
||||
|
||||
for _, amenity := range amenities {
|
||||
if err := db.FirstOrCreate(&amenity, models.Amenity{Name: amenity.Name}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
zapLogger.Debug("end fill init data")
|
||||
return nil
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"api_es/internal/config"
|
||||
"api_es/internal/models"
|
||||
"api_es/pkg/logger"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/driver/postgres"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func NewPostgresConnection(cfg *config.Config) (*gorm.DB, error) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Info("Start connect to Postgres DB")
|
||||
dsn := fmt.Sprintf("host=%s user=%s password=%s dbname=%s port=%s sslmode=disable TimeZone=UTC",
|
||||
cfg.DBHost, cfg.DBUser, cfg.DBPassword, cfg.DBName, cfg.DBPort)
|
||||
zapLogger.Info("dsn = %s", zap.String("dsn", dsn))
|
||||
|
||||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to database: %w", err)
|
||||
}
|
||||
|
||||
zapLogger.Info("AutoMigrate models")
|
||||
// Автомиграция
|
||||
if err := autoMigrate(db); err != nil {
|
||||
zapLogger.Error("can't migrate models, error = %s", zap.Error(err))
|
||||
return nil, fmt.Errorf("can't migrate models, error = %s", err)
|
||||
}
|
||||
zapLogger.Info("Migrate complite successfully")
|
||||
|
||||
zapLogger.Info("Fill init data")
|
||||
SeedInitialData(db)
|
||||
|
||||
zapLogger.Info("Successfully connected to database")
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func autoMigrate(db *gorm.DB) error {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Start migration")
|
||||
models := []interface{}{
|
||||
&models.User{},
|
||||
&models.Object{},
|
||||
&models.ObjectImage{},
|
||||
&models.Amenity{},
|
||||
&models.ObjectAmenity{},
|
||||
}
|
||||
|
||||
for _, model := range models {
|
||||
if err := db.AutoMigrate(model); err != nil {
|
||||
return fmt.Errorf("failed to migrate %T: %w", model, err)
|
||||
}
|
||||
}
|
||||
|
||||
zapLogger.Debug("End migration seccessfully")
|
||||
return nil
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"api_es/internal/models"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RegisterRequest - запрос на регистрацию
|
||||
type RegisterRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required,min=6"`
|
||||
FullName string `json:"full_name" validate:"required"`
|
||||
FirstName string `json:"first_name" validate:"required"`
|
||||
LastName string `json:"last_name" validate:"required"`
|
||||
Phone string `json:"phone"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
// LoginRequest - запрос на вход
|
||||
type LoginRequest struct {
|
||||
Email string `json:"email" validate:"required,email"`
|
||||
Password string `json:"password" validate:"required"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest - запрос на обновление пользователя
|
||||
type UpdateUserRequest struct {
|
||||
FullName string `json:"full_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone"`
|
||||
City string `json:"city"`
|
||||
OrganizationForm string `json:"organization_form"`
|
||||
OrganizationName string `json:"organization_name"`
|
||||
OrganizationShort string `json:"organization_short"`
|
||||
INN string `json:"inn"`
|
||||
PersonalINN string `json:"personal_inn"`
|
||||
}
|
||||
|
||||
// UserResponse - ответ с данными пользователя
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone"`
|
||||
City string `json:"city"`
|
||||
OrganizationForm string `json:"organization_form"`
|
||||
OrganizationName string `json:"organization_name"`
|
||||
OrganizationShort string `json:"organization_short"`
|
||||
INN string `json:"inn"`
|
||||
PersonalINN string `json:"personal_inn"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// AuthResponse - ответ с токеном
|
||||
type AuthResponse struct {
|
||||
Token string `json:"token"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
// ToUserResponse преобразует модель в DTO
|
||||
func ToUserResponse(user *models.User) UserResponse {
|
||||
return UserResponse{
|
||||
ID: user.ID,
|
||||
Email: user.Email,
|
||||
FullName: user.FullName,
|
||||
FirstName: user.FirstName,
|
||||
LastName: user.LastName,
|
||||
Phone: user.Phone,
|
||||
City: user.City,
|
||||
OrganizationForm: user.OrganizationForm,
|
||||
OrganizationName: user.OrganizationName,
|
||||
OrganizationShort: user.OrganizationShort,
|
||||
INN: user.INN,
|
||||
PersonalINN: user.PersonalINN,
|
||||
IsActive: user.IsActive,
|
||||
IsVerified: user.IsVerified,
|
||||
Role: user.Role,
|
||||
CreatedAt: user.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
// dto/auth.go (добавляем если нужно)
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" validate:"required"`
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"api_es/internal/config"
|
||||
"api_es/internal/repository"
|
||||
"api_es/internal/service"
|
||||
"api_es/internal/utils"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AllHandler struct {
|
||||
userHandler *UserHandler
|
||||
healthHandler *HealthHandler
|
||||
}
|
||||
|
||||
func NewAllHandler(db *gorm.DB, cfg *config.Config) *AllHandler {
|
||||
|
||||
userRepo := repository.NewUserRepository(db)
|
||||
|
||||
userService := service.NewUserService(userRepo, utils.NewJWTUtil(cfg.JWTSecret))
|
||||
|
||||
userHandler := NewUserHandler(userService)
|
||||
healthHandler := NewHealthHandler()
|
||||
|
||||
return &AllHandler{
|
||||
userHandler: userHandler,
|
||||
healthHandler: healthHandler,
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func (h *AllHandler) UserHandler() *UserHandler {
|
||||
return h.userHandler
|
||||
}
|
||||
|
||||
func (h *AllHandler) HealthHandler() *HealthHandler {
|
||||
return h.healthHandler
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"api_es/internal/utils"
|
||||
|
||||
)
|
||||
|
||||
type HealthHandler struct{}
|
||||
|
||||
func NewHealthHandler() *HealthHandler {
|
||||
return &HealthHandler{}
|
||||
}
|
||||
|
||||
func (h *HealthHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]string{
|
||||
"status": "ok",
|
||||
"message": "Service is healthy",
|
||||
}
|
||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
||||
}
|
||||
|
||||
func (h *HealthHandler) Check(w http.ResponseWriter, r *http.Request) {
|
||||
response := map[string]string{
|
||||
"status": "ok",
|
||||
"message": "API is working",
|
||||
}
|
||||
|
||||
utils.RespondWithJSON(w, http.StatusOK, response)
|
||||
}
|
||||
@@ -1,317 +0,0 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"api_es/internal/dto"
|
||||
appMiddleware "api_es/internal/middleware"
|
||||
"api_es/internal/service"
|
||||
"api_es/internal/utils"
|
||||
"api_es/pkg/logger"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-playground/validator/v10"
|
||||
)
|
||||
|
||||
type UserHandler struct {
|
||||
userService service.UserService
|
||||
validator *validator.Validate
|
||||
}
|
||||
|
||||
func NewUserHandler(userService service.UserService) *UserHandler {
|
||||
return &UserHandler{
|
||||
userService: userService,
|
||||
validator: validator.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// Register godoc
|
||||
// @Summary Register new user
|
||||
// @Description Create a new user account
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.RegisterRequest true "Register request"
|
||||
// @Success 201 {object} dto.AuthResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 500 {object} map[string]string
|
||||
// @Router /auth/register [post]
|
||||
func (h *UserHandler) Register(w http.ResponseWriter, r *http.Request) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Start register")
|
||||
var req dto.RegisterRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.userService.Register(r.Context(), req)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case service.ErrUserAlreadyExists:
|
||||
http.Error(w, "User already exists", http.StatusConflict)
|
||||
default:
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Устанавливаем куку с токеном
|
||||
appMiddleware.SetAuthCookie(w, response.Token)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
zapLogger.Debug("End register")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Login godoc
|
||||
// @Summary Login user
|
||||
// @Description Authenticate user and get token
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param request body dto.LoginRequest true "Login request"
|
||||
// @Success 200 {object} dto.AuthResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /auth/login [post]
|
||||
func (h *UserHandler) Login(w http.ResponseWriter, r *http.Request) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Start login")
|
||||
var req dto.LoginRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.validator.Struct(req); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
response, err := h.userService.Login(r.Context(), req)
|
||||
if err != nil {
|
||||
switch err {
|
||||
case service.ErrInvalidCredentials:
|
||||
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
|
||||
default:
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Устанавливаем куку с токеном
|
||||
appMiddleware.SetAuthCookie(w, response.Token)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
zapLogger.Debug("End login")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Добавляем новый метод для logout
|
||||
// Logout godoc
|
||||
// @Summary Logout user
|
||||
// @Description Clear authentication cookies and tokens
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} map[string]string
|
||||
// @Router /auth/logout [post]
|
||||
func (h *UserHandler) Logout(w http.ResponseWriter, r *http.Request) {
|
||||
// Очищаем auth cookie
|
||||
appMiddleware.ClearAuthCookie(w)
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{
|
||||
"message": "Successfully logged out",
|
||||
})
|
||||
}
|
||||
|
||||
// Добавляем метод для обновления токена
|
||||
// RefreshToken godoc
|
||||
// @Summary Refresh authentication token
|
||||
// @Description Refresh JWT token using refresh token or existing auth
|
||||
// @Tags auth
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} dto.AuthResponse
|
||||
// @Failure 401 {object} map[string]string
|
||||
// @Router /auth/refresh [post]
|
||||
func (h *UserHandler) RefreshToken(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Генерируем новый токен
|
||||
// В реальном приложении здесь должна быть логика с refresh token
|
||||
jwtUtil := utils.NewJWTUtil("secret")
|
||||
newToken, err := jwtUtil.GenerateToken(userID, user.Email, user.Role)
|
||||
if err != nil {
|
||||
http.Error(w, "Failed to generate token", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
// Обновляем куку
|
||||
appMiddleware.SetAuthCookie(w, newToken)
|
||||
|
||||
response := &dto.AuthResponse{
|
||||
Token: newToken,
|
||||
User: *user,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// GetProfile godoc
|
||||
// @Summary Get user profile
|
||||
// @Description Get current user profile
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /users/profile [get]
|
||||
func (h *UserHandler) GetProfile(w http.ResponseWriter, r *http.Request) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("GetProfile start debug level")
|
||||
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUserProfile(r.Context(), userID)
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
zapLogger.Debug("GetProfile end debug level")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// UpdateProfile godoc
|
||||
// @Summary Update user profile
|
||||
// @Description Update current user profile
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param request body dto.UpdateUserRequest true "Update request"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 400 {object} map[string]string
|
||||
// @Router /users/profile [put]
|
||||
func (h *UserHandler) UpdateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
userID, ok := r.Context().Value(appMiddleware.UserIDKey).(uint)
|
||||
if !ok {
|
||||
http.Error(w, "Unauthorized", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
var req dto.UpdateUserRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Invalid request body", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.UpdateUser(r.Context(), userID, req)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// GetUser godoc
|
||||
// @Summary Get user by ID
|
||||
// @Description Get user details by ID (admin only)
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param id path int true "User ID"
|
||||
// @Success 200 {object} dto.UserResponse
|
||||
// @Failure 404 {object} map[string]string
|
||||
// @Router /users/{id} [get]
|
||||
func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
|
||||
idStr := chi.URLParam(r, "id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 32)
|
||||
if err != nil {
|
||||
http.Error(w, "Invalid user ID", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
|
||||
user, err := h.userService.GetUser(r.Context(), uint(id))
|
||||
if err != nil {
|
||||
http.Error(w, "User not found", http.StatusNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(user)
|
||||
}
|
||||
|
||||
// ListUsers godoc
|
||||
// @Summary List users
|
||||
// @Description Get paginated list of users (admin only)
|
||||
// @Tags users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Security BearerAuth
|
||||
// @Param limit query int false "Limit" default(10)
|
||||
// @Param offset query int false "Offset" default(0)
|
||||
// @Success 200 {array} dto.UserResponse
|
||||
// @Router /users [get]
|
||||
func (h *UserHandler) ListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Debug start handler listUsers")
|
||||
limitStr := r.URL.Query().Get("limit")
|
||||
offsetStr := r.URL.Query().Get("offset")
|
||||
|
||||
limit := 10
|
||||
offset := 0
|
||||
|
||||
if limitStr != "" {
|
||||
if l, err := strconv.Atoi(limitStr); err == nil {
|
||||
limit = l
|
||||
}
|
||||
}
|
||||
|
||||
if offsetStr != "" {
|
||||
if o, err := strconv.Atoi(offsetStr); err == nil {
|
||||
offset = o
|
||||
}
|
||||
}
|
||||
|
||||
users, err := h.userService.ListUsers(r.Context(), limit, offset)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
zapLogger.Debug("Debug end handler listUsers")
|
||||
json.NewEncoder(w).Encode(users)
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
// auth.go
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"api_es/internal/utils"
|
||||
"api_es/pkg/logger"
|
||||
"context"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const (
|
||||
UserIDKey contextKey = "userID"
|
||||
UserEmailKey contextKey = "userEmail"
|
||||
UserRoleKey contextKey = "userRole"
|
||||
)
|
||||
|
||||
// Cookie конфигурация
|
||||
const (
|
||||
AuthCookieName = "auth_token"
|
||||
CookieMaxAge = 24 * 60 * 60 // 24 часа
|
||||
)
|
||||
|
||||
func AuthMiddleware(next http.Handler) http.Handler {
|
||||
zapLogger := logger.Get()
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
zapLogger.Debug("Debug start AuthMiddleware")
|
||||
|
||||
var tokenString string
|
||||
|
||||
// Пробуем получить токен из заголовка Authorization
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader != "" {
|
||||
tokenString = strings.Replace(authHeader, "Bearer ", "", 1)
|
||||
zapLogger.Debug("Token from Authorization header", zap.String("token", tokenString))
|
||||
}
|
||||
|
||||
// Если токена нет в заголовке, пробуем получить из куки
|
||||
if tokenString == "" {
|
||||
cookie, err := r.Cookie(AuthCookieName)
|
||||
if err == nil && cookie.Value != "" {
|
||||
tokenString = cookie.Value
|
||||
zapLogger.Debug("Token from cookie", zap.String("token", tokenString))
|
||||
}
|
||||
}
|
||||
|
||||
if tokenString == "" {
|
||||
http.Error(w, "Authorization required", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
// Валидируем токен
|
||||
jwtUtil := utils.NewJWTUtil("secret")
|
||||
claims, err := jwtUtil.ValidateToken(tokenString)
|
||||
if err != nil {
|
||||
// Если токен невалиден, удаляем куку
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AuthCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
http.Error(w, "Invalid token", http.StatusUnauthorized)
|
||||
return
|
||||
}
|
||||
|
||||
ctx := context.WithValue(r.Context(), UserIDKey, claims.UserID)
|
||||
ctx = context.WithValue(ctx, UserEmailKey, claims.Email)
|
||||
ctx = context.WithValue(ctx, UserRoleKey, claims.Role)
|
||||
|
||||
zapLogger.Debug("Debug end AuthMiddleware")
|
||||
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
|
||||
// Вспомогательная функция для установки auth cookie
|
||||
func SetAuthCookie(w http.ResponseWriter, token string) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AuthCookieName,
|
||||
Value: token,
|
||||
Path: "/",
|
||||
MaxAge: CookieMaxAge,
|
||||
HttpOnly: true,
|
||||
Secure: true, // В production должно быть true
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
// Вспомогательная функция для удаления auth cookie
|
||||
func ClearAuthCookie(w http.ResponseWriter) {
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: AuthCookieName,
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
Secure: true,
|
||||
SameSite: http.SameSiteStrictMode,
|
||||
})
|
||||
}
|
||||
|
||||
func AdminMiddleware(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
role, ok := r.Context().Value(UserRoleKey).(string)
|
||||
if !ok || role != "admin" {
|
||||
http.Error(w, "Admin access required", http.StatusForbidden)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
|
||||
)
|
||||
|
||||
// AuthRequest - запрос на аутентификацию
|
||||
type AuthRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
|
||||
// AuthResponse - ответ с токенами
|
||||
type AuthResponse struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
RefreshToken string `json:"refresh_token"`
|
||||
TokenType string `json:"token_type"` // Bearer
|
||||
ExpiresIn int64 `json:"expires_in"`
|
||||
User UserResponse `json:"user"`
|
||||
}
|
||||
|
||||
// RefreshTokenRequest - запрос на обновление токена
|
||||
type RefreshTokenRequest struct {
|
||||
RefreshToken string `json:"refresh_token" binding:"required"`
|
||||
}
|
||||
|
||||
// UserRegisterRequest - запрос на регистрацию
|
||||
type UserRegisterRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
FullName string `json:"full_name" binding:"required"`
|
||||
Phone string `json:"phone"`
|
||||
City string `json:"city"`
|
||||
}
|
||||
|
||||
// PasswordResetRequest - запрос на сброс пароля
|
||||
type PasswordResetRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
}
|
||||
|
||||
// PasswordResetConfirmRequest - подтверждение сброса пароля
|
||||
type PasswordResetConfirmRequest struct {
|
||||
Token string `json:"token" binding:"required"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
}
|
||||
@@ -1,29 +0,0 @@
|
||||
package models
|
||||
|
||||
type ObjectFilter struct {
|
||||
Search string `form:"search" json:"search"`
|
||||
Type ObjectType `form:"type" json:"type"`
|
||||
City string `form:"city" json:"city"`
|
||||
MinPrice float64 `form:"min_price" json:"min_price"`
|
||||
MaxPrice float64 `form:"max_price" json:"max_price"`
|
||||
MinRating float64 `form:"min_rating" json:"min_rating"`
|
||||
Status ObjectStatus `form:"status" json:"status"`
|
||||
OwnerID uint `form:"owner_id" json:"owner_id"`
|
||||
|
||||
// Пагинация
|
||||
Page int `form:"page" json:"page" binding:"min=1"`
|
||||
PageSize int `form:"page_size" json:"page_size" binding:"min=1,max=100"`
|
||||
|
||||
// Сортировка
|
||||
SortBy string `form:"sort_by" json:"sort_by"` // title, price, rating, city, created_at
|
||||
SortOrder string `form:"sort_order" json:"sort_order"` // asc, desc
|
||||
}
|
||||
|
||||
// PaginatedResponse - общий ответ с пагинацией
|
||||
type PaginatedResponse struct {
|
||||
Data interface{} `json:"data"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
TotalPages int `json:"total_pages"`
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package models
|
||||
@@ -1,121 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ObjectType string
|
||||
|
||||
const (
|
||||
ObjectTypeHotel ObjectType = "hotel"
|
||||
ObjectTypeSanatorium ObjectType = "sanatorium"
|
||||
ObjectTypeGuestHouse ObjectType = "guest_house"
|
||||
ObjectTypeTour ObjectType = "tour"
|
||||
ObjectTypeRestaurant ObjectType = "restaurant"
|
||||
ObjectTypeMuseum ObjectType = "museum"
|
||||
ObjectTypeLandmark ObjectType = "landmark"
|
||||
ObjectTypeEvent ObjectType = "event"
|
||||
ObjectTypeRoute ObjectType = "route"
|
||||
)
|
||||
|
||||
type ObjectStatus string
|
||||
|
||||
const (
|
||||
ObjectStatusDraft ObjectStatus = "draft"
|
||||
ObjectStatusModeration ObjectStatus = "moderation"
|
||||
ObjectStatusActive ObjectStatus = "active"
|
||||
ObjectStatusInactive ObjectStatus = "inactive"
|
||||
ObjectStatusRejected ObjectStatus = "rejected"
|
||||
)
|
||||
|
||||
type Object struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Основная информация
|
||||
Title string `gorm:"not null" json:"title"`
|
||||
Type ObjectType `gorm:"not null" json:"type"`
|
||||
Description string `gorm:"type:text" json:"description"`
|
||||
|
||||
// Локация
|
||||
City string `gorm:"not null" json:"city"`
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
|
||||
// Цена и условия
|
||||
Price float64 `gorm:"default:0" json:"price"`
|
||||
PricePeriod string `gorm:"default:'per_night'" json:"price_period"` // per_night, per_person, per_tour
|
||||
|
||||
// Статус и рейтинг
|
||||
Status ObjectStatus `gorm:"default:draft" json:"status"`
|
||||
Rating float64 `gorm:"default:0" json:"rating"`
|
||||
ReviewCount int `gorm:"default:0" json:"review_count"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
|
||||
// Владелец
|
||||
OwnerID uint `gorm:"not null;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"owner_id"`
|
||||
Owner User `gorm:"foreignKey:OwnerID;constraint:OnUpdate:CASCADE,OnDelete:SET NULL;" json:"owner,omitempty"`
|
||||
|
||||
// Связи
|
||||
Images []ObjectImage `gorm:"foreignKey:ObjectID" json:"images"`
|
||||
Amenities []Amenity `gorm:"many2many:object_amenities;" json:"amenities"`
|
||||
Reviews []Review `gorm:"foreignKey:ObjectID" json:"-"`
|
||||
}
|
||||
|
||||
// ObjectImage представляет изображения объекта
|
||||
type ObjectImage struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
ObjectID uint `gorm:"not null;constraint:OnUpdate:CASCADE,OnDelete:CASCADE;" json:"object_id"`
|
||||
URL string `gorm:"not null" json:"url"`
|
||||
IsPrimary bool `gorm:"default:false" json:"is_primary"`
|
||||
Order int `gorm:"default:0" json:"order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Amenity представляет удобства объекта
|
||||
type Amenity struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Name string `gorm:"uniqueIndex;not null" json:"name"`
|
||||
Category string `json:"category"` // basic, comfort, safety, entertainment, etc.
|
||||
Icon string `json:"icon"` // иконка для фронтенда
|
||||
Description string `json:"description"`
|
||||
}
|
||||
|
||||
// ObjectAmenity связь многие-ко-многим между Object и Amenity
|
||||
type ObjectAmenity struct {
|
||||
ObjectID uint `gorm:"primaryKey" json:"object_id"`
|
||||
AmenityID uint `gorm:"primaryKey" json:"amenity_id"`
|
||||
}
|
||||
|
||||
// ObjectCreateRequest - запрос на создание объекта
|
||||
type ObjectCreateRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Type ObjectType `json:"type" binding:"required"`
|
||||
Description string `json:"description" binding:"required"`
|
||||
City string `json:"city" binding:"required"`
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Price float64 `json:"price"`
|
||||
PricePeriod string `json:"price_period"`
|
||||
AmenityIDs []uint `json:"amenity_ids"`
|
||||
}
|
||||
|
||||
// ObjectUpdateRequest - запрос на обновление объекта
|
||||
type ObjectUpdateRequest struct {
|
||||
Title string `json:"title"`
|
||||
Type ObjectType `json:"type"`
|
||||
Description string `json:"description"`
|
||||
City string `json:"city"`
|
||||
Address string `json:"address"`
|
||||
Latitude float64 `json:"latitude"`
|
||||
Longitude float64 `json:"longitude"`
|
||||
Price float64 `json:"price"`
|
||||
PricePeriod string `json:"price_period"`
|
||||
Status ObjectStatus `json:"status"`
|
||||
AmenityIDs []uint `json:"amenity_ids"`
|
||||
}
|
||||
@@ -1 +0,0 @@
|
||||
package models
|
||||
@@ -1,28 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type Review struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Связи
|
||||
ObjectID uint `gorm:"not null" json:"object_id"`
|
||||
Object Object `gorm:"foreignKey:ObjectID" json:"object,omitempty"`
|
||||
AuthorID uint `gorm:"not null" json:"author_id"`
|
||||
Author User `gorm:"foreignKey:AuthorID" json:"author"`
|
||||
|
||||
// Контент отзыва
|
||||
Rating int `gorm:"not null;check:rating >= 1 AND rating <= 5" json:"rating"`
|
||||
Text string `gorm:"type:text" json:"text"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
}
|
||||
|
||||
// ReviewCreateRequest - запрос на создание отзыва
|
||||
type ReviewCreateRequest struct {
|
||||
ObjectID uint `json:"object_id" binding:"required"`
|
||||
Rating int `json:"rating" binding:"required,min=1,max=5"`
|
||||
Text string `json:"text" binding:"required,min=10"`
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package models
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"time"
|
||||
)
|
||||
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
// Основная информация
|
||||
Email string `gorm:"uniqueIndex;not null" json:"email"`
|
||||
PasswordHash string `gorm:"not null" json:"-"`
|
||||
FullName string `gorm:"not null;default:'Unknown'" json:"full_name"`
|
||||
FirstName string `gorm:"not null;default:'FirstName'" json:"first_name"`
|
||||
LastName string `gorm:"not null;default:'LastName'" json:"last_name"`
|
||||
Phone string `json:"phone"`
|
||||
City string `json:"city"`
|
||||
|
||||
// Бизнес информация (для владельцев объектов)
|
||||
OrganizationForm string `json:"organization_form"` // ИП, ООО и т.д.
|
||||
OrganizationName string `json:"organization_name"`
|
||||
OrganizationShort string `json:"organization_short"`
|
||||
INN string `json:"inn"` // ИНН организации
|
||||
PersonalINN string `json:"personal_inn"` // Личный ИНН
|
||||
|
||||
// Статус
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
IsVerified bool `gorm:"default:false" json:"is_verified"`
|
||||
Role string `gorm:"default:user" json:"role"` // user, admin, moderator
|
||||
|
||||
// Связи
|
||||
Objects []Object `gorm:"foreignKey:OwnerID" json:"-"`
|
||||
Reviews []Review `gorm:"foreignKey:AuthorID" json:"-"`
|
||||
}
|
||||
|
||||
// UserStats представляет статистику пользователя
|
||||
type UserStats struct {
|
||||
UserID uint `gorm:"primaryKey" json:"user_id"`
|
||||
TotalObjects int `gorm:"default:0" json:"total_objects"`
|
||||
ActiveObjects int `gorm:"default:0" json:"active_objects"`
|
||||
ModerationObjects int `gorm:"default:0" json:"moderation_objects"`
|
||||
TotalReviews int `gorm:"default:0" json:"total_reviews"`
|
||||
}
|
||||
|
||||
// UserResponse - структура для ответа API (без чувствительных данных)
|
||||
type UserResponse struct {
|
||||
ID uint `json:"id"`
|
||||
Email string `json:"email"`
|
||||
FullName string `json:"full_name"`
|
||||
FirstName string `json:"first_name"`
|
||||
LastName string `json:"last_name"`
|
||||
Phone string `json:"phone"`
|
||||
City string `json:"city"`
|
||||
OrganizationForm string `json:"organization_form"`
|
||||
OrganizationName string `json:"organization_name"`
|
||||
OrganizationShort string `json:"organization_short"`
|
||||
INN string `json:"inn"`
|
||||
PersonalINN string `json:"personal_inn"`
|
||||
IsActive bool `json:"is_active"`
|
||||
IsVerified bool `json:"is_verified"`
|
||||
Role string `json:"role"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Stats UserStats `json:"stats,omitempty"`
|
||||
}
|
||||
@@ -1,398 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"api_es/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrObjectNotFound = errors.New("object not found")
|
||||
)
|
||||
|
||||
type ObjectRepository interface {
|
||||
// Основные операции
|
||||
Create(object *models.Object) error
|
||||
GetByID(id uint) (*models.Object, error)
|
||||
Update(id uint, updates *models.ObjectUpdateRequest) error
|
||||
Delete(id uint) error
|
||||
List(filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error)
|
||||
|
||||
// Специфичные операции
|
||||
GetByOwner(ownerID uint, filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error)
|
||||
UpdateStatus(id uint, status models.ObjectStatus) error
|
||||
IncrementViewCount(id uint) error
|
||||
UpdateRating(id uint, rating float64, reviewCount int) error
|
||||
|
||||
// Работа с изображениями
|
||||
AddImage(objectID uint, image *models.ObjectImage) error
|
||||
RemoveImage(objectID uint, imageID uint) error
|
||||
SetPrimaryImage(objectID uint, imageID uint) error
|
||||
GetImages(objectID uint) ([]models.ObjectImage, error)
|
||||
|
||||
// Работа с удобствами
|
||||
AddAmenities(objectID uint, amenityIDs []uint) error
|
||||
RemoveAmenities(objectID uint, amenityIDs []uint) error
|
||||
GetAmenities(objectID uint) ([]models.Amenity, error)
|
||||
}
|
||||
|
||||
type ObjectFilter struct {
|
||||
Type []models.ObjectType
|
||||
City string
|
||||
Status []models.ObjectStatus
|
||||
OwnerID uint
|
||||
MinPrice float64
|
||||
MaxPrice float64
|
||||
MinRating float64
|
||||
AmenityIDs []uint
|
||||
Search string
|
||||
}
|
||||
|
||||
type Pagination struct {
|
||||
Page int `form:"page" default:"1"`
|
||||
PageSize int `form:"page_size" default:"20"`
|
||||
}
|
||||
|
||||
type objectRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewObjectRepository(db *gorm.DB) ObjectRepository {
|
||||
return &objectRepository{db: db}
|
||||
}
|
||||
|
||||
// Create создает новый объект
|
||||
func (r *objectRepository) Create(object *models.Object) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Создаем основной объект
|
||||
if err := tx.Create(object).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Добавляем связи с удобствами, если они есть
|
||||
if len(object.Amenities) > 0 {
|
||||
if err := tx.Model(object).Association("Amenities").Append(object.Amenities); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID возвращает объект по ID с связанными данными
|
||||
func (r *objectRepository) GetByID(id uint) (*models.Object, error) {
|
||||
var object models.Object
|
||||
err := r.db.
|
||||
Preload("Owner", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, first_name, last_name, email, phone")
|
||||
}).
|
||||
Preload("Images", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Order("is_primary DESC, order ASC")
|
||||
}).
|
||||
Preload("Amenities").
|
||||
First(&object, id).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrObjectNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &object, nil
|
||||
}
|
||||
|
||||
// Update обновляет объект
|
||||
func (r *objectRepository) Update(id uint, updates *models.ObjectUpdateRequest) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Обновляем основные поля
|
||||
updateData := map[string]interface{}{}
|
||||
|
||||
if updates.Title != "" {
|
||||
updateData["title"] = updates.Title
|
||||
}
|
||||
if updates.Type != "" {
|
||||
updateData["type"] = updates.Type
|
||||
}
|
||||
if updates.Description != "" {
|
||||
updateData["description"] = updates.Description
|
||||
}
|
||||
if updates.City != "" {
|
||||
updateData["city"] = updates.City
|
||||
}
|
||||
if updates.Address != "" {
|
||||
updateData["address"] = updates.Address
|
||||
}
|
||||
if updates.Latitude != 0 {
|
||||
updateData["latitude"] = updates.Latitude
|
||||
}
|
||||
if updates.Longitude != 0 {
|
||||
updateData["longitude"] = updates.Longitude
|
||||
}
|
||||
if updates.Price != 0 {
|
||||
updateData["price"] = updates.Price
|
||||
}
|
||||
if updates.PricePeriod != "" {
|
||||
updateData["price_period"] = updates.PricePeriod
|
||||
}
|
||||
if updates.Status != "" {
|
||||
updateData["status"] = updates.Status
|
||||
}
|
||||
|
||||
if len(updateData) > 0 {
|
||||
if err := tx.Model(&models.Object{}).Where("id = ?", id).Updates(updateData).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Обновляем удобства, если переданы
|
||||
if updates.AmenityIDs != nil {
|
||||
var object models.Object
|
||||
if err := tx.First(&object, id).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var amenities []models.Amenity
|
||||
if err := tx.Where("id IN ?", updates.AmenityIDs).Find(&amenities).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := tx.Model(&object).Association("Amenities").Replace(amenities); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete удаляет объект (мягкое удаление)
|
||||
func (r *objectRepository) Delete(id uint) error {
|
||||
result := r.db.Delete(&models.Object{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List возвращает список объектов с фильтрацией и пагинацией
|
||||
func (r *objectRepository) List(filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error) {
|
||||
var objects []models.Object
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Object{})
|
||||
|
||||
// Применяем фильтры
|
||||
if filter != nil {
|
||||
query = r.applyFilters(query, filter)
|
||||
}
|
||||
|
||||
// Считаем общее количество
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Применяем пагинацию
|
||||
if pagination != nil {
|
||||
offset := (pagination.Page - 1) * pagination.PageSize
|
||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
||||
}
|
||||
|
||||
// Загружаем данные с прелоадами
|
||||
err := query.
|
||||
Preload("Images", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Where("is_primary = ?", true).Limit(1)
|
||||
}).
|
||||
Preload("Amenities").
|
||||
Order("created_at DESC").
|
||||
Find(&objects).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return objects, total, nil
|
||||
}
|
||||
|
||||
// GetByOwner возвращает объекты владельца
|
||||
func (r *objectRepository) GetByOwner(ownerID uint, filter *ObjectFilter, pagination *Pagination) ([]models.Object, int64, error) {
|
||||
if filter == nil {
|
||||
filter = &ObjectFilter{}
|
||||
}
|
||||
filter.OwnerID = ownerID
|
||||
return r.List(filter, pagination)
|
||||
}
|
||||
|
||||
// UpdateStatus обновляет статус объекта
|
||||
func (r *objectRepository) UpdateStatus(id uint, status models.ObjectStatus) error {
|
||||
result := r.db.Model(&models.Object{}).Where("id = ?", id).Update("status", status)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IncrementViewCount увеличивает счетчик просмотров
|
||||
func (r *objectRepository) IncrementViewCount(id uint) error {
|
||||
return r.db.Model(&models.Object{}).
|
||||
Where("id = ?", id).
|
||||
Update("view_count", gorm.Expr("view_count + ?", 1)).
|
||||
Error
|
||||
}
|
||||
|
||||
// UpdateRating обновляет рейтинг и количество отзывов
|
||||
func (r *objectRepository) UpdateRating(id uint, rating float64, reviewCount int) error {
|
||||
return r.db.Model(&models.Object{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"rating": rating,
|
||||
"review_count": reviewCount,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// AddImage добавляет изображение к объекту
|
||||
func (r *objectRepository) AddImage(objectID uint, image *models.ObjectImage) error {
|
||||
image.ObjectID = objectID
|
||||
return r.db.Create(image).Error
|
||||
}
|
||||
|
||||
// RemoveImage удаляет изображение объекта
|
||||
func (r *objectRepository) RemoveImage(objectID uint, imageID uint) error {
|
||||
result := r.db.Where("object_id = ? AND id = ?", objectID, imageID).Delete(&models.ObjectImage{})
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetPrimaryImage устанавливает основное изображение
|
||||
func (r *objectRepository) SetPrimaryImage(objectID uint, imageID uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Сбрасываем все is_primary для объекта
|
||||
if err := tx.Model(&models.ObjectImage{}).
|
||||
Where("object_id = ?", objectID).
|
||||
Update("is_primary", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Устанавливаем новое основное изображение
|
||||
result := tx.Model(&models.ObjectImage{}).
|
||||
Where("object_id = ? AND id = ?", objectID, imageID).
|
||||
Update("is_primary", true)
|
||||
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrObjectNotFound
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetImages возвращает изображения объекта
|
||||
func (r *objectRepository) GetImages(objectID uint) ([]models.ObjectImage, error) {
|
||||
var images []models.ObjectImage
|
||||
err := r.db.Where("object_id = ?", objectID).
|
||||
Order("is_primary DESC, order ASC").
|
||||
Find(&images).Error
|
||||
return images, err
|
||||
}
|
||||
|
||||
// AddAmenities добавляет удобства к объекту
|
||||
func (r *objectRepository) AddAmenities(objectID uint, amenityIDs []uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var object models.Object
|
||||
if err := tx.First(&object, objectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var amenities []models.Amenity
|
||||
if err := tx.Where("id IN ?", amenityIDs).Find(&amenities).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&object).Association("Amenities").Append(amenities)
|
||||
})
|
||||
}
|
||||
|
||||
// RemoveAmenities удаляет удобства у объекта
|
||||
func (r *objectRepository) RemoveAmenities(objectID uint, amenityIDs []uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
var object models.Object
|
||||
if err := tx.First(&object, objectID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var amenities []models.Amenity
|
||||
if err := tx.Where("id IN ?", amenityIDs).Find(&amenities).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return tx.Model(&object).Association("Amenities").Delete(amenities)
|
||||
})
|
||||
}
|
||||
|
||||
// GetAmenities возвращает удобства объекта
|
||||
func (r *objectRepository) GetAmenities(objectID uint) ([]models.Amenity, error) {
|
||||
var amenities []models.Amenity
|
||||
err := r.db.Joins("JOIN object_amenities ON amenities.id = object_amenities.amenity_id").
|
||||
Where("object_amenities.object_id = ?", objectID).
|
||||
Find(&amenities).Error
|
||||
return amenities, err
|
||||
}
|
||||
|
||||
// applyFilters применяет фильтры к запросу
|
||||
func (r *objectRepository) applyFilters(query *gorm.DB, filter *ObjectFilter) *gorm.DB {
|
||||
if len(filter.Type) > 0 {
|
||||
query = query.Where("type IN ?", filter.Type)
|
||||
}
|
||||
|
||||
if filter.City != "" {
|
||||
query = query.Where("city = ?", filter.City)
|
||||
}
|
||||
|
||||
if len(filter.Status) > 0 {
|
||||
query = query.Where("status IN ?", filter.Status)
|
||||
}
|
||||
|
||||
if filter.OwnerID != 0 {
|
||||
query = query.Where("owner_id = ?", filter.OwnerID)
|
||||
}
|
||||
|
||||
if filter.MinPrice > 0 {
|
||||
query = query.Where("price >= ?", filter.MinPrice)
|
||||
}
|
||||
|
||||
if filter.MaxPrice > 0 {
|
||||
query = query.Where("price <= ?", filter.MaxPrice)
|
||||
}
|
||||
|
||||
if filter.MinRating > 0 {
|
||||
query = query.Where("rating >= ?", filter.MinRating)
|
||||
}
|
||||
|
||||
if filter.Search != "" {
|
||||
search := "%" + filter.Search + "%"
|
||||
query = query.Where("title ILIKE ? OR description ILIKE ?", search, search)
|
||||
}
|
||||
|
||||
// Фильтр по удобствам
|
||||
if len(filter.AmenityIDs) > 0 {
|
||||
query = query.Joins("JOIN object_amenities ON objects.id = object_amenities.object_id").
|
||||
Where("object_amenities.amenity_id IN ?", filter.AmenityIDs)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -1,389 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"gorm.io/gorm"
|
||||
"api_es/internal/models"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrReviewNotFound = errors.New("review not found")
|
||||
ErrDuplicateReview = errors.New("user already has review for this object")
|
||||
)
|
||||
|
||||
type ReviewRepository interface {
|
||||
// Основные операции
|
||||
Create(review *models.Review) error
|
||||
GetByID(id uint) (*models.Review, error)
|
||||
Update(id uint, updates map[string]interface{}) error
|
||||
Delete(id uint) error
|
||||
|
||||
// Списки отзывов
|
||||
GetByObject(objectID uint, pagination *Pagination) ([]models.Review, int64, error)
|
||||
GetByAuthor(authorID uint, pagination *Pagination) ([]models.Review, int64, error)
|
||||
GetByObjectAndAuthor(objectID, authorID uint) (*models.Review, error)
|
||||
|
||||
// Статистика
|
||||
GetObjectRatingStats(objectID uint) (float64, int, error)
|
||||
GetUserReviewStats(authorID uint) (int, float64, error)
|
||||
|
||||
// Административные методы
|
||||
SetActive(id uint, isActive bool) error
|
||||
GetAll(pagination *Pagination, filters *ReviewFilter) ([]models.Review, int64, error)
|
||||
}
|
||||
|
||||
type ReviewFilter struct {
|
||||
ObjectID uint
|
||||
AuthorID uint
|
||||
Rating int
|
||||
IsActive *bool
|
||||
MinRating int
|
||||
MaxRating int
|
||||
}
|
||||
|
||||
type reviewRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewReviewRepository(db *gorm.DB) ReviewRepository {
|
||||
return &reviewRepository{db: db}
|
||||
}
|
||||
|
||||
// Create создает новый отзыв
|
||||
func (r *reviewRepository) Create(review *models.Review) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Проверяем, не оставлял ли пользователь уже отзыв на этот объект
|
||||
var existingReview models.Review
|
||||
err := tx.Where("object_id = ? AND author_id = ?", review.ObjectID, review.AuthorID).
|
||||
First(&existingReview).Error
|
||||
|
||||
if err == nil {
|
||||
return ErrDuplicateReview
|
||||
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
|
||||
// Создаем отзыв
|
||||
if err := tx.Create(review).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем рейтинг объекта
|
||||
return r.updateObjectRating(tx, review.ObjectID)
|
||||
})
|
||||
}
|
||||
|
||||
// GetByID возвращает отзыв по ID
|
||||
func (r *reviewRepository) GetByID(id uint) (*models.Review, error) {
|
||||
var review models.Review
|
||||
err := r.db.
|
||||
Preload("Author", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, first_name, last_name, avatar")
|
||||
}).
|
||||
Preload("Object", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, title, type")
|
||||
}).
|
||||
First(&review, id).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrReviewNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &review, nil
|
||||
}
|
||||
|
||||
// Update обновляет отзыв
|
||||
func (r *reviewRepository) Update(id uint, updates map[string]interface{}) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Получаем отзыв для получения object_id
|
||||
var review models.Review
|
||||
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем отзыв
|
||||
result := tx.Model(&models.Review{}).Where("id = ?", id).Updates(updates)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
|
||||
// Обновляем рейтинг объекта, если изменился рейтинг
|
||||
if _, hasRating := updates["rating"]; hasRating {
|
||||
return r.updateObjectRating(tx, review.ObjectID)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// Delete удаляет отзыв
|
||||
func (r *reviewRepository) Delete(id uint) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Получаем отзыв для получения object_id
|
||||
var review models.Review
|
||||
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Удаляем отзыв
|
||||
result := tx.Delete(&models.Review{}, id)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
|
||||
// Обновляем рейтинг объекта
|
||||
return r.updateObjectRating(tx, review.ObjectID)
|
||||
})
|
||||
}
|
||||
|
||||
// GetByObject возвращает отзывы для объекта
|
||||
func (r *reviewRepository) GetByObject(objectID uint, pagination *Pagination) ([]models.Review, int64, error) {
|
||||
var reviews []models.Review
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Review{}).Where("object_id = ? AND is_active = ?", objectID, true)
|
||||
|
||||
// Считаем общее количество
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Применяем пагинацию
|
||||
if pagination != nil {
|
||||
offset := (pagination.Page - 1) * pagination.PageSize
|
||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
||||
}
|
||||
|
||||
// Загружаем данные
|
||||
err := query.
|
||||
Preload("Author", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, first_name, last_name, avatar")
|
||||
}).
|
||||
Order("created_at DESC").
|
||||
Find(&reviews).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return reviews, total, nil
|
||||
}
|
||||
|
||||
// GetByAuthor возвращает отзывы пользователя
|
||||
func (r *reviewRepository) GetByAuthor(authorID uint, pagination *Pagination) ([]models.Review, int64, error) {
|
||||
var reviews []models.Review
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Review{}).Where("author_id = ?", authorID)
|
||||
|
||||
// Считаем общее количество
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Применяем пагинацию
|
||||
if pagination != nil {
|
||||
offset := (pagination.Page - 1) * pagination.PageSize
|
||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
||||
}
|
||||
|
||||
// Загружаем данные
|
||||
err := query.
|
||||
Preload("Object", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, title, type, city")
|
||||
}).
|
||||
Order("created_at DESC").
|
||||
Find(&reviews).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return reviews, total, nil
|
||||
}
|
||||
|
||||
// GetByObjectAndAuthor возвращает отзыв конкретного пользователя для объекта
|
||||
func (r *reviewRepository) GetByObjectAndAuthor(objectID, authorID uint) (*models.Review, error) {
|
||||
var review models.Review
|
||||
err := r.db.
|
||||
Where("object_id = ? AND author_id = ?", objectID, authorID).
|
||||
First(&review).Error
|
||||
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrReviewNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &review, nil
|
||||
}
|
||||
|
||||
// GetObjectRatingStats возвращает статистику рейтинга для объекта
|
||||
func (r *reviewRepository) GetObjectRatingStats(objectID uint) (float64, int, error) {
|
||||
var stats struct {
|
||||
AverageRating float64
|
||||
ReviewCount int
|
||||
}
|
||||
|
||||
err := r.db.Model(&models.Review{}).
|
||||
Select("AVG(rating) as average_rating, COUNT(*) as review_count").
|
||||
Where("object_id = ? AND is_active = ?", objectID, true).
|
||||
Scan(&stats).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return stats.AverageRating, stats.ReviewCount, nil
|
||||
}
|
||||
|
||||
// GetUserReviewStats возвращает статистику отзывов пользователя
|
||||
func (r *reviewRepository) GetUserReviewStats(authorID uint) (int, float64, error) {
|
||||
var stats struct {
|
||||
ReviewCount int
|
||||
AverageRating float64
|
||||
}
|
||||
|
||||
err := r.db.Model(&models.Review{}).
|
||||
Select("COUNT(*) as review_count, AVG(rating) as average_rating").
|
||||
Where("author_id = ? AND is_active = ?", authorID, true).
|
||||
Scan(&stats).Error
|
||||
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
return stats.ReviewCount, stats.AverageRating, nil
|
||||
}
|
||||
|
||||
// SetActive активирует/деактивирует отзыв
|
||||
func (r *reviewRepository) SetActive(id uint, isActive bool) error {
|
||||
return r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// Получаем отзыв для получения object_id
|
||||
var review models.Review
|
||||
if err := tx.Select("object_id").First(&review, id).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Обновляем статус
|
||||
result := tx.Model(&models.Review{}).Where("id = ?", id).Update("is_active", isActive)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
return ErrReviewNotFound
|
||||
}
|
||||
|
||||
// Обновляем рейтинг объекта
|
||||
return r.updateObjectRating(tx, review.ObjectID)
|
||||
})
|
||||
}
|
||||
|
||||
// GetAll возвращает все отзывы с фильтрацией (для админки)
|
||||
func (r *reviewRepository) GetAll(pagination *Pagination, filters *ReviewFilter) ([]models.Review, int64, error) {
|
||||
var reviews []models.Review
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&models.Review{})
|
||||
|
||||
// Применяем фильтры
|
||||
if filters != nil {
|
||||
query = r.applyFilters(query, filters)
|
||||
}
|
||||
|
||||
// Считаем общее количество
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// Применяем пагинацию
|
||||
if pagination != nil {
|
||||
offset := (pagination.Page - 1) * pagination.PageSize
|
||||
query = query.Offset(offset).Limit(pagination.PageSize)
|
||||
}
|
||||
|
||||
// Загружаем данные
|
||||
err := query.
|
||||
Preload("Author", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, first_name, last_name, email")
|
||||
}).
|
||||
Preload("Object", func(db *gorm.DB) *gorm.DB {
|
||||
return db.Select("id, title, type")
|
||||
}).
|
||||
Order("created_at DESC").
|
||||
Find(&reviews).Error
|
||||
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return reviews, total, nil
|
||||
}
|
||||
|
||||
// updateObjectRating обновляет рейтинг объекта
|
||||
func (r *reviewRepository) updateObjectRating(tx *gorm.DB, objectID uint) error {
|
||||
stats, _, err := r.GetObjectRatingStats(objectID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
count_ := int64(0)
|
||||
|
||||
// Обновляем рейтинг объекта
|
||||
return tx.Model(&models.Object{}).
|
||||
Where("id = ?", objectID).
|
||||
Updates(map[string]interface{}{
|
||||
"rating": stats,
|
||||
"review_count": tx.Model(&models.Review{}).
|
||||
Where("object_id = ? AND is_active = ?", objectID, true).
|
||||
Count(&count_),
|
||||
}).Error
|
||||
}
|
||||
|
||||
// applyFilters применяет фильтры к запросу
|
||||
func (r *reviewRepository) applyFilters(query *gorm.DB, filters *ReviewFilter) *gorm.DB {
|
||||
if filters.ObjectID != 0 {
|
||||
query = query.Where("object_id = ?", filters.ObjectID)
|
||||
}
|
||||
|
||||
if filters.AuthorID != 0 {
|
||||
query = query.Where("author_id = ?", filters.AuthorID)
|
||||
}
|
||||
|
||||
if filters.Rating != 0 {
|
||||
query = query.Where("rating = ?", filters.Rating)
|
||||
}
|
||||
|
||||
if filters.IsActive != nil {
|
||||
query = query.Where("is_active = ?", *filters.IsActive)
|
||||
}
|
||||
|
||||
if filters.MinRating > 0 {
|
||||
query = query.Where("rating >= ?", filters.MinRating)
|
||||
}
|
||||
|
||||
if filters.MaxRating > 0 {
|
||||
query = query.Where("rating <= ?", filters.MaxRating)
|
||||
}
|
||||
|
||||
return query
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"api_es/internal/models"
|
||||
"context"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserRepository interface {
|
||||
Create(ctx context.Context, user *models.User) error
|
||||
GetByID(ctx context.Context, id uint) (*models.User, error)
|
||||
GetByEmail(ctx context.Context, email string) (*models.User, error)
|
||||
Update(ctx context.Context, user *models.User) error
|
||||
Delete(ctx context.Context, id uint) error
|
||||
List(ctx context.Context, limit, offset int) ([]*models.User, error)
|
||||
GetUserStats(ctx context.Context, userID uint) (*models.UserStats, error)
|
||||
}
|
||||
|
||||
type userRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserRepository(db *gorm.DB) UserRepository {
|
||||
return &userRepository{db: db}
|
||||
}
|
||||
|
||||
func (r *userRepository) Create(ctx context.Context, user *models.User) error {
|
||||
return r.db.WithContext(ctx).Create(user).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) GetByID(ctx context.Context, id uint) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.WithContext(ctx).First(&user, id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetByEmail(ctx context.Context, email string) (*models.User, error) {
|
||||
var user models.User
|
||||
err := r.db.WithContext(ctx).Where("email = ?", email).First(&user).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) Update(ctx context.Context, user *models.User) error {
|
||||
return r.db.WithContext(ctx).Save(user).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) Delete(ctx context.Context, id uint) error {
|
||||
return r.db.WithContext(ctx).Delete(&models.User{}, id).Error
|
||||
}
|
||||
|
||||
func (r *userRepository) List(ctx context.Context, limit, offset int) ([]*models.User, error) {
|
||||
var users []*models.User
|
||||
err := r.db.WithContext(ctx).Limit(limit).Offset(offset).Find(&users).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func (r *userRepository) GetUserStats(ctx context.Context, userID uint) (*models.UserStats, error) {
|
||||
var stats models.UserStats
|
||||
err := r.db.WithContext(ctx).First(&stats, userID).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &stats, nil
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"api_es/internal/config"
|
||||
"api_es/pkg/logger"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"api_es/internal/handler"
|
||||
appMiddleware "api_es/internal/middleware"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func SetupRouter(db *gorm.DB, config *config.Config) http.Handler {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Start setup rounting")
|
||||
r := chi.NewRouter()
|
||||
|
||||
// Initialize logger
|
||||
baseLogger := logger.NewWrapper(logger.Get())
|
||||
|
||||
setupMiddlewares(r)
|
||||
|
||||
// Health check
|
||||
r.Get("/health", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(map[string]string{"status": "healthy"})
|
||||
})
|
||||
|
||||
h := handler.NewAllHandler(db, config)
|
||||
|
||||
// Health routes
|
||||
r.Route("/", func(r chi.Router) {
|
||||
r.Get("/health", h.HealthHandler().HealthCheck)
|
||||
r.Get("/check", h.HealthHandler().Check)
|
||||
})
|
||||
|
||||
// router.go (обновляем секцию auth routes)
|
||||
r.Route("/auth", func(r chi.Router) {
|
||||
r.Post("/register", h.UserHandler().Register)
|
||||
r.Post("/login", h.UserHandler().Login)
|
||||
r.Post("/logout", h.UserHandler().Logout)
|
||||
r.Post("/refresh", h.UserHandler().RefreshToken)
|
||||
})
|
||||
|
||||
r.Route("/users", func(r chi.Router) {
|
||||
r.Use(appMiddleware.AuthMiddleware)
|
||||
|
||||
r.Get("/profile", h.UserHandler().GetProfile)
|
||||
r.Put("/profile", h.UserHandler().UpdateProfile)
|
||||
|
||||
// Admin routes
|
||||
r.With(appMiddleware.AdminMiddleware).Get("/", h.UserHandler().ListUsers)
|
||||
r.With(appMiddleware.AdminMiddleware).Get("/{id}", h.UserHandler().GetUser)
|
||||
})
|
||||
|
||||
zapLogger.Debug("End setup routing")
|
||||
|
||||
// Логируем все зарегистрированные маршруты
|
||||
routeLogger := logger.NewRouteLogger(baseLogger)
|
||||
routeLogger.LogRoutes(r)
|
||||
|
||||
return r
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/go-chi/cors"
|
||||
)
|
||||
|
||||
// setupMiddlewares — устанавливает общие middleware для роутера.
|
||||
func setupMiddlewares(r *chi.Mux) {
|
||||
// Логирование всех запросов
|
||||
r.Use(middleware.Logger)
|
||||
|
||||
// Восстановление после паник
|
||||
r.Use(middleware.Recoverer)
|
||||
|
||||
// Удаление завершающих слешей
|
||||
r.Use(middleware.StripSlashes)
|
||||
|
||||
// Установка реального IP из заголовков (X-Forwarded-For, X-Real-IP)
|
||||
r.Use(middleware.RealIP)
|
||||
|
||||
// Таймаут обработки запроса
|
||||
r.Use(middleware.Timeout(30 * time.Second))
|
||||
|
||||
// Поддержка CORS
|
||||
r.Use(cors.Handler(cors.Options{
|
||||
AllowedOrigins: []string{"https://easysite102.ru", "http://localhost:3000"},
|
||||
AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH"},
|
||||
AllowedHeaders: []string{"Accept", "Authorization", "Content-Type", "X-CSRF-Token", "X-Requested-With"},
|
||||
ExposedHeaders: []string{"Link"},
|
||||
AllowCredentials: true,
|
||||
MaxAge: 300, // 5 минут
|
||||
}))
|
||||
|
||||
|
||||
// Можно добавить и другие кастомные middleware при необходимости
|
||||
}
|
||||
@@ -1,201 +0,0 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"api_es/internal/dto"
|
||||
"api_es/internal/models"
|
||||
"api_es/internal/repository"
|
||||
"api_es/internal/utils"
|
||||
"api_es/pkg/logger"
|
||||
|
||||
"golang.org/x/crypto/bcrypt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUserNotFound = errors.New("user not found")
|
||||
ErrInvalidCredentials = errors.New("invalid credentials")
|
||||
ErrUserAlreadyExists = errors.New("user already exists")
|
||||
ErrInvalidPassword = errors.New("invalid password")
|
||||
)
|
||||
|
||||
type UserService interface {
|
||||
Register(ctx context.Context, req dto.RegisterRequest) (*dto.AuthResponse, error)
|
||||
Login(ctx context.Context, req dto.LoginRequest) (*dto.AuthResponse, error)
|
||||
GetUser(ctx context.Context, id uint) (*dto.UserResponse, error)
|
||||
UpdateUser(ctx context.Context, id uint, req dto.UpdateUserRequest) (*dto.UserResponse, error)
|
||||
DeleteUser(ctx context.Context, id uint) error
|
||||
ListUsers(ctx context.Context, limit, offset int) ([]*dto.UserResponse, error)
|
||||
GetUserProfile(ctx context.Context, id uint) (*dto.UserResponse, error)
|
||||
}
|
||||
|
||||
type userService struct {
|
||||
userRepo repository.UserRepository
|
||||
jwtUtil *utils.JWTUtil
|
||||
}
|
||||
|
||||
func NewUserService(userRepo repository.UserRepository, jwtUtil *utils.JWTUtil) UserService {
|
||||
return &userService{
|
||||
userRepo: userRepo,
|
||||
jwtUtil: jwtUtil,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *userService) Register(ctx context.Context, req dto.RegisterRequest) (*dto.AuthResponse, error) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Start register")
|
||||
// Проверяем существование пользователя
|
||||
existingUser, _ := s.userRepo.GetByEmail(ctx, req.Email)
|
||||
if existingUser != nil {
|
||||
return nil, ErrUserAlreadyExists
|
||||
}
|
||||
|
||||
// Хешируем пароль
|
||||
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Создаем пользователя
|
||||
user := &models.User{
|
||||
Email: req.Email,
|
||||
PasswordHash: string(hashedPassword),
|
||||
FullName: req.FullName,
|
||||
FirstName: req.FirstName,
|
||||
LastName: req.LastName,
|
||||
Phone: req.Phone,
|
||||
City: req.City,
|
||||
IsActive: true,
|
||||
IsVerified: false,
|
||||
Role: "user",
|
||||
}
|
||||
|
||||
if err := s.userRepo.Create(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Генерируем токен
|
||||
token, err := s.jwtUtil.GenerateToken(user.ID, user.Email, user.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userResponse := dto.ToUserResponse(user)
|
||||
zapLogger.Debug("End register")
|
||||
return &dto.AuthResponse{
|
||||
Token: token,
|
||||
User: userResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *userService) Login(ctx context.Context, req dto.LoginRequest) (*dto.AuthResponse, error) {
|
||||
zapLogger := logger.Get()
|
||||
zapLogger.Debug("Start login")
|
||||
// Находим пользователя по email
|
||||
user, err := s.userRepo.GetByEmail(ctx, req.Email)
|
||||
if err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Проверяем пароль
|
||||
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(req.Password)); err != nil {
|
||||
return nil, ErrInvalidCredentials
|
||||
}
|
||||
|
||||
// Проверяем активность пользователя
|
||||
if !user.IsActive {
|
||||
return nil, errors.New("account is deactivated")
|
||||
}
|
||||
|
||||
// Генерируем токен
|
||||
token, err := s.jwtUtil.GenerateToken(user.ID, user.Email, user.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
userResponse := dto.ToUserResponse(user)
|
||||
zapLogger.Debug("End login")
|
||||
return &dto.AuthResponse{
|
||||
Token: token,
|
||||
User: userResponse,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (s *userService) GetUser(ctx context.Context, id uint) (*dto.UserResponse, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
response := dto.ToUserResponse(user)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (s *userService) UpdateUser(ctx context.Context, id uint, req dto.UpdateUserRequest) (*dto.UserResponse, error) {
|
||||
user, err := s.userRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, ErrUserNotFound
|
||||
}
|
||||
|
||||
// Обновляем поля
|
||||
if req.FullName != "" {
|
||||
user.FullName = req.FullName
|
||||
}
|
||||
if req.FirstName != "" {
|
||||
user.FirstName = req.FirstName
|
||||
}
|
||||
if req.LastName != "" {
|
||||
user.LastName = req.LastName
|
||||
}
|
||||
if req.Phone != "" {
|
||||
user.Phone = req.Phone
|
||||
}
|
||||
if req.City != "" {
|
||||
user.City = req.City
|
||||
}
|
||||
if req.OrganizationForm != "" {
|
||||
user.OrganizationForm = req.OrganizationForm
|
||||
}
|
||||
if req.OrganizationName != "" {
|
||||
user.OrganizationName = req.OrganizationName
|
||||
}
|
||||
if req.OrganizationShort != "" {
|
||||
user.OrganizationShort = req.OrganizationShort
|
||||
}
|
||||
if req.INN != "" {
|
||||
user.INN = req.INN
|
||||
}
|
||||
if req.PersonalINN != "" {
|
||||
user.PersonalINN = req.PersonalINN
|
||||
}
|
||||
|
||||
if err := s.userRepo.Update(ctx, user); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
response := dto.ToUserResponse(user)
|
||||
return &response, nil
|
||||
}
|
||||
|
||||
func (s *userService) DeleteUser(ctx context.Context, id uint) error {
|
||||
return s.userRepo.Delete(ctx, id)
|
||||
}
|
||||
|
||||
func (s *userService) ListUsers(ctx context.Context, limit, offset int) ([]*dto.UserResponse, error) {
|
||||
users, err := s.userRepo.List(ctx, limit, offset)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
responses := make([]*dto.UserResponse, len(users))
|
||||
for i, user := range users {
|
||||
response := dto.ToUserResponse(user)
|
||||
responses[i] = &response
|
||||
}
|
||||
|
||||
return responses, nil
|
||||
}
|
||||
|
||||
func (s *userService) GetUserProfile(ctx context.Context, id uint) (*dto.UserResponse, error) {
|
||||
return s.GetUser(ctx, id)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package utils
|
||||
|
||||
// formatPace форматирует темп в строку "MM:SS"
|
||||
func FormatPace(minutes, seconds int) string {
|
||||
if seconds >= 60 {
|
||||
minutes += seconds / 60
|
||||
seconds = seconds % 60
|
||||
}
|
||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
||||
}
|
||||
|
||||
// formatTwoDigits форматирует число в двузначную строку
|
||||
func FormatTwoDigits(num int) string {
|
||||
if num < 10 {
|
||||
return "0" + string(rune(num+'0'))
|
||||
}
|
||||
return string(rune(num/10+'0')) + string(rune(num%10+'0'))
|
||||
}
|
||||
|
||||
// formatTime форматирует время в строку "MM:SS"
|
||||
func FormatTime(minutes, seconds int) string {
|
||||
if seconds >= 60 {
|
||||
minutes += seconds / 60
|
||||
seconds = seconds % 60
|
||||
}
|
||||
return FormatTwoDigits(minutes) + ":" + FormatTwoDigits(seconds)
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/golang-jwt/jwt/v4"
|
||||
)
|
||||
|
||||
type JWTUtil struct {
|
||||
secretKey string
|
||||
}
|
||||
|
||||
type Claims struct {
|
||||
UserID uint `json:"user_id"`
|
||||
Email string `json:"email"`
|
||||
Role string `json:"role"`
|
||||
jwt.RegisteredClaims
|
||||
}
|
||||
|
||||
func NewJWTUtil(secretKey string) *JWTUtil {
|
||||
return &JWTUtil{secretKey: secretKey}
|
||||
}
|
||||
|
||||
func (j *JWTUtil) GenerateToken(userID uint, email, role string) (string, error) {
|
||||
claims := Claims{
|
||||
UserID: userID,
|
||||
Email: email,
|
||||
Role: role,
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
ExpiresAt: jwt.NewNumericDate(time.Now().Add(24 * time.Hour)),
|
||||
IssuedAt: jwt.NewNumericDate(time.Now()),
|
||||
},
|
||||
}
|
||||
|
||||
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
|
||||
return token.SignedString([]byte(j.secretKey))
|
||||
}
|
||||
|
||||
func (j *JWTUtil) ValidateToken(tokenString string) (*Claims, error) {
|
||||
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
return []byte(j.secretKey), nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
return nil, jwt.ErrInvalidKey
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
// pkg/utils/response.go (дополнение)
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// RespondWithValidationError отправляет ответ с ошибками валидации
|
||||
func RespondWithValidationError(w http.ResponseWriter, validationError error) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"error": "Validation failed",
|
||||
"details": GetValidationErrors(validationError),
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
func RespondWithJSON(w http.ResponseWriter, statusCode int, data interface{}) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(statusCode)
|
||||
json.NewEncoder(w).Encode(data)
|
||||
}
|
||||
|
||||
func RespondWithError(w http.ResponseWriter, statusCode int, message string) {
|
||||
RespondWithJSON(w, statusCode, map[string]string{"error": message})
|
||||
}
|
||||
|
||||
// DecodeJSONBody декодирует JSON тело запроса
|
||||
func DecodeJSONBody(w http.ResponseWriter, r *http.Request, dst interface{}) error {
|
||||
if r.Header.Get("Content-Type") != "application/json" {
|
||||
return errors.New("Content-Type header is not application/json")
|
||||
}
|
||||
|
||||
r.Body = http.MaxBytesReader(w, r.Body, 1048576) // 1MB limit
|
||||
|
||||
dec := json.NewDecoder(r.Body)
|
||||
dec.DisallowUnknownFields()
|
||||
|
||||
err := dec.Decode(dst)
|
||||
if err != nil {
|
||||
var syntaxError *json.SyntaxError
|
||||
var unmarshalTypeError *json.UnmarshalTypeError
|
||||
|
||||
switch {
|
||||
case errors.As(err, &syntaxError):
|
||||
return fmt.Errorf("request body contains badly-formed JSON (at position %d)", syntaxError.Offset)
|
||||
|
||||
case errors.Is(err, io.ErrUnexpectedEOF):
|
||||
return errors.New("request body contains badly-formed JSON")
|
||||
|
||||
case errors.As(err, &unmarshalTypeError):
|
||||
return fmt.Errorf("request body contains an invalid value for the %q field (at position %d)", unmarshalTypeError.Field, unmarshalTypeError.Offset)
|
||||
|
||||
case strings.HasPrefix(err.Error(), "json: unknown field "):
|
||||
fieldName := strings.TrimPrefix(err.Error(), "json: unknown field ")
|
||||
return fmt.Errorf("request body contains unknown field %s", fieldName)
|
||||
|
||||
case errors.Is(err, io.EOF):
|
||||
return errors.New("request body must not be empty")
|
||||
|
||||
case err.Error() == "http: request body too large":
|
||||
return errors.New("request body must not be larger than 1MB")
|
||||
|
||||
default:
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = dec.Decode(&struct{}{})
|
||||
if err != io.EOF {
|
||||
return errors.New("request body must only contain a single JSON object")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserIDFromContext извлекает userID из контекста
|
||||
func GetUserIDFromContext(r *http.Request) (uint, bool) {
|
||||
userID, ok := r.Context().Value("userID").(uint)
|
||||
return userID, ok
|
||||
}
|
||||