Compare commits
50 Commits
main
..
eee067f0ca
| 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
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
name: Deploy api_yal
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
|
|
||||||
- name: Build Go binary (api_yal)
|
|
||||||
run: |
|
|
||||||
docker run --rm \
|
|
||||||
-v ${{ github.workspace }}:/workspace \
|
|
||||||
-w /workspace/main_dc/yalarba/api_yal \
|
|
||||||
golang:1.26.0-alpine \
|
|
||||||
sh -c 'CGO_ENABLED=0 GOOS=linux go build -o /workspace/main_dc/yalarba/api_yal/bin/api_yal ./cmd/main.go'
|
|
||||||
|
|
||||||
- name: Copy binary and restart service
|
|
||||||
run: |
|
|
||||||
cp main_dc/yalarba/api_yal/bin/api_yal /home/gaziz/artefacts/tp/main_dc/yalarba/api_yal/bin/api_yal
|
|
||||||
docker compose -f /home/gaziz/artefacts/tp/main_dc/docker-compose.yml restart api_yal
|
|
||||||
@@ -1,7 +1,4 @@
|
|||||||
/spa/node_modules
|
/spa/node_modules
|
||||||
.env
|
|
||||||
|
|
||||||
# Logs
|
|
||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
npm-debug.log*
|
npm-debug.log*
|
||||||
@@ -9,39 +6,32 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
pnpm-debug.log*
|
pnpm-debug.log*
|
||||||
lerna-debug.log*
|
lerna-debug.log*
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
*.node_modules
|
|
||||||
/node_modules
|
|
||||||
/.pnp
|
|
||||||
.pnp.js
|
|
||||||
.DS_Store
|
.DS_Store
|
||||||
dist
|
|
||||||
dist-ssr
|
|
||||||
coverage
|
coverage
|
||||||
/coverage
|
|
||||||
/build
|
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
.env.local
|
|
||||||
.env.development.local
|
|
||||||
.env.test.local
|
|
||||||
.env.production.local
|
|
||||||
|
|
||||||
/cypress/videos/
|
/cypress/videos/
|
||||||
/cypress/screenshots/
|
/cypress/screenshots/
|
||||||
|
|
||||||
# Editor directories and files
|
|
||||||
.vscode/*
|
.vscode/*
|
||||||
!.vscode/extensions.json
|
.vscode/extensions.json
|
||||||
.idea
|
.idea
|
||||||
*.suo
|
*.suo
|
||||||
*.ntvs*
|
*.ntvs*
|
||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
|
*.node_modules
|
||||||
# Binaries
|
/node_modules
|
||||||
api/bin/
|
/.pnp
|
||||||
|
.pnp.js
|
||||||
|
/coverage
|
||||||
|
/build
|
||||||
|
.env.local
|
||||||
|
.env.development.local
|
||||||
|
.env.test.local
|
||||||
|
.env.production.local
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.vscode/extensions.json
|
||||||
|
|||||||
@@ -1,103 +1,69 @@
|
|||||||
# YalArba (ЯлАрба) — Tourist Aggregator
|
# AGENTS.md
|
||||||
|
|
||||||
**Updated:** 2026-06-13
|
## Repo overview
|
||||||
**Branch:** main (merged develop)
|
|
||||||
|
|
||||||
## OVERVIEW
|
Docker Compose hosting for 4 websites (yalarba.ru, begushiybashkir.ru, easysite102.ru, valitovgaziz.ru).
|
||||||
Multi-service platform: Go REST APIs + Vue3/Nuxt SPAs + Nginx reverse proxy + Certbot SSL.
|
All infrastructure lives under `main_dc/`. Root `package.json` is vestigial — do not use it.
|
||||||
All deployable code lives under `main_dc/`. Docker Compose orchestrates ~10 services across 4 Docker networks.
|
|
||||||
PostgreSQL databases, JWT auth, Gitea Actions CI/CD.
|
## Directory structure
|
||||||
|
|
||||||
## STRUCTURE
|
|
||||||
```
|
```
|
||||||
main_dc/
|
main_dc/
|
||||||
├── docker-compose.yml # All services (nginx, certbot, db, apis, spas)
|
docker-compose.yml -- single compose file orchestrating everything
|
||||||
├── Makefile # Per-service build/restart targets
|
Makefile -- the primary dev/ops interface; use `make` not raw docker
|
||||||
├── .env # Environment variables (DO NOT COMMIT)
|
.env -- shared env: domains, email, api_es port
|
||||||
├── certbot/ # Let's Encrypt auto-renewal
|
BB/api_bb/ -- Go REST API (GORM+Chi), port 7777, DB: db_bb (5433)
|
||||||
├── nginx/ # Reverse proxy + SSL termination
|
BB/bbvue/ -- Vue 3 + Vite frontend for begushiybashkir.ru
|
||||||
├── stubSite/ # Maintenance/placeholder page
|
yalarba/api_tp/ -- Go REST API (GORM+Chi), port 8888, DB: db (5432)
|
||||||
├── valitovgaziz/ # Personal site + analytics
|
yalarba/api_es/ -- Go REST API (GORM+Chi), port 8088, DB: db (5432)
|
||||||
│ ├── html/ # Static site (valitovgaziz.ru)
|
yalarba/api_yal/ -- Go REST API (GORM+Chi), port 8787, DB: db (5432)
|
||||||
│ └── analytics/ # Node.js analytics server
|
yalarba/easySite/easySite/ -- Nuxt 4 SPA for easysite102.ru
|
||||||
├── BB/ # «Бегущий Башкир» project
|
yalarba/serv_spa/spa/vue/ -- Vue 3 + Vite SPA for yalarba.ru
|
||||||
│ ├── api_bb/ # Go REST API (chi + GORM)
|
valitovgaziz/analytics/ -- Node.js (Express) analytics server, port 9999
|
||||||
│ └── bbvue/ # Vue3 SPA frontend
|
valitovgaziz/html/ -- static HTML for valitovgaziz.ru
|
||||||
└── yalarba/ # YalArba platform
|
nginx/ -- nginx with automatic HTTP↔HTTPS switching
|
||||||
├── api_tp/ # Go API — auth + users (chi + GORM)
|
certbot/ -- Let's Encrypt cert management
|
||||||
├── api_yal/ # Go API — accounts, objects, ratings (DDD)
|
stubSite/ -- placeholder site while building
|
||||||
├── api_es/ # Go API — easysite102 backend
|
|
||||||
├── easySite/ # Nuxt.js SPA (easysite102.ru)
|
|
||||||
├── serv_spa/ # Vue3 SPA (yalarba.ru frontend)
|
|
||||||
└── tourism_types/ # Tourism classification data
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## KEY SERVICES
|
## Developer commands (always run from `main_dc/`)
|
||||||
|
|
||||||
| Service | Port | Tech | Description |
|
| Command | What it does |
|
||||||
|---------|------|------|-------------|
|
|---|---|
|
||||||
| nginx | 80, 443 | Nginx | Reverse proxy, SSL, static files |
|
| `make all` | Full cycle: down → git pull → build --no-cache → up -d → watch |
|
||||||
| certbot | — | Certbot | Auto-renew Let's Encrypt certs |
|
| `make <svc>` | Full cycle for one service, e.g. `make api_bb`, `make nginx`, `make es`, `make analytics` |
|
||||||
| db | 5432 | PostgreSQL 15 | Main DB (yalarba + easysite) |
|
| `make bbvue` | Rebuild Vue frontend (calls `npm run build` in `BB/bbvue/`) |
|
||||||
| db_bb | 5433 | PostgreSQL 15 | «Бегущий Башкир» DB |
|
| `make vue_bb` | git pull + npm cache clean + bbvue build + watch |
|
||||||
| api_tp | 8888 | Go/Chi/GORM | Auth + user management |
|
| `make wn` | `watch -n2 docker ps` — monitor containers |
|
||||||
| api_yal | 8787 (internal) | Go/Chi/GORM | Accounts, objects, ratings |
|
| `make bb_db` | `psql -U postgres -d bb_db` inside db_bb container |
|
||||||
| api_es | 8088 (internal) | Go/Chi/GORM | easysite102 backend |
|
|
||||||
| api_bb | 7777 | Go/Chi/GORM | «Бегущий Башкир» backend |
|
|
||||||
| easysite | 3000 | Nuxt.js | easysite102.ru frontend |
|
|
||||||
| analytics | 9999 | Node.js | valitovgaziz.ru analytics |
|
|
||||||
|
|
||||||
## WHERE TO LOOK
|
All `build_*` targets use `--no-cache`.
|
||||||
|
All full-cycle targets follow: `stop_<svc> → git → build_<svc> → start_<svc> → wn`.
|
||||||
|
|
||||||
| Task | Location |
|
## Frontend dev (outside compose)
|
||||||
|------|----------|
|
|
||||||
| YalArba auth API | `main_dc/yalarba/api_tp/` |
|
|
||||||
| YalArba business logic (DDD) | `main_dc/yalarba/api_yal/internal/domain/` |
|
|
||||||
| Main SPA (Vue3) | `main_dc/yalarba/serv_spa/spa/vue/` |
|
|
||||||
| EasySite (Nuxt) | `main_dc/yalarba/easySite/easySite/` |
|
|
||||||
| Nginx config | `main_dc/nginx/nginx-ssl.conf` |
|
|
||||||
| Docker compose | `main_dc/docker-compose.yml` |
|
|
||||||
| CI/CD workflow | `.gitea/workflows/deploy.yml` |
|
|
||||||
| Personal site | `main_dc/valitovgaziz/html/` |
|
|
||||||
|
|
||||||
## CODE MAP — api_yal (main business logic)
|
```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/
|
||||||
|
|
||||||
| Symbol | Location | Role |
|
cd main_dc/yalarba/serv_spa/spa/vue && npm run dev # Vite dev (yalarba SPA)
|
||||||
|--------|----------|------|
|
|
||||||
| `main` | `main_dc/yalarba/api_yal/cmd/main.go` | Entry point |
|
|
||||||
| `router.SetupRoutes` | `main_dc/yalarba/api_yal/internal/router/router.go` | Chi routes + middleware |
|
|
||||||
| `domain/auth` | `main_dc/yalarba/api_yal/internal/domain/auth/` | Login, Register, JWT, refresh tokens |
|
|
||||||
| `domain/account` | `main_dc/yalarba/api_yal/internal/domain/account/` | User profile CRUD |
|
|
||||||
| `domain/object` | `main_dc/yalarba/api_yal/internal/domain/object/` | Tourist objects CRUD |
|
|
||||||
| `domain/rating` | `main_dc/yalarba/api_yal/internal/domain/rating/` | Ratings (stub) |
|
|
||||||
| `domain/comment` | `main_dc/yalarba/api_yal/internal/domain/comment/` | Comments (stub) |
|
|
||||||
| `domain/feetback` | `main_dc/yalarba/api_yal/internal/domain/feetback/` | Feedback (stub) |
|
|
||||||
| `models` | `main_dc/yalarba/api_yal/internal/models/` | GORM models |
|
|
||||||
| `repository` | `main_dc/yalarba/api_yal/internal/repository/` | DB access layer |
|
|
||||||
| `middleware` | `main_dc/yalarba/api_yal/internal/middleware/` | Auth, admin, logging, context |
|
|
||||||
|
|
||||||
## CONVENTIONS
|
cd main_dc/yalarba/easySite/easySite && npm run dev # Nuxt dev
|
||||||
- **Go**: Chi router, GORM, PostgreSQL. DDD-layered (handler → service → repository).
|
cd main_dc/yalarba/easySite/easySite && npm run build # Nuxt build
|
||||||
- **Auth**: JWT in HttpOnly/Secure/SameSite cookies. Context key: user claims.
|
```
|
||||||
- **Config**: Environment variables via `.env` files, loaded by docker-compose.
|
|
||||||
- **DB Migrations**: Manual SQL in `main_dc/yalarba/api_tp/migrations/`.
|
|
||||||
- **Naming**: Go — short package names. Vue — PascalCase components.
|
|
||||||
|
|
||||||
## CI/CD
|
## Service quirks
|
||||||
- **Trigger**: Push to `main` branch
|
|
||||||
- **Workflow**: `.gitea/workflows/deploy.yml`
|
|
||||||
- **What it does**: Builds `api_yal` Go binary → copies to server → restarts container
|
|
||||||
- **Manual deploy**: Use `make` targets from `main_dc/Makefile`:
|
|
||||||
```bash
|
|
||||||
# On server:
|
|
||||||
make api_yal # Full cycle: stop → pull → build → start api_yal
|
|
||||||
make all # Full cycle for ALL services
|
|
||||||
make restart # Quick restart all
|
|
||||||
make nginx # Rebuild nginx
|
|
||||||
```
|
|
||||||
|
|
||||||
## NOTES
|
- **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.
|
||||||
- `.env` and `*.env` files contain secrets — **never commit**.
|
- **`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`.
|
||||||
- The old `api/`, `spa/`, `migrator/` at repo root are deleted; all code is in `main_dc/`.
|
- **`api_yal`** is the only container that runs as non-root. Runs on port 8787.
|
||||||
- `develop` branch was merged into `main` on 2026-06-13.
|
- **`api_es`** port is configurable via `API_ES_APP_PORT` in `.env` (default 8088). All other API ports are hardcoded.
|
||||||
- Google Goose migrations were removed; migrations are plain SQL in `api_tp/migrations/`.
|
- **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 всех сайтов
|
1. Написать документацию к api всех сайтов
|
||||||
2. Доработать begushiybashkir.ru && easysite102.rr
|
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_ADMIN_PASSWORD=your_secure_password
|
||||||
KEYCLOAK_DB_PASSWORD=your_secure_db_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
|
WORKDIR /app
|
||||||
|
|
||||||
# Копируем go.mod и go.sum
|
# Копируем весь исходный код
|
||||||
COPY go.mod go.sum ./
|
|
||||||
RUN go mod download
|
|
||||||
|
|
||||||
# Копируем исходный код
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# Скачиваем зависимости
|
||||||
|
RUN go mod tidy && go mod download
|
||||||
|
|
||||||
# Компилируем БЕЗ CGO
|
# Компилируем БЕЗ CGO
|
||||||
RUN CGO_ENABLED=0 GOOS=linux go build -o bin/main ./cmd/main.go
|
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/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.0
|
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
|
golang.org/x/crypto v0.43.0
|
||||||
gorm.io/driver/postgres v1.6.0
|
gorm.io/driver/postgres v1.6.0
|
||||||
gorm.io/gorm v1.31.0
|
gorm.io/gorm v1.31.0
|
||||||
@@ -15,8 +16,12 @@ require (
|
|||||||
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.10 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.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/leodido/go-urn v1.4.0 // indirect
|
||||||
|
github.com/lib/pq v1.10.9 // indirect
|
||||||
github.com/stretchr/testify v1.11.1 // 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
|
go.uber.org/multierr v1.10.0 // indirect
|
||||||
golang.org/x/sys v0.37.0 // indirect
|
golang.org/x/sys v0.37.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -32,7 +32,8 @@ func (a *App) Initialize() error {
|
|||||||
|
|
||||||
// Инициализация базы данных
|
// Инициализация базы данных
|
||||||
dbConfig := &database.Config{
|
dbConfig := &database.Config{
|
||||||
URL: a.cfg.DatabaseURL,
|
URL: a.cfg.DatabaseURL,
|
||||||
|
Schema: a.cfg.DBSchema,
|
||||||
}
|
}
|
||||||
a.db = database.NewDatabase(dbConfig)
|
a.db = database.NewDatabase(dbConfig)
|
||||||
|
|
||||||
@@ -46,11 +47,6 @@ func (a *App) Initialize() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Выполнение миграций
|
|
||||||
if err := a.db.Migrate(); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Настройка роутера
|
// Настройка роутера
|
||||||
router := routes.SetupRouter(a.db.DB, a.cfg)
|
router := routes.SetupRouter(a.db.DB, a.cfg)
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import (
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Port string
|
Port string
|
||||||
DatabaseURL string
|
DatabaseURL string
|
||||||
|
DBSchema string
|
||||||
StaticURL string `env:"STATIC_URL" envDefault:"http://localhost:8080"`
|
StaticURL string `env:"STATIC_URL" envDefault:"http://localhost:8080"`
|
||||||
JWTSecret string `env:"JWT_SECRET,required"`
|
JWTSecret string `env:"JWT_SECRET,required"`
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ func Load() *Config {
|
|||||||
return &Config{
|
return &Config{
|
||||||
Port: port,
|
Port: port,
|
||||||
DatabaseURL: databaseURL,
|
DatabaseURL: databaseURL,
|
||||||
|
DBSchema: getEnv("DB_SCHEMA", "public"),
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"api_bb/migrations"
|
||||||
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"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"
|
"go.uber.org/zap"
|
||||||
"gorm.io/driver/postgres"
|
gormpg "gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"api_bb/pkg/logger"
|
"api_bb/pkg/logger"
|
||||||
@@ -17,26 +23,34 @@ type Database struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
URL string
|
URL string
|
||||||
|
Schema string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDatabase(cfg *Config) *Database {
|
func NewDatabase(cfg *Config) *Database {
|
||||||
|
if cfg.Schema == "" {
|
||||||
|
cfg.Schema = "public"
|
||||||
|
}
|
||||||
return &Database{
|
return &Database{
|
||||||
cfg: cfg,
|
cfg: cfg,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect устанавливает соединение с базой данных
|
|
||||||
func (d *Database) Connect() error {
|
func (d *Database) Connect() error {
|
||||||
zapLogger := logger.Get()
|
zapLogger := logger.Get()
|
||||||
|
|
||||||
// Логирование попытки подключения к БД
|
|
||||||
zapLogger.Info("attempting to connect to database",
|
zapLogger.Info("attempting to connect to database",
|
||||||
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
||||||
zap.String("database", ExtractDBNameFromDSN(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 {
|
if err != nil {
|
||||||
zapLogger.Error("failed to connect to database",
|
zapLogger.Error("failed to connect to database",
|
||||||
zap.Error(err),
|
zap.Error(err),
|
||||||
@@ -47,7 +61,21 @@ func (d *Database) Connect() error {
|
|||||||
|
|
||||||
d.DB = db
|
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",
|
zapLogger.Info("successfully connected to database",
|
||||||
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
zap.String("host", ExtractHostFromDSN(d.cfg.URL)),
|
||||||
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
|
zap.String("database", ExtractDBNameFromDSN(d.cfg.URL)),
|
||||||
@@ -56,7 +84,32 @@ func (d *Database) Connect() error {
|
|||||||
return nil
|
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 {
|
func (d *Database) Ping() error {
|
||||||
zapLogger := logger.Get()
|
zapLogger := logger.Get()
|
||||||
|
|
||||||
@@ -75,7 +128,6 @@ func (d *Database) Ping() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close закрывает соединение с базой данных
|
|
||||||
func (d *Database) Close() error {
|
func (d *Database) Close() error {
|
||||||
zapLogger := logger.Get()
|
zapLogger := logger.Get()
|
||||||
|
|
||||||
@@ -99,11 +151,7 @@ func (d *Database) Close() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Вспомогательные функции для работы с DSN
|
|
||||||
|
|
||||||
// ExtractHostFromDSN извлекает хост из DSN строки
|
|
||||||
func ExtractHostFromDSN(dsn string) string {
|
func ExtractHostFromDSN(dsn string) string {
|
||||||
// Простая реализация для PostgreSQL DSN
|
|
||||||
parts := strings.Split(dsn, " ")
|
parts := strings.Split(dsn, " ")
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
if strings.HasPrefix(part, "host=") {
|
if strings.HasPrefix(part, "host=") {
|
||||||
@@ -113,9 +161,7 @@ func ExtractHostFromDSN(dsn string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExtractDBNameFromDSN извлекает имя базы данных из DSN строки
|
|
||||||
func ExtractDBNameFromDSN(dsn string) string {
|
func ExtractDBNameFromDSN(dsn string) string {
|
||||||
// Простая реализация для PostgreSQL DSN
|
|
||||||
parts := strings.Split(dsn, " ")
|
parts := strings.Split(dsn, " ")
|
||||||
for _, part := range parts {
|
for _, part := range parts {
|
||||||
if strings.HasPrefix(part, "dbname=") {
|
if strings.HasPrefix(part, "dbname=") {
|
||||||
@@ -125,9 +171,7 @@ func ExtractDBNameFromDSN(dsn string) string {
|
|||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// MaskPassword маскирует пароль в DSN строке для безопасного логирования
|
|
||||||
func MaskPassword(dsn string) string {
|
func MaskPassword(dsn string) string {
|
||||||
// Простая реализация - заменяет пароль на ***
|
|
||||||
parts := strings.Split(dsn, " ")
|
parts := strings.Split(dsn, " ")
|
||||||
for i, part := range parts {
|
for i, part := range parts {
|
||||||
if strings.HasPrefix(part, "password=") {
|
if strings.HasPrefix(part, "password=") {
|
||||||
|
|||||||
@@ -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 │
|
│ Docker Compose Cluster │
|
||||||
├─────────────────────────────────────────────────────────────┤
|
├─────────────────────────────────────────────────────────────┤
|
||||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||||
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_ES │ │
|
│ │ Nginx │ │ API_TP │ │ API_BB │ │ API_YAL │ │
|
||||||
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
|
│ │ (Proxy) │◄─┤(Yalarba) │ │(Бег.Баш)│ │(Easysite)│ │
|
||||||
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
|
│ └────┬─────┘ └──────────┘ └──────────┘ └──────────┘ │
|
||||||
│ │ │ │ │ │
|
│ │ │ │ │ │
|
||||||
|
|||||||
@@ -139,6 +139,9 @@ easysite_build:
|
|||||||
easysite_start:
|
easysite_start:
|
||||||
docker compose up easysite -d && docker ps
|
docker compose up easysite -d && docker ps
|
||||||
|
|
||||||
|
# all
|
||||||
|
easysite: easysite_stop git easysite_build easysite_start easysite_logs
|
||||||
|
|
||||||
# Мониторинг системных ресурсов
|
# Мониторинг системных ресурсов
|
||||||
top:
|
top:
|
||||||
htop
|
htop
|
||||||
@@ -165,21 +168,6 @@ restart_analytics:
|
|||||||
# Полный цикл обновления analytics
|
# Полный цикл обновления analytics
|
||||||
analytics: stop_analitics git build_analititcs start_analytics wn
|
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
|
# Остановка certbot
|
||||||
stop_cerbot:
|
stop_cerbot:
|
||||||
docker compose down certbot
|
docker compose down certbot
|
||||||
@@ -195,26 +183,32 @@ start_certbot:
|
|||||||
# Полный цикл обновления certbot
|
# Полный цикл обновления certbot
|
||||||
certbot: stop_cerbot git build_certbot start_certbot wat
|
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 секунды
|
# Мониторинг состояния контейнеров каждые 2 секунды
|
||||||
wn:
|
wn:
|
||||||
watch -n 2 'docker ps'
|
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
|
# Остановка api_yal
|
||||||
stop_api_yal:
|
stop_api_yal:
|
||||||
docker compose down api_yal
|
docker compose down api_yal
|
||||||
@@ -229,3 +223,18 @@ start_api_yal:
|
|||||||
|
|
||||||
# Полный цикл обновления api_yal
|
# Полный цикл обновления 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
|
fi
|
||||||
|
|
||||||
# Преобразуем дату истечения в UNIX-время
|
# Преобразуем дату истечения в 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-времени
|
# Текущая дата в UNIX-времени
|
||||||
current_unix=$(date +%s)
|
current_unix=$(date +%s)
|
||||||
|
|||||||
@@ -43,30 +43,27 @@ services:
|
|||||||
- certbot_data:/etc/letsencrypt
|
- certbot_data:/etc/letsencrypt
|
||||||
- certbot_www:/var/www/certbot
|
- certbot_www:/var/www/certbot
|
||||||
- ./stubSite:/usr/share/nginx/stub/html
|
- ./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
|
- ./BB/bbvue/dist:/usr/share/nginx/begushiybashkir/html
|
||||||
- analytics_logs:/var/log/analytics:ro
|
- analytics_logs:/var/log/analytics:ro
|
||||||
networks:
|
networks:
|
||||||
- web-network
|
- web-network
|
||||||
- internal
|
- internal
|
||||||
- app-network
|
- app-network
|
||||||
- bb-network
|
|
||||||
depends_on:
|
depends_on:
|
||||||
easysite:
|
easysite:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
api_es:
|
|
||||||
condition: service_healthy
|
|
||||||
certbot:
|
certbot:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
api_tp:
|
|
||||||
condition: service_healthy
|
|
||||||
api_bb:
|
api_bb:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
analytics:
|
analytics:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
api_yal:
|
api_yal:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
yalarba:
|
||||||
|
condition: service_healthy
|
||||||
|
valitovgaziz:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
|
test: ["CMD-SHELL", "curl -f http://localhost/health || exit 1"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
@@ -101,41 +98,24 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
|
|
||||||
# REST API app on Golang (Gorm, Chi) бизнес логика приложения yalarba.ru. Работает с БД на PostgresQL db:db_tp
|
# Vue 3 SPA для valitovgaziz.ru
|
||||||
api_tp:
|
valitovgaziz:
|
||||||
build:
|
build:
|
||||||
context: ./yalarba/api_tp
|
context: ./valitovgaziz
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
ports:
|
container_name: valitovgaziz
|
||||||
- "8888:8080"
|
|
||||||
container_name: api_tp
|
|
||||||
restart: unless-stopped
|
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:
|
networks:
|
||||||
- app-network
|
- web-network
|
||||||
|
depends_on:
|
||||||
|
analytics:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test: ["CMD", "wget", "--spider", "http://localhost/"]
|
||||||
[
|
|
||||||
"CMD",
|
|
||||||
"wget",
|
|
||||||
"--no-verbose",
|
|
||||||
"--tries=1",
|
|
||||||
"--spider",
|
|
||||||
"http://localhost:8080/health",
|
|
||||||
]
|
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
# PostgresQL DB база данных для храниния информации приложений Yalarba.ru && Easysite102.ru
|
# PostgresQL DB база данных для храниния информации приложений Yalarba.ru && Easysite102.ru
|
||||||
db:
|
db:
|
||||||
@@ -159,7 +139,7 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
# REST API on Golang (Gorm, Chi) логика обработки информации для сайта БегущийБашкир Работает с БД db_bb on PostgresQL
|
# REST API on Golang (Gorm, Chi) логика обработки информации для сайта БегущийБашкир
|
||||||
api_bb:
|
api_bb:
|
||||||
build:
|
build:
|
||||||
context: ./BB/api_bb
|
context: ./BB/api_bb
|
||||||
@@ -169,22 +149,22 @@ services:
|
|||||||
container_name: api_bb
|
container_name: api_bb
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
depends_on:
|
depends_on:
|
||||||
db_bb:
|
db:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
env_file:
|
env_file:
|
||||||
- ./BB/api_bb/.env
|
- ./BB/api_bb/.env
|
||||||
volumes:
|
volumes:
|
||||||
- api_bb_uploads:/app/uploads
|
- api_bb_uploads:/app/uploads
|
||||||
environment:
|
environment:
|
||||||
# Database connection settings
|
DB_HOST: db
|
||||||
DB_HOST: db_bb
|
|
||||||
DB_PORT: 5432
|
DB_PORT: 5432
|
||||||
DB_USER: postgres
|
DB_USER: postgres
|
||||||
DB_PASSWORD: postgres
|
DB_PASSWORD: postgres
|
||||||
DB_NAME: bb_db
|
DB_NAME: bb_db
|
||||||
|
DB_SCHEMA: bb
|
||||||
APP_PORT: 8080
|
APP_PORT: 8080
|
||||||
networks:
|
networks:
|
||||||
- bb-network
|
- app-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test:
|
test:
|
||||||
[
|
[
|
||||||
@@ -199,31 +179,10 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
retries: 3
|
||||||
|
|
||||||
# PostgresQL DB база данных для работы сайта Бегущий Башкир
|
# SPA app прилжение выполнено на nuxt.js интерфейс для туристического бизнеса. Хранение информации в api_yal REST API app
|
||||||
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
|
|
||||||
easysite:
|
easysite:
|
||||||
build:
|
build:
|
||||||
context: ./yalarba/easySite/easySite
|
context: ./yalarba/easySite
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
container_name: easysite
|
container_name: easysite
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -233,6 +192,7 @@ services:
|
|||||||
NODE_ENV: production
|
NODE_ENV: production
|
||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
NUXT_PUBLIC_API_BASE: /api/v1
|
||||||
networks:
|
networks:
|
||||||
- web-network
|
- web-network
|
||||||
- app-network
|
- app-network
|
||||||
@@ -242,34 +202,6 @@ services:
|
|||||||
timeout: 10s
|
timeout: 10s
|
||||||
retries: 3
|
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 сервиса
|
# REST API app on Golang для api_yal сервиса
|
||||||
api_yal:
|
api_yal:
|
||||||
build:
|
build:
|
||||||
@@ -300,12 +232,32 @@ services:
|
|||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
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:
|
volumes:
|
||||||
certbot_data: # volume для данных Certbot
|
certbot_data: # volume для данных Certbot
|
||||||
certbot_www: # volume для данных Certbot
|
certbot_www: # volume для данных Certbot
|
||||||
db_tp_data: # Volume для данных БД yalarba.ru
|
db_tp_data: # Volume для данных БД yalarba.ru
|
||||||
db_bb_data: # Volume для данных БД Бегущий башкир
|
|
||||||
api_bb_uploads: # Volume для загружаемых файлов бегущий башкир
|
api_bb_uploads: # Volume для загружаемых файлов бегущий башкир
|
||||||
analytics_logs: # Volume для логов аналитики
|
analytics_logs: # Volume для логов аналитики
|
||||||
analytics_data: # Volume для данных аналитики
|
analytics_data: # Volume для данных аналитики
|
||||||
@@ -317,8 +269,6 @@ networks:
|
|||||||
driver: bridge
|
driver: bridge
|
||||||
app-network:
|
app-network:
|
||||||
driver: bridge
|
driver: bridge
|
||||||
bb-network:
|
|
||||||
driver: bridge
|
|
||||||
|
|
||||||
# Эта опция автоматически удаляет orphans (Не используемые контейнеры)
|
# Эта опция автоматически удаляет orphans (Не используемые контейнеры)
|
||||||
x-remove-orphans: true
|
x-remove-orphans: true
|
||||||
@@ -41,7 +41,7 @@
|
|||||||
│ • certbot - SSL сертификаты │
|
│ • certbot - SSL сертификаты │
|
||||||
│ • analytics - Статистика (Node.js) │
|
│ • analytics - Статистика (Node.js) │
|
||||||
│ • api_tp - API yalarba.ru (Go) │
|
│ • api_tp - API yalarba.ru (Go) │
|
||||||
│ • api_es - API easysite102.ru (Go) │
|
│ • api_yal - API easysite102.ru (Go) │
|
||||||
│ • api_bb - API Бегущий Башкир (Go) │
|
│ • api_bb - API Бегущий Башкир (Go) │
|
||||||
│ • easysite - SPA (Nuxt.js) │
|
│ • easysite - SPA (Nuxt.js) │
|
||||||
│ • db - PostgreSQL (yalarba/easy) │
|
│ • db - PostgreSQL (yalarba/easy) │
|
||||||
@@ -74,7 +74,7 @@
|
|||||||
|-------|-----|----------------|---------------|
|
|-------|-----|----------------|---------------|
|
||||||
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` |
|
| `yalarba.ru` | SPA (Vue) | `api_tp:8080` | `/usr/share/nginx/yalarba/html` |
|
||||||
| `valitovgaziz.ru` | Статический сайт | - | `/usr/share/nginx/valitovgaziz/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` |
|
| `begushiybashkir.ru` | SPA (Vue) | `api_bb:8080` | `/usr/share/nginx/begushiybashkir/html` |
|
||||||
| `xn--80abahjtcfl5d0a8di.xn--p1ai` | Альтернативный домен для Бегущий Башкир | `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
|
EMAIL=admin@example.com # Для Let's Encrypt
|
||||||
ALL_DOMAINS=yalarba.ru,valitovgaziz.ru... # Все домены для SSL
|
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 |
|
| certbot | Проверка файла сертификата | - | 30s |
|
||||||
| analytics | `http://localhost:3000/health` | 3000 | 30s |
|
| analytics | `http://localhost:3000/health` | 3000 | 30s |
|
||||||
| api_tp | `http://localhost:8080/health` | 8080 | 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 |
|
| api_bb | `http://localhost:8080/api/health` | 8080 | 30s |
|
||||||
| easysite | `http://localhost:3000/api/health` | 3000 | 30s |
|
| easysite | `http://localhost:3000/api/health` | 3000 | 30s |
|
||||||
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
|
| db, db_bb | `pg_isready -U postgres` | 5432 | 30s |
|
||||||
|
|
||||||
### Зависимости запуска
|
### Зависимости запуска
|
||||||
Nginx запускается только после подтверждения здоровья:
|
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";
|
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ЛОКАЦИЯ: Корневая (SPA приложение)
|
# ЛОКАЦИЯ: Nuxt 4 SSR приложение
|
||||||
# ============================================
|
# ============================================
|
||||||
location / {
|
location / {
|
||||||
# Директория со скомпилированным Vue/React приложением
|
# Проксирование к Nuxt.js SSR серверу
|
||||||
root /usr/share/nginx/yalarba/html;
|
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 Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
# Увеличенные таймауты для длительных операций (10 минут)
|
# Длинные таймауты
|
||||||
proxy_connect_timeout 600;
|
proxy_connect_timeout 600;
|
||||||
proxy_send_timeout 600;
|
proxy_send_timeout 600;
|
||||||
proxy_read_timeout 600;
|
proxy_read_timeout 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
location /auth/ {
|
# ============================================
|
||||||
# Проксирование всех запросов к API на Golang сервис
|
# ЛОКАЦИЯ: REST API (api_yal)
|
||||||
proxy_pass http://api_yal/; # Контейнер Docker
|
# ============================================
|
||||||
|
location /api/v1/ {
|
||||||
# Передача оригинальных заголовков от клиента
|
proxy_pass http://api_yal:8787;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
# Увеличенные таймауты для длительных операций (10 минут)
|
|
||||||
proxy_connect_timeout 600;
|
proxy_connect_timeout 600;
|
||||||
proxy_send_timeout 600;
|
proxy_send_timeout 600;
|
||||||
proxy_read_timeout 600;
|
proxy_read_timeout 600;
|
||||||
@@ -175,48 +155,39 @@ server {
|
|||||||
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
|
ssl_ciphers "EECDH+AESGCM:EDH+AESGCM:AES256+EECDH:AES256+EDH";
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ЛОКАЦИЯ: Статический сайт
|
# ЛОКАЦИЯ: Проксирование к Vue SPA контейнеру
|
||||||
# ============================================
|
# ============================================
|
||||||
location / {
|
location / {
|
||||||
# Статические HTML файлы
|
proxy_pass http://valitovgaziz/;
|
||||||
root /usr/share/nginx/valitovgaziz/html;
|
proxy_set_header Host $host;
|
||||||
index index.html;
|
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;
|
||||||
try_files $uri $uri/ /index.html;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
proxy_connect_timeout 600;
|
||||||
|
proxy_send_timeout 600;
|
||||||
|
proxy_read_timeout 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ЛОКАЦИЯ: API для аналитики
|
# ЛОКАЦИЯ: API для аналитики
|
||||||
# ============================================
|
# ============================================
|
||||||
location /api/ {
|
location /api/ {
|
||||||
# Проксирование на Node.js сервис аналитики
|
|
||||||
proxy_pass http://analytics:3000/;
|
proxy_pass http://analytics:3000/;
|
||||||
|
|
||||||
# Базовые заголовки прокси
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
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-Origin "*" always;
|
||||||
add_header Access-Control-Allow-Methods "POST, GET, OPTIONS" 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-Headers "Content-Type, Authorization" always;
|
||||||
add_header Access-Control-Allow-Credentials "true" always;
|
add_header Access-Control-Allow-Credentials "true" always;
|
||||||
|
|
||||||
# Обработка предварительных OPTIONS запросов (preflight)
|
|
||||||
# Браузеры отправляют такие запросы перед основными
|
|
||||||
if ($request_method = OPTIONS) {
|
if ($request_method = OPTIONS) {
|
||||||
# 204 - No Content (успешный пустой ответ)
|
|
||||||
return 204;
|
return 204;
|
||||||
}
|
}
|
||||||
|
|
||||||
# Стандартные таймауты для API аналитики
|
|
||||||
proxy_connect_timeout 30s;
|
proxy_connect_timeout 30s;
|
||||||
proxy_send_timeout 30s;
|
proxy_send_timeout 30s;
|
||||||
proxy_read_timeout 30s;
|
proxy_read_timeout 30s;
|
||||||
@@ -260,43 +231,28 @@ server {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# ЛОКАЦИЯ: API Backend для Easysite
|
# ЛОКАЦИЯ: API Backend для Easysite (api_yal)
|
||||||
# ============================================
|
# ============================================
|
||||||
location /api/ {
|
location /api/v1/ {
|
||||||
# Отдельный API endpoint для backend
|
proxy_pass http://api_yal:8787;
|
||||||
proxy_pass http://api_es:8088/;
|
|
||||||
|
|
||||||
# Заголовки прокси
|
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
proxy_set_header X-Forwarded-Proto $scheme;
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
proxy_set_header X-Forwarded-Port $server_port;
|
proxy_set_header X-Forwarded-Port $server_port;
|
||||||
|
|
||||||
# Таймауты как у основного приложения
|
|
||||||
proxy_connect_timeout 600;
|
proxy_connect_timeout 600;
|
||||||
proxy_send_timeout 600;
|
proxy_send_timeout 600;
|
||||||
proxy_read_timeout 600;
|
proxy_read_timeout 600;
|
||||||
|
|
||||||
# ========================================
|
if ($request_method = OPTIONS) {
|
||||||
# ДЕТАЛЬНЫЕ НАСТРОЙКИ CORS ДЛЯ OPTIONS
|
|
||||||
# ========================================
|
|
||||||
if ($request_method = OPTIONS ) {
|
|
||||||
# Динамический заголовок Origin из запроса
|
|
||||||
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
add_header 'Access-Control-Allow-Origin' "$http_origin";
|
||||||
add_header 'Access-Control-Allow-Methods' 'GET, POST, PUT, PATCH, DELETE';
|
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';
|
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;
|
add_header 'Access-Control-Max-Age' 1728000;
|
||||||
|
|
||||||
# Пустой ответ для OPTIONS
|
|
||||||
add_header 'Content-Length' 0;
|
add_header 'Content-Length' 0;
|
||||||
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
add_header 'Content-Type' 'text/plain charset=UTF-8';
|
||||||
|
|
||||||
# Возвращаем 204 без тела ответа
|
|
||||||
return 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
|
|
||||||
}
|
|
||||||