Compare commits
7 Commits
56d551b43b
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 98295d0569 | |||
| d49449c30c | |||
| 0faa8b9d2d | |||
| 8094535747 | |||
| 6a018826f5 | |||
| 72f9386f57 | |||
| 2c88cc3eca |
@@ -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
|
||||
|
||||
|
||||
4
.templates/business/{{name.kebabCase}}/index.ts
Normal file
4
.templates/business/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { {{name.camelCase}}Factory } from './{{name.kebabCase}}.factory'
|
||||
export type { {{name.pascalCase}}Api } from './types/{{name.kebabCase}}-api.type'
|
||||
export type { {{name.pascalCase}}Deps } from './types/{{name.kebabCase}}-deps.type'
|
||||
export type { {{name.pascalCase}}Factory } from './types/{{name.kebabCase}}-factory.type'
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Публичный runtime API бизнес-модуля {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}Api = Record<string, never>
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Runtime-зависимости бизнес-модуля {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}Deps = Record<string, never>
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { {{name.pascalCase}}Api } from './{{name.kebabCase}}-api.type'
|
||||
|
||||
/**
|
||||
* Фабрика runtime API бизнес-модуля {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}Factory = () => {{name.pascalCase}}Api
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { {{name.pascalCase}}Factory } from './types/{{name.kebabCase}}-factory.type'
|
||||
|
||||
/**
|
||||
* Создаёт runtime API бизнес-модуля {{name.pascalCase}}.
|
||||
*/
|
||||
export const {{name.camelCase}}Factory: {{name.pascalCase}}Factory = () => {
|
||||
return {}
|
||||
}
|
||||
2
.templates/component/{{name.kebabCase}}/index.ts
Normal file
2
.templates/component/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
|
||||
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}-props.type'
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}Params = {
|
||||
/** Содержимое компонента. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type {{name.pascalCase}}Props = RootAttrs & {{name.pascalCase}}Params
|
||||
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}-props.type'
|
||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
||||
|
||||
/**
|
||||
* <Назначение компонента {{name.pascalCase}} в 1 строке>.
|
||||
*
|
||||
* Используется для:
|
||||
* - <сценарий 1>
|
||||
* - <сценарий 2>
|
||||
*/
|
||||
export const {{name.pascalCase}} = (props: {{name.pascalCase}}Props) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Ошибка API {{name.pascalCase}}.
|
||||
*/
|
||||
export class {{name.pascalCase}}Error extends Error {
|
||||
constructor(
|
||||
public readonly status: number,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = '{{name.pascalCase}}Error'
|
||||
}
|
||||
}
|
||||
3
.templates/infra/{{name.kebabCase}}/index.ts
Normal file
3
.templates/infra/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { {{name.pascalCase}}Client, create{{name.pascalCase}}Client } from './{{name.kebabCase}}.client'
|
||||
export { {{name.pascalCase}}Error } from './errors/{{name.kebabCase}}.error'
|
||||
export type { QueryParams } from './types/{{name.kebabCase}}-client.type'
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* Query-параметры API {{name.pascalCase}}.
|
||||
*/
|
||||
export type QueryParams = Record<string, boolean | number | string | null | undefined>
|
||||
@@ -0,0 +1,44 @@
|
||||
import { {{name.pascalCase}}Error } from './errors/{{name.kebabCase}}.error'
|
||||
import type { QueryParams } from './types/{{name.kebabCase}}-client.type'
|
||||
|
||||
/**
|
||||
* REST-клиент {{name.pascalCase}}.
|
||||
*/
|
||||
export class {{name.pascalCase}}Client {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly defaultHeaders: Record<string, string> = {},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Выполняет GET-запрос к API {{name.pascalCase}}.
|
||||
*/
|
||||
async get<T>(path: string, params: QueryParams = {}): Promise<T> {
|
||||
const base = `${this.baseUrl.replace(/\/+$/, '')}/`
|
||||
const url = new URL(path.replace(/^\/+/, ''), base)
|
||||
|
||||
Object.entries(params).forEach(([key, value]) => {
|
||||
if (value !== null && value !== undefined) {
|
||||
url.searchParams.set(key, String(value))
|
||||
}
|
||||
})
|
||||
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...this.defaultHeaders,
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new {{name.pascalCase}}Error(response.status, response.statusText)
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Создаёт REST-клиент {{name.pascalCase}} с заданным base URL.
|
||||
*/
|
||||
export const create{{name.pascalCase}}Client = (baseUrl: string) => new {{name.pascalCase}}Client(baseUrl)
|
||||
2
.templates/layout/{{name.kebabCase}}/index.ts
Normal file
2
.templates/layout/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { {{name.pascalCase}}Layout } from './{{name.kebabCase}}.layout'
|
||||
export type { {{name.pascalCase}}LayoutProps } from './types/{{name.kebabCase}}.type'
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры {{name.pascalCase}}Layout.
|
||||
*/
|
||||
export type {{name.pascalCase}}LayoutParams = {
|
||||
/** Содержимое layout. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type {{name.pascalCase}}LayoutProps = RootAttrs & {{name.pascalCase}}LayoutParams
|
||||
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { {{name.pascalCase}}LayoutProps } from './types/{{name.kebabCase}}.type'
|
||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
||||
|
||||
/**
|
||||
* <Назначение layout {{name.pascalCase}} в 1 строке>.
|
||||
*
|
||||
* Используется для:
|
||||
* - <сценарий 1>
|
||||
* - <сценарий 2>
|
||||
*/
|
||||
export const {{name.pascalCase}}Layout = (props: {{name.pascalCase}}LayoutProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
.templates/module/{{name.kebabCase}}/index.ts
Normal file
2
.templates/module/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
|
||||
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}.type'
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}Params = {
|
||||
/** Содержимое модуля. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type {{name.pascalCase}}Props = RootAttrs & {{name.pascalCase}}Params
|
||||
20
.templates/module/{{name.kebabCase}}/{{name.kebabCase}}.tsx
Normal file
20
.templates/module/{{name.kebabCase}}/{{name.kebabCase}}.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}.type'
|
||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
||||
|
||||
/**
|
||||
* <Назначение компонента {{name.pascalCase}} в 1 строке>.
|
||||
*
|
||||
* Используется для:
|
||||
* - <сценарий 1>
|
||||
* - <сценарий 2>
|
||||
*/
|
||||
export const {{name.pascalCase}} = (props: {{name.pascalCase}}Props) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
.templates/screen/{{name.kebabCase}}/index.ts
Normal file
2
.templates/screen/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { {{name.pascalCase}}Screen } from './{{name.kebabCase}}.screen'
|
||||
export type { {{name.pascalCase}}ScreenProps } from './types/{{name.kebabCase}}.type'
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры экрана {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}ScreenParams = {
|
||||
/** Содержимое экрана. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type {{name.pascalCase}}ScreenProps = RootAttrs & {{name.pascalCase}}ScreenParams
|
||||
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { {{name.pascalCase}}ScreenProps } from './types/{{name.kebabCase}}.type'
|
||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
||||
|
||||
/**
|
||||
* <Назначение экрана {{name.pascalCase}} в 1 строке>.
|
||||
*
|
||||
* Используется для:
|
||||
* - <сценарий 1>
|
||||
* - <сценарий 2>
|
||||
*/
|
||||
export const {{name.pascalCase}}Screen = (props: {{name.pascalCase}}ScreenProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
.templates/store/{{name.kebabCase}}/index.ts
Normal file
2
.templates/store/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { use{{name.pascalCase}}Store } from './{{name.kebabCase}}.store'
|
||||
export type { {{name.pascalCase}}State } from './{{name.kebabCase}}.type'
|
||||
@@ -0,0 +1,9 @@
|
||||
import { create } from 'zustand'
|
||||
import type { {{name.pascalCase}}State } from './{{name.kebabCase}}.type'
|
||||
|
||||
/**
|
||||
* Стор {{name.pascalCase}}.
|
||||
*/
|
||||
export const use{{name.pascalCase}}Store = create<{{name.pascalCase}}State>()(() => ({
|
||||
|
||||
}))
|
||||
@@ -0,0 +1,6 @@
|
||||
/**
|
||||
* Состояние {{name.pascalCase}}.
|
||||
*/
|
||||
export interface {{name.pascalCase}}State {
|
||||
|
||||
}
|
||||
2
.templates/ui/{{name.kebabCase}}/index.ts
Normal file
2
.templates/ui/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
|
||||
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}.type'
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}Params = {
|
||||
/** Содержимое UI-модуля. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type {{name.pascalCase}}Props = RootAttrs & {{name.pascalCase}}Params
|
||||
20
.templates/ui/{{name.kebabCase}}/{{name.kebabCase}}.tsx
Normal file
20
.templates/ui/{{name.kebabCase}}/{{name.kebabCase}}.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}.type'
|
||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
||||
|
||||
/**
|
||||
* <Назначение компонента {{name.pascalCase}} в 1 строке>.
|
||||
*
|
||||
* Используется для:
|
||||
* - <сценарий 1>
|
||||
* - <сценарий 2>
|
||||
*/
|
||||
export const {{name.pascalCase}} = (props: {{name.pascalCase}}Props) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2
.templates/widget/{{name.kebabCase}}/index.ts
Normal file
2
.templates/widget/{{name.kebabCase}}/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { {{name.pascalCase}}Widget } from './{{name.kebabCase}}'
|
||||
export type { {{name.pascalCase}}WidgetProps } from './types/{{name.kebabCase}}.type'
|
||||
@@ -0,0 +1,2 @@
|
||||
.root {
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
|
||||
|
||||
/**
|
||||
* Параметры виджета {{name.pascalCase}}.
|
||||
*/
|
||||
export type {{name.pascalCase}}WidgetParams = {
|
||||
/** Содержимое виджета. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<ComponentPropsWithoutRef<'div'>, 'children'>
|
||||
|
||||
export type {{name.pascalCase}}WidgetProps = RootAttrs & {{name.pascalCase}}WidgetParams
|
||||
20
.templates/widget/{{name.kebabCase}}/{{name.kebabCase}}.tsx
Normal file
20
.templates/widget/{{name.kebabCase}}/{{name.kebabCase}}.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import cl from 'clsx'
|
||||
import type { {{name.pascalCase}}WidgetProps } from './types/{{name.kebabCase}}.type'
|
||||
import styles from './styles/{{name.kebabCase}}.module.css'
|
||||
|
||||
/**
|
||||
* <Назначение виджета {{name.pascalCase}} в 1 строке>.
|
||||
*
|
||||
* Используется для:
|
||||
* - <сценарий 1>
|
||||
* - <сценарий 2>
|
||||
*/
|
||||
export const {{name.pascalCase}}Widget = (props: {{name.pascalCase}}WidgetProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<div {...rootAttrs} className={cl(styles.root, className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
29
AGENTS.md
Normal file
29
AGENTS.md
Normal file
@@ -0,0 +1,29 @@
|
||||
# AGENTS.md
|
||||
|
||||
Этот файл является коротким маршрутизатором по документации агента.
|
||||
|
||||
## Порядок работы
|
||||
|
||||
Перед началом работы агент обязан определить свою роль в таком порядке:
|
||||
|
||||
1. Если пользователь явно указал роль в запросе — использовать её.
|
||||
2. Если доступна переменная окружения `AGENT_ROLE` — использовать её значение.
|
||||
3. Если пользователь не указал роль и `AGENT_ROLE` пуста — использовать роль `developer`.
|
||||
|
||||
Агент не должен читать `.env` ради определения роли. В CI роль передаётся через переменную окружения `AGENT_ROLE`.
|
||||
|
||||
Допустимые роли:
|
||||
|
||||
- `developer` — реализация задач, исправление багов, рефакторинг, настройка проекта.
|
||||
- `reviewer` — ревью кода, поиск ошибок, рисков и регрессий.
|
||||
- `architect` — проектирование архитектуры, модулей, слоёв и технических решений.
|
||||
|
||||
Если определена неизвестная роль, агент обязан сообщить об ошибке конфигурации и уточнить дальнейшие действия.
|
||||
|
||||
После определения роли агент обязан открыть соответствующую инструкцию:
|
||||
|
||||
- `developer` → [DEVELOP.md](./ai/DEVELOP.md)
|
||||
|
||||
Если для определённой роли нет отдельной инструкции, агент обязан сообщить об этом пользователю и уточнить дальнейшие действия.
|
||||
|
||||
`AGENTS.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`.
|
||||
|
||||
45
ai/DEVELOP.md
Normal file
45
ai/DEVELOP.md
Normal file
@@ -0,0 +1,45 @@
|
||||
# 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. Открой `llms.txt`.
|
||||
2. Найди и прочитай архитектуру SLM.
|
||||
3. Найди и прочитай базовые правила.
|
||||
4. Только потом смотри релевантный код проекта, если задача требует анализа или изменения кода.
|
||||
5. Вернись к `llms.txt` и выбери дополнительные разделы под конкретную задачу.
|
||||
6. Только после этого реализовывай.
|
||||
|
||||
Если контекст был сжат, сессия продолжена после паузы или нет уверенности, что архитектура SLM и базовые правила есть в текущем контексте, считай baseline утраченным и прочитай его заново.
|
||||
|
||||
Во время frontend-задачи возвращайся к `llms.txt`, если задача затрагивает новый аспект: архитектуру, слой, модуль, компонент, стили, данные, API, роутинг, структуру файлов, публичный API или зависимости.
|
||||
|
||||
Не заменяй style guide догадками, привычными паттернами или общими практиками.
|
||||
|
||||
Если в style guide не найдено правило или пример для значимого frontend-решения:
|
||||
|
||||
1. Остановись до реализации.
|
||||
2. Сообщи пользователю, что правило не найдено в style guide.
|
||||
3. Кратко опиши, какой вопрос не покрыт.
|
||||
4. Предложи варианты реализации или спроси, как действовать дальше.
|
||||
5. Дождись подтверждения пользователя.
|
||||
6. Только после этого реализовывай.
|
||||
|
||||
Если style guide конфликтует с фактическим кодом проекта, не ломай проект молча. Сообщи о конфликте и предложи безопасный вариант.
|
||||
|
||||
### Backend
|
||||
|
||||
Соблюдай стиль кода существующего приложения.
|
||||
@@ -3,10 +3,11 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<title>Image Platform Admin</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<script type="module" src="/src/app/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -5,19 +5,31 @@
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc -b && vite build",
|
||||
"codegen:backend-api": "npx @gromlab/api-codegen@latest -i http://localhost:3001/docs-json -o src/infra/backend-api/generated -n backend-api.generated",
|
||||
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||
"preview": "vite preview --host 0.0.0.0 --port 5173",
|
||||
"typecheck": "tsc -b"
|
||||
},
|
||||
"dependencies": {
|
||||
"@mantine/core": "^9.1.1",
|
||||
"@mantine/form": "^9.1.1",
|
||||
"@mantine/hooks": "^9.1.1",
|
||||
"@mantine/notifications": "^9.1.1",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.2.5",
|
||||
"react-dom": "^19.2.5"
|
||||
"react-dom": "^19.2.5",
|
||||
"react-router-dom": "^7.15.0",
|
||||
"swr": "^2.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@csstools/postcss-global-data": "^4.0.0",
|
||||
"@types/node": "^24.12.2",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"autoprefixer": "^10.5.0",
|
||||
"postcss-custom-media": "^12.0.1",
|
||||
"postcss-nesting": "^14.0.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^8.0.10"
|
||||
}
|
||||
|
||||
10
apps/admin/postcss.config.mjs
Normal file
10
apps/admin/postcss.config.mjs
Normal file
@@ -0,0 +1,10 @@
|
||||
export default {
|
||||
plugins: {
|
||||
"@csstools/postcss-global-data": {
|
||||
files: ["src/shared/styles/media.css"],
|
||||
},
|
||||
"postcss-custom-media": {},
|
||||
"postcss-nesting": {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
5
apps/admin/public/favicon.svg
Normal file
5
apps/admin/public/favicon.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="16" fill="#191927" />
|
||||
<circle cx="24" cy="24" r="9" fill="#7b4cff" />
|
||||
<path d="M12 48 28 32l10 10 6-7 10 13H12Z" fill="#ffffff" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 238 B |
@@ -1,111 +0,0 @@
|
||||
:root {
|
||||
color: #171411;
|
||||
background: #f7f4ee;
|
||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
font-synthesis: none;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
min-width: 320px;
|
||||
min-height: 100vh;
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 18% 18%, rgba(123, 76, 255, 0.18), transparent 30rem),
|
||||
#f7f4ee;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
min-height: 100vh;
|
||||
padding: 48px 24px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 40px;
|
||||
border: 1px solid #e4ded4;
|
||||
border-radius: 32px;
|
||||
background: rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 22px 80px rgba(40, 32, 21, 0.08);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 16px;
|
||||
color: #7b4cff;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
h1 {
|
||||
max-width: 760px;
|
||||
margin: 0;
|
||||
font-size: clamp(40px, 7vw, 76px);
|
||||
line-height: 0.92;
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 680px;
|
||||
margin: 24px 0 0;
|
||||
color: #73695d;
|
||||
font-size: 18px;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 16px;
|
||||
max-width: 960px;
|
||||
margin: 24px auto 0;
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 150px;
|
||||
padding: 24px;
|
||||
border: 1px solid #e4ded4;
|
||||
border-radius: 24px;
|
||||
background: rgba(255, 255, 255, 0.76);
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.card p {
|
||||
margin: 12px 0 0;
|
||||
color: #73695d;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.app-shell {
|
||||
padding: 24px 16px;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: 28px;
|
||||
border-radius: 24px;
|
||||
}
|
||||
|
||||
.cards {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import "./App.css"
|
||||
|
||||
const cards = [
|
||||
{
|
||||
title: "Assets",
|
||||
description: "Каталог исходных изображений и связанной metadata.",
|
||||
},
|
||||
{
|
||||
title: "Variants",
|
||||
description: "Будущие AVIF/WebP/JPEG variants, presets и статусы генерации.",
|
||||
},
|
||||
{
|
||||
title: "Storage",
|
||||
description: "PostgreSQL как source of truth, S3/MinIO как хранилище bytes.",
|
||||
},
|
||||
]
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<main className="app-shell">
|
||||
<section className="hero">
|
||||
<p className="eyebrow">Image Platform Admin</p>
|
||||
<h1>чистый Vite React TS app</h1>
|
||||
<p className="lead">
|
||||
Это стартовая админка без UI-фреймворков. Дальше сюда добавим управление allowed hosts,
|
||||
assets, variants и presets.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="cards" aria-label="Будущие разделы">
|
||||
{cards.map((card) => (
|
||||
<article className="card" key={card.title}>
|
||||
<h2>{card.title}</h2>
|
||||
<p>{card.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
11
apps/admin/src/app/app-router.tsx
Normal file
11
apps/admin/src/app/app-router.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { AssetDetailPage, NotFoundPage, ProjectAssetsPage, ProjectsPage } from "pages"
|
||||
|
||||
export const AppRouter = () => (
|
||||
<Routes>
|
||||
<Route element={<ProjectsPage />} path="/" />
|
||||
<Route element={<ProjectAssetsPage />} path="/projects/:projectSlug" />
|
||||
<Route element={<AssetDetailPage />} path="/projects/:projectSlug/assets/:publicId" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
)
|
||||
17
apps/admin/src/app/app.tsx
Normal file
17
apps/admin/src/app/app.tsx
Normal file
@@ -0,0 +1,17 @@
|
||||
import { BrowserRouter } from "react-router-dom"
|
||||
import { ThemeProvider } from "infra/theme"
|
||||
import { MainLayout } from "layouts/main"
|
||||
|
||||
import { AppRouter } from "./app-router"
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ThemeProvider>
|
||||
<MainLayout>
|
||||
<AppRouter />
|
||||
</MainLayout>
|
||||
</ThemeProvider>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
18
apps/admin/src/app/main.tsx
Normal file
18
apps/admin/src/app/main.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import "shared/styles/global.css"
|
||||
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import { App } from "./app"
|
||||
|
||||
const root = document.getElementById("root")
|
||||
|
||||
if (!root) {
|
||||
throw new Error("Root element #root not found")
|
||||
}
|
||||
|
||||
createRoot(root).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
27
apps/admin/src/business/assets/assets.factory.ts
Normal file
27
apps/admin/src/business/assets/assets.factory.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
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"
|
||||
|
||||
/**
|
||||
* Создаёт runtime API бизнес-модуля Assets.
|
||||
*/
|
||||
export const assetsFactory: AssetsFactory = () => {
|
||||
return {
|
||||
useAssetOverview,
|
||||
useAssetPicture,
|
||||
useAssetVersions,
|
||||
useAssetsDashboard,
|
||||
useCreateAsset,
|
||||
useCreateAssetVersion,
|
||||
useGenerateAssetVariants,
|
||||
useImagePresets,
|
||||
useProjectAssets,
|
||||
}
|
||||
}
|
||||
10
apps/admin/src/business/assets/config/assets.config.ts
Normal file
10
apps/admin/src/business/assets/config/assets.config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type { ListAssetsParams } from "infra/backend-api"
|
||||
|
||||
export const ASSETS_DASHBOARD_LIST_PARAMS = {
|
||||
limit: "20",
|
||||
offset: "0",
|
||||
} satisfies ListAssetsParams
|
||||
|
||||
export const ASSET_PICTURE_SIZES = "(min-width: 992px) 34vw, 100vw"
|
||||
|
||||
export const ASSET_VARIANTS_POLLING_MS = 2000
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useGetAsset, useGetAssetVariants, type AssetVariantsResponseDto } from "infra/backend-api"
|
||||
|
||||
import { ASSET_VARIANTS_POLLING_MS } from "../config/assets.config"
|
||||
import type { AssetOverview } from "../types/assets-api.type"
|
||||
|
||||
const isRunningVariantStatus = (status: string) => status === "pending" || status === "processing"
|
||||
|
||||
const hasRunningVariants = (variants: AssetVariantsResponseDto["variants"] = []) =>
|
||||
variants.some((variant) => isRunningVariantStatus(variant.status))
|
||||
|
||||
/**
|
||||
* Данные выбранного asset и его variants.
|
||||
*/
|
||||
export const useAssetOverview = (publicId: string | null): AssetOverview => {
|
||||
const assetQuery = useGetAsset(publicId)
|
||||
const variantsQuery = useGetAssetVariants(
|
||||
publicId,
|
||||
assetQuery.data?.currentVersion ? String(assetQuery.data.currentVersion) : undefined,
|
||||
{
|
||||
refreshInterval: (data) => (hasRunningVariants(data?.variants) ? ASSET_VARIANTS_POLLING_MS : 0),
|
||||
},
|
||||
)
|
||||
const variants = variantsQuery.data?.variants ?? []
|
||||
|
||||
const refresh = async () => {
|
||||
await Promise.all([assetQuery.mutate(), variantsQuery.mutate()])
|
||||
}
|
||||
|
||||
return {
|
||||
asset: assetQuery.data ?? null,
|
||||
error: assetQuery.error ?? variantsQuery.error,
|
||||
hasRunningVariants: hasRunningVariants(variants),
|
||||
isLoading: assetQuery.isLoading || variantsQuery.isLoading,
|
||||
isRefreshing: assetQuery.isValidating || variantsQuery.isValidating,
|
||||
refresh,
|
||||
variants,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { useGetAssetPicture } from "infra/backend-api"
|
||||
|
||||
import { ASSET_PICTURE_SIZES } from "../config/assets.config"
|
||||
import type { AssetPicturePreview } from "../types/assets-api.type"
|
||||
|
||||
/**
|
||||
* Picture/srcset preview contract выбранного asset.
|
||||
*/
|
||||
export const useAssetPicture = (publicId: string | null, preset: string | null): AssetPicturePreview => {
|
||||
const pictureQuery = preset ? { preset, sizes: ASSET_PICTURE_SIZES } : null
|
||||
const picture = useGetAssetPicture(publicId, pictureQuery)
|
||||
|
||||
const refresh = async () => {
|
||||
await picture.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
error: picture.error,
|
||||
isLoading: picture.isLoading,
|
||||
isRefreshing: picture.isValidating,
|
||||
picture: picture.data ?? null,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useGetAssetsList, useGetPresets } from "infra/backend-api"
|
||||
|
||||
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
|
||||
import type { AssetsDashboard } from "../types/assets-api.type"
|
||||
|
||||
/**
|
||||
* Данные стартового dashboard по assets и presets.
|
||||
*/
|
||||
export const useAssetsDashboard = (): AssetsDashboard => {
|
||||
const assetsQuery = useGetAssetsList(ASSETS_DASHBOARD_LIST_PARAMS)
|
||||
const presetsQuery = useGetPresets()
|
||||
|
||||
const assets = assetsQuery.data?.assets ?? []
|
||||
const presets = presetsQuery.data?.presets ?? []
|
||||
const allowedSourceHosts = presetsQuery.data?.allowedSourceHosts ?? []
|
||||
|
||||
return {
|
||||
allowedSourceHosts,
|
||||
assets,
|
||||
custom: presetsQuery.data?.custom ?? null,
|
||||
error: assetsQuery.error ?? presetsQuery.error,
|
||||
isLoading: assetsQuery.isLoading || presetsQuery.isLoading,
|
||||
presets,
|
||||
summary: {
|
||||
assets: assets.length,
|
||||
hosts: allowedSourceHosts.length,
|
||||
presets: presets.length,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useState } from "react"
|
||||
import { useSWRConfig } from "swr"
|
||||
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"
|
||||
import type { CreateAssetVersionAction, CreateAssetVersionInput } from "../types/assets-api.type"
|
||||
|
||||
/**
|
||||
* Сценарий создания новой source version.
|
||||
*/
|
||||
export const useCreateAssetVersion = (): CreateAssetVersionAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const createAssetVersion = async (input: CreateAssetVersionInput) => {
|
||||
setError(null)
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const createdVersion = await backendApi.assets.createAssetVersion(
|
||||
{ publicId: input.publicId },
|
||||
{ sourceUrl: input.sourceUrl },
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
|
||||
mutate(getAssetKey(input.publicId)),
|
||||
mutate(getAssetVersionsKey(input.publicId)),
|
||||
mutate(getAssetVariantsKey(input.publicId, String(createdVersion.version))),
|
||||
])
|
||||
|
||||
return createdVersion
|
||||
} catch (caughtError) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createAssetVersion,
|
||||
error,
|
||||
isCreating,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useState } from "react"
|
||||
import { useSWRConfig } from "swr"
|
||||
import { backendApi, getAssetsListKey, getProjectAssetsKey } from "infra/backend-api"
|
||||
|
||||
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
|
||||
import { toError } from "../lib/to-error"
|
||||
import type { CreateAssetAction, CreateAssetInput } from "../types/assets-api.type"
|
||||
|
||||
/**
|
||||
* Сценарий создания asset с обновлением списка.
|
||||
*/
|
||||
export const useCreateAsset = (): CreateAssetAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const createAsset = async (input: CreateAssetInput) => {
|
||||
setError(null)
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
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) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createAsset,
|
||||
error,
|
||||
isCreating,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useState } from "react"
|
||||
import { useSWRConfig } from "swr"
|
||||
import {
|
||||
backendApi,
|
||||
getAssetKey,
|
||||
getAssetVariantsKey,
|
||||
getAssetsListKey,
|
||||
type CreateAssetVariantsRequestDto,
|
||||
} from "infra/backend-api"
|
||||
|
||||
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
|
||||
import { toError } from "../lib/to-error"
|
||||
import type { GenerateAssetVariantsAction, GenerateAssetVariantsInput } from "../types/assets-api.type"
|
||||
|
||||
const toGeneratedRequest = (input: GenerateAssetVariantsInput): CreateAssetVariantsRequestDto => {
|
||||
return {
|
||||
preset: input.preset,
|
||||
format: input.format,
|
||||
formats: input.formats,
|
||||
height: input.height,
|
||||
mode: input.mode,
|
||||
quality: input.quality,
|
||||
resize: input.resize,
|
||||
version: input.version,
|
||||
width: input.width,
|
||||
} as CreateAssetVariantsRequestDto
|
||||
}
|
||||
|
||||
/**
|
||||
* Сценарий постановки generation jobs для variants.
|
||||
*/
|
||||
export const useGenerateAssetVariants = (): GenerateAssetVariantsAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isGenerating, setIsGenerating] = useState(false)
|
||||
|
||||
const generateAssetVariants = async (input: GenerateAssetVariantsInput) => {
|
||||
setError(null)
|
||||
setIsGenerating(true)
|
||||
|
||||
try {
|
||||
const response = await backendApi.assets.createAssetVariants(
|
||||
{ publicId: input.publicId },
|
||||
toGeneratedRequest(input),
|
||||
)
|
||||
|
||||
await Promise.all([
|
||||
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
|
||||
mutate(getAssetKey(input.publicId)),
|
||||
mutate(getAssetVariantsKey(input.publicId, String(response.version))),
|
||||
])
|
||||
|
||||
return response
|
||||
} catch (caughtError) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
error,
|
||||
generateAssetVariants,
|
||||
isGenerating,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
20
apps/admin/src/business/assets/index.ts
Normal file
20
apps/admin/src/business/assets/index.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export { assetsFactory } from "./assets.factory"
|
||||
export type {
|
||||
AssetOverview,
|
||||
AssetPicturePreview,
|
||||
AssetVersionsHistory,
|
||||
AssetsApi,
|
||||
AssetsDashboard,
|
||||
AssetVariantFormat,
|
||||
AssetVariantMode,
|
||||
AssetVariantResize,
|
||||
CreateAssetAction,
|
||||
CreateAssetInput,
|
||||
CreateAssetVersionAction,
|
||||
CreateAssetVersionInput,
|
||||
GenerateAssetVariantsAction,
|
||||
GenerateAssetVariantsInput,
|
||||
ImagePresetsOverview,
|
||||
ProjectAssetsOverview,
|
||||
} from "./types/assets-api.type"
|
||||
export type { AssetsFactory } from "./types/assets-factory.type"
|
||||
2
apps/admin/src/business/assets/lib/to-error.ts
Normal file
2
apps/admin/src/business/assets/lib/to-error.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const toError = (error: unknown) =>
|
||||
error instanceof Error ? error : new Error("Неизвестная ошибка")
|
||||
130
apps/admin/src/business/assets/types/assets-api.type.ts
Normal file
130
apps/admin/src/business/assets/types/assets-api.type.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import type {
|
||||
AssetResponseDto,
|
||||
AssetPictureResponseDto,
|
||||
AssetVariantResponseDto,
|
||||
AssetVersionResponseDto,
|
||||
CreateAssetRequestDto,
|
||||
CreateAssetResponseDto,
|
||||
CreateAssetVersionResponseDto,
|
||||
CreateAssetVariantsResponseDto,
|
||||
PresetResponseDto,
|
||||
PresetsResponseDto,
|
||||
} from "infra/backend-api"
|
||||
|
||||
export type AssetVariantFormat = "avif" | "jpg" | "png" | "webp"
|
||||
export type AssetVariantMode = "family" | "single"
|
||||
export type AssetVariantResize = "fill" | "fit"
|
||||
|
||||
export type AssetOverview = {
|
||||
asset: AssetResponseDto | null
|
||||
error?: Error
|
||||
hasRunningVariants: boolean
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
refresh: () => Promise<void>
|
||||
variants: AssetVariantResponseDto[]
|
||||
}
|
||||
|
||||
export type AssetPicturePreview = {
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
picture: AssetPictureResponseDto | null
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type AssetVersionsHistory = {
|
||||
currentVersion: number | null
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
publicId: string | null
|
||||
refresh: () => Promise<void>
|
||||
versions: AssetVersionResponseDto[]
|
||||
}
|
||||
|
||||
export type ProjectAssetsOverview = {
|
||||
assets: AssetResponseDto[]
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type ImagePresetsOverview = {
|
||||
custom: PresetsResponseDto["custom"] | null
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
presets: PresetResponseDto[]
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type AssetsDashboard = {
|
||||
allowedSourceHosts: string[]
|
||||
assets: AssetResponseDto[]
|
||||
custom: PresetsResponseDto["custom"] | null
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
presets: PresetResponseDto[]
|
||||
summary: {
|
||||
assets: number
|
||||
hosts: number
|
||||
presets: number
|
||||
}
|
||||
}
|
||||
|
||||
export type CreateAssetInput = CreateAssetRequestDto & {
|
||||
projectSlug?: string
|
||||
}
|
||||
|
||||
export type CreateAssetAction = {
|
||||
createAsset: (input: CreateAssetInput) => Promise<CreateAssetResponseDto>
|
||||
error: Error | null
|
||||
isCreating: boolean
|
||||
}
|
||||
|
||||
export type CreateAssetVersionInput = {
|
||||
publicId: string
|
||||
sourceUrl: string
|
||||
}
|
||||
|
||||
export type CreateAssetVersionAction = {
|
||||
createAssetVersion: (input: CreateAssetVersionInput) => Promise<CreateAssetVersionResponseDto>
|
||||
error: Error | null
|
||||
isCreating: boolean
|
||||
}
|
||||
|
||||
export type GenerateAssetVariantsInput = {
|
||||
format?: AssetVariantFormat
|
||||
formats?: AssetVariantFormat[]
|
||||
height?: number
|
||||
mode: AssetVariantMode
|
||||
preset: string
|
||||
publicId: string
|
||||
quality?: number
|
||||
resize?: AssetVariantResize
|
||||
version?: number
|
||||
width?: number
|
||||
}
|
||||
|
||||
export type GenerateAssetVariantsAction = {
|
||||
error: Error | null
|
||||
generateAssetVariants: (input: GenerateAssetVariantsInput) => Promise<CreateAssetVariantsResponseDto>
|
||||
isGenerating: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный runtime API бизнес-модуля Assets.
|
||||
*/
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { AssetsApi } from "./assets-api.type"
|
||||
|
||||
/**
|
||||
* Фабрика runtime API бизнес-модуля Assets.
|
||||
*/
|
||||
export type AssetsFactory = () => AssetsApi
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react"
|
||||
import { useSWRConfig } from "swr"
|
||||
import { backendApi, getProjectsListKey } from "infra/backend-api"
|
||||
|
||||
import { toError } from "../lib/to-error"
|
||||
import type { CreateProjectAction, CreateProjectInput } from "../types/projects-api.type"
|
||||
|
||||
/**
|
||||
* Сценарий создания проекта.
|
||||
*/
|
||||
export const useCreateProject = (): CreateProjectAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const createProject = async (input: CreateProjectInput) => {
|
||||
setError(null)
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const project = await backendApi.projects.createProject(input)
|
||||
await mutate(getProjectsListKey())
|
||||
|
||||
return project
|
||||
} catch (caughtError) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createProject,
|
||||
error,
|
||||
isCreating,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
9
apps/admin/src/business/projects/index.ts
Normal file
9
apps/admin/src/business/projects/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { projectsFactory } from "./projects.factory"
|
||||
export type {
|
||||
CreateProjectAction,
|
||||
CreateProjectInput,
|
||||
ProjectDetail,
|
||||
ProjectsApi,
|
||||
ProjectsHome,
|
||||
} from "./types/projects-api.type"
|
||||
export type { ProjectsFactory } from "./types/projects-factory.type"
|
||||
1
apps/admin/src/business/projects/lib/to-error.ts
Normal file
1
apps/admin/src/business/projects/lib/to-error.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
|
||||
15
apps/admin/src/business/projects/projects.factory.ts
Normal file
15
apps/admin/src/business/projects/projects.factory.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCreateProject } from "./hooks/use-create-project.hook"
|
||||
import { useProjectDetail } from "./hooks/use-project-detail.hook"
|
||||
import { useProjectsHome } from "./hooks/use-projects-home.hook"
|
||||
import type { ProjectsFactory } from "./types/projects-factory.type"
|
||||
|
||||
/**
|
||||
* Создаёт runtime API бизнес-модуля Projects.
|
||||
*/
|
||||
export const projectsFactory: ProjectsFactory = () => {
|
||||
return {
|
||||
useCreateProject,
|
||||
useProjectDetail,
|
||||
useProjectsHome,
|
||||
}
|
||||
}
|
||||
34
apps/admin/src/business/projects/types/projects-api.type.ts
Normal file
34
apps/admin/src/business/projects/types/projects-api.type.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto } from "infra/backend-api"
|
||||
|
||||
export type ProjectsHome = {
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
projects: ProjectResponseDto[]
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type ProjectDetail = {
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
project: ProjectResponseDto | null
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type CreateProjectInput = CreateProjectRequestDto
|
||||
|
||||
export type CreateProjectAction = {
|
||||
createProject: (input: CreateProjectInput) => Promise<ProjectResponseDto>
|
||||
error: Error | null
|
||||
isCreating: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный runtime API бизнес-модуля Projects.
|
||||
*/
|
||||
export type ProjectsApi = {
|
||||
useCreateProject: () => CreateProjectAction
|
||||
useProjectDetail: (projectSlug: string | null) => ProjectDetail
|
||||
useProjectsHome: () => ProjectsHome
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ProjectsApi } from "./projects-api.type"
|
||||
|
||||
/**
|
||||
* Фабрика runtime API бизнес-модуля Projects.
|
||||
*/
|
||||
export type ProjectsFactory = () => ProjectsApi
|
||||
11
apps/admin/src/infra/backend-api/client.ts
Normal file
11
apps/admin/src/infra/backend-api/client.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Api, HttpClient } from "./generated/backend-api.generated"
|
||||
|
||||
const httpClient = new HttpClient({
|
||||
baseApiParams: {
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
export const backendApi = new Api(httpClient)
|
||||
1917
apps/admin/src/infra/backend-api/generated/backend-api.generated.ts
Normal file
1917
apps/admin/src/infra/backend-api/generated/backend-api.generated.ts
Normal file
File diff suppressed because it is too large
Load Diff
10
apps/admin/src/infra/backend-api/hooks/index.ts
Normal file
10
apps/admin/src/infra/backend-api/hooks/index.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export { getAssetPictureKey, useGetAssetPicture } from "./use-get-asset-picture.hook"
|
||||
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"
|
||||
@@ -0,0 +1,33 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetPictureResponseDto, GetAssetPictureParams } from "../generated/backend-api.generated"
|
||||
|
||||
export type AssetPictureQuery = Omit<GetAssetPictureParams, "publicId">
|
||||
|
||||
export const getAssetPictureKey = (publicId: string, query: AssetPictureQuery) =>
|
||||
[
|
||||
"backend-api",
|
||||
"assets",
|
||||
"picture",
|
||||
publicId,
|
||||
query.preset,
|
||||
query.version ?? null,
|
||||
query.quality ?? null,
|
||||
query.sizes ?? null,
|
||||
] as const
|
||||
|
||||
/**
|
||||
* Получение picture/srcset contract asset.
|
||||
*/
|
||||
export const useGetAssetPicture = (
|
||||
publicId: string | null,
|
||||
query: AssetPictureQuery | null,
|
||||
config?: SWRConfiguration<AssetPictureResponseDto>,
|
||||
) => {
|
||||
const key = publicId !== null && query !== null ? getAssetPictureKey(publicId, query) : null
|
||||
const fetcher = () => backendApi.assets.getAssetPicture({ publicId: publicId ?? "", ...(query ?? { preset: "" }) })
|
||||
|
||||
return useSWR<AssetPictureResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetVariantsResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getAssetVariantsKey = (publicId: string, version?: string) =>
|
||||
["backend-api", "assets", "variants", publicId, version ?? null] as const
|
||||
|
||||
/**
|
||||
* Получение variants asset.
|
||||
*/
|
||||
export const useGetAssetVariants = (
|
||||
publicId: string | null,
|
||||
version?: string,
|
||||
config?: SWRConfiguration<AssetVariantsResponseDto>,
|
||||
) => {
|
||||
const key = publicId !== null ? getAssetVariantsKey(publicId, version) : null
|
||||
const fetcher = () => backendApi.assets.listAssetVariants({ publicId: publicId ?? "", version })
|
||||
|
||||
return useSWR<AssetVariantsResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetVersionsResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getAssetVersionsKey = (publicId: string) => ["backend-api", "assets", "versions", publicId] as const
|
||||
|
||||
/**
|
||||
* Получение истории source versions asset.
|
||||
*/
|
||||
export const useGetAssetVersions = (publicId: string | null, config?: SWRConfiguration<AssetVersionsResponseDto>) => {
|
||||
const key = publicId !== null ? getAssetVersionsKey(publicId) : null
|
||||
const fetcher = () => backendApi.assets.listAssetVersions({ publicId: publicId ?? "" })
|
||||
|
||||
return useSWR<AssetVersionsResponseDto>(key, fetcher, config)
|
||||
}
|
||||
17
apps/admin/src/infra/backend-api/hooks/use-get-asset.hook.ts
Normal file
17
apps/admin/src/infra/backend-api/hooks/use-get-asset.hook.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getAssetKey = (publicId: string) => ["backend-api", "assets", "detail", publicId] as const
|
||||
|
||||
/**
|
||||
* Получение asset по publicId.
|
||||
*/
|
||||
export const useGetAsset = (publicId: string | null, config?: SWRConfiguration) => {
|
||||
const key = publicId !== null ? getAssetKey(publicId) : null
|
||||
const fetcher = () => backendApi.assets.getAsset({ publicId: publicId ?? "" })
|
||||
|
||||
return useSWR<AssetResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetsListResponseDto, ListAssetsParams } from "../generated/backend-api.generated"
|
||||
|
||||
export const getAssetsListKey = (params: ListAssetsParams = {}) =>
|
||||
["backend-api", "assets", "list", params.limit ?? null, params.offset ?? null] as const
|
||||
|
||||
/**
|
||||
* Получение списка assets.
|
||||
*/
|
||||
export const useGetAssetsList = (params: ListAssetsParams = {}, config?: SWRConfiguration) => {
|
||||
const key = getAssetsListKey(params)
|
||||
const fetcher = () => backendApi.assets.listAssets(params)
|
||||
|
||||
return useSWR<AssetsListResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { PresetsResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getPresetsKey = () => ["backend-api", "presets"] as const
|
||||
|
||||
/**
|
||||
* Получение presets и custom transform config.
|
||||
*/
|
||||
export const useGetPresets = (config?: SWRConfiguration) => {
|
||||
const fetcher = () => backendApi.presets.getPresets()
|
||||
|
||||
return useSWR<PresetsResponseDto>(getPresetsKey(), fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetsListResponseDto, ListProjectAssetsParams } from "../generated/backend-api.generated"
|
||||
|
||||
export const getProjectAssetsKey = (projectSlug: string, params: Omit<ListProjectAssetsParams, "projectSlug"> = {}) =>
|
||||
["backend-api", "projects", "assets", projectSlug, params.limit ?? null, params.offset ?? null] as const
|
||||
|
||||
/**
|
||||
* Получение assets проекта.
|
||||
*/
|
||||
export const useGetProjectAssets = (
|
||||
projectSlug: string | null,
|
||||
params: Omit<ListProjectAssetsParams, "projectSlug"> = {},
|
||||
config?: SWRConfiguration<AssetsListResponseDto>,
|
||||
) => {
|
||||
const key = projectSlug !== null ? getProjectAssetsKey(projectSlug, params) : null
|
||||
const fetcher = () => backendApi.projects.listProjectAssets({ ...params, projectSlug: projectSlug ?? "" })
|
||||
|
||||
return useSWR<AssetsListResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { ProjectResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getProjectKey = (projectSlug: string) => ["backend-api", "projects", "detail", projectSlug] as const
|
||||
|
||||
/**
|
||||
* Получение проекта по slug.
|
||||
*/
|
||||
export const useGetProject = (projectSlug: string | null, config?: SWRConfiguration<ProjectResponseDto>) => {
|
||||
const key = projectSlug !== null ? getProjectKey(projectSlug) : null
|
||||
const fetcher = () => backendApi.projects.getProject({ projectSlug: projectSlug ?? "" })
|
||||
|
||||
return useSWR<ProjectResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { ProjectsListResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getProjectsListKey = () => ["backend-api", "projects", "list"] as const
|
||||
|
||||
/**
|
||||
* Получение списка проектов.
|
||||
*/
|
||||
export const useGetProjectsList = (config?: SWRConfiguration<ProjectsListResponseDto>) => {
|
||||
const fetcher = () => backendApi.projects.listProjects()
|
||||
|
||||
return useSWR<ProjectsListResponseDto>(getProjectsListKey(), fetcher, config)
|
||||
}
|
||||
26
apps/admin/src/infra/backend-api/index.ts
Normal file
26
apps/admin/src/infra/backend-api/index.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
export { backendApi } from "./client"
|
||||
export * from "./hooks"
|
||||
export type {
|
||||
AssetResponseDto,
|
||||
AssetPictureResponseDto,
|
||||
AssetVariantResponseDto,
|
||||
AssetVariantsResponseDto,
|
||||
AssetVersionResponseDto,
|
||||
AssetVersionsResponseDto,
|
||||
AssetsListResponseDto,
|
||||
CreateAssetRequestDto,
|
||||
CreateAssetResponseDto,
|
||||
CreateAssetVersionRequestDto,
|
||||
CreateAssetVersionResponseDto,
|
||||
CreateAssetVariantsRequestDto,
|
||||
CreateAssetVariantsResponseDto,
|
||||
CreateProjectRequestDto,
|
||||
CustomTransformConfigResponseDto,
|
||||
GetAssetPictureParams,
|
||||
ListAssetsParams,
|
||||
ListProjectAssetsParams,
|
||||
PresetResponseDto,
|
||||
PresetsResponseDto,
|
||||
ProjectResponseDto,
|
||||
ProjectsListResponseDto,
|
||||
} from "./generated/backend-api.generated"
|
||||
25
apps/admin/src/infra/theme/config/theme.config.ts
Normal file
25
apps/admin/src/infra/theme/config/theme.config.ts
Normal file
@@ -0,0 +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: "forest",
|
||||
})
|
||||
2
apps/admin/src/infra/theme/index.ts
Normal file
2
apps/admin/src/infra/theme/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ThemeProvider } from "./theme-provider"
|
||||
export type { ThemeProviderProps } from "./types/theme-provider-props.type"
|
||||
23
apps/admin/src/infra/theme/theme-provider.tsx
Normal file
23
apps/admin/src/infra/theme/theme-provider.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { MantineProvider } from "@mantine/core"
|
||||
import { Notifications } from "@mantine/notifications"
|
||||
|
||||
import { ADMIN_THEME } from "./config/theme.config"
|
||||
import type { ThemeProviderProps } from "./types/theme-provider-props.type"
|
||||
|
||||
/**
|
||||
* Провайдер визуальной темы admin-приложения.
|
||||
*
|
||||
* Используется для:
|
||||
* - подключения Mantine theme
|
||||
* - подключения контейнера уведомлений
|
||||
*/
|
||||
export const ThemeProvider = (props: ThemeProviderProps) => {
|
||||
const { children } = props
|
||||
|
||||
return (
|
||||
<MantineProvider defaultColorScheme="light" theme={ADMIN_THEME}>
|
||||
<Notifications position="top-right" />
|
||||
{children}
|
||||
</MantineProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Параметры ThemeProvider.
|
||||
*/
|
||||
export type ThemeProviderProps = {
|
||||
/** Содержимое приложения. */
|
||||
children?: ReactNode
|
||||
}
|
||||
2
apps/admin/src/layouts/main/index.ts
Normal file
2
apps/admin/src/layouts/main/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { MainLayout } from "./main.layout"
|
||||
export type { MainLayoutProps } from "./types/main.type"
|
||||
38
apps/admin/src/layouts/main/main.layout.tsx
Normal file
38
apps/admin/src/layouts/main/main.layout.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
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"
|
||||
|
||||
/**
|
||||
* Базовый layout админки: задаёт page shell и общую навигационную шапку.
|
||||
*
|
||||
* Используется для:
|
||||
* - оборачивания экранов admin-приложения
|
||||
* - подключения общей структуры страницы
|
||||
*/
|
||||
export const MainLayout = (props: MainLayoutProps) => {
|
||||
const { children, className, ...rootAttrs } = props
|
||||
|
||||
return (
|
||||
<AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md">
|
||||
<AppShell.Header className={styles.header}>
|
||||
<Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}>
|
||||
<Link className={styles.brand} to="/" aria-label="Платформа изображений">
|
||||
<ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light">
|
||||
IP
|
||||
</ThemeIcon>
|
||||
<Text className={styles.brandText} fw={850}>
|
||||
Платформа изображений
|
||||
</Text>
|
||||
</Link>
|
||||
</Group>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Main className={styles.main}>
|
||||
<div className={styles.content}>{children}</div>
|
||||
</AppShell.Main>
|
||||
</AppShell>
|
||||
)
|
||||
}
|
||||
49
apps/admin/src/layouts/main/styles/main.module.css
Normal file
49
apps/admin/src/layouts/main/styles/main.module.css
Normal file
@@ -0,0 +1,49 @@
|
||||
.root {
|
||||
min-height: 100vh;
|
||||
background: var(--color-page);
|
||||
}
|
||||
|
||||
.header {
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
background: var(--color-header);
|
||||
}
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-3);
|
||||
color: var(--color-text);
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.brandMark {
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-accent);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.brandText {
|
||||
display: none;
|
||||
color: var(--color-text);
|
||||
|
||||
@media (--sm) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.main {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content {
|
||||
max-width: 82rem;
|
||||
margin: 0 auto;
|
||||
padding: var(--space-4) 0 var(--space-8);
|
||||
|
||||
@media (--md) {
|
||||
padding-top: var(--space-6);
|
||||
}
|
||||
}
|
||||
15
apps/admin/src/layouts/main/types/main.type.ts
Normal file
15
apps/admin/src/layouts/main/types/main.type.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { AppShellProps } from "@mantine/core"
|
||||
import type { ReactNode } from "react"
|
||||
|
||||
/**
|
||||
* Параметры MainLayout.
|
||||
*/
|
||||
export type MainLayoutParams = {
|
||||
/** Содержимое layout. */
|
||||
children?: ReactNode
|
||||
}
|
||||
|
||||
/** Атрибуты корневого элемента без children. */
|
||||
type RootAttrs = Omit<AppShellProps, "children">
|
||||
|
||||
export type MainLayoutProps = RootAttrs & MainLayoutParams
|
||||
@@ -1,10 +0,0 @@
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
|
||||
import { App } from "./App"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
)
|
||||
16
apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx
Normal file
16
apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Navigate, useParams } from "react-router-dom"
|
||||
import { AssetDetailScreen } from "screens/asset-detail"
|
||||
|
||||
export const AssetDetailPage = () => {
|
||||
const { projectSlug, publicId } = useParams()
|
||||
|
||||
if (!projectSlug) {
|
||||
return <Navigate replace to="/" />
|
||||
}
|
||||
|
||||
if (!publicId) {
|
||||
return <Navigate replace to={`/projects/${projectSlug}`} />
|
||||
}
|
||||
|
||||
return <AssetDetailScreen projectSlug={projectSlug} publicId={publicId} />
|
||||
}
|
||||
1
apps/admin/src/pages/asset-detail-page/index.ts
Normal file
1
apps/admin/src/pages/asset-detail-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssetDetailPage } from "./asset-detail.page"
|
||||
4
apps/admin/src/pages/index.ts
Normal file
4
apps/admin/src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AssetDetailPage } from "./asset-detail-page"
|
||||
export { NotFoundPage } from "./not-found-page"
|
||||
export { ProjectAssetsPage } from "./project-assets-page"
|
||||
export { ProjectsPage } from "./projects-page"
|
||||
1
apps/admin/src/pages/not-found-page/index.ts
Normal file
1
apps/admin/src/pages/not-found-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NotFoundPage } from "./not-found.page"
|
||||
14
apps/admin/src/pages/not-found-page/not-found.page.tsx
Normal file
14
apps/admin/src/pages/not-found-page/not-found.page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Button, Paper, Stack, Text, Title } from "@mantine/core"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export const NotFoundPage = () => (
|
||||
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
|
||||
<Stack align="start" gap="md">
|
||||
<Title order={1}>Страница не найдена</Title>
|
||||
<Text c="dimmed">Такого маршрута в админке нет.</Text>
|
||||
<Button component={Link} radius="xl" to="/">
|
||||
Вернуться к проектам
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
1
apps/admin/src/pages/project-assets-page/index.ts
Normal file
1
apps/admin/src/pages/project-assets-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectAssetsPage } from "./project-assets.page"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user