From d49449c30c7315eb95289a409e9aa55cb14421e2 Mon Sep 17 00:00:00 2001 From: "S.Gromov" Date: Tue, 12 May 2026 07:54:32 +0300 Subject: [PATCH] sync --- .env.example | 3 + AGENTS.md | 30 +- README.md | 6 +- ai/nextjs-style-guide/DEVELOP.md | 114 +-- ai/nextjs-style-guide/MAP.md | 66 -- ai/nextjs-style-guide/VERSION | 2 - ai/nextjs-style-guide/applied/aliases.md | 77 -- ai/nextjs-style-guide/applied/biome.md | 81 -- ai/nextjs-style-guide/applied/component.md | 165 ---- ai/nextjs-style-guide/applied/fonts.md | 128 --- ai/nextjs-style-guide/applied/images.md | 95 --- ai/nextjs-style-guide/applied/localization.md | 81 -- ai/nextjs-style-guide/applied/module.md | 156 ---- ai/nextjs-style-guide/applied/page-level.md | 186 ----- ai/nextjs-style-guide/applied/postcss.md | 70 -- .../applied/project-structure.md | 101 --- ai/nextjs-style-guide/applied/stores.md | 0 .../applied/styles/styles-setup.md | 176 ----- .../applied/styles/styles-usage.md | 271 ------- .../applied/svg-sprites/svg-sprites-intro.md | 31 - .../applied/svg-sprites/svg-sprites-setup.md | 132 ---- .../applied/svg-sprites/svg-sprites-usage.md | 56 -- .../applied/templates/templates-create.md | 97 --- .../applied/templates/templates-intro.md | 32 - .../applied/templates/templates-setup.md | 44 -- .../applied/templates/templates-usage.md | 45 -- ai/nextjs-style-guide/applied/vscode.md | 88 --- .../basics/architecture/index.md | 108 --- .../basics/architecture/layers.md | 249 ------ .../basics/architecture/modules.md | 284 ------- .../basics/architecture/segments.md | 176 ----- ai/nextjs-style-guide/basics/code-style.md | 153 ---- ai/nextjs-style-guide/basics/documentation.md | 134 ---- ai/nextjs-style-guide/basics/naming.md | 146 ---- ai/nextjs-style-guide/basics/tech-stack.md | 42 - ai/nextjs-style-guide/basics/typing.md | 62 -- .../creating-project/from-template.md | 51 -- .../creating-project/manual.md | 90 --- .../creating-project/nextjs.md | 112 --- ai/nextjs-style-guide/data/index.md | 60 -- ai/nextjs-style-guide/data/realtime.md | 79 -- .../data/rest/clients/auto.md | 193 ----- .../data/rest/clients/hooks.md | 206 ----- .../data/rest/clients/index.md | 75 -- .../data/rest/clients/manual.md | 187 ----- ai/nextjs-style-guide/data/rest/index.md | 74 -- .../rest/strategies/business-composition.md | 121 --- .../data/rest/strategies/client-get-hook.md | 89 --- .../strategies/client-hooks-initial-data.md | 109 --- .../data/rest/strategies/index.md | 100 --- .../strategies/parallel-server-requests.md | 82 -- .../data/rest/strategies/pass-promise-down.md | 62 -- .../data/rest/strategies/server-await.md | 88 --- ai/nextjs-style-guide/workflow.md | 8 - apps/admin/index.html | 1 + apps/admin/package.json | 1 + apps/admin/public/favicon.svg | 5 + apps/admin/src/app/app-router.tsx | 11 + apps/admin/src/app/app.tsx | 16 +- .../src/business/assets/assets.factory.ts | 6 + .../assets/hooks/use-asset-versions.hook.ts | 24 + .../hooks/use-create-asset-version.hook.ts | 3 +- .../assets/hooks/use-create-asset.hook.ts | 13 +- .../assets/hooks/use-image-presets.hook.ts | 23 + .../assets/hooks/use-project-assets.hook.ts | 23 + apps/admin/src/business/assets/index.ts | 3 + .../business/assets/types/assets-api.type.ts | 35 +- .../projects/hooks/use-create-project.hook.ts | 39 + .../projects/hooks/use-project-detail.hook.ts | 22 + .../projects/hooks/use-projects-home.hook.ts | 22 + apps/admin/src/business/projects/index.ts | 9 + .../src/business/projects/lib/to-error.ts | 1 + .../src/business/projects/projects.factory.ts | 15 + .../projects/types/projects-api.type.ts | 34 + .../projects/types/projects-factory.type.ts | 6 + .../generated/backend-api.generated.ts | 425 ++++++++++ .../src/infra/backend-api/hooks/index.ts | 4 + .../hooks/use-get-asset-versions.hook.ts | 17 + .../hooks/use-get-project-assets.hook.ts | 22 + .../backend-api/hooks/use-get-project.hook.ts | 17 + .../hooks/use-get-projects-list.hook.ts | 16 + apps/admin/src/infra/backend-api/index.ts | 6 + .../src/infra/theme/config/theme.config.ts | 16 +- apps/admin/src/layouts/main/main.layout.tsx | 13 +- .../src/layouts/main/styles/main.module.css | 18 +- .../asset-detail-page/asset-detail.page.tsx | 16 + .../src/pages/asset-detail-page/index.ts | 1 + apps/admin/src/pages/index.ts | 4 + apps/admin/src/pages/not-found-page/index.ts | 1 + .../pages/not-found-page/not-found.page.tsx | 14 + .../src/pages/project-assets-page/index.ts | 1 + .../project-assets.page.tsx | 12 + apps/admin/src/pages/projects-page/index.ts | 1 + .../src/pages/projects-page/projects.page.tsx | 3 + .../asset-detail/asset-detail.screen.tsx | 119 +++ apps/admin/src/screens/asset-detail/index.ts | 1 + .../asset-detail-panel/asset-detail-panel.tsx | 67 +- .../parts/asset-detail-panel/index.ts | 0 .../types/asset-detail-panel-props.type.ts | 0 .../create-source-version-modal.tsx | 20 +- .../create-source-version-modal/index.ts | 0 .../create-source-version-modal-props.type.ts | 0 .../generate-variants-modal.tsx | 78 +- .../parts/generate-variants-modal/index.ts | 0 .../generate-variants-modal-props.type.ts | 0 .../parts/picture-preview-panel/index.ts | 0 .../picture-preview-panel.tsx | 46 +- .../types/picture-preview-panel-props.type.ts | 0 .../parts/presets-panel/index.ts | 0 .../parts/presets-panel/presets-panel.tsx | 28 +- .../types/presets-panel-props.type.ts | 0 .../parts/source-versions-panel/index.ts | 1 + .../source-versions-panel.tsx | 135 ++++ .../types/source-versions-panel-props.type.ts | 11 + .../types/asset-detail-screen-props.type.ts | 9 + .../dashboard/config/dashboard.config.ts | 36 - .../screens/dashboard/dashboard.screen.tsx | 137 ---- .../hooks/use-dashboard-url-state.hook.ts | 61 -- apps/admin/src/screens/dashboard/index.ts | 2 - .../dashboard/parts/summary-cards/index.ts | 2 - .../parts/summary-cards/summary-cards.tsx | 37 - .../types/summary-cards-props.type.ts | 11 - .../screens/dashboard/types/dashboard.type.ts | 4 - .../admin/src/screens/project-assets/index.ts | 1 + .../parts/assets-explorer/assets-explorer.tsx | 146 ++++ .../parts/assets-explorer/index.ts | 1 + .../styles/assets-explorer.module.css | 216 +++++ .../types/assets-explorer-props.type.ts | 13 + .../parts/assets-table/assets-table.tsx | 24 +- .../parts/assets-table/index.ts | 0 .../types/assets-table-props.type.ts | 0 .../create-asset-modal/create-asset-modal.tsx | 24 +- .../parts/create-asset-modal/index.ts | 0 .../types/create-asset-modal-props.type.ts | 2 + .../parts/presets-table/index.ts | 1 + .../parts/presets-table/presets-table.tsx | 114 +++ .../styles/presets-table.module.css | 40 + .../types/presets-table-props.type.ts | 9 + .../parts/project-header/index.ts | 1 + .../parts/project-header/project-header.tsx | 66 ++ .../styles/project-header.module.css | 64 ++ .../types/project-header-props.type.ts | 17 + .../project-assets/project-assets.screen.tsx | 82 ++ .../styles/project-assets.module.css | 13 + .../types/project-assets-screen-props.type.ts | 7 + apps/admin/src/screens/projects/index.ts | 1 + .../create-project-modal.tsx | 98 +++ .../parts/create-project-modal/index.ts | 1 + .../types/create-project-modal-props.type.ts | 15 + .../projects/parts/projects-grid/index.ts | 1 + .../parts/projects-grid/projects-grid.tsx | 116 +++ .../styles/projects-grid.module.css | 219 +++++ .../types/projects-grid-props.type.ts | 13 + .../src/screens/projects/projects.screen.tsx | 48 ++ .../types/projects-screen-props.type.ts | 4 + .../screens/shared/config/image-ui.config.ts | 37 + .../{dashboard => shared}/lib/copy-text.ts | 6 +- .../{dashboard => shared}/lib/format-date.ts | 0 .../styles/screen.module.css} | 10 +- apps/admin/src/shared/styles/variables.css | 29 +- apps/admin/tsconfig.app.json | 2 + apps/admin/vite.config.ts | 1 + apps/backend/src/app.module.ts | 6 +- apps/backend/src/assets/asset-response.dto.ts | 46 ++ apps/backend/src/assets/assets.controller.ts | 15 +- apps/backend/src/assets/assets.service.ts | 114 ++- apps/backend/src/projects/project-slug.ts | 21 + .../src/projects/projects.controller.ts | 96 +++ apps/backend/src/projects/projects.dto.ts | 37 + apps/backend/src/projects/projects.service.ts | 107 +++ apps/gateway/src/config.ts | 4 + apps/gateway/src/server.ts | 306 +++++++ docs/architecture.md | 2 + docs/assets-delivery-platform.md | 214 +++++ docs/imgproxy-contract.md | 2 +- docs/next-image-provider.md | 40 +- docs/unpic-react-provider.md | 60 ++ package.json | 4 +- packages/client/package.json | 21 + packages/client/src/index.ts | 127 +++ packages/client/tsconfig.build.json | 7 + packages/client/tsconfig.json | 20 + .../database/drizzle/0002_grey_ser_duncan.sql | 14 + .../database/drizzle/meta/0002_snapshot.json | 745 ++++++++++++++++++ packages/database/drizzle/meta/_journal.json | 7 + packages/database/src/schema.ts | 16 +- pnpm-lock.yaml | 40 + 187 files changed, 4826 insertions(+), 5884 deletions(-) delete mode 100644 ai/nextjs-style-guide/MAP.md delete mode 100644 ai/nextjs-style-guide/VERSION delete mode 100644 ai/nextjs-style-guide/applied/aliases.md delete mode 100644 ai/nextjs-style-guide/applied/biome.md delete mode 100644 ai/nextjs-style-guide/applied/component.md delete mode 100644 ai/nextjs-style-guide/applied/fonts.md delete mode 100644 ai/nextjs-style-guide/applied/images.md delete mode 100644 ai/nextjs-style-guide/applied/localization.md delete mode 100644 ai/nextjs-style-guide/applied/module.md delete mode 100644 ai/nextjs-style-guide/applied/page-level.md delete mode 100644 ai/nextjs-style-guide/applied/postcss.md delete mode 100644 ai/nextjs-style-guide/applied/project-structure.md delete mode 100644 ai/nextjs-style-guide/applied/stores.md delete mode 100644 ai/nextjs-style-guide/applied/styles/styles-setup.md delete mode 100644 ai/nextjs-style-guide/applied/styles/styles-usage.md delete mode 100644 ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-intro.md delete mode 100644 ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-setup.md delete mode 100644 ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-usage.md delete mode 100644 ai/nextjs-style-guide/applied/templates/templates-create.md delete mode 100644 ai/nextjs-style-guide/applied/templates/templates-intro.md delete mode 100644 ai/nextjs-style-guide/applied/templates/templates-setup.md delete mode 100644 ai/nextjs-style-guide/applied/templates/templates-usage.md delete mode 100644 ai/nextjs-style-guide/applied/vscode.md delete mode 100644 ai/nextjs-style-guide/basics/architecture/index.md delete mode 100644 ai/nextjs-style-guide/basics/architecture/layers.md delete mode 100644 ai/nextjs-style-guide/basics/architecture/modules.md delete mode 100644 ai/nextjs-style-guide/basics/architecture/segments.md delete mode 100644 ai/nextjs-style-guide/basics/code-style.md delete mode 100644 ai/nextjs-style-guide/basics/documentation.md delete mode 100644 ai/nextjs-style-guide/basics/naming.md delete mode 100644 ai/nextjs-style-guide/basics/tech-stack.md delete mode 100644 ai/nextjs-style-guide/basics/typing.md delete mode 100644 ai/nextjs-style-guide/creating-project/from-template.md delete mode 100644 ai/nextjs-style-guide/creating-project/manual.md delete mode 100644 ai/nextjs-style-guide/creating-project/nextjs.md delete mode 100644 ai/nextjs-style-guide/data/index.md delete mode 100644 ai/nextjs-style-guide/data/realtime.md delete mode 100644 ai/nextjs-style-guide/data/rest/clients/auto.md delete mode 100644 ai/nextjs-style-guide/data/rest/clients/hooks.md delete mode 100644 ai/nextjs-style-guide/data/rest/clients/index.md delete mode 100644 ai/nextjs-style-guide/data/rest/clients/manual.md delete mode 100644 ai/nextjs-style-guide/data/rest/index.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/business-composition.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/client-get-hook.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/client-hooks-initial-data.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/index.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/parallel-server-requests.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/pass-promise-down.md delete mode 100644 ai/nextjs-style-guide/data/rest/strategies/server-await.md delete mode 100644 ai/nextjs-style-guide/workflow.md create mode 100644 apps/admin/public/favicon.svg create mode 100644 apps/admin/src/app/app-router.tsx create mode 100644 apps/admin/src/business/assets/hooks/use-asset-versions.hook.ts create mode 100644 apps/admin/src/business/assets/hooks/use-image-presets.hook.ts create mode 100644 apps/admin/src/business/assets/hooks/use-project-assets.hook.ts create mode 100644 apps/admin/src/business/projects/hooks/use-create-project.hook.ts create mode 100644 apps/admin/src/business/projects/hooks/use-project-detail.hook.ts create mode 100644 apps/admin/src/business/projects/hooks/use-projects-home.hook.ts create mode 100644 apps/admin/src/business/projects/index.ts create mode 100644 apps/admin/src/business/projects/lib/to-error.ts create mode 100644 apps/admin/src/business/projects/projects.factory.ts create mode 100644 apps/admin/src/business/projects/types/projects-api.type.ts create mode 100644 apps/admin/src/business/projects/types/projects-factory.type.ts create mode 100644 apps/admin/src/infra/backend-api/hooks/use-get-asset-versions.hook.ts create mode 100644 apps/admin/src/infra/backend-api/hooks/use-get-project-assets.hook.ts create mode 100644 apps/admin/src/infra/backend-api/hooks/use-get-project.hook.ts create mode 100644 apps/admin/src/infra/backend-api/hooks/use-get-projects-list.hook.ts create mode 100644 apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx create mode 100644 apps/admin/src/pages/asset-detail-page/index.ts create mode 100644 apps/admin/src/pages/index.ts create mode 100644 apps/admin/src/pages/not-found-page/index.ts create mode 100644 apps/admin/src/pages/not-found-page/not-found.page.tsx create mode 100644 apps/admin/src/pages/project-assets-page/index.ts create mode 100644 apps/admin/src/pages/project-assets-page/project-assets.page.tsx create mode 100644 apps/admin/src/pages/projects-page/index.ts create mode 100644 apps/admin/src/pages/projects-page/projects.page.tsx create mode 100644 apps/admin/src/screens/asset-detail/asset-detail.screen.tsx create mode 100644 apps/admin/src/screens/asset-detail/index.ts rename apps/admin/src/screens/{dashboard => asset-detail}/parts/asset-detail-panel/asset-detail-panel.tsx (71%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/asset-detail-panel/index.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/create-source-version-modal/create-source-version-modal.tsx (77%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/create-source-version-modal/index.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/generate-variants-modal/generate-variants-modal.tsx (76%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/generate-variants-modal/index.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/generate-variants-modal/types/generate-variants-modal-props.type.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/picture-preview-panel/index.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/picture-preview-panel/picture-preview-panel.tsx (74%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/presets-panel/index.ts (100%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/presets-panel/presets-panel.tsx (73%) rename apps/admin/src/screens/{dashboard => asset-detail}/parts/presets-panel/types/presets-panel-props.type.ts (100%) create mode 100644 apps/admin/src/screens/asset-detail/parts/source-versions-panel/index.ts create mode 100644 apps/admin/src/screens/asset-detail/parts/source-versions-panel/source-versions-panel.tsx create mode 100644 apps/admin/src/screens/asset-detail/parts/source-versions-panel/types/source-versions-panel-props.type.ts create mode 100644 apps/admin/src/screens/asset-detail/types/asset-detail-screen-props.type.ts delete mode 100644 apps/admin/src/screens/dashboard/config/dashboard.config.ts delete mode 100644 apps/admin/src/screens/dashboard/dashboard.screen.tsx delete mode 100644 apps/admin/src/screens/dashboard/hooks/use-dashboard-url-state.hook.ts delete mode 100644 apps/admin/src/screens/dashboard/index.ts delete mode 100644 apps/admin/src/screens/dashboard/parts/summary-cards/index.ts delete mode 100644 apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx delete mode 100644 apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts delete mode 100644 apps/admin/src/screens/dashboard/types/dashboard.type.ts create mode 100644 apps/admin/src/screens/project-assets/index.ts create mode 100644 apps/admin/src/screens/project-assets/parts/assets-explorer/assets-explorer.tsx create mode 100644 apps/admin/src/screens/project-assets/parts/assets-explorer/index.ts create mode 100644 apps/admin/src/screens/project-assets/parts/assets-explorer/styles/assets-explorer.module.css create mode 100644 apps/admin/src/screens/project-assets/parts/assets-explorer/types/assets-explorer-props.type.ts rename apps/admin/src/screens/{dashboard => project-assets}/parts/assets-table/assets-table.tsx (75%) rename apps/admin/src/screens/{dashboard => project-assets}/parts/assets-table/index.ts (100%) rename apps/admin/src/screens/{dashboard => project-assets}/parts/assets-table/types/assets-table-props.type.ts (100%) rename apps/admin/src/screens/{dashboard => project-assets}/parts/create-asset-modal/create-asset-modal.tsx (76%) rename apps/admin/src/screens/{dashboard => project-assets}/parts/create-asset-modal/index.ts (100%) rename apps/admin/src/screens/{dashboard => project-assets}/parts/create-asset-modal/types/create-asset-modal-props.type.ts (79%) create mode 100644 apps/admin/src/screens/project-assets/parts/presets-table/index.ts create mode 100644 apps/admin/src/screens/project-assets/parts/presets-table/presets-table.tsx create mode 100644 apps/admin/src/screens/project-assets/parts/presets-table/styles/presets-table.module.css create mode 100644 apps/admin/src/screens/project-assets/parts/presets-table/types/presets-table-props.type.ts create mode 100644 apps/admin/src/screens/project-assets/parts/project-header/index.ts create mode 100644 apps/admin/src/screens/project-assets/parts/project-header/project-header.tsx create mode 100644 apps/admin/src/screens/project-assets/parts/project-header/styles/project-header.module.css create mode 100644 apps/admin/src/screens/project-assets/parts/project-header/types/project-header-props.type.ts create mode 100644 apps/admin/src/screens/project-assets/project-assets.screen.tsx create mode 100644 apps/admin/src/screens/project-assets/styles/project-assets.module.css create mode 100644 apps/admin/src/screens/project-assets/types/project-assets-screen-props.type.ts create mode 100644 apps/admin/src/screens/projects/index.ts create mode 100644 apps/admin/src/screens/projects/parts/create-project-modal/create-project-modal.tsx create mode 100644 apps/admin/src/screens/projects/parts/create-project-modal/index.ts create mode 100644 apps/admin/src/screens/projects/parts/create-project-modal/types/create-project-modal-props.type.ts create mode 100644 apps/admin/src/screens/projects/parts/projects-grid/index.ts create mode 100644 apps/admin/src/screens/projects/parts/projects-grid/projects-grid.tsx create mode 100644 apps/admin/src/screens/projects/parts/projects-grid/styles/projects-grid.module.css create mode 100644 apps/admin/src/screens/projects/parts/projects-grid/types/projects-grid-props.type.ts create mode 100644 apps/admin/src/screens/projects/projects.screen.tsx create mode 100644 apps/admin/src/screens/projects/types/projects-screen-props.type.ts create mode 100644 apps/admin/src/screens/shared/config/image-ui.config.ts rename apps/admin/src/screens/{dashboard => shared}/lib/copy-text.ts (68%) rename apps/admin/src/screens/{dashboard => shared}/lib/format-date.ts (100%) rename apps/admin/src/screens/{dashboard/styles/dashboard.module.css => shared/styles/screen.module.css} (78%) create mode 100644 apps/backend/src/projects/project-slug.ts create mode 100644 apps/backend/src/projects/projects.controller.ts create mode 100644 apps/backend/src/projects/projects.dto.ts create mode 100644 apps/backend/src/projects/projects.service.ts create mode 100644 docs/assets-delivery-platform.md create mode 100644 docs/unpic-react-provider.md create mode 100644 packages/client/package.json create mode 100644 packages/client/src/index.ts create mode 100644 packages/client/tsconfig.build.json create mode 100644 packages/client/tsconfig.json create mode 100644 packages/database/drizzle/0002_grey_ser_duncan.sql create mode 100644 packages/database/drizzle/meta/0002_snapshot.json diff --git a/.env.example b/.env.example index de4189c..64bef63 100644 --- a/.env.example +++ b/.env.example @@ -29,12 +29,15 @@ PUBLIC_IMAGE_BASE_URL=http://localhost:8888 # Gateway proxies /api and Swagger routes to this upstream. GATEWAY_BACKEND_UPSTREAM=http://localhost:3001 +GATEWAY_IMGPROXY_UPSTREAM=http://localhost:18080 GATEWAY_L1_MAX_ENTRIES=256 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. SOURCE_HOST_ALLOW_ALL=false SOURCE_ALLOWED_HOSTS=storage.yandexcloud.net +SOURCE_ALLOW_PRIVATE_NETWORKS=false IMAGE_ALLOW_CUSTOM_TRANSFORMS=true IMAGE_ENSURE_WAIT_MS=15000 diff --git a/AGENTS.md b/AGENTS.md index 2c3a302..45e527c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` не содержит правил разработки, ревью или архитектуры. Все правила находятся в документации соответствующей роли. diff --git a/README.md b/README.md index 4d1bc26..0a5e912 100644 --- a/README.md +++ b/README.md @@ -39,7 +39,9 @@ client - Fastify gateway - 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 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/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`. @@ -108,3 +111,4 @@ curl -sS -X POST http://localhost:3001/api/assets/asset_demo/variants \ - `docs/backend-contract-draft.md` - черновик будущего backend-контракта. - `docs/imgproxy-contract.md` - контракт с external imgproxy. - `docs/next-image-provider.md` - контракт custom provider для `next/image`. +- `docs/unpic-react-provider.md` - контракт custom transformer для `@unpic/react`. diff --git a/ai/nextjs-style-guide/DEVELOP.md b/ai/nextjs-style-guide/DEVELOP.md index 523abfd..49e97a0 100644 --- a/ai/nextjs-style-guide/DEVELOP.md +++ b/ai/nextjs-style-guide/DEVELOP.md @@ -1,103 +1,45 @@ ---- -title: Гид для агента -description: Что AI-агент обязан прочитать перед началом работы, а что — по задаче. ---- +# DEVELOP.md -# Обязательное чтение перед началом работы +Ты 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) -* [Архитектура: Слои](./basics/architecture/layers.md) -* [Архитектура: Модули](./basics/architecture/modules.md) -* [Архитектура: Сегменты](./basics/architecture/segments.md) +Если контекст был сжат, сессия продолжена после паузы или нет уверенности, что архитектура SLM и базовые правила есть в текущем контексте, считай baseline утраченным и прочитай его заново. -**Архитектура — это самое важное в проекте.** +Во время frontend-задачи возвращайся к `llms.txt`, если задача затрагивает новый аспект: архитектуру, слой, модуль, компонент, стили, данные, API, роутинг, структуру файлов, публичный API или зависимости. -Агент обязан: +Не заменяй style guide догадками, привычными паттернами или общими практиками. -* строго понимать архитектурный подход (SLM) -* соблюдать архитектуру **на 100% без отклонений** -* не предлагать решений, нарушающих архитектурные принципы -* не упрощать архитектуру даже ради скорости выполнения задачи +Если в style guide не найдено правило или пример для значимого frontend-решения: -Любое нарушение архитектуры недопустимо. +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. Задача пользователя - -Если задача противоречит архитектуре — задача должна быть переосмыслена, а не выполнена напрямую. +Соблюдай стиль кода существующего приложения. diff --git a/ai/nextjs-style-guide/MAP.md b/ai/nextjs-style-guide/MAP.md deleted file mode 100644 index f4d196a..0000000 --- a/ai/nextjs-style-guide/MAP.md +++ /dev/null @@ -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-модуль. diff --git a/ai/nextjs-style-guide/VERSION b/ai/nextjs-style-guide/VERSION deleted file mode 100644 index 5f72f8c..0000000 --- a/ai/nextjs-style-guide/VERSION +++ /dev/null @@ -1,2 +0,0 @@ -bf1781f -2026-05-03T01:23:40.449Z diff --git a/ai/nextjs-style-guide/applied/aliases.md b/ai/nextjs-style-guide/applied/aliases.md deleted file mode 100644 index 49383d6..0000000 --- a/ai/nextjs-style-guide/applied/aliases.md +++ /dev/null @@ -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' -``` diff --git a/ai/nextjs-style-guide/applied/biome.md b/ai/nextjs-style-guide/applied/biome.md deleted file mode 100644 index b8a8483..0000000 --- a/ai/nextjs-style-guide/applied/biome.md +++ /dev/null @@ -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). diff --git a/ai/nextjs-style-guide/applied/component.md b/ai/nextjs-style-guide/applied/component.md deleted file mode 100644 index 4575d7b..0000000 --- a/ai/nextjs-style-guide/applied/component.md +++ /dev/null @@ -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, '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 ( - - {label} - - ) -} -``` - -### Стили - -`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' -``` diff --git a/ai/nextjs-style-guide/applied/fonts.md b/ai/nextjs-style-guide/applied/fonts.md deleted file mode 100644 index 981276b..0000000 --- a/ai/nextjs-style-guide/applied/fonts.md +++ /dev/null @@ -1,128 +0,0 @@ ---- -title: Шрифты -description: Как подключать шрифты через Next.js Font в проекте. ---- - -# Шрифты - -Как подключать шрифты через Next.js Font в проекте. - -## Назначение - -Шрифты подключаются через `next/font`. Это стандартный способ Next.js: шрифты загружаются без ручных ``, `@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 ( - - {children} - - ) -} -``` - -```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 ( - - {children} - - ) -} -``` - -Путь в `localFont` указывается относительно `.font.ts`, поэтому файлы шрифта импортируются коротко: `./Roboto-Regular.woff2`. - -Если шрифтов несколько, у каждого своя папка и свой `.font.ts`. - -## Правила - -- Использовать `next/font/google` или `next/font/local`. -- Не подключать шрифты через ручные `` и `@font-face` без необходимости. -- Подключать шрифты один раз — в корневом layout через готовый объект шрифта. -- Использовать CSS-переменные `variable`, а не жёстко прописывать семейство в каждом компоненте. -- Локальные файлы шрифтов хранить в `src/shared/fonts/{font-name}/` рядом с `{font-name}.font.ts`. -- Не объявлять `localFont` внутри `src/app/layout.tsx`; layout только импортирует готовый шрифт. diff --git a/ai/nextjs-style-guide/applied/images.md b/ai/nextjs-style-guide/applied/images.md deleted file mode 100644 index 448efd8..0000000 --- a/ai/nextjs-style-guide/applied/images.md +++ /dev/null @@ -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` из `next/image`, не обычный ``. -- Для контентных изображений всегда писать осмысленный `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 ( -
- Обложка статьи -
- ) -} -``` - -```css -.root { - position: relative; - aspect-ratio: 16 / 9; - overflow: hidden; -} -``` diff --git a/ai/nextjs-style-guide/applied/localization.md b/ai/nextjs-style-guide/applied/localization.md deleted file mode 100644 index 6b58703..0000000 --- a/ai/nextjs-style-guide/applied/localization.md +++ /dev/null @@ -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 ( - - - {children} - - - ) -} -``` - -## Использование - -Компоненты получают переводы через готовый API модуля локализации: - -```tsx -import { useTranslation } from 'infra/i18n' - -export const ProfileTitle = () => { - const { t } = useTranslation() - - return

{t('profile.title')}

-} -``` - -## Правила - -- Локализация живёт в `infra/i18n/`. -- `app/` только подключает готовый provider и передаёт locale. -- Словари не импортируются напрямую в компоненты, screens или business-модули. -- Ключи переводов не собираются динамически из строк, если это ломает типизацию и поиск. -- Тексты интерфейса не хардкодятся в переиспользуемых компонентах, если они должны переводиться. -- Форматирование дат, чисел и валют должно проходить через API локализации или отдельные утилиты infra-модуля. diff --git a/ai/nextjs-style-guide/applied/module.md b/ai/nextjs-style-guide/applied/module.md deleted file mode 100644 index b76b121..0000000 --- a/ai/nextjs-style-guide/applied/module.md +++ /dev/null @@ -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 -} -``` - -`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 -} -``` - -## Инфраструктурный модуль - -Инфраструктурный модуль строится вокруг технического сервиса или интеграции. Его структура определяется природой сервиса — фиксированного корневого файла нет. - -Архитектурное описание: [Архитектура → Типы модулей → Инфраструктурный модуль](../basics/architecture/modules.md#инфраструктурный-модуль). - -Пример модуля темы: - -```text -theme/ -├── index.ts -├── config/ -├── hooks/ -├── styles/ -└── ui/ -``` - -Пример модуля API-клиента: - -```text -backend-api/ -├── backend-api.client.ts -├── config/ -├── types/ -└── index.ts -``` diff --git a/ai/nextjs-style-guide/applied/page-level.md b/ai/nextjs-style-guide/applied/page-level.md deleted file mode 100644 index 5a96eb6..0000000 --- a/ai/nextjs-style-guide/applied/page-level.md +++ /dev/null @@ -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 -} -``` - -## Данные первого рендера - -Если данные нужны до первого рендера, `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 -} -``` - -Если данные нужны нескольким клиентским 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 ( - - {children} - - ) -} -``` - -Подробнее о стратегиях запросов и начальных данных для клиентских хуков: [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 ( - - - - ) -} -``` - -## 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 -} - -export default ErrorPage -``` diff --git a/ai/nextjs-style-guide/applied/postcss.md b/ai/nextjs-style-guide/applied/postcss.md deleted file mode 100644 index 38c6283..0000000 --- a/ai/nextjs-style-guide/applied/postcss.md +++ /dev/null @@ -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), раздел «Импорт стилей»). diff --git a/ai/nextjs-style-guide/applied/project-structure.md b/ai/nextjs-style-guide/applied/project-structure.md deleted file mode 100644 index 0bf210f..0000000 --- a/ai/nextjs-style-guide/applied/project-structure.md +++ /dev/null @@ -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_` доступны в клиентском коде. Остальные доступны только на сервере. diff --git a/ai/nextjs-style-guide/applied/stores.md b/ai/nextjs-style-guide/applied/stores.md deleted file mode 100644 index e69de29..0000000 diff --git a/ai/nextjs-style-guide/applied/styles/styles-setup.md b/ai/nextjs-style-guide/applied/styles/styles-setup.md deleted file mode 100644 index 64ea0e9..0000000 --- a/ai/nextjs-style-guide/applied/styles/styles-setup.md +++ /dev/null @@ -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 ( - - {children} - - ) -} -``` - -`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) — стили иконок отдельно от глобальных. diff --git a/ai/nextjs-style-guide/applied/styles/styles-usage.md b/ai/nextjs-style-guide/applied/styles/styles-usage.md deleted file mode 100644 index d7f1405..0000000 --- a/ai/nextjs-style-guide/applied/styles/styles-usage.md +++ /dev/null @@ -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. -- Исключение — нетривиальные хаки и обходные решения, к которым стоит оставить пояснение. diff --git a/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-intro.md b/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-intro.md deleted file mode 100644 index 4bc1fb2..0000000 --- a/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-intro.md +++ /dev/null @@ -1,31 +0,0 @@ ---- -title: SVG-спрайты -description: "Что такое SVG-спрайты и какие проблемы они решают." ---- - -# SVG-спрайты - -Что такое SVG-спрайты и какие проблемы они решают. - -## Проблема - -Иконки в проекте — это десятки и сотни SVG-файлов, которые нужно как-то доставлять в интерфейс. Подход «один `` на иконку» или инлайн SVG в каждом компоненте приводят к трём проблемам: - -- **Дублирование.** Инлайн SVG в нескольких компонентах — один и тот же код размазан по проекту. Изменение иконки требует правок в десяти местах. -- **Размер бандла.** Каждый инлайн SVG — полный XML-код, который попадает в JS-бандл. Сотня иконок × средний размер SVG = сотни килобайт, которые браузер парсит как JavaScript, а не как статику. -- **Нет управления цветом.** Инлайн SVG жёстко закрашивает иконку. Сменить цвет по состоянию (`:hover`, `._disabled`) — значит дублировать SVG или городить `currentColor`-хаки в каждом компоненте. - -## Решение - -SVG-спрайты — это единый файл-контейнер, в который собираются все иконки проекта. В коде используется один React-компонент ``, а браузер загружает спрайт как статику — один раз, с кешированием. - -Что дают SVG-спрайты: - -- **Один источник.** Каждая иконка — один SVG-файл в `src/shared/sprites/`. Обновил файл — иконка обновилась везде. -- **Лёгкий бандл.** Спрайт отдаётся как статический файл из `public/`, не попадает в JavaScript. Типы имён иконок генерируются автоматически — автодополнение работает без ручных описаний. -- **Цвет через CSS.** При сборке цвета в SVG заменяются на CSS-переменные. Цвет иконки меняется через `color` родителя или через переменные `--icon-color-N` — как любой другой стиль. - -## Состав раздела - -- [Настройка](./svg-sprites-setup.md) — подключение пакета, конфигурация, первая генерация. -- [Использование](./svg-sprites-usage.md) — добавление иконок, компонент ``, управление цветом. diff --git a/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-setup.md b/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-setup.md deleted file mode 100644 index 7329443..0000000 --- a/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-setup.md +++ /dev/null @@ -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//` — это слой `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. Глобальный спрайт (иконки) подключается через `` в корневом 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 ( - - - - - {children} - - ) - } - ``` - - Локальные спрайты (если есть) подключаются аналогично в 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) — добавление иконок, компонент ``, управление цветом. diff --git a/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-usage.md b/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-usage.md deleted file mode 100644 index d984642..0000000 --- a/ai/nextjs-style-guide/applied/svg-sprites/svg-sprites-usage.md +++ /dev/null @@ -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. **Импортировать компонент.** Компонент `` генерируется пакетом вместе с типами имён иконок — автодополнение работает без ручных описаний: - - ```tsx - import { SvgSprite } from 'ui/svg-sprite' - - - ``` - -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); -} -``` diff --git a/ai/nextjs-style-guide/applied/templates/templates-create.md b/ai/nextjs-style-guide/applied/templates/templates-create.md deleted file mode 100644 index d8d0953..0000000 --- a/ai/nextjs-style-guide/applied/templates/templates-create.md +++ /dev/null @@ -1,97 +0,0 @@ ---- -title: Создание шаблонов генерации -description: "Структура шаблонов, синтаксис переменных и примеры." -keywords: [шаблоны, templates, .templates, syntax, переменные, kebabCase, pascalCase, scaffold] ---- - - -::: 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. - -::: diff --git a/ai/nextjs-style-guide/applied/templates/templates-intro.md b/ai/nextjs-style-guide/applied/templates/templates-intro.md deleted file mode 100644 index 1ae1b95..0000000 --- a/ai/nextjs-style-guide/applied/templates/templates-intro.md +++ /dev/null @@ -1,32 +0,0 @@ ---- -title: Шаблоны генерации -description: "Что такое шаблоны кодогенерации и какие проблемы они решают." ---- - -# Шаблоны генерации - -Что такое шаблоны кодогенерации и какие проблемы они решают. - -## Проблема - -Каждый новый модуль в проекте — компонент, стор, бизнес-модуль — требует однотипной структуры файлов и boilerplate-кода. Ручное создание приводит к трём проблемам: - -- **Расхождения.** Разные разработчики создают модули по-разному: забывают `index.ts`, называют типы не по канону, пропускают стили. -- **Время.** Создание одного компонента с типами, стилями и экспортом — 5–10 минут рутины. За спринт набегают часы. -- **Ошибки копипасты.** Копирование существующего модуля и переименование — источник опечаток и забытых ссылок. - -## Решение - -Шаблоны кодогенерации — это папки с файлами-заготовками в `.templates/`. Вместо ручного создания файлов разработчик вызывает генератор, указывает имя — и получает готовый модуль со всей структурой, именами и boilerplate, подставленными автоматически. - -Что дают шаблоны: - -- **Единообразие.** Все модули одного типа идентичны по структуре. Канон живёт в шаблоне, а не в памяти разработчика. -- **Скорость.** Генерация модуля — одна команда. Остальное время — на бизнес-логику. -- **Согласованность с архитектурой.** Шаблоны учитывают SLM: правильные слои, сегменты, экспорты. Отклонение от стайлгайда требует осознанного усилия, а не случайного упущения. - -## Состав раздела - -- [Настройка](./templates-setup.md) — первичная установка: скачивание стандартного набора шаблонов в проект. -- [Создание шаблонов](./templates-create.md) — структура файлов, синтаксис переменных, примеры. -- [Использование](./templates-usage.md) — генерация через VS Code плагин и CLI. diff --git a/ai/nextjs-style-guide/applied/templates/templates-setup.md b/ai/nextjs-style-guide/applied/templates/templates-setup.md deleted file mode 100644 index 4365c71..0000000 --- a/ai/nextjs-style-guide/applied/templates/templates-setup.md +++ /dev/null @@ -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. diff --git a/ai/nextjs-style-guide/applied/templates/templates-usage.md b/ai/nextjs-style-guide/applied/templates/templates-usage.md deleted file mode 100644 index 80ff431..0000000 --- a/ai/nextjs-style-guide/applied/templates/templates-usage.md +++ /dev/null @@ -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` отдельно не добавляется. diff --git a/ai/nextjs-style-guide/applied/vscode.md b/ai/nextjs-style-guide/applied/vscode.md deleted file mode 100644 index f844ead..0000000 --- a/ai/nextjs-style-guide/applied/vscode.md +++ /dev/null @@ -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` с общими для команды настройками. diff --git a/ai/nextjs-style-guide/basics/architecture/index.md b/ai/nextjs-style-guide/basics/architecture/index.md deleted file mode 100644 index 26c587f..0000000 --- a/ai/nextjs-style-guide/basics/architecture/index.md +++ /dev/null @@ -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. -- **Архитектура — каркас, не клетка.** Правила фиксируют направление зависимостей и структуру модуля, остальное определяет команда. diff --git a/ai/nextjs-style-guide/basics/architecture/layers.md b/ai/nextjs-style-guide/basics/architecture/layers.md deleted file mode 100644 index 59f15ee..0000000 --- a/ai/nextjs-style-guide/basics/architecture/layers.md +++ /dev/null @@ -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-состояния \ No newline at end of file diff --git a/ai/nextjs-style-guide/basics/architecture/modules.md b/ai/nextjs-style-guide/basics/architecture/modules.md deleted file mode 100644 index 66afe3b..0000000 --- a/ai/nextjs-style-guide/basics/architecture/modules.md +++ /dev/null @@ -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 -} -``` - -```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 -} -``` - -## Жизненный цикл - -Модуль рождается на самом низком уровне использования и поднимается выше только при реальной потребности. - -- Нужен на одной странице → `screens/{name}/parts/` -- Появился в 2+ местах → поднимается по природе: - - абстрактный UI → `ui/` - - блок с данными/логикой → `widgets/` - - представление бизнес-домена → `business/{area}/parts/` - -Подъём — обычный рефакторинг в рамках задачи, а не отдельная активность. \ No newline at end of file diff --git a/ai/nextjs-style-guide/basics/architecture/segments.md b/ai/nextjs-style-guide/basics/architecture/segments.md deleted file mode 100644 index 7305e67..0000000 --- a/ai/nextjs-style-guide/basics/architecture/segments.md +++ /dev/null @@ -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 -``` \ No newline at end of file diff --git a/ai/nextjs-style-guide/basics/code-style.md b/ai/nextjs-style-guide/basics/code-style.md deleted file mode 100644 index e47e625..0000000 --- a/ai/nextjs-style-guide/basics/code-style.md +++ /dev/null @@ -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 - -``` - -**Плохо** -```ts -// Плохо: двойные кавычки в TS и конкатенация вместо шаблонной строки. -const label = "Сохранить"; -const title = 'Привет, ' + name; -``` - -```tsx -// Плохо: одинарные кавычки в JSX-атрибутах. - -``` - -## Точки с запятой и запятые - -- Допускаются упущения точки с запятой, если код остаётся читаемым и однозначным. -- В многострочных массивах, объектах и параметрах функции запятая в конце допускается, но не обязательна. - -## Импорты - -- В именованных импортах использовать пробелы внутри фигурных скобок. -- Типы импортировать через `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 } }; -``` diff --git a/ai/nextjs-style-guide/basics/documentation.md b/ai/nextjs-style-guide/basics/documentation.md deleted file mode 100644 index 81abcf1..0000000 --- a/ai/nextjs-style-guide/basics/documentation.md +++ /dev/null @@ -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; -} -``` diff --git a/ai/nextjs-style-guide/basics/naming.md b/ai/nextjs-style-guide/basics/naming.md deleted file mode 100644 index 096dffb..0000000 --- a/ai/nextjs-style-guide/basics/naming.md +++ /dev/null @@ -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..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; -const userIds = ['u1', 'u2']; -const ordersMap = new Map(); -const featureFlagsDict = { beta: true, legacy: false } as Record; -``` - -**Плохо** -```ts -// Плохо: имя не отражает, что это коллекция. -const user = []; -// Плохо: словарь назван как массив. -const usersMap = []; -// Плохо: по имени непонятно, что это словарь. -const users = {} as Record; -``` diff --git a/ai/nextjs-style-guide/basics/tech-stack.md b/ai/nextjs-style-guide/basics/tech-stack.md deleted file mode 100644 index 484ab93..0000000 --- a/ai/nextjs-style-guide/basics/tech-stack.md +++ /dev/null @@ -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` — шаблонизатор для создания слоёв и других файлов из шаблонов. diff --git a/ai/nextjs-style-guide/basics/typing.md b/ai/nextjs-style-guide/basics/typing.md deleted file mode 100644 index 840765f..0000000 --- a/ai/nextjs-style-guide/basics/typing.md +++ /dev/null @@ -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; -``` diff --git a/ai/nextjs-style-guide/creating-project/from-template.md b/ai/nextjs-style-guide/creating-project/from-template.md deleted file mode 100644 index c549640..0000000 --- a/ai/nextjs-style-guide/creating-project/from-template.md +++ /dev/null @@ -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/` — решение о репозитории принимает разработчик. diff --git a/ai/nextjs-style-guide/creating-project/manual.md b/ai/nextjs-style-guide/creating-project/manual.md deleted file mode 100644 index d345a50..0000000 --- a/ai/nextjs-style-guide/creating-project/manual.md +++ /dev/null @@ -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-спрайты | Иконки через ``, управление цветом | [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-компонента ``. - -См. [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 и часть инструментов; полный набор — это эталон, а не обязательство. diff --git a/ai/nextjs-style-guide/creating-project/nextjs.md b/ai/nextjs-style-guide/creating-project/nextjs.md deleted file mode 100644 index 546f9f1..0000000 --- a/ai/nextjs-style-guide/creating-project/nextjs.md +++ /dev/null @@ -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

Home

-} -``` - -Очистить `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 ( - - {children} - - ) -} -``` - -### 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`). diff --git a/ai/nextjs-style-guide/data/index.md b/ai/nextjs-style-guide/data/index.md deleted file mode 100644 index 75b1a09..0000000 --- a/ai/nextjs-style-guide/data/index.md +++ /dev/null @@ -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. Отдельный прикладной раздел для них пока не ведётся. diff --git a/ai/nextjs-style-guide/data/realtime.md b/ai/nextjs-style-guide/data/realtime.md deleted file mode 100644 index f284c2b..0000000 --- a/ai/nextjs-style-guide/data/realtime.md +++ /dev/null @@ -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 {count ?? 0} -} -``` - -Плюсы: кеш и дедупликация подписки между несколькими местами рендера; единая модель данных с 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/`. - -Исключения — точечные и обоснованные (например, диагностический скрипт), помечаются комментарием. diff --git a/ai/nextjs-style-guide/data/rest/clients/auto.md b/ai/nextjs-style-guide/data/rest/clients/auto.md deleted file mode 100644 index 5098f16..0000000 --- a/ai/nextjs-style-guide/data/rest/clients/auto.md +++ /dev/null @@ -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 -} -``` - -```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. diff --git a/ai/nextjs-style-guide/data/rest/clients/hooks.md b/ai/nextjs-style-guide/data/rest/clients/hooks.md deleted file mode 100644 index 7b4f271..0000000 --- a/ai/nextjs-style-guide/data/rest/clients/hooks.md +++ /dev/null @@ -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(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 ( - - {children} - - ) -} -``` - -Клиентский компонент при этом ничего не знает про 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(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). diff --git a/ai/nextjs-style-guide/data/rest/clients/index.md b/ai/nextjs-style-guide/data/rest/clients/index.md deleted file mode 100644 index b0e8ff8..0000000 --- a/ai/nextjs-style-guide/data/rest/clients/index.md +++ /dev/null @@ -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). diff --git a/ai/nextjs-style-guide/data/rest/clients/manual.md b/ai/nextjs-style-guide/data/rest/clients/manual.md deleted file mode 100644 index cb9820b..0000000 --- a/ai/nextjs-style-guide/data/rest/clients/manual.md +++ /dev/null @@ -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 -``` - -## Ошибка 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 = {}, - ) {} - - async get(path: string, params: QueryParams = {}): Promise { - 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 - } -} -``` - -Это минимальный шаблон. Авторизация, дополнительные заголовки, `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('posts', query), - - /** GET /posts/{slug} */ - get: (slug: string) => - client.get(`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). diff --git a/ai/nextjs-style-guide/data/rest/index.md b/ai/nextjs-style-guide/data/rest/index.md deleted file mode 100644 index 174a7d5..0000000 --- a/ai/nextjs-style-guide/data/rest/index.md +++ /dev/null @@ -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/`. diff --git a/ai/nextjs-style-guide/data/rest/strategies/business-composition.md b/ai/nextjs-style-guide/data/rest/strategies/business-composition.md deleted file mode 100644 index b9250e5..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/business-composition.md +++ /dev/null @@ -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-модуль отвечает за смысл этих данных в продукте. diff --git a/ai/nextjs-style-guide/data/rest/strategies/client-get-hook.md b/ai/nextjs-style-guide/data/rest/strategies/client-get-hook.md deleted file mode 100644 index 6dde3e1..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/client-get-hook.md +++ /dev/null @@ -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('available') - const { data: pets, isLoading, error } = useGetPetList(status) - - return ( -
-
- {statuses.map((item) => ( - - ))} -
- - {isLoading &&
Загрузка...
} - {error &&
Ошибка загрузки
} - -
    - {pets?.map((pet) => ( -
  • {pet.name}
  • - ))} -
-
- ) -} -``` - -Компонент выбирает параметр `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). diff --git a/ai/nextjs-style-guide/data/rest/strategies/client-hooks-initial-data.md b/ai/nextjs-style-guide/data/rest/strategies/client-hooks-initial-data.md deleted file mode 100644 index 610eb87..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/client-hooks-initial-data.md +++ /dev/null @@ -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 ( - - {children} - - ) -} -``` - -Если 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
Загрузка...
- - return ( -
    - {pets?.map((pet) => ( -
  • {pet.name}
  • - ))} -
- ) -} -``` - -Компонент не знает, что данные были запущены на сервере. Он использует обычный GET-хук REST-клиента. - -## Что важно - -- Ключ `fallback` должен совпадать с ключом GET-хука. -- Серверный код вызывает метод клиента, а не GET-хук. -- Клиентский компонент вызывает GET-хук, а не `useSWR` напрямую. -- Эта стратегия не означает ручную работу с кешем в компонентах. - -## Когда не использовать - -Если данные нужны только серверному компоненту, используйте [Серверный await](./server-await.md). Если данные зависят от состояния браузера, используйте [Клиентский GET-хук](./client-get-hook.md). diff --git a/ai/nextjs-style-guide/data/rest/strategies/index.md b/ai/nextjs-style-guide/data/rest/strategies/index.md deleted file mode 100644 index a7e6d34..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/index.md +++ /dev/null @@ -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-модуля. diff --git a/ai/nextjs-style-guide/data/rest/strategies/parallel-server-requests.md b/ai/nextjs-style-guide/data/rest/strategies/parallel-server-requests.md deleted file mode 100644 index fec2efb..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/parallel-server-requests.md +++ /dev/null @@ -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 ( - - ) -} -``` - -## Плохо - -```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 ( - - ) -} -``` - -Во втором примере каждый следующий запрос ждёт предыдущий, хотя они независимы. - -## Зависимые запросы - -Если второй запрос зависит от результата первого, последовательный `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 -} -``` - -Не превращайте зависимый сценарий в `Promise.all` искусственно. - -## Когда выбрать другую стратегию - -Если часть данных не обязательна для первого блока UI, можно запустить промис выше и передать его ниже: [Передача промиса ниже](./pass-promise-down.md). diff --git a/ai/nextjs-style-guide/data/rest/strategies/pass-promise-down.md b/ai/nextjs-style-guide/data/rest/strategies/pass-promise-down.md deleted file mode 100644 index d2e5004..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/pass-promise-down.md +++ /dev/null @@ -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 ( -
-

Питомцы

- }> - - -
- ) -} - -async function AvailablePets({ petsPromise }: { petsPromise: Promise }) { - const pets = await petsPromise - - return -} -``` - -Запрос стартует в `PetsPage`, но ожидание происходит внутри `AvailablePets`. `Suspense` управляет fallback для этой части UI. - -## Граница стратегии - -Эта стратегия остаётся серверной. Не используйте её как замену GET-хукам в Client Components. - -Если данные должны попасть в клиентский SWR-хук, используйте [Начальные данные для клиентских хуков](./client-hooks-initial-data.md). - -## Что не делать - -```tsx -// Плохо — передавать промис в произвольный клиентский компонент без ясной стратегии -return -``` - -Для клиентского потребления есть отдельная стратегия через `SWRConfig fallback` и готовые GET-хуки REST-клиента. diff --git a/ai/nextjs-style-guide/data/rest/strategies/server-await.md b/ai/nextjs-style-guide/data/rest/strategies/server-await.md deleted file mode 100644 index 87c68b5..0000000 --- a/ai/nextjs-style-guide/data/rest/strategies/server-await.md +++ /dev/null @@ -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 -} -``` - -`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 -} -``` - -Обработка 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). diff --git a/ai/nextjs-style-guide/workflow.md b/ai/nextjs-style-guide/workflow.md deleted file mode 100644 index c060231..0000000 --- a/ai/nextjs-style-guide/workflow.md +++ /dev/null @@ -1,8 +0,0 @@ ---- -title: Подсказки -description: Короткие ответы на типовые вопросы и решения для спорных ситуаций. ---- - -# Подсказки - -Короткие ответы на типовые вопросы и решения для спорных ситуаций. diff --git a/apps/admin/index.html b/apps/admin/index.html index 227fea4..9e90066 100644 --- a/apps/admin/index.html +++ b/apps/admin/index.html @@ -3,6 +3,7 @@ + Image Platform Admin diff --git a/apps/admin/package.json b/apps/admin/package.json index d75751e..81e1b91 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -18,6 +18,7 @@ "clsx": "^2.1.1", "react": "^19.2.5", "react-dom": "^19.2.5", + "react-router-dom": "^7.15.0", "swr": "^2.4.1" }, "devDependencies": { diff --git a/apps/admin/public/favicon.svg b/apps/admin/public/favicon.svg new file mode 100644 index 0000000..7800c3d --- /dev/null +++ b/apps/admin/public/favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/apps/admin/src/app/app-router.tsx b/apps/admin/src/app/app-router.tsx new file mode 100644 index 0000000..fdc4675 --- /dev/null +++ b/apps/admin/src/app/app-router.tsx @@ -0,0 +1,11 @@ +import { Route, Routes } from "react-router-dom" +import { AssetDetailPage, NotFoundPage, ProjectAssetsPage, ProjectsPage } from "pages" + +export const AppRouter = () => ( + + } path="/" /> + } path="/projects/:projectSlug" /> + } path="/projects/:projectSlug/assets/:publicId" /> + } path="*" /> + +) diff --git a/apps/admin/src/app/app.tsx b/apps/admin/src/app/app.tsx index 655d8a3..121535e 100644 --- a/apps/admin/src/app/app.tsx +++ b/apps/admin/src/app/app.tsx @@ -1,13 +1,17 @@ +import { BrowserRouter } from "react-router-dom" import { ThemeProvider } from "infra/theme" import { MainLayout } from "layouts/main" -import { DashboardScreen } from "screens/dashboard" + +import { AppRouter } from "./app-router" export function App() { return ( - - - - - + + + + + + + ) } diff --git a/apps/admin/src/business/assets/assets.factory.ts b/apps/admin/src/business/assets/assets.factory.ts index e096807..0327f19 100644 --- a/apps/admin/src/business/assets/assets.factory.ts +++ b/apps/admin/src/business/assets/assets.factory.ts @@ -1,9 +1,12 @@ 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 { useAssetsDashboard } from "./hooks/use-assets-dashboard.hook" import { useCreateAsset } from "./hooks/use-create-asset.hook" import { useCreateAssetVersion } from "./hooks/use-create-asset-version.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" /** @@ -13,9 +16,12 @@ export const assetsFactory: AssetsFactory = () => { return { useAssetOverview, useAssetPicture, + useAssetVersions, useAssetsDashboard, useCreateAsset, useCreateAssetVersion, useGenerateAssetVariants, + useImagePresets, + useProjectAssets, } } diff --git a/apps/admin/src/business/assets/hooks/use-asset-versions.hook.ts b/apps/admin/src/business/assets/hooks/use-asset-versions.hook.ts new file mode 100644 index 0000000..27adc1b --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-asset-versions.hook.ts @@ -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 ?? [], + } +} diff --git a/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts b/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts index 91e6fb3..058ca72 100644 --- a/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts +++ b/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts @@ -1,6 +1,6 @@ import { useState } from "react" 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 { toError } from "../lib/to-error" @@ -27,6 +27,7 @@ export const useCreateAssetVersion = (): CreateAssetVersionAction => { await Promise.all([ mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)), mutate(getAssetKey(input.publicId)), + mutate(getAssetVersionsKey(input.publicId)), mutate(getAssetVariantsKey(input.publicId, String(createdVersion.version))), ]) diff --git a/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts b/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts index f4fffc6..7d1d4ba 100644 --- a/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts +++ b/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts @@ -1,6 +1,6 @@ import { useState } from "react" 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 { toError } from "../lib/to-error" @@ -19,8 +19,15 @@ export const useCreateAsset = (): CreateAssetAction => { setIsCreating(true) try { - const createdAsset = await backendApi.assets.createAsset(input) - await mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)) + const { projectSlug, ...request } = input + 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 } catch (caughtError) { diff --git a/apps/admin/src/business/assets/hooks/use-image-presets.hook.ts b/apps/admin/src/business/assets/hooks/use-image-presets.hook.ts new file mode 100644 index 0000000..889ccb0 --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-image-presets.hook.ts @@ -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, + } +} diff --git a/apps/admin/src/business/assets/hooks/use-project-assets.hook.ts b/apps/admin/src/business/assets/hooks/use-project-assets.hook.ts new file mode 100644 index 0000000..01fccac --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-project-assets.hook.ts @@ -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, + } +} diff --git a/apps/admin/src/business/assets/index.ts b/apps/admin/src/business/assets/index.ts index 51c28e6..dfc02e8 100644 --- a/apps/admin/src/business/assets/index.ts +++ b/apps/admin/src/business/assets/index.ts @@ -2,6 +2,7 @@ export { assetsFactory } from "./assets.factory" export type { AssetOverview, AssetPicturePreview, + AssetVersionsHistory, AssetsApi, AssetsDashboard, AssetVariantFormat, @@ -13,5 +14,7 @@ export type { CreateAssetVersionInput, GenerateAssetVariantsAction, GenerateAssetVariantsInput, + ImagePresetsOverview, + ProjectAssetsOverview, } from "./types/assets-api.type" export type { AssetsFactory } from "./types/assets-factory.type" diff --git a/apps/admin/src/business/assets/types/assets-api.type.ts b/apps/admin/src/business/assets/types/assets-api.type.ts index 9469b9f..8b4b650 100644 --- a/apps/admin/src/business/assets/types/assets-api.type.ts +++ b/apps/admin/src/business/assets/types/assets-api.type.ts @@ -2,6 +2,7 @@ import type { AssetResponseDto, AssetPictureResponseDto, AssetVariantResponseDto, + AssetVersionResponseDto, CreateAssetRequestDto, CreateAssetResponseDto, CreateAssetVersionResponseDto, @@ -32,6 +33,33 @@ export type AssetPicturePreview = { refresh: () => Promise } +export type AssetVersionsHistory = { + currentVersion: number | null + error?: Error + isLoading: boolean + isRefreshing: boolean + publicId: string | null + refresh: () => Promise + versions: AssetVersionResponseDto[] +} + +export type ProjectAssetsOverview = { + assets: AssetResponseDto[] + error?: Error + isLoading: boolean + isRefreshing: boolean + refresh: () => Promise +} + +export type ImagePresetsOverview = { + custom: PresetsResponseDto["custom"] | null + error?: Error + isLoading: boolean + isRefreshing: boolean + presets: PresetResponseDto[] + refresh: () => Promise +} + export type AssetsDashboard = { allowedSourceHosts: string[] assets: AssetResponseDto[] @@ -46,7 +74,9 @@ export type AssetsDashboard = { } } -export type CreateAssetInput = CreateAssetRequestDto +export type CreateAssetInput = CreateAssetRequestDto & { + projectSlug?: string +} export type CreateAssetAction = { createAsset: (input: CreateAssetInput) => Promise @@ -90,8 +120,11 @@ export type GenerateAssetVariantsAction = { export type AssetsApi = { useAssetOverview: (publicId: string | null) => AssetOverview useAssetPicture: (publicId: string | null, preset: string | null) => AssetPicturePreview + useAssetVersions: (publicId: string | null) => AssetVersionsHistory useAssetsDashboard: () => AssetsDashboard useCreateAsset: () => CreateAssetAction useCreateAssetVersion: () => CreateAssetVersionAction useGenerateAssetVariants: () => GenerateAssetVariantsAction + useImagePresets: () => ImagePresetsOverview + useProjectAssets: (projectSlug: string | null) => ProjectAssetsOverview } diff --git a/apps/admin/src/business/projects/hooks/use-create-project.hook.ts b/apps/admin/src/business/projects/hooks/use-create-project.hook.ts new file mode 100644 index 0000000..cd13ac0 --- /dev/null +++ b/apps/admin/src/business/projects/hooks/use-create-project.hook.ts @@ -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(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, + } +} diff --git a/apps/admin/src/business/projects/hooks/use-project-detail.hook.ts b/apps/admin/src/business/projects/hooks/use-project-detail.hook.ts new file mode 100644 index 0000000..3137f90 --- /dev/null +++ b/apps/admin/src/business/projects/hooks/use-project-detail.hook.ts @@ -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, + } +} diff --git a/apps/admin/src/business/projects/hooks/use-projects-home.hook.ts b/apps/admin/src/business/projects/hooks/use-projects-home.hook.ts new file mode 100644 index 0000000..2718fc6 --- /dev/null +++ b/apps/admin/src/business/projects/hooks/use-projects-home.hook.ts @@ -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, + } +} diff --git a/apps/admin/src/business/projects/index.ts b/apps/admin/src/business/projects/index.ts new file mode 100644 index 0000000..9219dcf --- /dev/null +++ b/apps/admin/src/business/projects/index.ts @@ -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" diff --git a/apps/admin/src/business/projects/lib/to-error.ts b/apps/admin/src/business/projects/lib/to-error.ts new file mode 100644 index 0000000..bc2d025 --- /dev/null +++ b/apps/admin/src/business/projects/lib/to-error.ts @@ -0,0 +1 @@ +export const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error))) diff --git a/apps/admin/src/business/projects/projects.factory.ts b/apps/admin/src/business/projects/projects.factory.ts new file mode 100644 index 0000000..b4903de --- /dev/null +++ b/apps/admin/src/business/projects/projects.factory.ts @@ -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, + } +} diff --git a/apps/admin/src/business/projects/types/projects-api.type.ts b/apps/admin/src/business/projects/types/projects-api.type.ts new file mode 100644 index 0000000..7823fb4 --- /dev/null +++ b/apps/admin/src/business/projects/types/projects-api.type.ts @@ -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 +} + +export type ProjectDetail = { + error?: Error + isLoading: boolean + isRefreshing: boolean + project: ProjectResponseDto | null + refresh: () => Promise +} + +export type CreateProjectInput = CreateProjectRequestDto + +export type CreateProjectAction = { + createProject: (input: CreateProjectInput) => Promise + error: Error | null + isCreating: boolean +} + +/** + * Публичный runtime API бизнес-модуля Projects. + */ +export type ProjectsApi = { + useCreateProject: () => CreateProjectAction + useProjectDetail: (projectSlug: string | null) => ProjectDetail + useProjectsHome: () => ProjectsHome +} diff --git a/apps/admin/src/business/projects/types/projects-factory.type.ts b/apps/admin/src/business/projects/types/projects-factory.type.ts new file mode 100644 index 0000000..597e671 --- /dev/null +++ b/apps/admin/src/business/projects/types/projects-factory.type.ts @@ -0,0 +1,6 @@ +import type { ProjectsApi } from "./projects-api.type" + +/** + * Фабрика runtime API бизнес-модуля Projects. + */ +export type ProjectsFactory = () => ProjectsApi diff --git a/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts b/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts index 3c496bd..f288031 100644 --- a/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts +++ b/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts @@ -112,6 +112,79 @@ export interface CreateAssetResponseDto { 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 { /** * Постоянная ссылка на новую версию исходного изображения. @@ -543,6 +616,62 @@ export interface PresetsResponseDto { 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. * @example "active" @@ -704,6 +833,15 @@ export enum PresetResponseDtoResizeEnum { Fill = "fill", } +/** + * Статус проекта. + * @example "active" + */ +export enum ProjectResponseDtoStatusEnum { + Active = "active", + Disabled = "disabled", +} + export interface ListAssetsParams { /** * Максимальное количество assets в ответе. @@ -725,6 +863,14 @@ export interface GetAssetParams { publicId: string; } +export interface ListAssetVersionsParams { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; +} + export interface CreateAssetVersionParams { /** * Публичный идентификатор asset. @@ -782,6 +928,40 @@ export interface CreateAssetVariantsParams { 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 { /** * @description Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests. @@ -862,6 +1042,27 @@ export namespace Assets { 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. * @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 * Принимает URL и возвращает Promise с данными @@ -1366,6 +1673,25 @@ export class Api { ...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({ + path: `/api/assets/${publicId}/versions`, + method: "GET", + format: "json", + ...params, + }), + /** * @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs. * @@ -1489,4 +1815,103 @@ export class Api { ...params, }), }; + projects = { + /** + * @description Возвращает проекты верхнего уровня для главной страницы admin. + * + * @tags projects + * @name ListProjects + * @summary получить список проектов + * @request GET:/api/projects + */ + listProjects: (params: RequestParams = {}) => + this.http.request({ + 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({ + 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({ + 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({ + 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({ + path: `/api/projects/${projectSlug}/assets`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + }; } diff --git a/apps/admin/src/infra/backend-api/hooks/index.ts b/apps/admin/src/infra/backend-api/hooks/index.ts index 8340a2f..5b08df2 100644 --- a/apps/admin/src/infra/backend-api/hooks/index.ts +++ b/apps/admin/src/infra/backend-api/hooks/index.ts @@ -2,5 +2,9 @@ export { getAssetPictureKey, useGetAssetPicture } from "./use-get-asset-picture. export type { AssetPictureQuery } from "./use-get-asset-picture.hook" export { getAssetKey, useGetAsset } from "./use-get-asset.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 { 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" diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-asset-versions.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-asset-versions.hook.ts new file mode 100644 index 0000000..1733f61 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-asset-versions.hook.ts @@ -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) => { + const key = publicId !== null ? getAssetVersionsKey(publicId) : null + const fetcher = () => backendApi.assets.listAssetVersions({ publicId: publicId ?? "" }) + + return useSWR(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-project-assets.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-project-assets.hook.ts new file mode 100644 index 0000000..d05bfa9 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-project-assets.hook.ts @@ -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 = {}) => + ["backend-api", "projects", "assets", projectSlug, params.limit ?? null, params.offset ?? null] as const + +/** + * Получение assets проекта. + */ +export const useGetProjectAssets = ( + projectSlug: string | null, + params: Omit = {}, + config?: SWRConfiguration, +) => { + const key = projectSlug !== null ? getProjectAssetsKey(projectSlug, params) : null + const fetcher = () => backendApi.projects.listProjectAssets({ ...params, projectSlug: projectSlug ?? "" }) + + return useSWR(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-project.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-project.hook.ts new file mode 100644 index 0000000..a60eb87 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-project.hook.ts @@ -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) => { + const key = projectSlug !== null ? getProjectKey(projectSlug) : null + const fetcher = () => backendApi.projects.getProject({ projectSlug: projectSlug ?? "" }) + + return useSWR(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-projects-list.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-projects-list.hook.ts new file mode 100644 index 0000000..6279ec5 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-projects-list.hook.ts @@ -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) => { + const fetcher = () => backendApi.projects.listProjects() + + return useSWR(getProjectsListKey(), fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/index.ts b/apps/admin/src/infra/backend-api/index.ts index e9f5872..058f6f0 100644 --- a/apps/admin/src/infra/backend-api/index.ts +++ b/apps/admin/src/infra/backend-api/index.ts @@ -5,6 +5,8 @@ export type { AssetPictureResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, + AssetVersionResponseDto, + AssetVersionsResponseDto, AssetsListResponseDto, CreateAssetRequestDto, CreateAssetResponseDto, @@ -12,9 +14,13 @@ export type { CreateAssetVersionResponseDto, CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto, + CreateProjectRequestDto, CustomTransformConfigResponseDto, GetAssetPictureParams, ListAssetsParams, + ListProjectAssetsParams, PresetResponseDto, PresetsResponseDto, + ProjectResponseDto, + ProjectsListResponseDto, } from "./generated/backend-api.generated" diff --git a/apps/admin/src/infra/theme/config/theme.config.ts b/apps/admin/src/infra/theme/config/theme.config.ts index e0de397..d78e6e9 100644 --- a/apps/admin/src/infra/theme/config/theme.config.ts +++ b/apps/admin/src/infra/theme/config/theme.config.ts @@ -1,11 +1,25 @@ import { createTheme } from "@mantine/core" export const ADMIN_THEME = createTheme({ + colors: { + forest: [ + "#edf3ed", + "#dfe8df", + "#bdcfbe", + "#98b199", + "#77967a", + "#5f8164", + "#506f55", + "#445846", + "#394a3c", + "#303f33", + ], + }, defaultRadius: "lg", fontFamily: "var(--font-sans)", headings: { fontFamily: "var(--font-sans)", fontWeight: "850", }, - primaryColor: "violet", + primaryColor: "forest", }) diff --git a/apps/admin/src/layouts/main/main.layout.tsx b/apps/admin/src/layouts/main/main.layout.tsx index 19a0f2a..0bad17a 100644 --- a/apps/admin/src/layouts/main/main.layout.tsx +++ b/apps/admin/src/layouts/main/main.layout.tsx @@ -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 { Link } from "react-router-dom" import styles from "./styles/main.module.css" import type { MainLayoutProps } from "./types/main.type" @@ -18,18 +19,16 @@ export const MainLayout = (props: MainLayoutProps) => { - + IP - Image Platform + Платформа изображений - + - - Admin MVP - + Админка diff --git a/apps/admin/src/layouts/main/styles/main.module.css b/apps/admin/src/layouts/main/styles/main.module.css index 497e69f..0673dd0 100644 --- a/apps/admin/src/layouts/main/styles/main.module.css +++ b/apps/admin/src/layouts/main/styles/main.module.css @@ -1,15 +1,11 @@ .root { min-height: 100vh; - background: - 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); + background: var(--color-page); } .header { border-bottom: 1px solid var(--color-border); - background: rgb(247 244 238 / 78%); - backdrop-filter: blur(18px); + background: var(--color-header); } .brand { @@ -24,7 +20,6 @@ border: 1px solid var(--color-border); background: var(--color-surface); color: var(--color-accent); - box-shadow: var(--shadow-soft); font-size: 0.8125rem; font-weight: 850; letter-spacing: 0.08em; @@ -32,12 +27,21 @@ .brandText { display: none; + color: var(--color-text); @media (--sm) { display: block; } } +.sectionLabel { + color: var(--color-text-subtle); + font-size: 0.75rem; + font-weight: 760; + letter-spacing: 0.12em; + text-transform: uppercase; +} + .main { background: transparent; } diff --git a/apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx b/apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx new file mode 100644 index 0000000..77f0a7f --- /dev/null +++ b/apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx @@ -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 + } + + if (!publicId) { + return + } + + return +} diff --git a/apps/admin/src/pages/asset-detail-page/index.ts b/apps/admin/src/pages/asset-detail-page/index.ts new file mode 100644 index 0000000..557c4d7 --- /dev/null +++ b/apps/admin/src/pages/asset-detail-page/index.ts @@ -0,0 +1 @@ +export { AssetDetailPage } from "./asset-detail.page" diff --git a/apps/admin/src/pages/index.ts b/apps/admin/src/pages/index.ts new file mode 100644 index 0000000..ea5e9b2 --- /dev/null +++ b/apps/admin/src/pages/index.ts @@ -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" diff --git a/apps/admin/src/pages/not-found-page/index.ts b/apps/admin/src/pages/not-found-page/index.ts new file mode 100644 index 0000000..ec9ef0f --- /dev/null +++ b/apps/admin/src/pages/not-found-page/index.ts @@ -0,0 +1 @@ +export { NotFoundPage } from "./not-found.page" diff --git a/apps/admin/src/pages/not-found-page/not-found.page.tsx b/apps/admin/src/pages/not-found-page/not-found.page.tsx new file mode 100644 index 0000000..a30d915 --- /dev/null +++ b/apps/admin/src/pages/not-found-page/not-found.page.tsx @@ -0,0 +1,14 @@ +import { Button, Paper, Stack, Text, Title } from "@mantine/core" +import { Link } from "react-router-dom" + +export const NotFoundPage = () => ( + + + Страница не найдена + Такого маршрута в админке нет. + + + +) diff --git a/apps/admin/src/pages/project-assets-page/index.ts b/apps/admin/src/pages/project-assets-page/index.ts new file mode 100644 index 0000000..d69654f --- /dev/null +++ b/apps/admin/src/pages/project-assets-page/index.ts @@ -0,0 +1 @@ +export { ProjectAssetsPage } from "./project-assets.page" diff --git a/apps/admin/src/pages/project-assets-page/project-assets.page.tsx b/apps/admin/src/pages/project-assets-page/project-assets.page.tsx new file mode 100644 index 0000000..39a87b6 --- /dev/null +++ b/apps/admin/src/pages/project-assets-page/project-assets.page.tsx @@ -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 + } + + return +} diff --git a/apps/admin/src/pages/projects-page/index.ts b/apps/admin/src/pages/projects-page/index.ts new file mode 100644 index 0000000..854f8d0 --- /dev/null +++ b/apps/admin/src/pages/projects-page/index.ts @@ -0,0 +1 @@ +export { ProjectsPage } from "./projects.page" diff --git a/apps/admin/src/pages/projects-page/projects.page.tsx b/apps/admin/src/pages/projects-page/projects.page.tsx new file mode 100644 index 0000000..4850421 --- /dev/null +++ b/apps/admin/src/pages/projects-page/projects.page.tsx @@ -0,0 +1,3 @@ +import { ProjectsScreen } from "screens/projects" + +export const ProjectsPage = () => diff --git a/apps/admin/src/screens/asset-detail/asset-detail.screen.tsx b/apps/admin/src/screens/asset-detail/asset-detail.screen.tsx new file mode 100644 index 0000000..9145b79 --- /dev/null +++ b/apps/admin/src/screens/asset-detail/asset-detail.screen.tsx @@ -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(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 ( +
+ + + + Проекты + + / + + {projectDetail.project?.name ?? projectSlug} + + / + {publicId} + + + + +
+ Изображение + {publicId} + Метаданные источника, версии, варианты и контракт для picture/srcset. +
+ + +
+
+ + {dashboard.error || overview.error || sourceVersions.error || picturePreview.error ? ( + + Проверьте backend API и существование изображения `{publicId}`. + + ) : null} + +
+ + +
+ + + + +
+ + undefined} + opened={isCreateVersionOpen} + publicId={publicId} + /> + + undefined} + opened={isGenerateVariantsOpen} + presets={dashboard.presets} + /> +
+ ) +} diff --git a/apps/admin/src/screens/asset-detail/index.ts b/apps/admin/src/screens/asset-detail/index.ts new file mode 100644 index 0000000..248301c --- /dev/null +++ b/apps/admin/src/screens/asset-detail/index.ts @@ -0,0 +1 @@ +export { AssetDetailScreen } from "./asset-detail.screen" diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx b/apps/admin/src/screens/asset-detail/parts/asset-detail-panel/asset-detail-panel.tsx similarity index 71% rename from apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx rename to apps/admin/src/screens/asset-detail/parts/asset-detail-panel/asset-detail-panel.tsx index b4ce9fd..545c700 100644 --- a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx +++ b/apps/admin/src/screens/asset-detail/parts/asset-detail-panel/asset-detail-panel.tsx @@ -14,9 +14,14 @@ import { Title, } from "@mantine/core" -import { ASSET_STATUS_COLORS, VARIANT_STATUS_COLORS } from "../../config/dashboard.config" -import { copyText } from "../../lib/copy-text" -import { formatDateTime } from "../../lib/format-date" +import { + ASSET_STATUS_COLORS, + 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" /** @@ -34,10 +39,10 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { return ( - Asset detail + Детали изображения - Выберите asset из таблицы, чтобы увидеть source URL и variants. + Выберите изображение из списка, чтобы увидеть URL исходника и варианты. ) @@ -48,7 +53,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
- Asset detail + Детали изображения {publicId} @@ -58,23 +63,23 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { {asset ? ( - - {asset.status} + {ASSET_STATUS_LABELS[asset.status] ?? asset.status} {overview.hasRunningVariants ? ( - polling variants + обновляем варианты ) : null} @@ -87,14 +92,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { - Source URL + URL исходника {asset.sourceUrl} - @@ -107,14 +112,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { {asset.sourceHost} - updated {formatDateTime(asset.updatedAt)} + обновлено {formatDateTime(asset.updatedAt)} - Variants + Варианты {variants.length} @@ -126,11 +131,11 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { - Preview - Preset - Format - Size - Status + Превью + Пресет + Формат + Размер + Статус URL @@ -142,7 +147,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { ) : ( - not ready + не готово )} @@ -151,20 +156,20 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { {variant.format} - {variant.width}x{variant.height || "auto"} q{variant.quality} + {variant.width}x{variant.height || "авто"} q{variant.quality} - {variant.status} + {VARIANT_STATUS_LABELS[variant.status] ?? variant.status} - open + открыть - @@ -174,12 +179,12 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
) : ( - Variants для текущей версии пока не созданы. + Варианты для текущей версии пока не созданы. )}
) : ( - Asset не найден или ещё загружается. + Изображение не найдено или ещё загружается. )} ) diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/index.ts b/apps/admin/src/screens/asset-detail/parts/asset-detail-panel/index.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/asset-detail-panel/index.ts rename to apps/admin/src/screens/asset-detail/parts/asset-detail-panel/index.ts diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts b/apps/admin/src/screens/asset-detail/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts rename to apps/admin/src/screens/asset-detail/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts diff --git a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/create-source-version-modal.tsx b/apps/admin/src/screens/asset-detail/parts/create-source-version-modal/create-source-version-modal.tsx similarity index 77% rename from apps/admin/src/screens/dashboard/parts/create-source-version-modal/create-source-version-modal.tsx rename to apps/admin/src/screens/asset-detail/parts/create-source-version-modal/create-source-version-modal.tsx index 9d51846..e99606f 100644 --- a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/create-source-version-modal.tsx +++ b/apps/admin/src/screens/asset-detail/parts/create-source-version-modal/create-source-version-modal.tsx @@ -29,7 +29,7 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) = validate: { sourceUrl: (value) => { if (!value.trim()) { - return "Укажите source URL" + return "Укажите URL исходника" } try { @@ -60,8 +60,8 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) = }) notifications.show({ color: "green", - message: `Asset ${createdVersion.publicId} обновлён до v${createdVersion.version}`, - title: "Source version created", + message: `Изображение ${createdVersion.publicId} обновлено до v${createdVersion.version}`, + title: "Версия источника создана", }) form.reset() onCreated(createdVersion.publicId) @@ -70,24 +70,24 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) = notifications.show({ color: "red", message: toErrorMessage(error), - title: "Не удалось создать source version", + title: "Не удалось создать версию источника", }) } }) return ( - +
- Новая source version изменит currentVersion asset. Старые public URLs останутся immutable. + Новая версия источника изменит текущую версию изображения. Старые публичные URL останутся неизменяемыми. - + diff --git a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/index.ts b/apps/admin/src/screens/asset-detail/parts/create-source-version-modal/index.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/create-source-version-modal/index.ts rename to apps/admin/src/screens/asset-detail/parts/create-source-version-modal/index.ts diff --git a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts b/apps/admin/src/screens/asset-detail/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts rename to apps/admin/src/screens/asset-detail/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts diff --git a/apps/admin/src/screens/dashboard/parts/generate-variants-modal/generate-variants-modal.tsx b/apps/admin/src/screens/asset-detail/parts/generate-variants-modal/generate-variants-modal.tsx similarity index 76% rename from apps/admin/src/screens/dashboard/parts/generate-variants-modal/generate-variants-modal.tsx rename to apps/admin/src/screens/asset-detail/parts/generate-variants-modal/generate-variants-modal.tsx index 6f4fd6f..039f0a5 100644 --- a/apps/admin/src/screens/dashboard/parts/generate-variants-modal/generate-variants-modal.tsx +++ b/apps/admin/src/screens/asset-detail/parts/generate-variants-modal/generate-variants-modal.tsx @@ -66,12 +66,12 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { width: "", }, validate: { - preset: (value) => (value ? null : "Выберите preset"), + preset: (value) => (value ? null : "Выберите пресет"), width: (value, values) => { const selectedPreset = presets.find((preset) => preset.name === values.preset) const needsWidth = values.mode === "single" && (values.preset === "custom" || selectedPreset?.mode === "responsive") - return needsWidth && !toOptionalNumber(value) ? "Укажите width" : null + return needsWidth && !toOptionalNumber(value) ? "Укажите ширину" : null }, }, }) @@ -81,13 +81,13 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { const availableFormats = isCustom ? (custom?.formats ?? []) : (selectedPreset?.formats ?? FORMAT_OPTIONS) const presetOptions = [ ...presets.map((preset) => ({ - label: `${preset.name} (${preset.mode})`, + label: `${preset.name} (${formatPresetMode(preset.mode)})`, value: preset.name, })), - ...(custom?.enabled ? [{ label: "custom", value: "custom" }] : []), + ...(custom?.enabled ? [{ label: "произвольный", value: "custom" }] : []), ] const formatOptions = availableFormats.map((format) => ({ label: format, value: format })) - const widthHint = selectedPreset?.widths?.length ? `Allowed: ${selectedPreset.widths.join(", ")}` : undefined + const widthHint = selectedPreset?.widths?.length ? `Разрешено: ${selectedPreset.widths.join(", ")}` : undefined const handleClose = () => { if (!action.isGenerating) { @@ -103,7 +103,7 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { if (values.mode === "family" && isCustom) { notifications.show({ color: "red", - message: "Custom transform поддерживает только single generation.", + message: "Произвольная трансформация поддерживает только одиночную генерацию.", title: "Некорректный режим", }) return @@ -130,8 +130,8 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { const response = await action.generateAssetVariants(input) notifications.show({ color: "green", - message: `Поставлено variants: ${response.variants.length}`, - title: "Generation jobs created", + message: `Поставлено вариантов в очередь: ${response.variants.length}`, + title: "Задачи генерации созданы", }) onGenerated(response.publicId) onClose() @@ -139,23 +139,23 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { notifications.show({ color: "red", message: toErrorMessage(error), - title: "Не удалось поставить generation jobs", + title: "Не удалось поставить задачи генерации", }) } }) return ( - + - Endpoint создаёт rows variants и RabbitMQ jobs. Worker сгенерирует bytes асинхронно. + Сервер создаст записи вариантов и задачи в очереди. Обработчик сгенерирует файлы асинхронно. form.setFieldValue("format", (value ?? DEFAULT_FORMAT) as AssetVariantFormat)} required value={form.values.format} @@ -202,15 +202,15 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { @@ -218,16 +218,16 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => { {!publicId ? ( - Выберите asset для preview. + Выберите изображение для предпросмотра. ) : picturePreview.isLoading ? ( ) : picture ? ( @@ -69,20 +69,20 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => { q{picture.quality} - widths {picture.widths.join(", ")} + ширины {picture.widths.join(", ")} - Fallback URL + Резервный URL {picture.image.src} - @@ -90,8 +90,8 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => { - Format - Type + Формат + Тип srcset @@ -105,7 +105,7 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => { {source.srcSet} @@ -114,9 +114,21 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
) : ( - Picture contract пока недоступен. + Контракт picture/srcset пока недоступен. )} ) } + +function formatPresetMode(mode: string): string { + if (mode === "fixed") { + return "фиксированный" + } + + if (mode === "responsive") { + return "адаптивный" + } + + return mode +} diff --git a/apps/admin/src/screens/dashboard/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts b/apps/admin/src/screens/asset-detail/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts rename to apps/admin/src/screens/asset-detail/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts diff --git a/apps/admin/src/screens/dashboard/parts/presets-panel/index.ts b/apps/admin/src/screens/asset-detail/parts/presets-panel/index.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/presets-panel/index.ts rename to apps/admin/src/screens/asset-detail/parts/presets-panel/index.ts diff --git a/apps/admin/src/screens/dashboard/parts/presets-panel/presets-panel.tsx b/apps/admin/src/screens/asset-detail/parts/presets-panel/presets-panel.tsx similarity index 73% rename from apps/admin/src/screens/dashboard/parts/presets-panel/presets-panel.tsx rename to apps/admin/src/screens/asset-detail/parts/presets-panel/presets-panel.tsx index 07d3c41..4a3d59b 100644 --- a/apps/admin/src/screens/dashboard/parts/presets-panel/presets-panel.tsx +++ b/apps/admin/src/screens/asset-detail/parts/presets-panel/presets-panel.tsx @@ -17,15 +17,15 @@ export const PresetsPanel = (props: PresetsPanelProps) => {
- Presets + Пресеты - Static transform profiles, formats, qualities и source allowlist. + Статические профили трансформаций, форматы, качество и список разрешённых источников.
{custom ? ( - custom {custom.enabled ? "enabled" : "disabled"} + произвольные {custom.enabled ? "включены" : "выключены"} ) : null}
@@ -41,15 +41,15 @@ export const PresetsPanel = (props: PresetsPanelProps) => { {preset.name} - {preset.mode} + {formatPresetMode(preset.mode)} {preset.resize}, q{preset.quality} - formats: {preset.formats.join(", ")} + форматы: {preset.formats.join(", ")} - sizes: {preset.widths?.join(", ") ?? `${preset.width}x${preset.height}`} + размеры: {preset.widths?.join(", ") ?? `${preset.width}x${preset.height}`} @@ -59,7 +59,7 @@ export const PresetsPanel = (props: PresetsPanelProps) => { {custom ? ( - max {custom.maxWidth}x{custom.maxHeight} + максимум {custom.maxWidth}x{custom.maxHeight} q{custom.quality} @@ -72,7 +72,7 @@ export const PresetsPanel = (props: PresetsPanelProps) => { - Allowed source hosts + Разрешённые источники {allowedSourceHosts.map((host) => ( @@ -85,3 +85,15 @@ export const PresetsPanel = (props: PresetsPanelProps) => { ) } + +function formatPresetMode(mode: string): string { + if (mode === "fixed") { + return "фиксированный" + } + + if (mode === "responsive") { + return "адаптивный" + } + + return mode +} diff --git a/apps/admin/src/screens/dashboard/parts/presets-panel/types/presets-panel-props.type.ts b/apps/admin/src/screens/asset-detail/parts/presets-panel/types/presets-panel-props.type.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/presets-panel/types/presets-panel-props.type.ts rename to apps/admin/src/screens/asset-detail/parts/presets-panel/types/presets-panel-props.type.ts diff --git a/apps/admin/src/screens/asset-detail/parts/source-versions-panel/index.ts b/apps/admin/src/screens/asset-detail/parts/source-versions-panel/index.ts new file mode 100644 index 0000000..d498e05 --- /dev/null +++ b/apps/admin/src/screens/asset-detail/parts/source-versions-panel/index.ts @@ -0,0 +1 @@ +export { SourceVersionsPanel } from "./source-versions-panel" diff --git a/apps/admin/src/screens/asset-detail/parts/source-versions-panel/source-versions-panel.tsx b/apps/admin/src/screens/asset-detail/parts/source-versions-panel/source-versions-panel.tsx new file mode 100644 index 0000000..ce090f9 --- /dev/null +++ b/apps/admin/src/screens/asset-detail/parts/source-versions-panel/source-versions-panel.tsx @@ -0,0 +1,135 @@ +import { Anchor, Badge, Button, Code, Group, Paper, ScrollArea, Skeleton, Stack, Table, Text, Title } from "@mantine/core" + +import { copyText } from "screens/shared/lib/copy-text" +import { formatDateTime } from "screens/shared/lib/format-date" +import type { SourceVersionsPanelProps } from "./types/source-versions-panel-props.type" + +const bytesFormatter = new Intl.NumberFormat("ru-RU") + +const formatBytes = (value?: number | null) => { + if (value === undefined || value === null) { + return "ожидает обработки" + } + + return `${bytesFormatter.format(value)} B` +} + +const formatDimensions = (width?: number | null, height?: number | null) => { + if (!width || !height) { + return "ожидает обработки" + } + + return `${width}x${height}` +} + +/** + * История source versions выбранного asset. + */ +export const SourceVersionsPanel = (props: SourceVersionsPanelProps) => { + const { history, publicId } = props + + return ( + + +
+ + Версии источника + + + Неизменяемые URL исходников, из которых строятся версионные пути доставки. + +
+ + + {history.currentVersion ? ( + + текущая v{history.currentVersion} + + ) : null} + + +
+ + {!publicId ? ( + Выберите изображение, чтобы увидеть историю версий источника. + ) : history.isLoading ? ( + + ) : history.versions.length > 0 ? ( + + + + + Версия + URL исходника + Путь доставки + Метаданные + Создана + + + + {history.versions.map((version) => ( + + + + + v{version.version} + + {version.isCurrent ? ( + + текущая + + ) : null} + + + + + + + открыть исходник + + + + + {version.sourceHost} + + + + + + {version.imageBasePath} + + + + + + {formatDimensions(version.width, version.height)} + + {version.contentType ?? "тип содержимого ожидает обработки"} / {formatBytes(version.sizeBytes)} + + + + {formatDateTime(version.createdAt)} + + ))} + +
+
+ ) : ( + У изображения пока нет зарегистрированных версий источника. + )} +
+ ) +} diff --git a/apps/admin/src/screens/asset-detail/parts/source-versions-panel/types/source-versions-panel-props.type.ts b/apps/admin/src/screens/asset-detail/parts/source-versions-panel/types/source-versions-panel-props.type.ts new file mode 100644 index 0000000..01e9067 --- /dev/null +++ b/apps/admin/src/screens/asset-detail/parts/source-versions-panel/types/source-versions-panel-props.type.ts @@ -0,0 +1,11 @@ +import type { AssetVersionsHistory } from "business/assets" + +/** + * Параметры SourceVersionsPanel. + */ +export type SourceVersionsPanelProps = { + /** История source versions выбранного asset. */ + history: AssetVersionsHistory + /** Выбранный publicId. */ + publicId: string | null +} diff --git a/apps/admin/src/screens/asset-detail/types/asset-detail-screen-props.type.ts b/apps/admin/src/screens/asset-detail/types/asset-detail-screen-props.type.ts new file mode 100644 index 0000000..74c643d --- /dev/null +++ b/apps/admin/src/screens/asset-detail/types/asset-detail-screen-props.type.ts @@ -0,0 +1,9 @@ +import type { ComponentPropsWithoutRef } from "react" + +/** Параметры AssetDetailScreen. */ +export type AssetDetailScreenProps = ComponentPropsWithoutRef<"section"> & { + /** Public ID asset. */ + publicId: string + /** Slug проекта. */ + projectSlug: string +} diff --git a/apps/admin/src/screens/dashboard/config/dashboard.config.ts b/apps/admin/src/screens/dashboard/config/dashboard.config.ts deleted file mode 100644 index 5fecfe5..0000000 --- a/apps/admin/src/screens/dashboard/config/dashboard.config.ts +++ /dev/null @@ -1,36 +0,0 @@ -export const DASHBOARD_CARDS = [ - { - metric: "assets", - title: "Assets", - description: "Каталог исходных изображений, версий и публичных identifiers.", - }, - { - metric: "presets", - title: "Variants", - description: "Статусы генерации AVIF/WebP/JPEG под presets и custom transforms.", - }, - { - metric: "hosts", - title: "Storage", - description: "PostgreSQL как source of truth, S3/MinIO как хранилище готовых bytes.", - }, -] as const - -export const DASHBOARD_PIPELINE = ["Backend", "RabbitMQ", "Worker", "imgproxy", "S3"] as const - -export const DASHBOARD_ASSET_SEARCH_PARAM = "asset" - -export const DASHBOARD_PRESET_SEARCH_PARAM = "preset" - -export const ASSET_STATUS_COLORS = { - active: "green", - deleted: "red", - disabled: "gray", -} as const - -export const VARIANT_STATUS_COLORS = { - failed: "red", - pending: "yellow", - processing: "blue", - ready: "green", -} as const diff --git a/apps/admin/src/screens/dashboard/dashboard.screen.tsx b/apps/admin/src/screens/dashboard/dashboard.screen.tsx deleted file mode 100644 index 10e4ff2..0000000 --- a/apps/admin/src/screens/dashboard/dashboard.screen.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import { Alert, Button, Group, Paper, Stack, Text, Title } from "@mantine/core" -import { useDisclosure } from "@mantine/hooks" -import cl from "clsx" -import { assetsFactory } from "business/assets" - -import { DASHBOARD_PIPELINE } from "./config/dashboard.config" -import { useDashboardUrlState } from "./hooks/use-dashboard-url-state.hook" -import { AssetDetailPanel } from "./parts/asset-detail-panel" -import { AssetsTable } from "./parts/assets-table" -import { CreateAssetModal } from "./parts/create-asset-modal" -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 { SummaryCards } from "./parts/summary-cards" -import styles from "./styles/dashboard.module.css" -import type { DashboardScreenProps } from "./types/dashboard.type" - -const assets = assetsFactory() - -/** - * Стартовый dashboard admin-приложения. - * - * Используется для: - * - отображения стартового состояния admin MVP - * - обзора будущих разделов и пайплайна генерации - */ -export const DashboardScreen = (props: DashboardScreenProps) => { - const { className, ...rootAttrs } = props - const { selectedPicturePreset, selectedPublicId, setSelectedPicturePreset, setSelectedPublicId } = - useDashboardUrlState() - const [isCreateAssetOpen, createAssetModal] = useDisclosure(false) - const [isCreateVersionOpen, createVersionModal] = useDisclosure(false) - const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false) - const dashboard = assets.useAssetsDashboard() - const createAsset = assets.useCreateAsset() - const createAssetVersion = assets.useCreateAssetVersion() - const generateAssetVariants = assets.useGenerateAssetVariants() - const effectivePublicId = selectedPublicId ?? dashboard.assets[0]?.publicId ?? null - const effectivePicturePreset = selectedPicturePreset ?? dashboard.presets[0]?.name ?? null - const overview = assets.useAssetOverview(effectivePublicId) - const picturePreview = assets.useAssetPicture(effectivePublicId, effectivePicturePreset) - - return ( -
- - - -
- Image Platform Admin - Control plane для image delivery - - Управление allowed hosts, assets, source versions, presets и variant generation без - прямого доступа к storage-слою. - -
- - -
-
- - - - - {DASHBOARD_PIPELINE.map((step) => ( - - {step} - - ))} - - - {dashboard.error ? ( - - Проверьте, что backend запущен на `localhost:3001`, а Vite proxy доступен по `/api`. - - ) : null} - -
- - -
- - - - -
- - - - - - -
- ) -} diff --git a/apps/admin/src/screens/dashboard/hooks/use-dashboard-url-state.hook.ts b/apps/admin/src/screens/dashboard/hooks/use-dashboard-url-state.hook.ts deleted file mode 100644 index 96159bc..0000000 --- a/apps/admin/src/screens/dashboard/hooks/use-dashboard-url-state.hook.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { useEffect, useState } from "react" - -import { DASHBOARD_ASSET_SEARCH_PARAM, DASHBOARD_PRESET_SEARCH_PARAM } from "../config/dashboard.config" - -const readSearchParam = (name: string) => { - if (typeof window === "undefined") { - return null - } - - return new URLSearchParams(window.location.search).get(name) -} - -const replaceSearchParam = (name: string, value: string | null) => { - const url = new URL(window.location.href) - - if (value) { - url.searchParams.set(name, value) - } else { - url.searchParams.delete(name) - } - - window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`) -} - -/** - * URL-state dashboard для выбранного asset и picture preset. - */ -export const useDashboardUrlState = () => { - const [selectedPublicId, setSelectedPublicIdState] = useState(() => readSearchParam(DASHBOARD_ASSET_SEARCH_PARAM)) - const [selectedPicturePreset, setSelectedPicturePresetState] = useState(() => - readSearchParam(DASHBOARD_PRESET_SEARCH_PARAM), - ) - - useEffect(() => { - const handlePopState = () => { - setSelectedPublicIdState(readSearchParam(DASHBOARD_ASSET_SEARCH_PARAM)) - setSelectedPicturePresetState(readSearchParam(DASHBOARD_PRESET_SEARCH_PARAM)) - } - - window.addEventListener("popstate", handlePopState) - - return () => window.removeEventListener("popstate", handlePopState) - }, []) - - const setSelectedPublicId = (publicId: string | null) => { - setSelectedPublicIdState(publicId) - replaceSearchParam(DASHBOARD_ASSET_SEARCH_PARAM, publicId) - } - - const setSelectedPicturePreset = (preset: string | null) => { - setSelectedPicturePresetState(preset) - replaceSearchParam(DASHBOARD_PRESET_SEARCH_PARAM, preset) - } - - return { - selectedPicturePreset, - selectedPublicId, - setSelectedPicturePreset, - setSelectedPublicId, - } -} diff --git a/apps/admin/src/screens/dashboard/index.ts b/apps/admin/src/screens/dashboard/index.ts deleted file mode 100644 index 4e99be1..0000000 --- a/apps/admin/src/screens/dashboard/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { DashboardScreen } from "./dashboard.screen" -export type { DashboardScreenProps } from "./types/dashboard.type" diff --git a/apps/admin/src/screens/dashboard/parts/summary-cards/index.ts b/apps/admin/src/screens/dashboard/parts/summary-cards/index.ts deleted file mode 100644 index 7188f58..0000000 --- a/apps/admin/src/screens/dashboard/parts/summary-cards/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export { SummaryCards } from "./summary-cards" -export type { SummaryCardsProps } from "./types/summary-cards-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx b/apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx deleted file mode 100644 index 90451e9..0000000 --- a/apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { Paper, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core" - -import { DASHBOARD_CARDS } from "../../config/dashboard.config" -import type { SummaryCardsProps } from "./types/summary-cards-props.type" - -/** - * Карточки сводных метрик dashboard. - * - * Используется для: - * - отображения количества assets, presets и hosts - * - компактного статуса загрузки данных - */ -export const SummaryCards = (props: SummaryCardsProps) => { - const { isLoading, summary } = props - - return ( - - {DASHBOARD_CARDS.map((card) => ( - - - {isLoading ? ( - - ) : ( - - {summary[card.metric]} - - )} - {card.title} - - {card.description} - - - - ))} - - ) -} diff --git a/apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts b/apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts deleted file mode 100644 index 606a9b5..0000000 --- a/apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts +++ /dev/null @@ -1,11 +0,0 @@ -import type { AssetsDashboard } from "business/assets" - -/** - * Параметры SummaryCards. - */ -export type SummaryCardsProps = { - /** Признак загрузки данных. */ - isLoading: boolean - /** Сводные метрики dashboard. */ - summary: AssetsDashboard["summary"] -} diff --git a/apps/admin/src/screens/dashboard/types/dashboard.type.ts b/apps/admin/src/screens/dashboard/types/dashboard.type.ts deleted file mode 100644 index 8122cba..0000000 --- a/apps/admin/src/screens/dashboard/types/dashboard.type.ts +++ /dev/null @@ -1,4 +0,0 @@ -import type { ComponentPropsWithoutRef } from "react" - -/** Параметры экрана Dashboard. */ -export type DashboardScreenProps = ComponentPropsWithoutRef<"section"> diff --git a/apps/admin/src/screens/project-assets/index.ts b/apps/admin/src/screens/project-assets/index.ts new file mode 100644 index 0000000..ee4af3c --- /dev/null +++ b/apps/admin/src/screens/project-assets/index.ts @@ -0,0 +1 @@ +export { ProjectAssetsScreen } from "./project-assets.screen" diff --git a/apps/admin/src/screens/project-assets/parts/assets-explorer/assets-explorer.tsx b/apps/admin/src/screens/project-assets/parts/assets-explorer/assets-explorer.tsx new file mode 100644 index 0000000..c13ad0f --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/assets-explorer/assets-explorer.tsx @@ -0,0 +1,146 @@ +import { Button, Group, Paper, SimpleGrid, Skeleton, Tabs, Text, Title } from "@mantine/core" +import { useState } from "react" + +import { formatDateTime } from "screens/shared/lib/format-date" +import styles from "./styles/assets-explorer.module.css" +import type { AssetsExplorerProps } from "./types/assets-explorer-props.type" + +type AssetItem = AssetsExplorerProps["assets"]["assets"][number] + +/** + * Explorer изображений проекта. + * + * Используется для: + * - просмотра добавленных изображений в виде сетки + * - размещения будущего списка изображений по URL + */ +export const AssetsExplorer = (props: AssetsExplorerProps) => { + const { assets, onCreateAsset, onSelectAsset } = props + + return ( + +
+
+ + Изображения + + Изображения проекта, сгруппированные по способу добавления. +
+
+ + + + Добавленные + По URL + + + + {assets.isLoading ? ( + + ) : assets.assets.length > 0 ? ( +
+ + + + {assets.assets.map((asset) => ( + + ))} + +
+ ) : ( +
+
+ + Добавленных изображений пока нет + + Добавьте первое изображение, чтобы увидеть его в проводнике. +
+ +
+ )} +
+ + +
+
+ + Изображения по URL пока не отслеживаются + + + Сюда попадут изображения, созданные через доставку по удалённому URL, например из будущей интеграции с загрузчиком изображений. + +
+
+
+
+
+ ) +} + +const AssetPreview = (props: { asset: AssetItem }) => { + const { asset } = props + const [hasPreviewError, setHasPreviewError] = useState(false) + + if (hasPreviewError) { + return ( +
+ {getAssetInitial(asset.publicId)} +
+ ) + } + + return ( +
+ setHasPreviewError(true)} + referrerPolicy="no-referrer" + src={asset.sourceUrl} + /> +
+ ) +} + +const AddedAssetsSkeleton = () => ( +
+ + + + + + + +
+) + +function getAssetInitial(publicId: string): string { + const [firstLetter = "A"] = publicId.trim() + + return firstLetter.toUpperCase() +} diff --git a/apps/admin/src/screens/project-assets/parts/assets-explorer/index.ts b/apps/admin/src/screens/project-assets/parts/assets-explorer/index.ts new file mode 100644 index 0000000..4d40446 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/assets-explorer/index.ts @@ -0,0 +1 @@ +export { AssetsExplorer } from "./assets-explorer" diff --git a/apps/admin/src/screens/project-assets/parts/assets-explorer/styles/assets-explorer.module.css b/apps/admin/src/screens/project-assets/parts/assets-explorer/styles/assets-explorer.module.css new file mode 100644 index 0000000..d501dc8 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/assets-explorer/styles/assets-explorer.module.css @@ -0,0 +1,216 @@ +.root { + padding: var(--space-5); + background: var(--color-surface-solid); + + @media (--md) { + padding: var(--space-6); + } +} + +.header { + margin-bottom: var(--space-5); +} + +.title { + color: var(--color-text); + font-size: 1.5rem; + letter-spacing: -0.035em; +} + +.subtitle { + margin-top: var(--space-1); + color: var(--color-text-muted); + font-size: 0.9375rem; + line-height: 1.55; +} + +.tabsList { + margin-bottom: var(--space-5); +} + +.tab { + color: var(--color-text-muted); + font-weight: 760; + + &[data-active] { + color: var(--color-accent); + } +} + +.explorerLayout { + display: grid; + gap: var(--space-5); + + @media (--lg) { + grid-template-columns: 16rem minmax(0, 1fr); + align-items: start; + } +} + +.folders { + padding: var(--space-4); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-4); + background: var(--color-surface-muted); +} + +.foldersTitle { + margin-bottom: var(--space-3); + color: var(--color-text-subtle); + font-size: 0.75rem; + font-weight: 800; + letter-spacing: 0.12em; + text-transform: uppercase; +} + +.folderItem { + display: flex; + width: 100%; + align-items: center; + justify-content: space-between; + padding: 0.75rem 0.875rem; + border: 1px solid var(--color-border); + border-radius: 0.875rem; + background: var(--color-surface-solid); + color: var(--color-text); + font: inherit; + font-size: 0.875rem; + font-weight: 760; + cursor: default; +} + +.folderHint { + margin-top: var(--space-3); + color: var(--color-text-muted); + font-size: 0.8125rem; + line-height: 1.5; +} + +.foldersSkeleton { + min-height: 12rem; +} + +.assetsGrid { + min-width: 0; +} + +.assetCard { + overflow: hidden; + border: 1px solid var(--color-border); + border-radius: var(--radius-4); + background: var(--color-surface-solid); + color: var(--color-text); + text-align: left; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; + + &:hover { + border-color: var(--color-border-strong); + box-shadow: var(--shadow-card); + transform: translateY(-2px); + } + + &:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; + } +} + +.preview, +.previewFallback { + aspect-ratio: 4 / 3; + overflow: hidden; + background: + linear-gradient(135deg, rgb(255 255 255 / 16%), transparent 46%), + linear-gradient(145deg, #c7bcae 0%, #80796d 52%, #455043 100%); +} + +.previewImage { + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.previewFallback { + display: grid; + place-items: center; + color: var(--color-cover-text); + font-size: 2rem; + font-weight: 820; + letter-spacing: -0.06em; +} + +.assetBody { + display: grid; + gap: var(--space-2); + padding: var(--space-4); +} + +.assetName { + overflow: hidden; + color: var(--color-text); + font-size: 1rem; + font-weight: 820; + line-height: 1.25; + text-overflow: ellipsis; + white-space: nowrap; +} + +.assetMeta { + min-width: 0; +} + +.version { + color: var(--color-accent); + font-size: 0.8125rem; + font-weight: 760; +} + +.host { + overflow: hidden; + color: var(--color-text-muted); + font-size: 0.8125rem; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.updatedAt { + color: var(--color-text-subtle); + font-size: 0.8125rem; + line-height: 1.35; +} + +.emptyState { + display: flex; + flex-direction: column; + gap: var(--space-4); + align-items: flex-start; + padding: var(--space-5); + border: 1px solid var(--color-border-soft); + border-radius: var(--radius-4); + background: var(--color-surface-muted); + + @media (--md) { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.emptyTitle { + color: var(--color-text); + font-size: 1.25rem; + letter-spacing: -0.025em; +} + +.emptyText { + max-width: 40rem; + margin-top: var(--space-2); + color: var(--color-text-muted); + font-size: 0.9375rem; + line-height: 1.6; +} diff --git a/apps/admin/src/screens/project-assets/parts/assets-explorer/types/assets-explorer-props.type.ts b/apps/admin/src/screens/project-assets/parts/assets-explorer/types/assets-explorer-props.type.ts new file mode 100644 index 0000000..8381938 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/assets-explorer/types/assets-explorer-props.type.ts @@ -0,0 +1,13 @@ +import type { ProjectAssetsOverview } from "business/assets" + +/** + * Параметры AssetsExplorer. + */ +export type AssetsExplorerProps = { + /** Состояние assets проекта. */ + assets: ProjectAssetsOverview + /** Callback создания asset. */ + onCreateAsset: () => void + /** Callback выбора asset. */ + onSelectAsset: (publicId: string) => void +} diff --git a/apps/admin/src/screens/dashboard/parts/assets-table/assets-table.tsx b/apps/admin/src/screens/project-assets/parts/assets-table/assets-table.tsx similarity index 75% rename from apps/admin/src/screens/dashboard/parts/assets-table/assets-table.tsx rename to apps/admin/src/screens/project-assets/parts/assets-table/assets-table.tsx index b74e9c7..ce53d81 100644 --- a/apps/admin/src/screens/dashboard/parts/assets-table/assets-table.tsx +++ b/apps/admin/src/screens/project-assets/parts/assets-table/assets-table.tsx @@ -1,7 +1,7 @@ import { Badge, Group, Paper, ScrollArea, Skeleton, Table, Text, Title } from "@mantine/core" -import { ASSET_STATUS_COLORS } from "../../config/dashboard.config" -import { formatDateTime } from "../../lib/format-date" +import { ASSET_STATUS_COLORS, ASSET_STATUS_LABELS } from "screens/shared/config/image-ui.config" +import { formatDateTime } from "screens/shared/lib/format-date" import type { AssetsTableProps } from "./types/assets-table-props.type" /** @@ -19,14 +19,14 @@ export const AssetsTable = (props: AssetsTableProps) => {
- Assets + Изображения - Последние зарегистрированные исходные изображения. + Последние зарегистрированные изображения.
- {assets.length} loaded + загружено: {assets.length}
@@ -37,11 +37,11 @@ export const AssetsTable = (props: AssetsTableProps) => { - publicId - Status - Version - Host - Updated + Публичный ID + Статус + Версия + Источник + Обновлено @@ -57,7 +57,7 @@ export const AssetsTable = (props: AssetsTableProps) => { - {asset.status} + {ASSET_STATUS_LABELS[asset.status] ?? asset.status} v{asset.currentVersion} @@ -69,7 +69,7 @@ export const AssetsTable = (props: AssetsTableProps) => {
) : ( - Assets пока не зарегистрированы. + Изображения пока не зарегистрированы. )} ) diff --git a/apps/admin/src/screens/dashboard/parts/assets-table/index.ts b/apps/admin/src/screens/project-assets/parts/assets-table/index.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/assets-table/index.ts rename to apps/admin/src/screens/project-assets/parts/assets-table/index.ts diff --git a/apps/admin/src/screens/dashboard/parts/assets-table/types/assets-table-props.type.ts b/apps/admin/src/screens/project-assets/parts/assets-table/types/assets-table-props.type.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/assets-table/types/assets-table-props.type.ts rename to apps/admin/src/screens/project-assets/parts/assets-table/types/assets-table-props.type.ts diff --git a/apps/admin/src/screens/dashboard/parts/create-asset-modal/create-asset-modal.tsx b/apps/admin/src/screens/project-assets/parts/create-asset-modal/create-asset-modal.tsx similarity index 76% rename from apps/admin/src/screens/dashboard/parts/create-asset-modal/create-asset-modal.tsx rename to apps/admin/src/screens/project-assets/parts/create-asset-modal/create-asset-modal.tsx index f0ce884..1e0c6a8 100644 --- a/apps/admin/src/screens/dashboard/parts/create-asset-modal/create-asset-modal.tsx +++ b/apps/admin/src/screens/project-assets/parts/create-asset-modal/create-asset-modal.tsx @@ -22,7 +22,7 @@ const toErrorMessage = (error: unknown) => (error instanceof Error ? error.messa * - запуска первого write-сценария admin MVP */ export const CreateAssetModal = (props: CreateAssetModalProps) => { - const { action, onClose, onCreated, opened } = props + const { action, onClose, onCreated, opened, projectSlug } = props const form = useForm({ initialValues: { @@ -32,7 +32,7 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => { validate: { sourceUrl: (value) => { if (!value.trim()) { - return "Укажите source URL" + return "Укажите URL исходника" } try { @@ -54,6 +54,7 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => { const handleSubmit = form.onSubmit(async (values) => { const publicId = values.publicId.trim() const input: CreateAssetInput = { + ...(projectSlug ? { projectSlug } : {}), sourceUrl: values.sourceUrl.trim(), ...(publicId ? { publicId } : {}), } @@ -62,8 +63,8 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => { const createdAsset = await action.createAsset(input) notifications.show({ color: "green", - message: `Asset ${createdAsset.publicId} зарегистрирован`, - title: "Asset created", + message: `Изображение ${createdAsset.publicId} зарегистрировано`, + title: "Изображение создано", }) form.reset() onCreated(createdAsset.publicId) @@ -72,28 +73,29 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => { notifications.show({ color: "red", message: toErrorMessage(error), - title: "Не удалось создать asset", + title: "Не удалось создать изображение", }) } }) return ( - + - Backend создаст asset и первую immutable source version. Public ID можно оставить пустым. + Сервер создаст изображение и первую неизменяемую версию источника. Публичный ID можно оставить пустым. + {projectSlug ? ` Проект: ${projectSlug}.` : ""} { diff --git a/apps/admin/src/screens/dashboard/parts/create-asset-modal/index.ts b/apps/admin/src/screens/project-assets/parts/create-asset-modal/index.ts similarity index 100% rename from apps/admin/src/screens/dashboard/parts/create-asset-modal/index.ts rename to apps/admin/src/screens/project-assets/parts/create-asset-modal/index.ts diff --git a/apps/admin/src/screens/dashboard/parts/create-asset-modal/types/create-asset-modal-props.type.ts b/apps/admin/src/screens/project-assets/parts/create-asset-modal/types/create-asset-modal-props.type.ts similarity index 79% rename from apps/admin/src/screens/dashboard/parts/create-asset-modal/types/create-asset-modal-props.type.ts rename to apps/admin/src/screens/project-assets/parts/create-asset-modal/types/create-asset-modal-props.type.ts index 6044020..cec3bdd 100644 --- a/apps/admin/src/screens/dashboard/parts/create-asset-modal/types/create-asset-modal-props.type.ts +++ b/apps/admin/src/screens/project-assets/parts/create-asset-modal/types/create-asset-modal-props.type.ts @@ -12,4 +12,6 @@ export type CreateAssetModalProps = { onCreated: (publicId: string) => void /** Открыта ли modal. */ opened: boolean + /** Slug проекта, внутри которого создаётся asset. */ + projectSlug?: string | null } diff --git a/apps/admin/src/screens/project-assets/parts/presets-table/index.ts b/apps/admin/src/screens/project-assets/parts/presets-table/index.ts new file mode 100644 index 0000000..593e21c --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/presets-table/index.ts @@ -0,0 +1 @@ +export { PresetsTable } from "./presets-table" diff --git a/apps/admin/src/screens/project-assets/parts/presets-table/presets-table.tsx b/apps/admin/src/screens/project-assets/parts/presets-table/presets-table.tsx new file mode 100644 index 0000000..e12f088 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/presets-table/presets-table.tsx @@ -0,0 +1,114 @@ +import { ActionIcon, Badge, Button, Group, Paper, ScrollArea, Skeleton, Table, Text, Title } from "@mantine/core" + +import styles from "./styles/presets-table.module.css" +import type { PresetsTableProps } from "./types/presets-table-props.type" + +/** + * Таблица image presets проекта. + * + * Используется для: + * - отображения текущих presets генерации изображений + * - размещения будущих действий управления presets + */ +export const PresetsTable = (props: PresetsTableProps) => { + const { presets } = props + + return ( + + +
+ + Пресеты + + Наборы трансформаций, которые доступны изображениям проекта. +
+ + +
+ + {presets.isLoading ? ( + + ) : presets.presets.length > 0 ? ( + + + + + Название + Режим + Размер + Форматы + Качество + Изменение размера + + + + + {presets.presets.map((preset) => ( + + + {preset.name} + + + + {formatPresetMode(preset.mode)} + + + {formatPresetSize(preset)} + {preset.formats.join(", ")} + {preset.quality} + {formatResizeMode(preset.resize)} + + + + Р + + + У + + + + + ))} + +
+
+ ) : ( + Пресеты пока не настроены. + )} +
+ ) +} + +function formatPresetSize(preset: PresetsTableProps["presets"]["presets"][number]): string { + if (preset.mode === "fixed") { + return `${preset.width ?? 0}x${preset.height ?? 0}` + } + + return preset.widths?.length ? preset.widths.join(", ") : "-" +} + +function formatPresetMode(mode: string): string { + if (mode === "fixed") { + return "фиксированный" + } + + if (mode === "responsive") { + return "адаптивный" + } + + return mode +} + +function formatResizeMode(resize: string): string { + if (resize === "fit") { + return "вписать" + } + + if (resize === "fill") { + return "заполнить" + } + + return resize +} diff --git a/apps/admin/src/screens/project-assets/parts/presets-table/styles/presets-table.module.css b/apps/admin/src/screens/project-assets/parts/presets-table/styles/presets-table.module.css new file mode 100644 index 0000000..be45206 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/presets-table/styles/presets-table.module.css @@ -0,0 +1,40 @@ +.root { + padding: var(--space-5); + background: var(--color-surface-solid); + + @media (--md) { + padding: var(--space-6); + } +} + +.header { + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +.title { + color: var(--color-text); + font-size: 1.5rem; + letter-spacing: -0.035em; +} + +.subtitle { + margin-top: var(--space-1); + color: var(--color-text-muted); + font-size: 0.9375rem; + line-height: 1.55; +} + +.table { + min-width: 54rem; +} + +.presetName { + color: var(--color-text); + font-weight: 800; +} + +.emptyText { + color: var(--color-text-muted); + font-size: 0.9375rem; +} diff --git a/apps/admin/src/screens/project-assets/parts/presets-table/types/presets-table-props.type.ts b/apps/admin/src/screens/project-assets/parts/presets-table/types/presets-table-props.type.ts new file mode 100644 index 0000000..cbe6d62 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/presets-table/types/presets-table-props.type.ts @@ -0,0 +1,9 @@ +import type { ImagePresetsOverview } from "business/assets" + +/** + * Параметры PresetsTable. + */ +export type PresetsTableProps = { + /** Presets проекта. */ + presets: ImagePresetsOverview +} diff --git a/apps/admin/src/screens/project-assets/parts/project-header/index.ts b/apps/admin/src/screens/project-assets/parts/project-header/index.ts new file mode 100644 index 0000000..9dcf859 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/project-header/index.ts @@ -0,0 +1 @@ +export { ProjectHeader } from "./project-header" diff --git a/apps/admin/src/screens/project-assets/parts/project-header/project-header.tsx b/apps/admin/src/screens/project-assets/parts/project-header/project-header.tsx new file mode 100644 index 0000000..e2b49ed --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/project-header/project-header.tsx @@ -0,0 +1,66 @@ +import { Button, Group, Paper, Skeleton, Text, Title } from "@mantine/core" + +import styles from "./styles/project-header.module.css" +import type { ProjectHeaderProps } from "./types/project-header-props.type" + +/** + * Шапка страницы проекта. + * + * Используется для: + * - отображения основной информации проекта + * - размещения ключевых действий проекта + */ +export const ProjectHeader = (props: ProjectHeaderProps) => { + const { isLoading, onCreateAsset, onOpenSettings, project, projectSlug } = props + const assetsCount = project?.assetsCount ?? 0 + + return ( + +
+
+ {isLoading ? ( + + ) : ( + {project?.name ?? projectSlug} + )} + + + {project?.slug ?? projectSlug} + + {formatAssetsCount(assetsCount)} + +
+ + + + + +
+
+ ) +} + +function formatAssetsCount(count: number): string { + return `${count} ${getImagesWord(count)}` +} + +function getImagesWord(count: number): string { + const mod10 = count % 10 + const mod100 = count % 100 + + if (mod10 === 1 && mod100 !== 11) { + return "изображение" + } + + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return "изображения" + } + + return "изображений" +} diff --git a/apps/admin/src/screens/project-assets/parts/project-header/styles/project-header.module.css b/apps/admin/src/screens/project-assets/parts/project-header/styles/project-header.module.css new file mode 100644 index 0000000..b471fc3 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/project-header/styles/project-header.module.css @@ -0,0 +1,64 @@ +.root { + padding: var(--space-5); + background: var(--color-surface-solid); + + @media (--md) { + padding: var(--space-6); + } +} + +.content { + display: grid; + gap: var(--space-5); + + @media (--md) { + grid-template-columns: minmax(0, 1fr) auto; + align-items: end; + } +} + +.heading { + min-width: 0; +} + +.title { + color: var(--color-text); + font-size: clamp(2rem, 4vw, 3.75rem); + line-height: 1; + letter-spacing: -0.06em; +} + +.meta { + margin-top: var(--space-3); +} + +.slug { + overflow: hidden; + max-width: 100%; + color: var(--color-accent); + font-size: 0.9375rem; + font-weight: 760; + line-height: 1.4; + text-overflow: ellipsis; + white-space: nowrap; +} + +.dot { + color: var(--color-text-subtle); + font-size: 0.875rem; +} + +.assetsCount { + color: var(--color-text-muted); + font-size: 0.9375rem; + font-weight: 680; + line-height: 1.4; +} + +.actions { + width: 100%; + + @media (--sm) { + width: auto; + } +} diff --git a/apps/admin/src/screens/project-assets/parts/project-header/types/project-header-props.type.ts b/apps/admin/src/screens/project-assets/parts/project-header/types/project-header-props.type.ts new file mode 100644 index 0000000..4b1ca95 --- /dev/null +++ b/apps/admin/src/screens/project-assets/parts/project-header/types/project-header-props.type.ts @@ -0,0 +1,17 @@ +import type { ProjectResponseDto } from "infra/backend-api" + +/** + * Параметры ProjectHeader. + */ +export type ProjectHeaderProps = { + /** Признак загрузки проекта. */ + isLoading: boolean + /** Callback создания asset. */ + onCreateAsset: () => void + /** Callback открытия настроек проекта. */ + onOpenSettings: () => void + /** Metadata проекта. */ + project: ProjectResponseDto | null + /** Slug проекта из route params. */ + projectSlug: string +} diff --git a/apps/admin/src/screens/project-assets/project-assets.screen.tsx b/apps/admin/src/screens/project-assets/project-assets.screen.tsx new file mode 100644 index 0000000..8e27508 --- /dev/null +++ b/apps/admin/src/screens/project-assets/project-assets.screen.tsx @@ -0,0 +1,82 @@ +import { Alert, Anchor, Group, Stack, Text } from "@mantine/core" +import { useDisclosure } from "@mantine/hooks" +import { notifications } from "@mantine/notifications" +import cl from "clsx" +import { Link, useNavigate } from "react-router-dom" +import { assetsFactory } from "business/assets" +import { projectsFactory } from "business/projects" + +import { AssetsExplorer } from "./parts/assets-explorer" +import { CreateAssetModal } from "./parts/create-asset-modal" +import { PresetsTable } from "./parts/presets-table" +import { ProjectHeader } from "./parts/project-header" +import styles from "./styles/project-assets.module.css" +import type { ProjectAssetsScreenProps } from "./types/project-assets-screen-props.type" + +const assets = assetsFactory() +const projects = projectsFactory() + +/** + * Страница assets выбранного проекта. + */ +export const ProjectAssetsScreen = (props: ProjectAssetsScreenProps) => { + const { className, projectSlug, ...rootAttrs } = props + const navigate = useNavigate() + const [isCreateAssetOpen, createAssetModal] = useDisclosure(false) + const projectDetail = projects.useProjectDetail(projectSlug) + const projectAssets = assets.useProjectAssets(projectSlug) + const imagePresets = assets.useImagePresets() + const createAsset = assets.useCreateAsset() + + const openAsset = (publicId: string) => { + navigate(`/projects/${projectSlug}/assets/${publicId}`) + } + + const openProjectSettings = () => { + notifications.show({ + color: "gray", + message: "Экран настроек проекта будет добавлен позже.", + title: "Настройки проекта", + }) + } + + return ( +
+ + + + Проекты + + / + {projectSlug} + + + + + {projectDetail.error || projectAssets.error || imagePresets.error ? ( + + Проверьте backend API и существование проекта `{projectSlug}`. + + ) : null} + + + + + + + +
+ ) +} diff --git a/apps/admin/src/screens/project-assets/styles/project-assets.module.css b/apps/admin/src/screens/project-assets/styles/project-assets.module.css new file mode 100644 index 0000000..bc7428c --- /dev/null +++ b/apps/admin/src/screens/project-assets/styles/project-assets.module.css @@ -0,0 +1,13 @@ +.root { + min-width: 0; +} + +.breadcrumbs { + color: var(--color-text-muted); + font-size: 0.875rem; +} + +.breadcrumbCurrent { + color: var(--color-text); + font-weight: 760; +} diff --git a/apps/admin/src/screens/project-assets/types/project-assets-screen-props.type.ts b/apps/admin/src/screens/project-assets/types/project-assets-screen-props.type.ts new file mode 100644 index 0000000..a0e3e4d --- /dev/null +++ b/apps/admin/src/screens/project-assets/types/project-assets-screen-props.type.ts @@ -0,0 +1,7 @@ +import type { ComponentPropsWithoutRef } from "react" + +/** Параметры ProjectAssetsScreen. */ +export type ProjectAssetsScreenProps = ComponentPropsWithoutRef<"section"> & { + /** Slug проекта. */ + projectSlug: string +} diff --git a/apps/admin/src/screens/projects/index.ts b/apps/admin/src/screens/projects/index.ts new file mode 100644 index 0000000..342a081 --- /dev/null +++ b/apps/admin/src/screens/projects/index.ts @@ -0,0 +1 @@ +export { ProjectsScreen } from "./projects.screen" diff --git a/apps/admin/src/screens/projects/parts/create-project-modal/create-project-modal.tsx b/apps/admin/src/screens/projects/parts/create-project-modal/create-project-modal.tsx new file mode 100644 index 0000000..4129660 --- /dev/null +++ b/apps/admin/src/screens/projects/parts/create-project-modal/create-project-modal.tsx @@ -0,0 +1,98 @@ +import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core" +import { useForm } from "@mantine/form" +import { notifications } from "@mantine/notifications" + +import type { CreateProjectInput } from "business/projects" +import type { CreateProjectModalProps } from "./types/create-project-modal-props.type" + +type CreateProjectFormValues = { + name: string + slug: string +} + +const toErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Неизвестная ошибка") + +/** + * Modal создания проекта. + */ +export const CreateProjectModal = (props: CreateProjectModalProps) => { + const { action, onClose, onCreated, opened } = props + + const form = useForm({ + initialValues: { + name: "", + slug: "", + }, + validate: { + name: (value) => (value.trim() ? null : "Укажите название проекта"), + slug: (value) => { + const normalized = value.trim() + + if (!normalized) { + return null + } + + return /^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized) + ? null + : "Слаг: 3-64 символа, строчные латинские буквы, цифры, _ или -" + }, + }, + }) + + const handleClose = () => { + if (!action.isCreating) { + onClose() + } + } + + const handleSubmit = form.onSubmit(async (values) => { + const slug = values.slug.trim() + const input: CreateProjectInput = { + name: values.name.trim(), + ...(slug ? { slug } : {}), + } + + try { + const project = await action.createProject(input) + notifications.show({ + color: "green", + message: `Проект ${project.slug} создан`, + title: "Проект создан", + }) + form.reset() + onCreated(project.slug) + onClose() + } catch (error) { + notifications.show({ + color: "red", + message: toErrorMessage(error), + title: "Не удалось создать проект", + }) + } + }) + + return ( + + + + + Проект станет верхним уровнем для изображений, SDK URL и будущих токенов доступа. + + + + + + + + + + + + + + ) +} diff --git a/apps/admin/src/screens/projects/parts/create-project-modal/index.ts b/apps/admin/src/screens/projects/parts/create-project-modal/index.ts new file mode 100644 index 0000000..5a28b45 --- /dev/null +++ b/apps/admin/src/screens/projects/parts/create-project-modal/index.ts @@ -0,0 +1 @@ +export { CreateProjectModal } from "./create-project-modal" diff --git a/apps/admin/src/screens/projects/parts/create-project-modal/types/create-project-modal-props.type.ts b/apps/admin/src/screens/projects/parts/create-project-modal/types/create-project-modal-props.type.ts new file mode 100644 index 0000000..8cf1f73 --- /dev/null +++ b/apps/admin/src/screens/projects/parts/create-project-modal/types/create-project-modal-props.type.ts @@ -0,0 +1,15 @@ +import type { CreateProjectAction } from "business/projects" + +/** + * Параметры CreateProjectModal. + */ +export type CreateProjectModalProps = { + /** Сценарий создания проекта. */ + action: CreateProjectAction + /** Callback закрытия modal. */ + onClose: () => void + /** Callback успешного создания проекта. */ + onCreated: (projectSlug: string) => void + /** Открыта ли modal. */ + opened: boolean +} diff --git a/apps/admin/src/screens/projects/parts/projects-grid/index.ts b/apps/admin/src/screens/projects/parts/projects-grid/index.ts new file mode 100644 index 0000000..db66aae --- /dev/null +++ b/apps/admin/src/screens/projects/parts/projects-grid/index.ts @@ -0,0 +1 @@ +export { ProjectsGrid } from "./projects-grid" diff --git a/apps/admin/src/screens/projects/parts/projects-grid/projects-grid.tsx b/apps/admin/src/screens/projects/parts/projects-grid/projects-grid.tsx new file mode 100644 index 0000000..c48f14e --- /dev/null +++ b/apps/admin/src/screens/projects/parts/projects-grid/projects-grid.tsx @@ -0,0 +1,116 @@ +import { Button, Group, SimpleGrid, Skeleton, Text, Title } from "@mantine/core" + +import { formatDateTime } from "screens/shared/lib/format-date" +import styles from "./styles/projects-grid.module.css" +import type { ProjectsGridProps } from "./types/projects-grid-props.type" + +/** + * Главная сетка проектов. + */ +export const ProjectsGrid = (props: ProjectsGridProps) => { + const { home, onCreateProject, onSelectProject } = props + + return ( +
+ +
+ + Проекты + + + Карточки проектов для управления изображениями, версиями источников и пресетами доставки. + +
+ + + + + +
+ + {home.isLoading ? ( + + + + + + ) : home.projects.length > 0 ? ( + + {home.projects.map((project) => ( + { + event.preventDefault() + onSelectProject(project.slug) + }} + > + + +
+
+ + {project.name} + + {project.slug} +
+ +
+ {formatAssetsCount(project.assetsCount)} + Создан {formatDateTime(project.createdAt)} +
+
+
+ ))} +
+ ) : ( +
+
+ + Проекты пока не созданы + + + Создайте первый проект, чтобы подключить изображения и настроить URL доставки. + +
+ + +
+ )} +
+ ) +} + +function getProjectInitial(name: string): string { + const [firstLetter = "P"] = name.trim() + + return firstLetter.toUpperCase() +} + +function formatAssetsCount(count: number): string { + return `${count} ${getImagesWord(count)}` +} + +function getImagesWord(count: number): string { + const mod10 = count % 10 + const mod100 = count % 100 + + if (mod10 === 1 && mod100 !== 11) { + return "изображение" + } + + if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) { + return "изображения" + } + + return "изображений" +} diff --git a/apps/admin/src/screens/projects/parts/projects-grid/styles/projects-grid.module.css b/apps/admin/src/screens/projects/parts/projects-grid/styles/projects-grid.module.css new file mode 100644 index 0000000..3bea631 --- /dev/null +++ b/apps/admin/src/screens/projects/parts/projects-grid/styles/projects-grid.module.css @@ -0,0 +1,219 @@ +.root { + padding: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-5); + background: var(--color-surface-solid); + box-shadow: var(--shadow-soft); + + @media (--md) { + padding: var(--space-6); + } +} + +.toolbar { + gap: var(--space-4); + margin-bottom: var(--space-5); +} + +.heading { + min-width: 0; +} + +.title { + color: var(--color-text); + font-size: clamp(2rem, 4vw, 3.5rem); + line-height: 1; + letter-spacing: -0.055em; +} + +.subtitle { + max-width: 38rem; + margin-top: var(--space-2); + color: var(--color-text-muted); + font-size: 0.9375rem; + line-height: 1.6; +} + +.actions { + width: 100%; + + @media (--sm) { + width: auto; + } +} + +.skeletonCard { + min-height: 24rem; +} + +.card { + display: flex; + overflow: hidden; + flex-direction: column; + border: 1px solid var(--color-border); + border-radius: var(--radius-4); + background: var(--color-surface-solid); + color: var(--color-text); + text-align: left; + text-decoration: none; + box-shadow: none; + transition: + border-color 160ms ease, + box-shadow 160ms ease, + transform 160ms ease; + + &:hover { + border-color: var(--color-border-strong); + box-shadow: var(--shadow-card); + transform: translateY(-2px); + } + + &:focus-visible { + outline: 2px solid var(--color-accent); + outline-offset: 3px; + } +} + +.cover { + position: relative; + display: flex; + min-height: 12rem; + align-items: flex-end; + overflow: hidden; + padding: var(--space-5); + background: + linear-gradient(180deg, rgb(29 26 22 / 0%), rgb(29 26 22 / 28%)), + linear-gradient(145deg, #c7bcae 0%, #80796d 48%, #455043 100%); + + &::before { + content: ''; + position: absolute; + inset: 0; + background: + linear-gradient(90deg, rgb(255 255 255 / 10%) 1px, transparent 1px), + linear-gradient(0deg, rgb(255 255 255 / 8%) 1px, transparent 1px); + background-size: 2.75rem 2.75rem; + mask-image: linear-gradient(135deg, black, transparent 62%); + } + + &::after { + content: ''; + position: absolute; + right: var(--space-5); + bottom: var(--space-5); + width: 5.5rem; + height: 5.5rem; + border: 1px solid rgb(255 255 255 / 18%); + border-radius: 1.25rem; + background: rgb(255 255 255 / 10%); + transform: rotate(6deg); + } +} + +.coverInitial { + position: relative; + z-index: 1; + display: inline-flex; + width: 4rem; + height: 4rem; + align-items: center; + justify-content: center; + border: 1px solid rgb(255 255 255 / 24%); + border-radius: 1rem; + background: rgb(29 26 22 / 24%); + color: var(--color-cover-text); + font-size: 1.75rem; + font-weight: 780; + letter-spacing: -0.06em; + backdrop-filter: blur(8px); +} + +.cardBody { + display: grid; + grid-template-rows: minmax(4.75rem, auto) auto; + gap: var(--space-5); + padding: var(--space-5); +} + +.cardHeading { + display: grid; + gap: var(--space-2); + align-content: start; +} + +.cardTitle { + display: -webkit-box; + max-width: 18rem; + min-height: 3.1rem; + overflow: hidden; + color: var(--color-text); + font-size: 1.35rem; + line-height: 1.15; + letter-spacing: -0.035em; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; +} + +.slug { + overflow: hidden; + color: var(--color-accent); + font-size: 0.875rem; + font-weight: 720; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cardMeta { + display: grid; + gap: var(--space-2); + padding-top: var(--space-4); + border-top: 1px solid var(--color-border-soft); +} + +.assetsCount { + color: var(--color-accent); + font-size: 0.875rem; + font-weight: 760; + line-height: 1.35; +} + +.createdAt { + overflow: hidden; + color: var(--color-text-subtle); + font-size: 0.8125rem; + font-weight: 620; + line-height: 1.35; + text-overflow: ellipsis; + white-space: nowrap; +} + +.emptyState { + display: flex; + flex-direction: column; + gap: var(--space-4); + align-items: flex-start; + padding: var(--space-5); + border: 1px solid var(--color-border); + border-radius: var(--radius-4); + background: var(--color-surface-muted); + + @media (--md) { + flex-direction: row; + align-items: center; + justify-content: space-between; + } +} + +.emptyTitle { + color: var(--color-text); + font-size: 1.25rem; + letter-spacing: -0.025em; +} + +.emptyText { + margin-top: var(--space-2); + color: var(--color-text-muted); + font-size: 0.9375rem; + line-height: 1.6; +} diff --git a/apps/admin/src/screens/projects/parts/projects-grid/types/projects-grid-props.type.ts b/apps/admin/src/screens/projects/parts/projects-grid/types/projects-grid-props.type.ts new file mode 100644 index 0000000..15a9fb8 --- /dev/null +++ b/apps/admin/src/screens/projects/parts/projects-grid/types/projects-grid-props.type.ts @@ -0,0 +1,13 @@ +import type { ProjectsHome } from "business/projects" + +/** + * Параметры ProjectsGrid. + */ +export type ProjectsGridProps = { + /** Данные главной страницы проектов. */ + home: ProjectsHome + /** Callback открытия modal создания проекта. */ + onCreateProject: () => void + /** Callback выбора проекта. */ + onSelectProject: (projectSlug: string) => void +} diff --git a/apps/admin/src/screens/projects/projects.screen.tsx b/apps/admin/src/screens/projects/projects.screen.tsx new file mode 100644 index 0000000..69b8fe8 --- /dev/null +++ b/apps/admin/src/screens/projects/projects.screen.tsx @@ -0,0 +1,48 @@ +import { Alert, Stack } from "@mantine/core" +import { useDisclosure } from "@mantine/hooks" +import cl from "clsx" +import { useNavigate } from "react-router-dom" +import { projectsFactory } from "business/projects" + +import styles from "screens/shared/styles/screen.module.css" +import { CreateProjectModal } from "./parts/create-project-modal" +import { ProjectsGrid } from "./parts/projects-grid" +import type { ProjectsScreenProps } from "./types/projects-screen-props.type" + +const projects = projectsFactory() + +/** + * Главная страница проектов. + */ +export const ProjectsScreen = (props: ProjectsScreenProps) => { + const { className, ...rootAttrs } = props + const navigate = useNavigate() + const [isCreateProjectOpen, createProjectModal] = useDisclosure(false) + const projectsHome = projects.useProjectsHome() + const createProject = projects.useCreateProject() + + const openProject = (projectSlug: string) => { + navigate(`/projects/${projectSlug}`) + } + + return ( +
+ + {projectsHome.error ? ( + + Проверьте, что бэкенд запущен на `localhost:3001`, а Vite proxy доступен по `/api`. + + ) : null} + + + + + +
+ ) +} diff --git a/apps/admin/src/screens/projects/types/projects-screen-props.type.ts b/apps/admin/src/screens/projects/types/projects-screen-props.type.ts new file mode 100644 index 0000000..516b7a4 --- /dev/null +++ b/apps/admin/src/screens/projects/types/projects-screen-props.type.ts @@ -0,0 +1,4 @@ +import type { ComponentPropsWithoutRef } from "react" + +/** Параметры ProjectsScreen. */ +export type ProjectsScreenProps = ComponentPropsWithoutRef<"section"> diff --git a/apps/admin/src/screens/shared/config/image-ui.config.ts b/apps/admin/src/screens/shared/config/image-ui.config.ts new file mode 100644 index 0000000..633ea8c --- /dev/null +++ b/apps/admin/src/screens/shared/config/image-ui.config.ts @@ -0,0 +1,37 @@ +export const IMAGE_PIPELINE_STEPS = ["Backend", "RabbitMQ", "Worker", "imgproxy", "S3"] as const + +export const ASSET_STATUS_COLORS = { + active: "green", + deleted: "red", + disabled: "gray", +} as const + +export const ASSET_STATUS_LABELS = { + active: "активно", + deleted: "удалено", + disabled: "отключено", +} as const + +export const PROJECT_STATUS_COLORS = { + active: "green", + disabled: "gray", +} as const + +export const PROJECT_STATUS_LABELS = { + active: "активен", + disabled: "отключён", +} as const + +export const VARIANT_STATUS_COLORS = { + failed: "red", + pending: "yellow", + processing: "blue", + ready: "green", +} as const + +export const VARIANT_STATUS_LABELS = { + failed: "ошибка", + pending: "в очереди", + processing: "генерируется", + ready: "готово", +} as const diff --git a/apps/admin/src/screens/dashboard/lib/copy-text.ts b/apps/admin/src/screens/shared/lib/copy-text.ts similarity index 68% rename from apps/admin/src/screens/dashboard/lib/copy-text.ts rename to apps/admin/src/screens/shared/lib/copy-text.ts index 1fd8b76..46ac690 100644 --- a/apps/admin/src/screens/dashboard/lib/copy-text.ts +++ b/apps/admin/src/screens/shared/lib/copy-text.ts @@ -5,14 +5,14 @@ export const copyText = async (value: string, label: string) => { await navigator.clipboard.writeText(value) notifications.show({ color: "green", - message: `${label} скопирован в clipboard`, - title: "Copied", + message: `${label} скопирован в буфер обмена`, + title: "Скопировано", }) } catch { notifications.show({ color: "red", message: `Не удалось скопировать ${label}`, - title: "Copy failed", + title: "Копирование не удалось", }) } } diff --git a/apps/admin/src/screens/dashboard/lib/format-date.ts b/apps/admin/src/screens/shared/lib/format-date.ts similarity index 100% rename from apps/admin/src/screens/dashboard/lib/format-date.ts rename to apps/admin/src/screens/shared/lib/format-date.ts diff --git a/apps/admin/src/screens/dashboard/styles/dashboard.module.css b/apps/admin/src/screens/shared/styles/screen.module.css similarity index 78% rename from apps/admin/src/screens/dashboard/styles/dashboard.module.css rename to apps/admin/src/screens/shared/styles/screen.module.css index d0fb108..814cd10 100644 --- a/apps/admin/src/screens/dashboard/styles/dashboard.module.css +++ b/apps/admin/src/screens/shared/styles/screen.module.css @@ -5,8 +5,8 @@ .hero { overflow: hidden; background: - linear-gradient(135deg, rgb(255 255 255 / 92%), rgb(255 255 255 / 72%)), - radial-gradient(circle at 92% 8%, rgb(123 76 255 / 18%), transparent 18rem); + linear-gradient(135deg, rgb(255 253 248 / 96%), rgb(255 253 248 / 86%)), + radial-gradient(circle at 94% 10%, var(--color-accent-wash), transparent 18rem); } .heroContent { @@ -24,9 +24,9 @@ .title { max-width: 50rem; - font-size: clamp(2.75rem, 7vw, 5.75rem); - line-height: 0.9; - letter-spacing: -0.075em; + font-size: clamp(2.25rem, 5vw, 4.25rem); + line-height: 0.98; + letter-spacing: -0.06em; } .lead { diff --git a/apps/admin/src/shared/styles/variables.css b/apps/admin/src/shared/styles/variables.css index 22ec7c8..e9f16ec 100644 --- a/apps/admin/src/shared/styles/variables.css +++ b/apps/admin/src/shared/styles/variables.css @@ -1,12 +1,20 @@ :root { - --color-page: #f7f4ee; - --color-surface: rgb(255 255 255 / 82%); - --color-surface-muted: rgb(255 255 255 / 76%); - --color-border: #e4ded4; - --color-text: #171411; - --color-text-muted: #73695d; - --color-accent: #7b4cff; - --color-accent-wash: rgb(123 76 255 / 18%); + --color-page: #f5f1ea; + --color-header: #f9f6f0; + --color-surface: rgb(255 253 248 / 86%); + --color-surface-solid: #fffdfa; + --color-surface-muted: #f8f3eb; + --color-border: #ded6c9; + --color-border-soft: #ebe4d9; + --color-border-strong: #c9beaf; + --color-text: #1d1a16; + --color-text-muted: #6f675d; + --color-text-subtle: #92887b; + --color-accent: #445846; + --color-accent-wash: rgb(68 88 70 / 12%); + --color-cover: #82796d; + --color-cover-deep: #3f4c41; + --color-cover-text: #fffaf0; --font-sans: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; --content-width: 60rem; --space-1: 0.25rem; @@ -19,6 +27,7 @@ --radius-4: 1.5rem; --radius-5: 2rem; --radius-round: 999px; - --shadow-soft: 0 0.75rem 2rem rgb(40 32 21 / 8%); - --shadow-panel: 0 1.375rem 5rem rgb(40 32 21 / 8%); + --shadow-soft: 0 0.75rem 2rem rgb(51 44 34 / 6%); + --shadow-card: 0 1rem 2.5rem rgb(51 44 34 / 10%); + --shadow-panel: 0 1.375rem 5rem rgb(51 44 34 / 8%); } diff --git a/apps/admin/tsconfig.app.json b/apps/admin/tsconfig.app.json index 185e86a..bd301ef 100644 --- a/apps/admin/tsconfig.app.json +++ b/apps/admin/tsconfig.app.json @@ -21,6 +21,8 @@ "paths": { "app/*": ["src/app/*"], "layouts/*": ["src/layouts/*"], + "pages": ["src/pages/index.ts"], + "pages/*": ["src/pages/*"], "screens/*": ["src/screens/*"], "widgets/*": ["src/widgets/*"], "business/*": ["src/business/*"], diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index a871d4f..cde2374 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ alias: { app: srcPath("app"), layouts: srcPath("layouts"), + pages: srcPath("pages"), screens: srcPath("screens"), widgets: srcPath("widgets"), business: srcPath("business"), diff --git a/apps/backend/src/app.module.ts b/apps/backend/src/app.module.ts index 3db5ec8..b5710d5 100644 --- a/apps/backend/src/app.module.ts +++ b/apps/backend/src/app.module.ts @@ -9,9 +9,11 @@ import { StorageService } from "./infra/storage.service" import { InternalImagesController } from "./internal-images/internal-images.controller" import { InternalImagesService } from "./internal-images/internal-images.service" import { PresetsController } from "./presets/presets.controller" +import { ProjectsController } from "./projects/projects.controller" +import { ProjectsService } from "./projects/projects.service" @Module({ - controllers: [HealthController, AssetsController, InternalImagesController, PresetsController], - providers: [AssetsService, DatabaseService, InternalImagesService, QueueService, StorageService], + controllers: [HealthController, AssetsController, InternalImagesController, PresetsController, ProjectsController], + providers: [AssetsService, DatabaseService, InternalImagesService, ProjectsService, QueueService, StorageService], }) export class AppModule {} diff --git a/apps/backend/src/assets/asset-response.dto.ts b/apps/backend/src/assets/asset-response.dto.ts index 3abd20e..c5fb1e4 100644 --- a/apps/backend/src/assets/asset-response.dto.ts +++ b/apps/backend/src/assets/asset-response.dto.ts @@ -87,6 +87,52 @@ export class AssetVariantsResponseDto { variants!: AssetVariantResponseDto[] } +export class AssetVersionResponseDto { + @ApiProperty({ description: "Внутренний UUID версии source image.", example: "3b5da974-bb7f-4d73-b172-d6ad9c244528" }) + id!: string + + @ApiProperty({ description: "Номер версии source image.", example: 2 }) + version!: number + + @ApiProperty({ description: "Является ли версия текущей для asset.", example: true }) + isCurrent!: boolean + + @ApiProperty({ description: "Source URL версии.", example: "https://storage.yandexcloud.net/shared1318/img/1.jpg" }) + sourceUrl!: string + + @ApiProperty({ description: "Hostname source URL версии.", example: "storage.yandexcloud.net" }) + sourceHost!: string + + @ApiProperty({ description: "Базовый Gateway path для версии.", example: "/images/asset_demo/v2/card" }) + imageBasePath!: string + + @ApiPropertyOptional({ description: "Ширина оригинального изображения, если уже определена Worker.", example: 1200, nullable: true, type: Number }) + width!: number | null + + @ApiPropertyOptional({ description: "Высота оригинального изображения, если уже определена Worker.", example: 800, nullable: true, type: Number }) + height!: number | null + + @ApiPropertyOptional({ description: "Content-Type оригинального изображения, если уже определён Worker.", example: "image/jpeg", nullable: true, type: String }) + contentType!: string | null + + @ApiPropertyOptional({ description: "Размер оригинального изображения в bytes, если уже определён Worker.", example: 245760, nullable: true, type: Number }) + sizeBytes!: number | null + + @ApiProperty({ description: "Дата создания версии.", example: "2026-05-05T12:00:00.000Z" }) + createdAt!: string +} + +export class AssetVersionsResponseDto { + @ApiProperty({ description: "Публичный идентификатор asset.", example: "asset_demo" }) + publicId!: string + + @ApiProperty({ description: "Текущая версия source image.", example: 2 }) + currentVersion!: number + + @ApiProperty({ description: "История версий source image.", type: [AssetVersionResponseDto] }) + versions!: AssetVersionResponseDto[] +} + export class AssetsListResponseDto { @ApiProperty({ description: "Список assets.", type: [AssetResponseDto] }) assets!: AssetResponseDto[] diff --git a/apps/backend/src/assets/assets.controller.ts b/apps/backend/src/assets/assets.controller.ts index 6f159a6..0402c84 100644 --- a/apps/backend/src/assets/assets.controller.ts +++ b/apps/backend/src/assets/assets.controller.ts @@ -13,7 +13,7 @@ import { import { AssetsService } from "./assets.service" import { AssetPictureResponseDto } from "./asset-picture-response.dto" -import { AssetResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto" +import { AssetResponseDto, AssetVariantsResponseDto, AssetVersionsResponseDto, AssetsListResponseDto } from "./asset-response.dto" import { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto" import { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto" import { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto" @@ -60,6 +60,19 @@ export class AssetsController { return this.assets.getAsset(publicId) } + @Get(":publicId/versions") + @ApiOperation({ + summary: "получить историю версий source image", + description: + "Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.", + }) + @ApiParam({ description: "Публичный идентификатор asset.", example: "asset_demo", name: "publicId" }) + @ApiOkResponse({ description: "История версий source image возвращена.", type: AssetVersionsResponseDto }) + @ApiNotFoundResponse({ description: "Asset не найден." }) + listAssetVersions(@Param("publicId") publicId: string): Promise { + return this.assets.listAssetVersions(publicId) + } + @Post(":publicId/versions") @ApiOperation({ summary: "создать новую версию source image", diff --git a/apps/backend/src/assets/assets.service.ts b/apps/backend/src/assets/assets.service.ts index a755e60..318cb7a 100644 --- a/apps/backend/src/assets/assets.service.ts +++ b/apps/backend/src/assets/assets.service.ts @@ -1,5 +1,5 @@ import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common" -import { imageAssets, imageAssetVersions, imageVariants } from "@image-platform/database" +import { imageAssets, imageAssetVersions, imageProjects, imageVariants } from "@image-platform/database" import { CUSTOM_PRESET_NAME, ImageTransformConfigError, @@ -17,8 +17,15 @@ import { createHash, randomUUID } from "node:crypto" import { DatabaseService } from "../infra/database.service" import { QueueService } from "../infra/queue.service" +import { normalizeProjectSlug } from "../projects/project-slug" import type { AssetPictureResponseDto } from "./asset-picture-response.dto" -import type { AssetResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto } from "./asset-response.dto" +import type { + AssetResponseDto, + AssetVariantResponseDto, + AssetVariantsResponseDto, + AssetVersionsResponseDto, + AssetsListResponseDto, +} from "./asset-response.dto" import type { CreateAssetVersionRequestDto, CreateAssetVersionResponseDto } from "./create-asset-version.dto" import type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto } from "./create-asset-variants.dto" import type { CreateAssetRequestDto, CreateAssetResponseDto } from "./create-asset.dto" @@ -61,10 +68,39 @@ export class AssetsService { private readonly queue: QueueService, ) {} - async listAssets(input: { limit?: string; offset?: string }): Promise { + async listAssets(input: { limit?: string; offset?: string; projectSlug?: string }): Promise { const limit = parsePaginationInteger(input.limit, 50, 1, 100) const offset = parsePaginationInteger(input.offset, 0, 0, 10_000) + if (input.projectSlug) { + const project = await this.loadProject(input.projectSlug) + + const rows = await this.database.db + .select({ + createdAt: imageAssets.createdAt, + currentVersion: imageAssets.currentVersion, + id: imageAssets.id, + publicId: imageAssets.publicId, + sourceHost: imageAssetVersions.sourceHost, + sourceUrl: imageAssetVersions.sourceUrl, + status: imageAssets.status, + updatedAt: imageAssets.updatedAt, + }) + .from(imageAssets) + .innerJoin( + imageAssetVersions, + and(eq(imageAssetVersions.assetId, imageAssets.id), eq(imageAssetVersions.version, imageAssets.currentVersion)), + ) + .where(eq(imageAssets.projectId, project.id)) + .orderBy(desc(imageAssets.createdAt)) + .limit(limit) + .offset(offset) + + return { + assets: rows.map(mapAssetResponse), + } + } + const rows = await this.database.db .select({ createdAt: imageAssets.createdAt, @@ -90,18 +126,19 @@ export class AssetsService { } } - async createAsset(request: CreateAssetRequestDto): Promise { + async createAsset(request: CreateAssetRequestDto, projectSlug?: string): Promise { const source = normalizeSourceUrl(request.sourceUrl) await this.assertAllowedHost(source.hostname) const publicId = request.publicId ? normalizePublicId(request.publicId) : generatePublicId() const sourceHash = createSourceHash(source.sourceUrl) + const project = projectSlug ? await this.loadProject(projectSlug) : null try { const result = await this.database.db.transaction(async (tx) => { const [asset] = await tx .insert(imageAssets) - .values({ publicId }) + .values(project ? { projectId: project.id, publicId } : { publicId }) .returning({ id: imageAssets.id, publicId: imageAssets.publicId }) if (!asset) { @@ -148,6 +185,58 @@ export class AssetsService { return mapAssetResponse(asset) } + async listAssetVersions(publicId: string): Promise { + const normalizedPublicId = normalizePublicId(publicId) + const [asset] = await this.database.db + .select({ + currentVersion: imageAssets.currentVersion, + id: imageAssets.id, + publicId: imageAssets.publicId, + status: imageAssets.status, + }) + .from(imageAssets) + .where(eq(imageAssets.publicId, normalizedPublicId)) + .limit(1) + + if (!asset || asset.status !== "active") { + throw new NotFoundException("asset not found") + } + + const versions = await this.database.db + .select({ + contentType: imageAssetVersions.contentType, + createdAt: imageAssetVersions.createdAt, + height: imageAssetVersions.height, + id: imageAssetVersions.id, + sizeBytes: imageAssetVersions.sizeBytes, + sourceHost: imageAssetVersions.sourceHost, + sourceUrl: imageAssetVersions.sourceUrl, + version: imageAssetVersions.version, + width: imageAssetVersions.width, + }) + .from(imageAssetVersions) + .where(eq(imageAssetVersions.assetId, asset.id)) + .orderBy(desc(imageAssetVersions.version)) + + return { + currentVersion: asset.currentVersion, + publicId: asset.publicId, + versions: versions.map((version) => ({ + contentType: version.contentType, + createdAt: version.createdAt.toISOString(), + height: version.height, + id: version.id, + imageBasePath: `/images/${asset.publicId}/v${version.version}/card`, + isCurrent: version.version === asset.currentVersion, + sizeBytes: version.sizeBytes, + sourceHost: version.sourceHost, + sourceUrl: version.sourceUrl, + version: version.version, + width: version.width, + })), + } + } + async createAssetVersion( publicId: string, request: CreateAssetVersionRequestDto, @@ -381,6 +470,21 @@ export class AssetsService { return asset } + private async loadProject(projectSlug: string) { + const normalizedSlug = normalizeProjectSlug(projectSlug) + const [project] = await this.database.db + .select({ id: imageProjects.id, status: imageProjects.status }) + .from(imageProjects) + .where(eq(imageProjects.slug, normalizedSlug)) + .limit(1) + + if (!project || project.status !== "active") { + throw new NotFoundException("project not found") + } + + return project + } + private async loadAssetVersion(publicId: string, versionInput: number | null | undefined): Promise { const normalizedPublicId = normalizePublicId(publicId) const [asset] = await this.database.db diff --git a/apps/backend/src/projects/project-slug.ts b/apps/backend/src/projects/project-slug.ts new file mode 100644 index 0000000..2f4b6a6 --- /dev/null +++ b/apps/backend/src/projects/project-slug.ts @@ -0,0 +1,21 @@ +import { BadRequestException } from "@nestjs/common" + +export function normalizeProjectSlug(value: string) { + const normalized = value.trim().toLowerCase() + + if (!/^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized)) { + throw new BadRequestException("project slug must be 3-64 chars and contain lowercase letters, digits, _ or -") + } + + return normalized +} + +export function createProjectSlug(name: string) { + const slug = name + .trim() + .toLowerCase() + .replaceAll(/[^a-z0-9_-]+/g, "-") + .replaceAll(/^-+|-+$/g, "") + + return normalizeProjectSlug(slug || "project") +} diff --git a/apps/backend/src/projects/projects.controller.ts b/apps/backend/src/projects/projects.controller.ts new file mode 100644 index 0000000..6ebce2a --- /dev/null +++ b/apps/backend/src/projects/projects.controller.ts @@ -0,0 +1,96 @@ +import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common" +import { + ApiBadRequestResponse, + ApiConflictResponse, + ApiCreatedResponse, + ApiNotFoundResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiQuery, + ApiTags, +} from "@nestjs/swagger" + +import { AssetsListResponseDto } from "../assets/asset-response.dto" +import { AssetsService } from "../assets/assets.service" +import { CreateAssetRequestDto, CreateAssetResponseDto } from "../assets/create-asset.dto" +import { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto" +import { ProjectsService } from "./projects.service" + +@ApiTags("projects") +@Controller("projects") +export class ProjectsController { + constructor( + private readonly assets: AssetsService, + private readonly projects: ProjectsService, + ) {} + + @Get() + @ApiOperation({ + summary: "получить список проектов", + description: "Возвращает проекты верхнего уровня для главной страницы admin.", + }) + @ApiOkResponse({ description: "Список проектов возвращён.", type: ProjectsListResponseDto }) + listProjects(): Promise { + return this.projects.listProjects() + } + + @Post() + @ApiOperation({ + summary: "создать проект", + description: "Создаёт проект, внутри которого admin управляет assets и source versions.", + }) + @ApiCreatedResponse({ description: "Проект создан.", type: ProjectResponseDto }) + @ApiBadRequestResponse({ description: "Некорректные name или slug." }) + @ApiConflictResponse({ description: "Проект с таким slug уже существует." }) + createProject(@Body() request: CreateProjectRequestDto): Promise { + return this.projects.createProject(request) + } + + @Get(":projectSlug") + @ApiOperation({ + summary: "получить проект по slug", + description: "Возвращает metadata проекта для project-level страницы admin.", + }) + @ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" }) + @ApiOkResponse({ description: "Проект найден.", type: ProjectResponseDto }) + @ApiNotFoundResponse({ description: "Проект не найден." }) + getProject(@Param("projectSlug") projectSlug: string): Promise { + return this.projects.getProject(projectSlug) + } + + @Get(":projectSlug/assets") + @ApiOperation({ + summary: "получить assets проекта", + description: "Возвращает assets, созданные внутри выбранного проекта.", + }) + @ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" }) + @ApiQuery({ description: "Максимальное количество assets в ответе.", example: 50, name: "limit", required: false }) + @ApiQuery({ description: "Смещение для простого paging.", example: 0, name: "offset", required: false }) + @ApiOkResponse({ description: "Список assets проекта возвращён.", type: AssetsListResponseDto }) + @ApiNotFoundResponse({ description: "Проект не найден." }) + listProjectAssets( + @Param("projectSlug") projectSlug: string, + @Query("limit") limit?: string, + @Query("offset") offset?: string, + ): Promise { + return this.assets.listAssets({ limit, offset, projectSlug }) + } + + @Post(":projectSlug/assets") + @ApiOperation({ + summary: "создать asset в проекте", + description: "Создаёт asset и первую source version внутри выбранного проекта.", + }) + @ApiParam({ description: "Публичный slug проекта.", example: "demo-shop", name: "projectSlug" }) + @ApiCreatedResponse({ description: "Asset проекта создан.", type: CreateAssetResponseDto }) + @ApiBadRequestResponse({ description: "Некорректный sourceUrl, publicId или source host запрещён настройками." }) + @ApiConflictResponse({ description: "Asset с таким publicId уже существует." }) + @ApiNotFoundResponse({ description: "Проект не найден." }) + createProjectAsset( + @Param("projectSlug") projectSlug: string, + @Body() request: CreateAssetRequestDto, + ): Promise { + return this.assets.createAsset(request, projectSlug) + } +} diff --git a/apps/backend/src/projects/projects.dto.ts b/apps/backend/src/projects/projects.dto.ts new file mode 100644 index 0000000..49d940c --- /dev/null +++ b/apps/backend/src/projects/projects.dto.ts @@ -0,0 +1,37 @@ +import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger" + +export class CreateProjectRequestDto { + @ApiProperty({ description: "Название проекта в admin UI.", example: "Demo Shop" }) + name!: string + + @ApiPropertyOptional({ description: "Публичный slug проекта для URL и SDK.", example: "demo-shop" }) + slug?: string +} + +export class ProjectResponseDto { + @ApiProperty({ description: "Внутренний UUID проекта.", example: "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" }) + id!: string + + @ApiProperty({ description: "Публичный slug проекта.", example: "demo-shop" }) + slug!: string + + @ApiProperty({ description: "Название проекта.", example: "Demo Shop" }) + name!: string + + @ApiProperty({ description: "Статус проекта.", enum: ["active", "disabled"], example: "active" }) + status!: string + + @ApiProperty({ description: "Количество assets в проекте.", example: 12 }) + assetsCount!: number + + @ApiProperty({ description: "Дата создания проекта.", example: "2026-05-05T12:00:00.000Z" }) + createdAt!: string + + @ApiProperty({ description: "Дата обновления проекта.", example: "2026-05-05T12:00:00.000Z" }) + updatedAt!: string +} + +export class ProjectsListResponseDto { + @ApiProperty({ description: "Список проектов.", type: [ProjectResponseDto] }) + projects!: ProjectResponseDto[] +} diff --git a/apps/backend/src/projects/projects.service.ts b/apps/backend/src/projects/projects.service.ts new file mode 100644 index 0000000..0be82cf --- /dev/null +++ b/apps/backend/src/projects/projects.service.ts @@ -0,0 +1,107 @@ +import { BadRequestException, ConflictException, Injectable, NotFoundException } from "@nestjs/common" +import { imageAssets, imageProjects } from "@image-platform/database" +import { count, desc, eq, inArray } from "drizzle-orm" + +import { DatabaseService } from "../infra/database.service" +import { createProjectSlug, normalizeProjectSlug } from "./project-slug" +import type { CreateProjectRequestDto, ProjectResponseDto, ProjectsListResponseDto } from "./projects.dto" + +@Injectable() +export class ProjectsService { + constructor(private readonly database: DatabaseService) {} + + async listProjects(): Promise { + const projects = await this.database.db.select().from(imageProjects).orderBy(desc(imageProjects.createdAt)) + const assetsCountByProjectId = await this.countAssetsByProjectIds(projects.map((project) => project.id)) + + return { + projects: projects.map((project) => mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0)), + } + } + + async createProject(request: CreateProjectRequestDto): Promise { + const name = normalizeProjectName(request.name) + const slug = request.slug ? normalizeProjectSlug(request.slug) : createProjectSlug(name) + + try { + const [project] = await this.database.db.insert(imageProjects).values({ name, slug }).returning() + + if (!project) { + throw new Error("failed to create project") + } + + return mapProjectResponse(project, 0) + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException("project slug already exists") + } + + throw error + } + } + + async getProject(slug: string): Promise { + const project = await this.loadProject(slug) + const assetsCountByProjectId = await this.countAssetsByProjectIds([project.id]) + + return mapProjectResponse(project, assetsCountByProjectId.get(project.id) ?? 0) + } + + async loadProject(slug: string) { + const normalizedSlug = normalizeProjectSlug(slug) + const [project] = await this.database.db + .select() + .from(imageProjects) + .where(eq(imageProjects.slug, normalizedSlug)) + .limit(1) + + if (!project || project.status !== "active") { + throw new NotFoundException("project not found") + } + + return project + } + + private async countAssetsByProjectIds(projectIds: string[]) { + if (projectIds.length === 0) { + return new Map() + } + + const rows = await this.database.db + .select({ + assetsCount: count(imageAssets.id), + projectId: imageAssets.projectId, + }) + .from(imageAssets) + .where(inArray(imageAssets.projectId, projectIds)) + .groupBy(imageAssets.projectId) + + return new Map(rows.flatMap((row) => (row.projectId ? [[row.projectId, row.assetsCount]] : []))) + } +} + +function mapProjectResponse(row: typeof imageProjects.$inferSelect, assetsCount: number): ProjectResponseDto { + return { + assetsCount, + createdAt: row.createdAt.toISOString(), + id: row.id, + name: row.name, + slug: row.slug, + status: row.status, + updatedAt: row.updatedAt.toISOString(), + } +} + +function normalizeProjectName(value: string) { + const normalized = value.trim() + + if (!normalized || normalized.length > 120) { + throw new BadRequestException("project name must be 1-120 chars") + } + + return normalized +} + +function isUniqueViolation(error: unknown) { + return typeof error === "object" && error !== null && "code" in error && error.code === "23505" +} diff --git a/apps/gateway/src/config.ts b/apps/gateway/src/config.ts index d8646c1..911ce46 100644 --- a/apps/gateway/src/config.ts +++ b/apps/gateway/src/config.ts @@ -1,18 +1,22 @@ export type GatewayConfig = { backendUpstream: URL host: string + imgproxyUpstream: URL l1MaxEntries: number l1TtlMs: number port: number + remoteCacheControl: string } export function loadGatewayConfig(): GatewayConfig { return { backendUpstream: new URL(process.env.GATEWAY_BACKEND_UPSTREAM ?? "http://localhost:3001"), host: process.env.GATEWAY_HOST ?? "0.0.0.0", + imgproxyUpstream: new URL(process.env.GATEWAY_IMGPROXY_UPSTREAM ?? process.env.IMGPROXY_UPSTREAM ?? "http://localhost:18080"), l1MaxEntries: parsePositiveInteger(process.env.GATEWAY_L1_MAX_ENTRIES, 256), l1TtlMs: parsePositiveInteger(process.env.GATEWAY_L1_TTL_MS, 10 * 60 * 1000), port: parsePort(process.env.GATEWAY_PORT, 8888), + remoteCacheControl: process.env.GATEWAY_REMOTE_CACHE_CONTROL ?? "public, max-age=86400, stale-while-revalidate=604800", } } diff --git a/apps/gateway/src/server.ts b/apps/gateway/src/server.ts index 6f55f67..f1e05f3 100644 --- a/apps/gateway/src/server.ts +++ b/apps/gateway/src/server.ts @@ -1,29 +1,54 @@ import Fastify, { type FastifyReply } from "fastify" import { ImageTransformConfigError, + isAllowedSourceHost, + loadAllowedSourceHostsFromEnv, normalizeImageTransform, parseBooleanFlag, selectFormatForAccept, type ActualImageFormat, type ResizeMode, } from "@image-platform/image-config" +import { createHash } from "node:crypto" +import { isIP } from "node:net" import type { GatewayConfig } from "./config.js" import { ImageMemoryCache, type CachedImage } from "./image-cache.js" import { proxyToUpstream } from "./proxy.js" +const FAVICON_SVG = `` + export function createGatewayServer(config: GatewayConfig) { const app = Fastify({ logger: true, }) const imageCache = new ImageMemoryCache(config.l1MaxEntries, config.l1TtlMs) const allowCustomTransforms = parseBooleanFlag(process.env.IMAGE_ALLOW_CUSTOM_TRANSFORMS, false) + const allowedSourceHosts = loadAllowedSourceHostsFromEnv() + const allowUnregisteredHosts = parseBooleanFlag(process.env.SOURCE_HOST_ALLOW_ALL, false) + const allowPrivateSourceNetworks = parseBooleanFlag(process.env.SOURCE_ALLOW_PRIVATE_NETWORKS, false) app.get("/health", async () => ({ service: "image-platform-gateway", status: "ok", })) + app.get("/favicon.ico", async (_request, reply) => + reply + .code(200) + .header("cache-control", "no-store") + .header("content-type", "image/svg+xml; charset=utf-8") + .send(FAVICON_SVG), + ) + + app.get("/favicon.svg", async (_request, reply) => + reply + .code(200) + .header("cache-control", "no-store") + .header("content-type", "image/svg+xml; charset=utf-8") + .send(FAVICON_SVG), + ) + app.get<{ Params: ImageParams; Querystring: ImageQuery }>( "/images/:assetId/:version/:preset", async (request, reply) => { @@ -147,6 +172,130 @@ export function createGatewayServer(config: GatewayConfig) { }, ) + app.get<{ Params: RemoteImageParams; Querystring: RemoteImageQuery }>( + "/p/:project/remote/:preset", + async (request, reply) => { + if (!isSafeProjectSlug(request.params.project)) { + return reply.code(400).send({ + message: "project must be 3-128 chars and contain only letters, digits, _ or -", + statusCode: 400, + }) + } + + const source = normalizeRemoteSourceUrl(request.query.src, { + allowedHosts: allowedSourceHosts, + allowPrivateNetworks: allowPrivateSourceNetworks, + allowUnregisteredHosts, + }) + + if (!source.ok) { + return reply.code(400).send({ + message: source.message, + statusCode: 400, + }) + } + + const width = parseOptionalInteger(request.query.w) + const height = parseOptionalNonNegativeInteger(request.query.h) + const quality = parseOptionalInteger(request.query.q) + const resize = parseResizeMode(request.query.fit) + + if ( + (request.query.w !== undefined && width === null) || + (request.query.h !== undefined && height === null) || + (request.query.q !== undefined && quality === null) + ) { + return reply.code(400).send({ + message: "w, h and q query params must be positive integers", + statusCode: 400, + }) + } + + if (request.query.fit !== undefined && resize === null) { + return reply.code(400).send({ + message: "fit query param must be fit or fill", + statusCode: 400, + }) + } + + const format = selectFormat({ + acceptHeader: request.headers.accept, + allowCustomTransforms, + preset: request.params.preset, + requestedFormat: request.query.f ?? "auto", + }) + + if (!format.ok) { + return reply.code(400).send({ + message: format.message, + statusCode: 400, + }) + } + + const transform = normalizeTransform({ + allowCustomTransforms, + format: format.value.format, + height, + preset: request.params.preset, + quality, + requestedFormat: format.value.requestedFormat, + resize, + width, + }) + + if (!transform.ok) { + return reply.code(400).send({ + message: transform.message, + statusCode: 400, + }) + } + + const cacheKey = buildRemoteImageCacheKey({ + format: transform.value.format, + height: transform.value.height, + preset: transform.value.preset, + project: request.params.project, + quality: transform.value.quality, + resize: transform.value.resize, + sourceUrl: source.value, + width: transform.value.width, + }) + const cached = imageCache.get(cacheKey) + const vary = transform.value.requestedFormat === "auto" ? "Accept" : null + + if (cached) { + return sendImage(reply, cached, "HIT", vary) + } + + const imgproxyUrl = buildImgproxyUrl(config.imgproxyUpstream, source.value, { + format: transform.value.format, + height: transform.value.height, + quality: transform.value.quality, + resize: transform.value.resize, + width: transform.value.width, + }) + const imgproxyResponse = await fetch(imgproxyUrl) + + if (!imgproxyResponse.ok) { + return reply.code(502).header("cache-control", "no-store").send({ + message: `imgproxy returned ${imgproxyResponse.status}`, + statusCode: 502, + }) + } + + const image: CachedImage = { + body: Buffer.from(await imgproxyResponse.arrayBuffer()), + cacheControl: imgproxyResponse.headers.get("cache-control") ?? config.remoteCacheControl, + contentType: imgproxyResponse.headers.get("content-type") ?? contentTypeForFormat(transform.value.format), + etag: imgproxyResponse.headers.get("etag"), + } + + imageCache.set(cacheKey, image) + + return sendImage(reply, image, "MISS", vary) + }, + ) + app.all("/api/*", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) app.all("/docs", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) app.all("/docs/*", async (request, reply) => proxyToUpstream(request, reply, config.backendUpstream)) @@ -170,6 +319,10 @@ type ImageQuery = { w?: string } +type RemoteImageQuery = ImageQuery & { + src?: string +} + type ImageCacheKeyInput = { assetId: string format: ActualImageFormat @@ -187,6 +340,22 @@ type ImageParams = { version: string } +type RemoteImageParams = { + preset: string + project: string +} + +type RemoteImageCacheKeyInput = Omit & { + project: string + sourceUrl: string +} + +type RemoteSourceValidationOptions = { + allowedHosts: ReadonlySet + allowPrivateNetworks: boolean + allowUnregisteredHosts: boolean +} + function parseVersionParam(value: string) { if (!value.startsWith("v")) { return null @@ -258,6 +427,143 @@ function buildImageCacheKey(input: ImageCacheKeyInput) { ].join(":") } +function buildRemoteImageCacheKey(input: RemoteImageCacheKeyInput) { + const sourceHash = createHash("sha256").update(input.sourceUrl).digest("hex").slice(0, 32) + + return [ + "remote", + input.project, + sourceHash, + input.preset, + input.width, + input.height, + input.resize, + input.quality, + input.format, + ].join(":") +} + +function normalizeRemoteSourceUrl(value: string | undefined, options: RemoteSourceValidationOptions) { + if (!value) { + return { message: "src query param is required", ok: false as const } + } + + if (value.length > 4096) { + return { message: "src query param is too long", ok: false as const } + } + + let url: URL + + try { + url = new URL(value) + } catch { + return { message: "src must be an absolute http or https URL", ok: false as const } + } + + if (url.protocol !== "http:" && url.protocol !== "https:") { + return { message: "src must use http or https protocol", ok: false as const } + } + + if (url.username || url.password) { + return { message: "src must not contain credentials", ok: false as const } + } + + if (!options.allowPrivateNetworks && isPrivateSourceHostname(url.hostname)) { + return { message: "src host must not be localhost or private network address", ok: false as const } + } + + if (!options.allowUnregisteredHosts && !isAllowedSourceHost(url.hostname, options.allowedHosts)) { + return { message: "src host is not allowed", ok: false as const } + } + + url.hash = "" + + return { ok: true as const, value: url.toString() } +} + +function isSafeProjectSlug(value: string) { + return /^[a-zA-Z0-9_-]{3,128}$/.test(value) +} + +function isPrivateSourceHostname(hostname: string) { + const normalized = hostname.toLowerCase() + + if (normalized === "localhost" || normalized.endsWith(".localhost")) { + return true + } + + if (isIP(normalized) === 4) { + return isPrivateIpv4(normalized) + } + + if (isIP(normalized) === 6) { + return isPrivateIpv6(normalized) + } + + return false +} + +function isPrivateIpv4(value: string) { + const parts = value.split(".").map((part) => Number.parseInt(part, 10)) + const [first, second] = parts + + if (first === undefined || second === undefined) { + return true + } + + return ( + first === 0 || + first === 10 || + first === 127 || + (first === 169 && second === 254) || + (first === 172 && second >= 16 && second <= 31) || + (first === 192 && second === 168) + ) +} + +function isPrivateIpv6(value: string) { + const normalized = value.toLowerCase() + + return normalized === "::1" || normalized.startsWith("fc") || normalized.startsWith("fd") || normalized.startsWith("fe80:") +} + +function buildImgproxyUrl( + upstream: URL, + sourceUrl: string, + options: { + format: ActualImageFormat + height: number + quality: number + resize: ResizeMode + width: number + }, +) { + const url = new URL(upstream) + const encodedSource = Buffer.from(sourceUrl).toString("base64url") + url.pathname = joinUrlPath( + url.pathname, + "insecure", + `rs:${options.resize}:${options.width}:${options.height}`, + `q:${options.quality}`, + `${encodedSource}.${options.format}`, + ) + + return url +} + +function joinUrlPath(...segments: string[]) { + return segments + .flatMap((segment) => segment.split("/")) + .filter(Boolean) + .map(encodePathSegment) + .join("/") + .replace(/^/, "/") +} + +function encodePathSegment(segment: string) { + return segment.includes(":") ? segment : encodeURIComponent(segment) +} + function normalizeTransform(input: Parameters[0]) { try { return { ok: true as const, value: normalizeImageTransform(input) } diff --git a/docs/architecture.md b/docs/architecture.md index 03f7e0f..5efa4d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -4,6 +4,8 @@ `image-platform` - отдельный control plane для своего Cloudinary-like image pipeline. +Проект эволюционирует в Assets Delivery Platform: изображения остаются первым production-ready vertical slice, а будущие типы ассетов будут добавляться отдельными модулями со своими worker-пайплайнами. Концепция закреплена в `docs/assets-delivery-platform.md`. + Проект отвечает за metadata, S3 artifacts, variants, allowlist, presets и генерацию изображений через внешний `imgproxy`. ## Компоненты diff --git a/docs/assets-delivery-platform.md b/docs/assets-delivery-platform.md new file mode 100644 index 0000000..534f04c --- /dev/null +++ b/docs/assets-delivery-platform.md @@ -0,0 +1,214 @@ +# Assets Delivery Platform + +## Концепция + +`image-platform` эволюционирует из платформы для изображений в Assets Delivery Platform. + +Платформа должна быть control plane и delivery layer для загрузки, обработки, версионирования и доставки ассетов. Первый поддерживаемый тип ассетов - изображения. Текущие наработки по image pipeline остаются основой первого production-ready vertical slice. + +## Продуктовая цель + +Пользователь должен иметь возможность управлять ассетами через кабинет и программно через API. + +Клиентский backend должен уметь без участия UI: + +- загрузить изображение; +- выбрать preset обработки; +- запустить build; +- получить статус обработки; +- получить готовые delivery URL; +- использовать публичные URL в приложении, CMS, магазине или любом другом сервисе. + +Платформа должна быть не только оптимизатором изображений, а headless-сервисом доставки ассетов с UI для управления. + +## Первый vertical slice: Images + +Первый модуль платформы - изображения. + +В рамках images сохраняются текущие архитектурные решения: + +- `Gateway` как публичный image origin; +- read-through delivery flow; +- `Backend` как orchestration/control plane; +- `PostgreSQL` как источник правды для metadata, statuses и variants; +- `S3/MinIO` как хранилище originals и generated variants; +- `RabbitMQ` как очередь задач; +- `Worker` как исполнитель image processing; +- внешний `imgproxy` как CPU-heavy image processor; +- versioned immutable public URLs; +- presets и variants; +- `f=auto` с negotiation по `Accept` header. + +Текущий публичный URL для managed images остаётся базовым delivery contract: + +```text +GET /images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto +``` + +## Кабинет пользователя + +На первом этапе кабинет показывает только раздел работы с изображениями. + +Пользователь должен иметь возможность: + +- загружать изображения; +- создавать и редактировать image presets; +- смотреть список загруженных изображений; +- смотреть версии, variants, статусы обработки и готовые URL; +- выпускать API-ключи для проекта. + +В будущем кабинет расширяется разделами: + +- Images; +- Videos; +- Sprites; +- Fonts; +- другие типы ассетов при появлении продуктовой необходимости. + +## Projects + +`Project` становится основной областью изоляции. + +К проекту должны относиться: + +- assets; +- presets; +- builds; +- variants/results; +- API keys; +- лимиты; +- настройки delivery; +- allowlist источников; +- usage/billing metrics, если они появятся. + +На первом этапе можно развивать images внутри проекта, не создавая преждевременно универсальную модель для всех типов ассетов. + +## API Keys + +Для каждого проекта пользователь может выпускать API-ключи. + +API-ключ нужен для server-side интеграций, где backend клиента программно добавляет файлы, запускает обработку и получает результаты. + +Ключ должен храниться безопасно: + +- secret показывается пользователю только при создании; +- в базе хранится hash секрета; +- в UI отображается только prefix/identifier; +- ключ можно отозвать; +- ключ может иметь scopes; +- желательно хранить дату последнего использования. + +Базовые scopes: + +```text +assets:read +assets:write +assets:delete +presets:read +presets:write +builds:read +builds:write +``` + +Delivery URLs остаются публичными и не требуют `Authorization`, если конкретный проект не включает приватный delivery mode. + +## Headless API + +Платформа должна предоставлять публичный management API для backend-клиентов. + +Минимальный image API первого этапа: + +```text +POST /api/v1/images +GET /api/v1/images +GET /api/v1/images/{id} +DELETE /api/v1/images/{id} + +POST /api/v1/images/{id}/builds +GET /api/v1/images/{id}/builds +GET /api/v1/images/{id}/results + +GET /api/v1/image-presets + +POST /api/v1/project-api-keys +GET /api/v1/project-api-keys +DELETE /api/v1/project-api-keys/{id} +``` + +API должен поддерживать загрузку файла напрямую, а не только регистрацию внешнего `sourceUrl`. + +Пример server-side сценария: + +```text +1. Backend клиента отправляет изображение в API платформы с project API key. +2. Платформа сохраняет original, создаёт asset и version. +3. Backend клиента указывает preset или запускает build. +4. Worker генерирует variants/results. +5. Backend клиента получает готовые public delivery URL. +``` + +## Builds и Results + +`Build` описывает запуск обработки ассета по preset или transform config. + +`Result` или `Variant` описывает готовый артефакт, который можно доставлять через public URL. + +Для images текущая сущность `image_variants` уже выполняет роль результата обработки. При развитии API можно добавить explicit build layer, не ломая текущий read-through delivery flow. + +## Realtime transforms и cropping + +Платформа должна поддерживать два режима обработки изображений: + +- preset builds - заранее заданные и ограниченные variants; +- realtime transforms - динамические resize/crop/format/quality операции через delivery URL. + +Realtime crop должен быть ограничен правилами проекта и preset/custom transform config, чтобы пользователь не мог бесконтрольно создавать произвольные дорогие трансформации. + +Первый запрос на dynamic transform может генерировать результат через worker/imgproxy и сохранять его в storage/cache. Следующие запросы должны отдавать уже готовый артефакт. + +Пример будущего dynamic transform URL: + +```text +GET /images/{assetId}/v{version}/custom?w=800&h=600&fit=fill&crop=center&f=auto&q=80 +``` + +Параметры, влияющие на bytes, должны входить в deterministic variant hash и S3 key. + +## Workers + +Для каждого типа ассетов предусматривается специализированный worker. + +Общий orchestration остаётся в backend, database, queue и storage. Worker конкретного типа отвечает за инструменты обработки этого типа. + +Планируемая модель: + +```text +image-worker -> imgproxy / sharp / imagemagick +video-worker -> ffmpeg +font-worker -> fonttools / subset tools +sprite-worker -> svg/css sprite builder +``` + +На первом этапе реализуется и развивается `image-worker`. Остальные worker'ы добавляются только при появлении соответствующих продуктовых задач. + +## Архитектурный принцип + +Не нужно преждевременно строить универсальный engine для всех возможных ассетов. + +Правильное направление: + +- делать images как первый полноценный модуль; +- общие сущности называть так, чтобы они не блокировали будущие типы ассетов; +- выносить в общий слой только реально общие части: projects, API keys, queue orchestration, storage contract, statuses, delivery concepts; +- типоспецифичную обработку держать внутри конкретного модуля. + +Примеры naming direction: + +```text +Project вместо ImageProject +ProjectApiKey вместо ImageApiKey +ProcessingJob вместо ImageWorkerJob +AssetBuild вместо ImageBuild, если build станет общим понятием +``` + +При этом текущие `image_assets`, `image_asset_versions` и `image_variants` могут оставаться конкретными image-таблицами, пока images являются единственным реализованным типом ассетов. diff --git a/docs/imgproxy-contract.md b/docs/imgproxy-contract.md index cddd965..ae5dd5d 100644 --- a/docs/imgproxy-contract.md +++ b/docs/imgproxy-contract.md @@ -63,7 +63,7 @@ Final signed URL: ## Security rules - Не отдавать `IMGPROXY_KEY` и `IMGPROXY_SALT` в браузер. -- Source URL валидировать в Backend/worker. +- Source URL валидировать в Backend/worker для managed assets и в Gateway для remote source mode. - Разрешать только `http` и `https`. - Запрещать localhost, private IP, loopback, link-local. - Source host должен быть разрешён mock allowlist `SOURCE_ALLOWED_HOSTS`; таблица `allowed_image_hosts` остаётся для будущего CRUD. diff --git a/docs/next-image-provider.md b/docs/next-image-provider.md index 2f5e01b..2122fe5 100644 --- a/docs/next-image-provider.md +++ b/docs/next-image-provider.md @@ -12,7 +12,9 @@ Next.js custom loader получает только: Поэтому public image URL должен принимать эти параметры напрямую и сам запускать read-through генерацию при miss. -## Loader config +## Remote source loader config + +Remote source mode нужен для сценария, где consumer project уже имеет изображение в `public` или внешний image URL и хочет получить `srcset` без предварительной регистрации asset. В Next.js приложении используется `loaderFile`: @@ -32,14 +34,14 @@ module.exports = { ```js "use client" -const imageBaseUrl = process.env.NEXT_PUBLIC_IMAGE_PLATFORM_URL +import { createImagePlatformNextLoader } from "@image-platform/client" -export default function imagePlatformLoader({ src, width, quality }) { - const normalizedSrc = src.startsWith("/") ? src.slice(1) : src - const q = quality || 80 - - return `${imageBaseUrl}/images/${normalizedSrc}?w=${width}&q=${q}&f=auto` -} +export default createImagePlatformNextLoader({ + baseUrl: process.env.NEXT_PUBLIC_IMAGE_PLATFORM_URL, + preset: "card", + project: process.env.NEXT_PUBLIC_IMAGE_PLATFORM_PROJECT, + sourceBaseUrl: process.env.NEXT_PUBLIC_SITE_ORIGIN, +}) ``` Пример использования: @@ -48,12 +50,28 @@ export default function imagePlatformLoader({ src, width, quality }) { import Image from "next/image" export function ProductCard() { - return Product + return Product } ``` +Если `src` относительный, `sourceBaseUrl` превращает его в абсолютный source URL, например `https://site.example.com/images/product.jpg`. Если `src` уже абсолютный, он передаётся как есть. + ## Public URL +Remote source URL: + +```text +GET /p/{project}/remote/{preset}?src={sourceUrl}&w={width}&q={quality}&f=auto +``` + +Пример: + +```text +https://img.example.com/p/acme/remote/card?src=https%3A%2F%2Fsite.example.com%2Fimages%2Fproduct.jpg&w=640&q=80&f=auto +``` + +Managed asset URL: + ```text GET /images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto ``` @@ -66,10 +84,12 @@ Route реализован в Fastify Gateway. Для `card` ширина дол https://img.example.com/images/asset_123/v4/card?w=640&q=80&f=auto ``` -`src` не должен быть source URL. Это должен быть versioned platform identifier, например `asset_123/v4/card`. Source URL хранится в PostgreSQL и не раскрывается в public image URL. +Для managed asset `src` не должен быть source URL. Это должен быть versioned platform identifier, например `asset_123/v4/card`. Source URL хранится в PostgreSQL и не раскрывается в public image URL. `v{version}` меняется при обновлении source image. Старые URL можно кэшировать как immutable без purge. +Для remote source `src` является исходным URL. Этот режим не immutable по умолчанию: Gateway отдаёт `GATEWAY_REMOTE_CACHE_CONTROL`, потому что источник может быть mutable. + ## Format auto `f=auto` выбирает output format по `Accept` header: diff --git a/docs/unpic-react-provider.md b/docs/unpic-react-provider.md new file mode 100644 index 0000000..f8f1466 --- /dev/null +++ b/docs/unpic-react-provider.md @@ -0,0 +1,60 @@ +# @unpic/react Provider + +`@unpic/react` интегрируется через base component и custom transformer из `@image-platform/client`. + +## Remote source mode + +Remote source mode принимает обычный image `src` из проекта или внешний URL и генерирует responsive `srcset` через Gateway: + +```text +GET /p/{project}/remote/{preset}?src={sourceUrl}&w={width}&q={quality}&f=auto +``` + +## Usage + +```tsx +import { Image } from "@unpic/react/base" +import { imagePlatformUnpicTransformer } from "@image-platform/client" + +export function ProductImage() { + return ( + Product + ) +} +``` + +Если `src` относительный, `sourceBaseUrl` превращает его в абсолютный source URL. Если `src` уже абсолютный, он используется без изменений. + +## Breakpoints + +Static presets принимают только разрешённые widths. Для `card` это `320`, `640`, `960`, поэтому consumer должен передать compatible `breakpoints` или использовать Next/Unpic config, который не генерирует лишние widths. + +```tsx +Product +``` + +## Auth + +Image delivery URL публичный и не использует `Authorization`. Management API tokens нужны только server-side для создания assets, versions и variants. diff --git a/package.json b/package.json index 96ff3a1..fc024f5 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,8 @@ "backend:dev": "pnpm image-config:build && pnpm db:build && pnpm queue:build && pnpm storage:build && pnpm --filter @image-platform/backend dev", "backend:start": "pnpm image-config:build && pnpm db:build && pnpm queue:build && pnpm storage:build && pnpm --filter @image-platform/backend start", "backend:typecheck": "pnpm --filter @image-platform/backend typecheck", + "client:build": "pnpm --filter @image-platform/client build", + "client:typecheck": "pnpm --filter @image-platform/client typecheck", "db:build": "pnpm --filter @image-platform/database build", "db:generate": "pnpm --filter @image-platform/database db:generate", "db:migrate": "pnpm --filter @image-platform/database db:migrate", @@ -41,6 +43,6 @@ "infra:up": "docker compose -f infra/compose.dev.yml up -d", "infra:down": "docker compose -f infra/compose.dev.yml down", "infra:logs": "docker compose -f infra/compose.dev.yml logs -f", - "check": "pnpm infra:config && pnpm image-config:typecheck && pnpm db:typecheck && pnpm queue:typecheck && pnpm storage:typecheck && pnpm backend:typecheck && pnpm admin:typecheck && pnpm gateway:typecheck && pnpm worker:typecheck" + "check": "pnpm infra:config && pnpm image-config:typecheck && pnpm client:typecheck && pnpm db:typecheck && pnpm queue:typecheck && pnpm storage:typecheck && pnpm backend:typecheck && pnpm admin:typecheck && pnpm gateway:typecheck && pnpm worker:typecheck" } } diff --git a/packages/client/package.json b/packages/client/package.json new file mode 100644 index 0000000..16ae824 --- /dev/null +++ b/packages/client/package.json @@ -0,0 +1,21 @@ +{ + "name": "@image-platform/client", + "version": "0.1.0", + "private": true, + "exports": { + ".": { + "types": "./src/index.ts", + "require": "./dist/index.js", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "types": "./src/index.ts", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit -p tsconfig.json" + }, + "devDependencies": { + "typescript": "^6.0.3" + } +} diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts new file mode 100644 index 0000000..2bc71ee --- /dev/null +++ b/packages/client/src/index.ts @@ -0,0 +1,127 @@ +export type ImagePlatformFormat = "auto" | "avif" | "jpg" | "png" | "webp" +export type ImagePlatformResize = "fill" | "fit" + +export type ImagePlatformRemoteUrlInput = { + baseUrl: string + fit?: ImagePlatformResize + format?: ImagePlatformFormat + height?: number | null + preset: string + project: string + quality?: number | null + sourceBaseUrl?: string + src: string + width?: number | null +} + +export type ImagePlatformNextLoaderOptions = { + baseUrl: string + defaultQuality?: number + fit?: ImagePlatformResize + format?: ImagePlatformFormat + preset: string + project: string + sourceBaseUrl?: string +} + +export type ImagePlatformNextLoaderParams = { + quality?: number + src: string + width: number +} + +export type ImagePlatformOperations = { + fit?: ImagePlatformResize + format?: ImagePlatformFormat + height?: number + quality?: number + width?: number +} + +export type ImagePlatformUnpicOptions = { + baseUrl: string + defaultQuality?: number + fit?: ImagePlatformResize + format?: ImagePlatformFormat + preset: string + project: string + sourceBaseUrl?: string +} + +export function buildImagePlatformRemoteUrl(input: ImagePlatformRemoteUrlInput) { + const sourceUrl = resolveImagePlatformSourceUrl(input.src, input.sourceBaseUrl) + const url = new URL(`/p/${encodePathSegment(input.project)}/remote/${encodePathSegment(input.preset)}`, input.baseUrl) + + url.searchParams.set("src", sourceUrl) + + if (input.width) { + url.searchParams.set("w", input.width.toString()) + } + + if (input.height) { + url.searchParams.set("h", input.height.toString()) + } + + if (input.quality) { + url.searchParams.set("q", input.quality.toString()) + } + + if (input.fit) { + url.searchParams.set("fit", input.fit) + } + + url.searchParams.set("f", input.format ?? "auto") + + return url.toString() +} + +export function createImagePlatformNextLoader(options: ImagePlatformNextLoaderOptions) { + return function imagePlatformNextLoader(params: ImagePlatformNextLoaderParams) { + return buildImagePlatformRemoteUrl({ + baseUrl: options.baseUrl, + fit: options.fit, + format: options.format, + preset: options.preset, + project: options.project, + quality: params.quality ?? options.defaultQuality, + sourceBaseUrl: options.sourceBaseUrl, + src: params.src, + width: params.width, + }) + } +} + +export function imagePlatformUnpicTransformer( + src: string, + operations: ImagePlatformOperations, + options: ImagePlatformUnpicOptions, +) { + return buildImagePlatformRemoteUrl({ + baseUrl: options.baseUrl, + fit: operations.fit ?? options.fit, + format: operations.format ?? options.format, + height: operations.height, + preset: options.preset, + project: options.project, + quality: operations.quality ?? options.defaultQuality, + sourceBaseUrl: options.sourceBaseUrl, + src, + width: operations.width, + }) +} + +export function resolveImagePlatformSourceUrl(src: string, sourceBaseUrl?: string) { + try { + return new URL(src).toString() + } catch { + if (!sourceBaseUrl) { + throw new Error("sourceBaseUrl is required for relative image src") + } + + return new URL(src, sourceBaseUrl).toString() + } +} + +function encodePathSegment(value: string) { + return encodeURIComponent(value).replaceAll("%2D", "-").replaceAll("%5F", "_") +} diff --git a/packages/client/tsconfig.build.json b/packages/client/tsconfig.build.json new file mode 100644 index 0000000..69b2412 --- /dev/null +++ b/packages/client/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false + }, + "exclude": ["dist", "node_modules", "**/*.spec.ts"] +} diff --git a/packages/client/tsconfig.json b/packages/client/tsconfig.json new file mode 100644 index 0000000..189f921 --- /dev/null +++ b/packages/client/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "allowSyntheticDefaultImports": true, + "declaration": true, + "esModuleInterop": true, + "forceConsistentCasingInFileNames": true, + "lib": ["ES2023", "DOM"], + "module": "Node16", + "moduleResolution": "Node16", + "noUncheckedIndexedAccess": true, + "outDir": "./dist", + "rootDir": "./src", + "skipLibCheck": true, + "sourceMap": true, + "strict": true, + "target": "ES2023" + }, + "include": ["src/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/database/drizzle/0002_grey_ser_duncan.sql b/packages/database/drizzle/0002_grey_ser_duncan.sql new file mode 100644 index 0000000..be1f4b0 --- /dev/null +++ b/packages/database/drizzle/0002_grey_ser_duncan.sql @@ -0,0 +1,14 @@ +CREATE TYPE "public"."project_status" AS ENUM('active', 'disabled');--> statement-breakpoint +CREATE TABLE "image_projects" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "slug" text NOT NULL, + "name" text NOT NULL, + "status" "project_status" DEFAULT 'active' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "image_assets" ADD COLUMN "project_id" uuid;--> statement-breakpoint +CREATE UNIQUE INDEX "image_projects_slug_idx" ON "image_projects" USING btree ("slug");--> statement-breakpoint +ALTER TABLE "image_assets" ADD CONSTRAINT "image_assets_project_id_image_projects_id_fk" FOREIGN KEY ("project_id") REFERENCES "public"."image_projects"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "image_assets_project_id_idx" ON "image_assets" USING btree ("project_id"); \ No newline at end of file diff --git a/packages/database/drizzle/meta/0002_snapshot.json b/packages/database/drizzle/meta/0002_snapshot.json new file mode 100644 index 0000000..7c0d49e --- /dev/null +++ b/packages/database/drizzle/meta/0002_snapshot.json @@ -0,0 +1,745 @@ +{ + "id": "f407b041-197f-4277-8c7d-2b5ce920adc5", + "prevId": "9b706710-b809-4324-8632-634884f75166", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.allowed_image_hosts": { + "name": "allowed_image_hosts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "hostname": { + "name": "hostname", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "allowed_image_hosts_hostname_idx": { + "name": "allowed_image_hosts_hostname_idx", + "columns": [ + { + "expression": "hostname", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_asset_versions": { + "name": "image_asset_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_host": { + "name": "source_host", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_hash": { + "name": "source_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "original_s3_key": { + "name": "original_s3_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_asset_versions_asset_version_idx": { + "name": "image_asset_versions_asset_version_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_asset_versions_source_hash_idx": { + "name": "image_asset_versions_source_hash_idx", + "columns": [ + { + "expression": "source_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_asset_versions_asset_id_image_assets_id_fk": { + "name": "image_asset_versions_asset_id_image_assets_id_fk", + "tableFrom": "image_asset_versions", + "tableTo": "image_assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_assets": { + "name": "image_assets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "public_id": { + "name": "public_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "status": { + "name": "status", + "type": "asset_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_assets_public_id_idx": { + "name": "image_assets_public_id_idx", + "columns": [ + { + "expression": "public_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_assets_project_id_idx": { + "name": "image_assets_project_id_idx", + "columns": [ + { + "expression": "project_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_assets_project_id_image_projects_id_fk": { + "name": "image_assets_project_id_image_projects_id_fk", + "tableFrom": "image_assets", + "tableTo": "image_projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_projects": { + "name": "image_projects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "project_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_projects_slug_idx": { + "name": "image_projects_slug_idx", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.image_variants": { + "name": "image_variants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "asset_id": { + "name": "asset_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_version_id": { + "name": "asset_version_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "asset_version": { + "name": "asset_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "preset": { + "name": "preset", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variant_hash": { + "name": "variant_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "requested_format": { + "name": "requested_format", + "type": "requested_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'auto'" + }, + "format": { + "name": "format", + "type": "variant_format", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "resize_mode": { + "name": "resize_mode", + "type": "resize_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'fit'" + }, + "quality": { + "name": "quality", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "s3_key": { + "name": "s3_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "etag": { + "name": "etag", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "variant_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "attempt_count": { + "name": "attempt_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "image_variants_lookup_idx": { + "name": "image_variants_lookup_idx", + "columns": [ + { + "expression": "asset_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "asset_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "preset", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "width", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "height", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resize_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "quality", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "format", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_variants_s3_key_idx": { + "name": "image_variants_s3_key_idx", + "columns": [ + { + "expression": "s3_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_variants_variant_hash_idx": { + "name": "image_variants_variant_hash_idx", + "columns": [ + { + "expression": "variant_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "image_variants_status_idx": { + "name": "image_variants_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "image_variants_asset_id_image_assets_id_fk": { + "name": "image_variants_asset_id_image_assets_id_fk", + "tableFrom": "image_variants", + "tableTo": "image_assets", + "columnsFrom": [ + "asset_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "image_variants_asset_version_id_image_asset_versions_id_fk": { + "name": "image_variants_asset_version_id_image_asset_versions_id_fk", + "tableFrom": "image_variants", + "tableTo": "image_asset_versions", + "columnsFrom": [ + "asset_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.asset_status": { + "name": "asset_status", + "schema": "public", + "values": [ + "active", + "disabled", + "deleted" + ] + }, + "public.project_status": { + "name": "project_status", + "schema": "public", + "values": [ + "active", + "disabled" + ] + }, + "public.requested_format": { + "name": "requested_format", + "schema": "public", + "values": [ + "auto", + "avif", + "webp", + "jpg", + "png" + ] + }, + "public.resize_mode": { + "name": "resize_mode", + "schema": "public", + "values": [ + "fit", + "fill" + ] + }, + "public.variant_format": { + "name": "variant_format", + "schema": "public", + "values": [ + "avif", + "webp", + "jpg", + "png" + ] + }, + "public.variant_status": { + "name": "variant_status", + "schema": "public", + "values": [ + "pending", + "processing", + "ready", + "failed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/drizzle/meta/_journal.json b/packages/database/drizzle/meta/_journal.json index 996df66..9b3ea42 100644 --- a/packages/database/drizzle/meta/_journal.json +++ b/packages/database/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1777973330318, "tag": "0001_familiar_nextwave", "breakpoints": true + }, + { + "idx": 2, + "version": "7", + "when": 1778007484095, + "tag": "0002_grey_ser_duncan", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index a3d6141..f84c13b 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -12,6 +12,7 @@ import { } from "drizzle-orm/pg-core" export const assetStatusEnum = pgEnum("asset_status", ["active", "disabled", "deleted"]) +export const projectStatusEnum = pgEnum("project_status", ["active", "disabled"]) export const variantFormatEnum = pgEnum("variant_format", ["avif", "webp", "jpg", "png"]) export const requestedFormatEnum = pgEnum("requested_format", ["auto", "avif", "webp", "jpg", "png"]) export const resizeModeEnum = pgEnum("resize_mode", ["fit", "fill"]) @@ -34,16 +35,29 @@ export const allowedImageHosts = pgTable( (table) => [uniqueIndex("allowed_image_hosts_hostname_idx").on(table.hostname)], ) +export const imageProjects = pgTable( + "image_projects", + { + id: uuid("id").primaryKey().defaultRandom(), + slug: text("slug").notNull(), + name: text("name").notNull(), + status: projectStatusEnum("status").notNull().default("active"), + ...timestamps, + }, + (table) => [uniqueIndex("image_projects_slug_idx").on(table.slug)], +) + export const imageAssets = pgTable( "image_assets", { id: uuid("id").primaryKey().defaultRandom(), + projectId: uuid("project_id").references(() => imageProjects.id, { onDelete: "restrict" }), publicId: text("public_id").notNull(), currentVersion: integer("current_version").notNull().default(1), status: assetStatusEnum("status").notNull().default("active"), ...timestamps, }, - (table) => [uniqueIndex("image_assets_public_id_idx").on(table.publicId)], + (table) => [uniqueIndex("image_assets_public_id_idx").on(table.publicId), index("image_assets_project_id_idx").on(table.projectId)], ) export const imageAssetVersions = pgTable( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 91e7767..af6c9c5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,6 +31,9 @@ importers: react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + react-router-dom: + specifier: ^7.15.0 + version: 7.15.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) swr: specifier: ^2.4.1 version: 2.4.1(react@19.2.5) @@ -183,6 +186,12 @@ importers: specifier: ^6.0.3 version: 6.0.3 + packages/client: + devDependencies: + typescript: + specifier: ^6.0.3 + version: 6.0.3 + packages/database: dependencies: drizzle-orm: @@ -2858,6 +2867,23 @@ packages: '@types/react': optional: true + react-router-dom@7.15.0: + resolution: {integrity: sha512-VcrVg64Fo8nwBvDscajG8gRTLIuTC6N50nb22l2HOOV4PTOHgoGp8mUjy9wLiHYoYTSYI36tUnXZgasSRFZorQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.15.0: + resolution: {integrity: sha512-HW9vYwuM8f4yx66Izy8xfrzCM+SBJluoZcCbww9A1TySax11S5Vgw6fi3ZjMONw9J4gQwngL7PzkyIpJJpJ7RQ==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + react-style-singleton@2.2.3: resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} engines: {node: '>=10'} @@ -6087,6 +6113,20 @@ snapshots: optionalDependencies: '@types/react': 19.2.14 + react-router-dom@7.15.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-router: 7.15.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + + react-router@7.15.0(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + cookie: 1.1.1 + react: 19.2.5 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.5(react@19.2.5) + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): dependencies: get-nonce: 1.0.1