first commit

This commit is contained in:
Web-serfer 2026-03-26 08:56:25 +05:00
commit e94c732875
106 changed files with 5443 additions and 0 deletions

25
.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# Backend (исключает всю папку backend)
backend/
# Dependency directories
node_modules/
frontend/node_modules/
# Build outputs
dist/
frontend/dist/
backend/dist/
*.log
# Environment variables
.env
.env.local
.env.production
# OS generated files
.DS_Store
Thumbs.db
# Project documentation files
info/content_management_plan.txt
info/project_description.txt

5
.idea/.gitignore vendored Normal file
View file

@ -0,0 +1,5 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/

13
.idea/astro_redi.iml Normal file
View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="WEB_MODULE" version="4">
<component name="NewModuleRootManager">
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.tmp" />
<excludeFolder url="file://$MODULE_DIR$/temp" />
<excludeFolder url="file://$MODULE_DIR$/tmp" />
<excludeFolder url="file://$MODULE_DIR$/frontend/.astro" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

8
.idea/modules.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/astro_redi.iml" filepath="$PROJECT_DIR$/.idea/astro_redi.iml" />
</modules>
</component>
</project>

8
.idea/vcs.xml Normal file
View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
<mapping directory="$PROJECT_DIR$/astro_redidev" vcs="Git" />
<mapping directory="$PROJECT_DIR$/frontend" vcs="Git" />
</component>
</project>

1
.nvmrc Normal file
View file

@ -0,0 +1 @@
18.17.0

61
QWEN.md Normal file
View file

@ -0,0 +1,61 @@
# Правила взаимодействия с Qwen Code Assistant
## Основные принципы
1. **Изменения в коде возможны только с явного разрешения пользователя**
- Перед внесением любых изменений в файлы ассистент должен получить подтверждение от пользователя
- Все изменения должны быть предварительно объяснены пользователю
- Перед решением конкретной задачи всегда составлять план
- После внесения изменений в код - проводить проверку - только после этого приступать к дальнейшему решению задачи
2. **Прозрачность действий**
- Ассистент должен объяснить, какие изменения планируется внести
- Необходимо указать, в какие файлы будут внесены изменения
- Следует объяснить последствия предполагаемых изменений
3. **Безопасность кода**
- Все изменения должны проходить проверку на безопасность
- Не должны вноситься изменения, которые могут повредить функциональность приложения
- Рекомендуется создание резервных копий при значительных изменениях
4. **Согласование архитектурных решений**
- При внесении изменений, затрагивающих архитектуру приложения, необходима дискуссия с пользователем
- Предложения по улучшению архитектуры должны обсуждаться до реализации
5. **Работа с разными типами проектов**
- Уважать существующую архитектуру и стиль кода проекта
- Следовать установленным в проекте принципам и паттернам
6. **Использование Bun**
- Все команды должны выполняться с использованием Bun (bun install, bun dev, bun build и т.д.)
- При создании скриптов в package.json, они должны быть совместимы с Bun
7. **Язык общения**
- Всё общение с пользователем происходит на русском языке
8. **Проверка изменений**
- После внесения изменений в код не требуется запускать сервер разработки для проверки
- Пользователь самостоятельно запускает сервер и проверяет изменения
9. **Проверка типов данных**
- Проверять проект на ошибки типизации через команду `bun run tsc --noEmit -p frontend/tsconfig.json`
- В проекте не должно быть типов any
- Все интерфейсы компонентов прописывать в файле globalInterfaces.ts
- При работе с PocketBase использовать актуальные сигнатуры методов из файла `D:\Verstka\production\astro_minivan\frontend\node_modules\pocketbase\dist\pocketbase.es.d.ts`
10. **Плагин @astrojs/sitemap**
- Обязательно к установке в проект пакета @astrojs/sitemap
- Обязательно к созданию в проекте файла .nvmrc
11. **Замена хоста при развертывании проекта**
- Обязательно нужно изменить http://localhost:3000/ в шаблонах писем на реальный
## Qwen Added Memories
- URL документации Astro: https://docs.astro.build/en/getting-started/
- URL документации PocketBase: https://pocketbase.io/docs/
- URL документации SolidJS: https://docs.solidjs.com/solid-start/getting-started
- URL документации astro-icons: https://www.astroicon.dev/getting-started/
- URL документации PayloadCMS: https://payloadcms.com/docs/getting-started/what-is-payload
- URL документации Prisma ORM и Astro: https://www.prisma.io/docs/ai/prompts/astro

62
README.md Normal file
View file

@ -0,0 +1,62 @@
# Astro Redi Monorepo
Этот монорепозиторий содержит frontend и backend части приложения Astro REDi.
## Структура проекта
- `frontend/` - Astro-приложение для фронтенда
- `backend/` - PocketBase-приложение для бэкенда
## Установка
1. Установите зависимости для всего проекта:
```bash
bun install
```
2. Установите зависимости для frontend:
```bash
cd frontend && bun install
```
Или выполните установку всех зависимостей одной командой:
```bash
bun run install:all
```
## Запуск приложения
Для одновременного запуска frontend и backend приложения выполните:
```bash
bun run dev
```
Для запуска только frontend:
```bash
bun run dev:frontend
```
Для запуска только backend:
```bash
bun run dev:backend
```
Для остановки запущенных процессов:
```bash
bun run stop
```
## Сборка проекта
Для сборки frontend приложения:
```bash
bun run build
```

79
bun.lock Normal file
View file

@ -0,0 +1,79 @@
{
"lockfileVersion": 1,
"configVersion": 0,
"workspaces": {
"": {
"name": "astro-redi-monorepo",
"dependencies": {
"baseline-browser-mapping": "^2.10.8",
"caniuse-lite": "^1.0.30001780",
},
"devDependencies": {
"concurrently": "^8.2.0",
},
},
},
"packages": {
"@babel/runtime": ["@babel/runtime@7.28.4", "", {}, "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ=="],
"ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"baseline-browser-mapping": ["baseline-browser-mapping@2.10.8", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-PCLz/LXGBsNTErbtB6i5u4eLpHeMfi93aUv5duMmj6caNu6IphS4q6UevDnL36sZQv9lrP11dbPKGMaXPwMKfQ=="],
"caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="],
"chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="],
"cliui": ["cliui@8.0.1", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.1", "wrap-ansi": "^7.0.0" } }, "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"concurrently": ["concurrently@8.2.2", "", { "dependencies": { "chalk": "^4.1.2", "date-fns": "^2.30.0", "lodash": "^4.17.21", "rxjs": "^7.8.1", "shell-quote": "^1.8.1", "spawn-command": "0.0.2", "supports-color": "^8.1.1", "tree-kill": "^1.2.2", "yargs": "^17.7.2" }, "bin": { "conc": "dist/bin/concurrently.js", "concurrently": "dist/bin/concurrently.js" } }, "sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg=="],
"date-fns": ["date-fns@2.30.0", "", { "dependencies": { "@babel/runtime": "^7.21.0" } }, "sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw=="],
"emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
"get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="],
"require-directory": ["require-directory@2.1.1", "", {}, "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q=="],
"rxjs": ["rxjs@7.8.2", "", { "dependencies": { "tslib": "^2.1.0" } }, "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA=="],
"shell-quote": ["shell-quote@1.8.3", "", {}, "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw=="],
"spawn-command": ["spawn-command@0.0.2", "", {}, "sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ=="],
"string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@8.1.1", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q=="],
"tree-kill": ["tree-kill@1.2.2", "", { "bin": { "tree-kill": "cli.js" } }, "sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
"wrap-ansi": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="],
"yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="],
"yargs-parser": ["yargs-parser@21.1.1", "", {}, "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw=="],
"chalk/supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
}
}

12
frontend/.editorconfig Normal file
View file

@ -0,0 +1,12 @@
# EditorConfig is awesome: https://EditorConfig.org
# top-most EditorConfig file
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = false
insert_final_newline = false

25
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,25 @@
# build output
dist/
# generated types
.astro/
# dependencies
node_modules/
# logs
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# environment variables
.env
.env.production
# macOS-specific files
.DS_Store
# jetbrains setting folder
.idea/

1
frontend/.nvmrc Normal file
View file

@ -0,0 +1 @@
20

163
frontend/README.md Normal file
View file

@ -0,0 +1,163 @@
# Astro Frontend для Redi.dev
Современный веб-сайт-портфолио, разработанный на Astro с использованием PocketBase в качестве CMS.
## 🚀 Стек технологий
- **Astro 6** - фреймворк для создания быстрых сайтов
- **TypeScript** - строгая типизация
- **Tailwind CSS** - стилизация
- **Solid.js** - реактивные компоненты
- **PocketBase** - headless CMS и бэкенд
- **Bun** - пакетный менеджер и рантайм
- **Nanostores** - управление состоянием
## 📁 Структура проекта
```
astro_redi/
├── .env # Переменные окружения (в корне проекта!)
├── frontend/ # Исходный код frontend части
│ ├── public/ # Статические файлы
│ ├── src/
│ │ ├── assets/ # Стили, изображения
│ │ ├── components/ # Astro и Solid.js компоненты
│ │ ├── layouts/ # Макеты страниц
│ │ ├── lib/ # Вспомогательные библиотеки (PocketBase клиент)
│ │ ├── pages/ # Страницы приложения
│ │ └── globalInterfaces.ts # Глобальные TypeScript интерфейсы
│ ├── astro.config.mjs # Конфигурация Astro
│ ├── package.json # Зависимости
│ └── tsconfig.json # Конфигурация TypeScript
├── astro_redidev/ # Резервная копия старой версии
├── backend/ # Бэкенд часть (если есть)
└── scripts/
└── dev-server.js # Скрипт запуска локальных серверов
```
## ⚙️ Переменные окружения
Файл `.env` должен находиться в **корне проекта** (`astro_redi/.env`):
```env
# Локальная разработка
PUBLIC_SITE_URL=http://localhost:4321
PUBLIC_POCKETBASE_URL=http://127.0.0.1:8090
# Продакшен (раскомментировать при деплое)
# PUBLIC_SITE_URL=https://redib.ru
# PUBLIC_POCKETBASE_URL=http://pocketbase-y8oskk4kgkgko0s88c8c4oo0.144.124.229.64.sslip.io:8080
```
## 🛠️ Установка и запуск
### Требования
- **Bun** (рекомендуется) или Node.js 22+
- **PocketBase** (локально или удалённо)
### Локальная разработка
1. **Установите зависимости:**
```bash
bun install
```
2. **Запустите PocketBase:**
```bash
# PocketBase должен быть запущен на порту 8090
# Используйте скрипт dev-server.js для одновременного запуска
node scripts/dev-server.js
```
3. **Откройте браузер:**
- Astro: http://localhost:4321
- PocketBase Admin: http://localhost:8090/_/
### Сборка для продакшена
```bash
bun run build
```
### Продакшен запуск
```bash
bun run start
```
## 📊 Коллекции PocketBase
Проект использует следующие коллекции:
| Коллекка | Описание | Поля |
|----------|----------|------|
| `projects` | Проекты портфолио | `name`, `description`, `short_description`, `long_description`, `stack`, `image`, `url_site`, `github`, `order`, `isActive` |
| `featured_projects` | Избранные проекты | `name`, `description`, `image`, `url`, `github`, `stack`, `featured`, `forSale`, `order`, `isActive` |
| `posts` | Блог | `title`, `description`, `publishDate`, `tags`, `coverImage`, `coverImageAlt`, `isFeatured`, `isActive` |
| `about` | Информация об авторе | `title`, `description`, `professional_experience`, `skills`, `contact_title`, `contact_description`, `whatsapp_link`, `email`, `image`, `alt_text`, `isActive` |
| `navigation` | Навигация сайта | `items` (JSON) |
## 🌐 Изображения из PocketBase
Изображения загружаются напрямую из PocketBase по URL:
```
{PUBLIC_POCKETBASE_URL}/api/files/{collection_id}/{record_id}/{filename}
```
Пример:
```
http://127.0.0.1:8090/api/files/projects/t7i08mbfzuzp6e6/tech_news_ncqktq5j8r.avif
```
## 📦 Деплой
### В Coolify
Проект настроен для деплоя в Coolify с использованием адаптера `@astrojs/node`:
1. Укажите переменные окружения в Coolify:
- `COOLIFY_FQDN` — домен приложения
- `COOLIFY_URL` — URL приложения
- `PUBLIC_SITE_URL` — публичный URL сайта
- `PUBLIC_POCKETBASE_URL` — URL PocketBase
2. Команды сборки и запуска:
- Build: `bun run build`
- Start: `bun run start`
- Install: `bun install`
### Другие платформы
Проект совместим с:
- Vercel
- Netlify
- Cloudflare Pages
- Любые VPS с Node.js/Bun
## 🔧 Скрипты
| Команда | Описание |
|---------|----------|
| `bun install` | Установка зависимостей |
| `bun run dev` | Запуск режима разработки |
| `bun run build` | Сборка проекта |
| `bun run start` | Запуск продакшен версии |
| `bun run astro check` | Проверка типов TypeScript |
## 📝 Особенности реализации
- **SSR (Server-Side Rendering)** — динамические страницы рендерятся на сервере
- **Статическая генерация** — пагинация проектов генерируется при сборке
- **Оптимизация изображений** — ленивая загрузка, правильные размеры
- **SEO** — sitemap, метатеги, семантическая разметка
- **Тёмная тема** — автоматическое переключение по системным настройкам
- **Адаптивный дизайн** — мобильная версия, планшет, десктоп
## 👨‍💻 Автор
**RediBedi** — веб-разработчик, специализирующийся на Astro и современных frontend технологиях.
- Портфолио: [redib.ru](https://redib.ru)
- GitHub: [RediBedi](https://github.com/RediBedi)

24
frontend/astro.config.mjs Normal file
View file

@ -0,0 +1,24 @@
import { defineConfig } from "astro/config";
import solidJs from '@astrojs/solid-js';
import tailwind from "@astrojs/tailwind";
import icon from "astro-icon";
import sitemap from "@astrojs/sitemap";
import node from "@astrojs/node";
// https://astro.build/config
export default defineConfig({
site: process.env.PUBLIC_SITE_URL || "https://redib.ru",
integrations: [tailwind(), solidJs(), icon(), sitemap()],
output: "server",
vite: {
build: {
// Встраивать CSS размером до 15 КБ
cssInlineLimit: 15000
}
},
adapter: node({
mode: "standalone",
}),
});

1299
frontend/bun.lock Normal file

File diff suppressed because it is too large Load diff

39
frontend/package.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "redi-dev",
"type": "module",
"version": "0.0.1",
"scripts": {
"dev": "astro dev",
"start": "astro preview",
"build": "astro build",
"preview": "astro preview",
"astro": "astro",
"format": "biome format --write .",
"lint": "biome lint .",
"lint:fix": "biome check --apply ."
},
"devDependencies": {
"@astrojs/check": "0.9.8",
"@astrojs/tailwind": "^6.0.2",
"@biomejs/biome": "1.7.3",
"@tailwindcss/typography": "^0.5.13",
"astro": "6.0.6",
"tailwindcss": "^3.4.3",
"typescript": "^5.4.5"
},
"packageManager": "bun@1.1.29",
"dependencies": {
"@astrojs/node": "10.0.2",
"@astrojs/sitemap": "3.7.1",
"@astrojs/solid-js": "6.0.1",
"@nanostores/solid": "^1.1.1",
"astro-icon": "^1.1.5",
"baseline-browser-mapping": "^2.10.8",
"caniuse-lite": "^1.0.30001780",
"nanostores": "^1.0.1",
"pocketbase": "^0.26.5",
"sharp": "^0.34.3",
"solid-icons": "^1.1.0",
"solid-js": "^1.9.9"
}
}

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,2 @@
User-agent: *
Allow: /

View file

@ -0,0 +1,127 @@
article.prose h2 {
text-align: center !important; /* Center on mobile */
}
@media (min-width: 768px) {
/* md breakpoint */
article.prose h2 {
text-align: left !important; /* Left-align on md and above */
}
}
.prose img {
border-radius: 30px;
}
/* Styles for paragraph spacing on mobile */
article.prose p {
margin-top: 1.5em !important; /* Increased top margin for paragraphs on mobile */
margin-bottom: 1.5em !important; /* Increased bottom margin for paragraphs on mobile */
font-size: 0.9em !important; /* Smaller font size for paragraphs on mobile */
}
@media (min-width: 768px) {
/* md breakpoint */
article.prose p {
/* Reset or keep default prose spacing and font size for larger screens */
margin-top: 1em !important; /* Adjust as needed, or remove !important if prose default is fine */
margin-bottom: 1em !important; /* Adjust as needed, or remove !important if prose default is fine */
font-size: 1em !important; /* Revert to default or desired font size for larger screens */
}
}
/* General text readability improvements for article content */
article.prose p {
line-height: 1.75; /* Relaxed line height */
margin-top: 1em; /* Default paragraph spacing */
margin-bottom: 1em;
}
article.prose ul,
article.prose ol {
margin-top: 1em;
margin-bottom: 1em;
}
article.prose li {
margin-top: 0.5em;
margin-bottom: 0.5em;
}
article.prose h1 {
margin-top: 3rem; /* Equivalent to spacing.12 */
margin-bottom: 1.5rem; /* Equivalent to spacing.6 */
}
article.prose h2 {
margin-top: 2.5rem; /* Equivalent to spacing.10 */
margin-bottom: 1.25rem; /* Equivalent to spacing.5 */
}
article.prose h3 {
margin-top: 2rem; /* Equivalent to spacing.8 */
margin-bottom: 1rem; /* Equivalent to spacing.4 */
}
article.prose h4 {
margin-top: 1.5rem; /* Equivalent to spacing.6 */
margin-bottom: 0.75rem; /* Equivalent to spacing.3 */
}
/* Code block styling */
article.prose pre {
background-color: #1a202c; /* neutral-900 */
border: 1px solid #2d3748; /* neutral-800 */
border-radius: 0.5rem; /* lg */
padding: 1rem; /* spacing.4 */
overflow-x: auto; /* Enable horizontal scrolling for long lines */
}
article.prose code {
background-color: #2d3748; /* neutral-800 */
padding: 0.2em 0.4em;
border-radius: 0.25rem;
font-size: 0.875em; /* Smaller font size for inline code */
}
/* Table styling */
article.prose table {
width: 100%;
border-collapse: collapse;
margin-top: 1.5em;
margin-bottom: 1.5em;
}
article.prose th,
article.prose td {
border: 1px solid #4a5568; /* neutral-600 */
padding: 0.75em;
text-align: left;
}
article.prose th {
background-color: #2d3748; /* neutral-800 */
font-weight: bold;
}
/* Link hover effect */
article.prose a {
transition: color 0.2s ease-in-out, text-decoration-color 0.2s ease-in-out;
}
article.prose a:hover {
color: #6366f1; /* indigo-500 */
text-decoration-color: #6366f1; /* indigo-500 */
}
/* Blockquote styling */
article.prose blockquote {
border-left: 4px solid #4f46e5; /* indigo-600 */
padding-left: 1em;
margin-left: 0;
font-style: italic;
color: #a0aec0; /* neutral-400 */
background-color: #1a202c; /* neutral-900 */
padding: 1em;
border-radius: 0.5rem;
}

View file

@ -0,0 +1,122 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
#sun {
transform: translate3d(0, 0px, 0);
}
#moon {
transform: translate3d(0, 0px, 0);
}
#darkToggle:hover #sun {
transform: translate3d(0, 10px, 0);
}
#darkToggle:hover #moon {
transform: translate3d(0, 10px, 0);
}
html.dark #darkToggle:hover .horizon {
border-color: #718096 !important;
}
html.dark {
color-scheme: dark;
}
.horizon .setting {
animation: 1s ease 0s 1 setting;
}
.horizon .rising {
animation: 1s ease 0s 1 rising;
}
.no-bullets {
list-style-type: none;
}
/* Можно добавить и другие ваши стили, например: */
.no-bullets {
list-style-type: none;
padding-left: 0; /* Убираем стандартный отступ слева */
}
@keyframes setting {
0% {
transform: translate3d(0, 10px, 0);
}
40% {
transform: translate3d(0, -2px, 0);
}
to {
transform: translate3d(0, 30px, 0);
}
}
@keyframes rising {
0% {
opacity: 0;
transform: translate3d(0, 30px, 0);
}
40% {
opacity: 1;
transform: translate3d(0, -2px, 0);
}
to {
opacity: 1;
transform: translate3d(0, 10px, 0);
}
}
@keyframes fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes slide-down {
from {
transform: translateY(-20px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.animate-fade-in {
animation: fade-in 0.3s ease-out forwards;
}
.animate-slide-down {
animation: slide-down 0.3s ease-out forwards;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-on-scroll {
opacity: 0;
}
.animate-on-scroll.is-visible {
animation: fadeInUp 0.8s ease-out forwards;
}

View file

@ -0,0 +1,297 @@
---
import PageHeading from '@components/base/PageHeading.astro'
import { getImageUrl } from '@lib/pocketbase';
import type { AboutData } from '@globalInterfaces';
interface Props {
aboutData: AboutData;
}
const { aboutData } = Astro.props;
const skills = aboutData.skills || [];
---
<section class="space-y-8 md:space-y-12">
<PageHeading
title={aboutData.title}
description={aboutData.description}
/>
<!-- Первая строка: изображение + блок доступности + текст -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6 md:gap-8 mb-8 md:mb-12">
<!-- Левая колонка - 2 ячейки -->
<div class="lg:col-span-1 space-y-4 md:space-y-6">
<!-- Картинка -->
<div class="relative group">
<div class="relative overflow-hidden rounded-xl md:rounded-2xl shadow-lg md:shadow-2xl">
<img
src={getImageUrl(aboutData, aboutData.image)}
width={280}
height={280}
alt={aboutData.alt_text}
class="w-full h-auto object-cover transition-transform duration-700 group-hover:scale-105"
loading="eager"
/>
<div class="absolute inset-0 bg-gradient-to-t from-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</div>
</div>
<!-- Блок "Доступен для проектов" с анимированной ракетой -->
<div class="p-4 md:p-6 bg-gradient-to-r from-indigo-600 to-purple-600 rounded-xl md:rounded-2xl shadow-md md:shadow-lg overflow-hidden relative rocket-container">
<div class="text-center relative z-10">
<div class="rocket-animation">
<div class="rocket text-2xl md:text-4xl">🚀</div>
<div class="exhaust"></div>
<div class="stars">
{Array.from({ length: 10 }).map((_, i) => (
<div class={`star star-${i + 1}`}></div>
))}
</div>
</div>
<h3 class="text-base md:text-lg font-semibold text-white mb-1 md:mb-2 mt-2 md:mt-4">Доступен для проектов</h3>
<p class="text-indigo-100 text-xs md:text-sm">Готов к новым вызовам и интересным проектам</p>
</div>
</div>
</div>
<!-- Правая колонка - Текст -->
<div class="lg:col-span-2">
<div class="p-4 md:p-6 lg:p-8 bg-white dark:bg-neutral-900 rounded-xl md:rounded-2xl shadow-md md:shadow-lg border border-gray-100 dark:border-neutral-800 h-full">
<div class="text-md md:text-base text-center md:text-left lg:text-lg leading-8 md:leading-9 text-gray-700 dark:text-neutral-300 tracking-wide"
style="letter-spacing: 0.05em; word-spacing: 0.15em;"
set:html={aboutData.professional_experience}>
</div>
</div>
</div>
</div>
<!-- Вторая строка: Технологии на всю ширину -->
<div>
<h2 class="text-xl md:text-2xl font-bold text-gray-900 dark:text-white mb-4 md:mb-6 lg:mb-8 text-center">
Технологии которые я использую
</h2>
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-2 md:gap-3 lg:gap-4">
{skills.map((skill, index) => (
<div
class="flex items-center justify-center p-2 md:p-3 lg:p-4 bg-gradient-to-br from-white to-gray-50 dark:from-neutral-800 dark:to-neutral-700 rounded-lg md:rounded-xl shadow-sm hover:shadow-md transition-all duration-300 hover:-translate-y-1 border border-gray-200 dark:border-neutral-600 group"
style={`animation-delay: ${index * 50}ms`}
>
<span class="text-xs md:text-sm font-medium text-gray-700 dark:text-neutral-200 text-center group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{skill}
</span>
</div>
))}
</div>
</div>
</section>
<style>
/* Анимация для карточек технологий */
.grid.grid-cols-2 > div {
animation: fadeInUp 0.6s ease-out forwards;
opacity: 0;
transform: translateY(20px);
}
@keyframes fadeInUp {
to {
opacity: 1;
transform: translateY(0);
}
}
/* Плавные переходы */
.group {
transition: all 0.3s ease-in-out;
}
/* Анимация ракеты */
.rocket-container {
overflow: hidden;
}
.rocket-animation {
position: relative;
height: 60px;
display: flex;
justify-content: center;
align-items: center;
}
@media (min-width: 768px) {
.rocket-animation {
height: 80px;
}
}
.rocket {
animation: rocketFloat 3s ease-in-out infinite;
z-index: 2;
position: relative;
}
.exhaust {
position: absolute;
bottom: -8px;
width: 16px;
height: 24px;
background: linear-gradient(to top, #ff9d00, #ff2c00, transparent);
border-radius: 50% 50% 0 0;
opacity: 0.8;
animation: exhaustPulse 1.5s ease-in-out infinite;
z-index: 1;
filter: blur(2px);
}
@media (min-width: 768px) {
.exhaust {
bottom: -10px;
width: 20px;
height: 30px;
filter: blur(3px);
}
}
.stars {
position: absolute;
width: 100%;
height: 100%;
top: 0;
left: 0;
}
.star {
position: absolute;
background-color: white;
border-radius: 50%;
opacity: 0;
}
/* Создаем несколько звездочек с разными позициями и анимациями */
.star-1 { width: 1px; height: 1px; top: 20%; left: 30%; animation: starShine 3s 0.5s infinite; }
.star-2 { width: 2px; height: 2px; top: 40%; left: 60%; animation: starShine 4s 1s infinite; }
.star-3 { width: 1px; height: 1px; top: 60%; left: 20%; animation: starShine 3.5s 0.8s infinite; }
.star-4 { width: 2px; height: 2px; top: 30%; left: 80%; animation: starShine 4.5s 1.2s infinite; }
.star-5 { width: 1px; height: 1px; top: 70%; left: 40%; animation: starShine 3.2s 0.6s infinite; }
.star-6 { width: 2px; height: 2px; top: 10%; left: 50%; animation: starShine 4.2s 1.4s infinite; }
.star-7 { width: 1px; height: 1px; top: 50%; left: 10%; animation: starShine 3.8s 0.9s infinite; }
.star-8 { width: 2px; height: 2px; top: 80%; left: 70%; animation: starShine 4.8s 1.6s infinite; }
.star-9 { width: 1px; height: 1px; top: 25%; left: 90%; animation: starShine 3.3s 1.1s infinite; }
.star-10 { width: 2px; height: 2px; top: 65%; left: 30%; animation: starShine 4.3s 0.7s infinite; }
@media (min-width: 768px) {
.star-1, .star-3, .star-5, .star-7, .star-9 { width: 2px; height: 2px; }
.star-2, .star-4, .star-6, .star-8, .star-10 { width: 3px; height: 3px; }
}
@keyframes rocketFloat {
0%, 100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-4px) rotate(2deg);
}
50% {
transform: translateY(0) rotate(0deg);
}
75% {
transform: translateY(4px) rotate(-2deg);
}
}
@keyframes exhaustPulse {
0%, 100% {
height: 16px;
opacity: 0.6;
}
50% {
height: 24px;
opacity: 0.9;
}
}
@media (min-width: 768px) {
@keyframes exhaustPulse {
0%, 100% {
height: 20px;
opacity: 0.6;
}
50% {
height: 30px;
opacity: 0.9;
}
}
}
@keyframes starShine {
0%, 100% {
opacity: 0;
transform: scale(0.5);
}
50% {
opacity: 1;
transform: scale(1);
}
}
/* Эффект при наведении на контейнер */
.rocket-container:hover .rocket {
animation: rocketHover 2s ease-in-out;
}
.rocket-container:hover .exhaust {
animation: exhaustBoost 1s ease-in-out;
}
@keyframes rocketHover {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-10px);
}
100% {
transform: translateY(0);
}
}
@media (min-width: 768px) {
@keyframes rocketHover {
0% {
transform: translateY(0);
}
50% {
transform: translateY(-15px);
}
100% {
transform: translateY(0);
}
}
}
@keyframes exhaustBoost {
0%, 100% {
height: 16px;
opacity: 0.6;
}
50% {
height: 32px;
opacity: 1;
}
}
@media (min-width: 768px) {
@keyframes exhaustBoost {
0%, 100% {
height: 20px;
opacity: 0.6;
}
50% {
height: 40px;
opacity: 1;
}
}
}
</style>

View file

@ -0,0 +1,76 @@
---
import { Icon } from 'astro-icon/components'
import type { AboutData } from '@globalInterfaces';
interface Props {
aboutData: AboutData;
}
const { aboutData } = Astro.props;
---
<section class="py-8 md:py-12 bg-gradient-to-r from-indigo-50 to-purple-50 dark:from-neutral-900 dark:to-neutral-800 rounded-xl md:rounded-2xl">
<div class="max-w-4xl mx-auto px-4 sm:px-5 lg:px-8">
<div class="text-center">
<!-- Заголовок -->
<h2 class="text-2xl md:text-3xl lg:text-4xl font-bold text-gray-900 dark:text-white mb-4 md:mb-6">
{aboutData.contact_title}
</h2>
<!-- Описание -->
<p class="text-base md:text-lg lg:text-xl text-gray-600 dark:text-neutral-300 mb-6 md:mb-8 lg:mb-10 max-w-2xl mx-auto">
{aboutData.contact_description}
</p>
<!-- Кнопки контактов -->
<div class="flex flex-col sm:flex-row gap-3 md:gap-4 justify-center items-center">
<!-- WhatsApp -->
<a
href={aboutData.whatsapp_link}
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center px-5 py-3 md:px-6 md:py-3 lg:px-8 lg:py-4 text-sm md:text-base font-semibold rounded-xl md:rounded-2xl shadow-md md:shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-0.5 md:hover:-translate-y-1 bg-green-500 hover:bg-green-600 text-white contact-cta-button w-full sm:w-auto justify-center"
>
<Icon name="whatsapp" class="w-5 h-5 md:w-6 md:h-6 mr-2 md:mr-3" />
Написать в WhatsApp
</a>
<!-- Email -->
<a
href={`mailto:${aboutData.email}`}
class="inline-flex items-center px-5 py-3 md:px-6 md:py-3 lg:px-8 lg:py-4 text-sm md:text-base font-semibold rounded-xl md:rounded-2xl shadow-md md:shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-0.5 md:hover:-translate-y-1 bg-white dark:bg-neutral-800 text-gray-700 dark:text-neutral-300 border border-gray-200 dark:border-neutral-700 contact-cta-button w-full sm:w-auto justify-center"
>
<Icon name="envelope" class="w-4 h-4 md:w-5 md:h-5 mr-2" />
<span class="truncate">{aboutData.email}</span>
</a>
</div>
<!-- Дополнительная информация -->
<div class="mt-6 md:mt-8 p-3 md:p-4 bg-white dark:bg-neutral-800 rounded-xl md:rounded-2xl shadow-sm">
<p class="text-xs md:text-sm text-gray-500 dark:text-neutral-400">
⚡ Обычно отвечаю в течение 1-2 часов в рабочее время
</p>
</div>
</div>
</div>
</section>
<style>
/* Уникальные стили для компонента ContactCTA */
.contact-cta-button {
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.contact-cta-button:hover {
transform: translateY(-2px);
box-shadow: 0 10px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* Адаптивность для мобильных устройств */
@media (max-width: 640px) {
.contact-cta-button {
padding: 0.75rem 1.25rem;
font-size: 0.875rem;
}
}
</style>

View file

@ -0,0 +1,10 @@
---
const { link, text } = Astro.props
---
<a
href={link}
class="inline-flex w-auto px-4 py-2 mt-5 text-xs font-semibold duration-300 ease-out border rounded-full bg-neutral-900 dark:bg-white dark:text-neutral-900 text-neutral-100 hover:border-neutral-700 border-neutral-900 dark:hover:border-neutral-300 hover:bg-white dark:hover:bg-black dark:hover:text-white hover:text-neutral-900"
>
{text}
</a>

View file

@ -0,0 +1,16 @@
---
const { title, description } = Astro.props
---
<div class="relative z-20 w-full mx-auto lg:mx-0">
<h1
class="text-2xl font-bold text-center tracking-tight text-neutral-900 dark:text-neutral-100 sm:text-3xl lg:text-4xl"
>
{title}
</h1>
<p
class="mt-3 text-sm leading-6 text-center text-neutral-600 dark:text-neutral-400 sm:mt-4 lg:mt-6 sm:leading-7 lg:leading-8 sm:text-base lg:text-lg"
>
{description}
</p>
</div>

View file

@ -0,0 +1,57 @@
import type { Component } from 'solid-js';
import { Show } from 'solid-js';
import { FiChevronLeft, FiChevronRight } from 'solid-icons/fi';
import type { PaginationProps } from '@globalInterfaces';
interface Props {
page: PaginationProps;
}
const Pagination: Component<Props> = (props) => {
const isPrevDisabled = () => !props.page.url.prev;
const isNextDisabled = () => !props.page.url.next;
return (
<Show when={props.page.lastPage > 1}>
<nav
class="mt-12 flex items-center justify-center gap-6 border-t border-neutral-200 pt-8 dark:border-neutral-800"
aria-label="Навигация по страницам"
>
{/* Кнопка "Назад" */}
<a
href={props.page.url.prev}
classList={{
'flex items-center justify-center h-10 w-10 rounded-full border transition-all duration-300': true,
'border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-600': isPrevDisabled(),
'border-neutral-300 bg-white text-neutral-700 shadow-sm hover:bg-neutral-50 hover:shadow-md active:scale-95 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-700': !isPrevDisabled(),
}}
aria-disabled={isPrevDisabled()}
aria-label="Перейти на предыдущую страницу"
>
<FiChevronLeft class="h-5 w-5" />
</a>
{/* Индикатор текущей страницы */}
<span class="text-sm font-medium text-neutral-600 dark:text-neutral-400">
Страница <span class="font-semibold text-blue-600 dark:text-blue-400">{props.page.currentPage}</span> из {props.page.lastPage}
</span>
{/* Кнопка "Вперед" */}
<a
href={props.page.url.next}
classList={{
'flex items-center justify-center h-10 w-10 rounded-full border transition-all duration-300': true,
'border-neutral-300 bg-neutral-100 text-neutral-400 cursor-not-allowed dark:border-neutral-700 dark:bg-neutral-800 dark:text-neutral-600': isNextDisabled(),
'border-neutral-300 bg-white text-neutral-700 shadow-sm hover:bg-neutral-50 hover:shadow-md active:scale-95 dark:bg-neutral-800 dark:border-neutral-600 dark:text-neutral-300 dark:hover:bg-neutral-700': !isNextDisabled(),
}}
aria-disabled={isNextDisabled()}
aria-label="Перейти на следующую страницу"
>
<FiChevronRight class="h-5 w-5" />
</a>
</nav>
</Show>
);
};
export default Pagination;

View file

@ -0,0 +1,2 @@
<div class="w-full h-auto bg-white dark:bg-neutral-950 aspect-square {classes}">
</div>

View file

@ -0,0 +1,17 @@
---
import Square from './Square.astro'
---
<div
class="relative flex w-full divide-x h-[30px] sm:h-[45px] md:h-[60px] xl:h-[88px] divide-neutral-300 dark:divide-neutral-700 divide-dashed"
>
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
<Square />
</div>

View file

@ -0,0 +1,43 @@
---
import SquareLine from './SquareLine.astro'
---
<div class="absolute w-full h-auto" style="z-index:-1">
<div
class="absolute top-0 left-0 w-1/2 h-auto bg-neutral-100 dark:bg-neutral-800"
>
<div
class="absolute inset-0 z-30 w-full h-full pointer-events-none bg-gradient-to-tl from-white dark:from-neutral-950 from-50% via-90% to-100% via-transparent to-transparent"
>
</div>
<div
class="flex flex-col w-full h-full border-t border-l divide-y divide-dashed divide-neutral-300 dark:divide-neutral-700 border-neutral-300 dark:border-neutral-900"
>
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
</div>
</div>
<div
class="absolute top-0 right-0 w-1/2 h-auto bg-neutral-100 dark:bg-neutral-800"
>
<div
class="absolute inset-0 z-30 w-full h-full pointer-events-none bg-gradient-to-tr from-white dark:from-neutral-950 from-50% via-90% to-100% via-transparent to-transparent"
>
</div>
<div
class="flex flex-col w-full h-full border-t border-l divide-y divide-dashed divide-neutral-300 dark:divide-neutral-700 border-neutral-300 dark:border-neutral-900"
>
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
<SquareLine />
</div>
</div>
</div>

View file

@ -0,0 +1,21 @@
---
// WhatsAppButton.astro
const { phoneNumber, btnText = "напиши мне в WhatsApp" } = Astro.props;
// Очистка номера
const cleanNumber = phoneNumber?.replace(/\D/g, '') || "";
const message = encodeURIComponent("Здравствуйте! Хочу обсудить проект");
const whatsappUrl = `https://wa.me/${cleanNumber}?text=${message}`;
---
<a
href={whatsappUrl}
target="_blank"
rel="noopener noreferrer"
class="mt-5 inline-flex w-auto items-center justify-center rounded-full border border-neutral-900 bg-neutral-900 px-4 py-2 text-xs font-semibold text-neutral-100 duration-300 ease-out hover:border-neutral-700 hover:bg-[#25D366] hover:text-white dark:bg-white dark:text-neutral-900 dark:hover:border-neutral-300 dark:hover:bg-[#25D366] dark:hover:text-white shadow-lg"
>
<svg class="mr-2 h-4 w-4 fill-current" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path d="M17.472 14.382c-.297-.149-1.758-.867-2.03-.967-.273-.099-.471-.148-.67.15-.197.297-.767.966-.94 1.164-.173.199-.347.223-.644.075-.297-.15-1.255-.463-2.39-1.475-.883-.788-1.48-1.761-1.653-2.059-.173-.297-.018-.458.13-.606.134-.133.298-.347.446-.52.149-.174.198-.298.298-.497.099-.198.05-.371-.025-.52-.075-.149-.669-1.612-.916-2.207-.242-.579-.487-.5-.669-.51-.173-.008-.371-.01-.57-.01-.198 0-.52.074-.792.372-.272.297-1.04 1.016-1.04 2.479 0 1.462 1.065 2.875 1.213 3.074.149.198 2.096 3.2 5.077 4.487.709.306 1.262.489 1.694.625.712.227 1.36.195 1.871.118.571-.085 1.758-.719 2.006-1.413.248-.694.248-1.289.173-1.413-.074-.124-.272-.198-.57-.347m-5.421 7.403h-.004a9.87 9.87 0 01-5.031-1.378l-.361-.214-3.741.982.998-3.648-.235-.374a9.86 9.86 0 01-1.51-5.26c.001-5.45 4.436-9.884 9.888-9.884 2.64 0 5.122 1.03 6.988 2.898a9.825 9.825 0 012.893 6.994c-.003 5.45-4.437 9.884-9.885 9.884m8.413-18.297A11.815 11.815 0 0012.05 0C5.495 0 .16 5.335.157 11.892c0 2.096.547 4.142 1.588 5.945L.057 24l6.305-1.654a11.882 11.882 0 005.683 1.448h.005c6.554 0 11.89-5.335 11.893-11.893a11.821 11.821 0 00-3.48-8.413z"/>
</svg>
<span>{btnText}</span>
</a>

View file

@ -0,0 +1,87 @@
---
import type { Post } from '@globalInterfaces';
interface Props {
post: Post;
}
const { post } = Astro.props;
const formattedDate = new Date(post.publishDate).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
const postLink = `/blog/${post.slug}`;
---
<div class="relative p-6 md:p-8 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors duration-300 group">
{/*
1. ГЛАВНАЯ ССЫЛКА (на весь блок)
Она лежит в самом низу (z-0) и растянута на всю карточку.
*/}
<a href={postLink} class="absolute inset-0 z-0" aria-label={`Читать статью: ${post.title}`}></a>
{/*
2. КОНТЕНТ
Лежит выше ссылки (z-10).
pointer-events-none пропускает клики сквозь текст на ссылку-подложку.
*/}
<div class="relative z-10 pointer-events-none">
{/* Верхняя строка: Иконка звезды и Дата */}
<div class="flex items-center justify-between mb-4">
{/* Слева: Иконка звезды и текст "Рекомендую" */}
<div class="flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 text-neutral-400 group-hover:text-yellow-500 transition-colors">
<path fill-rule="evenodd" d="M10.788 3.21c.448-1.077 1.976-1.077 2.424 0l2.082 5.007 5.404.433c1.164.093 1.636 1.545.749 2.305l-4.117 3.527 1.257 5.273c.271 1.136-.964 2.033-1.96 1.425L12 18.354 7.373 21.18c-.996.608-2.231-.29-1.96-1.425l1.257-5.273-4.117-3.527c-.887-.76-.415-2.212.749-2.305l5.404-.433 2.082-5.006z" clip-rule="evenodd" />
</svg>
<span class="font-medium uppercase tracking-wide text-xs text-neutral-500">Рекомендую</span>
</div>
{/* Справа: Дата с иконкой календаря */}
<div class="flex items-center gap-2 text-xs text-neutral-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 text-neutral-400">
<path fill-rule="evenodd" d="M6.75 2.25A.75.75 0 017.5 3v1.5h9V3A.75.75 0 0118 3v1.5h.75a3 3 0 013 3v11.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V7.5a3 3 0 013-3H6V3a.75.75 0 01.75-.75zm13.5 9a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5z" clip-rule="evenodd" />
</svg>
<span class="font-medium">{formattedDate}</span>
</div>
</div>
{/*
Заголовок
- Убрал underline
- Добавил тот же hover-эффект цвета, что и в основной карточке
*/}
<h3 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100 mb-3 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{post.title}
</h3>
{/* Описание */}
<p class="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{post.description}
</p>
</div>
{/*
3. ТЕГИ
- Теперь стилизованы как "плашки" (фон, скругление, отступы), как в основной карточке.
- Использую bg-white (так как сама карточка серая), чтобы создать контраст.
*/}
{post.tags && post.tags.length > 0 && (
<div class="relative z-20 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800 flex flex-wrap gap-2 pointer-events-auto">
{post.tags.map((tag) => (
<a
href={`/blog/tags/${tag.toLowerCase()}`}
class="text-xs text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors bg-white dark:bg-neutral-950 px-2 py-1 rounded border border-neutral-200 dark:border-neutral-800"
>
#{tag}
</a>
))}
</div>
)}
</div>

View file

@ -0,0 +1,92 @@
---
import type { Post } from '@globalInterfaces';
interface Props {
posts: Post[];
}
const { posts } = Astro.props;
function formatDate(date: string): string {
if (!date) return '';
return new Date(date).toLocaleString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
}
---
{
posts.map((post) => {
const postLink = `/blog/${post.slug}`;
const formattedDate = formatDate(post.publishDate);
const displayTitle = post.title.replace('{year}', new Date().getFullYear().toString());
return (
<div class="relative p-6 md:p-7 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white dark:bg-neutral-950 hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors duration-300 group">
{/*
ГЛАВНАЯ ССЫЛКА
z-0 - самый нижний слой
*/}
<a href={postLink} class="absolute inset-0 z-0" aria-label={`Читать: ${displayTitle}`}></a>
{/*
КОНТЕНТ
z-10 - слой выше
*/}
<div class="relative z-10 pointer-events-none">
{/*
ВЕРХНЯЯ СТРОКА:
Слева - декоративная линия
Справа - Дата
*/}
<div class="flex items-center justify-between mb-4">
{/* Слева: Декоративная линия ("строка") */}
<div class="w-12 h-0.5 bg-neutral-200 dark:bg-neutral-800 rounded-full group-hover:bg-neutral-300 dark:group-hover:bg-neutral-700 transition-colors"></div>
{/* Справа: Дата */}
<div class="flex items-center gap-2 text-xs text-neutral-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 text-neutral-400">
<path fill-rule="evenodd" d="M6.75 2.25A.75.75 0 017.5 3v1.5h9V3A.75.75 0 0118 3v1.5h.75a3 3 0 013 3v11.25a3 3 0 01-3 3H5.25a3 3 0 01-3-3V7.5a3 3 0 013-3H6V3a.75.75 0 01.75-.75zm13.5 9a1.5 1.5 0 00-1.5-1.5H5.25a1.5 1.5 0 00-1.5 1.5v7.5a1.5 1.5 0 001.5 1.5h13.5a1.5 1.5 0 001.5-1.5v-7.5z" clip-rule="evenodd" />
</svg>
<span class="font-medium">{formattedDate}</span>
</div>
</div>
{/* Заголовок */}
<h2 class="text-xl font-bold text-neutral-900 dark:text-neutral-100 mb-2 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{displayTitle}
</h2>
{/* Описание */}
<p class="text-sm text-neutral-600 dark:text-neutral-400 leading-relaxed line-clamp-2 mb-4">
{post.description}
</p>
</div>
{/*
НИЗ: Теги
z-20 - верхний слой, клики включены
*/}
{post.tags && post.tags.length > 0 && (
<div class="relative z-20 mt-auto pt-4 border-t border-neutral-100 dark:border-neutral-900 flex flex-wrap gap-2 pointer-events-auto">
{post.tags.map((tag) => (
<a
href={`/blog/tags/${tag.toLowerCase()}`}
class="text-xs text-neutral-500 hover:text-indigo-600 dark:hover:text-indigo-400 transition-colors bg-neutral-50 dark:bg-neutral-900/50 px-2 py-1 rounded"
>
#{tag}
</a>
))}
</div>
)}
</div>
);
})
}

View file

@ -0,0 +1,71 @@
---
import { Icon } from 'astro-icon/components';
import { pb } from '@lib/pocketbase';
import type { Post } from '@globalInterfaces';
interface Props {
currentTags: string[];
currentSlug: string;
}
const { currentTags, currentSlug }: Props = Astro.props;
// Находим 5 похожих постов из PocketBase
let relatedPosts: Post[] = [];
if (currentTags && currentTags.length > 0) {
try {
// Формируем фильтр для поиска постов с похожими тегами
const tagFilters = currentTags.map((tag: string) => `tags ~ "${tag}"`).join(' || ');
const filter = `${tagFilters} && isActive = true && id != "${currentSlug}"`;
const result = await pb.collection('posts').getList(1, 5, {
filter: filter,
sort: '-publishDate',
requestKey: `related_posts_${currentSlug}`
});
relatedPosts = result.items.map(item => ({
id: item.id,
title: item.title,
description: item.description,
publishDate: item.publishDate,
slug: item.slug,
tags: item.tags || [],
content: item.content,
image: item.image,
isFeatured: item.isFeatured,
isActive: item.isActive
}));
} catch (error) {
console.error('Ошибка при получении похожих статей:', error);
relatedPosts = [];
}
}
---
{relatedPosts.length > 0 && (
<div class="mt-16 mb-20">
<div class="border-t border-dashed border-neutral-300 dark:border-neutral-700"></div>
<div class="mt-8">
<h2 class="text-2xl font-bold mb-6 text-neutral-800 dark:text-neutral-200">Похожие статьи</h2>
<ul class="flex flex-col gap-3">
{relatedPosts.map(post => {
const title = post.title.replace('{year}', new Date().getFullYear().toString());
return (
<li>
<a
href={`/blog/${post.slug}`}
class="group flex items-center justify-between rounded-lg p-4 bg-neutral-50 dark:bg-neutral-900 hover:bg-neutral-100 dark:hover:bg-neutral-800/60 border border-neutral-200/80 dark:border-neutral-800 transition-all duration-200 shadow-sm hover:shadow-md"
>
<span class="font-bold text-neutral-800 dark:text-neutral-200 group-hover:text-sky-600 dark:group-hover:text-sky-400 transition-colors duration-200">
{title}
</span>
<Icon name="chevron-left" class="h-5 w-5 text-neutral-400 dark:text-neutral-500 transform -rotate-180 transition-transform duration-200 group-hover:translate-x-1" />
</a>
</li>
);
})}
</ul>
</div>
</div>
)}

View file

@ -0,0 +1,124 @@
import { createSignal, onMount, createEffect, For, Show } from 'solid-js';
import type { Component } from 'solid-js';
interface Heading {
id: string;
text: string;
}
const slugify = (text: string) => {
return text
.toLowerCase()
.replace(/[^\w\s-]/g, '')
.replace(/\s+/g, '-')
.replace(/--+/g, '-');
};
const throttle = (fn: () => void, delay: number) => {
let timeoutId: number | null = null;
return () => {
if (timeoutId) return;
timeoutId = window.setTimeout(() => {
fn();
timeoutId = null;
}, delay);
};
};
const TableOfContents: Component = () => {
const [headings, setHeadings] = createSignal<Heading[]>([]);
const [activeId, setActiveId] = createSignal('');
let headingElements: HTMLHeadingElement[] = [];
onMount(() => {
setTimeout(() => {
const elements = Array.from(
document.querySelectorAll('article h2'),
) as HTMLHeadingElement[];
const extractedHeadings = elements.map((el, index) => {
const originalText = el.innerText.replace(/^\d+\.\s*/, '');
const id = `${slugify(originalText)}-${index}`;
const numberedText = `${index + 1}. ${originalText}`;
el.id = id;
el.innerHTML = numberedText;
return { id, text: numberedText };
});
headingElements = elements;
setHeadings(extractedHeadings);
}, 100);
});
createEffect(() => {
if (headings().length === 0) return;
const handler = () => {
const headerOffset = document.getElementById('header')?.offsetHeight || 90;
const topOffset = headerOffset + 160;
// Batch the DOM reads to avoid layout thrashing
const headingTops = headingElements.map(h => h.getBoundingClientRect().top);
let currentActiveId = '';
// Iterate over the stored values, not the DOM elements
for (let i = 0; i < headingTops.length; i++) {
const top = headingTops[i];
if (top <= topOffset) {
currentActiveId = headingElements[i].id;
} else {
break;
}
}
setActiveId(currentActiveId);
};
const throttledHandler = throttle(handler, 100);
window.addEventListener('scroll', throttledHandler, { passive: true });
handler(); // run once on creation
});
const handleLinkClick = (
e: MouseEvent,
id: string,
) => {
e.preventDefault();
const element = document.getElementById(id);
const header = document.getElementById('header');
if (element && header) {
const headerOffset = header.offsetHeight;
const elementPosition = element.getBoundingClientRect().top;
const offsetPosition =
elementPosition + window.pageYOffset - headerOffset - 20;
window.scrollTo({ top: offsetPosition, behavior: 'smooth' });
}
};
return (
<Show when={headings().length > 0}>
<nav class="mb-12 block w-full rounded-lg border border-neutral-700 bg-neutral-900/80 p-4 backdrop-blur-sm lg:fixed lg:right-10 lg:top-1/2 lg:w-64 lg:-translate-y-1/2 lg:mb-0">
<h2 class="mb-4 text-center text-xl font-bold text-white">Содержание</h2>
<ul>
<For each={headings()}>
{(heading) => (
<li class="my-1">
<a
href={`#${heading.id}`}
onClick={(e) => handleLinkClick(e, heading.id)}
classList={{
'block text-sm transition-colors duration-200': true,
'font-semibold text-white': activeId() === heading.id,
'text-neutral-400 hover:text-white': activeId() !== heading.id,
}}
>
{heading.text}
</a>
</li>
)}
</For>
</ul>
</nav>
</Show>
);
};
export default TableOfContents;

View file

@ -0,0 +1,94 @@
---
import type { Course } from '@globalInterfaces';
interface Props {
course: Course;
}
const { course } = Astro.props;
const courseLink = `/courses/${course.slug}`;
// Используем placeholder из unsplash, пока нет реального изображения
const thumbnailUrl = course.thumbnail ? `${import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/courses/${course.id}/${course.thumbnail}` : `https://source.unsplash.com/400x225/?education,${course.title.split(' ')[0]}`;
---
<div class="relative p-6 md:p-8 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-900/50 hover:border-neutral-300 dark:hover:border-neutral-700 transition-colors duration-300 group">
{/*
1. ГЛАВНАЯ ССЫЛКА (на весь блок)
Она лежит в самом низу (z-0) и растянута на всю карточку.
*/}
<a href={courseLink} class="absolute inset-0 z-0" aria-label={`Подробнее о курсе: ${course.title}`}></a>
{/*
2. КОНТЕНТ
Лежит выше ссылки (z-10).
pointer-events-none пропускает клики сквозь текст на ссылку-подложку.
*/}
<div class="relative z-10 pointer-events-none">
{/* Изображение курса */}
<div class="mb-4">
<img
src={thumbnailUrl}
alt={course.title}
class="w-full h-40 object-cover rounded-lg"
loading="lazy"
/>
</div>
{/* Верхняя строка: Уровень и Цена */}
<div class="flex items-center justify-between mb-4">
{/* Слева: Уровень сложности */}
<div class="flex items-center gap-2">
<span class="font-medium uppercase tracking-wide text-xs text-neutral-500 bg-neutral-200 dark:bg-neutral-800 px-2 py-1 rounded">
{course.level}
</span>
</div>
{/* Справа: Цена */}
<div class="flex items-center gap-2 text-lg font-bold text-indigo-600 dark:text-indigo-400">
{course.price} ₽
</div>
</div>
{/*
Заголовок
- Убрал underline
- Добавил тот же hover-эффект цвета, что и в основной карточке
*/}
<h3 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100 mb-3 group-hover:text-indigo-600 dark:group-hover:text-indigo-400 transition-colors">
{course.title}
</h3>
{/* Описание */}
<p class="text-neutral-600 dark:text-neutral-400 leading-relaxed mb-4">
{course.description}
</p>
{/* Продолжительность */}
<div class="flex items-center gap-2 text-sm text-neutral-500 mb-4">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 text-neutral-400">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zM12.75 6a.75.75 0 00-1.5 0v6c0 .414.336.75.75.75h4.5a.75.75 0 000-1.5h-3.75V6z" clip-rule="evenodd" />
</svg>
<span class="font-medium">{course.duration}</span>
</div>
</div>
{/*
3. ТЕГИ
- Теперь стилизованы как "плашки" (фон, скругление, отступы), как в основной карточке.
- Использую bg-white (так как сама карточка серая), чтобы создать контраст.
*/}
{course.tags && course.tags.length > 0 && (
<div class="relative z-20 mt-4 pt-4 border-t border-neutral-200 dark:border-neutral-800 flex flex-wrap gap-2 pointer-events-auto">
{course.tags.map((tag) => (
<span class="text-xs text-neutral-500 bg-white dark:bg-neutral-950 px-2 py-1 rounded border border-neutral-200 dark:border-neutral-800">
#{tag}
</span>
))}
</div>
)}
</div>

View file

@ -0,0 +1,88 @@
---
import WhatsAppButton from '@components/base/WhatsAppButton.astro'
import TechStack from '@components/home/TechStack.astro'
import HeroImage from '@components/home/HeroImage.astro'
import { pb } from '@lib/pocketbase';
interface HeroData {
id: string;
title: string;
subtitle: string;
tech_title: string;
whatsapp_phone_number: string;
btn_text: string;
frontend_tech: string[];
backend_tech: string[];
is_active: boolean;
[key: string]: any; // для остальных полей
}
const result = await pb.collection('home_hero').getList(1, 1, {
filter: 'is_active = true',
sort: '-created'
});
const heroData: HeroData = {
...result.items[0],
id: result.items[0].id,
title: result.items[0].title,
subtitle: result.items[0].subtitle,
tech_title: result.items[0].tech_title,
whatsapp_phone_number: result.items[0].whatsapp_phone_number,
btn_text: result.items[0].btn_text,
frontend_tech: result.items[0].frontend_tech || [],
backend_tech: result.items[0].backend_tech || [],
is_active: result.items[0].is_active
};
---
<section class="w-full">
<div class="flex flex-col md:flex-row items-center justify-between gap-10 lg:gap-20">
<!-- ЛЕВАЯ КОЛОНКА -->
<div class="flex-1 hero-content-animation">
<div class="text-center md:text-left">
<h1 class="mb-5 text-2xl md:text-4xl lg:text-5xl font-bold leading-tight dark:text-white animate-fade-in-up" style="animation-delay: 0.1s;">
{heroData.title}
</h1>
<div class="mb-6 space-y-3 animate-fade-in-up" style="animation-delay: 0.2s;">
<p class="text-base md:text-lg text-neutral-600 dark:text-neutral-400">
{heroData.subtitle}
</p>
</div>
</div>
<!-- Стек и кнопка (внутри левой колонки) -->
<div class="mt-8 animate-fade-in-up" style="animation-delay: 0.3s;">
<h2 class="text-xl md:text-2xl font-bold text-center md:text-left mb-6 dark:text-white">
{heroData.tech_title}
</h2>
<TechStack heroData={heroData} />
</div>
<div class="mt-8 flex justify-center animate-fade-in-up" style="animation-delay: 0.7s;">
<WhatsAppButton
phoneNumber={heroData.whatsapp_phone_number}
btnText={heroData.btn_text}
/>
</div>
</div>
<!-- ПРАВАЯ КОЛОНКА (Картинка) -->
<div class="flex-1 w-full max-w-sm md:max-w-md lg:max-w-lg">
<HeroImage heroData={heroData} />
</div>
</div>
</section>
<style>
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.animate-fade-in-up {
opacity: 0;
animation: fadeInUp 0.8s ease-out forwards;
}
</style>

View file

@ -0,0 +1,70 @@
---
import { getImageUrl } from '@lib/pocketbase';
const { heroData } = Astro.props;
const imageUrl = getImageUrl(heroData, heroData.image);
---
<div class="relative z-10 w-full max-w-sm mx-auto hero-image-container">
<!-- Эмодзи 👋 -->
<div class="absolute -top-4 -left-4 z-40 w-16 h-16 rounded-full">
<span class="relative z-20 flex items-center justify-center w-full h-full text-2xl border-8 border-white rounded-full dark:border-neutral-950 bg-neutral-100 dark:bg-neutral-900 wave-emoji">
<span class="flex items-center justify-center w-full h-full bg-white border border-dashed rounded-full dark:bg-neutral-950 border-neutral-300 dark:border-neutral-700">
👋
</span>
</span>
</div>
<div class="relative z-30">
<img
src={imageUrl}
alt={heroData.img_alt}
width={790}
height={1193}
loading="eager"
decoding="auto"
class="relative z-30 w-full h-auto hero-image"
/>
<span
class="absolute bottom-[45%] left-[40%] -translate-x-1/2 z-40 rounded-full bg-white/80 dark:bg-black/80 px-3 py-1 text-sm font-semibold text-neutral-800 dark:text-neutral-200 opacity-0 greeting-text-animation shadow-lg"
>
{heroData.greeting_text}
</span>
</div>
<!-- Декоративная рамка -->
<div class="absolute inset-0 z-20 -m-4 border border-dashed rounded-2xl bg-gradient-to-r dark:from-neutral-950 dark:via-black dark:to-neutral-950 from-white via-neutral-50 to-white border-neutral-300 dark:border-neutral-700 hero-border">
</div>
</div>
<style>
@keyframes fadeInScale {
0% { opacity: 0; transform: scale(0.8) rotate(-2deg); }
70% { opacity: 1; }
100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
@keyframes borderGlow {
0% { opacity: 0; transform: scale(0.95); box-shadow: 0 0 0 0 rgba(99, 102, 241, 0.7); }
50% { opacity: 1; box-shadow: 0 0 20px 10px rgba(99, 102, 241, 0.3); }
100% { opacity: 1; transform: scale(1); box-shadow: 0 0 0 0 rgba(99, 102, 241, 0); }
}
@keyframes wave {
0% { transform: rotate(0deg); opacity: 0; }
20% { opacity: 1; }
25% { transform: rotate(-15deg); }
75% { transform: rotate(15deg); }
100% { transform: rotate(0deg); opacity: 1; }
}
@keyframes greet-fade-in {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.greeting-text-animation { animation: greet-fade-in 0.8s ease-out 2.0s forwards; }
.hero-image-container { animation: fadeInScale 1.2s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.2s forwards; }
.hero-image { animation: fadeInScale 1s cubic-bezier(0.175, 0.885, 0.32, 1.275) 0.4s forwards; opacity: 0; }
.hero-border { animation: borderGlow 1.5s ease-out 0.6s forwards; opacity: 0; }
.wave-emoji { animation: wave 1s ease-in-out 1.5s forwards; opacity: 0; }
</style>

View file

@ -0,0 +1,37 @@
---
const { text } = Astro.props
---
<div class="relative my-16">
<div class="relative w-full pl-5 overflow-x-hidden md:pl-0">
<div
class="absolute w-full h-px bg-gradient-to-r from-transparent to-white md:from-white dark:from-transparent dark:to-neutral-950 md:dark:from-neutral-950 md:via-transparent md:dark:via-transparent md:to-white md:dark:to-neutral-950"
>
</div>
<div
class="w-full h-px border-t border-dashed border-neutral-300 dark:border-neutral-600"
>
</div>
</div>
<div
class="absolute flex items-center justify-center w-auto h-auto px-3 py-1.5 uppercase tracking-widest space-x-1 text-[0.6rem] md:-translate-x-1/2 -translate-y-1/2 border rounded-full bg-white dark:bg-neutral-900 text-neutral-400 left-0 md:ml-0 ml-5 md:left-1/2 border-neutral-100 dark:border-neutral-800 shadow-sm"
>
<p class="leading-none">{text}</p>
<div
class="flex items-center justify-center w-5 h-5 translate-x-1 border rounded-full border-neutral-100 dark:border-neutral-800"
>
<svg
class="w-3 h-3"
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
><path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 4.5v15m0 0l6.75-6.75M12 19.5l-6.75-6.75"></path></svg
>
</div>
</div>
</div>

View file

@ -0,0 +1,72 @@
---
interface Props {
heroData: {
frontend_tech: string[];
backend_tech: string[];
};
}
const { heroData }: Props = Astro.props;
const frontendTech: string[] = heroData.frontend_tech;
const backendTech: string[] = heroData.backend_tech;
---
<div class="flex flex-col md:flex-row gap-4 border border-dashed rounded-lg border-neutral-200 dark:border-neutral-700 mt-8">
<!-- Секция Фронтенд -->
<div class="flex-1 p-6">
<h3 class="mb-4 text-lg font-semibold text-center text-neutral-700 dark:text-neutral-300 md:text-left">
Фронтенд
</h3>
<ul class="space-y-2 text-sm text-center text-neutral-500 dark:text-neutral-400 md:text-left">
{
frontendTech.map((tech, index) => (
<li
class="transition-all duration-200 ease-in-out hover:translate-x-2 hover:text-neutral-800 dark:hover:text-neutral-200 animate-fade-in-up"
style={`animation-delay: ${0.4 + index * 0.05}s;`}
>
🔘 {tech}
</li>
))
}
</ul>
</div>
<!-- Секция Бэкенд -->
<div class="flex-1 p-6">
<h3 class="mb-4 text-lg font-semibold text-center text-neutral-700 dark:text-neutral-300 md:text-left">
Бэкенд
</h3>
<ul class="space-y-2 text-sm text-center text-neutral-500 dark:text-neutral-400 md:text-left">
{
backendTech.map((tech, index) => (
<li
class="transition-all duration-200 ease-in-out hover:translate-x-2 hover:text-neutral-800 dark:hover:text-neutral-200 animate-fade-in-up"
style={`animation-delay: ${0.4 + index * 0.05}s;`}
>
🔘 {tech}
</li>
))
}
</ul>
</div>
</div>
<style>
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(15px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.animate-fade-in-up {
opacity: 0;
animation: fadeInUp 0.6s ease-out forwards;
}
</style>

View file

@ -0,0 +1,94 @@
---
import Button from '../base/Button.astro'
import PostsLoop from '../blog/PostsLoop.astro'
import FeaturedPostCard from '@components/blog/FeaturedPost.astro';
import { pb } from '@lib/pocketbase';
// --- ШАГ 1: Получаем избранные статьи (до 5 шт) ---
const featuredResult = await pb.collection('posts').getList(1, 5, {
filter: 'isActive = true && isFeatured = true',
sort: '-publishDate',
requestKey: 'home_featured_multi'
});
import type { Post } from '@globalInterfaces';
// ПРЕОБРАЗОВАНИЕ (MAPPING)
// Превращаем ответ PocketBase в чистый объект для компонента
const featuredPosts: Post[] = featuredResult.items.map(post => ({
id: post.id, // ID нужен для исключения ниже
title: post.title,
slug: post.slug,
description: post.description,
publishDate: post.publishDate,
tags: post.tags,
content: post.content,
image: post.image,
isFeatured: post.isFeatured,
isActive: post.isActive
}));
// Собираем ID всех избранных статей, чтобы не показывать их в общем списке
const featuredIds = featuredPosts.map(p => p.id);
// --- ШАГ 2: Получаем обычные статьи (3 шт) ---
let mainFilter = 'isActive = true';
// Если есть избранные, добавляем условие: И id не равен ... И id не равен ...
if (featuredIds.length > 0) {
const exclusionQuery = featuredIds.map(id => `id != "${id}"`).join(' && ');
mainFilter += ` && ${exclusionQuery}`;
}
const recentResult = await pb.collection('posts').getList(1, 3, {
filter: mainFilter,
sort: '-publishDate',
requestKey: 'home_recent'
});
// ПРЕОБРАЗОВАНИЕ (MAPPING) для обычного списка
const recentPosts: Post[] = recentResult.items.map(post => ({
id: post.id,
title: post.title,
slug: post.slug,
description: post.description,
publishDate: post.publishDate,
tags: post.tags,
content: post.content,
image: post.image,
isFeatured: post.isFeatured,
isActive: post.isActive
}));
---
<section class="max-w-4xl mx-auto px-7 lg:px-0 animate-on-scroll">
<h2 class="text-2xl text-center font-bold leading-10 tracking-tight text-neutral-900 dark:text-neutral-100">
Мои статьи
</h2>
<p class="mb-6 text-base text-center text-neutral-600 dark:text-neutral-400">
Помимо программирования, я также люблю писать о web-технологиях.
</p>
<div class="w-full max-w-4xl mx-auto my-4 xl:px-0">
<div class="flex justify-center">
<div class="w-full md:w-2/3 space-y-7">
{/* БЛОК ИЗБРАННЫХ СТАТЕЙ */}
{featuredPosts.length > 0 && (
<div class="flex flex-col gap-6 mb-8">
{featuredPosts.map((post) => (
<FeaturedPostCard post={post} />
))}
</div>
)}
{/* СПИСОК ОБЫЧНЫХ СТАТЕЙ */}
<!--<PostsLoop posts={recentPosts} />-->
<div class="flex items-center justify-center w-full py-5">
<Button text="Посмотреть все статьи" link="/blog" />
</div>
</div>
</div>
</div>
</section>

View file

@ -0,0 +1,23 @@
import type { Component } from 'solid-js';
const currentYear = new Date().getFullYear();
const Footer: Component = () => {
return (
<footer class="border-t border-neutral-200 bg-white/50 backdrop-blur-sm dark:border-neutral-800 dark:bg-neutral-950/50">
<div class="mx-auto max-w-7xl px-6 py-8 lg:px-8">
{/* Контейнер для копирайта и иконок */}
<div class="flex flex-col-reverse items-center justify-between gap-6 sm:flex-row">
{/* Копирайт */}
<div class="w-full text-center">
<p class="text-md text-neutral-500 dark:text-neutral-400">
&copy; 2024 - {currentYear} redi.dev - Все права защищены.
</p>
</div>
</div>
</div>
</footer>
);
};
export default Footer;

View file

@ -0,0 +1,70 @@
---
import Logo from './Logo.astro';
import Navbar from './Navbar.astro';
import MobileMenu from './MobileMenu.astro';
import Search from './Search.astro';
import { pb } from '@lib/pocketbase';
interface NavItem {
id: string;
title: string;
url: string;
is_visible: boolean;
order: number;
}
// Уникальный ключ 'header_nav' предотвращает отмену
const navItems: NavItem[] = await pb.collection('nav_items').getFullList({
filter: 'is_visible = true',
sort: 'order',
requestKey: 'header_nav'
});
const { pathname } = Astro.url;
const currentPath = pathname.replace(/\/$/, '') || '/';
---
<header id="header" class="sticky top-0 z-50 w-full h-20 transition-[height] duration-300">
<div id="header-container" class="relative h-full max-w-7xl px-4 mx-auto transition-all duration-300 border-transparent">
<div class="flex items-center justify-between h-full select-none">
<Logo />
<nav class="flex items-center gap-x-6">
<div class="hidden lg:flex items-center pr-6">
<Navbar items={navItems} currentPath={currentPath} />
</div>
<Search />
<div class="lg:hidden ml-2">
<MobileMenu items={navItems} currentPath={currentPath} />
</div>
</nav>
</div>
</div>
</header>
<script>
const headerElement = document.getElementById("header");
const headerContainerElement = document.getElementById("header-container");
function evaluateHeaderPosition() {
if (window.scrollY > 20) {
if (headerContainerElement) {
headerContainerElement.classList.add("dark:border-neutral-800", "border-x", "border-b", "dark:bg-neutral-900/80", "backdrop-blur-xl", "rounded-b-xl");
headerContainerElement.classList.remove("border-transparent");
}
if (headerElement) {
headerElement.classList.add("h-16");
headerElement.classList.remove("h-20");
}
} else {
if (headerContainerElement) {
headerContainerElement.classList.remove("dark:border-neutral-800", "border-x", "border-b", "dark:bg-neutral-900/80", "backdrop-blur-xl", "rounded-b-xl");
headerContainerElement.classList.add("border-transparent");
}
if (headerElement) {
headerElement.classList.remove("h-16");
headerElement.classList.add("h-20");
}
}
}
window.addEventListener("scroll", evaluateHeaderPosition, { passive: true });
</script>

View file

@ -0,0 +1,20 @@
---
import { pb, getImageUrl } from '@lib/pocketbase';
// Уникальный ключ 'logo_main'
const logoData = await pb.collection('logo').getFirstListItem('', {
requestKey: 'logo_main'
});
---
<a href={logoData.link_url} class="flex items-center">
<img
src={getImageUrl(logoData, logoData.image)}
alt={logoData.alt_text}
width={logoData.width}
height={logoData.height}
loading="eager"
fetchpriority="high"
class="w-8 object-contain"
/>
</a>

View file

@ -0,0 +1,176 @@
---
import { pb, getImageUrl } from '@lib/pocketbase';
interface NavItem {
id: string;
title: string;
url: string;
is_visible: boolean;
order: number;
}
interface LogoData {
id: string;
image: string;
alt_text: string;
}
interface Props {
items?: NavItem[];
currentPath?: string;
}
const { items, currentPath = '/' }: Props = Astro.props;
// Нормализация текущего пути
const normalize = (path: string) => path.replace(/\/$/, '') || '/';
const normalizedCurrentPath = normalize(currentPath);
// Получаем пункты меню из базы, если не переданы извне
const navItems: NavItem[] = items ? items : await pb.collection('nav_items').getFullList({
filter: 'is_visible = true',
sort: 'order',
requestKey: 'mobile_menu_nav'
});
// Фильтруем пункт "Главная" на главной странице
const filteredNavItems = navItems.filter((item) => {
const itemUrl = normalize(item.url);
if (itemUrl === '/' && normalizedCurrentPath === '/') {
return false;
}
return true;
});
// Получаем логотип (безопасно, чтобы не ломалось, если базы нет)
let logoData = null;
try {
logoData = await pb.collection('logo').getFirstListItem('', { requestKey: 'mobile_menu_logo' });
} catch (e) {
// Игнорируем ошибку, если лого не загрузилось
}
---
<!-- Кнопка открытия -->
<button
id="mobile-menu-btn"
type="button"
aria-label="Открыть меню"
class="rounded-full bg-neutral-800/50 p-2 lg:hidden relative z-[60]"
>
<!-- Контейнер для иконок, чтобы обеспечить точное позиционирование -->
<div class="relative w-6 h-6">
<!-- Иконка Menu (Hamburger) -->
<svg id="icon-menu" 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="text-white absolute top-0 left-0 w-full h-full"><line x1="3" y1="12" x2="21" y2="12"></line><line x1="3" y1="6" x2="21" y2="6"></line><line x1="3" y1="18" x2="21" y2="18"></line></svg>
<!-- Иконка Close (X) - скрыта по умолчанию -->
<svg id="icon-close" 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="text-white absolute top-0 left-0 w-full h-full hidden"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</div>
</button>
<!-- Оверлей (затемнение фона) -->
<div
id="mobile-overlay"
class="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm transition-opacity duration-300 opacity-0 pointer-events-none"
aria-hidden="true"
></div>
<!-- Выдвижное меню -->
<div
id="mobile-drawer"
class="fixed bottom-0 left-0 top-0 z-50 flex h-full w-[85vw] max-w-sm flex-col border-r border-neutral-800 bg-neutral-900 shadow-lg transition-transform duration-300 ease-in-out -translate-x-full"
>
<!-- Шапка меню -->
<div class="flex flex-shrink-0 items-center justify-between border-b border-neutral-800 p-4">
<a href="/" class="flex items-center gap-x-3">
{logoData ? (
<img
src={getImageUrl(logoData, logoData.image)}
alt={logoData.alt_text}
class="h-8 w-8 object-contain"
/>
) : (
<span class="text-white font-bold">LOGO</span>
)}
</a>
<!-- Кнопка поиска внутри меню -->
<button type="button" id="mobile-search-btn" class="p-2 text-white">
<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"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
</div>
<!-- Ссылки -->
<nav class="flex flex-grow flex-col items-center justify-start gap-4 p-4 pt-8">
{filteredNavItems.map((item, index) => (
<a
href={item.url}
class="mobile-link block transform text-center text-xl font-medium text-neutral-300 transition-all duration-300 ease-out hover:text-indigo-400 translate-y-4 opacity-0"
style={`transition-delay: ${100 + index * 75}ms`}
>
{item.title}
</a>
))}
</nav>
</div>
<script>
// Логика работы меню (оригинальная)
const btn = document.getElementById('mobile-menu-btn');
const overlay = document.getElementById('mobile-overlay');
const drawer = document.getElementById('mobile-drawer');
const iconMenu = document.getElementById('icon-menu');
const iconClose = document.getElementById('icon-close');
const links = document.querySelectorAll('.mobile-link');
const mobileSearchBtn = document.getElementById('mobile-search-btn');
let isOpen = false;
function toggleMenu() {
isOpen = !isOpen;
// Блокировка скролла
document.body.style.overflow = isOpen ? 'hidden' : '';
// Иконки
if (isOpen) {
iconMenu?.classList.add('hidden');
iconClose?.classList.remove('hidden');
} else {
iconMenu?.classList.remove('hidden');
iconClose?.classList.add('hidden');
}
// Оверлей
overlay?.classList.toggle('opacity-0', !isOpen);
overlay?.classList.toggle('pointer-events-none', !isOpen);
overlay?.classList.toggle('opacity-100', isOpen);
// Панель (Drawer)
drawer?.classList.toggle('-translate-x-full', !isOpen);
drawer?.classList.toggle('translate-x-0', isOpen);
// Анимация ссылок
links.forEach((link: Element) => {
link.classList.toggle('translate-y-4', !isOpen);
link.classList.toggle('translate-y-0', isOpen);
link.classList.toggle('opacity-0', !isOpen);
link.classList.toggle('opacity-100', isOpen);
});
}
btn?.addEventListener('click', toggleMenu);
overlay?.addEventListener('click', toggleMenu);
// Закрываем меню при клике на ссылку
links.forEach((link: Element) => {
link.addEventListener('click', toggleMenu);
});
// Обработка кнопки поиска в мобильном меню
mobileSearchBtn?.addEventListener('click', () => {
toggleMenu(); // Закрываем меню
// Вызываем открытие глобального поиска (событие слушается в Search.astro)
window.dispatchEvent(new CustomEvent('open-search'));
});
</script>

View file

@ -0,0 +1,58 @@
---
interface NavItem {
id: string;
title: string;
url: string;
is_visible: boolean;
order: number;
target_blank?: boolean;
}
interface Props {
items?: NavItem[];
currentPath?: string;
}
const { items, currentPath = '/' }: Props = Astro.props;
const navItems: NavItem[] = items || [];
// Нормализация путей
const normalize = (path: string) => path.replace(/\/$/, '') || '/';
const normalizedCurrentPath = normalize(currentPath);
---
<nav class="flex items-center gap-x-6">
{
navItems
.filter((item) => {
// Скрываем пункт "Главная" на главной странице
const itemUrl = normalize(item.url);
if (itemUrl === '/' && normalizedCurrentPath === '/') {
return false;
}
return true;
})
.map((item) => {
const itemUrl = normalize(item.url);
const isActive = itemUrl === '/'
? normalizedCurrentPath === '/'
: normalizedCurrentPath.startsWith(itemUrl);
return (
<a
href={item.url}
target={item.target_blank ? "_blank" : "_self"}
class:list={[
"text-lg font-medium transition-colors duration-300",
isActive
? "text-indigo-600 dark:text-indigo-400"
: "text-neutral-500 dark:text-neutral-400 hover:text-indigo-600 dark:hover:text-indigo-400"
]}
>
{item.title}
</a>
);
})
}
</nav>

View file

@ -0,0 +1,180 @@
---
// Search.astro
---
<!-- Кнопка вызова -->
<button
type="button"
id="search-trigger"
aria-label="Открыть поиск"
class="group relative hidden w-full transform items-center justify-center p-2 text-center font-medium tracking-wide text-neutral-400 transition-transform duration-200 ease-out hover:scale-110 hover:text-white sm:mb-0 md:w-auto lg:flex"
>
<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"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</button>
<!-- Модальное окно -->
<dialog id="search-modal" class="bg-transparent p-0 m-0 w-full h-full max-w-none max-h-none fixed inset-0 z-50 backdrop:bg-black/70 backdrop:backdrop-blur-sm open:animate-fade-in outline-none">
<div class="fixed inset-0 flex items-start justify-center p-4 pt-20" id="search-container">
<div class="relative w-full max-w-2xl rounded-2xl border border-neutral-800 bg-neutral-900 shadow-2xl overflow-hidden flex flex-col max-h-[80vh]" id="search-panel">
<!-- Кнопка закрытия -->
<button
type="button"
id="close-search"
class="absolute right-5 top-5 z-20 flex h-10 w-10 items-center justify-center rounded-full bg-neutral-800 text-neutral-400 transition-all duration-200 hover:bg-neutral-700 hover:text-white hover:rotate-90 border border-neutral-700"
>
<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"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>
</button>
<!-- Контейнер инпута с ОГРОМНЫМ отступом сверху (pt-24 = 96px) -->
<div class="px-8 pb-6 pt-24 border-b border-neutral-800 bg-neutral-900/50">
<div class="relative">
<div class="absolute left-5 top-1/2 -translate-y-1/2 text-indigo-500">
<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"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
</div>
<input
id="search-input"
type="text"
placeholder="Введите запрос..."
class="w-full rounded-xl border border-neutral-700 bg-black/20 py-4 pl-14 pr-4 text-xl text-white placeholder:text-neutral-500 transition-all focus:border-indigo-500 focus:bg-neutral-950 focus:outline-none focus:ring-1 focus:ring-indigo-500"
/>
</div>
</div>
<!-- Результаты -->
<div id="search-results" class="flex-1 overflow-y-auto p-4 space-y-2 min-h-[100px]">
<p class="py-10 text-center text-neutral-500 text-lg">Начните вводить текст для поиска</p>
</div>
</div>
</div>
</dialog>
<!-- TEMPLATE и SCRIPT без изменений (как в прошлом ответе) -->
<template id="search-result-template">
<a href="" class="block rounded-lg p-4 transition-colors hover:bg-neutral-800 group border border-transparent hover:border-neutral-700">
<h3 class="text-lg font-semibold text-white group-hover:text-indigo-400 transition-colors result-title"></h3>
<p class="mt-1 text-sm text-neutral-400 line-clamp-2 result-desc"></p>
</a>
</template>
<style>
dialog[open] { animation: fadeIn 0.2s ease-out; }
@keyframes fadeIn { from { opacity: 0; transform: scale(0.98); } to { opacity: 1; transform: scale(1); } }
</style>
<!-- Оставьте HTML как есть, замените только секцию <script> -->
<script>
// Интерфейс для типизации (если используете TypeScript)
interface SearchItem {
id: string;
title: string;
description: string;
slug: string;
}
const trigger = document.getElementById('search-trigger');
const modal = document.getElementById('search-modal') as HTMLDialogElement | null;
const closeBtn = document.getElementById('close-search');
const container = document.getElementById('search-container');
const input = document.getElementById('search-input') as HTMLInputElement | null;
const resultsContainer = document.getElementById('search-results');
const template = document.getElementById('search-result-template') as HTMLTemplateElement | null;
// Открытие модалки
const openModal = () => {
if (!modal) return;
modal.showModal();
document.body.style.overflow = 'hidden'; // Блокируем скролл страницы
input?.focus();
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-neutral-500">Начните вводить текст для поиска</p>';
};
// Закрытие модалки
const closeModal = () => {
if (!modal) return;
modal.close();
document.body.style.overflow = '';
if (input) input.value = ''; // Очищаем поле
};
// Слушатели событий
trigger?.addEventListener('click', openModal);
closeBtn?.addEventListener('click', closeModal);
// Закрытие по клику вне области (на backdrop)
modal?.addEventListener('click', (e) => {
const rect = modal.getBoundingClientRect();
// Проверяем, был ли клик внутри диалога или снаружи
// (HTMLDialogElement работает специфично с backdrop)
if (e.target === modal) {
closeModal();
}
});
// Логика поиска
let debounceTimer: ReturnType<typeof setTimeout>;
input?.addEventListener('input', (e: Event) => {
const target = e.target as HTMLInputElement;
const query = target.value.trim();
// Сброс таймера
clearTimeout(debounceTimer);
// Очистка если пусто
if (!query) {
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-neutral-500">Начните вводить текст</p>';
return;
}
// Задержка перед запросом (Debounce)
debounceTimer = setTimeout(async () => {
try {
if (resultsContainer) resultsContainer.innerHTML = '<div class="py-10 text-center text-neutral-500">Поиск...</div>';
// ! ВАЖНО: Запрос идет на наш созданный API
const res = await fetch(`/api/search.json?q=${encodeURIComponent(query)}`);
if (!res.ok) throw new Error('Network error');
const data: SearchItem[] = await res.json();
// Очистка контейнера перед вставкой
if (resultsContainer) resultsContainer.innerHTML = '';
if (data.length === 0) {
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-neutral-400">Ничего не найдено</p>';
return;
}
// Рендер результатов
data.forEach(item => {
if (template) {
const clone = template.content.cloneNode(true) as DocumentFragment;
const link = clone.querySelector('a');
const titleEl = clone.querySelector('.result-title');
const descEl = clone.querySelector('.result-desc');
// Подставляем данные. Убедитесь, что пути URL совпадают с вашей структурой (/blog/ или /posts/)
if (link) link.href = `/blog/${item.slug}`;
if (titleEl) titleEl.textContent = item.title;
// Если описания нет, можно не выводить
if (descEl) descEl.textContent = item.description || '';
if (resultsContainer) resultsContainer.appendChild(clone);
}
});
} catch (error) {
console.error('Ошибка:', error);
if (resultsContainer) resultsContainer.innerHTML = '<p class="py-10 text-center text-red-400">Ошибка при поиске</p>';
}
}, 300); // Ждем 300мс после ввода последней буквы
});
</script>

View file

@ -0,0 +1,111 @@
---
import Button from '@components/base/Button.astro';
import type { FeaturedProject } from '@globalInterfaces';
import { pb } from '@lib/pocketbase';
// Получаем избранные проекты из PocketBase
const result = await pb.collection('featured_projects').getFullList({
filter: 'featured = true && isActive = true',
sort: 'order'
});
const featuredProjects: FeaturedProject[] = result.map((item: any) => ({
id: item.id,
collectionId: item.collectionId,
name: item.name,
description: item.description,
image: item.image,
url: item.url,
github: item.github,
stack: typeof item.stack === 'string' ? JSON.parse(item.stack) : item.stack,
featured: item.featured,
forSale: item.forSale,
order: item.order,
isActive: item.isActive
}));
// Если нет featured проектов, выходим
if (featuredProjects.length === 0) {
console.log('Нет доступных избранных проектов');
return null;
}
const today = new Date();
const startOfYear = new Date(today.getFullYear(), 0, 0);
const diff = today.getTime() - startOfYear.getTime();
const dayOfYear = Math.floor(diff / (1000 * 60 * 60 * 24));
// Логика выбора проекта остается прежней
const projectIndex = Math.floor(dayOfYear / 3) % featuredProjects.length;
const featuredProject = featuredProjects[projectIndex];
// Путь к изображению из PocketBase
const imageUrl = `${import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/featured_projects/${featuredProject.id}/${featuredProject.image}`;
---
{
featuredProject && (
<section class="max-w-4xl mx-auto px-7 lg:px-0 animate-on-scroll">
<h2 class="text-2xl text-center font-bold leading-10 tracking-tight text-neutral-900 dark:text-neutral-100">
Избранный проект
</h2>
<p class="mb-10 text-center text-base text-neutral-600 dark:text-neutral-400">
Это один из проектов, которым я особенно горжусь. Больше работ можно
найти на странице проектов.
</p>
<div class="grid items-center gap-8 md:grid-cols-2 md:items-start md:gap-12">
<a
href={featuredProject.url}
target="_blank"
rel="noopener noreferrer"
class="block group"
aria-label={`Перейти на сайт проекта ${featuredProject.name}`}
>
<img
src={imageUrl}
alt={`Скриншот проекта ${featuredProject.name}`}
class="rounded-lg shadow-xl aspect-video object-cover object-top transition-transform duration-300 group-hover:scale-105"
/>
</a>
<div class="flex flex-col justify-center h-full items-center text-center md:items-start md:text-left">
<div class="flex items-center gap-x-4">
<h3 class="text-2xl font-bold text-neutral-900 dark:text-neutral-100">
{featuredProject.name}
</h3>
{featuredProject.forSale && (
<span class="inline-flex items-center px-3 py-1 text-sm font-medium text-white bg-green-600 rounded-full">
Продается
</span>
)}
</div>
<p class="mt-2 text-neutral-600 dark:text-neutral-400">
{featuredProject.description}
</p>
{featuredProject.stack && featuredProject.stack.length > 0 && (
<div class="flex flex-wrap justify-center md:justify-start gap-2 mt-4">
{(typeof featuredProject.stack === 'string' ? JSON.parse(featuredProject.stack) : featuredProject.stack).map((tech: string) => (
<span class="px-2 py-1 text-xs font-medium rounded-full bg-neutral-200 text-neutral-800 dark:bg-neutral-700 dark:text-neutral-200">
{tech}
</span>
))}
</div>
)}
<div class="flex items-center gap-4 mt-6">
<Button text="Посмотреть сайт" link={featuredProject.url} />
{featuredProject.github && (
<Button text="GitHub" link={featuredProject.github} />
)}
</div>
</div>
</div>
<div class="flex items-center justify-center w-full pt-5 mt-10 border-t border-neutral-200 dark:border-neutral-800">
<Button text="Посмотреть все проекты" link="/projects" />
</div>
</section>
)
}

View file

@ -0,0 +1,34 @@
---
import type { Project } from '@globalInterfaces';
interface Props {
project: Project
}
const { project } = Astro.props
---
<a
href={project.demo_link}
target="_blank"
rel="noopener noreferrer"
class="block p-6 transition-all duration-300 ease-out bg-white border border-dashed rounded-2xl border-neutral-300 dark:border-neutral-600 dark:bg-neutral-900 group hover:border-solid hover:-translate-y-1 hover:shadow-xl"
>
<img
src={project.image}
alt={project.alt_text || `Скриншот проекта ${project.title}`}
class="object-cover w-full mb-4 rounded-lg aspect-video"
loading="lazy"
width="400"
height="225"
/>
<h3 class="text-lg font-bold text-neutral-900 dark:text-neutral-100">{project.title}</h3>
<p class="mt-1 text-sm text-neutral-600 dark:text-neutral-400">{project.description}</p>
{project.stack.length > 0 && (
<div class="mt-2 flex flex-wrap gap-1">
{project.stack.map(tech => (
<span class="text-xs px-2 py-1 bg-neutral-100 dark:bg-neutral-800 rounded-full">{tech}</span>
))}
</div>
)}
</a>

View file

@ -0,0 +1,22 @@
---
import ProjectComponent from './Project.astro'
import type { Project } from '@globalInterfaces';
interface Props {
projects: Project[]
}
const { projects }: Props = Astro.props
---
<div
class="grid items-stretch w-full grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-7 mt-7"
>
{
projects.map((project) => (
<ProjectComponent
project={project}
/>
))
}
</div>

View file

@ -0,0 +1,3 @@
// Заглушка для совместимости - теперь данные приходят из PocketBase
// Экспортируем пустой массив для совместимости с существующим кодом
export const allProjects = [];

2
frontend/src/env.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
/// <reference path="../.astro/types.d.ts" />
/// <reference types="astro/client" />

View file

@ -0,0 +1,90 @@
// Глобальные интерфейсы для компонентов
export interface Course {
id: string;
title: string;
slug: string;
description: string;
price: number;
duration: string;
level: string;
tags?: string[];
content?: string;
thumbnail?: string;
}
export interface AboutData {
id: string;
collectionId: string;
title: string;
description: string;
professional_experience: string;
skills: string[];
contact_title: string;
contact_description: string;
whatsapp_link: string;
email: string;
image: string;
alt_text: string;
isActive: boolean;
}
export interface Project {
id: string;
collectionId: string;
title: string;
description: string;
short_description: string;
long_description: string;
stack: string[];
github_link: string;
demo_link: string;
image: string;
alt_text: string;
order: number;
isActive: boolean;
}
export interface FeaturedProject {
id: string;
collectionId: string;
name: string;
description: string;
image: string;
url: string;
github?: string;
stack: string[];
featured: boolean;
forSale: boolean;
order: number;
isActive: boolean;
}
export interface Post {
id: string;
title: string;
description: string;
publishDate: string;
slug: string;
tags: string[];
content?: string;
image?: string; // Legacy field name
coverImage?: string; // Actual field name from PocketBase
coverImageAlt?: string; // Alt text for the cover image
isFeatured?: boolean;
isActive: boolean;
}
export interface PaginationProps {
currentPage: number;
lastPage: number;
url: {
prev?: string;
next?: string;
};
}
// Вспомогательная функция для декодирования HTML-сущностей
export function decodeHtmlEntities(text: string): string {
return text.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
}

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M5.616 21q-.691 0-1.153-.462T4 19.385V6.615q0-.69.463-1.152T5.616 5h1.769V3.308q0-.233.153-.386t.385-.153t.386.153t.153.386V5h7.154V3.27q0-.214.143-.358t.357-.143t.356.143t.144.357V5h1.769q.69 0 1.153.463T20 6.616v12.769q0 .69-.462 1.153T18.384 21zm0-1h12.769q.23 0 .423-.192t.192-.424v-8.768H5v8.769q0 .23.192.423t.423.192M12 14.154q-.31 0-.54-.23t-.23-.54t.23-.539t.54-.23t.54.23t.23.54t-.23.539t-.54.23m-4 0q-.31 0-.54-.23t-.23-.54t.23-.539t.54-.23t.54.23t.23.54t-.23.539t-.54.23m8 0q-.31 0-.54-.23t-.23-.54t.23-.539t.54-.23t.54.23t.23.54t-.23.539t-.54.23M12 18q-.31 0-.54-.23t-.23-.54t.23-.539t.54-.23t.54.23t.23.54t-.23.54T12 18m-4 0q-.31 0-.54-.23t-.23-.54t.23-.539t.54-.23t.54.23t.23.54t-.23.54T8 18m8 0q-.31 0-.54-.23t-.23-.54t.23-.539t.54-.23t.54.23t.23.54t-.23.54T16 18"/></svg>

After

Width:  |  Height:  |  Size: 1,021 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Myna UI Icons by Praveen Juge - https://github.com/praveenjuge/mynaui-icons/blob/main/LICENSE --><path fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="m15 6l-6 6l6 6"/></svg>

After

Width:  |  Height:  |  Size: 327 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M4 20q-.825 0-1.412-.587T2 18V6q0-.825.588-1.412T4 4h16q.825 0 1.413.588T22 6v12q0 .825-.587 1.413T20 20zm8-7l8-5V6l-8 5l-8-5v2z"/></svg>

After

Width:  |  Height:  |  Size: 364 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Google Material Icons by Material Design Authors - https://github.com/material-icons/material-icons/blob/master/LICENSE --><path fill="currentColor" d="M22 12c0-5.52-4.48-10-10-10S2 6.48 2 12c0 4.84 3.44 8.87 8 9.8V15H8v-3h2V9.5C10 7.57 11.57 6 13.5 6H16v3h-2c-.55 0-1 .45-1 1v2h3v3h-3v6.95c5.05-.5 9-4.76 9-9.95"/></svg>

After

Width:  |  Height:  |  Size: 419 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE --><path fill="currentColor" d="M19 3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2zm-.5 15.5v-5.3a3.26 3.26 0 0 0-3.26-3.26c-.85 0-1.84.52-2.32 1.3v-1.11h-2.79v8.37h2.79v-4.93c0-.77.62-1.4 1.39-1.4a1.4 1.4 0 0 1 1.4 1.4v4.93zM6.88 8.56a1.68 1.68 0 0 0 1.68-1.68c0-.93-.75-1.69-1.68-1.69a1.69 1.69 0 0 0-1.69 1.69c0 .93.76 1.68 1.69 1.68m1.39 9.94v-8.37H5.5v8.37z"/></svg>

After

Width:  |  Height:  |  Size: 593 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Design Icons by Pictogrammers - https://github.com/Templarian/MaterialDesign/blob/master/LICENSE --><path fill="currentColor" d="M22.46 6c-.77.35-1.6.58-2.46.69c.88-.53 1.56-1.37 1.88-2.38c-.83.5-1.75.85-2.72 1.05C18.37 4.5 17.26 4 16 4c-2.35 0-4.27 1.92-4.27 4.29c0 .34.04.67.11.98C8.28 9.09 5.11 7.38 3 4.79c-.37.63-.58 1.37-.58 2.15c0 1.49.75 2.81 1.91 3.56c-.71 0-1.37-.2-1.95-.5v.03c0 2.08 1.48 3.82 3.44 4.21a4.2 4.2 0 0 1-1.93.07a4.28 4.28 0 0 0 4 2.98a8.52 8.52 0 0 1-5.33 1.84q-.51 0-1.02-.06C3.44 20.29 5.7 21 8.12 21C16 21 20.33 14.46 20.33 8.79c0-.19 0-.37-.01-.56c.84-.6 1.56-1.36 2.14-2.23"/></svg>

After

Width:  |  Height:  |  Size: 719 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Material Symbols Light by Google - https://github.com/google/material-design-icons/blob/master/LICENSE --><path fill="currentColor" d="M12 11.385q-1.237 0-2.119-.882T9 8.385t.881-2.12T12 5.386t2.119.88t.881 2.12t-.881 2.118t-2.119.882m-7 7.23V16.97q0-.619.36-1.158q.361-.54.97-.838q1.416-.679 2.834-1.018q1.417-.34 2.836-.34t2.837.34t2.832 1.018q.61.298.97.838q.361.539.361 1.158v1.646z"/></svg>

After

Width:  |  Height:  |  Size: 493 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24"><!-- Icon from Google Material Icons by Material Design Authors - https://github.com/material-icons/material-icons/blob/master/LICENSE --><path fill="currentColor" d="M19.05 4.91A9.82 9.82 0 0 0 12.04 2c-5.46 0-9.91 4.45-9.91 9.91c0 1.75.46 3.45 1.32 4.95L2.05 22l5.25-1.38c1.45.79 3.08 1.21 4.74 1.21c5.46 0 9.91-4.45 9.91-9.91c0-2.65-1.03-5.14-2.9-7.01m-7.01 15.24c-1.48 0-2.93-.4-4.2-1.15l-.3-.18l-3.12.82l.83-3.04l-.2-.31a8.26 8.26 0 0 1-1.26-4.38c0-4.54 3.7-8.24 8.24-8.24c2.2 0 4.27.86 5.82 2.42a8.18 8.18 0 0 1 2.41 5.83c.02 4.54-3.68 8.23-8.22 8.23m4.52-6.16c-.25-.12-1.47-.72-1.69-.81c-.23-.08-.39-.12-.56.12c-.17.25-.64.81-.78.97c-.14.17-.29.19-.54.06c-.25-.12-1.05-.39-1.99-1.23c-.74-.66-1.23-1.47-1.38-1.72c-.14-.25-.02-.38.11-.51c.11-.11.25-.29.37-.43s.17-.25.25-.41c.08-.17.04-.31-.02-.43s-.56-1.34-.76-1.84c-.2-.48-.41-.42-.56-.43h-.48c-.17 0-.43.06-.66.31c-.22.25-.86.85-.86 2.07s.89 2.4 1.01 2.56c.12.17 1.75 2.67 4.23 3.74c.59.26 1.05.41 1.41.52c.59.19 1.13.16 1.56.1c.48-.07 1.47-.6 1.67-1.18c.21-.58.21-1.07.14-1.18s-.22-.16-.47-.28"/></svg>

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -0,0 +1,28 @@
---
import '@assets/css/global.css'
import Header from '@components/layout/header/Header.astro'
import Footer from '@components/layout/footer/Footer.tsx'
import SquareLines from '@components/base/SquareLines.astro'
const { title, description, canonicalLink } = Astro.props
const canonicalURL = canonicalLink ? new URL(canonicalLink, Astro.site) : new URL(Astro.url.pathname, Astro.site)
---
<!doctype html>
<html lang="ru" class="dark">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{title}</title>
<meta name="description" content={description} />
<link rel="canonical" href={canonicalURL} />
<link rel="icon" type="image/x-icon" href="/images/favicon.ico" />
</head>
<body class="antialiased bg-neutral-950">
<SquareLines />
<Header />
<slot />
<Footer client:visible />
</body>
</html>

View file

@ -0,0 +1,273 @@
---
import { Image } from 'astro:assets';
import Layout from '@layouts/Layout.astro';
import TableOfContents from '../components/blog/TableOfContents.tsx';
import { Icon } from 'astro-icon/components';
import '@assets/css/Post.css';
import RelatedPosts from '@components/blog/RelatedPosts.astro';
interface Frontmatter {
id: string;
title: string;
description: string;
publishDate: string;
tags: string[];
image?: string;
slug: string;
content?: string;
isFeatured?: boolean;
isActive: boolean;
coverImage?: string;
coverImageAlt?: string;
}
interface Props {
frontmatter: Frontmatter;
slug: string;
}
// Получаем пропсы
const { frontmatter, slug }: Props = Astro.props;
// --- 1. Логика Даты и Заголовка ---
const currentYear = new Date().getFullYear();
// Заменяем {year} на текущий год (например, "Тренды {year}" -> "Тренды 2025")
const dynamicTitle = frontmatter.title.replace('{year}', currentYear.toString());
const formattedDate = new Date(frontmatter.publishDate).toLocaleDateString('ru-RU', {
year: 'numeric',
month: 'long',
day: 'numeric',
});
// Данные для соцсетей
const postUrl = Astro.url.href;
// --- 2. Логика Изображения PocketBase ---
// Получаем URL сервера из переменных среды или фоллбэк на локальный
const PB_URL = import.meta.env.PUBLIC_POCKETBASE_URL || 'http://127.0.0.1:8090';
// Используем поле image или coverImage из поста
let imageFilename = frontmatter.image || frontmatter.coverImage;
// Если в посте нет изображения, пробуем найти первое изображение в контенте
if (!imageFilename && frontmatter.content) {
// Ищем первое изображение в HTML-контенте
const imgMatch = frontmatter.content.match(/<img[^>]+src="([^"]+)"[^>]*>/i);
if (imgMatch && imgMatch[1]) {
const imgSrc = imgMatch[1];
// Если это URL PocketBase, извлекаем имя файла
if (imgSrc.includes('/api/files/')) {
const parts = imgSrc.split('/');
imageFilename = parts[parts.length - 1];
}
}
}
// Формируем полную ссылку: URL / api / files / COLLECTION / ID / FILENAME
const heroImageSrc = imageFilename
? `${PB_URL}/api/files/posts/${frontmatter.id}/${imageFilename}`
: null;
// Alt текст: используем заголовок статьи или coverImageAlt из поста
const heroImageAlt = frontmatter.coverImageAlt || dynamicTitle;
---
<Layout
title={`${dynamicTitle} | Блог Redi`}
description={frontmatter.description}
canonicalLink={`/blog/${slug}`}
>
<!-- Стрелка возврата -->
<div class="max-w-5xl mx-auto px-4 mb-6">
<a href="/blog" class="inline-flex items-center text-neutral-600 hover:text-indigo-600 dark:text-neutral-400 dark:hover:text-indigo-400 text-sm font-medium transition-colors hover:underline hover:underline-offset-4 hover:decoration-indigo-600 dark:hover:decoration-indigo-400">
← Вернуться к блогу
</a>
</div>
{/* --- ХЕДЕР СТАТЬИ (Hero Section) --- */}
<header class="relative max-w-5xl mx-auto md:rounded-2xl overflow-hidden shadow-2xl mb-8 group" id="article-header">
{/* Фон: Картинка или заливка */}
{heroImageSrc ? (
<div class="absolute inset-0">
<Image
src={heroImageSrc}
alt={heroImageAlt}
width={1030}
height={560}
class="w-full h-full object-cover"
loading="eager"
/>
</div>
) : (
<div class="absolute inset-0 bg-neutral-800 flex items-center justify-center">
<svg class="w-16 h-16 text-neutral-600" fill="none" stroke="currentColor" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
)}
{/* Градиенты для читаемости текста */}
<div class="absolute inset-0 bg-gradient-to-b from-black/40 via-transparent to-black/70 transition-opacity duration-500 ease-in-out group-hover:opacity-0"></div>
<div class="absolute inset-0 bg-black/40 backdrop-blur-sm transition-all duration-500 ease-in-out group-hover:opacity-0 group-hover:backdrop-blur-none"></div>
{/* Заголовок по центру */}
<div class="relative z-10 flex flex-col w-full h-full text-white aspect-[1030/460] p-6 md:p-10 transition-opacity duration-500 ease-in-out group-hover:opacity-0">
<div class="flex items-center justify-center flex-grow px-4">
<h1 class="text-2xl font-bold leading-tight tracking-tight text-balance text-center md:text-5xl lg:text-6xl drop-shadow-xl">
{dynamicTitle}
</h1>
</div>
</div>
{/* --- ВЫДВИЖНАЯ ПАНЕЛЬ С МЕТА-ДАННЫМИ --- */}
<div id="meta-panel" class="absolute right-0 bottom-4 z-20 bg-black/80 backdrop-blur-xl rounded-l-2xl px-3 py-2 md:px-6 md:py-3 border border-white/10 transform translate-x-full transition-transform duration-500 shadow-2xl">
<div class="flex flex-row items-center gap-x-3 md:gap-x-4 text-xs md:text-sm">
{/* Дата */}
<div class="flex items-center text-neutral-100">
<Icon name="calendar" class="h-3.5 w-3.5 md:h-4 md:w-4 mr-1.5 md:mr-2 opacity-80" />
<span class="font-medium whitespace-nowrap">{formattedDate}</span>
</div>
{/* Автор */}
<div class="flex items-center text-neutral-100">
<Icon name="user" class="h-3.5 w-3.5 md:h-4 md:w-4 mr-1.5 md:mr-2 opacity-80" />
<span class="whitespace-nowrap">RediBedi</span>
</div>
<div class="border-l border-white/20 h-5 md:h-6"></div>
{/* Поделиться */}
<div class="flex items-center gap-x-2 md:gap-x-3">
<span class="text-neutral-300 whitespace-nowrap hidden sm:inline">Поделиться:</span>
<div class="flex gap-x-1.5 md:gap-x-2">
<a
href={`https://twitter.com/intent/tweet?url=${encodeURIComponent(postUrl)}&text=${encodeURIComponent(dynamicTitle)}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Twitter"
class="social-share-btn hover:bg-blue-500"
>
<Icon name="twitter" class="h-3 w-3 md:h-3.5 md:w-3.5" />
</a>
<a
href={`https://www.linkedin.com/shareArticle?mini=true&url=${encodeURIComponent(postUrl)}&title=${encodeURIComponent(dynamicTitle)}`}
target="_blank"
rel="noopener noreferrer"
aria-label="LinkedIn"
class="social-share-btn hover:bg-blue-600"
>
<Icon name="linkedin" class="h-3 w-3 md:h-3.5 md:w-3.5" />
</a>
<a
href={`https://www.facebook.com/sharer/sharer.php?u=${encodeURIComponent(postUrl)}`}
target="_blank"
rel="noopener noreferrer"
aria-label="Facebook"
class="social-share-btn hover:bg-blue-700"
>
<Icon name="facebook" class="h-3 w-3 md:h-3.5 md:w-3.5" />
</a>
</div>
</div>
</div>
</div>
{/* Кнопка открытия панели */}
<button id="toggle-meta" aria-label="Показать информацию" class="absolute bottom-4 right-4 z-30 w-8 h-8 flex items-center justify-center rounded-full bg-black/60 text-white backdrop-blur-md border border-white/20 hover:bg-black/90 transition-all duration-300">
<Icon name="chevron-left" class="h-4 w-4 transition-transform duration-300" id="toggle-arrow" />
</button>
</header>
{/* --- ОСНОВНОЙ КОНТЕНТ --- */}
<main class="max-w-5xl px-4 py-1 mx-auto">
<TableOfContents client:load />
<article class="w-full max-w-5xl mx-auto mb-20 prose prose-sm md:prose-lg dark:prose-invert prose-headings:font-bold prose-a:text-blue-600 dark:prose-a:text-blue-400 hover:prose-a:text-blue-700 dark:hover:prose-a:text-blue-300 prose-img:rounded-2xl prose-img:shadow-lg">
<slot />
</article>
{/* --- ТЕГИ --- */}
{frontmatter.tags && frontmatter.tags.length > 0 && (
<div class="max-w-5xl mx-auto mt-16 mb-16">
<div class="border-t border-dashed border-neutral-300 dark:border-neutral-700"></div>
<div class="mt-8">
<h2 class="text-xl md:text-2xl font-bold mb-6 text-neutral-800 dark:text-neutral-200">Теги статьи</h2>
<div class="flex flex-wrap items-center gap-3">
{frontmatter.tags.map((tag) => (
<a href={`/blog/tags/${tag.toLowerCase()}`} class="rounded-full bg-sky-100 px-4 py-1.5 text-xs font-medium text-sky-700 dark:bg-sky-900/50 dark:text-sky-200 hover:bg-sky-200 dark:hover:bg-sky-800 transition-colors duration-200">
{tag}
</a>
))}
</div>
</div>
</div>
)}
{/* --- ПОХОЖИЕ СТАТЬИ --- */}
<RelatedPosts currentTags={frontmatter.tags} currentSlug={slug} />
</main>
</Layout>
<style>
#meta-panel {
box-shadow: -5px 0 20px rgba(0, 0, 0, 0.4);
}
h1 {
text-shadow: 0 4px 20px rgba(0, 0, 0, 0.8);
}
.social-share-btn {
@apply flex items-center justify-center w-6 h-6 md:w-7 md:h-7 rounded-full bg-white/10 text-neutral-200 transition-all duration-200;
}
</style>
<script>
// Логика для кнопки "i" (показать мета-данные)
document.addEventListener('DOMContentLoaded', () => {
const metaPanel = document.getElementById('meta-panel');
const toggleButton = document.getElementById('toggle-meta');
const toggleArrow = document.getElementById('toggle-arrow');
if (!metaPanel || !toggleButton || !toggleArrow) return;
let isMetaVisible = false;
let autoHideTimeout: number | null = null;
const showMetaPanel = () => {
metaPanel.style.transform = 'translateX(0)';
toggleArrow.style.transform = 'rotate(180deg)';
isMetaVisible = true;
toggleButton.style.opacity = '0';
toggleButton.style.pointerEvents = 'none';
if (autoHideTimeout) clearTimeout(autoHideTimeout);
autoHideTimeout = window.setTimeout(hideMetaPanel, 5000);
};
const hideMetaPanel = () => {
metaPanel.style.transform = 'translateX(100%)';
toggleArrow.style.transform = 'rotate(0deg)';
isMetaVisible = false;
toggleButton.style.opacity = '1';
toggleButton.style.pointerEvents = 'auto';
};
toggleButton.addEventListener('click', () => {
isMetaVisible ? hideMetaPanel() : showMetaPanel();
});
metaPanel.addEventListener('mouseenter', () => {
if (autoHideTimeout) clearTimeout(autoHideTimeout);
});
metaPanel.addEventListener('mouseleave', () => {
if (isMetaVisible) autoHideTimeout = window.setTimeout(hideMetaPanel, 5000);
});
});
</script>

View file

@ -0,0 +1,10 @@
import PocketBase from 'pocketbase';
export const pb = new PocketBase(import.meta.env.PUBLIC_POCKETBASE_URL);
// Это отключает авто-отмену глобально для всех запросов
pb.autoCancellation = false;
export function getImageUrl(record, filename) {
return `${import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/${record.collectionId}/${record.id}/${filename}`;
}

View file

@ -0,0 +1,30 @@
/**
* Функция для обработки HTML-контента
* 1. Удаляет пустые заголовки (которые создают лишние цифры в нумерации)
* 2. Добавляет атрибут alt к изображениям, если его нет
*/
export function processHtmlContent(htmlContent: string): string {
if (!htmlContent) return '';
let processed = htmlContent;
// --- ШАГ 1: Удаляем пустые заголовки ---
// Проблема: Редактор создает <h2>&nbsp;</h2> при нажатии Enter. CSS считает это за пункт и ставит цифру.
// Решение: Удаляем все h1-h6, внутри которых только пробелы, &nbsp; или <br>
processed = processed.replace(/<h[1-6][^>]*>(?:\s|&nbsp;|<br\/?>)*<\/h[1-6]>/gi, '');
// --- ШАГ 2 (Опционально): Удаляем ручную нумерацию из текста ---
// Если у вас в CSS настроена авто-нумерация, а в тексте вы пишете "3. Заголовок",
// то на сайте будет "3. 3. Заголовок".
// Раскомментируйте строку ниже, чтобы убрать цифры из текста и оставить только CSS:
// processed = processed.replace(/(<h[2-6][^>]*>)\s*\d+(\.\d+)*\.?\s*/gi, '$1');
// --- ШАГ 3: Обработка изображений (ваш старый код) ---
// Ищем теги <img> без атрибута alt и добавляем пустой alt
const imgWithoutAltRegex = /<img(?![^>]*\balt\b)([^>]*)>/gi;
processed = processed.replace(imgWithoutAltRegex, '<img$1 alt="">');
return processed;
}

View file

@ -0,0 +1,46 @@
---
import Layout from '@layouts/Layout.astro'
import AboutHero from '@components/about/AboutHero.astro'
import Contacts from '@components/about/ContactCTA.astro'
import { pb } from '@lib/pocketbase';
import type { AboutData } from '@globalInterfaces';
const title = 'Обо мне | Redi - Веб-разработчик'
const description = 'Узнайте больше обо мне, Redi - веб-разработчике. Мой профессиональный опыт, биография и увлечение современными веб-технологиями.'
// Получаем данные из коллекции about
let aboutData: AboutData | null = null;
try {
const response = await pb.collection('about').getFirstListItem('isActive = true');
aboutData = {
id: response.id,
collectionId: response.collectionId,
title: response.title,
description: response.description,
professional_experience: response.prof_exp,
skills: response.skills || [],
contact_title: response.contact_title,
contact_description: response.contact_description,
whatsapp_link: response.whatsapp_link,
email: response.email,
image: response.image,
alt_text: response.alt_text,
isActive: response.isActive,
};
} catch (error) {
console.error('Ошибка при получении данных из коллекции about:', error);
// В случае ошибки, можно использовать заглушку или перенаправить
aboutData = null;
}
---
<Layout title={title} description={description} canonicalLink="/about">
<main class="max-w-3xl mx-auto my-12 px-4 sm:px-6 lg:px-0 space-y-16">
{aboutData && (
<>
<AboutHero aboutData={aboutData} />
<Contacts aboutData={aboutData} />
</>
)}
</main>
</Layout>

View file

@ -0,0 +1,55 @@
// src/pages/api/search.json.ts
import type { APIRoute } from "astro";
import { pb } from '@lib/pocketbase';
export const GET: APIRoute = async ({ url }): Promise<Response> => {
try {
const query = url.searchParams.get('q')?.trim() || '';
if (!query) {
return new Response(JSON.stringify([]), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// 1. САНИТИЗАЦИЯ: Экранируем кавычки, чтобы запрос не сломал синтаксис фильтра PB
// Если пользователь введет: React "Hero", мы превратим это в: React \"Hero\"
const safeQuery = query.replace(/"/g, '\\"');
// 2. СБОРКА ФИЛЬТРА: Собираем строку вручную (это самый надежный способ)
// Мы ищем совпадения в заголовке, описании ИЛИ контенте
const filterString = `isActive = true && (title ~ "${safeQuery}" || description ~ "${safeQuery}" || content ~ "${safeQuery}")`;
const result = await pb.collection('posts').getList(1, 15, {
filter: filterString,
sort: '-publishDate',
// Запрашиваем только нужные поля (без content, чтобы не грузить сеть)
fields: 'id,title,description,slug',
});
const searchData = result.items.map((post) => ({
id: post.id,
title: post.title,
description: post.description,
slug: post.slug,
}));
return new Response(JSON.stringify(searchData), {
status: 200,
headers: {
"Content-Type": "application/json",
"Cache-Control": "public, max-age=60"
},
});
} catch (error) {
// Логируем ошибку подробно, чтобы видеть причину в консоли
console.error('Search API error:', error);
return new Response(JSON.stringify({ error: 'Internal server error' }), {
status: 500,
headers: { 'Content-Type': 'application/json' },
});
}
};

View file

@ -0,0 +1,34 @@
---
import PostLayout from '@layouts/LayoutPost.astro';
import { pb } from '@lib/pocketbase';
import { processHtmlContent } from '@lib/processHtmlContent';
import type { Post } from '@globalInterfaces';
// Страницы постов генерируются при запросе (не при сборке)
export const prerender = false;
const { slug } = Astro.params;
// Получаем пост при запросе страницы
const post: Post = await pb.collection('posts').getFirstListItem(`slug="${slug}"`);
// Обработка HTML-контента для добавления атрибутов alt к изображениям
const processedContent = processHtmlContent(post.content || '');
---
<!-- Передаем данные в лейаут. Важно: content передаем как слот или пропс, зависит от LayoutPost -->
<PostLayout frontmatter={{
id: post.id,
title: post.title,
description: post.description,
publishDate: post.publishDate,
tags: post.tags,
image: post.image || post.coverImage,
slug: post.slug,
content: post.content,
isFeatured: post.isFeatured,
isActive: post.isActive
}} slug={post.slug}>
<!-- Вывод обработанного HTML контента из PocketBase -->
<div set:html={processedContent} />
</PostLayout>

View file

@ -0,0 +1,106 @@
---
import Layout from '@layouts/Layout.astro';
import PageHeading from '@components/base/PageHeading.astro';
import PostsLoop from '@components/blog/PostsLoop.astro';
import Pagination from '@components/base/Pagination.tsx';
import FeaturedPostCard from '@components/blog/FeaturedPost.astro';
import { pb } from '@lib/pocketbase';
import type { Post } from '@globalInterfaces';
const title = 'Блог Redi | Статьи о веб-разработке';
const description = 'Читайте статьи о веб-разработке...';
const page = Number(Astro.url.searchParams.get('page')) || 1;
const perPage = 4;
// 2. ЯВНО УКАЗЫВАЕМ ТИП ПЕРЕМЕННОЙ
// Переменная может быть или Post, или null
let featuredPost: Post | null = null;
let excludedId = '';
// --- ШАГ 1: Ищем ОДИН избранный пост (только для 1-й страницы) ---
if (page === 1) {
try {
const rawPost = await pb.collection('posts').getFirstListItem('isActive = true && isFeatured = true', {
sort: '-publishDate',
});
excludedId = rawPost.id;
// Превращаем ответ PocketBase в наш тип Post
featuredPost = {
id: rawPost.id,
title: rawPost.title,
slug: rawPost.slug,
description: rawPost.description,
publishDate: rawPost.publishDate,
tags: rawPost.tags || [],
content: rawPost.content,
image: rawPost.image || rawPost.coverImage,
isFeatured: rawPost.isFeatured,
isActive: rawPost.isActive
};
} catch (e) {
featuredPost = null;
}
}
// --- ШАГ 2: Основной список (исключая избранный) ---
const mainListFilter = `isActive = true ${excludedId ? `&& id != "${excludedId}"` : ''}`;
const result = await pb.collection('posts').getList(page, perPage, {
filter: mainListFilter,
sort: '-isFeatured,-publishDate',
requestKey: 'blog_list'
});
// Маппинг для списка
const posts = result.items.map(post => ({
id: post.id,
title: post.title,
slug: post.slug,
description: post.description,
publishDate: post.publishDate,
tags: post.tags || [],
content: post.content,
image: post.image || post.coverImage,
isFeatured: post.isFeatured,
isActive: post.isActive
}));
// Данные для пагинации
const paginationData = {
currentPage: result.page,
lastPage: result.totalPages,
url: {
prev: result.page > 1 ? `/blog?page=${result.page - 1}` : undefined,
next: result.page < result.totalPages ? `/blog?page=${result.page + 1}` : undefined,
},
};
---
<Layout title={title} description={description} canonicalLink="/blog">
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Статьи о веб-разработке"
description="Коллекция постов о веб разработке и современных технологиях."
/>
{/* ИЗБРАННЫЙ ПОСТ (Если найден) */}
{featuredPost && (
<div class="mb-10 mt-8">
<FeaturedPostCard post={featuredPost} />
</div>
)}
{/* ОСНОВНОЙ СПИСОК */}
<div class="z-50 flex flex-col items-stretch w-full gap-5 my-8">
<PostsLoop posts={posts} />
</div>
{result.totalPages > 1 && (
<Pagination client:load page={paginationData} />
)}
</section>
</Layout>

View file

@ -0,0 +1,72 @@
---
import Layout from '@layouts/Layout.astro';
import PageHeading from '@components/base/PageHeading.astro';
import PostsLoop from '@components/blog/PostsLoop.astro';
import { pb } from '@lib/pocketbase';
import type { Post } from '@globalInterfaces';
// Страницы тегов генерируются при запросе (не при сборке)
export const prerender = false;
const { tag } = Astro.params;
// Получаем посты для этого тега при запросе страницы
let posts: Post[] = [];
let displayTag = tag ?? '';
try {
const allPosts: Post[] = await pb.collection('posts').getFullList({
filter: 'isActive = true',
sort: '-publishDate',
});
const tagToPostsMap = new Map<string, { displayTag: string; posts: Post[] }>();
for (const post of allPosts) {
if (!post.tags || post.tags.length === 0) continue;
for (const originalTag of post.tags) {
const lowerCaseTag = originalTag.toLowerCase();
if (!tagToPostsMap.has(lowerCaseTag)) {
tagToPostsMap.set(lowerCaseTag, {
displayTag: originalTag,
posts: []
});
}
tagToPostsMap.get(lowerCaseTag)!.posts.push(post);
}
}
const tagData = tagToPostsMap.get(tag ?? '');
if (tagData) {
posts = tagData.posts;
displayTag = tagData.displayTag;
}
} catch (error) {
console.error('Ошибка при получении постов для тега:', error);
}
const title = `Статьи по тегу: ${displayTag} | Блог Redi`;
const description = `Все статьи и материалы по тегу '${displayTag}' в блоге Redi.`;
---
<Layout title={title} description={description} canonicalLink={`/blog/tags/${tag}`}>
<section class="relative z-20 max-w-2xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title={`Тег: #${displayTag}`}
description={`Найдено ${posts.length} статей по теме "${displayTag}".`}
/>
<div class="z-50 flex flex-col items-stretch w-full gap-5 my-8">
<PostsLoop posts={posts} />
</div>
<div class="flex justify-center mt-8">
<a href="/blog" class="text-sm font-medium text-neutral-600 hover:text-neutral-900 dark:text-neutral-400 dark:hover:text-neutral-100 underline underline-offset-4 decoration-neutral-300 transition-colors">
← Вернуться ко всем статьям
</a>
</div>
</section>
</Layout>

View file

@ -0,0 +1,110 @@
---
import Layout from '@layouts/Layout.astro'
import type { Course } from '@globalInterfaces';
import { decodeHtmlEntities } from '@globalInterfaces';
import { pb } from '@lib/pocketbase';
import { processHtmlContent } from '@lib/processHtmlContent';
const { slug } = Astro.params
// Получаем курс из PocketBase по slug
let courseData: Course | null = null;
let title = 'Курс не найден | Redi - Веб-разработчик';
let description = 'Запрашиваемый курс не найден';
try {
const record = await pb.collection('courses').getFirstListItem(`slug = "${slug}"`, {
requestKey: `course_${slug}`
});
// Преобразуем данные в нужный формат
courseData = {
id: record.id,
title: record.title,
slug: record.slug,
description: record.description,
price: record.price,
duration: record.duration,
level: record.levels,
tags: record.tags || [],
thumbnail: record.thumbnail || '',
content: processHtmlContent(decodeHtmlEntities(record.content || '')) // Декодируем HTML-сущности и обрабатываем контент
};
title = `${courseData.title} | Redi - Веб-разработчик`;
description = courseData.description;
} catch (error) {
console.error('Ошибка при получении курса:', error);
}
---
<Layout title={title} description={description} canonicalLink={`/courses/${slug}`}>
{courseData ? (
<main class="max-w-5xl mx-auto px-4 py-1 space-y-8">
<div>
{/* Стрелка возврата */}
<div class="mb-6">
<a href="/courses" class="inline-flex items-center text-neutral-600 hover:text-indigo-600 dark:text-neutral-400 dark:hover:text-indigo-400 text-sm font-medium transition-colors hover:underline hover:underline-offset-4 hover:decoration-indigo-600 dark:hover:decoration-indigo-400">
← Вернуться к курсам
</a>
</div>
{/* Изображение курса */}
{courseData.thumbnail && (
<div class="mb-8">
<img
src={`${import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/courses/${courseData.id}/${courseData.thumbnail}`}
alt={courseData.title}
class="w-full h-auto rounded-2xl shadow-lg object-cover max-w-5xl mx-auto"
/>
</div>
)}
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 gap-4">
<h1 class="text-4xl font-bold text-neutral-900 dark:text-neutral-100">{courseData.title}</h1>
<div class="flex items-center gap-4">
<span class="text-3xl font-bold text-indigo-600 dark:text-indigo-400">{courseData.price} ₽</span>
<button class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-3 px-6 rounded-lg transition-colors duration-300">
Купить курс
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-4 mb-8">
<span class="font-medium uppercase tracking-wide text-xs text-neutral-500 bg-neutral-200 dark:bg-neutral-800 px-2 py-1 rounded">
{courseData.level}
</span>
<div class="flex items-center gap-2 text-sm text-neutral-500">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" class="w-4 h-4 text-neutral-400">
<path fill-rule="evenodd" d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zM12.75 6a.75.75 0 00-1.5 0v6c0 .414.336.75.75.75h4.5a.75.75 0 000-1.5h-3.75V6z" clip-rule="evenodd" />
</svg>
<span class="font-medium">{courseData.duration}</span>
</div>
</div>
<div class="prose prose-neutral dark:prose-invert max-w-none mb-12 bg-neutral-50 dark:bg-neutral-900/30 p-6 rounded-xl">
<div set:html={courseData.content} />
</div>
{courseData.tags && courseData.tags.length > 0 && (
<div class="pt-4 border-t border-neutral-200 dark:border-neutral-800 flex flex-wrap gap-2">
{courseData.tags.map((tag) => (
<span class="text-sm text-neutral-500 bg-white dark:bg-neutral-950 px-3 py-1 rounded border border-neutral-200 dark:border-neutral-800">
#{tag}
</span>
))}
</div>
)}
</div>
</main>
) : (
<main class="max-w-5xl mx-auto px-4 py-1 space-y-8">
<div class="text-center">
<h1 class="text-4xl font-bold text-neutral-900 dark:text-neutral-100 mb-4">Курс не найден</h1>
<p class="text-xl text-neutral-600 dark:text-neutral-400">
К сожалению, запрашиваемый курс не существует
</p>
</div>
</main>
)}
</Layout>

View file

@ -0,0 +1,60 @@
---
import Layout from '@layouts/Layout.astro'
import CourseCard from '@components/courses/CourseCard.astro'
import type { Course } from '@globalInterfaces';
import { pb } from '@lib/pocketbase';
const title = 'Курсы программирования | Redi - Веб-разработчик'
const description = 'Купить курсы по программированию'
// Получаем курсы из PocketBase
let courses: Course[] = [];
try {
const records = await pb.collection('courses').getFullList({
filter: 'isActive = true',
sort: 'order',
requestKey: 'courses_list'
});
courses = records.map(record => ({
id: record.id,
title: record.title,
slug: record.slug,
description: record.description,
price: record.price,
duration: record.duration,
level: record.levels,
tags: record.tags || [],
thumbnail: record.thumbnail || '',
content: record.content || ''
}));
} catch (error) {
console.error('Ошибка при получении курсов:', error);
courses = []; // В случае ошибки возвращаем пустой массив
}
---
<Layout title={title} description={description} canonicalLink="/courses">
<main class="max-w-7xl mx-auto my-12 px-4 sm:px-6 lg:px-0 space-y-16">
<div class="text-center">
<h1 class="text-4xl font-bold text-neutral-900 dark:text-neutral-100 mb-4">Курсы программирования</h1>
<p class="text-xl text-neutral-600 dark:text-neutral-400 max-w-3xl mx-auto">
Выберите подходящий курс и начните путь в веб-разработку уже сегодня
</p>
</div>
{courses.length > 0 ? (
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-2 gap-8">
{courses.map(course => (
<CourseCard course={course} />
))}
</div>
) : (
<div class="text-center py-12">
<p class="text-xl text-neutral-600 dark:text-neutral-400">
Курсы в настоящее время недоступны
</p>
</div>
)}
</main>
</Layout>

View file

@ -0,0 +1,73 @@
---
import Layout from '@layouts/Layout.astro'
import Hero from '@components/home/Hero.astro'
import FeaturedPostCard from '@components/blog/FeaturedPost.astro';
import Separator from '@components/home/Separator.astro'
import { pb } from '@lib/pocketbase';
const title = 'Redi: Портфолио веб-разработчика | Проекты и статьи'
const description =
'Портфолио веб-разработчика Redi. Изучите мои проекты, читайте статьи о современных веб-технологиях и свяжитесь со мной для сотрудничества.'
// Получаем избранный пост
import type { Post } from '@globalInterfaces';
let featuredPost: Post | null = null;
try {
const rawPost = await pb.collection('posts').getFirstListItem('isActive = true && isFeatured = true', {
sort: '-publishDate',
});
featuredPost = {
id: rawPost.id,
title: rawPost.title,
slug: rawPost.slug,
description: rawPost.description,
publishDate: rawPost.publishDate,
tags: rawPost.tags || [],
content: rawPost.content,
image: rawPost.image || rawPost.coverImage,
isFeatured: rawPost.isFeatured,
isActive: rawPost.isActive
};
} catch (error) {
console.log('Нет доступных избранных постов');
}
---
<Layout title={title} description={description} canonicalLink="/">
<!-- Основной контейнер для всего контента страницы -->
<div class="relative z-20 mx-auto mt-16 w-full max-w-4xl px-7 md:mt-24 lg:mt-32 xl:px-0">
<!-- Hero-секция: двухколоночный макет на десктопе -->
<Hero />
</div>
<Separator text="Check out my projects" />
{featuredPost && (
<div class="max-w-2xl mx-auto px-7 lg:px-0 mb-16">
<FeaturedPostCard post={featuredPost} />
</div>
)}
</Layout>
<script>
document.addEventListener('DOMContentLoaded', () => {
const animatedElements = document.querySelectorAll('.animate-on-scroll');
if (animatedElements.length > 0) {
const observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('is-visible');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.1 });
animatedElements.forEach(element => {
observer.observe(element);
});
}
});
</script>

View file

@ -0,0 +1,69 @@
---
// Страницы проектов генерируются при запросе (не при сборке)
export const prerender = false;
import type { Page } from 'astro';
import PageHeading from '@components/base/PageHeading.astro';
import ProjectGrid from '@components/projects/ProjectGrid.astro';
import Layout from '@layouts/Layout.astro';
import Pagination from '@components/base/Pagination.tsx';
import { pb } from '@lib/pocketbase';
import type { Project } from '@globalInterfaces';
// Получаем номер страницы из параметров
const { page: pageNumber } = Astro.params;
const currentPage = Number(pageNumber) || 1;
const perPage = 6;
// Получаем проекты из PocketBase для конкретной страницы
const result = await pb.collection('projects').getList(currentPage, perPage, {
sort: '-order,-created',
requestKey: 'projects_list'
});
// Маппинг для списка
const projects = result.items.map((item) => ({
id: item.id,
collectionId: item.collectionId,
title: item.name,
description: item.description,
short_description: item.short_description,
long_description: item.long_description,
stack: item.stack || [],
github_link: item.github,
demo_link: item.url_site,
image: `${import.meta.env.PUBLIC_POCKETBASE_URL}/api/files/projects/${item.id}/${item.image}`,
alt_text: item.alt_text,
order: item.order || 0,
isActive: item.isActive
}));
// Создаем объект page вручную
const page: Page<Project> = {
data: projects,
firstItem: result.page > 1 ? (result.page - 1) * perPage + 1 : 1,
lastItem: Math.min(result.page * perPage, result.totalItems),
totalPages: result.totalPages,
currentPage: result.page,
lastPage: result.totalPages,
url: {
prev: result.page > 1 ? (result.page > 2 ? `/projects/${result.page - 1}` : '/projects') : undefined,
next: result.page < result.totalPages ? `/projects/${result.page + 1}` : undefined,
}
};
const title = `Портфолио проектов (Страница ${page.currentPage}) | Redi`;
const description = `Страница ${page.currentPage} из ${page.totalPages} с проектами в портфолио веб-разработчика Redi. Примеры моих работ и кейсов.`;
---
<Layout title={title} description={description} canonicalLink={`/projects/${currentPage}`}>
<section class="relative z-20 max-w-4xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Портфолио моих лучших веб-проектов и работ"
description="Здесь собраны все проекты, над которыми я работал."
/>
<ProjectGrid projects={page.data} />
<Pagination page={page} client:load />
</section>
</Layout>

Some files were not shown because too many files have changed in this diff Show more