This commit is contained in:
2026-05-12 07:54:32 +03:00
parent 0faa8b9d2d
commit d49449c30c
187 changed files with 4826 additions and 5884 deletions

View File

@@ -29,12 +29,15 @@ PUBLIC_IMAGE_BASE_URL=http://localhost:8888
# Gateway proxies /api and Swagger routes to this upstream. # Gateway proxies /api and Swagger routes to this upstream.
GATEWAY_BACKEND_UPSTREAM=http://localhost:3001 GATEWAY_BACKEND_UPSTREAM=http://localhost:3001
GATEWAY_IMGPROXY_UPSTREAM=http://localhost:18080
GATEWAY_L1_MAX_ENTRIES=256 GATEWAY_L1_MAX_ENTRIES=256
GATEWAY_L1_TTL_MS=600000 GATEWAY_L1_TTL_MS=600000
GATEWAY_REMOTE_CACHE_CONTROL=public, max-age=86400, stale-while-revalidate=604800
# MVP dev mode: mock source host allowlist without DB/admin CRUD. # MVP dev mode: mock source host allowlist without DB/admin CRUD.
SOURCE_HOST_ALLOW_ALL=false SOURCE_HOST_ALLOW_ALL=false
SOURCE_ALLOWED_HOSTS=storage.yandexcloud.net SOURCE_ALLOWED_HOSTS=storage.yandexcloud.net
SOURCE_ALLOW_PRIVATE_NETWORKS=false
IMAGE_ALLOW_CUSTOM_TRANSFORMS=true IMAGE_ALLOW_CUSTOM_TRANSFORMS=true
IMAGE_ENSURE_WAIT_MS=15000 IMAGE_ENSURE_WAIT_MS=15000

View File

@@ -1,11 +1,29 @@
# AGENTS.md — правила для агента # AGENTS.md
## Frontend-разработка Этот файл является коротким маршрутизатором по документации агента.
Если задача связана с frontend-разработкой, агент должен писать код по правилам из: ## Порядок работы
👉 [ai/nextjs-style-guide/DEVELOP.md](./ai/nextjs-style-guide/DEVELOP.md) Перед началом работы агент обязан определить свою роль в таком порядке:
Если правило конфликтует с текущим проектом, стеком или фреймворком, приоритет имеет корректная реализация для фактически используемого фреймворка. 1. Если пользователь явно указал роль в запросе — использовать её.
2. Если доступна переменная окружения `AGENT_ROLE` — использовать её значение.
3. Если пользователь не указал роль и `AGENT_ROLE` пуста — использовать роль `developer`.
Next.js-специфичные правила применяются только в Next.js-проектах. Агент не должен читать `.env` ради определения роли. В CI роль передаётся через переменную окружения `AGENT_ROLE`.
Допустимые роли:
- `developer` — реализация задач, исправление багов, рефакторинг, настройка проекта.
- `reviewer` — ревью кода, поиск ошибок, рисков и регрессий.
- `architect` — проектирование архитектуры, модулей, слоёв и технических решений.
Если определена неизвестная роль, агент обязан сообщить об ошибке конфигурации и уточнить дальнейшие действия.
После определения роли агент обязан открыть соответствующую инструкцию:
- `developer` → [DEVELOP.md](./ai/nextjs-style-guide/DEVELOP.md)
Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия.
`AGENTS.md` не содержит правил разработки, ревью или архитектуры. Все правила находятся в документации соответствующей роли.

View File

@@ -39,7 +39,9 @@ client
- Fastify gateway - Fastify gateway
- worker - worker
Gateway принимает `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`, выбирает фактический формат по `Accept`, вызывает Backend ensure endpoint и кэширует готовые bytes в L1 memory cache. Gateway принимает managed assets через `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`, выбирает фактический формат по `Accept`, вызывает Backend ensure endpoint и кэширует готовые bytes в L1 memory cache.
Gateway также принимает remote source mode для `next/image`/`@unpic/react`: `/p/{project}/remote/{preset}?src={absoluteSourceUrl}&w={width}&q={quality}&f=auto`. В этом режиме source URL проходит allowlist-проверку и трансформируется через imgproxy без предварительной регистрации asset.
```bash ```bash
cp .env.example .env cp .env.example .env
@@ -64,6 +66,7 @@ curl -sS -X POST http://localhost:3001/api/assets \
curl -i "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=auto" curl -i "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=auto"
curl -i "http://localhost:8888/images/asset_demo/v1/avatar?f=auto" curl -i "http://localhost:8888/images/asset_demo/v1/avatar?f=auto"
curl -i "http://localhost:8888/p/demo/remote/card?src=https%3A%2F%2Fstorage.yandexcloud.net%2Fshared1318%2Fimg%2F1.jpg&w=640&q=80&f=auto"
``` ```
Сейчас доступны static presets из `packages/image-config`: `card`, `hero`, `avatar`. Произвольный single-image режим доступен через `/images/{assetId}/v{version}/custom?...`, если включён `IMAGE_ALLOW_CUSTOM_TRANSFORMS=true`. Сейчас доступны static presets из `packages/image-config`: `card`, `hero`, `avatar`. Произвольный single-image режим доступен через `/images/{assetId}/v{version}/custom?...`, если включён `IMAGE_ALLOW_CUSTOM_TRANSFORMS=true`.
@@ -108,3 +111,4 @@ curl -sS -X POST http://localhost:3001/api/assets/asset_demo/variants \
- `docs/backend-contract-draft.md` - черновик будущего backend-контракта. - `docs/backend-contract-draft.md` - черновик будущего backend-контракта.
- `docs/imgproxy-contract.md` - контракт с external imgproxy. - `docs/imgproxy-contract.md` - контракт с external imgproxy.
- `docs/next-image-provider.md` - контракт custom provider для `next/image`. - `docs/next-image-provider.md` - контракт custom provider для `next/image`.
- `docs/unpic-react-provider.md` - контракт custom transformer для `@unpic/react`.

View File

@@ -1,103 +1,45 @@
--- # DEVELOP.md
title: Гид для агента
description: Что AI-агент обязан прочитать перед началом работы, а что — по задаче.
---
# Обязательное чтение перед началом работы Ты senior fullstack JavaScript/TypeScript-разработчик.
Этот документ определяет **строгий порядок действий агента перед выполнением любых задач**. ## Направления разработки
## Общее правило ### Frontend
Перед началом работы над **любой задачей** агент **обязан ознакомиться с базовой документацией проекта**. Для frontend-задач NextJS Style Guide является обязательным источником решений. Используй только:
Нарушение этого порядка считается ошибкой. https://nextjs-style-guide.gromlab.ru/llms.txt
--- `llms.txt` — карта документации. Агент сам выбирает нужные разделы под текущую задачу.
## Порядок обязательного чтения Baseline = архитектура SLM + базовые правила.
Агент должен читать документацию **строго в следующем порядке**: Перед каждой frontend-задачей или новой сессией строго выполни порядок:
### 1. Архитектура (КРИТИЧЕСКИ ВАЖНО) 1. Открой `llms.txt`.
2. Найди и прочитай архитектуру SLM.
3. Найди и прочитай базовые правила.
4. Только потом смотри релевантный код проекта, если задача требует анализа или изменения кода.
5. Вернись к `llms.txt` и выбери дополнительные разделы под конкретную задачу.
6. Только после этого реализовывай.
* [Архитектура: Обзор](./basics/architecture/index.md) Если контекст был сжат, сессия продолжена после паузы или нет уверенности, что архитектура SLM и базовые правила есть в текущем контексте, считай baseline утраченным и прочитай его заново.
* [Архитектура: Слои](./basics/architecture/layers.md)
* [Архитектура: Модули](./basics/architecture/modules.md)
* [Архитектура: Сегменты](./basics/architecture/segments.md)
**Архитектура — это самое важное в проекте.** Во время frontend-задачи возвращайся к `llms.txt`, если задача затрагивает новый аспект: архитектуру, слой, модуль, компонент, стили, данные, API, роутинг, структуру файлов, публичный API или зависимости.
Агент обязан: Не заменяй style guide догадками, привычными паттернами или общими практиками.
* строго понимать архитектурный подход (SLM) Если в style guide не найдено правило или пример для значимого frontend-решения:
* соблюдать архитектуру **на 100% без отклонений**
* не предлагать решений, нарушающих архитектурные принципы
* не упрощать архитектуру даже ради скорости выполнения задачи
Любое нарушение архитектуры недопустимо. 1. Остановись до реализации.
2. Сообщи пользователю, что правило не найдено в style guide.
3. Кратко опиши, какой вопрос не покрыт.
4. Предложи варианты реализации или спроси, как действовать дальше.
5. Дождись подтверждения пользователя.
6. Только после этого реализовывай.
--- Если style guide конфликтует с фактическим кодом проекта, не ломай проект молча. Сообщи о конфликте и предложи безопасный вариант.
### 2. Базовые правила ### Backend
После архитектуры необходимо изучить: Соблюдай стиль кода существующего приложения.
* [Технологии и библиотеки](./basics/tech-stack.md)
* [Именование](./basics/naming.md)
* [Стиль кода](./basics/code-style.md)
* [Документирование](./basics/documentation.md)
* [Типизация](./basics/typing.md)
Агент обязан применять эти правила во всех решениях.
---
## Использование карты документации
Для поиска дополнительных сведений агент должен использовать:
* [MAP.md](./MAP.md)
MAP.md содержит ссылки на все прикладные и вспомогательные разделы.
Агент может:
* переходить к нужным разделам через MAP.md
* уточнять детали реализации
* искать примеры и частные случаи
---
## Запрещено
Агенту запрещено:
* начинать выполнение задачи без изучения архитектуры
* игнорировать базовые правила
* принимать решения, противоречащие архитектуре
* придумывать собственные подходы, если они не описаны в документации
---
## Ожидаемое поведение агента
Перед выполнением задачи агент должен:
1. Изучить архитектуру
2. Изучить базовые правила
3. При необходимости открыть MAP.md и найти релевантные разделы
4. Только после этого приступать к решению задачи
---
## Приоритеты
При принятии решений агент должен руководствоваться следующим приоритетом:
1. **Архитектура**
2. Базовые правила
3. Документация из MAP.md
4. Задача пользователя
Если задача противоречит архитектуре — задача должна быть переосмыслена, а не выполнена напрямую.

View File

@@ -1,66 +0,0 @@
# Карта документации
Список всех разделов архива с относительными ссылками. Точка входа
`DEVELOP.md` рядом с этим файлом.
## Подсказки
- [Подсказки](./workflow.md) — Короткие ответы на типовые вопросы и решения для спорных ситуаций.
## Базовые правила
- [Технологии и библиотеки](./basics/tech-stack.md) — Какие библиотеки и инструменты используются в проекте.
- [Именование](./basics/naming.md) — Как называть переменные, файлы и прочие сущности в коде.
- [Архитектура: Обзор](./basics/architecture/index.md) — Архитектурный подход проекта: что такое SLM и как он устроен.
- [Архитектура: Слои](./basics/architecture/layers.md) — Из каких слоёв состоит SLM-архитектура и как они связаны.
- [Архитектура: Модули](./basics/architecture/modules.md) — Что такое модуль в SLM-архитектуре и как он устроен.
- [Архитектура: Сегменты](./basics/architecture/segments.md) — Что такое сегмент модуля в SLM-архитектуре и какие они бывают.
- [Стиль кода](./basics/code-style.md) — Как оформляется код в проекте.
- [Документирование](./basics/documentation.md) — Что и как документировать в коде.
- [Типизация](./basics/typing.md) — Как типизируется код в проекте.
## Создание проекта
- [Из шаблона](./creating-project/from-template.md) — Создание нового проекта на основе готового шаблона.
- [По гайду вручную](./creating-project/manual.md) — Поэтапное создание нового проекта без использования шаблона.
- [Чистый Next.js](./creating-project/nextjs.md) — Установка Next.js без лишнего шаблона — голый каркас под дальнейшую сборку.
## Работа с данными
- [Введение](./data/index.md) — Какие источники данных используются в проекте и как с ними работать.
- [REST](./data/rest/index.md) — Как правильно работать с REST API в проекте.
- [REST: Создание клиента](./data/rest/clients/index.md) — Как выбрать способ создания REST-клиента и где размещать его части.
- [REST: Создание клиента: Автогенерация из OpenAPI](./data/rest/clients/auto.md) — Генерация REST-клиента из OpenAPI-спецификации через @gromlab/api-codegen.
- [REST: Создание клиента: Ручное создание](./data/rest/clients/manual.md) — Создание REST-клиента вручную, когда OpenAPI нет или он неполный.
- [REST: Создание клиента: GET-хуки REST-клиента](./data/rest/clients/hooks.md) — Прозрачные SWR-обёртки над GET-методами REST-клиента.
- [REST: Использование: Стратегии получения данных](./data/rest/strategies/index.md) — Как выбрать способ получения REST-данных в зависимости от места и сценария.
- [REST: Использование: Серверный await](./data/rest/strategies/server-await.md) — Получение REST-данных на сервере прямым await метода клиента.
- [REST: Использование: Параллельные серверные запросы](./data/rest/strategies/parallel-server-requests.md) — Как запускать независимые REST-запросы на сервере без waterfall.
- [REST: Использование: Передача промиса ниже](./data/rest/strategies/pass-promise-down.md) — Как запускать серверный REST-запрос выше и ожидать его во вложенном server-компоненте.
- [REST: Использование: Начальные данные для клиентских хуков](./data/rest/strategies/client-hooks-initial-data.md) — Как передать серверный промис в SWR fallback, чтобы клиентские GET-хуки получили начальные данные.
- [REST: Использование: Клиентский GET-хук](./data/rest/strategies/client-get-hook.md) — Получение REST-данных в Client Components через готовые GET-хуки REST-клиента.
- [REST: Использование: Business-композиция](./data/rest/strategies/business-composition.md) — Когда REST-данные нужно объединить или интерпретировать в бизнес-модуле.
- [Realtime](./data/realtime.md) — Работа с push-данными от сервера: подписки и события.
## Прикладные разделы
- [Структура проекта](./applied/project-structure.md) — Из чего состоит проект и где что лежит.
- [Страницы](./applied/page-level.md) — Как работать со страницами и другими файлами роутинга Next.js App Router.
- [Компонент](./applied/component.md) — Как создавать React-компоненты внутри SLM-модулей.
- [Модуль](./applied/module.md) — Как создавать и организовывать SLM-модули в проекте.
- [Стили: Настройка](./applied/styles/styles-setup.md) — Подготовка стилевой основы проекта: токены, медиа-запросы, глобальные стили.
- [Стили: Использование](./applied/styles/styles-usage.md) — Как пишутся стили в проекте.
- [SVG-спрайты](./applied/svg-sprites/svg-sprites-intro.md) — Что такое SVG-спрайты и какие проблемы они решают.
- [SVG-спрайты: Настройка](./applied/svg-sprites/svg-sprites-setup.md) — Подключение SVG-спрайтов в новом проекте.
- [SVG-спрайты: Использование](./applied/svg-sprites/svg-sprites-usage.md) — Как добавлять и использовать SVG-иконки в коде.
- [Изображения](./applied/images.md) — Как подключать изображения через Next.js Image в проекте.
- [Шрифты](./applied/fonts.md) — Как подключать шрифты через Next.js Font в проекте.
- [Алиасы импортов](./applied/aliases.md) — Какие алиасы импортов есть в проекте и как ими пользоваться.
- [Шаблоны генерации](./applied/templates/templates-intro.md) — Что такое шаблоны кодогенерации и какие проблемы они решают.
- [Шаблоны генерации: Настройка](./applied/templates/templates-setup.md) — Первичная установка шаблонов кодогенерации в проект.
- [Шаблоны генерации: Создание шаблонов](./applied/templates/templates-create.md) — Структура шаблонов, синтаксис переменных и примеры.
- [Шаблоны генерации: Использование](./applied/templates/templates-usage.md) — Генерация файлов из шаблонов через VS Code плагин и CLI.
- [Biome](./applied/biome.md) — Установка и настройка линтера-форматтера в новом проекте.
- [PostCSS](./applied/postcss.md) — Установка и настройка CSS-процессора в новом проекте.
- [VS Code](./applied/vscode.md) — Единые настройки редактора и расширений для команды.
- [Локализация](./applied/localization.md) — Как организовать локализацию как infra-модуль.

View File

@@ -1,2 +0,0 @@
bf1781f
2026-05-03T01:23:40.449Z

View File

@@ -1,77 +0,0 @@
---
title: Алиасы импортов
description: Какие алиасы импортов есть в проекте и как ими пользоваться.
keywords: [алиасы, aliases, paths, tsconfig, импорты, baseUrl, app, layouts, screens, widgets, business, infra, ui, shared]
---
# Алиасы импортов
Какие алиасы импортов есть в проекте и как ими пользоваться.
## Конфиг
`tsconfig.json` в корне проекта:
```json
{
"compilerOptions": {
"paths": {
"app/*": ["./src/app/*"],
"layouts/*": ["./src/layouts/*"],
"screens/*": ["./src/screens/*"],
"widgets/*": ["./src/widgets/*"],
"business/*": ["./src/business/*"],
"infra/*": ["./src/infra/*"],
"ui/*": ["./src/ui/*"],
"shared/*": ["./src/shared/*"]
}
}
}
```
Восемь алиасов — ровно по числу слоёв. Других алиасов в проекте нет.
## Правила
- **Каждый импорт между модулями — через алиас слоя.** Относительные пути (`../../`) запрещены за пределами своего модуля.
- **Внутри одного модуля** допустимы относительные импорты (`./model`, `./ui/button`) — это часть инкапсуляции модуля.
- **Префикс `@/` не используется.** Имя слоя — само по себе адрес.
- **Направление импортов** определяется архитектурой, не алиасами. Алиас разрешает импорт технически, но не отменяет правила слоёв (→ [Слои](../basics/architecture/layers.md)).
**Хорошо**
```ts
import { Button } from 'ui/button'
import { useUser } from 'business/user'
import { formatDate } from 'shared/utils/date'
```
**Плохо**
```ts
// Относительный путь между модулями
import { Button } from '../../../ui/button'
// Префикс @/, которого нет в paths
import { Button } from '@/ui/button'
// Алиас на src — не предусмотрен
import { Button } from 'src/ui/button'
```
## Внутри модуля
Внутри своего модуля — относительные пути:
```ts
// src/ui/button/button.tsx
import styles from './button.module.css'
import { Icon } from './icon'
```
Не использовать алиас на самого себя:
```ts
// Плохо — алиас вместо относительного пути внутри модуля
import { Icon } from 'ui/button/icon'
```

View File

@@ -1,81 +0,0 @@
---
title: Biome
description: Установка и настройка линтера-форматтера в новом проекте.
keywords: [biome, линтер, форматтер, lint, format, biome.json, "@biomejs/biome", замена eslint, замена prettier]
---
# Biome
Установка и настройка линтера-форматтера в новом проекте.
## Требования
- Node.js 18+.
- Проект без установленного ESLint и Prettier (они конфликтуют с Biome).
## Установка
1. Установить пакет:
```bash
npm install --save-dev --save-exact @biomejs/biome
```
2. Инициализировать конфиг:
```bash
npx @biomejs/biome init
```
В корне появится `biome.json` с дефолтными настройками.
3. Привести `biome.json` к стандартному виду — добавить override для `*.css` (см. «Стандартный `biome.json`»). Делается сразу после `init`, до первого запуска `lint`/`check`.
4. Добавить скрипты в `package.json`:
```json
{
"scripts": {
"lint": "biome lint .",
"format": "biome format --write .",
"check": "biome check --write ."
}
}
```
| Скрипт | Что делает |
|--------|-----------|
| `lint` | Проверка правил без правок |
| `format` | Автоформатирование всех файлов |
| `check` | Lint + format + organize imports в один проход (основная команда) |
## Стандартный `biome.json`
Дефолтный `biome.json`, созданный `biome init`, кастомизируется ровно одним блоком — `overrides` для `*.css` с отключённым правилом `suspicious/noUnknownAtRules`. Этот override **обязателен по умолчанию во всех проектах**, независимо от того, подключены ли уже стили: проектный CSS-стек использует `@custom-media` и другие нестандартные at-правила, которые Biome не распознаёт; без override `npm run lint` падает.
Фрагмент, который добавляется в `biome.json`:
```jsonc
{
"overrides": [
{
"includes": ["**/*.css"],
"linter": {
"rules": {
"suspicious": {
"noUnknownAtRules": "off"
}
}
}
}
]
}
```
Если в `biome.json` уже есть массив `overrides` — добавить элемент в него; не дублировать массив.
Прочая настройка правил Biome — отдельная задача, не входит в стандартный канон.
## Интеграция с VS Code
Расширение `biomejs.biome` и автоформатирование при сохранении настраиваются в [VS Code](./vscode.md).

View File

@@ -1,165 +0,0 @@
---
title: Компонент
description: Как должен выглядеть сгенерированный React-компонент внутри SLM-модуля.
---
# Компонент
Как должен выглядеть сгенерированный React-компонент внутри SLM-модуля.
## Назначение
Архитектурное определение компонента описано в разделе [Модули → Компонент](../basics/architecture/modules.md#компонент), а структура сегмента `ui/` — в разделе [Сегменты → ui/](../basics/architecture/segments.md#сегмент-ui).
Эта страница не повторяет архитектурные ограничения. Она показывает, каким должен быть результат генерации компонента: структура папки, `.tsx`, типы, стили и локальный экспорт.
::: danger Компоненты не создаются вручную
Компоненты в проекте создаются только через кодогенератор: через [VS Code](./templates/templates-usage.md#через-vs-code) или [CLI](./templates/templates-usage.md#через-cli).
Ручное создание компонента запрещено. Это грубое нарушение правил работы в проекте для разработчика и AI-ассистента.
Если в проекте нет шаблона `.templates/component`, сначала создайте шаблон по разделу [Создание шаблонов](./templates/templates-create.md), и только потом генерируйте компонент на его основе.
:::
## Создание
1. Проверьте, что в проекте есть шаблон `.templates/component`.
2. Если шаблона нет — создайте его по разделу [Создание шаблонов](./templates/templates-create.md).
3. Сгенерируйте компонент через [VS Code или CLI](./templates/templates-usage.md).
Структура и код ниже показывают ожидаемый результат генерации. Их нельзя использовать как инструкцию для ручного создания файлов.
## Структура
Компонент размещается в `ui/{component-name}/` родительского модуля.
Для каждого компонента обязательны `.tsx`, типы, стили и локальный `index.ts`.
```text
user-card/
└── ui/
└── user-status/
├── styles/
│ └── user-status.module.css
├── types/
│ └── user-status-props.type.ts
├── user-status.tsx
└── index.ts
```
## Реализация
Пример ниже показывает файлы базового компонента.
### Типы
Файл типов делится на три части:
- `UserStatusParams` — собственные параметры компонента. Здесь лежат только данные, которые нужны именно этому компоненту.
- `RootAttrs` — параметры корневой обёртки: `div`, `span`, `a`, `button` или другого HTML-элемента. Если компонент сам управляет `children`, они исключаются через `Omit`.
- `UserStatusProps` — итоговые пропсы компонента. Тип объединяет собственные параметры и параметры корневой обёртки.
Собственные параметры и их поля документируются по правилам раздела [Документирование → Типы, интерфейсы, enum](../basics/documentation.md#типы-интерфейсы-enum).
`user-card/ui/user-status/types/user-status-props.type.ts`
```ts
import type { ComponentPropsWithoutRef } from 'react'
/**
* Параметры UserStatus.
*/
export type UserStatusParams = {
/** Текст статуса пользователя. */
label: string
/** Доступен ли пользователь сейчас. */
isOnline: boolean
}
/** Атрибуты корневого элемента без children. */
type RootAttrs = Omit<ComponentPropsWithoutRef<'span'>, 'children'>
export type UserStatusProps = RootAttrs & UserStatusParams
```
### TSX
В `.tsx` лежит только сам компонент:
- Компонент объявляется через `const` и именованный экспорт.
- `React.FC` не используется.
- Параметры компонента типизируются через `Props`.
- Возвращаемый тип не указывается: TypeScript корректно выводит JSX-результат, а явный `ReactElement` сужает допустимые варианты возврата.
- JSDoc-комментарий обязателен и пишется по правилам раздела [Документирование → Компоненты](../basics/documentation.md#компоненты).
- Пропсы деструктурируются в теле компонента, а не в сигнатуре.
- Из пропсов обязательно выделяются `className` и `...rootAttrs`.
- Функция конкатенации CSS-классов импортируется и именуется `cl`.
- Корневой CSS-класс всегда называется `.root`.
Комментарий описывает назначение и сценарии применения компонента, а не DOM-разметку или внутреннюю реализацию.
`className` — внешний CSS-класс, который родитель может передать компоненту. `rootAttrs` — остальные атрибуты корневой обёртки: `id`, `aria-*`, `data-*`, обработчики событий и другие HTML-атрибуты. Они прокидываются на корневой DOM-элемент компонента.
`.root` нужен, чтобы в DevTools быстро находить корневой DOM-узел компонента и одинаково подключать внешний `className` к реальному корню.
`user-card/ui/user-status/user-status.tsx`
```tsx
import cl from 'clsx'
import type { UserStatusProps } from './types/user-status-props.type'
import styles from './styles/user-status.module.css'
/**
* Статус пользователя в карточке профиля.
*
* Используется для:
* - отображения текущей доступности пользователя
* - визуального выделения онлайн- и офлайн-состояний
*/
export const UserStatus = (props: UserStatusProps) => {
const { label, isOnline, className, ...rootAttrs } = props
return (
<span
{...rootAttrs}
className={cl(styles.root, isOnline && styles.online, className)}
>
{label}
</span>
)
}
```
### Стили
`user-card/ui/user-status/styles/user-status.module.css`
```css
.root {
display: inline-flex;
align-items: center;
gap: 6px;
color: var(--color-text-muted);
}
.root::before {
content: '';
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.online {
color: var(--color-success);
}
```
### Локальный экспорт
`user-card/ui/user-status/index.ts`
```ts
export { UserStatus } from './user-status'
export type { UserStatusProps } from './types/user-status-props.type'
```

View File

@@ -1,128 +0,0 @@
---
title: Шрифты
description: Как подключать шрифты через Next.js Font в проекте.
---
# Шрифты
Как подключать шрифты через Next.js Font в проекте.
## Назначение
Шрифты подключаются через `next/font`. Это стандартный способ Next.js: шрифты загружаются без ручных `<link>`, `@font-face` и настройки preconnect.
Шрифт подключается в точке инициализации приложения, а в CSS используется через переменную.
## Google Fonts
```tsx
// src/app/layout.tsx
import type { ReactNode } from 'react'
import { Inter } from 'next/font/google'
import 'shared/styles/global.css'
const inter = Inter({
subsets: ['latin', 'cyrillic'],
variable: '--font-main',
display: 'swap',
})
type RootLayoutProps = {
children: ReactNode
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="ru" className={inter.variable}>
<body>{children}</body>
</html>
)
}
```
```css
/* src/shared/styles/global.css */
body {
font-family: var(--font-main), system-ui, sans-serif;
}
```
## Локальные шрифты
Каждый локальный шрифт размещается в отдельной папке внутри `src/shared/fonts/`. В этой же папке лежит `.font.ts`, где объявляется `localFont`.
```text
src/shared/fonts/
└── roboto/
├── roboto.font.ts
├── Roboto-Regular.woff2
├── Roboto-Italic.woff2
├── Roboto-Bold.woff2
└── Roboto-BoldItalic.woff2
```
```ts
// src/shared/fonts/roboto/roboto.font.ts
import localFont from 'next/font/local'
export const roboto = localFont({
src: [
{
path: './Roboto-Regular.woff2',
weight: '400',
style: 'normal',
},
{
path: './Roboto-Italic.woff2',
weight: '400',
style: 'italic',
},
{
path: './Roboto-Bold.woff2',
weight: '700',
style: 'normal',
},
{
path: './Roboto-BoldItalic.woff2',
weight: '700',
style: 'italic',
},
],
variable: '--font-main',
display: 'swap',
})
```
`app/` импортирует готовый объект шрифта и только подключает его к документу:
```tsx
// src/app/layout.tsx
import type { ReactNode } from 'react'
import { roboto } from 'shared/fonts/roboto/roboto.font'
import 'shared/styles/global.css'
type RootLayoutProps = {
children: ReactNode
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="ru" className={roboto.variable}>
<body>{children}</body>
</html>
)
}
```
Путь в `localFont` указывается относительно `.font.ts`, поэтому файлы шрифта импортируются коротко: `./Roboto-Regular.woff2`.
Если шрифтов несколько, у каждого своя папка и свой `.font.ts`.
## Правила
- Использовать `next/font/google` или `next/font/local`.
- Не подключать шрифты через ручные `<link>` и `@font-face` без необходимости.
- Подключать шрифты один раз — в корневом layout через готовый объект шрифта.
- Использовать CSS-переменные `variable`, а не жёстко прописывать семейство в каждом компоненте.
- Локальные файлы шрифтов хранить в `src/shared/fonts/{font-name}/` рядом с `{font-name}.font.ts`.
- Не объявлять `localFont` внутри `src/app/layout.tsx`; layout только импортирует готовый шрифт.

View File

@@ -1,95 +0,0 @@
---
title: Изображения
description: Как подключать изображения через Next.js Image в проекте.
---
# Изображения
Как подключать изображения через Next.js Image в проекте.
## Назначение
Изображения рендерятся через компонент `Image` из `next/image`. Это сохраняет единый API для размеров, `alt`, lazy-loading и `priority`, даже если оптимизация изображений отключена.
В проекте оптимизация Next.js Image отключается через `unoptimized`, чтобы сборка и рантайм не зависели от встроенного image optimizer.
## Настройка
Отключение оптимизации задаётся глобально в `next.config.ts`:
```ts
import type { NextConfig } from 'next'
const nextConfig: NextConfig = {
images: {
unoptimized: true,
},
}
export default nextConfig
```
После этого `unoptimized` не нужно повторять на каждом `Image`.
## Использование
Статические изображения, доступные по URL, размещаются в `public/`:
```text
public/
└── images/
└── user-avatar.png
```
```tsx
import Image from 'next/image'
export const UserAvatar = () => {
return (
<Image
src="/images/user-avatar.png"
alt="Аватар пользователя"
width={96}
height={96}
/>
)
}
```
## Правила
- Использовать `Image` из `next/image`, не обычный `<img>`.
- Для контентных изображений всегда писать осмысленный `alt`.
- Для декоративных изображений использовать `alt=""`.
- Указывать `width` и `height`, если изображение не использует `fill`.
- При `fill` задавать `sizes` и контролировать размеры родителя стилями.
- `priority` ставить только для изображений первого экрана.
- SVG-иконки не оформлять как изображения — для них используется раздел [SVG-спрайты](./svg-sprites/svg-sprites-intro.md).
## Пример с `fill`
```tsx
import Image from 'next/image'
import styles from '../styles/article-card-cover.module.css'
export const ArticleCardCover = () => {
return (
<div className={styles.root}>
<Image
src="/images/article-cover.jpg"
alt="Обложка статьи"
fill
sizes="(min-width: 768px) 33vw, 100vw"
/>
</div>
)
}
```
```css
.root {
position: relative;
aspect-ratio: 16 / 9;
overflow: hidden;
}
```

View File

@@ -1,81 +0,0 @@
---
title: Локализация
description: Как организовать локализацию как infra-модуль.
---
# Локализация
Как организовать локализацию как infra-модуль.
## Назначение
Локализация — инфраструктурная подсистема приложения. Она отвечает за текущую локаль, словари, форматирование переводов и API для компонентов.
Код локализации живёт в `src/infra/i18n/`. Компоненты и модули не читают словари напрямую — они используют публичный API infra-модуля.
## Структура
```text
src/infra/i18n/
├── config/
│ └── i18n.config.ts
├── dictionaries/
│ ├── ru.ts
│ └── en.ts
├── hooks/
│ └── use-translation.hook.ts
├── providers/
│ └── i18n-provider.tsx
├── types/
│ └── i18n.type.ts
└── index.ts
```
Набор сегментов может отличаться, но публичная точка входа остаётся одна — `infra/i18n`.
## Подключение
`app/` только подключает готовый провайдер локализации. Реализация провайдера, словари и конфиг остаются в `infra/i18n/`.
```tsx
// src/app/layout.tsx
import type { ReactNode } from 'react'
import { I18nProvider } from 'infra/i18n'
type RootLayoutProps = {
children: ReactNode
}
export default function RootLayout({ children }: RootLayoutProps) {
return (
<html lang="ru">
<body>
<I18nProvider locale="ru">{children}</I18nProvider>
</body>
</html>
)
}
```
## Использование
Компоненты получают переводы через готовый API модуля локализации:
```tsx
import { useTranslation } from 'infra/i18n'
export const ProfileTitle = () => {
const { t } = useTranslation()
return <h1>{t('profile.title')}</h1>
}
```
## Правила
- Локализация живёт в `infra/i18n/`.
- `app/` только подключает готовый provider и передаёт locale.
- Словари не импортируются напрямую в компоненты, screens или business-модули.
- Ключи переводов не собираются динамически из строк, если это ломает типизацию и поиск.
- Тексты интерфейса не хардкодятся в переиспользуемых компонентах, если они должны переводиться.
- Форматирование дат, чисел и валют должно проходить через API локализации или отдельные утилиты infra-модуля.

View File

@@ -1,156 +0,0 @@
---
title: Модуль
description: Как должен выглядеть сгенерированный SLM-модуль в проекте.
---
# Модуль
Как должен выглядеть сгенерированный SLM-модуль в проекте.
## Назначение
Архитектурное определение модуля описано в разделе [Архитектура → Модули](../basics/architecture/modules.md). Список сегментов описан в разделе [Архитектура → Сегменты](../basics/architecture/segments.md).
Эта страница показывает прикладное оформление трёх типов модулей: UI, бизнес и инфраструктурный.
## Создание
1. Проверьте, что в проекте есть нужный шаблон в `.templates/`.
2. Если шаблона нет — создайте его по разделу [Создание шаблонов](./templates/templates-create.md).
3. Сгенерируйте модуль через [VS Code или CLI](./templates/templates-usage.md).
## Типы модулей
Архитектура определяет три типа модулей ([Типы модулей](../basics/architecture/modules.md#типы-модулей)):
| Тип | Обязательный файл | Описание |
|---|---|---|
| UI-модуль | `{name}.tsx` | Модуль, выросший из компонента |
| Бизнес-модуль | `{name}.factory.ts` | Модуль вокруг публичного runtime API |
| Инфраструктурный модуль | нет | Модуль вокруг технического сервиса |
## UI-модуль
UI-модуль — это компонент, который перерос ограничения компонента: получил собственные хуки, вложенные модули в `parts/`, сценарную логику или публичный API. Внутренняя структура та же, что у компонента: корневой `.tsx`, типы, стили, `ui/`. Но без ограничений компонента.
Подробное оформление компонентов внутри `ui/` описано в разделе [Компонент](./component.md).
## Бизнес-модуль
Бизнес-модуль строится вокруг публичного runtime API. Ключевой файл — фабрика (`{name}.factory.ts`), которая возвращает всё, что нужно внешнему коду в runtime.
Архитектурное описание фабрики: [Архитектура → Фабрика](../basics/architecture/modules.md#фабрика).
### Структура
```text
business/customer/
├── customer.factory.ts
├── index.ts
└── types/
├── customer.type.ts
├── customer-api.type.ts
├── customer-deps.type.ts
└── customer-factory.type.ts
```
### Типы
`business/customer/types/customer-api.type.ts`
```ts
export type CustomerApi = {
useCustomer: () => Customer
CustomerCard: (props: CustomerCardProps) => ReactNode
}
```
`business/order/types/order-deps.type.ts`
```ts
export type OrderDeps = {
customer: Pick<CustomerApi, 'useCustomer'>
}
```
`business/order/types/order-factory.type.ts`
```ts
export type OrderFactory = (deps: OrderDeps) => OrderApi
```
### Фабрика без зависимостей
`business/customer/customer.factory.ts`
```ts
import type { CustomerFactory } from './types/customer-factory.type'
export const customerFactory: CustomerFactory = () => {
return {
useCustomer,
CustomerCard,
}
}
```
### Фабрика с зависимостями
`business/order/order.factory.ts`
```ts
import type { OrderFactory } from './types/order-factory.type'
export const orderFactory: OrderFactory = (deps) => {
return {
useOrder,
OrderCard,
}
}
```
### Композиция на уровне screen
```tsx
// screens/home/home.screen.tsx
import { customerFactory } from '@/business/customer'
import { orderFactory } from '@/business/order'
const customer = customerFactory()
const order = orderFactory({ customer })
const { useOrder, OrderCard } = order
export const HomeScreen = () => {
const currentOrder = useOrder()
return <OrderCard order={currentOrder} />
}
```
## Инфраструктурный модуль
Инфраструктурный модуль строится вокруг технического сервиса или интеграции. Его структура определяется природой сервиса — фиксированного корневого файла нет.
Архитектурное описание: [Архитектура → Типы модулей → Инфраструктурный модуль](../basics/architecture/modules.md#инфраструктурный-модуль).
Пример модуля темы:
```text
theme/
├── index.ts
├── config/
├── hooks/
├── styles/
└── ui/
```
Пример модуля API-клиента:
```text
backend-api/
├── backend-api.client.ts
├── config/
├── types/
└── index.ts
```

View File

@@ -1,186 +0,0 @@
---
title: Файлы роутинга
description: Как работать со страницами и другими файлами роутинга Next.js App Router.
---
# Файлы роутинга
Как работать со страницами и другими файлами роутинга Next.js App Router.
## Назначение
`src/app/**` — точка входа приложения и слой файлового роутинга Next.js.
Файлы роутинга не реализуют интерфейс. Они описывают маршрут: читают параметры, получают данные первого рендера, подготавливают кеш или состояние и передают результат в screen.
Границы слоя описаны в [Архитектура → Слои → App](../basics/architecture/layers.md#слой-app).
## Граница ответственности
| Область | Где живёт |
|---|---|
| Файлы маршрутов (`page.tsx`, `layout.tsx`, `loading.tsx`, `error.tsx`, `not-found.tsx`) | `src/app/**` |
| Параметры маршрута, `metadata`, `redirect()`, `notFound()` | `src/app/**` |
| Серверные запросы для первого рендера | `src/app/**`, через готовые клиенты и сервисы нижних слоёв |
| Прогрев SWR-кеша, начальное состояние, подключение провайдеров | `src/app/**`, только через готовые обёртки из нижних слоёв |
| UI страницы | `screens/` |
| Каркас страницы: header, footer, sidebar | `layouts/` |
| Провайдеры, сторы, хуки, API-клиенты, сервисы | нижние слои (`screens/`, `business/`, `infra/`, `shared/`) |
| CSS Modules и стили компонентов | рядом с компонентами, не в `src/app/**` |
## Что можно делать в `page.tsx`
- Экспортировать `metadata` или `generateMetadata`.
- Читать `params` и `searchParams`.
- Нормализовать и валидировать параметры маршрута.
- Делать серверные запросы для первого рендера через готовые клиенты или сервисы.
- Вызывать `redirect()` и `notFound()`.
- Готовить начальные данные для screen.
- Готовить SWR `fallback` и передавать его в готовый провайдер.
- Подключать готовый провайдер стора страницы и передавать начальное состояние.
- Рендерить screen или композицию из готовых обёрток и screen.
## Что запрещено
- Писать UI-разметку страницы прямо в файле роутинга.
- Создавать локальные компоненты внутри `src/app/**`.
- Добавлять CSS Modules, стили компонентов, `components/`, `styles/`, `hooks/`, `stores/`, `services/` внутри `src/app/**`.
- Реализовывать провайдеры, сторы, хуки, API-клиенты или сервисы в файлах роутинга.
- Размещать бизнес-логику, мапперы и правила предметной области в файлах роутинга.
- Вызывать `useSWR` и доменные клиентские хуки в файлах роутинга.
## Страницы
Страница объявляется через `export default function`. Для серверных запросов используется `async function`.
```tsx
import type { Metadata } from 'next'
import { ProfileScreen } from 'screens/profile'
export const metadata: Metadata = {
title: 'Профиль',
description: 'Страница профиля пользователя',
}
type ProfilePageProps = {
params: Promise<{ id: string }>
}
export default async function ProfilePage({ params }: ProfilePageProps) {
const { id } = await params
return <ProfileScreen id={id} />
}
```
## Данные первого рендера
Если данные нужны до первого рендера, `page.tsx` получает их на сервере и передаёт в screen. Сам запрос выполняется через готовый клиент или сервис нижнего слоя.
```tsx
import { notFound } from 'next/navigation'
import { userApi } from 'infra/backend-api'
import { UserScreen } from 'screens/user'
type UserPageProps = {
params: Promise<{ id: string }>
}
export default async function UserPage({ params }: UserPageProps) {
const { id } = await params
const user = await userApi.users.get(id)
if (!user) {
notFound()
}
return <UserScreen user={user} />
}
```
Если данные нужны нескольким клиентским SWR-хукам, файл роутинга может обернуть дерево в `SWRConfig` и передать `fallback`. Запросы стартуют на сервере, а клиентские хуки получают данные из кеша.
Ключи `fallback` должны совпадать с ключами внутри GET-хуков REST-клиента. Для array-key используется `unstable_serialize`.
```tsx
import type { ReactNode } from 'react'
import { SWRConfig, unstable_serialize } from 'swr'
import {
backendApi,
getCurrentUserKey,
getPostListKey,
} from 'infra/backend-api'
type FeedLayoutProps = {
children: ReactNode
}
export default async function FeedLayout({ children }: FeedLayoutProps) {
const userPromise = backendApi.user.getCurrent()
const postsPromise = backendApi.posts.list()
return (
<SWRConfig
value={{
fallback: {
[unstable_serialize(getCurrentUserKey())]: userPromise,
[unstable_serialize(getPostListKey())]: postsPromise,
},
}}
>
{children}
</SWRConfig>
)
}
```
Подробнее о стратегиях запросов и начальных данных для клиентских хуков: [REST → Стратегии получения данных](../data/rest/strategies/index.md), [REST → Начальные данные для клиентских хуков](../data/rest/strategies/client-hooks-initial-data.md).
## Инициализация состояния
Файл роутинга может подключить готовый провайдер стора страницы, если состояние зависит от маршрута или данных первого рендера. Реализация стора и провайдера не размещается в `src/app/**`.
```tsx
import { ProfileScreen, ProfileStoreProvider } from 'screens/profile'
type ProfilePageProps = {
params: Promise<{ id: string }>
}
export default async function ProfilePage({ params }: ProfilePageProps) {
const { id } = await params
return (
<ProfileStoreProvider initialState={{ userId: id }}>
<ProfileScreen />
</ProfileStoreProvider>
)
}
```
## Layout
`layout.tsx` подключает готовую инициализацию приложения: глобальные стили, провайдеры и верхнеуровневые обёртки из нижних слоёв.
Вёрстка layout-каркаса выносится в слой `layouts/`. Реализация провайдеров, стилей и UI не размещается в `app/`.
## Error и Not Found
`error.tsx` и `not-found.tsx` делегируют разметку готовым screen или widget. В файле роутинга остаётся только адаптация API Next.js к пропсам нижнего слоя.
```tsx
'use client'
import { ErrorScreen } from 'screens/error'
type ErrorPageProps = {
error: Error & { digest?: string }
reset: () => void
}
const ErrorPage = ({ error, reset }: ErrorPageProps) => {
return <ErrorScreen error={error} reset={reset} />
}
export default ErrorPage
```

View File

@@ -1,70 +0,0 @@
---
title: PostCSS
description: Установка и настройка CSS-процессора в новом проекте.
keywords: [postcss, postcss.config.mjs, postcss-custom-media, postcss-nesting, autoprefixer, postcss-global-data, csstools, "@custom-media", "@nest", css-процессор]
---
# PostCSS
Установка и настройка CSS-процессора в новом проекте.
## Зачем PostCSS
Подключаем ради двух вещей:
- **Вложенность** — `&:hover`, `&::before`, `&._active` и `@media` внутри селектора. Без процессора нативный CSS не покрывает всех нужных кейсов вложенности.
- **`@custom-media`** — единые breakpoints проекта (`@media (--md)`) вместо магических `min-width`. Определяются в одном месте, переиспользуются везде.
Autoprefixer и `@csstools/postcss-global-data` идут довеском под эти две задачи.
## Требования
- Next.js 14+ (App Router).
- Node.js 18+.
CSS Modules поддерживаются Next.js из коробки — отдельной установки не требуют.
## Установка
1. Установить PostCSS-плагины как devDependencies:
```bash
npm install -D postcss-custom-media postcss-nesting autoprefixer @csstools/postcss-global-data
```
2. Создать `postcss.config.mjs` в корне проекта (см. «Конфиг»).
## Конфиг
Файл `postcss.config.mjs` в корне проекта.
```js
// postcss.config.mjs
export default {
plugins: {
'@csstools/postcss-global-data': {
files: ['src/shared/styles/media.css'],
},
'postcss-custom-media': {},
'postcss-nesting': {},
autoprefixer: {},
},
}
```
### Разбор плагинов
| Плагин | Назначение |
|--------|------------|
| `@csstools/postcss-global-data` | Подгружает определения `@custom-media` из `src/shared/styles/media.css` перед обработкой каждого CSS-модуля. Семантика — «глобальный файл определений, который не импортируется в исходники» |
| `postcss-custom-media` | Поддержка `@custom-media --md (...)` и использования `@media (--md) {}`. Определения берутся из файла, который подгрузил `postcss-global-data` |
| `postcss-nesting` | Нативная CSS-вложенность: `&:hover`, `&::before`, `&._active` |
| `autoprefixer` | Добавление вендорных префиксов по browserslist |
### Почему внешний файл с `@custom-media`, а не `@import`
`@custom-media` — глобальные определения, одинаковые для всего проекта. Держим их в `src/shared/styles/media.css`. `@csstools/postcss-global-data` подгружает этот файл перед каждым модулем, а `postcss-custom-media` заменяет `@media (--md)` на конкретные `@media (min-width: ...)` на этапе сборки. Сами определения в бандл не попадают.
Опция `importFrom` у `postcss-custom-media` удалена в v10+; её роль теперь выполняет `@csstools/postcss-global-data`.
Импортировать `media.css` в файлы компонентов **не нужно** и запрещено правилами (см. [Использование стилей](./styles/styles-usage.md), раздел «Импорт стилей»).

View File

@@ -1,101 +0,0 @@
---
title: Структура проекта
description: Из чего состоит проект и где что лежит.
---
# Структура проекта
Из чего состоит проект и где что лежит.
## Корень репозитория
```text
project-root/
├── .templates/ # Шаблоны для генерации модулей
├── .vscode/ # Настройки и рекомендуемые расширения VS Code
├── public/ # Статика, доступная по прямому URL
├── src/ # Исходный код приложения
├── .env.example # Переменные окружения проекта (шаблон)
├── .env # Переменные окружения проекта (не коммитить)
├── .gitignore
├── AGENTS.md # Инструкции для AI-агентов
├── biome.json # Линтер и форматтер (вместо ESLint + Prettier)
├── next.config.ts # Конфигурация Next.js
├── package.json # Зависимости и скрипты
├── postcss.config.mjs # Конфигурация PostCSS
└── tsconfig.json # Конфигурация TypeScript
```
## Папка `public/`
Хранит статические файлы, которые отдаются по прямому URL без обработки сборщиком:
```text
public/
└── og-image.png
```
Компоненты, стили и другой исходный код здесь не размещаются.
## Папка `src/`
```text
src/
├── app/ # Роутинг Next.js и точка входа приложения
├── layouts/ # Каркасы страниц (header, footer, sidebar)
├── screens/ # Контент конкретной страницы
├── widgets/ # Составные блоки интерфейса, не привязанные к домену
├── business/ # Бизнес-домены (auth, catalog, orders)
├── infra/ # Техсервисы (theme, i18n, API-адаптеры)
├── ui/ # UI-кит без бизнес-логики
└── shared/ # Общие ресурсы (утилиты, типы, стили)
```
Принципы организации слоёв описаны в разделе [Архитектура](../basics/architecture/).
### Папка `app/`
Точка входа приложения и файловый роутинг Next.js (`layout.tsx`, `page.tsx`, route-сегменты).
`app/` подключает готовую инициализацию из нижних слоёв, но не реализует провайдеры, стили, UI-компоненты, хуки, сторы или сервисы.
Подробнее о границах слоя: [Архитектура → Слои → App](../basics/architecture/layers.md#слой-app).
```text
src/app/
├── layout.tsx # Корневой layout
└── page.tsx # Главная страница
```
## Папка `.templates/`
Содержит шаблоны для генерации кода. Каждый подкаталог — шаблон отдельного типа модуля:
```text
.templates/
├── component/ # Шаблон компонента
├── screen/ # Шаблон экрана
├── layout/ # Шаблон layout
├── widget/ # Шаблон виджета
├── module/ # Шаблон бизнес-модуля
└── store/ # Шаблон стора
```
Подробнее о генерации описано в разделе [Шаблоны генерации](./templates/templates-intro.md).
## Конфигурационные файлы
| Файл | Назначение |
|---|---|
| `next.config.ts` | Настройки Next.js: редиректы, переменные окружения, webpack |
| `tsconfig.json` | Настройки TypeScript: пути, строгость, таргет |
| `biome.json` | Правила линтера и форматтера Biome |
| `postcss.config.mjs` | Подключение PostCSS-плагинов (CSS Modules, custom media) |
| `package.json` | Зависимости, версии, npm-скрипты |
| `AGENTS.md` | Инструкции для AI-агентов, работающих в проекте |
## Переменные окружения
- `.env` — переменные окружения проекта, запрещено коммитить
- `.env.example` — шаблон, коммитится в репозиторий
Переменные с префиксом `NEXT_PUBLIC_` доступны в клиентском коде. Остальные доступны только на сервере.

View File

@@ -1,176 +0,0 @@
---
title: Настройка стилей
description: "Подготовка стилевой основы проекта: токены, медиа-запросы, глобальные стили."
keywords: [variables.css, media.css, global.css, shared/styles, токены, переменные, custom-media, breakpoints, подключение стилей, базовые стили, инициализация]
---
# Настройка стилей
Подготовка стилевой основы проекта: токены, медиа-запросы, глобальные стили.
## Требования
- Установлен PostCSS или любой другой pre/post-процессор с поддержкой `@custom-media`.
## Файлы
Состав глобальных стилей — три файла:
| Файл | Роль |
|------|------|
| `variables.css` | Токены проекта (цвета, отступы, радиусы) |
| `media.css` | Custom media queries (брейкпоинты по ширине и высоте) |
| `global.css` | Точка сборки глобальных стилей: через `@import` тянет все остальные глобалы, импортируется в приложение один раз |
Правила подключения:
- В приложение импортируется **только** `global.css`.
- `variables.css` и будущие глобальные файлы (резеты, темы, типографика) подключаются в `global.css` через `@import`.
- `media.css` **не импортируется** — ни в `global.css`, ни в компоненты, ни в точку инициализации. Его читает CSS-процессор на этапе сборки (см. [PostCSS](../postcss.md)).
## Корневой `font-size`
Базовая единица `rem` в проекте привязана к **16px**: корневой `font-size` не переопределяется. `html { font-size: ... }` писать запрещено — пользовательская настройка размера шрифта в браузере должна работать (a11y). Все `rem`-значения в `media.css` и других стилях трактуются как `1rem = 16px по умолчанию`.
Reset браузерных дефолтов (`box-sizing`, сброс `margin`, типографика) каноном не задаётся — каждый проект решает сам. Если заводится — подключается через `global.css`.
## Установка
### 1. Создать файлы
```bash
mkdir -p src/shared/styles
touch src/shared/styles/variables.css src/shared/styles/media.css src/shared/styles/global.css
```
### 2. Заполнить `media.css`
Файл `src/shared/styles/media.css`. Стандартный набор брейкпоинтов проекта; редактировать только при согласованном изменении шкалы.
Единица — `rem` (реагирует на корневой `font-size`). Перевод исходит из дефолтного `html { font-size: 16px }`, т.е. `1rem = 16px`.
```css
/* src/shared/styles/media.css */
/* Ширина — Mobile First (min-width), кроме --xs (max-width) */
@custom-media --xs (max-width: 35.9375rem); /* 575px — до sm */
@custom-media --sm (min-width: 36rem); /* 576px — телефон альбом / малый планшет */
@custom-media --md (min-width: 48rem); /* 768px — планшет */
@custom-media --lg (min-width: 62rem); /* 992px — малый десктоп */
@custom-media --xl (min-width: 75rem); /* 1200px — десктоп */
@custom-media --2xl (min-width: 88rem); /* 1408px — широкий десктоп */
@custom-media --3xl (min-width: 120rem); /* 1920px — full HD+ */
/* Высота — min-height */
@custom-media --h-xs (min-height: 41.6875rem); /* 667px — iPhone SE портрет */
@custom-media --h-sm (min-height: 43.875rem); /* 702px */
@custom-media --h-md (min-height: 50.625rem); /* 810px — iPad портрет */
@custom-media --h-lg (min-height: 56.25rem); /* 900px */
@custom-media --h-xl (min-height: 62.5rem); /* 1000px */
@custom-media --h-2xl (min-height: 68.75rem); /* 1100px */
@custom-media --h-3xl (min-height: 75rem); /* 1200px */
```
Правила:
- только `@custom-media` на верхнем уровне;
- имена короткие, по шкале (`--xs``--3xl`); высотные — с префиксом `--h-`;
- единица — `rem`, не `em`/`px`; пиксельное значение указывается комментарием;
- значения ширины — `min-width` (Mobile First), исключение `--xs``max-width` (блок «строго меньше `--sm`»);
- значения высоты — `min-height`.
### 3. Заполнить `variables.css`
Файл `src/shared/styles/variables.css`. Набор токенов под проект расширяется по мере роста дизайн-системы.
```css
/* src/shared/styles/variables.css */
:root {
/* Цвета */
--color-primary: #3b82f6;
--color-bg: #ffffff;
--color-bg-hover: #f5f5f5;
--color-text: #1a1a1a;
/* Отступы */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
/* Скругления */
--radius-1: 4px;
--radius-2: 8px;
}
```
Правила:
- все токены определяются в `:root` — без вложенных селекторов;
- именование — `kebab-case` по ролям: `--color-*`, `--space-*`, `--radius-*`;
- `px` — основная единица для пространственных токенов;
- темы накладываются поверх через `[data-theme="..."] { ... }` — в отдельном файле темы или здесь же.
`variables.css` напрямую в приложение не импортируется — только через `global.css`.
### 4. Заполнить `global.css`
Файл `src/shared/styles/global.css`. Единственный глобальный файл, импортируемый в точку инициализации приложения. Внутри — `@import` остальных глобалов относительным путём.
```css
/* src/shared/styles/global.css */
@import './variables.css';
/* Сюда же подключаются будущие глобалы через @import:
* @import './reset.css';
* @import './typography.css';
* @import './themes.css';
* media.css НЕ импортируется — он работает через PostCSS.
*/
```
Правила:
- пути в `@import` — относительные (`./variables.css`), не через алиасы; нативный CSS `@import` не понимает tsconfig-paths;
- `media.css` в `global.css` **не импортируется**;
- собственные глобальные правила (`html { ... }`, `body { ... }`) писать **не здесь**, а в отдельных файлах рядом (`reset.css`, `typography.css`) и подключать через `@import`. `global.css` — только точка сборки;
- порядок `@import` определяет порядок каскада: токены первыми, дальше резеты / темы / типографика.
### 5. Подключить `global.css` в layout
Импорт делается **один раз** — в корневом layout приложения:
```tsx
// src/app/layout.tsx
import 'shared/styles/global.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'App',
description: '',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>{children}</body>
</html>
)
}
```
`variables.css` и `media.css` в layout **не импортируются напрямую** — только через `global.css` (variables) или через PostCSS на сборке (media).
## Проверка установки
- В `src/shared/styles/` присутствуют три файла: `variables.css`, `media.css`, `global.css`. В `src/app/` папки `styles/` нет.
- В `src/app/layout.tsx` есть `import 'shared/styles/global.css'`. Импортов `variables.css` и `media.css` там нет.
- В проекте **не появились** PostCSS-пакеты и `postcss.config.*` — этот раздел их не ставит.
- `npm run build` завершается успешно.
## Дальше
- [PostCSS](../postcss.md) — подключить процессор, чтобы заработали `@media (--md)` и вложенность.
- [Использование стилей](./styles-usage.md) — правила написания CSS в компонентах.
- [SVG-спрайты](../svg-sprites/svg-sprites-setup.md) — стили иконок отдельно от глобальных.

View File

@@ -1,271 +0,0 @@
---
title: Использование стилей
description: Как пишутся стили в проекте.
---
# Использование стилей
Как пишутся стили в проекте.
## Общие правила
- Только **PostCSS** и **CSS Modules** для кастомной стилизации.
- Подход **Mobile First** — стили пишутся от мобильных к десктопу.
- Именование классов — `camelCase` (`.root`, `.buttonNext`, `.itemTitle`).
- Корневой класс каждого CSS Module компонента всегда называется `.root` — это упрощает ориентацию в DevTools и отладку DOM.
- Модификаторы — отдельный класс с `_`, применяется через `&._modifier`.
**Хорошо**
```css
.submitButton {
padding: 8px 16px;
&._disabled {
opacity: 0.5;
}
}
```
**Плохо**
```css
/* Плохо: kebab-case и вложенный элемент вместо отдельного класса. */
.submit-button {
padding: 8px 16px;
&__icon {
margin-right: 8px;
}
}
```
## Вложенность
- Вложенность селекторов запрещена.
- Исключения:
- Псевдоклассы: `&:hover`, `&:active`, `&:focus`, `&:disabled` и т.д.
- Псевдоэлементы: `&::before`, `&::after`.
- Медиа-запросы: `@media`.
- Модификаторы: `&._active`, `&._disabled`.
- Каждый вложенный блок отделяется пустой строкой от предыдущих свойств.
**Хорошо**
```css
.card {
padding: 16px;
background-color: var(--color-bg);
&:hover {
background-color: var(--color-bg-hover);
}
&::after {
content: '';
display: block;
}
&._highlighted {
border-color: var(--color-primary);
}
@media (--md) {
padding: 24px;
}
}
.cardTitle {
font-size: 16px;
@media (--md) {
font-size: 20px;
}
}
```
**Плохо**
```css
/* Плохо: вложенность селекторов, нет пустых строк между блоками. */
.card {
padding: 16px;
.cardTitle {
font-size: 16px;
}
&:hover {
background-color: var(--color-bg-hover);
}
}
```
## Медиа-запросы
- Только **Custom Media Queries**: `@media (--md) {}`.
- Запрещены произвольные breakpoints: `@media (min-width: 768px)`.
- `@media` пишется только **внутри** селектора.
- Запрещено писать `@media` на верхнем уровне с селекторами внутри.
**Хорошо**
```css
.sidebar {
display: none;
@media (--md) {
display: block;
}
}
.sidebarTitle {
font-size: 14px;
@media (--md) {
font-size: 18px;
}
}
```
**Плохо**
```css
/* Плохо: @media на верхнем уровне с селекторами внутри. */
@media (--md) {
.sidebar {
display: block;
}
.sidebarTitle {
font-size: 18px;
}
}
/* Плохо: произвольный breakpoint вместо custom media. */
.sidebar {
@media (min-width: 992px) {
display: block;
}
}
```
## CSS-переменные
- Цвета (`--color-*`), отступы (`--space-*`), скругления (`--radius-*`) определяются в `src/shared/styles/variables.css` через `:root`.
- Файл переменных подключается через `src/shared/styles/global.css`, который импортируется один раз в `src/app/layout.tsx`.
- Не дублировать магические значения в компонентах.
**Хорошо**
```css
/* src/shared/styles/variables.css */
:root {
--color-primary: #3b82f6;
--color-bg: #ffffff;
--color-bg-hover: #f5f5f5;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--radius-1: 4px;
--radius-2: 8px;
}
```
```css
/* компонент */
.card {
padding: var(--space-3);
border-radius: var(--radius-2);
background-color: var(--color-bg);
}
```
**Плохо**
```css
/* Плохо: магические значения вместо переменных. */
.card {
padding: 12px;
border-radius: 8px;
background-color: #ffffff;
}
```
## Custom Media
- Breakpoints определяются через Custom Media Queries в `src/shared/styles/media.css`.
- Custom media подключаются глобально через конфиг PostCSS (плагин `postcss-custom-media`) — не импортировать в файлы стилей.
```css
/* src/shared/styles/media.css */
@custom-media --sm (min-width: 36em);
@custom-media --md (min-width: 62em);
@custom-media --lg (min-width: 82em);
```
## Импорт стилей
- Стили компонента импортируются только внутри своего компонента.
- Запрещено импортировать стили одного компонента в другой.
- Custom media не импортируются в файлы стилей — они подключаются глобально через конфиг PostCSS.
## Форматирование
- Пустая строка между селекторами верхнего уровня.
- Пустая строка перед каждым вложенным блоком (медиа, псевдокласс, модификатор).
**Хорошо**
```css
.userBar {
display: none;
color: var(--color-text);
@media (--md) {
display: flex;
}
}
.userBarButton {
background-color: var(--color-bg);
&:hover {
background-color: var(--color-bg-hover);
}
&._active {
background-color: var(--color-primary);
}
}
```
**Плохо**
```css
/* Плохо: нет пустых строк между селекторами и вложенными блоками. */
.userBar {
display: none;
color: var(--color-text);
@media (--md) {
display: flex;
}
}
.userBarButton {
background-color: var(--color-bg);
&:hover {
background-color: var(--color-bg-hover);
}
&._active {
background-color: var(--color-primary);
}
}
```
## Единицы измерения
- `px` — основная единица измерения.
- Остальные (`em`, `rem`, `%`, `vh`/`vw`) — допускаются по необходимости дизайна.
## Порядок CSS-свойств
В стилях рекомендуется придерживаться логического порядка свойств:
1. Позиционирование (`position`, `top`, `left`, `z-index`).
2. Блочная модель (`display`, `width`, `height`, `margin`, `padding`).
3. Оформление (`background`, `border`, `box-shadow`, `border-radius`).
4. Текст (`font`, `color`, `text-align`, `line-height`).
5. Прочее (`transition`, `animation`, `opacity`, `cursor`).
## Комментарии
- Желательно не писать комментарии в CSS.
- Исключение — нетривиальные хаки и обходные решения, к которым стоит оставить пояснение.

View File

@@ -1,31 +0,0 @@
---
title: SVG-спрайты
description: "Что такое SVG-спрайты и какие проблемы они решают."
---
# SVG-спрайты
Что такое SVG-спрайты и какие проблемы они решают.
## Проблема
Иконки в проекте — это десятки и сотни SVG-файлов, которые нужно как-то доставлять в интерфейс. Подход «один `<img>` на иконку» или инлайн SVG в каждом компоненте приводят к трём проблемам:
- **Дублирование.** Инлайн SVG в нескольких компонентах — один и тот же код размазан по проекту. Изменение иконки требует правок в десяти местах.
- **Размер бандла.** Каждый инлайн SVG — полный XML-код, который попадает в JS-бандл. Сотня иконок × средний размер SVG = сотни килобайт, которые браузер парсит как JavaScript, а не как статику.
- **Нет управления цветом.** Инлайн SVG жёстко закрашивает иконку. Сменить цвет по состоянию (`:hover`, `._disabled`) — значит дублировать SVG или городить `currentColor`-хаки в каждом компоненте.
## Решение
SVG-спрайты — это единый файл-контейнер, в который собираются все иконки проекта. В коде используется один React-компонент `<SvgSprite icon="name"/>`, а браузер загружает спрайт как статику — один раз, с кешированием.
Что дают SVG-спрайты:
- **Один источник.** Каждая иконка — один SVG-файл в `src/shared/sprites/`. Обновил файл — иконка обновилась везде.
- **Лёгкий бандл.** Спрайт отдаётся как статический файл из `public/`, не попадает в JavaScript. Типы имён иконок генерируются автоматически — автодополнение работает без ручных описаний.
- **Цвет через CSS.** При сборке цвета в SVG заменяются на CSS-переменные. Цвет иконки меняется через `color` родителя или через переменные `--icon-color-N` — как любой другой стиль.
## Состав раздела
- [Настройка](./svg-sprites-setup.md) — подключение пакета, конфигурация, первая генерация.
- [Использование](./svg-sprites-usage.md) — добавление иконок, компонент `<SvgSprite/>`, управление цветом.

View File

@@ -1,132 +0,0 @@
---
title: Настройка SVG-спрайтов
description: Подключение SVG-спрайтов в новом проекте.
keywords: [svg-sprites, установка, настройка, config, пакет, "@gromlab/svg-sprites", svg-sprites.config.ts]
---
# Настройка SVG-спрайтов
Подключение SVG-спрайтов в новом проекте.
## Установка
1. Установить пакет:
```bash
npm install @gromlab/svg-sprites
```
2. Создать `svg-sprites.config.ts` в корне проекта (см. [Стандартный конфиг](#стандартныи-конфиг)).
3. Создать папку входа для SVG-файлов в слое `shared`:
```bash
mkdir -p src/shared/sprites/icons
```
Источники спрайтов живут в `src/shared/sprites/<group>/` — это слой `shared` SLM-архитектуры (см. [Структура проекта](../project-structure.md), [Архитектура](../../basics/architecture/index.md)). В `src/` посторонних каталогов вне слоёв не заводим.
4. Добавить скрипты в `package.json`:
```json
{
"scripts": {
"sprite": "svg-sprites",
"predev": "svg-sprites",
"prebuild": "svg-sprites"
}
}
```
Хуки `predev` и `prebuild` гарантируют, что спрайты и типы всегда актуальны перед запуском и сборкой.
5. Добавить сгенерированные артефакты в `.gitignore`:
```text
# Сгенерированные спрайты и React-компонент
/public/sprites/
/src/ui/svg-sprite/
```
6. Выполнить первую генерацию:
```bash
npm run sprite
```
7. Подключить спрайт в layout. Глобальный спрайт (иконки) подключается через `<link rel="preload">` в корневом layout — браузер загрузит файл заранее и закеширует:
```tsx
// src/app/layout.tsx
import 'shared/styles/global.css'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'App',
description: '',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<head>
<link rel="preload" href="/sprites/icons.sprite.stack.svg" as="image" />
</head>
<body>{children}</body>
</html>
)
}
```
Локальные спрайты (если есть) подключаются аналогично в layout конкретной страницы или маршрута.
## Стандартный конфиг
Файл `svg-sprites.config.ts` в корне проекта. Это канон — отклонения только по явной причине.
```ts
// svg-sprites.config.ts
import { defineConfig } from '@gromlab/svg-sprites'
export default defineConfig({
output: 'public/sprites',
publicPath: '/sprites',
react: 'src/ui/svg-sprite',
sprites: [
{ name: 'icons', input: 'src/shared/sprites/icons' },
],
})
```
### Фиксированные значения
| Опция | Значение | Почему так |
|-------|----------|------------|
| `output` | `public/sprites` | Единая папка статики Next.js |
| `publicPath` | `/sprites` | URL-путь без `public/` (Next.js раздаёт `public/` как `/`) |
| `react` | `src/ui/svg-sprite` | Слой `ui/` из архитектуры проекта (→ [Архитектура](../../basics/architecture/index.md)) |
| `sprites[0].name` | `icons` | Основной спрайт всегда называется `icons` |
### Трансформации
Все значения по умолчанию оставлять включёнными:
```ts
transform: {
removeSize: true,
replaceColors: true,
addTransition: true,
}
```
Явно прописывать блок `transform` не нужно — пакет применяет эти значения по умолчанию.
Отключать `replaceColors` — только для отдельного спрайта с фиксированной палитрой (например, брендовые логотипы). Делать это на уровне спрайта, не глобально.
### Режим
По умолчанию `mode: 'stack'` — не указывать явно. Переход на `symbol` требует обоснования: превью и примеры в пакете оптимизированы под `stack`.
## Дальше
- [Использование](./svg-sprites-usage.md) — добавление иконок, компонент `<SvgSprite/>`, управление цветом.

View File

@@ -1,56 +0,0 @@
---
title: Использование SVG-спрайтов
description: Как добавлять и использовать SVG-иконки в коде.
keywords: [svg, спрайт, sprite, иконка, icon, SvgSprite, превью, preview, цвет, color]
---
# Использование SVG-спрайтов
Как добавлять и использовать SVG-иконки в коде.
## Шаги
1. **Положить SVG в папку спрайта:**
```text
src/shared/sprites/icons/new-icon.svg
```
2. **Импортировать компонент.** Компонент `<SvgSprite/>` генерируется пакетом вместе с типами имён иконок — автодополнение работает без ручных описаний:
```tsx
import { SvgSprite } from 'ui/svg-sprite'
<SvgSprite icon="new-icon" />
```
3. **Посмотреть и пощупать иконку — в превью.** Пакет генерирует HTML-превью рядом со спрайтом (`public/sprites/icons.preview.html`). Там виден набор иконок, имена и поведение цвета.
## Управление цветом
При сборке цвета в SVG заменяются на CSS-переменные `--icon-color-N`. Управление — через обычный CSS родителя.
**Моно-иконка** наследует `color` родителя (`--icon-color-1` по умолчанию `currentColor`):
```css
.button {
color: var(--color-primary);
}
```
**Точечное переопределение** — через переменную:
```css
.icon-danger {
--icon-color-1: var(--color-danger);
}
```
**Мульти-иконка** — переменные задаются явно, порядок виден в превью:
```css
.folder {
--icon-color-1: var(--color-folder-bg);
--icon-color-2: var(--color-folder-accent);
}
```

View File

@@ -1,97 +0,0 @@
---
title: Создание шаблонов генерации
description: "Структура шаблонов, синтаксис переменных и примеры."
keywords: [шаблоны, templates, .templates, syntax, переменные, kebabCase, pascalCase, scaffold]
---
<!-- @formatter:off -->
::: v-pre
# Создание шаблонов генерации
Структура шаблонов, синтаксис переменных и примеры.
## Структура шаблонов
Все шаблоны лежат в `.templates/` в корне проекта. Каждая папка — отдельный шаблон.
```text
.templates/
├── component/ # шаблон компонента
│ └── {{name.kebabCase}}/
│ ├── styles/
│ │ └── {{name.kebabCase}}.module.css
│ ├── types/
│ │ └── {{name.kebabCase}}-props.type.ts
│ ├── {{name.kebabCase}}.tsx
│ └── index.ts
└── store/ # шаблон Zustand стора
└── {{name.kebabCase}}/
├── {{name.kebabCase}}.store.ts
├── {{name.kebabCase}}.type.ts
└── index.ts
```
## Обязательный шаблон компонента
Перед созданием компонентов в проекте должен существовать шаблон `.templates/component`.
Если шаблона нет, компонент не создаётся вручную. Сначала создаётся шаблон компонента, затем компонент генерируется через [VS Code или CLI](./templates-usage.md).
## Синтаксис шаблонов
### Переменные
Переменные работают в именах файлов/папок и внутри файлов:
```text
{{variable}}
```
Переменные могут быть любыми. `name` — дефолтная, подставляется генератором автоматически. Если реализация требует дополнительных параметров — можно использовать произвольные наборы переменных.
### Модификаторы
Модификаторы меняют регистр и формат записи переменной:
```text
{{name.pascalCase}} → MyButton
{{name.camelCase}} → myButton
{{name.kebabCase}} → my-button
{{name.snakeCase}} → my_button
{{name.screamingSnakeCase}} → MY_BUTTON
```
## Как создать новый шаблон
1. Создать папку в `.templates/` с именем шаблона (например `hook`).
2. Внутри разместить файлы и папки, используя `{{name}}` и модификаторы в именах и содержимом.
3. Шаблон сразу доступен и в расширении VS Code, и в CLI.
Пример — создание шаблона для хука:
```text
.templates/
└── hook/
└── {{name.kebabCase}}/
├── {{name.kebabCase}}.hook.ts
└── index.ts
```
```ts
// .templates/hook/{{name.kebabCase}}.hook.ts
export const {{name.camelCase}} = () => {
}
```
```ts
// .templates/hook/index.ts
export { {{name.camelCase}} } from './{{name.kebabCase}}.hook'
```
## Дальше
- [Использование](./templates-usage.md) — генерация через VS Code плагин и CLI.
:::

View File

@@ -1,32 +0,0 @@
---
title: Шаблоны генерации
description: "Что такое шаблоны кодогенерации и какие проблемы они решают."
---
# Шаблоны генерации
Что такое шаблоны кодогенерации и какие проблемы они решают.
## Проблема
Каждый новый модуль в проекте — компонент, стор, бизнес-модуль — требует однотипной структуры файлов и boilerplate-кода. Ручное создание приводит к трём проблемам:
- **Расхождения.** Разные разработчики создают модули по-разному: забывают `index.ts`, называют типы не по канону, пропускают стили.
- **Время.** Создание одного компонента с типами, стилями и экспортом — 510 минут рутины. За спринт набегают часы.
- **Ошибки копипасты.** Копирование существующего модуля и переименование — источник опечаток и забытых ссылок.
## Решение
Шаблоны кодогенерации — это папки с файлами-заготовками в `.templates/`. Вместо ручного создания файлов разработчик вызывает генератор, указывает имя — и получает готовый модуль со всей структурой, именами и boilerplate, подставленными автоматически.
Что дают шаблоны:
- **Единообразие.** Все модули одного типа идентичны по структуре. Канон живёт в шаблоне, а не в памяти разработчика.
- **Скорость.** Генерация модуля — одна команда. Остальное время — на бизнес-логику.
- **Согласованность с архитектурой.** Шаблоны учитывают SLM: правильные слои, сегменты, экспорты. Отклонение от стайлгайда требует осознанного усилия, а не случайного упущения.
## Состав раздела
- [Настройка](./templates-setup.md) — первичная установка: скачивание стандартного набора шаблонов в проект.
- [Создание шаблонов](./templates-create.md) — структура файлов, синтаксис переменных, примеры.
- [Использование](./templates-usage.md) — генерация через VS Code плагин и CLI.

View File

@@ -1,44 +0,0 @@
---
title: Настройка шаблонов генерации
description: Первичная установка шаблонов кодогенерации в проект.
keywords: [шаблоны, templates, .templates, tiged, generator, генератор шаблонов, скачать шаблоны, scaffold]
---
# Настройка шаблонов генерации
Первичная установка шаблонов кодогенерации в проект.
## Установка
1. Проверить, что `.templates/` отсутствует (или согласовать перезапись, если папка уже есть).
2. Скачать папку из эталонного репозитория:
```bash
npx tiged git@gromlab.ru:templates/nextjs-template.git/.templates .templates
```
3. Если `tiged` падает в default-режиме (HTTP-tarball у Gitea) — повторить с явным git-режимом:
```bash
npx tiged --mode=git git@gromlab.ru:templates/nextjs-template.git/.templates .templates
```
4. Проверить генерацию:
```bash
npx @gromlab/create component test src/ui
```
После проверки — удалить тестовый модуль.
## Проверка установки
- В корне проекта есть папка `.templates/`.
- Внутри `.templates/` присутствуют стандартные шаблоны (или согласованный кастомный набор).
- Пробная генерация через `npx @gromlab/create ...` отрабатывает без ошибок.
## Дальше
- [Создание шаблонов](./templates-create.md) — структура файлов, синтаксис переменных, примеры.
- [Использование](./templates-usage.md) — генерация через VS Code плагин и CLI.

View File

@@ -1,45 +0,0 @@
---
title: Использование шаблонов генерации
description: Генерация файлов из шаблонов через VS Code плагин и CLI.
keywords: [шаблоны, templates, generate, VS Code, CLI, gromlab/create, npx, scaffold]
---
# Использование шаблонов генерации
Генерация файлов из шаблонов через VS Code плагин и CLI.
::: danger Ручное создание запрещено
Файлы, для которых есть шаблоны в `.templates/`, создаются только генератором. Ручное создание компонента, модуля, стора или другого шаблонного блока запрещено.
Если нужного шаблона нет, сначала создайте шаблон в `.templates/`, затем сгенерируйте код на его основе.
:::
## Через VS Code
Template File Generator | gromlab ([Marketplace](https://marketplace.visualstudio.com/items?itemName=gromlab.vscode-templateFileGenerator), [Open VSX](https://open-vsx.org/extension/gromlab/vscode-templateFileGenerator)) — расширение для генерации файлов и папок из шаблонов через интерфейс редактора.
1. ПКМ на целевой папке в проводнике VS Code.
2. **Generate from template** → выбрать шаблон.
3. Ввести имя (например `button`) — расширение подставит его во все переменные `{{name}}`.
Расширение устанавливается разово на машину разработчика, не через проект.
## Через CLI
[@gromlab/create](https://www.npmjs.com/package/@gromlab/create) — CLI для генерации из тех же шаблонов. Используется через npx, глобальная установка не требуется.
```bash
npx @gromlab/create <шаблон> <имя> [путь]
```
Путь не обязателен — по умолчанию генерация происходит в текущую директорию.
| Команда | Что создаёт |
|---|---|
| `npx @gromlab/create component button` | Компонент в текущей папке |
| `npx @gromlab/create module auth src/business` | Бизнес-модуль |
| `npx @gromlab/create widget header src/widgets` | Виджет |
| `npx @gromlab/create layout admin src/layouts` | Layout |
| `npx @gromlab/create store auth src/business/auth/stores` | Стор |
CLI вызывается через `npx`, в `package.json` отдельно не добавляется.

View File

@@ -1,88 +0,0 @@
---
title: VS Code
description: Единые настройки редактора и расширений для команды.
---
# VS Code
Единые настройки редактора и расширений для команды.
## Структура `.vscode/`
```text
.vscode/
├── extensions.json # Рекомендуемые расширения
└── settings.json # Настройки редактора для проекта
```
Оба файла коммитятся в репозиторий.
## Расширения
Файл `.vscode/extensions.json` определяет список расширений, которые VS Code предложит установить при открытии проекта.
```json
// .vscode/extensions.json
{
"recommendations": [
"biomejs.biome",
"MyTemplateGenerator.mytemplategenerator",
"csstools.postcss"
]
}
```
| Расширение | Назначение |
|---|---|
| [Biome](https://marketplace.visualstudio.com/items?itemName=biomejs.biome) | Линтинг и форматирование кода. Заменяет ESLint и Prettier |
| Template File Generator \| gromlab ([Marketplace](https://marketplace.visualstudio.com/items?itemName=gromlab.vscode-templateFileGenerator), [Open VSX](https://open-vsx.org/extension/gromlab/vscode-templateFileGenerator)) | Генерация файлов и папок из шаблонов `.templates/` через контекстное меню |
| [PostCSS Language Support](https://marketplace.visualstudio.com/items?itemName=csstools.postcss) | Подсветка синтаксиса и автодополнение для PostCSS (`@custom-media`, `@nest` и др.) |
### Зачем это нужно
- Новый участник команды получает все нужные расширения одним кликом.
- Нет разночтений: все используют одинаковый форматтер и линтер.
- Расширения привязаны к проекту, а не к конкретному разработчику.
## Настройки редактора
Файл `.vscode/settings.json` переопределяет пользовательские настройки VS Code на уровне проекта.
```json
// .vscode/settings.json
{
"editor.defaultFormatter": "biomejs.biome",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit",
"source.organizeImports.biome": "explicit"
},
"files.associations": {
"*.css": "postcss"
}
}
```
### Разбор настроек
| Настройка | Значение | Что делает |
|---|---|---|
| `editor.defaultFormatter` | `biomejs.biome` | Biome используется как единственный форматтер для всех файлов |
| `editor.formatOnSave` | `true` | Код автоматически форматируется при каждом сохранении |
| `codeActionsOnSave.source.fixAll.biome` | `explicit` | Biome автоматически применяет безопасные исправления при сохранении |
| `codeActionsOnSave.source.organizeImports.biome` | `explicit` | Импорты сортируются и группируются автоматически при сохранении |
| `files.associations` | `"*.css": "postcss"` | Все CSS-файлы открываются с подсветкой PostCSS вместо стандартного CSS |
### Зачем это нужно
- **Единый стиль кода** -- форматирование происходит автоматически, невозможно закоммитить неформатированный код.
- **Автофикс при сохранении** -- распространённые ошибки линтинга исправляются без ручного вмешательства.
- **Сортировка импортов** -- импорты всегда в одном порядке, без конфликтов при мерже.
- **PostCSS-подсветка** -- кастомные at-правила (`@custom-media`, `@define-mixin`) подсвечиваются корректно, а не как ошибки.
## Что не должно быть в `.vscode/`
Не коммитятся файлы, специфичные для конкретного разработчика:
- **Не коммитить**: отладочные конфигурации с локальными путями, персональные сниппеты, настройки тем оформления.
- **Коммитить**: только `extensions.json` и `settings.json` с общими для команды настройками.

View File

@@ -1,108 +0,0 @@
# SLM Design
Scoped Layered Module Design — модульная архитектура фронтенд-приложений. Код организован по слоям ответственности, а модуль содержит всё, что ему нужно: компоненты, хуки, сторы, типы, стили.
::: warning Локальная копия
Документация по архитектуре — локальная копия. Оригинал находится на сайте [slm-design.gromlab.ru](https://slm-design.gromlab.ru/).
:::
## Разделы спецификации
Спецификация SLM Design состоит из нескольких связанных разделов. Этот обзор даёт общий контекст, а детальные правила описаны дальше:
- [Слои](./layers.md) — уровни организации `src/`, направление зависимостей и зона ответственности каждого слоя.
- [Модули](./modules.md) — границы ответственности, публичный API, типы модулей и отличие модуля от компонента.
- [Сегменты](./segments.md) — внутренние папки модуля (`ui/`, `parts/`, `hooks/`, `types/` и другие) и правила размещения файлов.
Рекомендуемый порядок чтения: обзор → слои → модули → сегменты.
## Преимущества
### Вертикальная организация домена
Бизнес-домен не разбивается по техническим слоям — сценарии, сущности, типы и UI живут в одном модуле. Это сокращает время навигации и упрощает сопровождение: все изменения домена локализованы.
### Dependency Injection без фреймворков
Cross-domain зависимости в бизнес-слое реализуются через фабрики — модуль декларирует что ему нужно, а точка использования предоставляет зависимости. Домены изолированы без DI-контейнеров, провайдеров и шин событий.
### Разделение ответственности без перегрузки слоёв
Сервисы приложения (`infra/`), UI-кит (`ui/`) и общие ресурсы (`shared/`) — три разных слоя с разной природой. Ни один слой не превращается в свалку разнородного кода.
### Горизонтальная инкапсуляция
Вложенные модули (`parts/`) и направление зависимостей позволяют нескольким разработчикам работать над одной областью приложения параллельно, не затрагивая код друг друга.
### Колокация по умолчанию
Код начинает жизнь рядом с местом использования и поднимается в общие слои только при реальной потребности. Глобальные слои не засоряются преждевременными абстракциями.
### Явное разделение каркаса и контента
Каркас группы маршрутов (`layouts/`) и контент конкретной страницы (`screens/`) — независимые слои с собственной ответственностью.
### Масштабирование через группировку
При росте проекта слои не теряют структуру — модули группируются по естественным признакам: бизнес-домены по субдоменам, страницы по разделам, UI-компоненты по уровню абстракции (примитивы и композиции).
## Происхождение
SLM Design вырос на основе:
- **Feature-Sliced Design** — слоистая структура, публичный API модуля, направление зависимостей
- **Vertical Slice Architecture** — модуль как вертикальный срез, содержащий всё необходимое
- **Screaming Architecture** — структура проекта «кричит» о назначении: открыл `business/auth` — видишь авторизацию
- **Colocation Principle** — код живёт рядом с местом использования
## Пример структуры проекта
```text
src/
├── app/
├── layouts/
│ ├── main/
│ └── dashboard/
├── screens/
│ ├── home/
│ ├── products/
│ ├── product-detail/
│ └── about/
├── widgets/
│ ├── page-heading/
│ ├── hero-section/
│ └── promo-banner/
├── business/
│ ├── auth/
│ ├── catalog/
│ ├── orders/
│ └── chat/
├── infra/
│ ├── theme/
│ ├── i18n/
│ ├── backend-api/
│ └── logger/
├── ui/
│ ├── button/
│ ├── input/
│ ├── modal/
│ ├── toast/
│ └── dropdown/
└── shared/
├── lib/
├── types/
└── styles/
```
## Принципы
- **Домен — единое целое.** Всё, что относится к домену, живёт в одном модуле.
- **Колокация.** Код рождается рядом с местом использования и поднимается только при необходимости.
- **Зависимости однонаправлены.** Импорты только сверху вниз, только через публичный API.
- **Архитектура — каркас, не клетка.** Правила фиксируют направление зависимостей и структуру модуля, остальное определяет команда.

View File

@@ -1,249 +0,0 @@
# Слои
Раздел описывает слои SLM: что такое слой, какие бывают, как между ними направлены зависимости и какие правила действуют на каждом.
## Определение
**Слой — уровень организации кода внутри `src/`. Каждый слой отвечает за свою область (каркас страницы, бизнес-логика, UI-кит) и задаёт правила для кода внутри: направление импортов, именование, допустимые связи между модулями.**
## Группы слоёв
Слои делятся на три группы:
| Группа | Слои | Описание |
|--------|------|----------|
| Композиция | `app`, `layouts`, `screens`, `widgets` | Собирают интерфейс из готовых блоков: маршруты, каркасы, страницы |
| Ядро | `business`, `infra`, `ui` | Реализация продукта: бизнес-домены, техсервисы, UI-кит |
| Фундамент | `shared` | Общие ресурсы: утилиты, хелперы, стили, конфиги |
## Направление зависимостей
Любой импорт между модулями — только через публичный API.
```
app → [ layouts | screens ] → widgets → business → infra → ui → shared
```
- `layouts` и `screens` — параллельные слои, не импортируют друг друга
- Модули одного слоя в группе «Композиция» изолированы друг от друга
- Модули одного слоя `infra` и `ui` могут импортировать друг друга через публичный API
- Модули `business` — cross-domain зависимости по коду через фабрику, `import type` напрямую
- Импорт типов (`import type`) в «Ядре» разрешён в обоих направлениях
## Слой App
Точка входа приложения. Отвечает за запуск, роутинг и композицию маршрутов из layout и screen.
В отличие от остальных слоёв, `app/` не содержит модулей SLM. Здесь живут только инфраструктурные файлы, которые не могут быть никаким другим слоем: файлы фреймворка роутинга, точка запуска и код инициализации.
### Требования
- Не содержит модулей SLM — только файлы фреймворка, роутинг, инициализация
- Содержит: файлы маршрутов, bootstrap, обработку ошибок верхнего уровня (404, error boundary), подключение глобальных стилей и ассетов
- Провайдеры и гарды — только подключает готовые из нижних слоёв, не реализует
- Не содержит бизнес-логику, UI-компоненты, хуки, сторы, сервисы
- Никем не импортируется
## Слой Layouts
Каркас страницы: общие элементы, одинаковые для группы маршрутов (header, footer, sidebar).
```text
src/layouts/
├── main/
├── dashboard/
└── auth/
```
### Требования
- Содержит только модули
- Не содержит бизнес-логику
- Контекстно-зависимые блоки принимает через пропсы от `app`, не импортирует напрямую
## Слой Screens
Контент конкретной страницы: собирает её из модулей нижних слоёв.
```text
src/screens/
├── home/
├── products/
├── product-detail/
├── about/
└── contacts/
```
Когда количество страниц затрудняет навигацию — вводится группировка по разделам. Группа — папка для организации, не модуль (без `index.ts`).
```text
src/screens/
├── shop/
│ ├── home/
│ ├── products/
│ ├── product-detail/
│ └── cart/
├── account/
│ ├── profile/
│ ├── settings/
│ └── order-history/
└── info/
├── about/
├── contacts/
└── faq/
```
### Требования
- Содержит только модули
- Не содержит бизнес-логику
- Локальные одноразовые секции живут внутри screen-модуля, не выносятся в `widgets`/`business`
## Слой Widgets
Составной блок интерфейса, который компонует модули ядра, но не принадлежит конкретному бизнес-домену. Widget появляется когда блок используется в нескольких screens или layouts.
Если блок принадлежит домену — он живёт в `business/{area}/`, даже если переиспользуется. Если блок нужен только в одном месте — это `screens/{name}/parts/` или `layouts/{name}/parts/`, а не widget.
```text
src/widgets/
├── page-heading/
├── hero-section/
├── onboarding-checklist/
├── promo-banner/
└── error-boundary/
```
### Требования
- Не принадлежит конкретному бизнес-домену. Если блок доменный — он живёт в `business/`
- Используется в нескольких screens или layouts
## Слой Business
Бизнес-домены приложения: auth, catalog, orders, checkout, chat. Каждый домен — отдельный модуль со своими типами, логикой, UI и сервисами.
Слой входит в группу «Ядро». Импортирует `infra/`, `ui/`, `shared/`. Каждый бизнес-модуль создаёт публичный runtime API через фабрику в корне. Cross-domain зависимости: runtime — через аргументы фабрики, типы — напрямую через `import type`.
Business объединяет то, что в FSD разделено на `features` и `entities`: пользовательские сценарии и бизнес-сущности живут вместе, внутри одного домена. Внутри домена сегменты разделяют ответственность: `types/` — доменная модель, `hooks/` и `services/` — сценарии и логика, `mappers/` — трансформация данных, `parts/` — составные блоки.
```text
src/business/
├── auth/
├── catalog/
├── orders/
├── checkout/
└── chat/
```
Когда количество доменов затрудняет навигацию — вводится группировка по субдоменам. Группа — папка для организации, не модуль (без `index.ts`).
```text
src/business/
├── commerce/
│ ├── catalog/
│ ├── cart/
│ ├── orders/
│ └── checkout/
└── communication/
├── chat/
└── notifications/
```
### Требования
- Один модуль = один бизнес-домен
- Циклические зависимости между доменами запрещены
- Публичный runtime API — через фабрику в корне модуля (`{name}.factory.ts`). `index.ts` экспортирует только фабрику и type-only экспорты
- Импорт runtime-кода между доменами — через фабрику. `import type` — напрямую
- Доменные типы (`User`, `Product`) живут здесь, не в `shared/`
## Слой infra
Техсервисы приложения: theme, i18n, API-адаптеры, logger, realtime. Каждый сервис — отдельный модуль.
Слой входит в группу «Ядро». Импортирует `infra/`, `ui/`, `shared/`.
Отличие от `shared/`: infra — инфраструктура приложения (сервисы, темы, адаптеры к API), `shared/` — общие ресурсы (утилиты, хелперы, стили, конфиги).
```text
src/infra/
├── theme/
├── i18n/
├── backend-api/
├── maps-api/
├── logger/
├── feature-flags/
└── realtime/
```
### Требования
- Один модуль = один техсервис
- Импортирует `infra/`, `ui/`, `shared/`
## Слой UI
UI-кит без бизнес-логики: button, carousel, toast, modal.
Слой входит в группу «Ядро». Импортирует `ui/` и `shared/`.
Компоненты строятся друг на друге: `button` использует `icon`, `carousel` использует `button`.
```text
src/ui/
├── button/
├── input/
├── icon/
├── carousel/
├── modal/
├── toast/
├── dropdown/
├── tabs/
└── tooltip/
```
Когда количество компонентов затрудняет навигацию — вводится группировка на примитивы и композиции. Примитивы (`button`, `icon`, `input`) не импортируют композиции. Композиции (`carousel`, `modal`, `dropdown`) строятся на примитивах.
```text
src/ui/
├── primitives/
│ ├── button/
│ ├── input/
│ ├── icon/
│ └── badge/
└── composites/
├── carousel/
├── modal/
├── dropdown/
├── tabs/
└── tooltip/
```
### Требования
- Не содержит бизнес-логику
- Импортирует только `ui/` и `shared/`
## Слой Shared
Общие ресурсы: утилиты, хелперы, стили, конфиги. Не знает о бизнес-домене.
Слой входит в группу «Фундамент» — ни о ком не знает, никого не импортирует.
Отличие от `infra/`: infra — инфраструктура приложения (сервисы, темы, адаптеры к API), `shared/` — общие ресурсы (утилиты, хелперы, стили, конфиги).
Отличие от `ui/`: UI-компоненты (button, carousel, modal) живут в слое `ui/`, а не здесь.
```text
src/shared/
├── lib/
├── types/
├── styles/
└── sprites/
```
### Требования
- Не имеет runtime-состояния

View File

@@ -1,284 +0,0 @@
# Модули
Раздел описывает модуль как границу ответственности в SLM: что считается модулем, что такое компонент внутри модуля и как модуль взаимодействует с остальным кодом.
## Определение
**Модуль — минимальная архитектурная единица SLM. Он живёт на одном из слоёв, владеет конкретной областью ответственности и предоставляет наружу только публичный API.**
Модуль может содержать всё, что нужно этой области: компоненты, вложенные модули, хуки, сторы, сервисы, типы, стили, конфиги и утилиты. Набор сегментов не фиксирован — модуль включает только то, что реально нужно.
Модуль не обязан быть UI-блоком. Это может быть страница, виджет, бизнес-домен, инфраструктурный сервис или UI-kit сущность.
Главная граница модуля — не папка, а ответственность.
## Компонент
**Компонент — презентационная единица модуля, которая находится только в `ui/` своего родительского модуля и отвечает за отображение части интерфейса.**
Компонент не является архитектурной единицей: он не владеет сценарием, зависимостями, данными или внутренней структурой. Он работает только внутри границы родительского модуля.
> Компонент отображает. Модуль организует.
Компонент не может:
- Импортировать код проекта за пределами родительского модуля.
- Владеть архитектурными зависимостями.
- Содержать любые компоненты.
- Содержать любые модули.
- Делать внешние запросы.
- Самостоятельно получать данные.
- Выбирать источник данных.
- Композировать данные.
- Вызывать сценарные хуки.
- Оркестрировать сценарий.
- Композировать модули.
- Решать, как устроен процесс.
- Содержать бизнес-логику.
- Содержать сценарную логику.
Если компоненту требуется что-то из этого списка, он перестаёт быть компонентом и должен быть оформлен как модуль.
```text
auth/
├── ui/
│ └── logout-button/
│ ├── logout-button.tsx
│ ├── styles/
│ │ └── logout-button.module.css
│ ├── types/
│ │ └── logout-button-props.type.ts
│ └── index.ts
└── index.ts
```
## Что считается модулем
Модулем считается папка, которая представляет самостоятельную область ответственности и имеет публичную границу.
Примеры модулей:
- `screens/home/` — модуль страницы.
- `widgets/page-heading/` — модуль виджета.
- `business/auth/` — модуль бизнес-домена.
- `infra/theme/` — модуль инфраструктурного сервиса.
- `ui/button/` — модуль UI-kit сущности.
- `screens/home/parts/hero-section/` — вложенный модуль страницы.
Не считаются модулями:
- `ui/`, `parts/`, `hooks/`, `types/`, `styles/`, `config/` — это сегменты.
- `screens/shop/`, `business/commerce/` — это группы, если в них нет `index.ts`.
- `screens/home/ui/user-card/` — это компонент, если он находится в `ui/` и соблюдает ограничения компонента.
## Типы модулей
Тип модуля определяет обязательный корневой файл и стартовую структуру.
### UI-модуль
Модуль строится вокруг основного UI-компонента и обязан иметь основной `.tsx` файл в корне:
```text
header/
├── header.tsx
└── index.ts
```
`ui/` внутри такого модуля используется только для компонентов, которые помогают корневому `.tsx` файлу.
### Бизнес-модуль
Бизнес-модуль — модуль, который строится вокруг публичного runtime API.
Бизнес-модуль обязан иметь фабрику в корне:
```text
auth/
├── auth.factory.ts
├── index.ts
└── types/
```
Фабрика возвращает публичный runtime API модуля.
### Инфраструктурный модуль
Инфраструктурный модуль — модуль, который строится вокруг технического сервиса или интеграции.
Инфраструктурный модуль не обязан иметь фиксированный корневой файл. Его структура определяется природой сервиса.
```text
theme/
├── index.ts
├── config/
├── hooks/
├── styles/
└── ui/
```
```text
backend-api/
├── backend-api.client.ts
├── config/
├── types/
└── index.ts
```
## Структура
Модуль состоит из сегментов. Ни один сегмент не обязателен — модуль включает только те части, которые нужны его ответственности.
```text
{module-name}/
├── {module-name}.factory.ts # фабрика (для business-модулей)
├── {module-name}.tsx # корневой файл модуля (опционален)
├── ui/ # компоненты модуля
├── parts/ # вложенные модули
├── hooks/ # хуки
├── stores/ # сторы состояния
├── services/ # внешние источники данных
├── mappers/ # трансформация данных между форматами
├── types/ # типы
├── styles/ # стили
├── lib/ # утилиты модуля
├── config/ # константы и конфигурация
└── index.ts # публичный API
```
Подробное описание сегментов — в разделе [Сегменты](./segments.md).
## Публичный API
Внешний код импортирует модуль только через публичный API.
```ts
// Хорошо
import { customerFactory } from '@/business/customer'
import type { Customer } from '@/business/customer'
```
```ts
// Плохо
import { validateToken } from '@/business/auth/lib/tokens'
```
`index.ts` модуля не обязан экспортировать всё содержимое. Он экспортирует только то, что действительно нужно снаружи.
Внутренние сегменты модуля остаются деталями реализации.
Business-модуль экспортирует из `index.ts` только фабрику и type-only экспорты. Хуки, компоненты, сервисы, мапперы и утилиты напрямую из `index.ts` не экспортируются — они доступны через API, который возвращает фабрика.
```ts
// business/customer/index.ts
export { customerFactory } from './customer.factory'
export type { Customer } from './types/customer.type'
export type { CustomerApi } from './types/customer-api.type'
export type { CustomerDeps } from './types/customer-deps.type'
export type { CustomerFactory } from './types/customer-factory.type'
```
## Фабрика
Business-модуль всегда экспортирует фабрику. Фабрика лежит в корне модуля (`{name}.factory.ts`), типизируется через `{Name}Factory` и возвращает публичный runtime API модуля.
Всё, что нужно внешнему коду в runtime, должно быть частью API, который возвращает фабрика.
Модуль без cross-domain зависимостей экспортирует фабрику без аргументов. Модуль с зависимостями — фабрику, принимающую зависимости доменными именами. Типы всегда экспортируются напрямую через `export type``import type` не является runtime-зависимостью.
Компоновка фабрик происходит на уровне модуля-потребителя: screen, layout, widget или любой другой модуль группы «Композиция».
### Структура business-модуля
```text
business/customer/
├── customer.factory.ts
├── index.ts
└── types/
├── customer.type.ts
├── customer-api.type.ts
├── customer-deps.type.ts
└── customer-factory.type.ts
```
### Типы
```ts
// business/customer/types/customer-api.type.ts
export type CustomerApi = {
useCustomer: () => Customer
CustomerCard: (props: CustomerCardProps) => ReactNode
}
```
```ts
// business/order/types/order-deps.type.ts
export type OrderDeps = {
customer: Pick<CustomerApi, 'useCustomer'>
}
```
```ts
// business/order/types/order-factory.type.ts
export type OrderFactory = (deps: OrderDeps) => OrderApi
```
### Фабрика без зависимостей
```ts
// business/customer/customer.factory.ts
import type { CustomerFactory } from './types/customer-factory.type'
export const customerFactory: CustomerFactory = () => {
return {
useCustomer,
CustomerCard,
}
}
```
### Фабрика с зависимостями
```ts
// business/order/order.factory.ts
import type { OrderFactory } from './types/order-factory.type'
export const orderFactory: OrderFactory = (deps) => {
return {
useOrder,
OrderCard,
}
}
```
### Композиция на уровне screen
```tsx
// screens/home/home.screen.tsx
import { customerFactory } from '@/business/customer'
import { orderFactory } from '@/business/order'
const customer = customerFactory()
const order = orderFactory({ customer })
const { useOrder, OrderCard } = order
export const HomeScreen = () => {
const currentOrder = useOrder()
return <OrderCard order={currentOrder} />
}
```
## Жизненный цикл
Модуль рождается на самом низком уровне использования и поднимается выше только при реальной потребности.
- Нужен на одной странице → `screens/{name}/parts/`
- Появился в 2+ местах → поднимается по природе:
- абстрактный UI → `ui/`
- блок с данными/логикой → `widgets/`
- представление бизнес-домена → `business/{area}/parts/`
Подъём — обычный рефакторинг в рамках задачи, а не отдельная активность.

View File

@@ -1,176 +0,0 @@
# Сегменты
Раздел описывает сегменты SLM: что такое сегмент, какие бывают и что в каждом из них лежит.
## Определение
**Сегмент — папка внутри модуля, которая группирует файлы по назначению. Набор сегментов не фиксирован — модуль включает только те, которые ему нужны. Команда сама определяет какие сегменты используются в проекте — архитектура даёт рекомендацию.**
## Обзор
| Сегмент | Содержимое |
|---------|------------|
| `ui/` | Презентационные компоненты родительского модуля |
| `parts/` | Вложенные модули со своими сегментами |
| `hooks/` | React-хуки |
| `stores/` | Сторы состояния |
| `services/` | Работа с внешними источниками данных |
| `mappers/` | Трансформация данных между форматами |
| `types/` | TypeScript-типы и интерфейсы |
| `styles/` | Стили |
| `lib/` | Утилиты и хелперы модуля |
| `config/` | Константы и конфигурация |
## Сегмент ui/
Презентационные компоненты родительского модуля. `ui/` содержит только компоненты, которые отвечают за отображение части интерфейса и не выходят за границы своего модуля.
Компонент в `ui/`:
- Находится в собственной папке.
- Может содержать только `{name}.tsx`, `index.ts`, `styles/`, `types/`.
- Не содержит любые компоненты.
- Не содержит любые модули.
- Не импортирует код проекта за пределами родительского модуля.
- Не делает внешние запросы.
- Не вызывает сценарные хуки.
- Не получает данные самостоятельно, не выбирает источник данных и не композирует данные.
- Не содержит бизнес-логику или сценарную логику.
Если UI-сущности нужно что-то за пределами этих ограничений, она должна быть оформлена как модуль. Полная граница описана в разделе [Компонент](./modules.md#компонент).
Корневой файл модуля в `ui/` не размещается. Он лежит в корне модуля: `{module-name}.tsx`.
```text
user/
├── ui/
│ ├── user-avatar/
│ │ ├── user-avatar.tsx
│ │ ├── styles/
│ │ │ └── user-avatar.module.css
│ │ ├── types/
│ │ │ └── user-avatar-props.type.ts
│ │ └── index.ts
│ └── user-status/
│ ├── user-status.tsx
│ └── index.ts
├── types/
├── hooks/
├── user.tsx
└── index.ts
```
Если UI-сущности нужна внутренняя декомпозиция, сценарная логика, получение данных или собственные архитектурные зависимости — это уже не компонент в `ui/`, а модуль в `parts/`.
## Сегмент parts/
Вложенные модули со своими сегментами. `parts/` содержит только модули: каждый элемент `parts/` — папка полноценного модуля с собственным публичным API. Отдельные `.tsx`, стили, хуки или произвольные файлы в `parts/` не размещаются.
```text
home/
├── parts/
│ ├── hero-section/
│ │ ├── hero-section.tsx
│ │ ├── styles/
│ │ ├── parts/
│ │ │ └── top-banner/
│ │ │ ├── top-banner.tsx
│ │ │ └── index.ts
│ │ └── index.ts
│ └── features-section/
│ ├── features-section.tsx
│ ├── hooks/
│ └── index.ts
├── home.screen.tsx
└── index.ts
```
Отличие от `ui/`: элемент `parts/` — модульная папка со своими сегментами. Элемент `ui/` — компонент родительского модуля без собственной архитектурной ответственности.
Вложенность `parts/` инкапсулирует область разработки горизонтально: каждый разработчик работает в своём `parts/`-модуле, не затрагивая чужие. Это снижает конфликты при параллельной разработке.
Если вложенный модуль обрастает своими `parts/` — это сигнал, что он достаточно самостоятельный для подъёма на уровень выше.
## Сегмент hooks/
React-хуки модуля. Инкапсулируют логику, состояние, подписки, побочные эффекты.
```text
hooks/
├── use-auth.hook.ts
├── use-session.hook.ts
└── use-permissions.hook.ts
```
## Сегмент stores/
Сторы состояния модуля. Конкретная реализация зависит от выбранного стейт-менеджера (Zustand, MobX, Redux и т.д.).
```text
stores/
├── auth.store.ts
└── session.store.ts
```
## Сегмент services/
Работа с внешними источниками данных: API-вызовы, запросы, подписки.
```text
services/
├── auth.service.ts
└── token.service.ts
```
## Сегмент mappers/
Функции трансформации данных из одного формата в другой: DTO в доменный тип, доменный тип в DTO, доменный тип в ViewModel.
```text
mappers/
├── map-user.ts
├── map-product.ts
└── map-order-to-dto.ts
```
## Сегмент types/
TypeScript-типы и интерфейсы модуля. Доменные типы, DTO, пропсы компонентов.
```text
types/
├── user.type.ts
└── session.type.ts
```
## Сегмент styles/
Стили модуля. Формат зависит от выбранного подхода (CSS Modules, SCSS, CSS-in-JS и т.д.).
```text
styles/
├── auth.module.css
└── login-form.module.css
```
## Сегмент lib/
Утилиты и хелперы, специфичные для модуля. Чистые функции без побочных эффектов.
```text
lib/
├── validate-email.ts
└── format-phone.ts
```
Отличие от `shared/lib/`: здесь лежат утилиты, нужные только этому модулю. Общие утилиты — в `shared/lib/`.
## Сегмент config/
Константы и конфигурация модуля: маршруты, лимиты, дефолтные значения.
```text
config/
├── routes.ts
└── constants.ts
```

View File

@@ -1,153 +0,0 @@
---
title: Стиль кода
description: Как оформляется код в проекте.
---
# Стиль кода
Как оформляется код в проекте.
## Отступы
- 2 пробела (не табы).
## Длина строк
- Ориентироваться на 100 символов, но превышение допустимо, если строка читается легко.
- Переносить выражение на новые строки, когда строка становится плохо читаемой.
- Не переносить строку внутри строковых литералов без необходимости.
**Хорошо**
```ts
const config = createRequestConfig(
endpoint,
{
headers: {
'X-Request-Id': requestId,
'X-User-Id': userId,
},
params: {
page,
pageSize,
sort: 'createdAt',
},
},
timeoutMs,
);
```
**Плохо**
```ts
// Плохо: длинная строка с вложенными структурами плохо читается.
const config = createRequestConfig(endpoint, { headers: { 'X-Request-Id': requestId, 'X-User-Id': userId }, params: { page, pageSize, sort: 'createdAt' } }, timeoutMs);
```
## Кавычки
- В JavaScript/TypeScript использовать одинарные кавычки.
- В JSX/TSX для атрибутов использовать двойные кавычки.
- Шаблонные строки использовать только при интерполяции или многострочном тексте.
**Хорошо**
```ts
const label = 'Сохранить';
const title = `Привет, ${name}`;
```
```tsx
<input type="text" placeholder="Введите имя" />
```
**Плохо**
```ts
// Плохо: двойные кавычки в TS и конкатенация вместо шаблонной строки.
const label = "Сохранить";
const title = 'Привет, ' + name;
```
```tsx
// Плохо: одинарные кавычки в JSX-атрибутах.
<input type='text' placeholder='Введите имя' />
```
## Точки с запятой и запятые
- Допускаются упущения точки с запятой, если код остаётся читаемым и однозначным.
- В многострочных массивах, объектах и параметрах функции запятая в конце допускается, но не обязательна.
## Импорты
- В именованных импортах использовать пробелы внутри фигурных скобок.
- Типы импортировать через `import type`.
- `default` экспорт избегать, использовать именованные. `default` импорт допустим (например, стили CSS Modules, сторонние библиотеки).
- Избегать импорта всего модуля через `*`.
**Хорошо**
```ts
import { MyComponent } from 'MyComponent';
import type { User } from '../model/types';
import styles from './styles/button.module.css';
```
**Плохо**
```ts
// Плохо: отсутствие пробелов в именованном импорте.
import type {User} from '../model/types';
// Плохо: default экспорт.
export default MyComponent;
```
## Ранние возвраты (early return)
- Использовать ранние возвраты для упрощения чтения.
- Избегать `else` после `return`.
**Хорошо**
```ts
const getName = (user?: { name: string }) => {
if (!user) {
return 'Гость';
}
return user.name;
};
```
**Плохо**
```ts
// Плохо: лишний else после return усложняет чтение.
const getName = (user?: { name: string }) => {
if (user) {
return user.name;
} else {
return 'Гость';
}
};
```
## Форматирование объектов и массивов
- В многострочных объектах каждое свойство на новой строке.
- В многострочных массивах каждый элемент на новой строке.
- Объекты и массивы можно писать в одну строку, если длина строки не превышает 100 символов.
- В однострочных объектах и массивах использовать пробелы после запятых.
**Хорошо**
```ts
const roles = ['admin', 'editor', 'viewer'];
const options = { id: 1, name: 'User' };
const config = {
url: '/api/users',
method: 'GET',
params: { page: 1, pageSize: 20 },
};
```
**Плохо**
```ts
// Плохо: нет пробелов после запятых и объект слишком длинный для одной строки.
const roles = ['admin','editor','viewer'];
const options = { id: 1,name: 'User' };
const config = { url: '/api/users', method: 'GET', params: { page: 1, pageSize: 20 } };
```

View File

@@ -1,134 +0,0 @@
---
title: Документирование
description: Что и как документировать в коде.
---
# Документирование
Что и как документировать в коде.
## Общие правила
- Документировать публичные функции, компоненты, типы, интерфейсы и enum.
- Не документировать очевидное — если название говорит само за себя, комментарий не нужен.
- Не документировать параметры, возвращаемые значения и типы пропсов — они видны из сигнатуры.
- Описание через пользу и назначение, а не через внутреннюю реализацию.
- Описание завершается точкой.
## Функции
Для документирования функций используется шаблон. Описание механики опционально —
добавляется когда логика нетривиальна.
**Шаблон**
```ts
/**
* <Что делает функция в 1 строке>.
*
* <Опционально: описание сложной механики или важных нюансов>.
*/
```
**Хорошо**
```ts
/**
* Форматирует цену с символом валюты.
*/
export const formatPrice = (value: number): string => { ... }
/**
* Рекурсивно собирает дерево категорий из плоского списка.
*
* Группирует элементы по parentId, начиная с корневых (parentId = null).
* Категории без родителя попадают в корень дерева.
*/
export const buildCategoryTree = (categories: Category[]): CategoryTree[] => { ... }
```
**Плохо**
```ts
// Плохо: дублирует сигнатуру.
/**
* @param value - число
* @returns строка с ценой
*/
```
## Компоненты
Компонент описывает своё **назначение** и **сценарии применения** — это помогает понять, когда и где его использовать, без необходимости читать реализацию.
**Шаблон**
```ts
/**
* <Назначение компонента в 1 строке>.
*
* Используется для:
* - <сценарий 1>
* - <сценарий 2>
* - <сценарий 3>
*/
```
**Хорошо**
```tsx
/**
* Контейнер с адаптивной максимальной шириной.
*
* Используется для:
* - обёртки контента страниц с ограничением ширины
* - центрирования блоков в лейауте
*/
export const Container = (props: ContainerProps) => { ... }
```
**Плохо**
```tsx
// Плохо: описывает реализацию, а не назначение.
/**
* Рендерит div с className и htmlAttr.
*/
// Плохо: нет описания вообще.
export const Container = (props: ContainerProps) => { ... }
```
## Типы, интерфейсы, enum
Документируются назначение сущности и каждое её поле.
**Хорошо**
```ts
/**
* Фильтры списка задач.
*/
export enum TodoFilter {
/** Все задачи. */
ALL = 'all',
/** Только активные. */
ACTIVE = 'active',
/** Только завершённые. */
COMPLETED = 'completed',
}
/**
* Задача пользователя.
*/
export interface TodoItem {
/** Уникальный идентификатор задачи. */
id: string;
/** Текст задачи. */
text: string;
/** Статус выполнения. */
completed: boolean;
}
```
**Плохо**
```ts
// Плохо: описывает очевидное.
export interface TodoItem {
/** id — это id */
id: string;
}
```

View File

@@ -1,146 +0,0 @@
---
title: Именование
description: Как называть переменные, файлы и прочие сущности в коде.
---
# Именование
Как называть переменные, файлы и прочие сущности в коде.
## Базовые правила
| Что | Рекомендуется |
| ---------------- | ---------------------- |
| Папки | `kebab-case` |
| Файлы | `kebab-case` |
| Переменные | `camelCase` |
| Константы | `SCREAMING_SNAKE_CASE` |
| Классы | `PascalCase` |
| React-компоненты | `PascalCase` |
| Хуки | `useSomething` |
| CSS классы | `camelCase` |
| Ключи enum | `SCREAMING_SNAKE_CASE` |
## Именование файлов
Суффикс обозначает роль или тип файла. Пишется в единственном числе.
Формат: `name.<suffix>.ts`.
**Хуки**
- `use-name.hook.ts` — файл хука, функция именуется `useName`
**Логика**
- `.store.ts` — стор
- `.service.ts` — сервис
**Корневые компоненты слоёв**
- `.screen.tsx` — корневой компонент screen-модуля: `screens/profile/profile.screen.tsx`, компонент `ProfileScreen`
- `.layout.tsx` — корневой компонент layout-модуля: `layouts/main/main.layout.tsx`, компонент `MainLayout`
Обычные и вложенные модули не получают суффикс слоя: `ui/button/button.tsx`, `screens/profile/parts/activity-feed/activity-feed.tsx`.
**Типы и контракты**
- `.type.ts` — типы и интерфейсы
- `.interface.ts` — интерфейсы
- `.enum.ts` — enum
- `.dto.ts` — внешние DTO
- `.schema.ts` — схемы валидации
- `.constant.ts` — константы
- `.config.ts` — конфигурация
**Утилиты**
- `.util.ts` — утилиты
- `.helper.ts` — вспомогательные функции
- `.lib.ts` — библиотечный код
**Тесты**
- `.test.ts` — тесты
- `.mock.ts` — моки
**Хорошо**
```text
business/
└── auth-by-email/
├── ui/
│ └── login-form.tsx
├── hooks/
│ └── use-auth.hook.ts
├── stores/
│ └── auth.store.ts
├── types/
│ └── auth.type.ts
├── auth-by-email.tsx
└── index.ts
```
**Плохо**
```text
business/
└── authByEmail/
├── LoginForm.tsx
├── useAuth.ts
├── authStore.ts
└── index.ts
```
## Булевы значения
- Использовать префиксы `is`, `has`, `can`, `should`.
**Хорошо**
```ts
const isReady = true;
const hasAccess = false;
const canSubmit = true;
const shouldRedirect = false;
```
**Плохо**
```ts
// Плохо: неясное булево значение без префикса.
const ready = true;
const access = false;
const submit = true;
```
## События и обработчики
- Обработчики начинать с `handle`.
- События и колбэки начинать с `on`.
**Хорошо**
```ts
const handleSubmit = () => { ... };
const onSubmit = () => { ... };
```
**Плохо**
```ts
// Плохо: неочевидное назначение имени.
const submitClick = () => { ... };
```
## Коллекции
- Для массивов использовать имена во множественном числе.
- Для словарей/мап — использовать суффиксы `ById`, `Map`, `Dict`.
**Хорошо**
```ts
const users = [];
const usersById = {} as Record<string, User>;
const userIds = ['u1', 'u2'];
const ordersMap = new Map<string, Order>();
const featureFlagsDict = { beta: true, legacy: false } as Record<string, boolean>;
```
**Плохо**
```ts
// Плохо: имя не отражает, что это коллекция.
const user = [];
// Плохо: словарь назван как массив.
const usersMap = [];
// Плохо: по имени непонятно, что это словарь.
const users = {} as Record<string, User>;
```

View File

@@ -1,42 +0,0 @@
---
title: Технологии и библиотеки
description: Какие библиотеки и инструменты используются в проекте.
---
# Технологии и библиотеки
Какие библиотеки и инструменты используются в проекте.
## Что используем
### Стек
- `React` / `TypeScript` — основной стек для UI и приложения.
- `Next.js` — для продуктовых сайтов.
### Архитектура
- `SLM Design` — собственная модульная архитектура проекта. Подробнее в разделе [Архитектура](./architecture/index.md).
### UI компоненты
- `Mantine UI` — базовые UI-компоненты.
### Работа с данными (API)
- `@gromlab/api-codegen` — генерация APIклиентов и типов.
- `SWR` — получение, кеширование, ревалидация, дедубликация.
- `SWR (useSWRSubscription)` — сокеты, реалтайм подписки.
### Store
- `Zustand` — глобальное состояние.
### Локализация
- `i18next (i18n)` — локализация всех пользовательских текстов.
### Тестирование
- `Vitest` — тестирование.
### Стили
- `PostCSS Modules` — изоляция стилей.
- `Mobile First` — подход к адаптивной верстке.
- `clsx` — конкатенация CSSклассов.
### Генерация
- `@gromlab/create` — шаблонизатор для создания слоёв и других файлов из шаблонов.

View File

@@ -1,62 +0,0 @@
---
title: Типизация
description: Как типизируется код в проекте.
---
# Типизация
Как типизируется код в проекте.
## Общие правила
- Указывать типы для параметров компонентов и параметров функций.
- Предпочитать `type` для описания сущностей и `interface` для расширяемых контрактов.
- Избегать `any` и `unknown` без необходимости.
- Не использовать `ts-ignore`, кроме крайних случаев с явным комментарием причины.
## React-компоненты
- Пропсы компонента типизировать через отдельный `Props`.
- Возвращаемый тип компонента не указывать: TypeScript корректно выводит JSX-результат, а явный `ReactElement` сужает допустимые варианты возврата.
## Функции
- Для публичных функций указывать возвращаемый тип.
- Не полагаться на неявный вывод для важных API.
**Хорошо**
```ts
export const formatPrice = (value: number): string => {
return `${value} ₽`;
};
```
**Плохо**
```ts
// Плохо: нет явного возвращаемого типа.
export const formatPrice = (value: number) => {
return `${value} ₽`;
};
```
## Работа с any/unknown
- `any` использовать только для временных заглушек.
- `unknown` сужать через проверки перед использованием.
**Хорошо**
```ts
const parse = (value: unknown): string => {
if (typeof value === 'string') {
return value;
}
return '';
};
```
**Плохо**
```ts
// Плохо: any отключает проверку типов.
const parse = (value: any) => value;
```

View File

@@ -1,51 +0,0 @@
---
title: Создание проекта из шаблона
description: Создание нового проекта на основе готового шаблона.
keywords: [создать проект из шаблона, шаблон, template, tiged, degit, клонировать шаблон, эталонный шаблон, быстрый старт, scaffold, новый проект]
---
# Создание проекта из шаблона
Создание нового проекта на основе готового шаблона.
## Что внутри
Шаблон — готовый скелет проекта с применёнными правилами стайлгайда:
- **Стек:** Next.js (App Router), TypeScript, React.
- **Архитектура:** структура папок по SLM, алиасы импортов.
- **Качество кода:** Biome (линтер и форматер), настройки VS Code.
- **Стили:** PostCSS Modules с плагинами, токены, медиа-брейкпоинты.
- **Ассеты:** генерация SVG-спрайтов.
- **Кодогенерация:** шаблоны для страниц, компонентов, хуков, сторов.
в
## Установка
1. Склонировать шаблон в родительском каталоге будущего проекта:
```bash
npx tiged git@gromlab.ru:templates/nextjs.git my-app
```
`tiged` копирует снимок репозитория без истории git. Имя каталога (`my-app`) заменяется на нужное.
2. Установить зависимости:
```bash
cd my-app
npm install
```
3. Проверить сборку:
```bash
npm run build
```
Сборка должна завершиться без ошибок.
## Правила
- **Шаблон — источник истины.** Не добавлять, не удалять и не переименовывать файлы шаблона «для приведения к канону»: шаблон уже канонический. Любое несоответствие — баг шаблона, а не проекта.
- **Менеджер пакетов — npm.** Отклонение (pnpm, yarn, bun) — только по явному решению с пониманием, что стайлгайд этого не предусматривает.
- **Не инициализировать git заново** автоматически. `tiged` намеренно не создаёт `.git/` — решение о репозитории принимает разработчик.

View File

@@ -1,90 +0,0 @@
---
title: Создание проекта вручную
description: Поэтапное создание нового проекта без использования шаблона.
keywords: [создать проект, новый проект, с нуля, init, initialize, scaffold, create-next-app, начать проект, поднять проект, эталонный проект, ручная установка]
---
# Создание проекта вручную
Поэтапное создание нового проекта без использования шаблона.
## Состав эталонного проекта
| Компонент | Роль | Раздел |
|-----------|------|--------|
| Next.js | Фреймворк (роутинг, сборка, SSR) | [Next.js](./nextjs.md) |
| Алиасы | Импорты по слоям SLM | [Алиасы](../applied/aliases.md) |
| Biome | Линтер и форматтер (замена ESLint + Prettier) | [Biome](../applied/biome.md) |
| Стили | Глобальные токены и breakpoints | [Стили](../applied/styles/styles-setup.md) |
| PostCSS | CSS-процессор для custom-media и вложенности | [PostCSS](../applied/postcss.md) |
| SVG-спрайты | Иконки через `<SvgSprite/>`, управление цветом | [SVG-спрайты](../applied/svg-sprites/svg-sprites-setup.md) |
| VS Code | Настройки редактора и расширения | [VS Code](../applied/vscode.md) |
| Шаблоны генерации | `.templates/` для `@gromlab/create` | [Шаблоны генерации](../applied/templates/templates-setup.md) |
Убрать компонент из состава — значит согласованно отказаться от части стайлгайда. Частичные проекты возможны (только Next.js, Next.js + стили и т.п.), но не являются эталоном.
## Канон раскладки
В `src/` допустимы только слои SLM: `app/`, `layouts/`, `screens/`, `widgets/`, `business/`, `infra/`, `ui/`, `shared/`. Любая другая папка в `src/` — нарушение канона ([Структура проекта](../applied/project-structure.md), [Архитектура](../basics/architecture/index.md)).
В частности: `src/app/` содержит только файлы роутинга Next.js и инициализации, без каталогов `styles/`, `assets/`, `components/`.
## Порядок установки
Подсистемы ставятся в фиксированном порядке — он отражает зависимости между шагами.
### 1. Next.js
Скелет фреймворка — обязательный первый шаг, остальное опирается на него.
См. [Next.js](./nextjs.md). После выполнения проверки этого раздела `npm run build` должен проходить.
### 2. Алиасы
Заменить дефолтный `"@/*"` в `tsconfig.json` на канонический список из восьми слой-префиксов.
См. [Алиасы](../applied/aliases.md).
### 3. Biome
Линтер и форматтер. Подключается **до** написания кода, иначе в проекте копятся несогласованные правки.
См. [Biome](../applied/biome.md).
### 4. Стили (базовая инфраструктура)
Файлы `variables.css`, `media.css`, `global.css` в `src/shared/styles/` и подключение `global.css` в `src/app/layout.tsx`. CSS-процессор на этом шаге не ставится.
См. [Стили](../applied/styles/styles-setup.md).
### 5. PostCSS
CSS-процессор поверх базовых стилей: `@custom-media`, вложенность, autoprefixer. Ставится **только после шага 4** — опирается на `src/shared/styles/media.css`.
См. [PostCSS](../applied/postcss.md).
### 6. SVG-спрайты
Пакет `@gromlab/svg-sprites`, генерация спрайт-файла и React-компонента `<SvgSprite/>`.
См. [SVG-спрайты](../applied/svg-sprites/svg-sprites-setup.md).
### 7. VS Code
Расширения и настройки редактора. Опирается на установленный Biome (форматирование при сохранении) и PostCSS (ассоциация `*.css`).
См. [VS Code](../applied/vscode.md).
### 8. Шаблоны генерации
Папка `.templates/` для генератора модулей `@gromlab/create`.
См. [Шаблоны генерации](../applied/templates/templates-setup.md).
## Правила
- **Порядок шагов фиксирован.** Перестановка ломает зависимости (PostCSS требует базовых стилей, VS Code — установленного Biome).
- **Между шагами обязательна проверка** из соответствующего раздела. Не переходить дальше, пока чеклист текущего шага не пройден.
- **Слои `src/`** (`layouts/`, `screens/`, `widgets/`, `business/`, `infra/`, `ui/`) не создавать авансом. Появляются по мере первого модуля. Исключения — `src/app/` (создаётся `create-next-app`), `src/shared/styles/` (шаг 1) и `src/shared/sprites/icons/` (шаг 6).
- **Посторонние каталоги в `src/`** (`assets/`, `utils/`, `lib/`, `components/` и т.п.) — запрещены.
- **Подмножество шагов допустимо.** Можно ставить только Next.js и часть инструментов; полный набор — это эталон, а не обязательство.

View File

@@ -1,112 +0,0 @@
---
title: Чистая установка Next.js
description: "Установка Next.js без лишнего шаблона — голый каркас под дальнейшую сборку."
keywords: [next.js, create-next-app, npx, установка, инициализация, фреймворк, скаффолдинг, app router, typescript]
---
# Чистая установка Next.js
Установка Next.js без лишнего шаблона — голый каркас под дальнейшую сборку.
## Требования
- Node.js 18.18+ (рекомендуется LTS 20+).
- npm 10+.
- Рабочая папка пуста, либо для установки выбрана подпапка (`create-next-app@16+` отказывается ставиться в непустую директорию).
## Установка
### 1. Инициализация через `create-next-app`
Флаги зафиксированы и не согласовываются — это канон стайлгайда:
```bash
npx create-next-app@latest my-app \
--typescript \
--app \
--src-dir \
--import-alias "@/*" \
--no-eslint \
--no-tailwind \
--use-npm
```
| Флаг | Значение | Почему так |
|------|----------|------------|
| `--typescript` | TS включён | Стайлгайд требует TypeScript ([Типизация](../basics/typing.md)) |
| `--app` | App Router | Pages Router не используется |
| `--src-dir` | Код в `src/` | Архитектура SLM требует `src/` ([Структура проекта](../applied/project-structure.md)) |
| `--import-alias "@/*"` | Placeholder | Требуется флагом; после установки `paths` полностью переписывается на слой-префиксы (см. [Алиасы](../applied/aliases.md)) |
| `--no-eslint` | ESLint не ставится | Линтер и форматтер — Biome ([Biome](../applied/biome.md)) |
| `--no-tailwind` | Tailwind не ставится | Стилизация — PostCSS Modules ([Стили](../applied/styles/styles-usage.md)) |
| `--use-npm` | Пакетный менеджер — npm | Единый инструмент в проектах |
### 2. Очистить дефолтный шаблон
`create-next-app` генерирует демо-страницу со стилями и ассетами, а Next.js 16+ дополнительно кладёт в корень собственные `AGENTS.md` и `CLAUDE.md` — всё это удаляется.
```bash
rm src/app/page.module.css
rm src/app/globals.css
rm public/next.svg public/vercel.svg public/file.svg public/globe.svg public/window.svg
rm -f AGENTS.md CLAUDE.md
```
Заменить `src/app/page.tsx` на минимальный:
```tsx
// src/app/page.tsx
export default function HomePage() {
return <h1>Home</h1>
}
```
Очистить `src/app/layout.tsx` от импорта шрифтов и `globals.css`:
```tsx
// src/app/layout.tsx
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'App',
description: '',
}
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="ru">
<body>{children}</body>
</html>
)
}
```
### 3. Создать папку `src/shared/styles/`
Глобальные стили в SLM-архитектуре живут в слое `shared`, а не в `src/app/` ([Структура проекта](../applied/project-structure.md)).
```bash
mkdir -p src/shared/styles
```
Остальные слои (`layouts/`, `screens/`, `widgets/`, `business/`, `infra/`, `ui/`) заводятся при появлении первого модуля в них. `src/shared/styles/` — единственный подкаталог `shared/`, который заводится сразу: без него не настроить стили на следующих шагах.
## Правила
- **Конфликт с непустой директорией** — не удалять файлы пользователя автоматически. Ставить в подпапку или временно перенести посторонние файлы.
- **Отклонение от канонических флагов** (pnpm, Tailwind, ESLint и т.п.) — только осознанное, с пониманием, что стайлгайд этого не предусматривает.
- **Слои `src/`** не создавать авансом — появляются при первом модуле. Алиасы прописываются сразу на все восемь слоёв (см. [Алиасы](../applied/aliases.md)).
- **`AGENTS.md` от Next.js** удаляется в шаге 2. Повторно не создаётся.
- **Biome, стили, PostCSS, SVG-спрайты, VS Code** — отдельные шаги установки, не в этом разделе.
## Проверка установки
- В корне проекта: `next.config.ts`, `tsconfig.json`, `package.json`.
- В `package.json`: Next.js установлен, нет `eslint`, `tailwindcss`.
- В `src/app/` присутствуют минимальные `page.tsx` и `layout.tsx`. `globals.css`, `page.module.css` отсутствуют. Каталогов `styles/`, `assets/`, `providers/`, `components/` в `src/app/` нет.
- Папка `src/shared/styles/` создана (пустая).
- В `src/` из слоёв SLM присутствуют только `app/` и `shared/` (с `styles/`). Посторонних каталогов нет.
- В `public/` удалены `next.svg`, `vercel.svg`, `file.svg`, `globe.svg`, `window.svg`.
- В корне проекта нет `AGENTS.md` и `CLAUDE.md` от Next.js.
- `npm run build` завершается успешно.
- Пакетный менеджер — npm (нет `pnpm-lock.yaml`, `yarn.lock`, `bun.lockb`).

View File

@@ -1,60 +0,0 @@
---
title: Источники данных
description: Какие источники данных используются в проекте и как с ними работать.
keywords: [данные, api, rest, realtime, клиент, swr, infra, введение, карта раздела]
---
# Источники данных
Какие источники данных используются в проекте и как с ними работать.
## Принципы раздела
- **Клиент — в `infra/`.** Каждый внешний сервис — отдельный модуль слоя `infra/{service-name}/`.
- **Прямой `fetch` запрещён.** Запросы идут только через клиент модуля. Исключения — точечные и обоснованные.
- **Источник данных диктует канал.** REST, realtime и т.п. — независимые подразделы, у каждого своя модель клиента и своё потребление.
- **Серверные и клиентские компоненты потребляют по-разному.** Server Components — прямой `await` метода клиента, клиентские — через готовые GET-хуки REST-клиента (`useGetUserList`, `useGetPostDetail` и т.п.). SWR инкапсулирован в хуке, компонент про него не знает.
## Карта раздела
### REST
Канал «запрос-ответ» по HTTP. Покрывает большинство API.
- [REST](./rest/index.md) — обзор раздела: создание клиента и использование.
- **Создание клиента** — как оформляется REST API в проекте:
- [Обзор](./rest/clients/index.md) — когда нужен клиент и как выбрать подход.
- [Автогенерация из OpenAPI](./rest/clients/auto.md) — для API с OpenAPI-спецификацией, через `@gromlab/api-codegen`.
- [Ручное создание](./rest/clients/manual.md) — для API без схемы, клиент пишется и поддерживается руками.
- [GET-хуки REST-клиента](./rest/clients/hooks.md) — прозрачные SWR-обёртки над GET-методами клиента.
- **Использование** — как получать данные через готовый клиент:
- [Стратегии получения данных](./rest/strategies/index.md) — как выбрать способ получения данных под ситуацию.
- [Серверный await](./rest/strategies/server-await.md) — прямой `await` метода клиента в Server Components.
- [Параллельные серверные запросы](./rest/strategies/parallel-server-requests.md) — запуск независимых серверных запросов без waterfall.
- [Передача промиса ниже](./rest/strategies/pass-promise-down.md) — серверный стриминг через промис и `Suspense`.
- [Начальные данные для клиентских хуков](./rest/strategies/client-hooks-initial-data.md) — серверный промис в `SWRConfig fallback`.
- [Клиентский GET-хук](./rest/strategies/client-get-hook.md) — получение данных в Client Components через готовый GET-хук.
- [Business-композиция](./rest/strategies/business-composition.md) — доменная интерпретация и композиция REST-данных.
### Realtime
Канал push-данных: WebSocket, SSE, событийные шины. Транспорт не зашит в правила — важна абстракция «подписка».
- [Realtime](./realtime.md) — клиент realtime в `infra/`, потребление через `useSWRSubscription` или прямые подписки.
## Что даёт раздел
После прочтения раздела понятно:
- Где живёт код работы с API и почему именно там.
- Когда генерировать клиент автоматически, а когда писать вручную, и как структурирован каждый из вариантов.
- Какие GET-хуки относятся к REST-клиенту и почему они живут в `infra/{service-name}/hooks/`.
- Как выбрать стратегию получения REST-данных под конкретную ситуацию.
- Как подключать realtime-источники в общую модель работы с данными.
- Какие правила обязательны и какие отклонения допустимы.
## Что не входит в раздел
- **Глобальное состояние UI** — Stores, формы, фичефлаги. Это [Stores](../applied/stores.md).
- **Доменная логика** — как данные превращаются в сценарии бизнеса. Это слой `business/` в [Архитектуре](../basics/architecture/index.md).
- **Хуки общего назначения** — переиспользуемые хуки UI, не привязанные к конкретному API. Отдельный прикладной раздел для них пока не ведётся.

View File

@@ -1,79 +0,0 @@
---
title: Realtime
description: "Работа с push-данными от сервера: подписки и события."
keywords: [realtime, websocket, sse, подписка, swr subscription, useSWRSubscription, push, события]
---
# Realtime
Работа с push-данными от сервера: подписки и события.
## Принципы
- **Клиент realtime — в `infra/`** отдельным модулем по имени канала. То же правило, что и для REST: никаких прямых соединений в коде приложения.
- **Подписка — единица потребления.** Клиент даёт функцию `subscribe(topic, handler) → unsubscribe`. Внутри — конкретный транспорт.
- **Использование на клиенте — два сценария:**
- **`useSWRSubscription`** — для данных, которые показываются в UI и должны кешироваться/синхронизироваться с REST.
- **Прямая подписка** — для побочных эффектов (тосты, нотификации, аналитика), не привязанных к рендеру.
## Размещение клиента
```text
src/infra/
└── {channel-name}/
├── connection.ts # установление соединения, реконнект
├── subscribe.ts # subscribe(topic, handler) → unsubscribe
├── types.ts
└── index.ts
```
## Использование через SWR
```tsx
'use client'
import useSWRSubscription from 'swr/subscription'
import { subscribe } from 'infra/notifications'
export function NotificationCounter() {
const { data: count } = useSWRSubscription(
['notifications', 'count'],
(key, { next }) =>
subscribe('notifications.count', (value: number) => next(null, value)),
)
return <span>{count ?? 0}</span>
}
```
Плюсы: кеш и дедупликация подписки между несколькими местами рендера; единая модель данных с REST.
## Прямая подписка
Для побочных эффектов, которые не влияют на состояние UI напрямую:
```tsx
'use client'
import { useEffect } from 'react'
import { subscribe } from 'infra/notifications'
import { showToast } from 'ui/toast'
export function NotificationsToaster() {
useEffect(() => {
return subscribe('notifications.new', (notification) => {
showToast(notification.message)
})
}, [])
return null
}
```
Возврат `unsubscribe` из `useEffect` обязателен — иначе утечка подписки.
## Запрет прямых соединений
Создавать `new WebSocket(...)`, `new EventSource(...)` или подписываться на событийные шины напрямую в коде приложения — запрещено. Все соединения проходят через клиент в `infra/`.
Исключения — точечные и обоснованные (например, диагностический скрипт), помечаются комментарием.

View File

@@ -1,193 +0,0 @@
---
title: Автогенерация из OpenAPI
description: Генерация REST-клиента из OpenAPI-спецификации через @gromlab/api-codegen.
keywords: [rest, openapi, api-codegen, автогенерация, generated, npx]
---
# Автогенерация из OpenAPI
Автогенерация используется, когда у API есть актуальная OpenAPI-спецификация. Генератор создаёт TypeScript-клиент, типы и методы API, а разработчик вручную добавляет настройку клиента и GET-хуки.
## Пример API
В примерах используется Swagger Petstore:
```text
https://petstore3.swagger.io/api/v3/openapi.json
```
Имена модуля:
```text
src/infra/pet-store-api/
petStoreApi
pet-store-api.generated.ts
```
## Скрипт генерации
`@gromlab/api-codegen` не устанавливается в `devDependencies`. Используем `npx @gromlab/api-codegen@latest`, чтобы запускать свежую версию.
```json
{
"scripts": {
"codegen:pet-store-api": "npx @gromlab/api-codegen@latest -i https://petstore3.swagger.io/api/v3/openapi.json -o src/infra/pet-store-api/generated -n pet-store-api.generated"
}
}
```
Параметры:
- `-i` — путь к OpenAPI-спецификации: URL или локальный файл.
- `-o` — директория для сгенерированного файла.
- `-n` — имя сгенерированного файла без `.ts`.
Ключ `--swr` не используется. GET-хуки REST-клиента пишутся вручную, чтобы сохранить проектный контракт: один GET-хук = один GET-метод, без бизнес-логики и композиции.
## Генерация
```bash
npm run codegen:pet-store-api
```
Ожидаемый результат:
```text
src/infra/pet-store-api/generated/
└── pet-store-api.generated.ts
```
Сгенерированный файл не правится руками и коммитится в репозиторий.
## Проверка методов
После генерации откройте `generated/pet-store-api.generated.ts` и проверьте фактические имена методов.
Для Petstore нужны GET-операции вида:
```ts
petStoreApi.pet.findPetsByStatus(...)
petStoreApi.pet.getPetById(...)
```
Точные сигнатуры зависят от OpenAPI-схемы и версии генератора. В рабочих задачах всегда сверяйтесь с generated-файлом.
## `client.ts`
Сгенерированный код не должен напрямую использоваться из приложения. Сначала создаётся настроенный инстанс клиента.
```ts
// src/infra/pet-store-api/client.ts
import { Api, HttpClient } from './generated/pet-store-api.generated'
const httpClient = new HttpClient({
baseUrl: 'https://petstore3.swagger.io/api/v3',
baseApiParams: {
secure: false,
headers: {
'Content-Type': 'application/json',
},
},
})
export const petStoreApi = new Api(httpClient)
```
В реальном проекте вместо строки `baseUrl` обычно берётся из env-переменной, нормализуется в `client.ts` и передаётся в `HttpClient` через конфиг.
`client.ts` не содержит расширения типов, `declare module` и `Extended`-типы. Он только настраивает транспорт и экспортирует инстанс клиента.
## Расширение сгенерированных типов
Сгенерированный файл не правится руками. Если OpenAPI-спецификация неполная или генератор дал слишком общий тип (`object`, `unknown`, отсутствующее поле), расширения живут в `types/`.
```text
src/infra/biocad-less-api/
├── generated/
│ └── biocad-less-api.generated.ts
├── types/
│ ├── term.ts
│ └── index.ts
├── client.ts
└── index.ts
```
Пример расширения generated-типа:
```ts
// src/infra/biocad-less-api/types/term.ts
import type { TermRecordItem } from '../generated/biocad-less-api.generated'
declare module '../generated/biocad-less-api.generated' {
interface TermRecordItem {
media?: {
file?: string
title?: string
url?: string
}
}
}
export type TermRecordItemExtended = Omit<
TermRecordItem,
'categories' | 'tags' | 'fields'
> & {
categories?: Array<{
_id?: string
id?: string
slug?: string
name?: string
}>
tags?: Array<{
_id?: string
id?: string
slug?: string
name?: string
}>
fields?: Record<string, unknown>
}
```
```ts
// src/infra/biocad-less-api/types/index.ts
export type { TermRecordItemExtended } from './term'
```
`declare module` используется для добавления отсутствующих полей в generated-интерфейс. `Extended`-тип используется, когда нужно переопределить неточные поля, не трогая generated-файл.
## Публичный API
```ts
// src/infra/pet-store-api/index.ts
export { petStoreApi } from './client'
export type { Pet } from './generated/pet-store-api.generated'
export * from './hooks'
```
Наружу импортируют только из `infra/pet-store-api`, не из `generated/`.
Если у модуля есть расширенные типы, они тоже реэкспортируются через `index.ts`:
```ts
// src/infra/biocad-less-api/index.ts
export type { TermRecordItemExtended } from './types'
```
## Регенерация
При изменении OpenAPI-схемы:
```bash
npm run codegen:pet-store-api
```
Что меняется:
- `generated/pet-store-api.generated.ts` — перезаписывается генератором.
- `client.ts`, `hooks/`, `types/`, `index.ts` — не трогаются автоматически.
Если после регенерации поменялись сигнатуры методов или типы, это исправляется в ручном коде модуля.
## Следующий шаг
После генерации и настройки `client.ts` проверьте серверный вызов метода клиента или добавьте [GET-хук REST-клиента](./hooks.md) для Client Components.

View File

@@ -1,206 +0,0 @@
---
title: GET-хуки REST-клиента
description: Прозрачные SWR-обёртки над GET-методами REST-клиента.
keywords: [rest, swr, get-хуки, client components, infra]
---
# GET-хуки REST-клиента
GET-хуки REST-клиента — прозрачные SWR-обёртки над GET-методами API-клиента. Они нужны, чтобы Client Components получали данные с кешированием, дедупликацией и ревалидацией, не работая с `useSWR` напрямую.
## Где лежат
GET-хуки принадлежат REST-клиенту конкретного сервиса и живут рядом с ним:
```text
src/infra/
└── pet-store-api/
├── client.ts
├── generated/
├── hooks/
│ ├── use-get-pet-list.hook.ts
│ ├── use-get-pet-detail.hook.ts
│ └── index.ts
└── index.ts
```
## Контракт
- Один GET-хук = один GET-метод клиента.
- Имя GET-хука начинается с `useGet`: `useGetPetList`, `useGetPetDetail`.
- Имя файла начинается с `use-get`: `use-get-pet-list.hook.ts`.
- Хук принимает только параметры GET-метода и `config?: SWRConfiguration`.
- Что передали хуку, то он передаёт в GET-метод.
- Внутри только SWR-механика: key, fetcher, `useSWR`, `config`.
- Хук возвращает тип ответа API: generated-тип или DTO из `types/`.
- Хук не объединяет несколько запросов.
- Хук не маппит DTO в доменную модель.
- Хук не вычисляет бизнес-флаги: `isAuth`, `canEdit`, `hasAccess`, `hasPets`.
- Хук не вызывает тосты, модалки, редиректы и не пишет UI-состояние.
## Пример списка
```ts
// src/infra/pet-store-api/hooks/use-get-pet-list.hook.ts
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import { petStoreApi } from '../client'
import type { Pet } from '../generated/pet-store-api.generated'
export type PetStatus = 'available' | 'pending' | 'sold'
export const getPetListKey = (status: PetStatus) =>
['pet-store-api', 'pet', 'list', status] as const
/**
* Получение списка питомцев по статусу.
*/
export const useGetPetList = (status: PetStatus | null, config?: SWRConfiguration) => {
const isReady = status !== null
const key = isReady ? getPetListKey(status) : null
const fetcher = () => petStoreApi.pet.findPetsByStatus({ status })
return useSWR<Pet[]>(key, fetcher, config)
}
```
Функция `getPetListKey` нужна, чтобы один и тот же SWR-ключ использовался внутри GET-хука и при передаче начальных данных через `SWRConfig fallback`.
Пример начальных данных для клиентского хука:
```tsx
import type { ReactNode } from 'react'
import { SWRConfig, unstable_serialize } from 'swr'
import {
getPetListKey,
petStoreApi,
} from 'infra/pet-store-api'
export default function PetsLayout({ children }: { children: ReactNode }) {
const petsPromise = petStoreApi.pet.findPetsByStatus({ status: 'available' })
return (
<SWRConfig
value={{
fallback: {
[unstable_serialize(getPetListKey('available'))]: petsPromise,
},
}}
>
{children}
</SWRConfig>
)
}
```
Клиентский компонент при этом ничего не знает про preload/fallback и продолжает вызывать обычный хук:
```tsx
const { data: pets } = useGetPetList('available')
```
## Пример detail-запроса
```ts
// src/infra/pet-store-api/hooks/use-get-pet-detail.hook.ts
import useSWR from 'swr'
import type { SWRConfiguration } from 'swr'
import { petStoreApi } from '../client'
import type { Pet } from '../generated/pet-store-api.generated'
export const getPetDetailKey = (id: number) =>
['pet-store-api', 'pet', 'detail', id] as const
/**
* Получение питомца по идентификатору.
*/
export const useGetPetDetail = (id: number | null, config?: SWRConfiguration) => {
const isReady = id !== null
const key = isReady ? getPetDetailKey(id) : null
const fetcher = () => petStoreApi.pet.getPetById(id)
return useSWR<Pet>(key, fetcher, config)
}
```
## Отложенный запрос через `null`
GET-хук может принимать `null` для обязательного параметра. `null` означает, что параметр ещё не готов и запрос выполнять нельзя.
Внутри хука это выражается через `isReady`: если параметр не готов, ключ SWR становится `null`, и SWR не вызывает fetcher.
```ts
const isReady = id !== null
const key = isReady ? getPetDetailKey(id) : null
```
`null` не передаётся в метод клиента. Key-функция принимает только готовые параметры, поэтому её можно безопасно использовать для начальных данных через `SWRConfig fallback`.
Для числовых идентификаторов не используйте проверку `if (id)`: значение `0` тоже валидное число. Проверяйте явно: `id !== null`.
## Экспорт
```ts
// src/infra/pet-store-api/hooks/index.ts
export { getPetListKey, useGetPetList } from './use-get-pet-list.hook'
export type { PetStatus } from './use-get-pet-list.hook'
export { getPetDetailKey, useGetPetDetail } from './use-get-pet-detail.hook'
```
```ts
// src/infra/pet-store-api/index.ts
export { petStoreApi } from './client'
export type { Pet } from './generated/pet-store-api.generated'
export * from './hooks'
```
## Где заканчивается infra
```ts
// Хорошо: infra, прозрачный GET-хук
const { data: pets } = useGetPetList('available')
```
```ts
// Хорошо: business, доменная интерпретация
export const useAvailablePets = () => {
const query = useGetPetList('available')
return {
...query,
hasPets: Boolean(query.data?.length),
}
}
```
`hasPets` — не часть GET-запроса, поэтому он не добавляется в `useGetPetList`.
## Что запрещено
```ts
// Плохо — useSWR в компоненте
const { data } = useSWR(
['pet-store-api', 'pet', 'list', status],
() => petStoreApi.pet.findPetsByStatus({ status }),
)
// Плохо — несколько GET внутри infra-хука
export const usePetDashboard = () => {
const available = useGetPetList('available')
const sold = useGetPetList('sold')
return { available, sold }
}
// Плохо — бизнес-флаг внутри GET-хука REST-клиента
export const useGetPetList = (status: PetStatus) => {
const query = useSWR(...)
return {
...query,
hasPets: Boolean(query.data?.length),
}
}
```
Подробное потребление таких хуков описано в стратегии [Клиентский GET-хук](../strategies/client-get-hook.md).

View File

@@ -1,75 +0,0 @@
---
title: Создание клиента
description: Из чего состоит REST-клиент и какие части нужно подготовить перед использованием API.
keywords: [rest, клиент, infra, методы, openapi, get-хуки, swr]
---
# Создание клиента
REST-клиент — это infra-модуль, через который проект работает с внешним REST API.
На этом этапе нужно подготовить клиент сервиса: создать оболочку клиента, получить методы API и добавить GET-хуки для клиентских компонентов.
## Из чего состоит клиент
REST-клиент состоит из трёх основных частей:
1. **Клиент** — самописная оболочка над транспортом.
2. **Методы** — сгенерированные из OpenAPI или написанные вручную вызовы API.
3. **GET-хуки** — SWR-обёртки для GET-запросов.
Эти части живут в одном REST-модуле, потому что относятся к одному внешнему сервису.
## Клиент
Клиент — ручной слой, который настраивает работу с API: `baseUrl`, заголовки, авторизацию, обработку ошибок и создание инстанса сервиса.
Даже если методы генерируются из OpenAPI, `client.ts` остаётся ручным файлом проекта.
`client.ts` — только сборочная точка клиента. В нём не размещаются DTO, `declare module`, `Extended`-типы, GET-хуки и бизнес-логика.
## Методы
Методы описывают конкретные запросы к API.
Они появляются одним из двух способов:
- генерируются из OpenAPI в `generated/`;
- создаются вручную в `methods/`.
Подробности:
- [Автогенерация из OpenAPI](./auto.md)
- [Ручное создание](./manual.md)
## GET-хуки
Для GET-запросов добавляются GET-хуки REST-клиента.
Это прозрачные SWR-обёртки над GET-методами клиента. Они живут в `hooks/` этого же REST-модуля и нужны для использования данных в Client Components.
GET-хуки именуются с префиксом `useGet`: `useGetPetList`, `useGetPetDetail`, `useGetCurrentUser`.
Подробности:
- [GET-хуки REST-клиента](./hooks.md)
## Структура модуля
```text
src/infra/{service-name}/
├── client.ts # самописная оболочка и инстанс клиента
├── generated/ или methods/ # методы API
├── hooks/ # GET-хуки REST-клиента
├── types/ # DTO, типы API и расширения типов
├── errors/ # ошибки API, если нужны
└── index.ts # публичный API
```
`index.ts` — единственная точка входа в REST-модуль для внешнего кода.
## Что делаем дальше
1. Создайте методы клиента: [Автогенерация из OpenAPI](./auto.md) или [Ручное создание](./manual.md).
2. Добавьте GET-хуки для GET-запросов: [GET-хуки REST-клиента](./hooks.md).
3. После создания клиента переходите к [Стратегиям получения данных](../strategies/index.md).

View File

@@ -1,187 +0,0 @@
---
title: Ручное создание
description: Создание REST-клиента вручную, когда OpenAPI нет или он неполный.
keywords: [rest, ручной клиент, fetch, methods, dto, errors, infra]
---
# Ручное создание
Ручной REST-клиент используется, когда у API нет OpenAPI-спецификации или она недостаточно точная для автогенерации.
Задача ручного клиента — дать такую же точку входа, как у автогенерированного клиента: именованный API-объект, методы по сущностям, DTO и GET-хуки рядом с клиентом.
## Что нужно создать
```text
src/infra/
└── pet-project-api/
├── methods/
│ └── posts.ts
├── hooks/
│ └── index.ts
├── types/
│ ├── client.ts
│ ├── post.ts
│ └── index.ts
├── errors/
│ └── pet-project-api.error.ts
├── client.ts
└── index.ts
```
| Файл | Роль |
|------|------|
| `client.ts` | Базовый транспорт и создание инстанса клиента |
| `methods/` | Методы API по сущностям |
| `types/` | DTO запросов, ответов и типы клиента |
| `errors/` | Ошибки конкретного API |
| `hooks/` | GET-хуки REST-клиента, если данные нужны в Client Components |
| `index.ts` | Публичный API REST-модуля |
## DTO и типы API
DTO запросов и ответов живут в `types/`. `client.ts` не содержит DTO и доменные типы.
```ts
// src/infra/pet-project-api/types/post.ts
export type PostDto = {
id: string
slug: string
title: string
}
export type PostListQueryDto = {
limit?: number
category?: string
}
```
```ts
// src/infra/pet-project-api/types/index.ts
export type { PostDto, PostListQueryDto } from './post'
```
Типы, которые нужны только базовому транспорту, можно держать отдельно:
```ts
// src/infra/pet-project-api/types/client.ts
export type QueryParams = Record<string, string | number | boolean>
```
## Ошибка API
Ошибка API тоже относится к REST-модулю.
```ts
// src/infra/pet-project-api/errors/pet-project-api.error.ts
export class PetProjectApiError extends Error {
constructor(
public readonly status: number,
message: string,
) {
super(message)
this.name = 'PetProjectApiError'
}
}
```
## Базовый клиент
`client.ts` содержит только транспортную оболочку и сборку инстанса. Прямой `fetch` живёт здесь, а не в компонентах и не в методах верхних слоёв.
```ts
// src/infra/pet-project-api/client.ts
import { PetProjectApiError } from './errors/pet-project-api.error'
import type { QueryParams } from './types/client'
export class PetProjectApiClient {
constructor(
private readonly baseUrl: string,
private readonly defaultHeaders: Record<string, string> = {},
) {}
async get<T>(path: string, params: QueryParams = {}): Promise<T> {
const base = `${this.baseUrl.replace(/\/+$/, '')}/`
const url = new URL(path.replace(/^\/+/, ''), base)
Object.entries(params).forEach(([key, value]) => {
url.searchParams.set(key, String(value))
})
const response = await fetch(url, {
headers: {
Accept: 'application/json',
...this.defaultHeaders,
},
})
if (!response.ok) {
throw new PetProjectApiError(response.status, response.statusText)
}
return response.json() as Promise<T>
}
}
```
Это минимальный шаблон. Авторизация, дополнительные заголовки, `next.revalidate`, `post`, `formdata` и другие детали добавляются только когда они реально нужны API.
## Методы API
Методы группируются по сущностям в `methods/`. Они не знают про React, SWR и UI.
```ts
// src/infra/pet-project-api/methods/posts.ts
import type { PetProjectApiClient } from '../client'
import type { PostDto, PostListQueryDto } from '../types/post'
export function postsMethods(client: PetProjectApiClient) {
return {
/** GET /posts */
list: (query: PostListQueryDto = {}) =>
client.get<PostDto[]>('posts', query),
/** GET /posts/{slug} */
get: (slug: string) =>
client.get<PostDto>(`posts/${slug}`),
}
}
```
Метод возвращает DTO в форме API. Если данным нужен доменный смысл, маппинг делается выше, в `business/`.
## Публичный API
`index.ts` собирает именованный API-объект и открывает наружу только публичные части модуля.
```ts
// src/infra/pet-project-api/index.ts
import { PetProjectApiClient } from './client'
import { postsMethods } from './methods/posts'
const client = new PetProjectApiClient(
process.env.NEXT_PUBLIC_API_URL ?? '',
{ 'Content-Type': 'application/json' },
)
export const petProjectApi = {
posts: postsMethods(client),
}
export { PetProjectApiError } from './errors/pet-project-api.error'
export type { PostDto, PostListQueryDto } from './types'
export * from './hooks'
```
Внешний код импортирует только из `infra/pet-project-api`, не из внутренних файлов модуля.
## Правила
- `fetch` используется только внутри базового клиента.
- DTO запросов и ответов живут в `types/`.
- `client.ts` не содержит DTO, GET-хуки и бизнес-логику.
- Методы лежат в `methods/` и возвращают DTO.
- GET-хуки добавляются отдельно в `hooks/`, если данные нужны в Client Components.
- Доменные типы и маппинг DTO живут не в REST-клиенте, а в `business/`.
Следующий шаг: [GET-хуки REST-клиента](./hooks.md) или [Стратегии получения данных](../strategies/index.md).

View File

@@ -1,74 +0,0 @@
---
title: REST
description: Как правильно работать с REST API в проекте.
keywords: [rest, api, данные, infra, клиент, swr, стратегии]
---
# REST
Раздел описывает, как правильно работать с REST API в проекте: создать клиент сервиса и выбрать способ получения данных в приложении.
REST в проекте проходит через два главных этапа:
1. Создание клиента.
2. Использование.
## 1. Создание клиента
На этом этапе внешний API оформляется как модуль слоя `infra/`.
Клиент отвечает за:
- генерацию или ручное описание методов API;
- настройку `baseUrl`;
- заголовки и авторизацию;
- обработку ошибок;
- кастомизацию и расширение типов;
- GET-хуки для клиентских компонентов;
- публичный API модуля.
Если у API есть OpenAPI-спецификация — клиент генерируется автоматически. Если OpenAPI нет или он неполный — клиент создаётся вручную.
GET-хуки относятся к клиенту, потому что это прозрачные SWR-обёртки над GET-методами этого клиента.
Подробнее:
- [Создание клиента](./clients/index.md)
- [Автогенерация из OpenAPI](./clients/auto.md)
- [Ручное создание](./clients/manual.md)
- [GET-хуки REST-клиента](./clients/hooks.md)
## 2. Использование
После создания клиента нужно определить рендер страницы и выбрать, как получать данные в конкретном месте приложения.
Раздел использования отвечает на вопросы:
- как понять, можно ли сохранить static/ISR;
- когда страница становится dynamic/SSR;
- когда получать данные через серверный `await`;
- когда запускать несколько серверных запросов параллельно;
- когда передавать промис ниже по дереву;
- когда передавать начальные данные клиентским GET-хукам;
- когда использовать GET-хук в клиентском компоненте;
- когда выносить композицию и бизнес-смысл в `business/`.
Подробнее:
- [Стратегии получения данных](./strategies/index.md)
- [Серверный await](./strategies/server-await.md)
- [Параллельные серверные запросы](./strategies/parallel-server-requests.md)
- [Передача промиса ниже](./strategies/pass-promise-down.md)
- [Начальные данные для клиентских хуков](./strategies/client-hooks-initial-data.md)
- [Клиентский GET-хук](./strategies/client-get-hook.md)
- [Business-композиция](./strategies/business-composition.md)
## Как читать раздел
Если API ещё не подключён — начните с [Создания клиента](./clients/index.md).
Если клиент уже есть, но непонятно как получить данные — начните со [Стратегий получения данных](./strategies/index.md).
Если данные нужны в Client Component — сначала проверьте, есть ли [GET-хук REST-клиента](./clients/hooks.md).
Если в коде появляется бизнес-смысл вроде `isAuth`, `canEdit`, `hasAccess` — это уже не REST-клиент, а `business/`.

View File

@@ -1,121 +0,0 @@
---
title: Business-композиция
description: Когда REST-данные нужно объединить или интерпретировать в бизнес-модуле.
keywords: [rest, business, композиция, hooks, domain, isAuth]
---
# Business-композиция
Business-композиция используется, когда простого GET-метода или прозрачного GET-хука недостаточно: нужно объединить несколько источников, преобразовать DTO или вычислить доменное состояние.
## Когда использовать
- Нужно объединить несколько GET-запросов.
- Нужно вычислить `isAuth`, `canEdit`, `hasAccess`, `hasPets`.
- Нужно преобразовать DTO в доменную модель.
- Нужно спрятать бизнес-сценарий за доменным API.
Такая логика не пишется в `infra/`. REST-клиент остаётся прозрачным адаптером к API.
## Пример поверх одного GET-хука
```ts
// src/business/pets/hooks/use-available-pets.hook.ts
import { useGetPetList } from 'infra/pet-store-api'
/**
* Доменный список доступных питомцев.
*/
export const useAvailablePets = () => {
const query = useGetPetList('available')
return {
...query,
hasPets: Boolean(query.data?.length),
}
}
```
`useGetPetList` — infra-хук. `hasPets` — бизнес-интерпретация, поэтому она появляется в `business/pets`.
## Пример композиции нескольких GET-хуков
```ts
// src/business/pets/hooks/use-pets-dashboard.hook.ts
import { useGetPetList } from 'infra/pet-store-api'
/**
* Данные dashboard по питомцам.
*/
export const usePetsDashboard = () => {
const availablePets = useGetPetList('available')
const pendingPets = useGetPetList('pending')
const soldPets = useGetPetList('sold')
return {
availablePets,
pendingPets,
soldPets,
total:
(availablePets.data?.length ?? 0) +
(pendingPets.data?.length ?? 0) +
(soldPets.data?.length ?? 0),
}
}
```
Композиция нескольких запросов не добавляется в `infra/pet-store-api/hooks/`, потому что это уже сценарий потребления данных.
## Пример auth-состояния
```ts
// src/business/auth/hooks/use-auth-state.hook.ts
import { useGetCurrentUser } from 'infra/backend-api'
/**
* Состояние авторизации текущего пользователя.
*/
export const useAuthState = () => {
const currentUser = useGetCurrentUser()
const user = currentUser.data
return {
...currentUser,
user,
isAuth: Boolean(user),
}
}
```
`isAuth` не является частью REST-клиента. Это доменный смысл результата запроса.
## Где размещать
```text
src/business/
└── pets/
├── hooks/
│ └── use-available-pets.hook.ts
├── mappers/
│ └── map-pet-dto-to-pet.ts
├── types/
└── index.ts
```
Модуль `business/` экспортирует наружу готовый доменный API через `index.ts`.
## Что запрещено
```ts
// Плохо — business-смысл внутри infra-хука
export const useGetPetList = (status: PetStatus) => {
const query = useSWR(...)
return {
...query,
hasPets: Boolean(query.data?.length),
}
}
```
REST-модуль отвечает за доступ к API. Business-модуль отвечает за смысл этих данных в продукте.

View File

@@ -1,89 +0,0 @@
---
title: Клиентский GET-хук
description: Получение REST-данных в Client Components через готовые GET-хуки REST-клиента.
keywords: [rest, client components, swr, get-хук, client state]
---
# Клиентский GET-хук
Клиентский GET-хук используется, когда данные зависят от состояния браузера: вкладки, фильтра, поиска, пагинации, модалки или действия пользователя.
## Когда использовать
- Запрос зависит от client state.
- Данные не обязательны для первого HTML.
- Пользователь меняет параметры запроса на клиенте.
- Нужны SWR-кеширование, дедупликация и ревалидация.
## Пример с вкладками
```tsx
'use client'
import { useState } from 'react'
import { useGetPetList } from 'infra/pet-store-api'
import type { PetStatus } from 'infra/pet-store-api'
const statuses: PetStatus[] = ['available', 'pending', 'sold']
export function PetTabs() {
const [status, setStatus] = useState<PetStatus>('available')
const { data: pets, isLoading, error } = useGetPetList(status)
return (
<section>
<div>
{statuses.map((item) => (
<button key={item} type="button" onClick={() => setStatus(item)}>
{item}
</button>
))}
</div>
{isLoading && <div>Загрузка...</div>}
{error && <div>Ошибка загрузки</div>}
<ul>
{pets?.map((pet) => (
<li key={pet.id}>{pet.name}</li>
))}
</ul>
</section>
)
}
```
Компонент выбирает параметр `status`, но не знает про SWR-ключ и fetcher. Запрос выполняет готовый GET-хук REST-клиента.
## Если хука нет
Хук добавляется в REST-модуль сервиса:
```text
src/infra/pet-store-api/hooks/use-get-pet-list.hook.ts
```
Не создавайте локальный `useSWR` в компоненте.
## Плохо
```tsx
// Плохо — прямой вызов клиента в useEffect
useEffect(() => {
petStoreApi.pet.findPetsByStatus({ status }).then(setPets)
}, [status])
// Плохо — useSWR в компоненте
const { data } = useSWR(
['pet-store-api', 'pet', 'list', status],
() => petStoreApi.pet.findPetsByStatus({ status }),
)
```
Такой код теряет единое место для ключей, дублирует fetcher и разносит инфраструктурные детали по UI.
## Когда выбрать другую стратегию
- Данные нужны до первого HTML — [Серверный await](./server-await.md).
- Клиентский хук должен получить начальные данные сразу — [Начальные данные для клиентских хуков](./client-hooks-initial-data.md).
- Нужно вычислить бизнес-состояние — [Business-композиция](./business-composition.md).

View File

@@ -1,109 +0,0 @@
---
title: Начальные данные для клиентских хуков
description: Как дать клиентским GET-хукам начальные REST-данные.
keywords: [rest, swr, fallback, initial data, client hooks, unstable_serialize, isr, ssr]
---
# Начальные данные для клиентских хуков
Как дать клиентским GET-хукам начальные REST-данные.
Эта стратегия используется, когда данные должны быть запущены на сервере, но потребляться на клиенте через GET-хуки REST-клиента.
Технически это делается через `SWRConfig fallback`: сервер передаёт промис в fallback, а клиентский хук использует тот же SWR-ключ.
## Когда использовать
- Внутри страницы есть Client Components с GET-хуками.
- Нужно начать загрузку данных на сервере раньше.
- Клиентский компонент должен остаться обычным потребителем `useGetPetList(...)`.
- Не нужно писать отдельный prop-drilling для начальных данных.
## Рендер страницы
Перед этой стратегией сначала определите рендер маршрута. Серверный preload для `fallback` подчиняется тем же правилам, что и любой серверный запрос в `page.tsx` или `layout.tsx`.
Если данные общие и могут обновляться по интервалу, сохраняйте static/ISR. Если preload зависит от cookie, headers, `searchParams`, `no-store` или персональных данных пользователя, маршрут становится dynamic/SSR.
`SWRConfig fallback` не должен быть причиной отключать ISR на всякий случай. Он только передаёт клиентскому GET-хуку данные, которые уже были запущены на сервере.
## Ключ хука
```ts
// src/infra/pet-store-api/hooks/use-get-pet-list.hook.ts
export const getPetListKey = (status: PetStatus) =>
['pet-store-api', 'pet', 'list', status] as const
```
Ключ экспортируется из REST-модуля, потому что он нужен и GET-хуку, и серверному `SWRConfig fallback`.
## Пример layout
```tsx
// src/app/(routes)/pets/layout.tsx
import type { ReactNode } from 'react'
import { SWRConfig, unstable_serialize } from 'swr'
import {
getPetListKey,
petStoreApi,
} from 'infra/pet-store-api'
type PetsLayoutProps = {
children: ReactNode
}
export default async function PetsLayout({ children }: PetsLayoutProps) {
const availablePetsPromise = petStoreApi.pet.findPetsByStatus({
status: 'available',
})
return (
<SWRConfig
value={{
fallback: {
[unstable_serialize(getPetListKey('available'))]: availablePetsPromise,
},
}}
>
{children}
</SWRConfig>
)
}
```
Если GET-хук использует array-key, ключ для `fallback` сериализуется через `unstable_serialize`.
## Клиентский компонент
```tsx
'use client'
import { useGetPetList } from 'infra/pet-store-api'
export function PetList() {
const { data: pets, isLoading } = useGetPetList('available')
if (isLoading) return <div>Загрузка...</div>
return (
<ul>
{pets?.map((pet) => (
<li key={pet.id}>{pet.name}</li>
))}
</ul>
)
}
```
Компонент не знает, что данные были запущены на сервере. Он использует обычный GET-хук REST-клиента.
## Что важно
- Ключ `fallback` должен совпадать с ключом GET-хука.
- Серверный код вызывает метод клиента, а не GET-хук.
- Клиентский компонент вызывает GET-хук, а не `useSWR` напрямую.
- Эта стратегия не означает ручную работу с кешем в компонентах.
## Когда не использовать
Если данные нужны только серверному компоненту, используйте [Серверный await](./server-await.md). Если данные зависят от состояния браузера, используйте [Клиентский GET-хук](./client-get-hook.md).

View File

@@ -1,100 +0,0 @@
---
title: Стратегии получения данных
description: Как выбрать получение REST-данных с учётом рендера страницы.
keywords: [rest, стратегии, render, isr, ssr, server components, swr, promise, business]
---
# Стратегии получения данных
Как выбрать получение REST-данных с учётом рендера страницы.
Перед выбором стратегии должен быть создан REST-клиент сервиса. Если клиента ещё нет, начните с раздела [Создание клиента](../clients/index.md).
## Сначала определите рендер страницы
В Next.js выбор начинается не с `await`, `Suspense` или SWR. Сначала нужно понять, какой рендер получится у маршрута: static/ISR или dynamic/SSR.
Next.js может перевести страницу в dynamic rendering автоматически, если в маршруте используются API текущего запроса. Поэтому первый вопрос такой:
```text
Можно ли сохранить ISR, или странице нужны данные на каждый request?
```
ISR — приоритет. Если данные общие для пользователей и их можно обновлять с интервалом, не переводите страницу в SSR без необходимости.
SSR/dynamic rendering выбирается только когда данные действительно зависят от текущего request или должны пересчитываться на каждый запрос.
## Что переводит страницу в dynamic rendering
Проверьте, нужны ли странице API и настройки, которые делают маршрут динамическим:
- `cookies()` — данные зависят от cookie текущего пользователя.
- `headers()` — данные зависят от request headers.
- `draftMode()` — нужен preview/draft-режим.
- `searchParams` в `page.tsx` — данные зависят от query string.
- `cache: 'no-store'` или `revalidate: 0` в методе клиента — запрос нельзя кешировать.
- `connection()` — рендер явно ждёт request.
- `export const dynamic = 'force-dynamic'` — SSR включён вручную.
Если ничего из этого не нужно, сначала проектируйте страницу как static/ISR. Серверный `await` сам по себе не означает SSR: режим зависит от кеширования запроса и dynamic API маршрута.
## Рендер перед стратегией
| Рендер | Когда подходит | Что выбирать дальше |
|--------|----------------|---------------------|
| Static/ISR | Данные общие и могут обновляться по интервалу | Серверные стратегии: `await`, `Promise.all`, передача промиса ниже, SWR `fallback` |
| SSR/dynamic | Данные зависят от request, пользователя или должны быть свежими на каждый запрос | Серверные стратегии с учётом блокировки первого HTML |
| После гидрации | Данные зависят от вкладки, фильтра, поиска, пагинации или действия пользователя | Клиентский GET-хук |
## Как выбрать стратегию
Когда режим рендера понятен, выбирайте конкретный способ получения данных:
| Ситуация после выбора рендера | Стратегия | Где читать |
|-------------------------------|-----------|------------|
| Данные обязательны для первого HTML, SEO, `notFound()` или `redirect()` | Серверный `await` | [Серверный await](./server-await.md) |
| Несколько независимых данных нужны до рендера | Запуск промисов + `Promise.all` | [Параллельные серверные запросы](./parallel-server-requests.md) |
| Часть UI можно загрузить отдельно | Передача промиса ниже + `Suspense` | [Передача промиса ниже](./pass-promise-down.md) |
| Client Component должен получить данные сразу из SWR | Начальные данные для клиентских хуков | [Начальные данные для клиентских хуков](./client-hooks-initial-data.md) |
| Данные зависят от client state | Клиентский GET-хук | [Клиентский GET-хук](./client-get-hook.md) |
| Нужно объединить несколько запросов или вычислить `isAuth`, `canEdit`, `hasPets` | Business-композиция | [Business-композиция](./business-composition.md) |
## Правило выбора
Не выбирайте стратегию по любимому инструменту. Выбирайте её по двум вопросам:
```text
Можно ли сохранить ISR?
Где нужны данные и что должно произойти до первого HTML?
```
Если данные можно кешировать между пользователями — сохраняйте static/ISR. Если данные request-specific — используйте SSR/dynamic rendering. Если данные зависят от состояния браузера — используйте GET-хук REST-клиента. Если простой GET превращается в доменный сценарий — переходите в `business/`.
## Общие запреты
```tsx
// Плохо — SSR включён на всякий случай
export const dynamic = 'force-dynamic'
// Плохо — ISR отключён без требования к свежести на каждый request
export const revalidate = 0
// Плохо — прямой fetch в компоненте
useEffect(() => {
fetch('/api/pets').then(...)
}, [])
// Плохо — useSWR в компоненте
const { data } = useSWR(
['pet-store-api', 'pet', 'list', status],
() => petStoreApi.pet.findPetsByStatus({ status }),
)
// Плохо — бизнес-флаг внутри GET-хука REST-клиента
return {
...query,
hasPets: Boolean(query.data?.length),
}
```
Не отключайте ISR без причины. В компонентах используются готовые методы клиента или готовые хуки. SWR-ключи, fetcher и транспорт остаются внутри REST-модуля.

View File

@@ -1,82 +0,0 @@
---
title: Параллельные серверные запросы
description: Как запускать независимые REST-запросы на сервере без waterfall.
keywords: [rest, promise.all, параллельные запросы, server components]
---
# Параллельные серверные запросы
Если серверному компоненту нужно несколько независимых данных, запускайте запросы до ожидания результата. Последовательный `await` создаёт waterfall и замедляет рендер.
## Когда использовать
- Запросы независимы друг от друга.
- Все данные нужны текущему серверному компоненту перед возвратом UI.
- Нельзя или не нужно стримить часть UI отдельно.
## Хорошо
```tsx
import { petStoreApi } from 'infra/pet-store-api'
import { PetsDashboardScreen } from 'screens/pets-dashboard'
export default async function PetsDashboardPage() {
const availablePetsPromise = petStoreApi.pet.findPetsByStatus({ status: 'available' })
const pendingPetsPromise = petStoreApi.pet.findPetsByStatus({ status: 'pending' })
const soldPetsPromise = petStoreApi.pet.findPetsByStatus({ status: 'sold' })
const [availablePets, pendingPets, soldPets] = await Promise.all([
availablePetsPromise,
pendingPetsPromise,
soldPetsPromise,
])
return (
<PetsDashboardScreen
availablePets={availablePets}
pendingPets={pendingPets}
soldPets={soldPets}
/>
)
}
```
## Плохо
```tsx
export default async function PetsDashboardPage() {
const availablePets = await petStoreApi.pet.findPetsByStatus({ status: 'available' })
const pendingPets = await petStoreApi.pet.findPetsByStatus({ status: 'pending' })
const soldPets = await petStoreApi.pet.findPetsByStatus({ status: 'sold' })
return (
<PetsDashboardScreen
availablePets={availablePets}
pendingPets={pendingPets}
soldPets={soldPets}
/>
)
}
```
Во втором примере каждый следующий запрос ждёт предыдущий, хотя они независимы.
## Зависимые запросы
Если второй запрос зависит от результата первого, последовательный `await` допустим:
```tsx
export default async function OrderPage({ params }: OrderPageProps) {
const { id } = await params
const order = await petStoreApi.store.getOrderById(Number(id))
const pet = await petStoreApi.pet.getPetById(order.petId)
return <OrderScreen order={order} pet={pet} />
}
```
Не превращайте зависимый сценарий в `Promise.all` искусственно.
## Когда выбрать другую стратегию
Если часть данных не обязательна для первого блока UI, можно запустить промис выше и передать его ниже: [Передача промиса ниже](./pass-promise-down.md).

View File

@@ -1,62 +0,0 @@
---
title: Передача промиса ниже
description: Как запускать серверный REST-запрос выше и ожидать его во вложенном server-компоненте.
keywords: [rest, promise, suspense, streaming, server components]
---
# Передача промиса ниже
Серверный компонент может запустить запрос и передать промис вложенному server-компоненту. Это полезно, когда часть UI можно загрузить отдельно через `Suspense`.
## Когда использовать
- Верхняя часть страницы может отрендериться без этих данных.
- Данные нужны только вложенному server-компоненту.
- Нужна `Suspense`-граница и серверный стриминг.
## Пример
```tsx
// src/app/(routes)/pets/page.tsx
import { Suspense } from 'react'
import { petStoreApi } from 'infra/pet-store-api'
import { PetListSection } from 'widgets/pet-list-section'
import { PetListSkeleton } from 'widgets/pet-list-section'
import type { Pet } from 'infra/pet-store-api'
export default function PetsPage() {
const petsPromise = petStoreApi.pet.findPetsByStatus({ status: 'available' })
return (
<main>
<h1>Питомцы</h1>
<Suspense fallback={<PetListSkeleton />}>
<AvailablePets petsPromise={petsPromise} />
</Suspense>
</main>
)
}
async function AvailablePets({ petsPromise }: { petsPromise: Promise<Pet[]> }) {
const pets = await petsPromise
return <PetListSection pets={pets} />
}
```
Запрос стартует в `PetsPage`, но ожидание происходит внутри `AvailablePets`. `Suspense` управляет fallback для этой части UI.
## Граница стратегии
Эта стратегия остаётся серверной. Не используйте её как замену GET-хукам в Client Components.
Если данные должны попасть в клиентский SWR-хук, используйте [Начальные данные для клиентских хуков](./client-hooks-initial-data.md).
## Что не делать
```tsx
// Плохо — передавать промис в произвольный клиентский компонент без ясной стратегии
return <PetListClient petsPromise={petsPromise} />
```
Для клиентского потребления есть отдельная стратегия через `SWRConfig fallback` и готовые GET-хуки REST-клиента.

View File

@@ -1,88 +0,0 @@
---
title: Серверный await
description: Получение REST-данных на сервере до первого HTML.
keywords: [rest, server components, await, nextjs, isr, ssr, notFound, redirect]
---
# Серверный await
Получение REST-данных на сервере до первого HTML.
Серверный `await` — базовая стратегия для данных, которые нужны до рендера страницы или серверного блока.
## Когда использовать
- Данные нужны для первого HTML.
- Данные влияют на `metadata`.
- По результату запроса нужно вызвать `notFound()` или `redirect()`.
- Компонент серверный и данные не зависят от состояния браузера.
## Влияние на рендер
Серверный `await` сам по себе не означает SSR. В App Router страница может остаться static/ISR, если маршрут не использует dynamic API и запросы можно кешировать.
ISR — приоритет для общих данных. Если список или детальная страница могут обновляться по интервалу, сохраняйте кеширование и не добавляйте `no-store`, `revalidate: 0` или `force-dynamic` без требования.
SSR/dynamic rendering нужен, когда данные зависят от текущего request: cookie, headers, `searchParams`, preview-режим или персональные данные пользователя.
## Пример страницы списка
```tsx
// src/app/(routes)/pets/page.tsx
import { petStoreApi } from 'infra/pet-store-api'
import { PetsScreen } from 'screens/pets'
export default async function PetsPage() {
const pets = await petStoreApi.pet.findPetsByStatus({
status: 'available',
})
return <PetsScreen pets={pets} />
}
```
`page.tsx` получает данные первого рендера и передаёт их ниже. UI страницы остаётся в `screens/`, а не пишется прямо в `app/`.
## Пример детальной страницы
```tsx
// src/app/(routes)/pets/[id]/page.tsx
import { notFound } from 'next/navigation'
import { petStoreApi } from 'infra/pet-store-api'
import { PetDetailScreen } from 'screens/pet-detail'
type PetPageProps = {
params: Promise<{ id: string }>
}
export default async function PetPage({ params }: PetPageProps) {
const { id } = await params
const pet = await petStoreApi.pet.getPetById(Number(id)).catch(() => null)
if (!pet) {
notFound()
}
return <PetDetailScreen pet={pet} />
}
```
Обработка 404 зависит от API-клиента и класса ошибок. В примере показана идея: решение о `notFound()` принимается на уровне маршрута, а не внутри REST-клиента.
## Что не делать
```tsx
// Плохо — хуки нельзя вызывать в Server Component
const { data } = useGetPetList('available')
// Плохо — прямой fetch в обход клиента
const response = await fetch('https://petstore3.swagger.io/api/v3/pet/findByStatus')
```
Если данные нужны на сервере, вызывайте метод REST-клиента напрямую.
## Когда выбрать другую стратегию
- Несколько независимых запросов — [Параллельные серверные запросы](./parallel-server-requests.md).
- Часть UI можно грузить отдельно — [Передача промиса ниже](./pass-promise-down.md).
- Данные нужны клиентскому хуку сразу после гидрации — [Начальные данные для клиентских хуков](./client-hooks-initial-data.md).

View File

@@ -1,8 +0,0 @@
---
title: Подсказки
description: Короткие ответы на типовые вопросы и решения для спорных ситуаций.
---
# Подсказки
Короткие ответы на типовые вопросы и решения для спорных ситуаций.

View File

@@ -3,6 +3,7 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Image Platform Admin</title> <title>Image Platform Admin</title>
</head> </head>
<body> <body>

View File

@@ -18,6 +18,7 @@
"clsx": "^2.1.1", "clsx": "^2.1.1",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-router-dom": "^7.15.0",
"swr": "^2.4.1" "swr": "^2.4.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
<rect width="64" height="64" rx="16" fill="#191927" />
<circle cx="24" cy="24" r="9" fill="#7b4cff" />
<path d="M12 48 28 32l10 10 6-7 10 13H12Z" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -0,0 +1,11 @@
import { Route, Routes } from "react-router-dom"
import { AssetDetailPage, NotFoundPage, ProjectAssetsPage, ProjectsPage } from "pages"
export const AppRouter = () => (
<Routes>
<Route element={<ProjectsPage />} path="/" />
<Route element={<ProjectAssetsPage />} path="/projects/:projectSlug" />
<Route element={<AssetDetailPage />} path="/projects/:projectSlug/assets/:publicId" />
<Route element={<NotFoundPage />} path="*" />
</Routes>
)

View File

@@ -1,13 +1,17 @@
import { BrowserRouter } from "react-router-dom"
import { ThemeProvider } from "infra/theme" import { ThemeProvider } from "infra/theme"
import { MainLayout } from "layouts/main" import { MainLayout } from "layouts/main"
import { DashboardScreen } from "screens/dashboard"
import { AppRouter } from "./app-router"
export function App() { export function App() {
return ( return (
<ThemeProvider> <BrowserRouter>
<MainLayout> <ThemeProvider>
<DashboardScreen /> <MainLayout>
</MainLayout> <AppRouter />
</ThemeProvider> </MainLayout>
</ThemeProvider>
</BrowserRouter>
) )
} }

View File

@@ -1,9 +1,12 @@
import { useAssetPicture } from "./hooks/use-asset-picture.hook" import { useAssetPicture } from "./hooks/use-asset-picture.hook"
import { useAssetVersions } from "./hooks/use-asset-versions.hook"
import { useAssetOverview } from "./hooks/use-asset-overview.hook" import { useAssetOverview } from "./hooks/use-asset-overview.hook"
import { useAssetsDashboard } from "./hooks/use-assets-dashboard.hook" import { useAssetsDashboard } from "./hooks/use-assets-dashboard.hook"
import { useCreateAsset } from "./hooks/use-create-asset.hook" import { useCreateAsset } from "./hooks/use-create-asset.hook"
import { useCreateAssetVersion } from "./hooks/use-create-asset-version.hook" import { useCreateAssetVersion } from "./hooks/use-create-asset-version.hook"
import { useGenerateAssetVariants } from "./hooks/use-generate-asset-variants.hook" import { useGenerateAssetVariants } from "./hooks/use-generate-asset-variants.hook"
import { useImagePresets } from "./hooks/use-image-presets.hook"
import { useProjectAssets } from "./hooks/use-project-assets.hook"
import type { AssetsFactory } from "./types/assets-factory.type" import type { AssetsFactory } from "./types/assets-factory.type"
/** /**
@@ -13,9 +16,12 @@ export const assetsFactory: AssetsFactory = () => {
return { return {
useAssetOverview, useAssetOverview,
useAssetPicture, useAssetPicture,
useAssetVersions,
useAssetsDashboard, useAssetsDashboard,
useCreateAsset, useCreateAsset,
useCreateAssetVersion, useCreateAssetVersion,
useGenerateAssetVariants, useGenerateAssetVariants,
useImagePresets,
useProjectAssets,
} }
} }

View File

@@ -0,0 +1,24 @@
import { useGetAssetVersions } from "infra/backend-api"
import type { AssetVersionsHistory } from "../types/assets-api.type"
/**
* История source versions выбранного asset.
*/
export const useAssetVersions = (publicId: string | null): AssetVersionsHistory => {
const versionsQuery = useGetAssetVersions(publicId)
const refresh = async () => {
await versionsQuery.mutate()
}
return {
currentVersion: versionsQuery.data?.currentVersion ?? null,
error: versionsQuery.error,
isLoading: versionsQuery.isLoading,
isRefreshing: versionsQuery.isValidating,
publicId: versionsQuery.data?.publicId ?? publicId,
refresh,
versions: versionsQuery.data?.versions ?? [],
}
}

View File

@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { useSWRConfig } from "swr" import { useSWRConfig } from "swr"
import { backendApi, getAssetKey, getAssetVariantsKey, getAssetsListKey } from "infra/backend-api" import { backendApi, getAssetKey, getAssetVariantsKey, getAssetVersionsKey, getAssetsListKey } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config" import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error" import { toError } from "../lib/to-error"
@@ -27,6 +27,7 @@ export const useCreateAssetVersion = (): CreateAssetVersionAction => {
await Promise.all([ await Promise.all([
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)), mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
mutate(getAssetKey(input.publicId)), mutate(getAssetKey(input.publicId)),
mutate(getAssetVersionsKey(input.publicId)),
mutate(getAssetVariantsKey(input.publicId, String(createdVersion.version))), mutate(getAssetVariantsKey(input.publicId, String(createdVersion.version))),
]) ])

View File

@@ -1,6 +1,6 @@
import { useState } from "react" import { useState } from "react"
import { useSWRConfig } from "swr" import { useSWRConfig } from "swr"
import { backendApi, getAssetsListKey } from "infra/backend-api" import { backendApi, getAssetsListKey, getProjectAssetsKey } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config" import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error" import { toError } from "../lib/to-error"
@@ -19,8 +19,15 @@ export const useCreateAsset = (): CreateAssetAction => {
setIsCreating(true) setIsCreating(true)
try { try {
const createdAsset = await backendApi.assets.createAsset(input) const { projectSlug, ...request } = input
await mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)) const createdAsset = projectSlug
? await backendApi.projects.createProjectAsset({ projectSlug }, request)
: await backendApi.assets.createAsset(request)
await Promise.all([
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
projectSlug ? mutate(getProjectAssetsKey(projectSlug, ASSETS_DASHBOARD_LIST_PARAMS)) : Promise.resolve(),
])
return createdAsset return createdAsset
} catch (caughtError) { } catch (caughtError) {

View File

@@ -0,0 +1,23 @@
import { useGetPresets } from "infra/backend-api"
import type { ImagePresetsOverview } from "../types/assets-api.type"
/**
* Presets изображений без загрузки общего assets dashboard.
*/
export const useImagePresets = (): ImagePresetsOverview => {
const presetsQuery = useGetPresets()
const refresh = async () => {
await presetsQuery.mutate()
}
return {
custom: presetsQuery.data?.custom ?? null,
error: presetsQuery.error,
isLoading: presetsQuery.isLoading,
isRefreshing: presetsQuery.isValidating,
presets: presetsQuery.data?.presets ?? [],
refresh,
}
}

View File

@@ -0,0 +1,23 @@
import { useGetProjectAssets } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import type { ProjectAssetsOverview } from "../types/assets-api.type"
/**
* Assets выбранного проекта.
*/
export const useProjectAssets = (projectSlug: string | null): ProjectAssetsOverview => {
const assetsQuery = useGetProjectAssets(projectSlug, ASSETS_DASHBOARD_LIST_PARAMS)
const refresh = async () => {
await assetsQuery.mutate()
}
return {
assets: assetsQuery.data?.assets ?? [],
error: assetsQuery.error,
isLoading: assetsQuery.isLoading,
isRefreshing: assetsQuery.isValidating,
refresh,
}
}

View File

@@ -2,6 +2,7 @@ export { assetsFactory } from "./assets.factory"
export type { export type {
AssetOverview, AssetOverview,
AssetPicturePreview, AssetPicturePreview,
AssetVersionsHistory,
AssetsApi, AssetsApi,
AssetsDashboard, AssetsDashboard,
AssetVariantFormat, AssetVariantFormat,
@@ -13,5 +14,7 @@ export type {
CreateAssetVersionInput, CreateAssetVersionInput,
GenerateAssetVariantsAction, GenerateAssetVariantsAction,
GenerateAssetVariantsInput, GenerateAssetVariantsInput,
ImagePresetsOverview,
ProjectAssetsOverview,
} from "./types/assets-api.type" } from "./types/assets-api.type"
export type { AssetsFactory } from "./types/assets-factory.type" export type { AssetsFactory } from "./types/assets-factory.type"

View File

@@ -2,6 +2,7 @@ import type {
AssetResponseDto, AssetResponseDto,
AssetPictureResponseDto, AssetPictureResponseDto,
AssetVariantResponseDto, AssetVariantResponseDto,
AssetVersionResponseDto,
CreateAssetRequestDto, CreateAssetRequestDto,
CreateAssetResponseDto, CreateAssetResponseDto,
CreateAssetVersionResponseDto, CreateAssetVersionResponseDto,
@@ -32,6 +33,33 @@ export type AssetPicturePreview = {
refresh: () => Promise<void> refresh: () => Promise<void>
} }
export type AssetVersionsHistory = {
currentVersion: number | null
error?: Error
isLoading: boolean
isRefreshing: boolean
publicId: string | null
refresh: () => Promise<void>
versions: AssetVersionResponseDto[]
}
export type ProjectAssetsOverview = {
assets: AssetResponseDto[]
error?: Error
isLoading: boolean
isRefreshing: boolean
refresh: () => Promise<void>
}
export type ImagePresetsOverview = {
custom: PresetsResponseDto["custom"] | null
error?: Error
isLoading: boolean
isRefreshing: boolean
presets: PresetResponseDto[]
refresh: () => Promise<void>
}
export type AssetsDashboard = { export type AssetsDashboard = {
allowedSourceHosts: string[] allowedSourceHosts: string[]
assets: AssetResponseDto[] assets: AssetResponseDto[]
@@ -46,7 +74,9 @@ export type AssetsDashboard = {
} }
} }
export type CreateAssetInput = CreateAssetRequestDto export type CreateAssetInput = CreateAssetRequestDto & {
projectSlug?: string
}
export type CreateAssetAction = { export type CreateAssetAction = {
createAsset: (input: CreateAssetInput) => Promise<CreateAssetResponseDto> createAsset: (input: CreateAssetInput) => Promise<CreateAssetResponseDto>
@@ -90,8 +120,11 @@ export type GenerateAssetVariantsAction = {
export type AssetsApi = { export type AssetsApi = {
useAssetOverview: (publicId: string | null) => AssetOverview useAssetOverview: (publicId: string | null) => AssetOverview
useAssetPicture: (publicId: string | null, preset: string | null) => AssetPicturePreview useAssetPicture: (publicId: string | null, preset: string | null) => AssetPicturePreview
useAssetVersions: (publicId: string | null) => AssetVersionsHistory
useAssetsDashboard: () => AssetsDashboard useAssetsDashboard: () => AssetsDashboard
useCreateAsset: () => CreateAssetAction useCreateAsset: () => CreateAssetAction
useCreateAssetVersion: () => CreateAssetVersionAction useCreateAssetVersion: () => CreateAssetVersionAction
useGenerateAssetVariants: () => GenerateAssetVariantsAction useGenerateAssetVariants: () => GenerateAssetVariantsAction
useImagePresets: () => ImagePresetsOverview
useProjectAssets: (projectSlug: string | null) => ProjectAssetsOverview
} }

View File

@@ -0,0 +1,39 @@
import { useState } from "react"
import { useSWRConfig } from "swr"
import { backendApi, getProjectsListKey } from "infra/backend-api"
import { toError } from "../lib/to-error"
import type { CreateProjectAction, CreateProjectInput } from "../types/projects-api.type"
/**
* Сценарий создания проекта.
*/
export const useCreateProject = (): CreateProjectAction => {
const { mutate } = useSWRConfig()
const [error, setError] = useState<Error | null>(null)
const [isCreating, setIsCreating] = useState(false)
const createProject = async (input: CreateProjectInput) => {
setError(null)
setIsCreating(true)
try {
const project = await backendApi.projects.createProject(input)
await mutate(getProjectsListKey())
return project
} catch (caughtError) {
const nextError = toError(caughtError)
setError(nextError)
throw nextError
} finally {
setIsCreating(false)
}
}
return {
createProject,
error,
isCreating,
}
}

View File

@@ -0,0 +1,22 @@
import { useGetProject } from "infra/backend-api"
import type { ProjectDetail } from "../types/projects-api.type"
/**
* Metadata выбранного проекта.
*/
export const useProjectDetail = (projectSlug: string | null): ProjectDetail => {
const projectQuery = useGetProject(projectSlug)
const refresh = async () => {
await projectQuery.mutate()
}
return {
error: projectQuery.error,
isLoading: projectQuery.isLoading,
isRefreshing: projectQuery.isValidating,
project: projectQuery.data ?? null,
refresh,
}
}

View File

@@ -0,0 +1,22 @@
import { useGetProjectsList } from "infra/backend-api"
import type { ProjectsHome } from "../types/projects-api.type"
/**
* Данные главной страницы проектов.
*/
export const useProjectsHome = (): ProjectsHome => {
const projectsQuery = useGetProjectsList()
const refresh = async () => {
await projectsQuery.mutate()
}
return {
error: projectsQuery.error,
isLoading: projectsQuery.isLoading,
isRefreshing: projectsQuery.isValidating,
projects: projectsQuery.data?.projects ?? [],
refresh,
}
}

View File

@@ -0,0 +1,9 @@
export { projectsFactory } from "./projects.factory"
export type {
CreateProjectAction,
CreateProjectInput,
ProjectDetail,
ProjectsApi,
ProjectsHome,
} from "./types/projects-api.type"
export type { ProjectsFactory } from "./types/projects-factory.type"

View File

@@ -0,0 +1 @@
export const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))

View File

@@ -0,0 +1,15 @@
import { useCreateProject } from "./hooks/use-create-project.hook"
import { useProjectDetail } from "./hooks/use-project-detail.hook"
import { useProjectsHome } from "./hooks/use-projects-home.hook"
import type { ProjectsFactory } from "./types/projects-factory.type"
/**
* Создаёт runtime API бизнес-модуля Projects.
*/
export const projectsFactory: ProjectsFactory = () => {
return {
useCreateProject,
useProjectDetail,
useProjectsHome,
}
}

View File

@@ -0,0 +1,34 @@
import type { CreateProjectRequestDto, ProjectResponseDto } from "infra/backend-api"
export type ProjectsHome = {
error?: Error
isLoading: boolean
isRefreshing: boolean
projects: ProjectResponseDto[]
refresh: () => Promise<void>
}
export type ProjectDetail = {
error?: Error
isLoading: boolean
isRefreshing: boolean
project: ProjectResponseDto | null
refresh: () => Promise<void>
}
export type CreateProjectInput = CreateProjectRequestDto
export type CreateProjectAction = {
createProject: (input: CreateProjectInput) => Promise<ProjectResponseDto>
error: Error | null
isCreating: boolean
}
/**
* Публичный runtime API бизнес-модуля Projects.
*/
export type ProjectsApi = {
useCreateProject: () => CreateProjectAction
useProjectDetail: (projectSlug: string | null) => ProjectDetail
useProjectsHome: () => ProjectsHome
}

View File

@@ -0,0 +1,6 @@
import type { ProjectsApi } from "./projects-api.type"
/**
* Фабрика runtime API бизнес-модуля Projects.
*/
export type ProjectsFactory = () => ProjectsApi

View File

@@ -112,6 +112,79 @@ export interface CreateAssetResponseDto {
imageBasePath: string; imageBasePath: string;
} }
export interface AssetVersionResponseDto {
/**
* Внутренний UUID версии source image.
* @example "3b5da974-bb7f-4d73-b172-d6ad9c244528"
*/
id: string;
/**
* Номер версии source image.
* @example 2
*/
version: number;
/**
* Является ли версия текущей для asset.
* @example true
*/
isCurrent: boolean;
/**
* Source URL версии.
* @example "https://storage.yandexcloud.net/shared1318/img/1.jpg"
*/
sourceUrl: string;
/**
* Hostname source URL версии.
* @example "storage.yandexcloud.net"
*/
sourceHost: string;
/**
* Базовый Gateway path для версии.
* @example "/images/asset_demo/v2/card"
*/
imageBasePath: string;
/**
* Ширина оригинального изображения, если уже определена Worker.
* @example 1200
*/
width?: number | null;
/**
* Высота оригинального изображения, если уже определена Worker.
* @example 800
*/
height?: number | null;
/**
* Content-Type оригинального изображения, если уже определён Worker.
* @example "image/jpeg"
*/
contentType?: string | null;
/**
* Размер оригинального изображения в bytes, если уже определён Worker.
* @example 245760
*/
sizeBytes?: number | null;
/**
* Дата создания версии.
* @example "2026-05-05T12:00:00.000Z"
*/
createdAt: string;
}
export interface AssetVersionsResponseDto {
/**
* Публичный идентификатор asset.
* @example "asset_demo"
*/
publicId: string;
/**
* Текущая версия source image.
* @example 2
*/
currentVersion: number;
/** История версий source image. */
versions: AssetVersionResponseDto[];
}
export interface CreateAssetVersionRequestDto { export interface CreateAssetVersionRequestDto {
/** /**
* Постоянная ссылка на новую версию исходного изображения. * Постоянная ссылка на новую версию исходного изображения.
@@ -543,6 +616,62 @@ export interface PresetsResponseDto {
allowedSourceHosts: string[]; allowedSourceHosts: string[];
} }
export interface ProjectResponseDto {
/**
* Внутренний UUID проекта.
* @example "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66"
*/
id: string;
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
slug: string;
/**
* Название проекта.
* @example "Demo Shop"
*/
name: string;
/**
* Статус проекта.
* @example "active"
*/
status: ProjectResponseDtoStatusEnum;
/**
* Количество assets в проекте.
* @example 12
*/
assetsCount: number;
/**
* Дата создания проекта.
* @example "2026-05-05T12:00:00.000Z"
*/
createdAt: string;
/**
* Дата обновления проекта.
* @example "2026-05-05T12:00:00.000Z"
*/
updatedAt: string;
}
export interface ProjectsListResponseDto {
/** Список проектов. */
projects: ProjectResponseDto[];
}
export interface CreateProjectRequestDto {
/**
* Название проекта в admin UI.
* @example "Demo Shop"
*/
name: string;
/**
* Публичный slug проекта для URL и SDK.
* @example "demo-shop"
*/
slug?: string;
}
/** /**
* Статус asset. * Статус asset.
* @example "active" * @example "active"
@@ -704,6 +833,15 @@ export enum PresetResponseDtoResizeEnum {
Fill = "fill", Fill = "fill",
} }
/**
* Статус проекта.
* @example "active"
*/
export enum ProjectResponseDtoStatusEnum {
Active = "active",
Disabled = "disabled",
}
export interface ListAssetsParams { export interface ListAssetsParams {
/** /**
* Максимальное количество assets в ответе. * Максимальное количество assets в ответе.
@@ -725,6 +863,14 @@ export interface GetAssetParams {
publicId: string; publicId: string;
} }
export interface ListAssetVersionsParams {
/**
* Публичный идентификатор asset.
* @example "asset_demo"
*/
publicId: string;
}
export interface CreateAssetVersionParams { export interface CreateAssetVersionParams {
/** /**
* Публичный идентификатор asset. * Публичный идентификатор asset.
@@ -782,6 +928,40 @@ export interface CreateAssetVariantsParams {
publicId: string; publicId: string;
} }
export interface GetProjectParams {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
}
export interface ListProjectAssetsParams {
/**
* Максимальное количество assets в ответе.
* @example 50
*/
limit?: string;
/**
* Смещение для простого paging.
* @example 0
*/
offset?: string;
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
}
export interface CreateProjectAssetParams {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
}
export namespace System { export namespace System {
/** /**
* @description Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests. * @description Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.
@@ -862,6 +1042,27 @@ export namespace Assets {
export type ResponseBody = AssetResponseDto; export type ResponseBody = AssetResponseDto;
} }
/**
* @description Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.
* @tags assets
* @name ListAssetVersions
* @summary получить историю версий source image
* @request GET:/api/assets/{publicId}/versions
*/
export namespace ListAssetVersions {
export type RequestParams = {
/**
* Публичный идентификатор asset.
* @example "asset_demo"
*/
publicId: string;
};
export type RequestQuery = {};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = AssetVersionsResponseDto;
}
/** /**
* @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs. * @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs.
* @tags assets * @tags assets
@@ -1008,6 +1209,112 @@ export namespace Presets {
} }
} }
export namespace Projects {
/**
* @description Возвращает проекты верхнего уровня для главной страницы admin.
* @tags projects
* @name ListProjects
* @summary получить список проектов
* @request GET:/api/projects
*/
export namespace ListProjects {
export type RequestParams = {};
export type RequestQuery = {};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = ProjectsListResponseDto;
}
/**
* @description Создаёт проект, внутри которого admin управляет assets и source versions.
* @tags projects
* @name CreateProject
* @summary создать проект
* @request POST:/api/projects
*/
export namespace CreateProject {
export type RequestParams = {};
export type RequestQuery = {};
export type RequestBody = CreateProjectRequestDto;
export type RequestHeaders = {};
export type ResponseBody = ProjectResponseDto;
}
/**
* @description Возвращает metadata проекта для project-level страницы admin.
* @tags projects
* @name GetProject
* @summary получить проект по slug
* @request GET:/api/projects/{projectSlug}
*/
export namespace GetProject {
export type RequestParams = {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
};
export type RequestQuery = {};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = ProjectResponseDto;
}
/**
* @description Возвращает assets, созданные внутри выбранного проекта.
* @tags projects
* @name ListProjectAssets
* @summary получить assets проекта
* @request GET:/api/projects/{projectSlug}/assets
*/
export namespace ListProjectAssets {
export type RequestParams = {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
};
export type RequestQuery = {
/**
* Максимальное количество assets в ответе.
* @example 50
*/
limit?: string;
/**
* Смещение для простого paging.
* @example 0
*/
offset?: string;
};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = AssetsListResponseDto;
}
/**
* @description Создаёт asset и первую source version внутри выбранного проекта.
* @tags projects
* @name CreateProjectAsset
* @summary создать asset в проекте
* @request POST:/api/projects/{projectSlug}/assets
*/
export namespace CreateProjectAsset {
export type RequestParams = {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
};
export type RequestQuery = {};
export type RequestBody = CreateAssetRequestDto;
export type RequestHeaders = {};
export type ResponseBody = CreateAssetResponseDto;
}
}
/** /**
* Фетчер для SWR * Фетчер для SWR
* Принимает URL и возвращает Promise с данными * Принимает URL и возвращает Promise с данными
@@ -1366,6 +1673,25 @@ export class Api<SecurityDataType extends unknown> {
...params, ...params,
}), }),
/**
* @description Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.
*
* @tags assets
* @name ListAssetVersions
* @summary получить историю версий source image
* @request GET:/api/assets/{publicId}/versions
*/
listAssetVersions: (
{ publicId, ...query }: ListAssetVersionsParams,
params: RequestParams = {},
) =>
this.http.request<AssetVersionsResponseDto, void>({
path: `/api/assets/${publicId}/versions`,
method: "GET",
format: "json",
...params,
}),
/** /**
* @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs. * @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs.
* *
@@ -1489,4 +1815,103 @@ export class Api<SecurityDataType extends unknown> {
...params, ...params,
}), }),
}; };
projects = {
/**
* @description Возвращает проекты верхнего уровня для главной страницы admin.
*
* @tags projects
* @name ListProjects
* @summary получить список проектов
* @request GET:/api/projects
*/
listProjects: (params: RequestParams = {}) =>
this.http.request<ProjectsListResponseDto, any>({
path: `/api/projects`,
method: "GET",
format: "json",
...params,
}),
/**
* @description Создаёт проект, внутри которого admin управляет assets и source versions.
*
* @tags projects
* @name CreateProject
* @summary создать проект
* @request POST:/api/projects
*/
createProject: (
data: CreateProjectRequestDto,
params: RequestParams = {},
) =>
this.http.request<ProjectResponseDto, void>({
path: `/api/projects`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Возвращает metadata проекта для project-level страницы admin.
*
* @tags projects
* @name GetProject
* @summary получить проект по slug
* @request GET:/api/projects/{projectSlug}
*/
getProject: (
{ projectSlug, ...query }: GetProjectParams,
params: RequestParams = {},
) =>
this.http.request<ProjectResponseDto, void>({
path: `/api/projects/${projectSlug}`,
method: "GET",
format: "json",
...params,
}),
/**
* @description Возвращает assets, созданные внутри выбранного проекта.
*
* @tags projects
* @name ListProjectAssets
* @summary получить assets проекта
* @request GET:/api/projects/{projectSlug}/assets
*/
listProjectAssets: (
{ projectSlug, ...query }: ListProjectAssetsParams,
params: RequestParams = {},
) =>
this.http.request<AssetsListResponseDto, void>({
path: `/api/projects/${projectSlug}/assets`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* @description Создаёт asset и первую source version внутри выбранного проекта.
*
* @tags projects
* @name CreateProjectAsset
* @summary создать asset в проекте
* @request POST:/api/projects/{projectSlug}/assets
*/
createProjectAsset: (
{ projectSlug, ...query }: CreateProjectAssetParams,
data: CreateAssetRequestDto,
params: RequestParams = {},
) =>
this.http.request<CreateAssetResponseDto, void>({
path: `/api/projects/${projectSlug}/assets`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
};
} }

View File

@@ -2,5 +2,9 @@ export { getAssetPictureKey, useGetAssetPicture } from "./use-get-asset-picture.
export type { AssetPictureQuery } from "./use-get-asset-picture.hook" export type { AssetPictureQuery } from "./use-get-asset-picture.hook"
export { getAssetKey, useGetAsset } from "./use-get-asset.hook" export { getAssetKey, useGetAsset } from "./use-get-asset.hook"
export { getAssetVariantsKey, useGetAssetVariants } from "./use-get-asset-variants.hook" export { getAssetVariantsKey, useGetAssetVariants } from "./use-get-asset-variants.hook"
export { getAssetVersionsKey, useGetAssetVersions } from "./use-get-asset-versions.hook"
export { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook" export { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook"
export { getPresetsKey, useGetPresets } from "./use-get-presets.hook" export { getPresetsKey, useGetPresets } from "./use-get-presets.hook"
export { getProjectKey, useGetProject } from "./use-get-project.hook"
export { getProjectAssetsKey, useGetProjectAssets } from "./use-get-project-assets.hook"
export { getProjectsListKey, useGetProjectsList } from "./use-get-projects-list.hook"

View File

@@ -0,0 +1,17 @@
import useSWR from "swr"
import type { SWRConfiguration } from "swr"
import { backendApi } from "../client"
import type { AssetVersionsResponseDto } from "../generated/backend-api.generated"
export const getAssetVersionsKey = (publicId: string) => ["backend-api", "assets", "versions", publicId] as const
/**
* Получение истории source versions asset.
*/
export const useGetAssetVersions = (publicId: string | null, config?: SWRConfiguration<AssetVersionsResponseDto>) => {
const key = publicId !== null ? getAssetVersionsKey(publicId) : null
const fetcher = () => backendApi.assets.listAssetVersions({ publicId: publicId ?? "" })
return useSWR<AssetVersionsResponseDto>(key, fetcher, config)
}

View File

@@ -0,0 +1,22 @@
import useSWR from "swr"
import type { SWRConfiguration } from "swr"
import { backendApi } from "../client"
import type { AssetsListResponseDto, ListProjectAssetsParams } from "../generated/backend-api.generated"
export const getProjectAssetsKey = (projectSlug: string, params: Omit<ListProjectAssetsParams, "projectSlug"> = {}) =>
["backend-api", "projects", "assets", projectSlug, params.limit ?? null, params.offset ?? null] as const
/**
* Получение assets проекта.
*/
export const useGetProjectAssets = (
projectSlug: string | null,
params: Omit<ListProjectAssetsParams, "projectSlug"> = {},
config?: SWRConfiguration<AssetsListResponseDto>,
) => {
const key = projectSlug !== null ? getProjectAssetsKey(projectSlug, params) : null
const fetcher = () => backendApi.projects.listProjectAssets({ ...params, projectSlug: projectSlug ?? "" })
return useSWR<AssetsListResponseDto>(key, fetcher, config)
}

View File

@@ -0,0 +1,17 @@
import useSWR from "swr"
import type { SWRConfiguration } from "swr"
import { backendApi } from "../client"
import type { ProjectResponseDto } from "../generated/backend-api.generated"
export const getProjectKey = (projectSlug: string) => ["backend-api", "projects", "detail", projectSlug] as const
/**
* Получение проекта по slug.
*/
export const useGetProject = (projectSlug: string | null, config?: SWRConfiguration<ProjectResponseDto>) => {
const key = projectSlug !== null ? getProjectKey(projectSlug) : null
const fetcher = () => backendApi.projects.getProject({ projectSlug: projectSlug ?? "" })
return useSWR<ProjectResponseDto>(key, fetcher, config)
}

View File

@@ -0,0 +1,16 @@
import useSWR from "swr"
import type { SWRConfiguration } from "swr"
import { backendApi } from "../client"
import type { ProjectsListResponseDto } from "../generated/backend-api.generated"
export const getProjectsListKey = () => ["backend-api", "projects", "list"] as const
/**
* Получение списка проектов.
*/
export const useGetProjectsList = (config?: SWRConfiguration<ProjectsListResponseDto>) => {
const fetcher = () => backendApi.projects.listProjects()
return useSWR<ProjectsListResponseDto>(getProjectsListKey(), fetcher, config)
}

View File

@@ -5,6 +5,8 @@ export type {
AssetPictureResponseDto, AssetPictureResponseDto,
AssetVariantResponseDto, AssetVariantResponseDto,
AssetVariantsResponseDto, AssetVariantsResponseDto,
AssetVersionResponseDto,
AssetVersionsResponseDto,
AssetsListResponseDto, AssetsListResponseDto,
CreateAssetRequestDto, CreateAssetRequestDto,
CreateAssetResponseDto, CreateAssetResponseDto,
@@ -12,9 +14,13 @@ export type {
CreateAssetVersionResponseDto, CreateAssetVersionResponseDto,
CreateAssetVariantsRequestDto, CreateAssetVariantsRequestDto,
CreateAssetVariantsResponseDto, CreateAssetVariantsResponseDto,
CreateProjectRequestDto,
CustomTransformConfigResponseDto, CustomTransformConfigResponseDto,
GetAssetPictureParams, GetAssetPictureParams,
ListAssetsParams, ListAssetsParams,
ListProjectAssetsParams,
PresetResponseDto, PresetResponseDto,
PresetsResponseDto, PresetsResponseDto,
ProjectResponseDto,
ProjectsListResponseDto,
} from "./generated/backend-api.generated" } from "./generated/backend-api.generated"

View File

@@ -1,11 +1,25 @@
import { createTheme } from "@mantine/core" import { createTheme } from "@mantine/core"
export const ADMIN_THEME = createTheme({ export const ADMIN_THEME = createTheme({
colors: {
forest: [
"#edf3ed",
"#dfe8df",
"#bdcfbe",
"#98b199",
"#77967a",
"#5f8164",
"#506f55",
"#445846",
"#394a3c",
"#303f33",
],
},
defaultRadius: "lg", defaultRadius: "lg",
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
headings: { headings: {
fontFamily: "var(--font-sans)", fontFamily: "var(--font-sans)",
fontWeight: "850", fontWeight: "850",
}, },
primaryColor: "violet", primaryColor: "forest",
}) })

View File

@@ -1,5 +1,6 @@
import { AppShell, Badge, Group, Text, ThemeIcon } from "@mantine/core" import { AppShell, Group, Text, ThemeIcon } from "@mantine/core"
import cl from "clsx" import cl from "clsx"
import { Link } from "react-router-dom"
import styles from "./styles/main.module.css" import styles from "./styles/main.module.css"
import type { MainLayoutProps } from "./types/main.type" import type { MainLayoutProps } from "./types/main.type"
@@ -18,18 +19,16 @@ export const MainLayout = (props: MainLayoutProps) => {
<AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md"> <AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md">
<AppShell.Header className={styles.header}> <AppShell.Header className={styles.header}>
<Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}> <Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}>
<a className={styles.brand} href="/" aria-label="Image Platform Admin"> <Link className={styles.brand} to="/" aria-label="Админка платформы изображений">
<ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light"> <ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light">
IP IP
</ThemeIcon> </ThemeIcon>
<Text className={styles.brandText} fw={850}> <Text className={styles.brandText} fw={850}>
Image Platform Платформа изображений
</Text> </Text>
</a> </Link>
<Badge color="violet" radius="xl" size="lg" variant="light"> <Text className={styles.sectionLabel}>Админка</Text>
Admin MVP
</Badge>
</Group> </Group>
</AppShell.Header> </AppShell.Header>

View File

@@ -1,15 +1,11 @@
.root { .root {
min-height: 100vh; min-height: 100vh;
background: background: var(--color-page);
radial-gradient(circle at 16% 12%, var(--color-accent-wash), transparent 32rem),
radial-gradient(circle at 86% 4%, rgb(255 176 96 / 16%), transparent 28rem),
var(--color-page);
} }
.header { .header {
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: rgb(247 244 238 / 78%); background: var(--color-header);
backdrop-filter: blur(18px);
} }
.brand { .brand {
@@ -24,7 +20,6 @@
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
background: var(--color-surface); background: var(--color-surface);
color: var(--color-accent); color: var(--color-accent);
box-shadow: var(--shadow-soft);
font-size: 0.8125rem; font-size: 0.8125rem;
font-weight: 850; font-weight: 850;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -32,12 +27,21 @@
.brandText { .brandText {
display: none; display: none;
color: var(--color-text);
@media (--sm) { @media (--sm) {
display: block; display: block;
} }
} }
.sectionLabel {
color: var(--color-text-subtle);
font-size: 0.75rem;
font-weight: 760;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.main { .main {
background: transparent; background: transparent;
} }

View File

@@ -0,0 +1,16 @@
import { Navigate, useParams } from "react-router-dom"
import { AssetDetailScreen } from "screens/asset-detail"
export const AssetDetailPage = () => {
const { projectSlug, publicId } = useParams()
if (!projectSlug) {
return <Navigate replace to="/" />
}
if (!publicId) {
return <Navigate replace to={`/projects/${projectSlug}`} />
}
return <AssetDetailScreen projectSlug={projectSlug} publicId={publicId} />
}

View File

@@ -0,0 +1 @@
export { AssetDetailPage } from "./asset-detail.page"

View File

@@ -0,0 +1,4 @@
export { AssetDetailPage } from "./asset-detail-page"
export { NotFoundPage } from "./not-found-page"
export { ProjectAssetsPage } from "./project-assets-page"
export { ProjectsPage } from "./projects-page"

View File

@@ -0,0 +1 @@
export { NotFoundPage } from "./not-found.page"

View File

@@ -0,0 +1,14 @@
import { Button, Paper, Stack, Text, Title } from "@mantine/core"
import { Link } from "react-router-dom"
export const NotFoundPage = () => (
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
<Stack align="start" gap="md">
<Title order={1}>Страница не найдена</Title>
<Text c="dimmed">Такого маршрута в админке нет.</Text>
<Button component={Link} radius="xl" to="/">
Вернуться к проектам
</Button>
</Stack>
</Paper>
)

View File

@@ -0,0 +1 @@
export { ProjectAssetsPage } from "./project-assets.page"

View File

@@ -0,0 +1,12 @@
import { Navigate, useParams } from "react-router-dom"
import { ProjectAssetsScreen } from "screens/project-assets"
export const ProjectAssetsPage = () => {
const { projectSlug } = useParams()
if (!projectSlug) {
return <Navigate replace to="/" />
}
return <ProjectAssetsScreen projectSlug={projectSlug} />
}

View File

@@ -0,0 +1 @@
export { ProjectsPage } from "./projects.page"

View File

@@ -0,0 +1,3 @@
import { ProjectsScreen } from "screens/projects"
export const ProjectsPage = () => <ProjectsScreen />

View File

@@ -0,0 +1,119 @@
import { Alert, Anchor, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import cl from "clsx"
import { useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { assetsFactory } from "business/assets"
import { projectsFactory } from "business/projects"
import styles from "screens/shared/styles/screen.module.css"
import { AssetDetailPanel } from "./parts/asset-detail-panel"
import { CreateSourceVersionModal } from "./parts/create-source-version-modal"
import { GenerateVariantsModal } from "./parts/generate-variants-modal"
import { PicturePreviewPanel } from "./parts/picture-preview-panel"
import { PresetsPanel } from "./parts/presets-panel"
import { SourceVersionsPanel } from "./parts/source-versions-panel"
import type { AssetDetailScreenProps } from "./types/asset-detail-screen-props.type"
const assets = assetsFactory()
const projects = projectsFactory()
/**
* Детальная страница asset внутри проекта.
*/
export const AssetDetailScreen = (props: AssetDetailScreenProps) => {
const { className, projectSlug, publicId, ...rootAttrs } = props
const navigate = useNavigate()
const [selectedPicturePreset, setSelectedPicturePreset] = useState<string | null>(null)
const [isCreateVersionOpen, createVersionModal] = useDisclosure(false)
const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false)
const dashboard = assets.useAssetsDashboard()
const overview = assets.useAssetOverview(publicId)
const projectDetail = projects.useProjectDetail(projectSlug)
const createAssetVersion = assets.useCreateAssetVersion()
const generateAssetVariants = assets.useGenerateAssetVariants()
const sourceVersions = assets.useAssetVersions(publicId)
const effectivePicturePreset = selectedPicturePreset ?? dashboard.presets[0]?.name ?? null
const picturePreview = assets.useAssetPicture(publicId, effectivePicturePreset)
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Stack gap="lg">
<Group gap="xs">
<Anchor component={Link} to="/">
Проекты
</Anchor>
<Text c="dimmed">/</Text>
<Anchor component={Link} to={`/projects/${projectSlug}`}>
{projectDetail.project?.name ?? projectSlug}
</Anchor>
<Text c="dimmed">/</Text>
<Text fw={700}>{publicId}</Text>
</Group>
<Paper className={styles.hero} p={{ base: "xl", md: 42 }} radius="xl" shadow="xs" withBorder>
<Group align="flex-end" justify="space-between" gap="xl">
<div className={styles.heroContent}>
<Text className={styles.eyebrow}>Изображение</Text>
<Title className={styles.title}>{publicId}</Title>
<Text className={styles.lead}>Метаданные источника, версии, варианты и контракт для picture/srcset.</Text>
</div>
<Button onClick={() => navigate(`/projects/${projectSlug}`)} radius="xl" size="md" variant="light">
К изображениям
</Button>
</Group>
</Paper>
{dashboard.error || overview.error || sourceVersions.error || picturePreview.error ? (
<Alert color="red" radius="lg" title="Данные изображения недоступны">
Проверьте backend API и существование изображения `{publicId}`.
</Alert>
) : null}
<div className={styles.workbench}>
<AssetDetailPanel
onCreateVersion={createVersionModal.open}
onGenerateVariants={generateVariantsModal.open}
overview={overview}
publicId={publicId}
/>
<PresetsPanel
allowedSourceHosts={dashboard.allowedSourceHosts}
custom={dashboard.custom}
isLoading={dashboard.isLoading}
presets={dashboard.presets}
/>
</div>
<SourceVersionsPanel history={sourceVersions} publicId={publicId} />
<PicturePreviewPanel
onPresetChange={setSelectedPicturePreset}
picturePreview={picturePreview}
presets={dashboard.presets}
publicId={publicId}
selectedPreset={effectivePicturePreset}
/>
</Stack>
<CreateSourceVersionModal
action={createAssetVersion}
onClose={createVersionModal.close}
onCreated={() => undefined}
opened={isCreateVersionOpen}
publicId={publicId}
/>
<GenerateVariantsModal
action={generateAssetVariants}
asset={overview.asset}
custom={dashboard.custom}
onClose={generateVariantsModal.close}
onGenerated={() => undefined}
opened={isGenerateVariantsOpen}
presets={dashboard.presets}
/>
</section>
)
}

View File

@@ -0,0 +1 @@
export { AssetDetailScreen } from "./asset-detail.screen"

View File

@@ -14,9 +14,14 @@ import {
Title, Title,
} from "@mantine/core" } from "@mantine/core"
import { ASSET_STATUS_COLORS, VARIANT_STATUS_COLORS } from "../../config/dashboard.config" import {
import { copyText } from "../../lib/copy-text" ASSET_STATUS_COLORS,
import { formatDateTime } from "../../lib/format-date" ASSET_STATUS_LABELS,
VARIANT_STATUS_COLORS,
VARIANT_STATUS_LABELS,
} from "screens/shared/config/image-ui.config"
import { copyText } from "screens/shared/lib/copy-text"
import { formatDateTime } from "screens/shared/lib/format-date"
import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type" import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type"
/** /**
@@ -34,10 +39,10 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
return ( return (
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder> <Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
<Title order={2} size="h3"> <Title order={2} size="h3">
Asset detail Детали изображения
</Title> </Title>
<Text c="dimmed" mt="sm"> <Text c="dimmed" mt="sm">
Выберите asset из таблицы, чтобы увидеть source URL и variants. Выберите изображение из списка, чтобы увидеть URL исходника и варианты.
</Text> </Text>
</Paper> </Paper>
) )
@@ -48,7 +53,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Group align="start" justify="space-between" mb="lg"> <Group align="start" justify="space-between" mb="lg">
<div> <div>
<Title order={2} size="h3"> <Title order={2} size="h3">
Asset detail Детали изображения
</Title> </Title>
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
{publicId} {publicId}
@@ -58,23 +63,23 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
{asset ? ( {asset ? (
<Group gap="xs"> <Group gap="xs">
<Button onClick={onCreateVersion} radius="xl" size="xs" variant="light"> <Button onClick={onCreateVersion} radius="xl" size="xs" variant="light">
New source version Новая версия источника
</Button> </Button>
<Button onClick={onGenerateVariants} radius="xl" size="xs"> <Button onClick={onGenerateVariants} radius="xl" size="xs">
Generate variants Сгенерировать варианты
</Button> </Button>
<Button onClick={() => void copyText(asset.publicId, "publicId")} radius="xl" size="xs" variant="subtle"> <Button onClick={() => void copyText(asset.publicId, "публичный ID")} radius="xl" size="xs" variant="subtle">
Copy ID Скопировать ID
</Button> </Button>
<Button loading={overview.isRefreshing} onClick={overview.refresh} radius="xl" size="xs" variant="subtle"> <Button loading={overview.isRefreshing} onClick={overview.refresh} radius="xl" size="xs" variant="subtle">
Refresh Обновить
</Button> </Button>
<Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light"> <Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
{asset.status} {ASSET_STATUS_LABELS[asset.status] ?? asset.status}
</Badge> </Badge>
{overview.hasRunningVariants ? ( {overview.hasRunningVariants ? (
<Badge color="yellow" radius="xl" variant="light"> <Badge color="yellow" radius="xl" variant="light">
polling variants обновляем варианты
</Badge> </Badge>
) : null} ) : null}
</Group> </Group>
@@ -87,14 +92,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Stack gap="lg"> <Stack gap="lg">
<Stack gap={6}> <Stack gap={6}>
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
Source URL URL исходника
</Text> </Text>
<Group align="center" gap="xs"> <Group align="center" gap="xs">
<Anchor href={asset.sourceUrl} target="_blank"> <Anchor href={asset.sourceUrl} target="_blank">
{asset.sourceUrl} {asset.sourceUrl}
</Anchor> </Anchor>
<Button onClick={() => void copyText(asset.sourceUrl, "source URL")} radius="xl" size="compact-xs" variant="subtle"> <Button onClick={() => void copyText(asset.sourceUrl, "URL исходника")} radius="xl" size="compact-xs" variant="subtle">
Copy Скопировать
</Button> </Button>
</Group> </Group>
</Stack> </Stack>
@@ -107,14 +112,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
{asset.sourceHost} {asset.sourceHost}
</Badge> </Badge>
<Badge color="gray" radius="xl" variant="light"> <Badge color="gray" radius="xl" variant="light">
updated {formatDateTime(asset.updatedAt)} обновлено {formatDateTime(asset.updatedAt)}
</Badge> </Badge>
</Group> </Group>
<Stack gap="sm"> <Stack gap="sm">
<Group justify="space-between"> <Group justify="space-between">
<Title order={3} size="h4"> <Title order={3} size="h4">
Variants Варианты
</Title> </Title>
<Badge radius="xl" variant="light"> <Badge radius="xl" variant="light">
{variants.length} {variants.length}
@@ -126,11 +131,11 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Table verticalSpacing="sm"> <Table verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Preview</Table.Th> <Table.Th>Превью</Table.Th>
<Table.Th>Preset</Table.Th> <Table.Th>Пресет</Table.Th>
<Table.Th>Format</Table.Th> <Table.Th>Формат</Table.Th>
<Table.Th>Size</Table.Th> <Table.Th>Размер</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>Статус</Table.Th>
<Table.Th>URL</Table.Th> <Table.Th>URL</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
@@ -142,7 +147,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Image alt={`${variant.preset} ${variant.format}`} fit="cover" h={46} radius="md" src={variant.url} w={72} /> <Image alt={`${variant.preset} ${variant.format}`} fit="cover" h={46} radius="md" src={variant.url} w={72} />
) : ( ) : (
<Text c="dimmed" fz="xs"> <Text c="dimmed" fz="xs">
not ready не готово
</Text> </Text>
)} )}
</Table.Td> </Table.Td>
@@ -151,20 +156,20 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
</Table.Td> </Table.Td>
<Table.Td>{variant.format}</Table.Td> <Table.Td>{variant.format}</Table.Td>
<Table.Td> <Table.Td>
{variant.width}x{variant.height || "auto"} q{variant.quality} {variant.width}x{variant.height || "авто"} q{variant.quality}
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Badge color={VARIANT_STATUS_COLORS[variant.status] ?? "gray"} radius="xl" variant="light"> <Badge color={VARIANT_STATUS_COLORS[variant.status] ?? "gray"} radius="xl" variant="light">
{variant.status} {VARIANT_STATUS_LABELS[variant.status] ?? variant.status}
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Group gap="xs" wrap="nowrap"> <Group gap="xs" wrap="nowrap">
<Anchor href={variant.url} target="_blank"> <Anchor href={variant.url} target="_blank">
open открыть
</Anchor> </Anchor>
<Button onClick={() => void copyText(variant.url, "variant URL")} size="compact-xs" variant="subtle"> <Button onClick={() => void copyText(variant.url, "URL варианта")} size="compact-xs" variant="subtle">
copy скопировать
</Button> </Button>
</Group> </Group>
</Table.Td> </Table.Td>
@@ -174,12 +179,12 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
</Table> </Table>
</ScrollArea> </ScrollArea>
) : ( ) : (
<Text c="dimmed">Variants для текущей версии пока не созданы.</Text> <Text c="dimmed">Варианты для текущей версии пока не созданы.</Text>
)} )}
</Stack> </Stack>
</Stack> </Stack>
) : ( ) : (
<Text c="dimmed">Asset не найден или ещё загружается.</Text> <Text c="dimmed">Изображение не найдено или ещё загружается.</Text>
)} )}
</Paper> </Paper>
) )

View File

@@ -29,7 +29,7 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
validate: { validate: {
sourceUrl: (value) => { sourceUrl: (value) => {
if (!value.trim()) { if (!value.trim()) {
return "Укажите source URL" return "Укажите URL исходника"
} }
try { try {
@@ -60,8 +60,8 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
}) })
notifications.show({ notifications.show({
color: "green", color: "green",
message: `Asset ${createdVersion.publicId} обновлён до v${createdVersion.version}`, message: `Изображение ${createdVersion.publicId} обновлено до v${createdVersion.version}`,
title: "Source version created", title: "Версия источника создана",
}) })
form.reset() form.reset()
onCreated(createdVersion.publicId) onCreated(createdVersion.publicId)
@@ -70,24 +70,24 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
notifications.show({ notifications.show({
color: "red", color: "red",
message: toErrorMessage(error), message: toErrorMessage(error),
title: "Не удалось создать source version", title: "Не удалось создать версию источника",
}) })
} }
}) })
return ( return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="New source version"> <Modal centered onClose={handleClose} opened={opened} radius="xl" title="Новая версия источника">
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<Stack gap="md"> <Stack gap="md">
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
Новая source version изменит currentVersion asset. Старые public URLs останутся immutable. Новая версия источника изменит текущую версию изображения. Старые публичные URL останутся неизменяемыми.
</Text> </Text>
<TextInput label="Asset" readOnly value={publicId ?? ""} /> <TextInput label="Изображение" readOnly value={publicId ?? ""} />
<TextInput <TextInput
disabled={action.isCreating} disabled={action.isCreating}
label="New source URL" label="Новый URL исходника"
placeholder={SOURCE_URL_EXAMPLE} placeholder={SOURCE_URL_EXAMPLE}
required required
{...form.getInputProps("sourceUrl")} {...form.getInputProps("sourceUrl")}
@@ -95,10 +95,10 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
<Group justify="flex-end"> <Group justify="flex-end">
<Button color="gray" disabled={action.isCreating} onClick={handleClose} variant="subtle"> <Button color="gray" disabled={action.isCreating} onClick={handleClose} variant="subtle">
Cancel Отмена
</Button> </Button>
<Button disabled={!publicId} loading={action.isCreating} type="submit"> <Button disabled={!publicId} loading={action.isCreating} type="submit">
Create version Создать версию
</Button> </Button>
</Group> </Group>
</Stack> </Stack>

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