Compare commits

...

7 Commits

Author SHA1 Message Date
98295d0569 feat: добавить новый backend и cabinet
- добавлен новый Nest backend для auth, projects и project access tokens
- добавлена control-plane схема БД и миграция Drizzle
- перенесён старый backend в old-backend
- добавлен React/Vite cabinet с auth-only входом и Mantine layout
- обновлены workspace scripts и lockfile
2026-05-12 09:22:04 +03:00
d49449c30c sync 2026-05-12 07:54:32 +03:00
0faa8b9d2d feat: добавить preview image pipeline в admin
- добавлен polling variants и ручной refresh выбранного asset\n- добавлен picture/srcset preview с выбором preset\n- добавлен URL-state и copy actions для рабочих ссылок
2026-05-05 16:41:20 +03:00
8094535747 feat: добавить workflow управления asset в admin
- добавлены сценарии создания source version и generation jobs\n- добавлены modal формы source version и variants generation\n- обновлена detail panel variants actions и ссылки на public URL
2026-05-05 15:20:24 +03:00
6a018826f5 feat: добавить рабочий dashboard admin
- добавлен Mantine theme provider и AppShell layout\n- сгенерирован Backend API клиент и добавлены infra/business хуки\n- добавлены таблица assets, detail/presets panels и create asset modal
2026-05-05 15:02:55 +03:00
72f9386f57 chore: добавить SLM-каркас admin
- добавлен app entrypoint, main layout и dashboard screen\n- настроены алиасы SLM-слоёв и PostCSS для CSS Modules\n- перенесены глобальные стили в shared/styles
2026-05-05 14:28:17 +03:00
2c88cc3eca chore: добавить frontend правила и шаблоны SLM
- добавлены frontend инструкции AGENTS и локальный style guide
- актуализированы SLM templates под Vite React и слой infra
- добавлены шаблоны component, infra и factory-based business
- нормализованы примеры документации под alias infra
2026-05-05 14:05:43 +03:00
291 changed files with 12366 additions and 240 deletions

View File

@@ -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

View 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'

View File

@@ -0,0 +1,4 @@
/**
* Публичный runtime API бизнес-модуля {{name.pascalCase}}.
*/
export type {{name.pascalCase}}Api = Record<string, never>

View File

@@ -0,0 +1,4 @@
/**
* Runtime-зависимости бизнес-модуля {{name.pascalCase}}.
*/
export type {{name.pascalCase}}Deps = Record<string, never>

View File

@@ -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

View File

@@ -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 {}
}

View File

@@ -0,0 +1,2 @@
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}-props.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -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'
}
}

View 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'

View File

@@ -0,0 +1,4 @@
/**
* Query-параметры API {{name.pascalCase}}.
*/
export type QueryParams = Record<string, boolean | number | string | null | undefined>

View File

@@ -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)

View File

@@ -0,0 +1,2 @@
export { {{name.pascalCase}}Layout } from './{{name.kebabCase}}.layout'
export type { {{name.pascalCase}}LayoutProps } from './types/{{name.kebabCase}}.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -0,0 +1,2 @@
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -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

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export { {{name.pascalCase}}Screen } from './{{name.kebabCase}}.screen'
export type { {{name.pascalCase}}ScreenProps } from './types/{{name.kebabCase}}.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -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

View File

@@ -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>
)
}

View File

@@ -0,0 +1,2 @@
export { use{{name.pascalCase}}Store } from './{{name.kebabCase}}.store'
export type { {{name.pascalCase}}State } from './{{name.kebabCase}}.type'

View File

@@ -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>()(() => ({
}))

View File

@@ -0,0 +1,6 @@
/**
* Состояние {{name.pascalCase}}.
*/
export interface {{name.pascalCase}}State {
}

View File

@@ -0,0 +1,2 @@
export { {{name.pascalCase}} } from './{{name.kebabCase}}'
export type { {{name.pascalCase}}Props } from './types/{{name.kebabCase}}.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -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

View 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>
)
}

View File

@@ -0,0 +1,2 @@
export { {{name.pascalCase}}Widget } from './{{name.kebabCase}}'
export type { {{name.pascalCase}}WidgetProps } from './types/{{name.kebabCase}}.type'

View File

@@ -0,0 +1,2 @@
.root {
}

View File

@@ -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

View 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
View 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` не содержит правил разработки, ревью или архитектуры. Все правила находятся в документации соответствующей роли.

View File

@@ -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
View 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
Соблюдай стиль кода существующего приложения.

View File

@@ -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>

View File

@@ -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"
}

View File

@@ -0,0 +1,10 @@
export default {
plugins: {
"@csstools/postcss-global-data": {
files: ["src/shared/styles/media.css"],
},
"postcss-custom-media": {},
"postcss-nesting": {},
autoprefixer: {},
},
}

View File

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

After

Width:  |  Height:  |  Size: 238 B

View File

@@ -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;
}
}

View File

@@ -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>
)
}

View File

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

View File

@@ -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>
)
}

View 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>,
)

View 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,
}
}

View 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

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

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

View File

@@ -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,
},
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

@@ -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,
}
}

View File

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

View File

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

View File

@@ -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"

View File

@@ -0,0 +1,2 @@
export const toError = (error: unknown) =>
error instanceof Error ? error : new Error("Неизвестная ошибка")

View 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
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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)

File diff suppressed because it is too large Load Diff

View 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"

View File

@@ -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)
}

View File

@@ -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)
}

View File

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

View File

@@ -0,0 +1,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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

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

View File

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

View File

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

View File

@@ -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"

View 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",
})

View File

@@ -0,0 +1,2 @@
export { ThemeProvider } from "./theme-provider"
export type { ThemeProviderProps } from "./types/theme-provider-props.type"

View 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>
)
}

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react"
/**
* Параметры ThemeProvider.
*/
export type ThemeProviderProps = {
/** Содержимое приложения. */
children?: ReactNode
}

View File

@@ -0,0 +1,2 @@
export { MainLayout } from "./main.layout"
export type { MainLayoutProps } from "./types/main.type"

View 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>
)
}

View 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);
}
}

View 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

View File

@@ -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>,
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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