first commit

This commit is contained in:
Web-serfer 2026-03-28 16:51:42 +05:00
commit 7c46ee6909
107 changed files with 5563 additions and 0 deletions

12
frontend/.editorconfig Normal file
View file

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

26
frontend/.gitignore vendored Normal file
View file

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

1
frontend/.nvmrc Normal file
View file

@ -0,0 +1 @@
20

26
frontend/Dockerfile Normal file
View file

@ -0,0 +1,26 @@
# Используем официальный образ Bun через зеркало
FROM dockerhub.timeweb.cloud/oven/bun:alpine AS build
WORKDIR /app
# Копируем файлы зависимостей
COPY package.json bun.lockb* ./
RUN bun install
# Копируем проект и собираем
COPY . .
RUN bun run build
# Финальный образ
FROM dockerhub.timeweb.cloud/oven/bun:alpine
WORKDIR /app
# Копируем билд из предыдущего этапа
COPY --from=build /app/dist ./dist
COPY --from=build /app/package.json ./package.json
# Указываем порт
EXPOSE 4321
# Запуск через Bun
CMD ["bun", "./dist/server/entry.mjs"]

163
frontend/README.md Normal file
View file

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

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

@ -0,0 +1,27 @@
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",
content: {
collections: {}
},
vite: {
build: {
// Встраивать CSS размером до 15 КБ
cssInlineLimit: 15000
}
},
adapter: node({
mode: "standalone",
}),
});

1299
frontend/bun.lock Normal file

File diff suppressed because it is too large Load diff

39
frontend/package.json Normal file
View file

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

After

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

View file

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

After

Width:  |  Height:  |  Size: 327 B

View file

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

After

Width:  |  Height:  |  Size: 364 B

View file

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

After

Width:  |  Height:  |  Size: 419 B

View file

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

After

Width:  |  Height:  |  Size: 593 B

View file

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

After

Width:  |  Height:  |  Size: 719 B

View file

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

After

Width:  |  Height:  |  Size: 493 B

View file

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

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

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

View file

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

View file

@ -0,0 +1,20 @@
import PocketBase from 'pocketbase';
// Определяем, где мы запускаемся
const isServer = typeof window === 'undefined';
const isDocker = isServer && process.env.COOLIFY_CONTAINER_NAME !== undefined;
// Для сервера в Docker используем внутренний URL, для клиента - публичный
const pbUrl = isDocker
? 'http://pocketbase-f477mqb7lrkphmff7fdyloeh:8080' // Внутренняя сеть Docker
: (import.meta.env.PUBLIC_POCKETBASE_URL || 'http://localhost:8090');
export const pb = new PocketBase(pbUrl);
pb.autoCancellation = false;
export function getImageUrl(record, filename) {
const baseUrl = isDocker
? 'http://pocketbase-f477mqb7lrkphmff7fdyloeh:8080'
: (import.meta.env.PUBLIC_POCKETBASE_URL || 'http://localhost:8090');
return `${baseUrl}/api/files/${record.collectionId}/${record.id}/${filename}`;
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,71 @@
---
import PageHeading from '@components/base/PageHeading.astro';
import ProjectGrid from '@components/projects/ProjectGrid.astro';
import Layout from '@layouts/Layout.astro';
import Pagination from 'src/components/base/Pagination.tsx';
import { pb } from '@lib/pocketbase';
import type { Project } from '@globalInterfaces';
interface PaginationData {
currentPage: number;
lastPage: number;
url: {
prev?: string;
next?: string;
};
}
const title = 'Мои проекты | Redi - Портфолио веб-разработчика';
const description =
'Ознакомьтесь с моими лучшими работами в портфолио. Примеры веб-приложений, сайтов и других проектов, разработанных мной.';
const page = Number(Astro.url.searchParams.get('page')) || 1;
const perPage = 6;
// Получаем проекты из PocketBase
const result = await pb.collection('projects').getList(page, 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
}));
// Данные для пагинации
const paginationData: PaginationData = {
currentPage: result.page,
lastPage: result.totalPages,
url: {
prev: result.page > 1 ? `/projects?page=${result.page - 1}` : undefined,
next: result.page < result.totalPages ? `/projects?page=${result.page + 1}` : undefined,
},
};
---
<Layout title={title} description={description} canonicalLink="/projects">
<section class="relative z-20 max-w-4xl mx-auto my-12 px-7 lg:px-0">
<PageHeading
title="Портфолио моих лучших веб-проектов"
description="Здесь собраны все проекты, над которыми я работал."
/>
<ProjectGrid projects={projects} />
{result.totalPages > 1 && (
<Pagination page={paginationData} client:load />
)}
</section>
</Layout>

View file

@ -0,0 +1,3 @@
import { atom } from "nanostores";
export const isSearchOpen = atom(false);

View file

@ -0,0 +1,58 @@
import typography from "@tailwindcss/typography";
/** @type {import('tailwindcss').Config} */
export default {
darkMode: "class",
content: ["./src/**/*.{astro,html,js,jsx,md,mdx,svelte,ts,tsx,vue}"],
theme: {
extend: {
typography: (theme) => ({
DEFAULT: {
css: {
"--tw-prose-body": theme("colors.neutral[300]"),
"--tw-prose-headings": theme("colors.white"),
"--tw-prose-lead": theme("colors.neutral[400]"),
"--tw-prose-links": theme("colors.indigo[400]"),
"--tw-prose-bold": theme("colors.white"),
"--tw-prose-counters": theme("colors.neutral[400]"),
"--tw-prose-bullets": theme("colors.neutral[600]"),
"--tw-prose-hr": theme("colors.neutral[800]"),
"--tw-prose-quotes": theme("colors.neutral[200]"),
"--tw-prose-quote-borders": theme("colors.neutral[700]"),
"--tw-prose-captions": theme("colors.neutral[400]"),
"--tw-prose-code": theme("colors.white"),
"--tw-prose-pre-code": theme("colors.neutral[200]"),
"--tw-prose-pre-bg": theme("colors.neutral[900]"),
"--tw-prose-th-borders": theme("colors.neutral[700]"),
"--tw-prose-td-borders": theme("colors.neutral[800]"),
// Стилизация блоков кода
pre: {
backgroundColor: theme("colors.neutral[900]"),
border: `1px solid ${theme("colors.neutral[800]")}`,
borderRadius: theme("borderRadius.lg"),
padding: theme("spacing.4"),
},
// Стилизация ссылок
a: {
textDecoration: "underline",
textDecorationColor: theme("colors.indigo[400]"),
textUnderlineOffset: "2px",
transition: "color 0.2s ease-out",
"&:hover": {
color: theme("colors.indigo[300]"),
},
},
// Стилизация цитат
blockquote: {
borderLeftColor: theme("colors.indigo[500]"),
fontStyle: "italic",
color: theme("colors.neutral[400]"),
},
},
},
}),
},
},
plugins: [typography],
};

27
frontend/tsconfig.json Normal file
View file

@ -0,0 +1,27 @@
{
"extends": "astro/tsconfigs/strict",
"compilerOptions": {
"strictNullChecks": true,
"baseUrl": ".",
"paths": {
"@components/*": ["src/components/*"],
"@layouts/*": ["src/layouts/*"],
"@images/*": ["src/images/*"],
"@styles/*": ["src/styles/*"],
"@assets/*": ["src/assets/*"],
"@stores/*": ["src/stores/*"],
"@lib/*": ["src/lib/*"],
"@globalInterfaces": ["src/globalInterfaces"]
},
"jsx": "preserve",
"jsxImportSource": "solid-js"
},
"include": [
"src/**/*",
".astro/types.d.ts"
],
"exclude": [
"node_modules",
"dist"
]
}