first commit
25
.gitignore
vendored
Normal 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
|
|
@ -0,0 +1,5 @@
|
||||||
|
# Default ignored files
|
||||||
|
/shelf/
|
||||||
|
/workspace.xml
|
||||||
|
# Editor-based HTTP Client requests
|
||||||
|
/httpRequests/
|
||||||
13
.idea/astro_redi.iml
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
18.17.0
|
||||||
61
QWEN.md
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -0,0 +1 @@
|
||||||
|
20
|
||||||
163
frontend/README.md
Normal 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
|
|
@ -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
39
frontend/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
frontend/public/images/about.avif
Normal file
BIN
frontend/public/images/courses/adaptive.jpg
Normal file
|
After Width: | Height: | Size: 139 KiB |
BIN
frontend/public/images/courses/basic_js.jpg
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
frontend/public/images/courses/nodejs.jpg
Normal file
|
After Width: | Height: | Size: 232 KiB |
BIN
frontend/public/images/courses/react_beginner.jpg
Normal file
|
After Width: | Height: | Size: 205 KiB |
BIN
frontend/public/images/experiences/fta.ico
Normal file
|
After Width: | Height: | Size: 9.4 KiB |
BIN
frontend/public/images/experiences/wulian.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/images/experiences/yoho.ico
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
BIN
frontend/public/images/favicon.ico
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
frontend/public/images/hero/redibedi.png
Normal file
|
After Width: | Height: | Size: 422 KiB |
BIN
frontend/public/images/logo.avif
Normal file
BIN
frontend/public/images/logo.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
frontend/public/images/posts/2025/08/astro-adv.avif
Normal file
BIN
frontend/public/images/posts/2025/08/astro-blog.avif
Normal file
BIN
frontend/public/images/posts/2025/08/astro-payloadcms.avif
Normal file
BIN
frontend/public/images/posts/2025/08/astro.avif
Normal file
BIN
frontend/public/images/posts/2025/08/astro_payload_guide.avif
Normal file
BIN
frontend/public/images/posts/2025/08/pour-over.avif
Normal file
BIN
frontend/public/images/posts/2025/09/pour-over.avif
Normal file
BIN
frontend/public/images/posts/2025/09/solidjs-vs-react.avif
Normal file
BIN
frontend/public/images/preview.jpg
Normal file
|
After Width: | Height: | Size: 90 KiB |
BIN
frontend/public/images/projects/adv-surgut.avif
Normal file
BIN
frontend/public/images/projects/algoexit.avif
Normal file
BIN
frontend/public/images/projects/auto-lawer.avif
Normal file
BIN
frontend/public/images/projects/bexer.avif
Normal file
BIN
frontend/public/images/projects/djag.avif
Normal file
BIN
frontend/public/images/projects/min-ber.avif
Normal file
BIN
frontend/public/images/projects/mot-phu.avif
Normal file
BIN
frontend/public/images/projects/tech-news.avif
Normal file
2
frontend/public/robots.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
127
frontend/src/assets/css/Post.css
Normal 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;
|
||||||
|
}
|
||||||
122
frontend/src/assets/css/global.css
Normal 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;
|
||||||
|
}
|
||||||
297
frontend/src/components/about/AboutHero.astro
Normal 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>
|
||||||
76
frontend/src/components/about/ContactCTA.astro
Normal 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>
|
||||||
10
frontend/src/components/base/Button.astro
Normal 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>
|
||||||
16
frontend/src/components/base/PageHeading.astro
Normal 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>
|
||||||
57
frontend/src/components/base/Pagination.tsx
Normal 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;
|
||||||
2
frontend/src/components/base/Square.astro
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
<div class="w-full h-auto bg-white dark:bg-neutral-950 aspect-square {classes}">
|
||||||
|
</div>
|
||||||
17
frontend/src/components/base/SquareLine.astro
Normal 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>
|
||||||
43
frontend/src/components/base/SquareLines.astro
Normal 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>
|
||||||
21
frontend/src/components/base/WhatsAppButton.astro
Normal 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>
|
||||||
87
frontend/src/components/blog/FeaturedPost.astro
Normal 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>
|
||||||
92
frontend/src/components/blog/PostsLoop.astro
Normal 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>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
71
frontend/src/components/blog/RelatedPosts.astro
Normal 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>
|
||||||
|
)}
|
||||||
124
frontend/src/components/blog/TableOfContents.tsx
Normal 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;
|
||||||
94
frontend/src/components/courses/CourseCard.astro
Normal 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>
|
||||||
88
frontend/src/components/home/Hero.astro
Normal 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>
|
||||||
70
frontend/src/components/home/HeroImage.astro
Normal 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>
|
||||||
37
frontend/src/components/home/Separator.astro
Normal 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>
|
||||||
72
frontend/src/components/home/TechStack.astro
Normal 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>
|
||||||
94
frontend/src/components/home/Writings.astro
Normal 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>
|
||||||
23
frontend/src/components/layout/footer/Footer.tsx
Normal 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">
|
||||||
|
© 2024 - {currentYear} redi.dev - Все права защищены.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Footer;
|
||||||
70
frontend/src/components/layout/header/Header.astro
Normal 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>
|
||||||
20
frontend/src/components/layout/header/Logo.astro
Normal 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>
|
||||||
176
frontend/src/components/layout/header/MobileMenu.astro
Normal 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>
|
||||||
58
frontend/src/components/layout/header/Navbar.astro
Normal 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>
|
||||||
180
frontend/src/components/layout/header/Search.astro
Normal 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>
|
||||||
111
frontend/src/components/projects/FeaturedProject.astro
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
frontend/src/components/projects/Project.astro
Normal 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>
|
||||||
22
frontend/src/components/projects/ProjectGrid.astro
Normal 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>
|
||||||
3
frontend/src/components/projects/dataProjects.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
// Заглушка для совместимости - теперь данные приходят из PocketBase
|
||||||
|
// Экспортируем пустой массив для совместимости с существующим кодом
|
||||||
|
export const allProjects = [];
|
||||||
2
frontend/src/env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
/// <reference path="../.astro/types.d.ts" />
|
||||||
|
/// <reference types="astro/client" />
|
||||||
90
frontend/src/globalInterfaces.ts
Normal 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(/</g, '<').replace(/>/g, '>').replace(/&/g, '&');
|
||||||
|
}
|
||||||
1
frontend/src/icons/calendar.svg
Normal 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 |
1
frontend/src/icons/chevron-left.svg
Normal 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 |
1
frontend/src/icons/envelope.svg
Normal 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 |
1
frontend/src/icons/facebook.svg
Normal 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 |
1
frontend/src/icons/linkedin.svg
Normal 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 |
1
frontend/src/icons/twitter.svg
Normal 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 |
1
frontend/src/icons/user.svg
Normal 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 |
1
frontend/src/icons/whatsapp.svg
Normal 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 |
28
frontend/src/layouts/Layout.astro
Normal 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>
|
||||||
273
frontend/src/layouts/LayoutPost.astro
Normal 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>
|
||||||
10
frontend/src/lib/pocketbase.js
Normal 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}`;
|
||||||
|
}
|
||||||
30
frontend/src/lib/processHtmlContent.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/**
|
||||||
|
* Функция для обработки HTML-контента
|
||||||
|
* 1. Удаляет пустые заголовки (которые создают лишние цифры в нумерации)
|
||||||
|
* 2. Добавляет атрибут alt к изображениям, если его нет
|
||||||
|
*/
|
||||||
|
export function processHtmlContent(htmlContent: string): string {
|
||||||
|
if (!htmlContent) return '';
|
||||||
|
|
||||||
|
let processed = htmlContent;
|
||||||
|
|
||||||
|
// --- ШАГ 1: Удаляем пустые заголовки ---
|
||||||
|
// Проблема: Редактор создает <h2> </h2> при нажатии Enter. CSS считает это за пункт и ставит цифру.
|
||||||
|
// Решение: Удаляем все h1-h6, внутри которых только пробелы, или <br>
|
||||||
|
processed = processed.replace(/<h[1-6][^>]*>(?:\s| |<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;
|
||||||
|
}
|
||||||
46
frontend/src/pages/about.astro
Normal 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>
|
||||||
55
frontend/src/pages/api/search.json.ts
Normal 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' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
34
frontend/src/pages/blog/[slug].astro
Normal 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>
|
||||||
106
frontend/src/pages/blog/index.astro
Normal 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>
|
||||||
72
frontend/src/pages/blog/tags/[tag].astro
Normal 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>
|
||||||
110
frontend/src/pages/courses/[slug].astro
Normal 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>
|
||||||
60
frontend/src/pages/courses/index.astro
Normal 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>
|
||||||
73
frontend/src/pages/index.astro
Normal 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>
|
||||||
69
frontend/src/pages/projects/[page].astro
Normal 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>
|
||||||