diff --git a/apps/admin/package.json b/apps/admin/package.json index 0f01da9..d75751e 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -5,14 +5,20 @@ "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", + "swr": "^2.4.1" }, "devDependencies": { "@csstools/postcss-global-data": "^4.0.0", diff --git a/apps/admin/src/app/app.tsx b/apps/admin/src/app/app.tsx index 66efbf4..655d8a3 100644 --- a/apps/admin/src/app/app.tsx +++ b/apps/admin/src/app/app.tsx @@ -1,10 +1,13 @@ +import { ThemeProvider } from "infra/theme" import { MainLayout } from "layouts/main" import { DashboardScreen } from "screens/dashboard" export function App() { return ( - - - + + + + + ) } diff --git a/apps/admin/src/business/.gitkeep b/apps/admin/src/business/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/admin/src/business/assets/assets.factory.ts b/apps/admin/src/business/assets/assets.factory.ts new file mode 100644 index 0000000..13e3cb3 --- /dev/null +++ b/apps/admin/src/business/assets/assets.factory.ts @@ -0,0 +1,15 @@ +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 type { AssetsFactory } from "./types/assets-factory.type" + +/** + * Создаёт runtime API бизнес-модуля Assets. + */ +export const assetsFactory: AssetsFactory = () => { + return { + useAssetOverview, + useAssetsDashboard, + useCreateAsset, + } +} diff --git a/apps/admin/src/business/assets/config/assets.config.ts b/apps/admin/src/business/assets/config/assets.config.ts new file mode 100644 index 0000000..776f097 --- /dev/null +++ b/apps/admin/src/business/assets/config/assets.config.ts @@ -0,0 +1,6 @@ +import type { ListAssetsParams } from "infra/backend-api" + +export const ASSETS_DASHBOARD_LIST_PARAMS = { + limit: "20", + offset: "0", +} satisfies ListAssetsParams diff --git a/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts b/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts new file mode 100644 index 0000000..c28cfe5 --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts @@ -0,0 +1,21 @@ +import { useGetAsset, useGetAssetVariants } from "infra/backend-api" + +import type { AssetOverview } from "../types/assets-api.type" + +/** + * Данные выбранного 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, + ) + + return { + asset: assetQuery.data ?? null, + error: assetQuery.error ?? variantsQuery.error, + isLoading: assetQuery.isLoading || variantsQuery.isLoading, + variants: variantsQuery.data?.variants ?? [], + } +} diff --git a/apps/admin/src/business/assets/hooks/use-assets-dashboard.hook.ts b/apps/admin/src/business/assets/hooks/use-assets-dashboard.hook.ts new file mode 100644 index 0000000..58c7bc9 --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-assets-dashboard.hook.ts @@ -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, + }, + } +} diff --git a/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts b/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts new file mode 100644 index 0000000..0f19cdf --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts @@ -0,0 +1,41 @@ +import { useState } from "react" +import { useSWRConfig } from "swr" +import { backendApi, getAssetsListKey } from "infra/backend-api" + +import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config" +import type { CreateAssetAction, CreateAssetInput } from "../types/assets-api.type" + +const toError = (error: unknown) => (error instanceof Error ? error : new Error("Неизвестная ошибка")) + +/** + * Сценарий создания asset с обновлением списка. + */ +export const useCreateAsset = (): CreateAssetAction => { + const { mutate } = useSWRConfig() + const [error, setError] = useState(null) + const [isCreating, setIsCreating] = useState(false) + + const createAsset = async (input: CreateAssetInput) => { + setError(null) + setIsCreating(true) + + try { + const createdAsset = await backendApi.assets.createAsset(input) + await mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)) + + return createdAsset + } catch (caughtError) { + const nextError = toError(caughtError) + setError(nextError) + throw nextError + } finally { + setIsCreating(false) + } + } + + return { + createAsset, + error, + isCreating, + } +} diff --git a/apps/admin/src/business/assets/index.ts b/apps/admin/src/business/assets/index.ts new file mode 100644 index 0000000..611fddd --- /dev/null +++ b/apps/admin/src/business/assets/index.ts @@ -0,0 +1,9 @@ +export { assetsFactory } from "./assets.factory" +export type { + AssetOverview, + AssetsApi, + AssetsDashboard, + CreateAssetAction, + CreateAssetInput, +} from "./types/assets-api.type" +export type { AssetsFactory } from "./types/assets-factory.type" diff --git a/apps/admin/src/business/assets/types/assets-api.type.ts b/apps/admin/src/business/assets/types/assets-api.type.ts new file mode 100644 index 0000000..1af75e0 --- /dev/null +++ b/apps/admin/src/business/assets/types/assets-api.type.ts @@ -0,0 +1,46 @@ +import type { + AssetResponseDto, + AssetVariantResponseDto, + CreateAssetRequestDto, + CreateAssetResponseDto, + PresetResponseDto, + PresetsResponseDto, +} from "infra/backend-api" + +export type AssetOverview = { + asset: AssetResponseDto | null + error?: Error + isLoading: boolean + variants: AssetVariantResponseDto[] +} + +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 + +export type CreateAssetAction = { + createAsset: (input: CreateAssetInput) => Promise + error: Error | null + isCreating: boolean +} + +/** + * Публичный runtime API бизнес-модуля Assets. + */ +export type AssetsApi = { + useAssetOverview: (publicId: string | null) => AssetOverview + useAssetsDashboard: () => AssetsDashboard + useCreateAsset: () => CreateAssetAction +} diff --git a/apps/admin/src/business/assets/types/assets-factory.type.ts b/apps/admin/src/business/assets/types/assets-factory.type.ts new file mode 100644 index 0000000..ad9910b --- /dev/null +++ b/apps/admin/src/business/assets/types/assets-factory.type.ts @@ -0,0 +1,6 @@ +import type { AssetsApi } from "./assets-api.type" + +/** + * Фабрика runtime API бизнес-модуля Assets. + */ +export type AssetsFactory = () => AssetsApi diff --git a/apps/admin/src/infra/.gitkeep b/apps/admin/src/infra/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/apps/admin/src/infra/backend-api/client.ts b/apps/admin/src/infra/backend-api/client.ts new file mode 100644 index 0000000..d0cccd2 --- /dev/null +++ b/apps/admin/src/infra/backend-api/client.ts @@ -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) diff --git a/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts b/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts new file mode 100644 index 0000000..3c496bd --- /dev/null +++ b/apps/admin/src/infra/backend-api/generated/backend-api.generated.ts @@ -0,0 +1,1492 @@ +/* eslint-disable */ +/* tslint:disable */ +// @ts-nocheck +/* + * --------------------------------------------------------------- + * ## THIS FILE WAS GENERATED VIA SWAGGER-TYPESCRIPT-API ## + * ## ## + * ## AUTHOR: acacode ## + * ## SOURCE: https://github.com/acacode/swagger-typescript-api ## + * --------------------------------------------------------------- + */ + +export interface HealthResponseDto { + /** + * Имя сервиса, который вернул health-check ответ. + * @example "image-platform-api" + */ + service: string; + /** + * Текущее состояние сервиса. + * @example "ok" + */ + status: string; +} + +export interface AssetResponseDto { + /** + * Внутренний UUID asset. + * @example "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" + */ + id: string; + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + /** + * Текущая версия source image. + * @example 1 + */ + currentVersion: number; + /** + * Статус asset. + * @example "active" + */ + status: AssetResponseDtoStatusEnum; + /** + * Source URL текущей версии. + * @example "https://storage.yandexcloud.net/shared1318/img/1.jpg" + */ + sourceUrl: string; + /** + * Hostname source URL текущей версии. + * @example "storage.yandexcloud.net" + */ + sourceHost: string; + /** + * Дата создания asset. + * @example "2026-05-05T12:00:00.000Z" + */ + createdAt: string; + /** + * Дата обновления asset. + * @example "2026-05-05T12:00:00.000Z" + */ + updatedAt: string; +} + +export interface AssetsListResponseDto { + /** Список assets. */ + assets: AssetResponseDto[]; +} + +export interface CreateAssetRequestDto { + /** + * Публичный стабильный идентификатор asset. Если не передан, Backend сгенерирует идентификатор автоматически. + * @example "asset_123" + */ + publicId?: string; + /** + * Постоянная ссылка на исходное изображение. Сейчас поддерживаются только публичные http/https URL. + * @example "https://storage.yandexcloud.net/shared1318/img/1.jpg" + */ + sourceUrl: string; +} + +export interface CreateAssetResponseDto { + /** + * Внутренний UUID asset в PostgreSQL. + * @example "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" + */ + id: string; + /** + * Публичный идентификатор asset для Gateway URL. + * @example "asset_123" + */ + publicId: string; + /** + * Номер версии source image. Используется в URL как `v{version}`. + * @example 1 + */ + version: number; + /** + * Нормализованный hostname исходного изображения. + * @example "storage.yandexcloud.net" + */ + sourceHost: string; + /** + * Базовый путь Gateway для запроса variant. Width, quality и format передаются query params. + * @example "/images/asset_123/v1/card" + */ + imageBasePath: string; +} + +export interface CreateAssetVersionRequestDto { + /** + * Постоянная ссылка на новую версию исходного изображения. + * @example "https://storage.yandexcloud.net/shared1318/img/1.jpg" + */ + sourceUrl: string; +} + +export interface CreateAssetVersionResponseDto { + /** + * Внутренний UUID asset. + * @example "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66" + */ + assetId: string; + /** + * Внутренний UUID новой версии source image. + * @example "3b5da974-bb7f-4d73-b172-d6ad9c244528" + */ + versionId: string; + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + /** + * Предыдущая активная версия source image. + * @example 1 + */ + previousVersion: number; + /** + * Новая активная версия source image. + * @example 2 + */ + version: number; + /** + * Нормализованный source URL новой версии. + * @example "https://storage.yandexcloud.net/shared1318/img/1.jpg" + */ + sourceUrl: string; + /** + * Hostname source URL новой версии. + * @example "storage.yandexcloud.net" + */ + sourceHost: string; + /** + * Базовый Gateway path для новой версии. + * @example "/images/asset_demo/v2/card" + */ + imageBasePath: string; + /** + * Дата создания версии. + * @example "2026-05-05T12:00:00.000Z" + */ + createdAt: string; +} + +export interface AssetPictureImageResponseDto { + /** + * Fallback image URL для ``. + * @example "http://localhost:8888/images/asset_demo/v1/card?w=960&q=80&f=jpg" + */ + src: string; + /** + * Fallback image format. + * @example "jpg" + */ + format: AssetPictureImageResponseDtoFormatEnum; + /** + * Fallback image Content-Type. + * @example "image/jpeg" + */ + type: string; + /** + * Fallback image width. + * @example 960 + */ + width: number; + /** + * Fallback image height. `0` означает auto height. + * @example 0 + */ + height: number; +} + +export interface AssetPictureSourceResponseDto { + /** + * Source format. + * @example "webp" + */ + format: AssetPictureSourceResponseDtoFormatEnum; + /** + * Source MIME type для ``. + * @example "image/webp" + */ + type: string; + /** Готовая строка srcset с width descriptors. */ + srcSet: string; +} + +export interface AssetPictureResponseDto { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + /** + * Preset, для которого построен picture contract. + * @example "card" + */ + preset: string; + /** + * Версия source image. + * @example 1 + */ + version: number; + /** + * Quality для всех URL. + * @example 80 + */ + quality: number; + /** + * Значение sizes для ``. + * @example "100vw" + */ + sizes: string; + /** + * Width descriptors, вошедшие в srcset. + * @example [320,640,960] + */ + widths: string[]; + /** Fallback image для ``. */ + image: AssetPictureImageResponseDto; + /** Sources для ``. */ + sources: AssetPictureSourceResponseDto[]; +} + +export interface AssetVariantResponseDto { + /** + * Внутренний UUID variant. + * @example "7748d24e-5f30-4064-8ee8-4745a4d2aef1" + */ + id: string; + /** + * Preset или `custom`. + * @example "card" + */ + preset: string; + /** + * Версия source image, для которой создан variant. + * @example 1 + */ + version: number; + /** + * Ширина variant. + * @example 640 + */ + width: number; + /** + * Высота variant. `0` означает auto height. + * @example 0 + */ + height: number; + /** + * Режим resize. + * @example "fit" + */ + resize: AssetVariantResponseDtoResizeEnum; + /** + * Качество variant. + * @example 80 + */ + quality: number; + /** + * Запрошенный формат. + * @example "webp" + */ + requestedFormat: AssetVariantResponseDtoRequestedFormatEnum; + /** + * Фактический формат bytes. + * @example "webp" + */ + format: AssetVariantResponseDtoFormatEnum; + /** + * Статус генерации. + * @example "ready" + */ + status: AssetVariantResponseDtoStatusEnum; + /** + * Публичный Gateway URL для variant. + * @example "http://localhost:8888/images/asset_demo/v1/card?w=640&q=80&f=webp" + */ + url: string; + /** + * S3 key variant object. + * @example "variants/asset_demo/v1/abc.webp" + */ + s3Key: string; + /** + * Content-Type готового object. + * @example "image/webp" + */ + contentType?: object; + /** + * Размер готового object в bytes. + * @example 71844 + */ + sizeBytes?: object; + /** Ошибка последней генерации, если status=`failed`. */ + error?: object; + /** + * Дата создания variant. + * @example "2026-05-05T12:00:00.000Z" + */ + createdAt: string; + /** + * Дата обновления variant. + * @example "2026-05-05T12:00:00.000Z" + */ + updatedAt: string; +} + +export interface AssetVariantsResponseDto { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + /** Список variants. */ + variants: AssetVariantResponseDto[]; +} + +export interface CreateAssetVariantsRequestDto { + /** + * Preset для генерации или `custom`. + * @example "card" + */ + preset: string; + /** + * Режим генерации: один variant или вся family preset. + * @example "single" + */ + mode?: CreateAssetVariantsRequestDtoModeEnum; + /** + * Версия source image. Если не передана, используется currentVersion asset. + * @example 1 + */ + version?: number; + /** + * Ширина variant. Обязательна для responsive preset в mode=`single` и custom. + * @example 640 + */ + width?: number; + /** + * Высота variant для custom. `0` или отсутствие означает auto height. + * @example 333 + */ + height?: number; + /** + * Качество. Если не передано, берётся из preset/custom config. + * @example 80 + */ + quality?: number; + /** + * Фактический формат для single generation. + * @example "webp" + */ + format?: CreateAssetVariantsRequestDtoFormatEnum; + /** Форматы для family generation. Если не переданы, используются все форматы preset. */ + formats?: CreateAssetVariantsRequestDtoFormatsEnum[]; + /** + * Resize mode для custom transforms. + * @example "fill" + */ + resize?: CreateAssetVariantsRequestDtoResizeEnum; +} + +export interface CreateAssetVariantsResponseDto { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + /** + * Версия source image, для которой поставлены jobs. + * @example 1 + */ + version: number; + /** Созданные или переиспользованные variants. */ + variants: AssetVariantResponseDto[]; +} + +export interface EnsureImageVariantRequestDto { + /** + * Публичный идентификатор asset из Gateway URL. + * @example "asset_123" + */ + assetId: string; + /** + * Версия source image из Gateway URL `v{version}`. + * @min 1 + * @example 4 + */ + version: number; + /** + * Имя preset трансформации. Сейчас используется как часть variant key. + * @example "card" + */ + preset: string; + /** + * Целевая ширина variant в пикселях. Обязательна для responsive presets и custom. + * @min 1 + * @example 640 + */ + width?: number; + /** + * Целевая высота variant в пикселях. `0` или отсутствие означает auto height. + * @min 0 + * @example 420 + */ + height?: number; + /** + * Качество сжатия для imgproxy. Если не передано, берётся из preset. + * @min 1 + * @example 80 + */ + quality?: number; + /** + * Формат, который запросил клиент. Для `auto` Gateway выбирает фактический формат по `Accept` header. + * @example "auto" + */ + requestedFormat?: EnsureImageVariantRequestDtoRequestedFormatEnum; + /** + * Режим resize для custom transforms. Для обычных presets берётся из preset config. + * @example "fit" + */ + resize?: EnsureImageVariantRequestDtoResizeEnum; + /** + * Фактический output format после negotiation. Именно этот формат попадает в S3 key и L1 cache key. + * @example "webp" + */ + format: EnsureImageVariantRequestDtoFormatEnum; +} + +export interface PresetResponseDto { + /** + * Имя preset. + * @example "card" + */ + name: string; + /** + * Режим preset. + * @example "responsive" + */ + mode: PresetResponseDtoModeEnum; + /** + * Разрешённые форматы. + * @example ["avif","webp","jpg"] + */ + formats: string[]; + /** + * Разрешённые значения quality. + * @example [75,80] + */ + qualities: string[]; + /** + * Quality по умолчанию. + * @example 80 + */ + quality: number; + /** + * Resize mode preset. + * @example "fit" + */ + resize: PresetResponseDtoResizeEnum; + /** + * Фиксированная ширина для fixed preset. + * @example 256 + */ + width?: number; + /** + * Фиксированная высота для fixed preset. + * @example 256 + */ + height?: number; + /** + * Разрешённые ширины для responsive preset. + * @example [320,640,960] + */ + widths?: string[]; +} + +export interface CustomTransformConfigResponseDto { + /** + * Включены ли custom transforms. + * @example true + */ + enabled: boolean; + /** + * Разрешённые форматы custom transforms. + * @example ["avif","webp","jpg","png"] + */ + formats: string[]; + /** + * Максимальная ширина custom transform. + * @example 4096 + */ + maxWidth: number; + /** + * Максимальная высота custom transform. + * @example 4096 + */ + maxHeight: number; + /** + * Quality по умолчанию для custom transform. + * @example 80 + */ + quality: number; +} + +export interface PresetsResponseDto { + /** Static presets. */ + presets: PresetResponseDto[]; + /** Custom transform config. */ + custom: CustomTransformConfigResponseDto; + /** + * Mock allowlist source hosts. + * @example ["storage.yandexcloud.net"] + */ + allowedSourceHosts: string[]; +} + +/** + * Статус asset. + * @example "active" + */ +export enum AssetResponseDtoStatusEnum { + Active = "active", + Disabled = "disabled", + Deleted = "deleted", +} + +/** + * Fallback image format. + * @example "jpg" + */ +export enum AssetPictureImageResponseDtoFormatEnum { + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Source format. + * @example "webp" + */ +export enum AssetPictureSourceResponseDtoFormatEnum { + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Режим resize. + * @example "fit" + */ +export enum AssetVariantResponseDtoResizeEnum { + Fit = "fit", + Fill = "fill", +} + +/** + * Запрошенный формат. + * @example "webp" + */ +export enum AssetVariantResponseDtoRequestedFormatEnum { + Auto = "auto", + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Фактический формат bytes. + * @example "webp" + */ +export enum AssetVariantResponseDtoFormatEnum { + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Статус генерации. + * @example "ready" + */ +export enum AssetVariantResponseDtoStatusEnum { + Pending = "pending", + Processing = "processing", + Ready = "ready", + Failed = "failed", +} + +/** + * Режим генерации: один variant или вся family preset. + * @example "single" + */ +export enum CreateAssetVariantsRequestDtoModeEnum { + Single = "single", + Family = "family", +} + +/** + * Фактический формат для single generation. + * @example "webp" + */ +export enum CreateAssetVariantsRequestDtoFormatEnum { + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +export enum CreateAssetVariantsRequestDtoFormatsEnum { + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Resize mode для custom transforms. + * @example "fill" + */ +export enum CreateAssetVariantsRequestDtoResizeEnum { + Fit = "fit", + Fill = "fill", +} + +/** + * Формат, который запросил клиент. Для `auto` Gateway выбирает фактический формат по `Accept` header. + * @example "auto" + */ +export enum EnsureImageVariantRequestDtoRequestedFormatEnum { + Auto = "auto", + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Режим resize для custom transforms. Для обычных presets берётся из preset config. + * @example "fit" + */ +export enum EnsureImageVariantRequestDtoResizeEnum { + Fit = "fit", + Fill = "fill", +} + +/** + * Фактический output format после negotiation. Именно этот формат попадает в S3 key и L1 cache key. + * @example "webp" + */ +export enum EnsureImageVariantRequestDtoFormatEnum { + Avif = "avif", + Webp = "webp", + Jpg = "jpg", + Png = "png", +} + +/** + * Режим preset. + * @example "responsive" + */ +export enum PresetResponseDtoModeEnum { + Fixed = "fixed", + Responsive = "responsive", +} + +/** + * Resize mode preset. + * @example "fit" + */ +export enum PresetResponseDtoResizeEnum { + Fit = "fit", + Fill = "fill", +} + +export interface ListAssetsParams { + /** + * Максимальное количество assets в ответе. + * @example 50 + */ + limit?: string; + /** + * Смещение для простого paging. + * @example 0 + */ + offset?: string; +} + +export interface GetAssetParams { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; +} + +export interface CreateAssetVersionParams { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; +} + +export interface GetAssetPictureParams { + /** + * Static preset для picture contract. + * @example "card" + */ + preset: string; + /** + * Версия source image. Если не передана, используется currentVersion asset. + * @example 1 + */ + version?: string; + /** + * Quality. Если не передано, берётся default quality preset. + * @example 80 + */ + quality?: string; + /** + * Значение для HTML `sizes`. + * @example "(min-width: 768px) 50vw, 100vw" + */ + sizes?: string; + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; +} + +export interface ListAssetVariantsParams { + /** + * Версия source image. Если не передана, возвращаются variants всех версий. + * @example 1 + */ + version?: string; + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; +} + +export interface CreateAssetVariantsParams { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; +} + +export namespace System { + /** + * @description Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests. + * @tags system + * @name GetHealth + * @summary проверить состояние Backend API + * @request GET:/api/health + */ + export namespace GetHealth { + export type RequestParams = {}; + export type RequestQuery = {}; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = HealthResponseDto; + } +} + +export namespace Assets { + /** + * @description Возвращает последние зарегистрированные assets вместе с source URL текущей версии. + * @tags assets + * @name ListAssets + * @summary получить список assets + * @request GET:/api/assets + */ + export namespace ListAssets { + export type RequestParams = {}; + export type RequestQuery = { + /** + * Максимальное количество assets в ответе. + * @example 50 + */ + limit?: string; + /** + * Смещение для простого paging. + * @example 0 + */ + offset?: string; + }; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = AssetsListResponseDto; + } + + /** + * @description Создаёт asset и первую версию source image. Source URL сохраняется в PostgreSQL, а публичный image URL строится через Gateway без раскрытия исходной ссылки клиенту. + * @tags assets + * @name CreateAsset + * @summary зарегистрировать исходное изображение + * @request POST:/api/assets + */ + export namespace CreateAsset { + export type RequestParams = {}; + export type RequestQuery = {}; + export type RequestBody = CreateAssetRequestDto; + export type RequestHeaders = {}; + export type ResponseBody = CreateAssetResponseDto; + } + + /** + * @description Возвращает metadata asset и source URL текущей версии. + * @tags assets + * @name GetAsset + * @summary получить asset по publicId + * @request GET:/api/assets/{publicId} + */ + export namespace GetAsset { + export type RequestParams = { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + }; + export type RequestQuery = {}; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = AssetResponseDto; + } + + /** + * @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs. + * @tags assets + * @name CreateAssetVersion + * @summary создать новую версию source image + * @request POST:/api/assets/{publicId}/versions + */ + export namespace CreateAssetVersion { + export type RequestParams = { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + }; + export type RequestQuery = {}; + export type RequestBody = CreateAssetVersionRequestDto; + export type RequestHeaders = {}; + export type ResponseBody = CreateAssetVersionResponseDto; + } + + /** + * @description Возвращает готовый контракт для `` и `` по static preset: sources, srcset, fallback src, sizes и versioned Gateway URLs. Endpoint не ставит generation jobs: Gateway сгенерирует bytes lazy или отдаст cache. + * @tags assets + * @name GetAssetPicture + * @summary получить picture/srcset URLs + * @request GET:/api/assets/{publicId}/picture + */ + export namespace GetAssetPicture { + export type RequestParams = { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + }; + export type RequestQuery = { + /** + * Static preset для picture contract. + * @example "card" + */ + preset: string; + /** + * Версия source image. Если не передана, используется currentVersion asset. + * @example 1 + */ + version?: string; + /** + * Quality. Если не передано, берётся default quality preset. + * @example 80 + */ + quality?: string; + /** + * Значение для HTML `sizes`. + * @example "(min-width: 768px) 50vw, 100vw" + */ + sizes?: string; + }; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = AssetPictureResponseDto; + } + + /** + * @description Возвращает variants asset: preset/custom параметры, status, S3 key, public URL и ошибку генерации, если она была. + * @tags assets + * @name ListAssetVariants + * @summary получить variants asset + * @request GET:/api/assets/{publicId}/variants + */ + export namespace ListAssetVariants { + export type RequestParams = { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + }; + export type RequestQuery = { + /** + * Версия source image. Если не передана, возвращаются variants всех версий. + * @example 1 + */ + version?: string; + }; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = AssetVariantsResponseDto; + } + + /** + * @description Business endpoint для явной подготовки variants. В режиме `single` создаёт один variant, в режиме `family` создаёт набор variants preset по всем разрешённым widths/formats. Endpoint не ждёт bytes, а возвращает созданные/переиспользованные rows и public URLs. + * @tags assets + * @name CreateAssetVariants + * @summary поставить generation jobs для variants + * @request POST:/api/assets/{publicId}/variants + */ + export namespace CreateAssetVariants { + export type RequestParams = { + /** + * Публичный идентификатор asset. + * @example "asset_demo" + */ + publicId: string; + }; + export type RequestQuery = {}; + export type RequestBody = CreateAssetVariantsRequestDto; + export type RequestHeaders = {}; + export type ResponseBody = CreateAssetVariantsResponseDto; + } +} + +export namespace InternalImages { + /** + * @description Внутренний endpoint для Gateway. На L1 cache miss Backend проверяет PostgreSQL и S3, создаёт variant при необходимости, публикует RabbitMQ job, ждёт генерацию worker и возвращает готовые image bytes. + * @tags internal-images + * @name EnsureImageVariant + * @summary подготовить variant изображения для Gateway + * @request POST:/api/internal/images/ensure + */ + export namespace EnsureImageVariant { + export type RequestParams = {}; + export type RequestQuery = {}; + export type RequestBody = EnsureImageVariantRequestDto; + export type RequestHeaders = {}; + export type ResponseBody = File; + } +} + +export namespace Presets { + /** + * @description Возвращает статический config presets, custom transform limits и mock allowlist source hosts. + * @tags presets + * @name GetPresets + * @summary получить доступные presets и custom config + * @request GET:/api/presets + */ + export namespace GetPresets { + export type RequestParams = {}; + export type RequestQuery = {}; + export type RequestBody = never; + export type RequestHeaders = {}; + export type ResponseBody = PresetsResponseDto; + } +} + +/** + * Фетчер для SWR + * Принимает URL и возвращает Promise с данными + */ +export const fetcher = (url: string): Promise => { + return fetch(url, { + headers: { + "Content-Type": "application/json", + }, + }).then((res) => { + if (!res.ok) { + throw new Error(`HTTP Error ${res.status}`); + } + return res.json(); + }); +}; + +export type QueryParamsType = Record; +export type ResponseFormat = keyof Omit; + +export interface FullRequestParams extends Omit { + /** set parameter to `true` for call `securityWorker` for this request */ + secure?: boolean; + /** request path */ + path: string; + /** content type of request body */ + type?: ContentType; + /** query params */ + query?: QueryParamsType; + /** format of response (i.e. response.json() -> format: "json") */ + format?: ResponseFormat; + /** request body */ + body?: unknown; + /** base url */ + baseUrl?: string; + /** request cancellation token */ + cancelToken?: CancelToken; +} + +export type RequestParams = Omit< + FullRequestParams, + "body" | "method" | "query" | "path" +>; + +export interface ApiConfig { + baseUrl?: string; + baseApiParams?: Omit; + securityWorker?: ( + securityData: SecurityDataType | null, + ) => Promise | RequestParams | void; + customFetch?: typeof fetch; +} + +export interface HttpResponse + extends Response { + data: D; + error: E; +} + +type CancelToken = Symbol | string | number; + +export enum ContentType { + Json = "application/json", + JsonApi = "application/vnd.api+json", + FormData = "multipart/form-data", + UrlEncoded = "application/x-www-form-urlencoded", + Text = "text/plain", +} + +export class HttpClient { + public baseUrl: string = ""; + private securityData: SecurityDataType | null = null; + private securityWorker?: ApiConfig["securityWorker"]; + private abortControllers = new Map(); + private customFetch = (...fetchParams: Parameters) => + fetch(...fetchParams); + + private baseApiParams: RequestParams = { + credentials: "same-origin", + headers: {}, + redirect: "follow", + referrerPolicy: "no-referrer", + }; + + constructor(apiConfig: ApiConfig = {}) { + Object.assign(this, apiConfig); + } + + public setSecurityData = (data: SecurityDataType | null) => { + this.securityData = data; + }; + + protected encodeQueryParam(key: string, value: any) { + const encodedKey = encodeURIComponent(key); + return `${encodedKey}=${encodeURIComponent(typeof value === "number" ? value : `${value}`)}`; + } + + protected addQueryParam(query: QueryParamsType, key: string) { + return this.encodeQueryParam(key, query[key]); + } + + protected addArrayQueryParam(query: QueryParamsType, key: string) { + const value = query[key]; + return value.map((v: any) => this.encodeQueryParam(key, v)).join("&"); + } + + protected toQueryString(rawQuery?: QueryParamsType): string { + const query = rawQuery || {}; + const keys = Object.keys(query).filter( + (key) => "undefined" !== typeof query[key], + ); + return keys + .map((key) => + Array.isArray(query[key]) + ? this.addArrayQueryParam(query, key) + : this.addQueryParam(query, key), + ) + .join("&"); + } + + protected addQueryParams(rawQuery?: QueryParamsType): string { + const queryString = this.toQueryString(rawQuery); + return queryString ? `?${queryString}` : ""; + } + + private contentFormatters: Record any> = { + [ContentType.Json]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.JsonApi]: (input: any) => + input !== null && (typeof input === "object" || typeof input === "string") + ? JSON.stringify(input) + : input, + [ContentType.Text]: (input: any) => + input !== null && typeof input !== "string" + ? JSON.stringify(input) + : input, + [ContentType.FormData]: (input: any) => { + if (input instanceof FormData) { + return input; + } + + return Object.keys(input || {}).reduce((formData, key) => { + const property = input[key]; + formData.append( + key, + property instanceof Blob + ? property + : typeof property === "object" && property !== null + ? JSON.stringify(property) + : `${property}`, + ); + return formData; + }, new FormData()); + }, + [ContentType.UrlEncoded]: (input: any) => this.toQueryString(input), + }; + + protected mergeRequestParams( + params1: RequestParams, + params2?: RequestParams, + ): RequestParams { + return { + ...this.baseApiParams, + ...params1, + ...(params2 || {}), + headers: { + ...(this.baseApiParams.headers || {}), + ...(params1.headers || {}), + ...((params2 && params2.headers) || {}), + }, + }; + } + + protected createAbortSignal = ( + cancelToken: CancelToken, + ): AbortSignal | undefined => { + if (this.abortControllers.has(cancelToken)) { + const abortController = this.abortControllers.get(cancelToken); + if (abortController) { + return abortController.signal; + } + return void 0; + } + + const abortController = new AbortController(); + this.abortControllers.set(cancelToken, abortController); + return abortController.signal; + }; + + public abortRequest = (cancelToken: CancelToken) => { + const abortController = this.abortControllers.get(cancelToken); + + if (abortController) { + abortController.abort(); + this.abortControllers.delete(cancelToken); + } + }; + + public request = async ({ + body, + secure, + path, + type, + query, + format, + baseUrl, + cancelToken, + ...params + }: FullRequestParams): Promise => { + const secureParams = + ((typeof secure === "boolean" ? secure : this.baseApiParams.secure) && + this.securityWorker && + (await this.securityWorker(this.securityData))) || + {}; + const requestParams = this.mergeRequestParams(params, secureParams); + const queryString = query && this.toQueryString(query); + const payloadFormatter = this.contentFormatters[type || ContentType.Json]; + const responseFormat = format || requestParams.format; + + return this.customFetch( + `${baseUrl || this.baseUrl || ""}${path}${queryString ? `?${queryString}` : ""}`, + { + ...requestParams, + headers: { + ...(requestParams.headers || {}), + ...(type && type !== ContentType.FormData + ? { "Content-Type": type } + : {}), + }, + signal: + (cancelToken + ? this.createAbortSignal(cancelToken) + : requestParams.signal) || null, + body: + typeof body === "undefined" || body === null + ? null + : payloadFormatter(body), + }, + ).then(async (response) => { + const r = response as HttpResponse; + r.data = null as unknown as T; + r.error = null as unknown as E; + + const responseToParse = responseFormat ? response.clone() : response; + const data = !responseFormat + ? r + : await responseToParse[responseFormat]() + .then((data) => { + if (r.ok) { + r.data = data; + } else { + r.error = data; + } + return r; + }) + .catch((e) => { + r.error = e; + return r; + }); + + if (cancelToken) { + this.abortControllers.delete(cancelToken); + } + + if (!response.ok) throw data; + return data.data; + }); + }; +} + +/** + * @title Image Platform API + * @version 0.1.0 + * @contact + * + * Backend API для управления image assets, metadata в PostgreSQL, S3 variants, RabbitMQ jobs и генерацией через imgproxy. + */ +export class Api { + http: HttpClient; + + constructor(http?: HttpClient) { + this.http = http || new HttpClient(); + } + + system = { + /** + * @description Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests. + * + * @tags system + * @name GetHealth + * @summary проверить состояние Backend API + * @request GET:/api/health + */ + getHealth: (params: RequestParams = {}) => + this.http.request({ + path: `/api/health`, + method: "GET", + format: "json", + ...params, + }), + }; + assets = { + /** + * @description Возвращает последние зарегистрированные assets вместе с source URL текущей версии. + * + * @tags assets + * @name ListAssets + * @summary получить список assets + * @request GET:/api/assets + */ + listAssets: (query: ListAssetsParams, params: RequestParams = {}) => + this.http.request({ + path: `/api/assets`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * @description Создаёт asset и первую версию source image. Source URL сохраняется в PostgreSQL, а публичный image URL строится через Gateway без раскрытия исходной ссылки клиенту. + * + * @tags assets + * @name CreateAsset + * @summary зарегистрировать исходное изображение + * @request POST:/api/assets + */ + createAsset: (data: CreateAssetRequestDto, params: RequestParams = {}) => + this.http.request({ + path: `/api/assets`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Возвращает metadata asset и source URL текущей версии. + * + * @tags assets + * @name GetAsset + * @summary получить asset по publicId + * @request GET:/api/assets/{publicId} + */ + getAsset: ( + { publicId, ...query }: GetAssetParams, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/api/assets/${publicId}`, + method: "GET", + format: "json", + ...params, + }), + + /** + * @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs. + * + * @tags assets + * @name CreateAssetVersion + * @summary создать новую версию source image + * @request POST:/api/assets/{publicId}/versions + */ + createAssetVersion: ( + { publicId, ...query }: CreateAssetVersionParams, + data: CreateAssetVersionRequestDto, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/api/assets/${publicId}/versions`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + + /** + * @description Возвращает готовый контракт для `` и `` по static preset: sources, srcset, fallback src, sizes и versioned Gateway URLs. Endpoint не ставит generation jobs: Gateway сгенерирует bytes lazy или отдаст cache. + * + * @tags assets + * @name GetAssetPicture + * @summary получить picture/srcset URLs + * @request GET:/api/assets/{publicId}/picture + */ + getAssetPicture: ( + { publicId, ...query }: GetAssetPictureParams, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/api/assets/${publicId}/picture`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * @description Возвращает variants asset: preset/custom параметры, status, S3 key, public URL и ошибку генерации, если она была. + * + * @tags assets + * @name ListAssetVariants + * @summary получить variants asset + * @request GET:/api/assets/{publicId}/variants + */ + listAssetVariants: ( + { publicId, ...query }: ListAssetVariantsParams, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/api/assets/${publicId}/variants`, + method: "GET", + query: query, + format: "json", + ...params, + }), + + /** + * @description Business endpoint для явной подготовки variants. В режиме `single` создаёт один variant, в режиме `family` создаёт набор variants preset по всем разрешённым widths/formats. Endpoint не ждёт bytes, а возвращает созданные/переиспользованные rows и public URLs. + * + * @tags assets + * @name CreateAssetVariants + * @summary поставить generation jobs для variants + * @request POST:/api/assets/{publicId}/variants + */ + createAssetVariants: ( + { publicId, ...query }: CreateAssetVariantsParams, + data: CreateAssetVariantsRequestDto, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/api/assets/${publicId}/variants`, + method: "POST", + body: data, + type: ContentType.Json, + format: "json", + ...params, + }), + }; + internalImages = { + /** + * @description Внутренний endpoint для Gateway. На L1 cache miss Backend проверяет PostgreSQL и S3, создаёт variant при необходимости, публикует RabbitMQ job, ждёт генерацию worker и возвращает готовые image bytes. + * + * @tags internal-images + * @name EnsureImageVariant + * @summary подготовить variant изображения для Gateway + * @request POST:/api/internal/images/ensure + */ + ensureImageVariant: ( + data: EnsureImageVariantRequestDto, + params: RequestParams = {}, + ) => + this.http.request({ + path: `/api/internal/images/ensure`, + method: "POST", + body: data, + type: ContentType.Json, + format: "blob", + ...params, + }), + }; + presets = { + /** + * @description Возвращает статический config presets, custom transform limits и mock allowlist source hosts. + * + * @tags presets + * @name GetPresets + * @summary получить доступные presets и custom config + * @request GET:/api/presets + */ + getPresets: (params: RequestParams = {}) => + this.http.request({ + path: `/api/presets`, + method: "GET", + format: "json", + ...params, + }), + }; +} diff --git a/apps/admin/src/infra/backend-api/hooks/index.ts b/apps/admin/src/infra/backend-api/hooks/index.ts new file mode 100644 index 0000000..916fb7b --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/index.ts @@ -0,0 +1,4 @@ +export { getAssetKey, useGetAsset } from "./use-get-asset.hook" +export { getAssetVariantsKey, useGetAssetVariants } from "./use-get-asset-variants.hook" +export { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook" +export { getPresetsKey, useGetPresets } from "./use-get-presets.hook" diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts new file mode 100644 index 0000000..f0518b1 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts @@ -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, +) => { + const key = publicId !== null ? getAssetVariantsKey(publicId, version) : null + const fetcher = () => backendApi.assets.listAssetVariants({ publicId: publicId ?? "", version }) + + return useSWR(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-asset.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-asset.hook.ts new file mode 100644 index 0000000..1e92d3f --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-asset.hook.ts @@ -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(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-assets-list.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-assets-list.hook.ts new file mode 100644 index 0000000..ba163d8 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-assets-list.hook.ts @@ -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(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-presets.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-presets.hook.ts new file mode 100644 index 0000000..33e470f --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-presets.hook.ts @@ -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(getPresetsKey(), fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/index.ts b/apps/admin/src/infra/backend-api/index.ts new file mode 100644 index 0000000..f5c6243 --- /dev/null +++ b/apps/admin/src/infra/backend-api/index.ts @@ -0,0 +1,14 @@ +export { backendApi } from "./client" +export * from "./hooks" +export type { + AssetResponseDto, + AssetVariantResponseDto, + AssetVariantsResponseDto, + AssetsListResponseDto, + CreateAssetRequestDto, + CreateAssetResponseDto, + CustomTransformConfigResponseDto, + ListAssetsParams, + PresetResponseDto, + PresetsResponseDto, +} from "./generated/backend-api.generated" diff --git a/apps/admin/src/infra/theme/config/theme.config.ts b/apps/admin/src/infra/theme/config/theme.config.ts new file mode 100644 index 0000000..e0de397 --- /dev/null +++ b/apps/admin/src/infra/theme/config/theme.config.ts @@ -0,0 +1,11 @@ +import { createTheme } from "@mantine/core" + +export const ADMIN_THEME = createTheme({ + defaultRadius: "lg", + fontFamily: "var(--font-sans)", + headings: { + fontFamily: "var(--font-sans)", + fontWeight: "850", + }, + primaryColor: "violet", +}) diff --git a/apps/admin/src/infra/theme/index.ts b/apps/admin/src/infra/theme/index.ts new file mode 100644 index 0000000..b38425c --- /dev/null +++ b/apps/admin/src/infra/theme/index.ts @@ -0,0 +1,2 @@ +export { ThemeProvider } from "./theme-provider" +export type { ThemeProviderProps } from "./types/theme-provider-props.type" diff --git a/apps/admin/src/infra/theme/theme-provider.tsx b/apps/admin/src/infra/theme/theme-provider.tsx new file mode 100644 index 0000000..fc33044 --- /dev/null +++ b/apps/admin/src/infra/theme/theme-provider.tsx @@ -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 ( + + + {children} + + ) +} diff --git a/apps/admin/src/infra/theme/types/theme-provider-props.type.ts b/apps/admin/src/infra/theme/types/theme-provider-props.type.ts new file mode 100644 index 0000000..230967d --- /dev/null +++ b/apps/admin/src/infra/theme/types/theme-provider-props.type.ts @@ -0,0 +1,9 @@ +import type { ReactNode } from "react" + +/** + * Параметры ThemeProvider. + */ +export type ThemeProviderProps = { + /** Содержимое приложения. */ + children?: ReactNode +} diff --git a/apps/admin/src/layouts/main/main.layout.tsx b/apps/admin/src/layouts/main/main.layout.tsx index b1c8146..19a0f2a 100644 --- a/apps/admin/src/layouts/main/main.layout.tsx +++ b/apps/admin/src/layouts/main/main.layout.tsx @@ -1,3 +1,4 @@ +import { AppShell, Badge, Group, Text, ThemeIcon } from "@mantine/core" import cl from "clsx" import styles from "./styles/main.module.css" @@ -14,17 +15,27 @@ export const MainLayout = (props: MainLayoutProps) => { const { children, className, ...rootAttrs } = props return ( -
-
- - IP - Image Platform - + + + + + + IP + + + Image Platform + + -

Admin MVP

-
+ + Admin MVP + + + -
{children}
-
+ +
{children}
+
+ ) } diff --git a/apps/admin/src/layouts/main/styles/main.module.css b/apps/admin/src/layouts/main/styles/main.module.css index 00931c5..497e69f 100644 --- a/apps/admin/src/layouts/main/styles/main.module.css +++ b/apps/admin/src/layouts/main/styles/main.module.css @@ -1,21 +1,15 @@ .root { min-height: 100vh; - padding: var(--space-4); background: - radial-gradient(circle at 18% 18%, var(--color-accent-wash), transparent 30rem), + radial-gradient(circle at 16% 12%, var(--color-accent-wash), transparent 32rem), + radial-gradient(circle at 86% 4%, rgb(255 176 96 / 16%), transparent 28rem), var(--color-page); - - @media (--md) { - padding: var(--space-6); - } } .header { - display: flex; - align-items: center; - justify-content: space-between; - max-width: var(--content-width); - margin: 0 auto var(--space-4); + border-bottom: 1px solid var(--color-border); + background: rgb(247 244 238 / 78%); + backdrop-filter: blur(18px); } .brand { @@ -23,21 +17,16 @@ align-items: center; gap: var(--space-3); color: var(--color-text); - font-weight: 800; letter-spacing: -0.03em; } .brandMark { - display: grid; - width: 2.5rem; - height: 2.5rem; - place-items: center; border: 1px solid var(--color-border); - border-radius: var(--radius-round); background: var(--color-surface); color: var(--color-accent); box-shadow: var(--shadow-soft); font-size: 0.8125rem; + font-weight: 850; letter-spacing: 0.08em; } @@ -45,22 +34,20 @@ display: none; @media (--sm) { - display: inline; + display: block; } } -.status { - margin: 0; - padding: var(--space-2) var(--space-3); - border: 1px solid var(--color-border); - border-radius: var(--radius-round); - background: var(--color-surface-muted); - color: var(--color-text-muted); - font-size: 0.8125rem; - font-weight: 700; +.main { + background: transparent; } .content { - max-width: var(--content-width); + max-width: 82rem; margin: 0 auto; + padding: var(--space-4) 0 var(--space-8); + + @media (--md) { + padding-top: var(--space-6); + } } diff --git a/apps/admin/src/layouts/main/types/main.type.ts b/apps/admin/src/layouts/main/types/main.type.ts index 5ac0d39..d74ec1b 100644 --- a/apps/admin/src/layouts/main/types/main.type.ts +++ b/apps/admin/src/layouts/main/types/main.type.ts @@ -1,4 +1,5 @@ -import type { ComponentPropsWithoutRef, ReactNode } from "react" +import type { AppShellProps } from "@mantine/core" +import type { ReactNode } from "react" /** * Параметры MainLayout. @@ -9,6 +10,6 @@ export type MainLayoutParams = { } /** Атрибуты корневого элемента без children. */ -type RootAttrs = Omit, 'children'> +type RootAttrs = Omit export type MainLayoutProps = RootAttrs & MainLayoutParams diff --git a/apps/admin/src/screens/dashboard/config/dashboard.config.ts b/apps/admin/src/screens/dashboard/config/dashboard.config.ts index 6105836..9d376cb 100644 --- a/apps/admin/src/screens/dashboard/config/dashboard.config.ts +++ b/apps/admin/src/screens/dashboard/config/dashboard.config.ts @@ -1,16 +1,32 @@ export const DASHBOARD_CARDS = [ { + metric: "assets", title: "Assets", description: "Каталог исходных изображений, версий и публичных identifiers.", }, { + metric: "presets", title: "Variants", description: "Статусы генерации AVIF/WebP/JPEG под presets и custom transforms.", }, { + metric: "hosts", title: "Storage", description: "PostgreSQL как source of truth, S3/MinIO как хранилище готовых bytes.", }, ] as const export const DASHBOARD_PIPELINE = ["Backend", "RabbitMQ", "Worker", "imgproxy", "S3"] as const + +export const ASSET_STATUS_COLORS = { + active: "green", + deleted: "red", + disabled: "gray", +} as const + +export const VARIANT_STATUS_COLORS = { + failed: "red", + pending: "yellow", + processing: "blue", + ready: "green", +} as const diff --git a/apps/admin/src/screens/dashboard/dashboard.screen.tsx b/apps/admin/src/screens/dashboard/dashboard.screen.tsx index 3e80edd..ec10f9e 100644 --- a/apps/admin/src/screens/dashboard/dashboard.screen.tsx +++ b/apps/admin/src/screens/dashboard/dashboard.screen.tsx @@ -1,9 +1,20 @@ +import { Alert, Button, Group, Paper, Stack, Text, Title } from "@mantine/core" +import { useDisclosure } from "@mantine/hooks" import cl from "clsx" +import { useState } from "react" +import { assetsFactory } from "business/assets" -import { DASHBOARD_CARDS, DASHBOARD_PIPELINE } from "./config/dashboard.config" +import { DASHBOARD_PIPELINE } from "./config/dashboard.config" +import { AssetDetailPanel } from "./parts/asset-detail-panel" +import { AssetsTable } from "./parts/assets-table" +import { CreateAssetModal } from "./parts/create-asset-modal" +import { PresetsPanel } from "./parts/presets-panel" +import { SummaryCards } from "./parts/summary-cards" import styles from "./styles/dashboard.module.css" import type { DashboardScreenProps } from "./types/dashboard.type" +const assets = assetsFactory() + /** * Стартовый dashboard admin-приложения. * @@ -13,34 +24,73 @@ import type { DashboardScreenProps } from "./types/dashboard.type" */ export const DashboardScreen = (props: DashboardScreenProps) => { const { className, ...rootAttrs } = props + const [selectedPublicId, setSelectedPublicId] = useState(null) + const [isCreateAssetOpen, createAssetModal] = useDisclosure(false) + const dashboard = assets.useAssetsDashboard() + const createAsset = assets.useCreateAsset() + const effectivePublicId = selectedPublicId ?? dashboard.assets[0]?.publicId ?? null + const overview = assets.useAssetOverview(effectivePublicId) return (
-
-

Image Platform Admin

-

Control plane для image delivery

-

- Админка будет управлять allowed hosts, assets, source versions, presets и variant - generation без прямого доступа к storage-слою. -

-
+ + + +
+ Image Platform Admin + Control plane для image delivery + + Управление allowed hosts, assets, source versions, presets и variant generation без + прямого доступа к storage-слою. + +
-
- {DASHBOARD_CARDS.map((card) => ( -
-

{card.title}

-

{card.description}

-
- ))} -
+ +
+
-
- {DASHBOARD_PIPELINE.map((step) => ( - - {step} - - ))} -
+ + + + {DASHBOARD_PIPELINE.map((step) => ( + + {step} + + ))} + + + {dashboard.error ? ( + + Проверьте, что backend запущен на `localhost:3001`, а Vite proxy доступен по `/api`. + + ) : null} + +
+ + +
+ + +
+ +
) } diff --git a/apps/admin/src/screens/dashboard/lib/format-date.ts b/apps/admin/src/screens/dashboard/lib/format-date.ts new file mode 100644 index 0000000..19d27f8 --- /dev/null +++ b/apps/admin/src/screens/dashboard/lib/format-date.ts @@ -0,0 +1,15 @@ +export const formatDateTime = (value: string) => { + const date = new Date(value) + + if (Number.isNaN(date.getTime())) { + return value + } + + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + month: "short", + year: "numeric", + }).format(date) +} diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx new file mode 100644 index 0000000..44e51a6 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx @@ -0,0 +1,126 @@ +import { Anchor, Badge, Code, Group, Paper, ScrollArea, Skeleton, Stack, Table, Text, Title } from "@mantine/core" + +import { ASSET_STATUS_COLORS, VARIANT_STATUS_COLORS } from "../../config/dashboard.config" +import { formatDateTime } from "../../lib/format-date" +import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type" + +/** + * Детали выбранного asset и его variants. + * + * Используется для: + * - отображения source metadata + * - отображения статусов generated variants + */ +export const AssetDetailPanel = (props: AssetDetailPanelProps) => { + const { overview, publicId } = props + const { asset, variants } = overview + + if (!publicId) { + return ( + + + Asset detail + + + Выберите asset из таблицы, чтобы увидеть source URL и variants. + + + ) + } + + return ( + + +
+ + Asset detail + + + {publicId} + +
+ + {asset ? ( + + {asset.status} + + ) : null} +
+ + {overview.isLoading ? ( + + ) : asset ? ( + + + + Source URL + + + {asset.sourceUrl} + + + + + + v{asset.currentVersion} + + + {asset.sourceHost} + + + updated {formatDateTime(asset.updatedAt)} + + + + + + + Variants + + + {variants.length} + + + + {variants.length > 0 ? ( + + + + + Preset + Format + Size + Status + + + + {variants.map((variant) => ( + + + {variant.preset} + + {variant.format} + + {variant.width}x{variant.height || "auto"} q{variant.quality} + + + + {variant.status} + + + + ))} + +
+
+ ) : ( + Variants для текущей версии пока не созданы. + )} +
+
+ ) : ( + Asset не найден или ещё загружается. + )} +
+ ) +} diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/index.ts b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/index.ts new file mode 100644 index 0000000..1535995 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/index.ts @@ -0,0 +1,2 @@ +export { AssetDetailPanel } from "./asset-detail-panel" +export type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts new file mode 100644 index 0000000..31f57a4 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts @@ -0,0 +1,11 @@ +import type { AssetOverview } from "business/assets" + +/** + * Параметры AssetDetailPanel. + */ +export type AssetDetailPanelProps = { + /** Данные выбранного asset. */ + overview: AssetOverview + /** Выбранный publicId. */ + publicId: string | null +} diff --git a/apps/admin/src/screens/dashboard/parts/assets-table/assets-table.tsx b/apps/admin/src/screens/dashboard/parts/assets-table/assets-table.tsx new file mode 100644 index 0000000..b74e9c7 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/assets-table/assets-table.tsx @@ -0,0 +1,76 @@ +import { Badge, Group, Paper, ScrollArea, Skeleton, Table, Text, Title } from "@mantine/core" + +import { ASSET_STATUS_COLORS } from "../../config/dashboard.config" +import { formatDateTime } from "../../lib/format-date" +import type { AssetsTableProps } from "./types/assets-table-props.type" + +/** + * Таблица assets. + * + * Используется для: + * - отображения реального списка assets из Backend API + * - выбора asset для detail-панели + */ +export const AssetsTable = (props: AssetsTableProps) => { + const { assets, isLoading, onSelect, selectedPublicId } = props + + return ( + + +
+ + Assets + + + Последние зарегистрированные исходные изображения. + +
+ + {assets.length} loaded + +
+ + {isLoading ? ( + + ) : assets.length > 0 ? ( + + + + + publicId + Status + Version + Host + Updated + + + + {assets.map((asset) => ( + onSelect(asset.publicId)} + style={{ cursor: "pointer" }} + > + + {asset.publicId} + + + + {asset.status} + + + v{asset.currentVersion} + {asset.sourceHost} + {formatDateTime(asset.updatedAt)} + + ))} + +
+
+ ) : ( + Assets пока не зарегистрированы. + )} +
+ ) +} diff --git a/apps/admin/src/screens/dashboard/parts/assets-table/index.ts b/apps/admin/src/screens/dashboard/parts/assets-table/index.ts new file mode 100644 index 0000000..be45515 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/assets-table/index.ts @@ -0,0 +1,2 @@ +export { AssetsTable } from "./assets-table" +export type { AssetsTableProps } from "./types/assets-table-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/assets-table/types/assets-table-props.type.ts b/apps/admin/src/screens/dashboard/parts/assets-table/types/assets-table-props.type.ts new file mode 100644 index 0000000..da37824 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/assets-table/types/assets-table-props.type.ts @@ -0,0 +1,15 @@ +import type { AssetsDashboard } from "business/assets" + +/** + * Параметры AssetsTable. + */ +export type AssetsTableProps = { + /** Список assets. */ + assets: AssetsDashboard["assets"] + /** Признак загрузки списка. */ + isLoading: boolean + /** Callback выбора asset. */ + onSelect: (publicId: string) => void + /** Выбранный publicId. */ + selectedPublicId: string | null +} diff --git a/apps/admin/src/screens/dashboard/parts/create-asset-modal/create-asset-modal.tsx b/apps/admin/src/screens/dashboard/parts/create-asset-modal/create-asset-modal.tsx new file mode 100644 index 0000000..f0ce884 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/create-asset-modal/create-asset-modal.tsx @@ -0,0 +1,115 @@ +import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core" +import { useForm } from "@mantine/form" +import { notifications } from "@mantine/notifications" + +import type { CreateAssetInput } from "business/assets" +import type { CreateAssetModalProps } from "./types/create-asset-modal-props.type" + +type CreateAssetFormValues = { + publicId: string + sourceUrl: string +} + +const SOURCE_URL_EXAMPLE = "https://storage.yandexcloud.net/shared1318/img/1.jpg" + +const toErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Неизвестная ошибка") + +/** + * Modal создания asset. + * + * Используется для: + * - регистрации source image + * - запуска первого write-сценария admin MVP + */ +export const CreateAssetModal = (props: CreateAssetModalProps) => { + const { action, onClose, onCreated, opened } = props + + const form = useForm({ + initialValues: { + publicId: "", + sourceUrl: SOURCE_URL_EXAMPLE, + }, + validate: { + sourceUrl: (value) => { + if (!value.trim()) { + return "Укажите source URL" + } + + try { + const url = new URL(value) + return url.protocol === "http:" || url.protocol === "https:" ? null : "URL должен быть http/https" + } catch { + return "Некорректный URL" + } + }, + }, + }) + + const handleClose = () => { + if (!action.isCreating) { + onClose() + } + } + + const handleSubmit = form.onSubmit(async (values) => { + const publicId = values.publicId.trim() + const input: CreateAssetInput = { + sourceUrl: values.sourceUrl.trim(), + ...(publicId ? { publicId } : {}), + } + + try { + const createdAsset = await action.createAsset(input) + notifications.show({ + color: "green", + message: `Asset ${createdAsset.publicId} зарегистрирован`, + title: "Asset created", + }) + form.reset() + onCreated(createdAsset.publicId) + onClose() + } catch (error) { + notifications.show({ + color: "red", + message: toErrorMessage(error), + title: "Не удалось создать asset", + }) + } + }) + + return ( + +
+ + + Backend создаст asset и первую immutable source version. Public ID можно оставить пустым. + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/admin/src/screens/dashboard/parts/create-asset-modal/index.ts b/apps/admin/src/screens/dashboard/parts/create-asset-modal/index.ts new file mode 100644 index 0000000..c48adb9 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/create-asset-modal/index.ts @@ -0,0 +1,2 @@ +export { CreateAssetModal } from "./create-asset-modal" +export type { CreateAssetModalProps } from "./types/create-asset-modal-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/create-asset-modal/types/create-asset-modal-props.type.ts b/apps/admin/src/screens/dashboard/parts/create-asset-modal/types/create-asset-modal-props.type.ts new file mode 100644 index 0000000..6044020 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/create-asset-modal/types/create-asset-modal-props.type.ts @@ -0,0 +1,15 @@ +import type { CreateAssetAction } from "business/assets" + +/** + * Параметры CreateAssetModal. + */ +export type CreateAssetModalProps = { + /** Сценарий создания asset. */ + action: CreateAssetAction + /** Callback закрытия modal. */ + onClose: () => void + /** Callback успешного создания asset. */ + onCreated: (publicId: string) => void + /** Открыта ли modal. */ + opened: boolean +} diff --git a/apps/admin/src/screens/dashboard/parts/presets-panel/index.ts b/apps/admin/src/screens/dashboard/parts/presets-panel/index.ts new file mode 100644 index 0000000..e17307b --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/presets-panel/index.ts @@ -0,0 +1,2 @@ +export { PresetsPanel } from "./presets-panel" +export type { PresetsPanelProps } from "./types/presets-panel-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/presets-panel/presets-panel.tsx b/apps/admin/src/screens/dashboard/parts/presets-panel/presets-panel.tsx new file mode 100644 index 0000000..07d3c41 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/presets-panel/presets-panel.tsx @@ -0,0 +1,87 @@ +import { Badge, Code, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core" + +import type { PresetsPanelProps } from "./types/presets-panel-props.type" + +/** + * Панель presets и allowlist hosts. + * + * Используется для: + * - отображения static presets + * - отображения custom transform limits + */ +export const PresetsPanel = (props: PresetsPanelProps) => { + const { allowedSourceHosts, custom, isLoading, presets } = props + + return ( + + +
+ + Presets + + + Static transform profiles, formats, qualities и source allowlist. + +
+ {custom ? ( + + custom {custom.enabled ? "enabled" : "disabled"} + + ) : null} +
+ + {isLoading ? ( + + ) : ( + + + {presets.map((preset) => ( + + + + {preset.name} + + {preset.mode} + + + + {preset.resize}, q{preset.quality} + + formats: {preset.formats.join(", ")} + + sizes: {preset.widths?.join(", ") ?? `${preset.width}x${preset.height}`} + + + + ))} + + + {custom ? ( + + + max {custom.maxWidth}x{custom.maxHeight} + + + q{custom.quality} + + + {custom.formats.join(", ")} + + + ) : null} + + + + Allowed source hosts + + + {allowedSourceHosts.map((host) => ( + {host} + ))} + + + + )} +
+ ) +} diff --git a/apps/admin/src/screens/dashboard/parts/presets-panel/types/presets-panel-props.type.ts b/apps/admin/src/screens/dashboard/parts/presets-panel/types/presets-panel-props.type.ts new file mode 100644 index 0000000..ef9d120 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/presets-panel/types/presets-panel-props.type.ts @@ -0,0 +1,15 @@ +import type { AssetsDashboard } from "business/assets" + +/** + * Параметры PresetsPanel. + */ +export type PresetsPanelProps = { + /** Разрешённые source hosts. */ + allowedSourceHosts: string[] + /** Custom transform config. */ + custom: AssetsDashboard["custom"] + /** Признак загрузки presets. */ + isLoading: boolean + /** Список presets. */ + presets: AssetsDashboard["presets"] +} diff --git a/apps/admin/src/screens/dashboard/parts/summary-cards/index.ts b/apps/admin/src/screens/dashboard/parts/summary-cards/index.ts new file mode 100644 index 0000000..7188f58 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/summary-cards/index.ts @@ -0,0 +1,2 @@ +export { SummaryCards } from "./summary-cards" +export type { SummaryCardsProps } from "./types/summary-cards-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx b/apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx new file mode 100644 index 0000000..90451e9 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/summary-cards/summary-cards.tsx @@ -0,0 +1,37 @@ +import { Paper, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core" + +import { DASHBOARD_CARDS } from "../../config/dashboard.config" +import type { SummaryCardsProps } from "./types/summary-cards-props.type" + +/** + * Карточки сводных метрик dashboard. + * + * Используется для: + * - отображения количества assets, presets и hosts + * - компактного статуса загрузки данных + */ +export const SummaryCards = (props: SummaryCardsProps) => { + const { isLoading, summary } = props + + return ( + + {DASHBOARD_CARDS.map((card) => ( + + + {isLoading ? ( + + ) : ( + + {summary[card.metric]} + + )} + {card.title} + + {card.description} + + + + ))} + + ) +} diff --git a/apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts b/apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts new file mode 100644 index 0000000..606a9b5 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/summary-cards/types/summary-cards-props.type.ts @@ -0,0 +1,11 @@ +import type { AssetsDashboard } from "business/assets" + +/** + * Параметры SummaryCards. + */ +export type SummaryCardsProps = { + /** Признак загрузки данных. */ + isLoading: boolean + /** Сводные метрики dashboard. */ + summary: AssetsDashboard["summary"] +} diff --git a/apps/admin/src/screens/dashboard/styles/dashboard.module.css b/apps/admin/src/screens/dashboard/styles/dashboard.module.css index 31865d3..d0fb108 100644 --- a/apps/admin/src/screens/dashboard/styles/dashboard.module.css +++ b/apps/admin/src/screens/dashboard/styles/dashboard.module.css @@ -1,79 +1,44 @@ .root { - display: grid; - gap: var(--space-4); + min-width: 0; } .hero { - padding: var(--space-6); - border: 1px solid var(--color-border); - border-radius: var(--radius-5); - background: var(--color-surface); - box-shadow: var(--shadow-panel); + overflow: hidden; + background: + linear-gradient(135deg, rgb(255 255 255 / 92%), rgb(255 255 255 / 72%)), + radial-gradient(circle at 92% 8%, rgb(123 76 255 / 18%), transparent 18rem); +} - @media (--md) { - padding: var(--space-8); - } +.heroContent { + max-width: 54rem; } .eyebrow { - margin: 0 0 var(--space-4); + margin-bottom: var(--space-4); color: var(--color-accent); font-size: 0.8125rem; - font-weight: 800; + font-weight: 850; letter-spacing: 0.22em; text-transform: uppercase; } .title { - max-width: 48rem; - margin: 0; - font-size: clamp(2.75rem, 7vw, 5.5rem); + max-width: 50rem; + font-size: clamp(2.75rem, 7vw, 5.75rem); line-height: 0.9; - letter-spacing: -0.07em; + letter-spacing: -0.075em; } .lead { - max-width: 43rem; - margin: var(--space-5) 0 0; + max-width: 42rem; + margin-top: var(--space-5); color: var(--color-text-muted); font-size: 1.0625rem; line-height: 1.7; } -.grid { - display: grid; - grid-template-columns: 1fr; - gap: var(--space-3); - - @media (--md) { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } -} - -.card { - min-height: 9.5rem; - padding: var(--space-5); - border: 1px solid var(--color-border); - border-radius: var(--radius-4); - background: var(--color-surface-muted); -} - -.cardTitle { - margin: 0; - font-size: 1.125rem; - letter-spacing: -0.03em; -} - -.cardDescription { - margin: var(--space-3) 0 0; - color: var(--color-text-muted); - line-height: 1.55; -} - -.pipeline { - display: flex; - flex-wrap: wrap; - gap: var(--space-2); +.primaryAction { + box-shadow: var(--shadow-soft); } .pipelineStep { @@ -85,3 +50,13 @@ font-size: 0.8125rem; font-weight: 700; } + +.workbench { + display: grid; + grid-template-columns: 1fr; + gap: var(--space-4); + + @media (--md) { + grid-template-columns: minmax(0, 1.35fr) minmax(22rem, 0.65fr); + } +} diff --git a/apps/admin/src/shared/styles/global.css b/apps/admin/src/shared/styles/global.css index 8bfa9ed..31474c2 100644 --- a/apps/admin/src/shared/styles/global.css +++ b/apps/admin/src/shared/styles/global.css @@ -1,2 +1,4 @@ +@import "@mantine/core/styles.css"; +@import "@mantine/notifications/styles.css"; @import "./variables.css"; @import "./reset.css"; diff --git a/apps/admin/vite.config.ts b/apps/admin/vite.config.ts index 1ca2f91..a871d4f 100644 --- a/apps/admin/vite.config.ts +++ b/apps/admin/vite.config.ts @@ -4,6 +4,7 @@ import react from "@vitejs/plugin-react" import { defineConfig } from "vite" const srcPath = (path: string) => fileURLToPath(new URL(`./src/${path}`, import.meta.url)) +const backendProxyTarget = process.env.ADMIN_BACKEND_PROXY_TARGET ?? "http://localhost:3001" export default defineConfig({ plugins: [react()], @@ -19,4 +20,12 @@ export default defineConfig({ shared: srcPath("shared"), }, }, + server: { + proxy: { + "/api": { + changeOrigin: true, + target: backendProxyTarget, + }, + }, + }, }) diff --git a/package.json b/package.json index f9f72df..96ff3a1 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ }, "scripts": { "admin:build": "pnpm --filter @image-platform/admin build", + "admin:codegen": "pnpm --filter @image-platform/admin codegen:backend-api", "admin:dev": "pnpm --filter @image-platform/admin dev", "admin:preview": "pnpm --filter @image-platform/admin preview", "admin:typecheck": "pnpm --filter @image-platform/admin typecheck", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a68e9aa..91e7767 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,6 +10,18 @@ importers: apps/admin: dependencies: + '@mantine/core': + specifier: ^9.1.1 + version: 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/form': + specifier: ^9.1.1 + version: 9.1.1(react@19.2.5) + '@mantine/hooks': + specifier: ^9.1.1 + version: 9.1.1(react@19.2.5) + '@mantine/notifications': + specifier: ^9.1.1 + version: 9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5) clsx: specifier: ^2.1.1 version: 2.1.1 @@ -19,6 +31,9 @@ importers: react-dom: specifier: ^19.2.5 version: 19.2.5(react@19.2.5) + swr: + specifier: ^2.4.1 + version: 2.4.1(react@19.2.5) devDependencies: '@csstools/postcss-global-data': specifier: ^4.0.0 @@ -415,6 +430,10 @@ packages: resolution: {integrity: sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==} engines: {node: '>=6.9.0'} + '@babel/runtime@7.29.2': + resolution: {integrity: sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==} + engines: {node: '>=6.9.0'} + '@borewit/text-codec@0.2.2': resolution: {integrity: sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ==} @@ -946,6 +965,27 @@ packages: '@fastify/proxy-addr@5.1.0': resolution: {integrity: sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==} + '@floating-ui/core@1.7.5': + resolution: {integrity: sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==} + + '@floating-ui/dom@1.7.6': + resolution: {integrity: sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==} + + '@floating-ui/react-dom@2.1.8': + resolution: {integrity: sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + + '@floating-ui/react@0.27.19': + resolution: {integrity: sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==} + peerDependencies: + react: '>=17.0.0' + react-dom: '>=17.0.0' + + '@floating-ui/utils@0.2.11': + resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@inquirer/ansi@1.0.2': resolution: {integrity: sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==} engines: {node: '>=18'} @@ -1109,6 +1149,36 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mantine/core@9.1.1': + resolution: {integrity: sha512-vClOZdCeZ4oLYuA/3jAOgKGQ6dXbF6ZkzpYz09Gied9nZpB7HcQeb3dcMh8UPBE4f+EM7KlYWk6dch7GoASeaA==} + peerDependencies: + '@mantine/hooks': 9.1.1 + react: ^19.2.0 + react-dom: ^19.2.0 + + '@mantine/form@9.1.1': + resolution: {integrity: sha512-xmebZ3s8GGMrCOPOaOwA+gQkdgNVfT2F9kBtkjAbRoZrMoY+vYFbiPWbIvWFl8pU1jBslYZrj+M0PIawJmFOdQ==} + peerDependencies: + react: ^19.2.0 + + '@mantine/hooks@9.1.1': + resolution: {integrity: sha512-tTJK73nGFyy1v214TLdvBq0be7QCoc6osfbXVuJgOH3YG85lWk9Mvvor6k+w6hC6HXSqKMqLKePyiGm83xGcMg==} + peerDependencies: + react: ^19.2.0 + + '@mantine/notifications@9.1.1': + resolution: {integrity: sha512-ZfcEMMDp0BQ+yKmVp8ifPXLKej8pv9TcaRnmy2CZ07USD61E9LH5ClRAP/hxQuCyf/qLb5BPHsI7+f3K8uhj4Q==} + peerDependencies: + '@mantine/core': 9.1.1 + '@mantine/hooks': 9.1.1 + react: ^19.2.0 + react-dom: ^19.2.0 + + '@mantine/store@9.1.1': + resolution: {integrity: sha512-kbxEU8wVGbobHlmQmk0lu9M+xCILKjuAPcMAshgzPznGLfXeE9zrB0gNT2cbk11Ik8dlV9J6Vsn9cuACyOSpfQ==} + peerDependencies: + react: ^19.2.0 + '@microsoft/tsdoc@0.16.0': resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} @@ -1531,6 +1601,9 @@ packages: resolution: {integrity: sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g==} engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tokenizer/inflate@0.4.1': resolution: {integrity: sha512-2mAv+8pkG6GIZiF1kNg1jAjh27IDxEPKwdGul3snfztFerfPGI1LjDezZp3i7BElXompqEtPmoPx6c2wgtWsOA==} engines: {node: '>=18'} @@ -1969,6 +2042,12 @@ packages: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} + detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + + dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + drizzle-kit@0.31.10: resolution: {integrity: sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw==} hasBin: true @@ -2251,6 +2330,10 @@ packages: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} + get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + get-proto@1.0.1: resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} engines: {node: '>= 0.4'} @@ -2366,6 +2449,10 @@ packages: jsonfile@6.2.1: resolution: {integrity: sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==} + klona@2.0.6: + resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} + engines: {node: '>= 8'} + light-my-request@6.6.0: resolution: {integrity: sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==} @@ -2457,6 +2544,10 @@ packages: resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} engines: {node: '>=10'} + loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + lru-cache@11.3.6: resolution: {integrity: sha512-Gf/KoL3C/MlI7Bt0PGI9I+TeTC/I6r/csU58N4BSNc4lppLBeKsOdFYkK+dX0ABDUMJNfCHTyPpzwwO21Awd3A==} engines: {node: 20 || >=22} @@ -2707,6 +2798,9 @@ packages: process-warning@5.0.0: resolution: {integrity: sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==} + prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -2735,6 +2829,51 @@ packages: peerDependencies: react: ^19.2.5 + react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + react-number-format@5.4.5: + resolution: {integrity: sha512-y8O2yHHj3w0aE9XO8d2BCcUOOdQTRSVq+WIuMlLVucAm5XNjJAy+BoOJiuQMldVYVOKTMyvVNfnbl2Oqp+YxGw==} + peerDependencies: + react: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^0.14 || ^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + react-remove-scroll-bar@2.3.8: + resolution: {integrity: sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + + react-remove-scroll@2.7.2: + resolution: {integrity: sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-style-singleton@2.2.3: + resolution: {integrity: sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + react-transition-group@4.4.5: + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -2937,10 +3076,22 @@ packages: peerDependencies: express: '>=4.0.0 || >=5.0.0-beta' + swr@2.4.1: + resolution: {integrity: sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==} + peerDependencies: + react: ^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + symbol-observable@4.0.0: resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==} engines: {node: '>=0.10'} + tabbable@6.4.0: + resolution: {integrity: sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==} + + tagged-tag@1.0.0: + resolution: {integrity: sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==} + engines: {node: '>=20'} + tapable@2.3.3: resolution: {integrity: sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==} engines: {node: '>=6'} @@ -3002,6 +3153,10 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + type-fest@5.6.0: + resolution: {integrity: sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA==} + engines: {node: '>=20'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -3054,6 +3209,31 @@ packages: uri-js@4.4.1: resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + use-callback-ref@1.3.3: + resolution: {integrity: sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sidecar@1.1.3: + resolution: {integrity: sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + + use-sync-external-store@1.6.0: + resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -3638,6 +3818,8 @@ snapshots: '@babel/helper-validator-identifier@7.28.5': {} + '@babel/runtime@7.29.2': {} + '@borewit/text-codec@0.2.2': {} '@colors/colors@1.5.0': @@ -3944,6 +4126,31 @@ snapshots: '@fastify/forwarded': 3.0.1 ipaddr.js: 2.4.0 + '@floating-ui/core@1.7.5': + dependencies: + '@floating-ui/utils': 0.2.11 + + '@floating-ui/dom@1.7.6': + dependencies: + '@floating-ui/core': 1.7.5 + '@floating-ui/utils': 0.2.11 + + '@floating-ui/react-dom@2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/dom': 1.7.6 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + '@floating-ui/react@0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/react-dom': 2.1.8(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@floating-ui/utils': 0.2.11 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + tabbable: 6.4.0 + + '@floating-ui/utils@0.2.11': {} + '@inquirer/ansi@1.0.2': {} '@inquirer/checkbox@4.3.2(@types/node@24.12.2)': @@ -4105,6 +4312,43 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@floating-ui/react': 0.27.19(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': 9.1.1(react@19.2.5) + clsx: 2.1.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-number-format: 5.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + react-remove-scroll: 2.7.2(@types/react@19.2.14)(react@19.2.5) + type-fest: 5.6.0 + transitivePeerDependencies: + - '@types/react' + + '@mantine/form@9.1.1(react@19.2.5)': + dependencies: + '@standard-schema/spec': 1.1.0 + fast-deep-equal: 3.1.3 + klona: 2.0.6 + react: 19.2.5 + + '@mantine/hooks@9.1.1(react@19.2.5)': + dependencies: + react: 19.2.5 + + '@mantine/notifications@9.1.1(@mantine/core@9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5))(@mantine/hooks@9.1.1(react@19.2.5))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)': + dependencies: + '@mantine/core': 9.1.1(@mantine/hooks@9.1.1(react@19.2.5))(@types/react@19.2.14)(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + '@mantine/hooks': 9.1.1(react@19.2.5) + '@mantine/store': 9.1.1(react@19.2.5) + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react-transition-group: 4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5) + + '@mantine/store@9.1.1(react@19.2.5)': + dependencies: + react: 19.2.5 + '@microsoft/tsdoc@0.16.0': {} '@napi-rs/wasm-runtime@1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': @@ -4605,6 +4849,8 @@ snapshots: dependencies: tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} + '@tokenizer/inflate@0.4.1': dependencies: debug: 4.4.3 @@ -5054,6 +5300,13 @@ snapshots: detect-libc@2.1.2: {} + detect-node-es@1.1.0: {} + + dom-helpers@5.2.1: + dependencies: + '@babel/runtime': 7.29.2 + csstype: 3.2.3 + drizzle-kit@0.31.10: dependencies: '@drizzle-team/brocli': 0.10.2 @@ -5373,6 +5626,8 @@ snapshots: hasown: 2.0.3 math-intrinsics: 1.1.0 + get-nonce@1.0.1: {} + get-proto@1.0.1: dependencies: dunder-proto: 1.0.1 @@ -5471,6 +5726,8 @@ snapshots: optionalDependencies: graceful-fs: 4.2.11 + klona@2.0.6: {} + light-my-request@6.6.0: dependencies: cookie: 1.1.1 @@ -5539,6 +5796,10 @@ snapshots: chalk: 4.1.2 is-unicode-supported: 0.1.0 + loose-envify@1.4.0: + dependencies: + js-tokens: 4.0.0 + lru-cache@11.3.6: {} magic-string@0.30.17: @@ -5767,6 +6028,12 @@ snapshots: process-warning@5.0.0: {} + prop-types@15.8.1: + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -5794,6 +6061,49 @@ snapshots: react: 19.2.5 scheduler: 0.27.0 + react-is@16.13.1: {} + + react-number-format@5.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + + react-remove-scroll-bar@2.3.8(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-remove-scroll@2.7.2(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + react-remove-scroll-bar: 2.3.8(@types/react@19.2.14)(react@19.2.5) + react-style-singleton: 2.2.3(@types/react@19.2.14)(react@19.2.5) + tslib: 2.8.1 + use-callback-ref: 1.3.3(@types/react@19.2.14)(react@19.2.5) + use-sidecar: 1.1.3(@types/react@19.2.14)(react@19.2.5) + optionalDependencies: + '@types/react': 19.2.14 + + react-style-singleton@2.2.3(@types/react@19.2.14)(react@19.2.5): + dependencies: + get-nonce: 1.0.1 + react: 19.2.5 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + react-transition-group@4.4.5(react-dom@19.2.5(react@19.2.5))(react@19.2.5): + dependencies: + '@babel/runtime': 7.29.2 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 19.2.5 + react-dom: 19.2.5(react@19.2.5) + react@19.2.5: {} readable-stream@3.6.2: @@ -6018,8 +6328,18 @@ snapshots: express: 5.2.1 swagger-ui-dist: 5.32.5 + swr@2.4.1(react@19.2.5): + dependencies: + dequal: 2.0.3 + react: 19.2.5 + use-sync-external-store: 1.6.0(react@19.2.5) + symbol-observable@4.0.0: {} + tabbable@6.4.0: {} + + tagged-tag@1.0.0: {} + tapable@2.3.3: {} terser-webpack-plugin@5.5.0(webpack@5.106.0): @@ -6078,6 +6398,10 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + type-fest@5.6.0: + dependencies: + tagged-tag: 1.0.0 + type-is@1.6.18: dependencies: media-typer: 0.3.0 @@ -6119,6 +6443,25 @@ snapshots: dependencies: punycode: 2.3.1 + use-callback-ref@1.3.3(@types/react@19.2.14)(react@19.2.5): + dependencies: + react: 19.2.5 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sidecar@1.1.3(@types/react@19.2.14)(react@19.2.5): + dependencies: + detect-node-es: 1.1.0 + react: 19.2.5 + tslib: 2.8.1 + optionalDependencies: + '@types/react': 19.2.14 + + use-sync-external-store@1.6.0(react@19.2.5): + dependencies: + react: 19.2.5 + util-deprecate@1.0.2: {} vary@1.1.2: {}