Compare commits
8 Commits
3ec1e51bea
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 98295d0569 | |||
| d49449c30c | |||
| 0faa8b9d2d | |||
| 8094535747 | |||
| 6a018826f5 | |||
| 72f9386f57 | |||
| 2c88cc3eca | |||
| 56d551b43b |
@@ -29,12 +29,15 @@ PUBLIC_IMAGE_BASE_URL=http://localhost:8888
|
|||||||
|
|
||||||
# Gateway proxies /api and Swagger routes to this upstream.
|
# Gateway proxies /api and Swagger routes to this upstream.
|
||||||
GATEWAY_BACKEND_UPSTREAM=http://localhost:3001
|
GATEWAY_BACKEND_UPSTREAM=http://localhost:3001
|
||||||
|
GATEWAY_IMGPROXY_UPSTREAM=http://localhost:18080
|
||||||
GATEWAY_L1_MAX_ENTRIES=256
|
GATEWAY_L1_MAX_ENTRIES=256
|
||||||
GATEWAY_L1_TTL_MS=600000
|
GATEWAY_L1_TTL_MS=600000
|
||||||
|
GATEWAY_REMOTE_CACHE_CONTROL=public, max-age=86400, stale-while-revalidate=604800
|
||||||
|
|
||||||
# MVP dev mode: mock source host allowlist without DB/admin CRUD.
|
# MVP dev mode: mock source host allowlist without DB/admin CRUD.
|
||||||
SOURCE_HOST_ALLOW_ALL=false
|
SOURCE_HOST_ALLOW_ALL=false
|
||||||
SOURCE_ALLOWED_HOSTS=storage.yandexcloud.net
|
SOURCE_ALLOWED_HOSTS=storage.yandexcloud.net
|
||||||
|
SOURCE_ALLOW_PRIVATE_NETWORKS=false
|
||||||
IMAGE_ALLOW_CUSTOM_TRANSFORMS=true
|
IMAGE_ALLOW_CUSTOM_TRANSFORMS=true
|
||||||
IMAGE_ENSURE_WAIT_MS=15000
|
IMAGE_ENSURE_WAIT_MS=15000
|
||||||
|
|
||||||
|
|||||||
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
|
- Fastify gateway
|
||||||
- worker
|
- worker
|
||||||
|
|
||||||
Gateway принимает `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`, выбирает фактический формат по `Accept`, вызывает Backend ensure endpoint и кэширует готовые bytes в L1 memory cache.
|
Gateway принимает managed assets через `/images/{assetId}/v{version}/{preset}?w={width}&q={quality}&f=auto`, выбирает фактический формат по `Accept`, вызывает Backend ensure endpoint и кэширует готовые bytes в L1 memory cache.
|
||||||
|
|
||||||
|
Gateway также принимает remote source mode для `next/image`/`@unpic/react`: `/p/{project}/remote/{preset}?src={absoluteSourceUrl}&w={width}&q={quality}&f=auto`. В этом режиме source URL проходит allowlist-проверку и трансформируется через imgproxy без предварительной регистрации asset.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
@@ -64,6 +66,7 @@ curl -sS -X POST http://localhost:3001/api/assets \
|
|||||||
|
|
||||||
curl -i "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=auto"
|
curl -i "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=auto"
|
||||||
curl -i "http://localhost:8888/images/asset_demo/v1/avatar?f=auto"
|
curl -i "http://localhost:8888/images/asset_demo/v1/avatar?f=auto"
|
||||||
|
curl -i "http://localhost:8888/p/demo/remote/card?src=https%3A%2F%2Fstorage.yandexcloud.net%2Fshared1318%2Fimg%2F1.jpg&w=640&q=80&f=auto"
|
||||||
```
|
```
|
||||||
|
|
||||||
Сейчас доступны static presets из `packages/image-config`: `card`, `hero`, `avatar`. Произвольный single-image режим доступен через `/images/{assetId}/v{version}/custom?...`, если включён `IMAGE_ALLOW_CUSTOM_TRANSFORMS=true`.
|
Сейчас доступны static presets из `packages/image-config`: `card`, `hero`, `avatar`. Произвольный single-image режим доступен через `/images/{assetId}/v{version}/custom?...`, если включён `IMAGE_ALLOW_CUSTOM_TRANSFORMS=true`.
|
||||||
@@ -73,6 +76,7 @@ Business API без админки:
|
|||||||
```bash
|
```bash
|
||||||
curl -sS http://localhost:3001/api/presets
|
curl -sS http://localhost:3001/api/presets
|
||||||
curl -sS http://localhost:3001/api/assets
|
curl -sS http://localhost:3001/api/assets
|
||||||
|
curl -sS 'http://localhost:3001/api/assets/asset_demo/picture?preset=card&sizes=100vw'
|
||||||
curl -sS http://localhost:3001/api/assets/asset_demo/variants
|
curl -sS http://localhost:3001/api/assets/asset_demo/variants
|
||||||
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \
|
curl -sS -X POST http://localhost:3001/api/assets/asset_demo/versions \
|
||||||
-H 'content-type: application/json' \
|
-H 'content-type: application/json' \
|
||||||
@@ -107,3 +111,4 @@ curl -sS -X POST http://localhost:3001/api/assets/asset_demo/variants \
|
|||||||
- `docs/backend-contract-draft.md` - черновик будущего backend-контракта.
|
- `docs/backend-contract-draft.md` - черновик будущего backend-контракта.
|
||||||
- `docs/imgproxy-contract.md` - контракт с external imgproxy.
|
- `docs/imgproxy-contract.md` - контракт с external imgproxy.
|
||||||
- `docs/next-image-provider.md` - контракт custom provider для `next/image`.
|
- `docs/next-image-provider.md` - контракт custom provider для `next/image`.
|
||||||
|
- `docs/unpic-react-provider.md` - контракт custom transformer для `@unpic/react`.
|
||||||
|
|||||||
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>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
<title>Image Platform Admin</title>
|
<title>Image Platform Admin</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/app/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -5,19 +5,31 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsc -b && vite build",
|
"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",
|
"dev": "vite --host 0.0.0.0 --port 5173",
|
||||||
"preview": "vite preview --host 0.0.0.0 --port 5173",
|
"preview": "vite preview --host 0.0.0.0 --port 5173",
|
||||||
"typecheck": "tsc -b"
|
"typecheck": "tsc -b"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"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": "^19.2.5",
|
||||||
"react-dom": "^19.2.5"
|
"react-dom": "^19.2.5",
|
||||||
|
"react-router-dom": "^7.15.0",
|
||||||
|
"swr": "^2.4.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@csstools/postcss-global-data": "^4.0.0",
|
||||||
"@types/node": "^24.12.2",
|
"@types/node": "^24.12.2",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@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",
|
"typescript": "^5.9.3",
|
||||||
"vite": "^8.0.10"
|
"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