This commit is contained in:
2026-05-12 07:54:32 +03:00
parent 0faa8b9d2d
commit d49449c30c
187 changed files with 4826 additions and 5884 deletions

View File

@@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<title>Image Platform Admin</title>
</head>
<body>

View File

@@ -18,6 +18,7 @@
"clsx": "^2.1.1",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.15.0",
"swr": "^2.4.1"
},
"devDependencies": {

View File

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

After

Width:  |  Height:  |  Size: 238 B

View File

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

View File

@@ -1,13 +1,17 @@
import { BrowserRouter } from "react-router-dom"
import { ThemeProvider } from "infra/theme"
import { MainLayout } from "layouts/main"
import { DashboardScreen } from "screens/dashboard"
import { AppRouter } from "./app-router"
export function App() {
return (
<ThemeProvider>
<MainLayout>
<DashboardScreen />
</MainLayout>
</ThemeProvider>
<BrowserRouter>
<ThemeProvider>
<MainLayout>
<AppRouter />
</MainLayout>
</ThemeProvider>
</BrowserRouter>
)
}

View File

@@ -1,9 +1,12 @@
import { useAssetPicture } from "./hooks/use-asset-picture.hook"
import { useAssetVersions } from "./hooks/use-asset-versions.hook"
import { useAssetOverview } from "./hooks/use-asset-overview.hook"
import { useAssetsDashboard } from "./hooks/use-assets-dashboard.hook"
import { useCreateAsset } from "./hooks/use-create-asset.hook"
import { useCreateAssetVersion } from "./hooks/use-create-asset-version.hook"
import { useGenerateAssetVariants } from "./hooks/use-generate-asset-variants.hook"
import { useImagePresets } from "./hooks/use-image-presets.hook"
import { useProjectAssets } from "./hooks/use-project-assets.hook"
import type { AssetsFactory } from "./types/assets-factory.type"
/**
@@ -13,9 +16,12 @@ export const assetsFactory: AssetsFactory = () => {
return {
useAssetOverview,
useAssetPicture,
useAssetVersions,
useAssetsDashboard,
useCreateAsset,
useCreateAssetVersion,
useGenerateAssetVariants,
useImagePresets,
useProjectAssets,
}
}

View File

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

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import { useSWRConfig } from "swr"
import { backendApi, getAssetKey, getAssetVariantsKey, getAssetsListKey } from "infra/backend-api"
import { backendApi, getAssetKey, getAssetVariantsKey, getAssetVersionsKey, getAssetsListKey } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error"
@@ -27,6 +27,7 @@ export const useCreateAssetVersion = (): CreateAssetVersionAction => {
await Promise.all([
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
mutate(getAssetKey(input.publicId)),
mutate(getAssetVersionsKey(input.publicId)),
mutate(getAssetVariantsKey(input.publicId, String(createdVersion.version))),
])

View File

@@ -1,6 +1,6 @@
import { useState } from "react"
import { useSWRConfig } from "swr"
import { backendApi, getAssetsListKey } from "infra/backend-api"
import { backendApi, getAssetsListKey, getProjectAssetsKey } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error"
@@ -19,8 +19,15 @@ export const useCreateAsset = (): CreateAssetAction => {
setIsCreating(true)
try {
const createdAsset = await backendApi.assets.createAsset(input)
await mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS))
const { projectSlug, ...request } = input
const createdAsset = projectSlug
? await backendApi.projects.createProjectAsset({ projectSlug }, request)
: await backendApi.assets.createAsset(request)
await Promise.all([
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
projectSlug ? mutate(getProjectAssetsKey(projectSlug, ASSETS_DASHBOARD_LIST_PARAMS)) : Promise.resolve(),
])
return createdAsset
} catch (caughtError) {

View File

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

View File

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

View File

@@ -2,6 +2,7 @@ export { assetsFactory } from "./assets.factory"
export type {
AssetOverview,
AssetPicturePreview,
AssetVersionsHistory,
AssetsApi,
AssetsDashboard,
AssetVariantFormat,
@@ -13,5 +14,7 @@ export type {
CreateAssetVersionInput,
GenerateAssetVariantsAction,
GenerateAssetVariantsInput,
ImagePresetsOverview,
ProjectAssetsOverview,
} from "./types/assets-api.type"
export type { AssetsFactory } from "./types/assets-factory.type"

View File

@@ -2,6 +2,7 @@ import type {
AssetResponseDto,
AssetPictureResponseDto,
AssetVariantResponseDto,
AssetVersionResponseDto,
CreateAssetRequestDto,
CreateAssetResponseDto,
CreateAssetVersionResponseDto,
@@ -32,6 +33,33 @@ export type AssetPicturePreview = {
refresh: () => Promise<void>
}
export type AssetVersionsHistory = {
currentVersion: number | null
error?: Error
isLoading: boolean
isRefreshing: boolean
publicId: string | null
refresh: () => Promise<void>
versions: AssetVersionResponseDto[]
}
export type ProjectAssetsOverview = {
assets: AssetResponseDto[]
error?: Error
isLoading: boolean
isRefreshing: boolean
refresh: () => Promise<void>
}
export type ImagePresetsOverview = {
custom: PresetsResponseDto["custom"] | null
error?: Error
isLoading: boolean
isRefreshing: boolean
presets: PresetResponseDto[]
refresh: () => Promise<void>
}
export type AssetsDashboard = {
allowedSourceHosts: string[]
assets: AssetResponseDto[]
@@ -46,7 +74,9 @@ export type AssetsDashboard = {
}
}
export type CreateAssetInput = CreateAssetRequestDto
export type CreateAssetInput = CreateAssetRequestDto & {
projectSlug?: string
}
export type CreateAssetAction = {
createAsset: (input: CreateAssetInput) => Promise<CreateAssetResponseDto>
@@ -90,8 +120,11 @@ export type GenerateAssetVariantsAction = {
export type AssetsApi = {
useAssetOverview: (publicId: string | null) => AssetOverview
useAssetPicture: (publicId: string | null, preset: string | null) => AssetPicturePreview
useAssetVersions: (publicId: string | null) => AssetVersionsHistory
useAssetsDashboard: () => AssetsDashboard
useCreateAsset: () => CreateAssetAction
useCreateAssetVersion: () => CreateAssetVersionAction
useGenerateAssetVariants: () => GenerateAssetVariantsAction
useImagePresets: () => ImagePresetsOverview
useProjectAssets: (projectSlug: string | null) => ProjectAssetsOverview
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -112,6 +112,79 @@ export interface CreateAssetResponseDto {
imageBasePath: string;
}
export interface AssetVersionResponseDto {
/**
* Внутренний UUID версии source image.
* @example "3b5da974-bb7f-4d73-b172-d6ad9c244528"
*/
id: string;
/**
* Номер версии source image.
* @example 2
*/
version: number;
/**
* Является ли версия текущей для asset.
* @example true
*/
isCurrent: boolean;
/**
* Source URL версии.
* @example "https://storage.yandexcloud.net/shared1318/img/1.jpg"
*/
sourceUrl: string;
/**
* Hostname source URL версии.
* @example "storage.yandexcloud.net"
*/
sourceHost: string;
/**
* Базовый Gateway path для версии.
* @example "/images/asset_demo/v2/card"
*/
imageBasePath: string;
/**
* Ширина оригинального изображения, если уже определена Worker.
* @example 1200
*/
width?: number | null;
/**
* Высота оригинального изображения, если уже определена Worker.
* @example 800
*/
height?: number | null;
/**
* Content-Type оригинального изображения, если уже определён Worker.
* @example "image/jpeg"
*/
contentType?: string | null;
/**
* Размер оригинального изображения в bytes, если уже определён Worker.
* @example 245760
*/
sizeBytes?: number | null;
/**
* Дата создания версии.
* @example "2026-05-05T12:00:00.000Z"
*/
createdAt: string;
}
export interface AssetVersionsResponseDto {
/**
* Публичный идентификатор asset.
* @example "asset_demo"
*/
publicId: string;
/**
* Текущая версия source image.
* @example 2
*/
currentVersion: number;
/** История версий source image. */
versions: AssetVersionResponseDto[];
}
export interface CreateAssetVersionRequestDto {
/**
* Постоянная ссылка на новую версию исходного изображения.
@@ -543,6 +616,62 @@ export interface PresetsResponseDto {
allowedSourceHosts: string[];
}
export interface ProjectResponseDto {
/**
* Внутренний UUID проекта.
* @example "59fcf4f6-9891-4df4-8bb7-a4dbe570bb66"
*/
id: string;
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
slug: string;
/**
* Название проекта.
* @example "Demo Shop"
*/
name: string;
/**
* Статус проекта.
* @example "active"
*/
status: ProjectResponseDtoStatusEnum;
/**
* Количество assets в проекте.
* @example 12
*/
assetsCount: number;
/**
* Дата создания проекта.
* @example "2026-05-05T12:00:00.000Z"
*/
createdAt: string;
/**
* Дата обновления проекта.
* @example "2026-05-05T12:00:00.000Z"
*/
updatedAt: string;
}
export interface ProjectsListResponseDto {
/** Список проектов. */
projects: ProjectResponseDto[];
}
export interface CreateProjectRequestDto {
/**
* Название проекта в admin UI.
* @example "Demo Shop"
*/
name: string;
/**
* Публичный slug проекта для URL и SDK.
* @example "demo-shop"
*/
slug?: string;
}
/**
* Статус asset.
* @example "active"
@@ -704,6 +833,15 @@ export enum PresetResponseDtoResizeEnum {
Fill = "fill",
}
/**
* Статус проекта.
* @example "active"
*/
export enum ProjectResponseDtoStatusEnum {
Active = "active",
Disabled = "disabled",
}
export interface ListAssetsParams {
/**
* Максимальное количество assets в ответе.
@@ -725,6 +863,14 @@ export interface GetAssetParams {
publicId: string;
}
export interface ListAssetVersionsParams {
/**
* Публичный идентификатор asset.
* @example "asset_demo"
*/
publicId: string;
}
export interface CreateAssetVersionParams {
/**
* Публичный идентификатор asset.
@@ -782,6 +928,40 @@ export interface CreateAssetVariantsParams {
publicId: string;
}
export interface GetProjectParams {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
}
export interface ListProjectAssetsParams {
/**
* Максимальное количество assets в ответе.
* @example 50
*/
limit?: string;
/**
* Смещение для простого paging.
* @example 0
*/
offset?: string;
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
}
export interface CreateProjectAssetParams {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
}
export namespace System {
/**
* @description Возвращает простой health-check ответ. Используется для локальной проверки, мониторинга и smoke tests.
@@ -862,6 +1042,27 @@ export namespace Assets {
export type ResponseBody = AssetResponseDto;
}
/**
* @description Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.
* @tags assets
* @name ListAssetVersions
* @summary получить историю версий source image
* @request GET:/api/assets/{publicId}/versions
*/
export namespace ListAssetVersions {
export type RequestParams = {
/**
* Публичный идентификатор asset.
* @example "asset_demo"
*/
publicId: string;
};
export type RequestQuery = {};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = AssetVersionsResponseDto;
}
/**
* @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs.
* @tags assets
@@ -1008,6 +1209,112 @@ export namespace Presets {
}
}
export namespace Projects {
/**
* @description Возвращает проекты верхнего уровня для главной страницы admin.
* @tags projects
* @name ListProjects
* @summary получить список проектов
* @request GET:/api/projects
*/
export namespace ListProjects {
export type RequestParams = {};
export type RequestQuery = {};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = ProjectsListResponseDto;
}
/**
* @description Создаёт проект, внутри которого admin управляет assets и source versions.
* @tags projects
* @name CreateProject
* @summary создать проект
* @request POST:/api/projects
*/
export namespace CreateProject {
export type RequestParams = {};
export type RequestQuery = {};
export type RequestBody = CreateProjectRequestDto;
export type RequestHeaders = {};
export type ResponseBody = ProjectResponseDto;
}
/**
* @description Возвращает metadata проекта для project-level страницы admin.
* @tags projects
* @name GetProject
* @summary получить проект по slug
* @request GET:/api/projects/{projectSlug}
*/
export namespace GetProject {
export type RequestParams = {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
};
export type RequestQuery = {};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = ProjectResponseDto;
}
/**
* @description Возвращает assets, созданные внутри выбранного проекта.
* @tags projects
* @name ListProjectAssets
* @summary получить assets проекта
* @request GET:/api/projects/{projectSlug}/assets
*/
export namespace ListProjectAssets {
export type RequestParams = {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
};
export type RequestQuery = {
/**
* Максимальное количество assets в ответе.
* @example 50
*/
limit?: string;
/**
* Смещение для простого paging.
* @example 0
*/
offset?: string;
};
export type RequestBody = never;
export type RequestHeaders = {};
export type ResponseBody = AssetsListResponseDto;
}
/**
* @description Создаёт asset и первую source version внутри выбранного проекта.
* @tags projects
* @name CreateProjectAsset
* @summary создать asset в проекте
* @request POST:/api/projects/{projectSlug}/assets
*/
export namespace CreateProjectAsset {
export type RequestParams = {
/**
* Публичный slug проекта.
* @example "demo-shop"
*/
projectSlug: string;
};
export type RequestQuery = {};
export type RequestBody = CreateAssetRequestDto;
export type RequestHeaders = {};
export type ResponseBody = CreateAssetResponseDto;
}
}
/**
* Фетчер для SWR
* Принимает URL и возвращает Promise с данными
@@ -1366,6 +1673,25 @@ export class Api<SecurityDataType extends unknown> {
...params,
}),
/**
* @description Возвращает все зарегистрированные source versions asset с current marker и versioned Gateway base path для каждой версии.
*
* @tags assets
* @name ListAssetVersions
* @summary получить историю версий source image
* @request GET:/api/assets/{publicId}/versions
*/
listAssetVersions: (
{ publicId, ...query }: ListAssetVersionsParams,
params: RequestParams = {},
) =>
this.http.request<AssetVersionsResponseDto, void>({
path: `/api/assets/${publicId}/versions`,
method: "GET",
format: "json",
...params,
}),
/**
* @description Регистрирует новый source URL для существующего asset, увеличивает currentVersion и тем самым создаёт новый immutable Gateway URL `/v{version}` без purge старых URLs.
*
@@ -1489,4 +1815,103 @@ export class Api<SecurityDataType extends unknown> {
...params,
}),
};
projects = {
/**
* @description Возвращает проекты верхнего уровня для главной страницы admin.
*
* @tags projects
* @name ListProjects
* @summary получить список проектов
* @request GET:/api/projects
*/
listProjects: (params: RequestParams = {}) =>
this.http.request<ProjectsListResponseDto, any>({
path: `/api/projects`,
method: "GET",
format: "json",
...params,
}),
/**
* @description Создаёт проект, внутри которого admin управляет assets и source versions.
*
* @tags projects
* @name CreateProject
* @summary создать проект
* @request POST:/api/projects
*/
createProject: (
data: CreateProjectRequestDto,
params: RequestParams = {},
) =>
this.http.request<ProjectResponseDto, void>({
path: `/api/projects`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
/**
* @description Возвращает metadata проекта для project-level страницы admin.
*
* @tags projects
* @name GetProject
* @summary получить проект по slug
* @request GET:/api/projects/{projectSlug}
*/
getProject: (
{ projectSlug, ...query }: GetProjectParams,
params: RequestParams = {},
) =>
this.http.request<ProjectResponseDto, void>({
path: `/api/projects/${projectSlug}`,
method: "GET",
format: "json",
...params,
}),
/**
* @description Возвращает assets, созданные внутри выбранного проекта.
*
* @tags projects
* @name ListProjectAssets
* @summary получить assets проекта
* @request GET:/api/projects/{projectSlug}/assets
*/
listProjectAssets: (
{ projectSlug, ...query }: ListProjectAssetsParams,
params: RequestParams = {},
) =>
this.http.request<AssetsListResponseDto, void>({
path: `/api/projects/${projectSlug}/assets`,
method: "GET",
query: query,
format: "json",
...params,
}),
/**
* @description Создаёт asset и первую source version внутри выбранного проекта.
*
* @tags projects
* @name CreateProjectAsset
* @summary создать asset в проекте
* @request POST:/api/projects/{projectSlug}/assets
*/
createProjectAsset: (
{ projectSlug, ...query }: CreateProjectAssetParams,
data: CreateAssetRequestDto,
params: RequestParams = {},
) =>
this.http.request<CreateAssetResponseDto, void>({
path: `/api/projects/${projectSlug}/assets`,
method: "POST",
body: data,
type: ContentType.Json,
format: "json",
...params,
}),
};
}

View File

@@ -2,5 +2,9 @@ export { getAssetPictureKey, useGetAssetPicture } from "./use-get-asset-picture.
export type { AssetPictureQuery } from "./use-get-asset-picture.hook"
export { getAssetKey, useGetAsset } from "./use-get-asset.hook"
export { getAssetVariantsKey, useGetAssetVariants } from "./use-get-asset-variants.hook"
export { getAssetVersionsKey, useGetAssetVersions } from "./use-get-asset-versions.hook"
export { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook"
export { getPresetsKey, useGetPresets } from "./use-get-presets.hook"
export { getProjectKey, useGetProject } from "./use-get-project.hook"
export { getProjectAssetsKey, useGetProjectAssets } from "./use-get-project-assets.hook"
export { getProjectsListKey, useGetProjectsList } from "./use-get-projects-list.hook"

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,8 @@ export type {
AssetPictureResponseDto,
AssetVariantResponseDto,
AssetVariantsResponseDto,
AssetVersionResponseDto,
AssetVersionsResponseDto,
AssetsListResponseDto,
CreateAssetRequestDto,
CreateAssetResponseDto,
@@ -12,9 +14,13 @@ export type {
CreateAssetVersionResponseDto,
CreateAssetVariantsRequestDto,
CreateAssetVariantsResponseDto,
CreateProjectRequestDto,
CustomTransformConfigResponseDto,
GetAssetPictureParams,
ListAssetsParams,
ListProjectAssetsParams,
PresetResponseDto,
PresetsResponseDto,
ProjectResponseDto,
ProjectsListResponseDto,
} from "./generated/backend-api.generated"

View File

@@ -1,11 +1,25 @@
import { createTheme } from "@mantine/core"
export const ADMIN_THEME = createTheme({
colors: {
forest: [
"#edf3ed",
"#dfe8df",
"#bdcfbe",
"#98b199",
"#77967a",
"#5f8164",
"#506f55",
"#445846",
"#394a3c",
"#303f33",
],
},
defaultRadius: "lg",
fontFamily: "var(--font-sans)",
headings: {
fontFamily: "var(--font-sans)",
fontWeight: "850",
},
primaryColor: "violet",
primaryColor: "forest",
})

View File

@@ -1,5 +1,6 @@
import { AppShell, Badge, Group, Text, ThemeIcon } from "@mantine/core"
import { AppShell, Group, Text, ThemeIcon } from "@mantine/core"
import cl from "clsx"
import { Link } from "react-router-dom"
import styles from "./styles/main.module.css"
import type { MainLayoutProps } from "./types/main.type"
@@ -18,18 +19,16 @@ export const MainLayout = (props: MainLayoutProps) => {
<AppShell {...rootAttrs} className={cl(styles.root, className)} header={{ height: 72 }} padding="md">
<AppShell.Header className={styles.header}>
<Group h="100%" justify="space-between" px={{ base: "md", md: "xl" }}>
<a className={styles.brand} href="/" aria-label="Image Platform Admin">
<Link className={styles.brand} to="/" aria-label="Админка платформы изображений">
<ThemeIcon className={styles.brandMark} radius="xl" size={42} variant="light">
IP
</ThemeIcon>
<Text className={styles.brandText} fw={850}>
Image Platform
Платформа изображений
</Text>
</a>
</Link>
<Badge color="violet" radius="xl" size="lg" variant="light">
Admin MVP
</Badge>
<Text className={styles.sectionLabel}>Админка</Text>
</Group>
</AppShell.Header>

View File

@@ -1,15 +1,11 @@
.root {
min-height: 100vh;
background:
radial-gradient(circle at 16% 12%, var(--color-accent-wash), transparent 32rem),
radial-gradient(circle at 86% 4%, rgb(255 176 96 / 16%), transparent 28rem),
var(--color-page);
background: var(--color-page);
}
.header {
border-bottom: 1px solid var(--color-border);
background: rgb(247 244 238 / 78%);
backdrop-filter: blur(18px);
background: var(--color-header);
}
.brand {
@@ -24,7 +20,6 @@
border: 1px solid var(--color-border);
background: var(--color-surface);
color: var(--color-accent);
box-shadow: var(--shadow-soft);
font-size: 0.8125rem;
font-weight: 850;
letter-spacing: 0.08em;
@@ -32,12 +27,21 @@
.brandText {
display: none;
color: var(--color-text);
@media (--sm) {
display: block;
}
}
.sectionLabel {
color: var(--color-text-subtle);
font-size: 0.75rem;
font-weight: 760;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.main {
background: transparent;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import { Navigate, useParams } from "react-router-dom"
import { ProjectAssetsScreen } from "screens/project-assets"
export const ProjectAssetsPage = () => {
const { projectSlug } = useParams()
if (!projectSlug) {
return <Navigate replace to="/" />
}
return <ProjectAssetsScreen projectSlug={projectSlug} />
}

View File

@@ -0,0 +1 @@
export { ProjectsPage } from "./projects.page"

View File

@@ -0,0 +1,3 @@
import { ProjectsScreen } from "screens/projects"
export const ProjectsPage = () => <ProjectsScreen />

View File

@@ -0,0 +1,119 @@
import { Alert, Anchor, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import cl from "clsx"
import { useState } from "react"
import { Link, useNavigate } from "react-router-dom"
import { assetsFactory } from "business/assets"
import { projectsFactory } from "business/projects"
import styles from "screens/shared/styles/screen.module.css"
import { AssetDetailPanel } from "./parts/asset-detail-panel"
import { CreateSourceVersionModal } from "./parts/create-source-version-modal"
import { GenerateVariantsModal } from "./parts/generate-variants-modal"
import { PicturePreviewPanel } from "./parts/picture-preview-panel"
import { PresetsPanel } from "./parts/presets-panel"
import { SourceVersionsPanel } from "./parts/source-versions-panel"
import type { AssetDetailScreenProps } from "./types/asset-detail-screen-props.type"
const assets = assetsFactory()
const projects = projectsFactory()
/**
* Детальная страница asset внутри проекта.
*/
export const AssetDetailScreen = (props: AssetDetailScreenProps) => {
const { className, projectSlug, publicId, ...rootAttrs } = props
const navigate = useNavigate()
const [selectedPicturePreset, setSelectedPicturePreset] = useState<string | null>(null)
const [isCreateVersionOpen, createVersionModal] = useDisclosure(false)
const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false)
const dashboard = assets.useAssetsDashboard()
const overview = assets.useAssetOverview(publicId)
const projectDetail = projects.useProjectDetail(projectSlug)
const createAssetVersion = assets.useCreateAssetVersion()
const generateAssetVariants = assets.useGenerateAssetVariants()
const sourceVersions = assets.useAssetVersions(publicId)
const effectivePicturePreset = selectedPicturePreset ?? dashboard.presets[0]?.name ?? null
const picturePreview = assets.useAssetPicture(publicId, effectivePicturePreset)
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Stack gap="lg">
<Group gap="xs">
<Anchor component={Link} to="/">
Проекты
</Anchor>
<Text c="dimmed">/</Text>
<Anchor component={Link} to={`/projects/${projectSlug}`}>
{projectDetail.project?.name ?? projectSlug}
</Anchor>
<Text c="dimmed">/</Text>
<Text fw={700}>{publicId}</Text>
</Group>
<Paper className={styles.hero} p={{ base: "xl", md: 42 }} radius="xl" shadow="xs" withBorder>
<Group align="flex-end" justify="space-between" gap="xl">
<div className={styles.heroContent}>
<Text className={styles.eyebrow}>Изображение</Text>
<Title className={styles.title}>{publicId}</Title>
<Text className={styles.lead}>Метаданные источника, версии, варианты и контракт для picture/srcset.</Text>
</div>
<Button onClick={() => navigate(`/projects/${projectSlug}`)} radius="xl" size="md" variant="light">
К изображениям
</Button>
</Group>
</Paper>
{dashboard.error || overview.error || sourceVersions.error || picturePreview.error ? (
<Alert color="red" radius="lg" title="Данные изображения недоступны">
Проверьте backend API и существование изображения `{publicId}`.
</Alert>
) : null}
<div className={styles.workbench}>
<AssetDetailPanel
onCreateVersion={createVersionModal.open}
onGenerateVariants={generateVariantsModal.open}
overview={overview}
publicId={publicId}
/>
<PresetsPanel
allowedSourceHosts={dashboard.allowedSourceHosts}
custom={dashboard.custom}
isLoading={dashboard.isLoading}
presets={dashboard.presets}
/>
</div>
<SourceVersionsPanel history={sourceVersions} publicId={publicId} />
<PicturePreviewPanel
onPresetChange={setSelectedPicturePreset}
picturePreview={picturePreview}
presets={dashboard.presets}
publicId={publicId}
selectedPreset={effectivePicturePreset}
/>
</Stack>
<CreateSourceVersionModal
action={createAssetVersion}
onClose={createVersionModal.close}
onCreated={() => undefined}
opened={isCreateVersionOpen}
publicId={publicId}
/>
<GenerateVariantsModal
action={generateAssetVariants}
asset={overview.asset}
custom={dashboard.custom}
onClose={generateVariantsModal.close}
onGenerated={() => undefined}
opened={isGenerateVariantsOpen}
presets={dashboard.presets}
/>
</section>
)
}

View File

@@ -0,0 +1 @@
export { AssetDetailScreen } from "./asset-detail.screen"

View File

@@ -14,9 +14,14 @@ import {
Title,
} from "@mantine/core"
import { ASSET_STATUS_COLORS, VARIANT_STATUS_COLORS } from "../../config/dashboard.config"
import { copyText } from "../../lib/copy-text"
import { formatDateTime } from "../../lib/format-date"
import {
ASSET_STATUS_COLORS,
ASSET_STATUS_LABELS,
VARIANT_STATUS_COLORS,
VARIANT_STATUS_LABELS,
} from "screens/shared/config/image-ui.config"
import { copyText } from "screens/shared/lib/copy-text"
import { formatDateTime } from "screens/shared/lib/format-date"
import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type"
/**
@@ -34,10 +39,10 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
return (
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
<Title order={2} size="h3">
Asset detail
Детали изображения
</Title>
<Text c="dimmed" mt="sm">
Выберите asset из таблицы, чтобы увидеть source URL и variants.
Выберите изображение из списка, чтобы увидеть URL исходника и варианты.
</Text>
</Paper>
)
@@ -48,7 +53,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Group align="start" justify="space-between" mb="lg">
<div>
<Title order={2} size="h3">
Asset detail
Детали изображения
</Title>
<Text c="dimmed" fz="sm">
{publicId}
@@ -58,23 +63,23 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
{asset ? (
<Group gap="xs">
<Button onClick={onCreateVersion} radius="xl" size="xs" variant="light">
New source version
Новая версия источника
</Button>
<Button onClick={onGenerateVariants} radius="xl" size="xs">
Generate variants
Сгенерировать варианты
</Button>
<Button onClick={() => void copyText(asset.publicId, "publicId")} radius="xl" size="xs" variant="subtle">
Copy ID
<Button onClick={() => void copyText(asset.publicId, "публичный ID")} radius="xl" size="xs" variant="subtle">
Скопировать ID
</Button>
<Button loading={overview.isRefreshing} onClick={overview.refresh} radius="xl" size="xs" variant="subtle">
Refresh
Обновить
</Button>
<Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
{asset.status}
{ASSET_STATUS_LABELS[asset.status] ?? asset.status}
</Badge>
{overview.hasRunningVariants ? (
<Badge color="yellow" radius="xl" variant="light">
polling variants
обновляем варианты
</Badge>
) : null}
</Group>
@@ -87,14 +92,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Stack gap="lg">
<Stack gap={6}>
<Text c="dimmed" fz="sm">
Source URL
URL исходника
</Text>
<Group align="center" gap="xs">
<Anchor href={asset.sourceUrl} target="_blank">
{asset.sourceUrl}
</Anchor>
<Button onClick={() => void copyText(asset.sourceUrl, "source URL")} radius="xl" size="compact-xs" variant="subtle">
Copy
<Button onClick={() => void copyText(asset.sourceUrl, "URL исходника")} radius="xl" size="compact-xs" variant="subtle">
Скопировать
</Button>
</Group>
</Stack>
@@ -107,14 +112,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
{asset.sourceHost}
</Badge>
<Badge color="gray" radius="xl" variant="light">
updated {formatDateTime(asset.updatedAt)}
обновлено {formatDateTime(asset.updatedAt)}
</Badge>
</Group>
<Stack gap="sm">
<Group justify="space-between">
<Title order={3} size="h4">
Variants
Варианты
</Title>
<Badge radius="xl" variant="light">
{variants.length}
@@ -126,11 +131,11 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Preview</Table.Th>
<Table.Th>Preset</Table.Th>
<Table.Th>Format</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Превью</Table.Th>
<Table.Th>Пресет</Table.Th>
<Table.Th>Формат</Table.Th>
<Table.Th>Размер</Table.Th>
<Table.Th>Статус</Table.Th>
<Table.Th>URL</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -142,7 +147,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Image alt={`${variant.preset} ${variant.format}`} fit="cover" h={46} radius="md" src={variant.url} w={72} />
) : (
<Text c="dimmed" fz="xs">
not ready
не готово
</Text>
)}
</Table.Td>
@@ -151,20 +156,20 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
</Table.Td>
<Table.Td>{variant.format}</Table.Td>
<Table.Td>
{variant.width}x{variant.height || "auto"} q{variant.quality}
{variant.width}x{variant.height || "авто"} q{variant.quality}
</Table.Td>
<Table.Td>
<Badge color={VARIANT_STATUS_COLORS[variant.status] ?? "gray"} radius="xl" variant="light">
{variant.status}
{VARIANT_STATUS_LABELS[variant.status] ?? variant.status}
</Badge>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Anchor href={variant.url} target="_blank">
open
открыть
</Anchor>
<Button onClick={() => void copyText(variant.url, "variant URL")} size="compact-xs" variant="subtle">
copy
<Button onClick={() => void copyText(variant.url, "URL варианта")} size="compact-xs" variant="subtle">
скопировать
</Button>
</Group>
</Table.Td>
@@ -174,12 +179,12 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
</Table>
</ScrollArea>
) : (
<Text c="dimmed">Variants для текущей версии пока не созданы.</Text>
<Text c="dimmed">Варианты для текущей версии пока не созданы.</Text>
)}
</Stack>
</Stack>
) : (
<Text c="dimmed">Asset не найден или ещё загружается.</Text>
<Text c="dimmed">Изображение не найдено или ещё загружается.</Text>
)}
</Paper>
)

View File

@@ -29,7 +29,7 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
validate: {
sourceUrl: (value) => {
if (!value.trim()) {
return "Укажите source URL"
return "Укажите URL исходника"
}
try {
@@ -60,8 +60,8 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
})
notifications.show({
color: "green",
message: `Asset ${createdVersion.publicId} обновлён до v${createdVersion.version}`,
title: "Source version created",
message: `Изображение ${createdVersion.publicId} обновлено до v${createdVersion.version}`,
title: "Версия источника создана",
})
form.reset()
onCreated(createdVersion.publicId)
@@ -70,24 +70,24 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
notifications.show({
color: "red",
message: toErrorMessage(error),
title: "Не удалось создать source version",
title: "Не удалось создать версию источника",
})
}
})
return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="New source version">
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="Новая версия источника">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text c="dimmed" fz="sm">
Новая source version изменит currentVersion asset. Старые public URLs останутся immutable.
Новая версия источника изменит текущую версию изображения. Старые публичные URL останутся неизменяемыми.
</Text>
<TextInput label="Asset" readOnly value={publicId ?? ""} />
<TextInput label="Изображение" readOnly value={publicId ?? ""} />
<TextInput
disabled={action.isCreating}
label="New source URL"
label="Новый URL исходника"
placeholder={SOURCE_URL_EXAMPLE}
required
{...form.getInputProps("sourceUrl")}
@@ -95,10 +95,10 @@ export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) =
<Group justify="flex-end">
<Button color="gray" disabled={action.isCreating} onClick={handleClose} variant="subtle">
Cancel
Отмена
</Button>
<Button disabled={!publicId} loading={action.isCreating} type="submit">
Create version
Создать версию
</Button>
</Group>
</Stack>

View File

@@ -66,12 +66,12 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
width: "",
},
validate: {
preset: (value) => (value ? null : "Выберите preset"),
preset: (value) => (value ? null : "Выберите пресет"),
width: (value, values) => {
const selectedPreset = presets.find((preset) => preset.name === values.preset)
const needsWidth = values.mode === "single" && (values.preset === "custom" || selectedPreset?.mode === "responsive")
return needsWidth && !toOptionalNumber(value) ? "Укажите width" : null
return needsWidth && !toOptionalNumber(value) ? "Укажите ширину" : null
},
},
})
@@ -81,13 +81,13 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
const availableFormats = isCustom ? (custom?.formats ?? []) : (selectedPreset?.formats ?? FORMAT_OPTIONS)
const presetOptions = [
...presets.map((preset) => ({
label: `${preset.name} (${preset.mode})`,
label: `${preset.name} (${formatPresetMode(preset.mode)})`,
value: preset.name,
})),
...(custom?.enabled ? [{ label: "custom", value: "custom" }] : []),
...(custom?.enabled ? [{ label: "произвольный", value: "custom" }] : []),
]
const formatOptions = availableFormats.map((format) => ({ label: format, value: format }))
const widthHint = selectedPreset?.widths?.length ? `Allowed: ${selectedPreset.widths.join(", ")}` : undefined
const widthHint = selectedPreset?.widths?.length ? `Разрешено: ${selectedPreset.widths.join(", ")}` : undefined
const handleClose = () => {
if (!action.isGenerating) {
@@ -103,7 +103,7 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
if (values.mode === "family" && isCustom) {
notifications.show({
color: "red",
message: "Custom transform поддерживает только single generation.",
message: "Произвольная трансформация поддерживает только одиночную генерацию.",
title: "Некорректный режим",
})
return
@@ -130,8 +130,8 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
const response = await action.generateAssetVariants(input)
notifications.show({
color: "green",
message: `Поставлено variants: ${response.variants.length}`,
title: "Generation jobs created",
message: `Поставлено вариантов в очередь: ${response.variants.length}`,
title: "Задачи генерации созданы",
})
onGenerated(response.publicId)
onClose()
@@ -139,23 +139,23 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
notifications.show({
color: "red",
message: toErrorMessage(error),
title: "Не удалось поставить generation jobs",
title: "Не удалось поставить задачи генерации",
})
}
})
return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" size="lg" title="Generate variants">
<Modal centered onClose={handleClose} opened={opened} radius="xl" size="lg" title="Сгенерировать варианты">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text c="dimmed" fz="sm">
Endpoint создаёт rows variants и RabbitMQ jobs. Worker сгенерирует bytes асинхронно.
Сервер создаст записи вариантов и задачи в очереди. Обработчик сгенерирует файлы асинхронно.
</Text>
<Select
data={presetOptions}
disabled={action.isGenerating || presetOptions.length === 0}
label="Preset"
label="Пресет"
onChange={(value) => form.setFieldValue("preset", value ?? "")}
required
value={form.values.preset}
@@ -163,8 +163,8 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<SegmentedControl
data={[
{ label: "Family", value: "family" },
{ label: "Single", value: "single" },
{ label: "Набор", value: "family" },
{ label: "Один вариант", value: "single" },
]}
disabled={action.isGenerating}
onChange={(value) => form.setFieldValue("mode", value as AssetVariantMode)}
@@ -173,9 +173,9 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<NumberInput
disabled={action.isGenerating}
label="Version"
label="Версия"
min={1}
placeholder={`current v${asset?.currentVersion ?? ""}`}
placeholder={`текущая v${asset?.currentVersion ?? ""}`}
{...form.getInputProps("version")}
/>
@@ -183,8 +183,8 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<MultiSelect
data={formatOptions}
disabled={action.isGenerating}
label="Formats"
placeholder="All preset formats"
label="Форматы"
placeholder="Все форматы пресета"
{...form.getInputProps("formats")}
/>
) : (
@@ -192,7 +192,7 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<Select
data={formatOptions}
disabled={action.isGenerating || formatOptions.length === 0}
label="Format"
label="Формат"
onChange={(value) => form.setFieldValue("format", (value ?? DEFAULT_FORMAT) as AssetVariantFormat)}
required
value={form.values.format}
@@ -202,15 +202,15 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<NumberInput
description={widthHint}
disabled={action.isGenerating}
label="Width"
label="Ширина"
min={1}
{...form.getInputProps("width")}
/>
<NumberInput
disabled={action.isGenerating}
label="Height"
label="Высота"
min={0}
placeholder="auto"
placeholder="авто"
{...form.getInputProps("height")}
/>
</Group>
@@ -218,16 +218,16 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<Group grow>
<NumberInput
disabled={action.isGenerating}
label="Quality"
label="Качество"
max={100}
min={1}
placeholder={String(selectedPreset?.quality ?? custom?.quality ?? 80)}
{...form.getInputProps("quality")}
/>
<Select
data={RESIZE_OPTIONS.map((resize) => ({ label: resize, value: resize }))}
data={RESIZE_OPTIONS.map((resize) => ({ label: formatResizeMode(resize), value: resize }))}
disabled={action.isGenerating || !isCustom}
label="Resize"
label="Изменение размера"
onChange={(value) => form.setFieldValue("resize", (value ?? DEFAULT_RESIZE) as AssetVariantResize)}
value={form.values.resize}
/>
@@ -237,10 +237,10 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
<Group justify="flex-end">
<Button color="gray" disabled={action.isGenerating} onClick={handleClose} variant="subtle">
Cancel
Отмена
</Button>
<Button disabled={!asset || presetOptions.length === 0} loading={action.isGenerating} type="submit">
Generate
Сгенерировать
</Button>
</Group>
</Stack>
@@ -248,3 +248,27 @@ export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
</Modal>
)
}
function formatPresetMode(mode: string): string {
if (mode === "fixed") {
return "фиксированный"
}
if (mode === "responsive") {
return "адаптивный"
}
return mode
}
function formatResizeMode(resize: string): string {
if (resize === "fit") {
return "вписать"
}
if (resize === "fill") {
return "заполнить"
}
return resize
}

View File

@@ -1,10 +1,10 @@
import { Anchor, Badge, Button, Code, Group, Image, Paper, Select, Skeleton, Stack, Table, Text, Title } from "@mantine/core"
import { copyText } from "../../lib/copy-text"
import { copyText } from "screens/shared/lib/copy-text"
import type { PicturePreviewPanelProps } from "./types/picture-preview-panel-props.type"
/**
* Preview Backend picture/srcset contract.
* Предпросмотр backend-контракта picture/srcset.
*
* Используется для:
* - проверки consumer-facing picture contract
@@ -14,7 +14,7 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
const { onPresetChange, picturePreview, presets, publicId, selectedPreset } = props
const picture = picturePreview.picture
const presetOptions = presets.map((preset) => ({
label: `${preset.name} (${preset.mode})`,
label: `${preset.name} (${formatPresetMode(preset.mode)})`,
value: preset.name,
}))
@@ -23,10 +23,10 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
<Group align="start" justify="space-between" mb="lg">
<div>
<Title order={2} size="h3">
Picture contract
Контракт picture/srcset
</Title>
<Text c="dimmed" fz="sm">
Preview для `GET /api/assets/:publicId/picture`.
Предпросмотр ответа `GET /api/assets/:publicId/picture`.
</Text>
</div>
<Button
@@ -36,7 +36,7 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
size="xs"
variant="light"
>
Refresh
Обновить
</Button>
</Group>
@@ -44,14 +44,14 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
<Select
data={presetOptions}
disabled={!publicId || presetOptions.length === 0}
label="Preset"
label="Пресет"
onChange={onPresetChange}
placeholder="Select preset"
placeholder="Выберите пресет"
value={selectedPreset}
/>
{!publicId ? (
<Text c="dimmed">Выберите asset для preview.</Text>
<Text c="dimmed">Выберите изображение для предпросмотра.</Text>
) : picturePreview.isLoading ? (
<Skeleton height={280} radius="lg" />
) : picture ? (
@@ -69,20 +69,20 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
q{picture.quality}
</Badge>
<Badge radius="xl" variant="light">
widths {picture.widths.join(", ")}
ширины {picture.widths.join(", ")}
</Badge>
</Group>
<Stack gap={6}>
<Text c="dimmed" fz="sm" fw={700}>
Fallback URL
Резервный URL
</Text>
<Group gap="xs">
<Anchor href={picture.image.src} target="_blank">
{picture.image.src}
</Anchor>
<Button onClick={() => void copyText(picture.image.src, "fallback URL")} radius="xl" size="compact-xs" variant="subtle">
Copy
<Button onClick={() => void copyText(picture.image.src, "резервный URL")} radius="xl" size="compact-xs" variant="subtle">
Скопировать
</Button>
</Group>
</Stack>
@@ -90,8 +90,8 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Format</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>Формат</Table.Th>
<Table.Th>Тип</Table.Th>
<Table.Th>srcset</Table.Th>
</Table.Tr>
</Table.Thead>
@@ -105,7 +105,7 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
<Table.Td>
<Text lineClamp={2}>{source.srcSet}</Text>
<Button onClick={() => void copyText(source.srcSet, `${source.format} srcset`)} size="compact-xs" variant="subtle">
copy srcset
скопировать srcset
</Button>
</Table.Td>
</Table.Tr>
@@ -114,9 +114,21 @@ export const PicturePreviewPanel = (props: PicturePreviewPanelProps) => {
</Table>
</Stack>
) : (
<Text c="dimmed">Picture contract пока недоступен.</Text>
<Text c="dimmed">Контракт picture/srcset пока недоступен.</Text>
)}
</Stack>
</Paper>
)
}
function formatPresetMode(mode: string): string {
if (mode === "fixed") {
return "фиксированный"
}
if (mode === "responsive") {
return "адаптивный"
}
return mode
}

View File

@@ -17,15 +17,15 @@ export const PresetsPanel = (props: PresetsPanelProps) => {
<Group justify="space-between" mb="lg">
<div>
<Title order={2} size="h3">
Presets
Пресеты
</Title>
<Text c="dimmed" fz="sm">
Static transform profiles, formats, qualities и source allowlist.
Статические профили трансформаций, форматы, качество и список разрешённых источников.
</Text>
</div>
{custom ? (
<Badge color={custom.enabled ? "green" : "gray"} radius="xl" variant="light">
custom {custom.enabled ? "enabled" : "disabled"}
произвольные {custom.enabled ? "включены" : "выключены"}
</Badge>
) : null}
</Group>
@@ -41,15 +41,15 @@ export const PresetsPanel = (props: PresetsPanelProps) => {
<Group justify="space-between">
<Text fw={800}>{preset.name}</Text>
<Badge color="violet" radius="xl" variant="light">
{preset.mode}
{formatPresetMode(preset.mode)}
</Badge>
</Group>
<Text c="dimmed" fz="sm">
{preset.resize}, q{preset.quality}
</Text>
<Text fz="sm">formats: {preset.formats.join(", ")}</Text>
<Text fz="sm">форматы: {preset.formats.join(", ")}</Text>
<Text fz="sm">
sizes: {preset.widths?.join(", ") ?? `${preset.width}x${preset.height}`}
размеры: {preset.widths?.join(", ") ?? `${preset.width}x${preset.height}`}
</Text>
</Stack>
</Paper>
@@ -59,7 +59,7 @@ export const PresetsPanel = (props: PresetsPanelProps) => {
{custom ? (
<Group gap="xs">
<Badge radius="xl" variant="light">
max {custom.maxWidth}x{custom.maxHeight}
максимум {custom.maxWidth}x{custom.maxHeight}
</Badge>
<Badge radius="xl" variant="light">
q{custom.quality}
@@ -72,7 +72,7 @@ export const PresetsPanel = (props: PresetsPanelProps) => {
<Stack gap={6}>
<Text c="dimmed" fz="sm" fw={700}>
Allowed source hosts
Разрешённые источники
</Text>
<Group gap="xs">
{allowedSourceHosts.map((host) => (
@@ -85,3 +85,15 @@ export const PresetsPanel = (props: PresetsPanelProps) => {
</Paper>
)
}
function formatPresetMode(mode: string): string {
if (mode === "fixed") {
return "фиксированный"
}
if (mode === "responsive") {
return "адаптивный"
}
return mode
}

View File

@@ -0,0 +1 @@
export { SourceVersionsPanel } from "./source-versions-panel"

View File

@@ -0,0 +1,135 @@
import { Anchor, Badge, Button, Code, Group, Paper, ScrollArea, Skeleton, Stack, Table, Text, Title } from "@mantine/core"
import { copyText } from "screens/shared/lib/copy-text"
import { formatDateTime } from "screens/shared/lib/format-date"
import type { SourceVersionsPanelProps } from "./types/source-versions-panel-props.type"
const bytesFormatter = new Intl.NumberFormat("ru-RU")
const formatBytes = (value?: number | null) => {
if (value === undefined || value === null) {
return "ожидает обработки"
}
return `${bytesFormatter.format(value)} B`
}
const formatDimensions = (width?: number | null, height?: number | null) => {
if (!width || !height) {
return "ожидает обработки"
}
return `${width}x${height}`
}
/**
* История source versions выбранного asset.
*/
export const SourceVersionsPanel = (props: SourceVersionsPanelProps) => {
const { history, publicId } = props
return (
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
<Group align="start" justify="space-between" mb="lg">
<div>
<Title order={2} size="h3">
Версии источника
</Title>
<Text c="dimmed" fz="sm">
Неизменяемые URL исходников, из которых строятся версионные пути доставки.
</Text>
</div>
<Group gap="xs">
{history.currentVersion ? (
<Badge color="violet" radius="xl" variant="light">
текущая v{history.currentVersion}
</Badge>
) : null}
<Button
disabled={!publicId}
loading={history.isRefreshing}
onClick={() => void history.refresh()}
radius="xl"
size="xs"
variant="light"
>
Обновить
</Button>
</Group>
</Group>
{!publicId ? (
<Text c="dimmed">Выберите изображение, чтобы увидеть историю версий источника.</Text>
) : history.isLoading ? (
<Skeleton height={220} radius="lg" />
) : history.versions.length > 0 ? (
<ScrollArea>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Версия</Table.Th>
<Table.Th>URL исходника</Table.Th>
<Table.Th>Путь доставки</Table.Th>
<Table.Th>Метаданные</Table.Th>
<Table.Th>Создана</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{history.versions.map((version) => (
<Table.Tr key={version.id}>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Badge color={version.isCurrent ? "violet" : "gray"} radius="xl" variant="light">
v{version.version}
</Badge>
{version.isCurrent ? (
<Badge color="green" radius="xl" variant="dot">
текущая
</Badge>
) : null}
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Group gap="xs" wrap="nowrap">
<Anchor href={version.sourceUrl} target="_blank">
открыть исходник
</Anchor>
<Button onClick={() => void copyText(version.sourceUrl, "URL исходника")} size="compact-xs" variant="subtle">
скопировать
</Button>
</Group>
<Text c="dimmed" fz="xs" lineClamp={1}>
{version.sourceHost}
</Text>
</Stack>
</Table.Td>
<Table.Td>
<Group gap="xs" wrap="nowrap">
<Code>{version.imageBasePath}</Code>
<Button onClick={() => void copyText(version.imageBasePath, "путь доставки")} size="compact-xs" variant="subtle">
скопировать
</Button>
</Group>
</Table.Td>
<Table.Td>
<Stack gap={4}>
<Text fz="sm">{formatDimensions(version.width, version.height)}</Text>
<Text c="dimmed" fz="xs">
{version.contentType ?? "тип содержимого ожидает обработки"} / {formatBytes(version.sizeBytes)}
</Text>
</Stack>
</Table.Td>
<Table.Td>{formatDateTime(version.createdAt)}</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
) : (
<Text c="dimmed">У изображения пока нет зарегистрированных версий источника.</Text>
)}
</Paper>
)
}

View File

@@ -0,0 +1,11 @@
import type { AssetVersionsHistory } from "business/assets"
/**
* Параметры SourceVersionsPanel.
*/
export type SourceVersionsPanelProps = {
/** История source versions выбранного asset. */
history: AssetVersionsHistory
/** Выбранный publicId. */
publicId: string | null
}

View File

@@ -0,0 +1,9 @@
import type { ComponentPropsWithoutRef } from "react"
/** Параметры AssetDetailScreen. */
export type AssetDetailScreenProps = ComponentPropsWithoutRef<"section"> & {
/** Public ID asset. */
publicId: string
/** Slug проекта. */
projectSlug: string
}

View File

@@ -1,36 +0,0 @@
export const DASHBOARD_CARDS = [
{
metric: "assets",
title: "Assets",
description: "Каталог исходных изображений, версий и публичных identifiers.",
},
{
metric: "presets",
title: "Variants",
description: "Статусы генерации AVIF/WebP/JPEG под presets и custom transforms.",
},
{
metric: "hosts",
title: "Storage",
description: "PostgreSQL как source of truth, S3/MinIO как хранилище готовых bytes.",
},
] as const
export const DASHBOARD_PIPELINE = ["Backend", "RabbitMQ", "Worker", "imgproxy", "S3"] as const
export const DASHBOARD_ASSET_SEARCH_PARAM = "asset"
export const DASHBOARD_PRESET_SEARCH_PARAM = "preset"
export const ASSET_STATUS_COLORS = {
active: "green",
deleted: "red",
disabled: "gray",
} as const
export const VARIANT_STATUS_COLORS = {
failed: "red",
pending: "yellow",
processing: "blue",
ready: "green",
} as const

View File

@@ -1,137 +0,0 @@
import { Alert, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import cl from "clsx"
import { assetsFactory } from "business/assets"
import { DASHBOARD_PIPELINE } from "./config/dashboard.config"
import { useDashboardUrlState } from "./hooks/use-dashboard-url-state.hook"
import { AssetDetailPanel } from "./parts/asset-detail-panel"
import { AssetsTable } from "./parts/assets-table"
import { CreateAssetModal } from "./parts/create-asset-modal"
import { CreateSourceVersionModal } from "./parts/create-source-version-modal"
import { GenerateVariantsModal } from "./parts/generate-variants-modal"
import { PicturePreviewPanel } from "./parts/picture-preview-panel"
import { PresetsPanel } from "./parts/presets-panel"
import { SummaryCards } from "./parts/summary-cards"
import styles from "./styles/dashboard.module.css"
import type { DashboardScreenProps } from "./types/dashboard.type"
const assets = assetsFactory()
/**
* Стартовый dashboard admin-приложения.
*
* Используется для:
* - отображения стартового состояния admin MVP
* - обзора будущих разделов и пайплайна генерации
*/
export const DashboardScreen = (props: DashboardScreenProps) => {
const { className, ...rootAttrs } = props
const { selectedPicturePreset, selectedPublicId, setSelectedPicturePreset, setSelectedPublicId } =
useDashboardUrlState()
const [isCreateAssetOpen, createAssetModal] = useDisclosure(false)
const [isCreateVersionOpen, createVersionModal] = useDisclosure(false)
const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false)
const dashboard = assets.useAssetsDashboard()
const createAsset = assets.useCreateAsset()
const createAssetVersion = assets.useCreateAssetVersion()
const generateAssetVariants = assets.useGenerateAssetVariants()
const effectivePublicId = selectedPublicId ?? dashboard.assets[0]?.publicId ?? null
const effectivePicturePreset = selectedPicturePreset ?? dashboard.presets[0]?.name ?? null
const overview = assets.useAssetOverview(effectivePublicId)
const picturePreview = assets.useAssetPicture(effectivePublicId, effectivePicturePreset)
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Stack gap="lg">
<Paper className={styles.hero} p={{ base: "xl", md: 42 }} radius="xl" shadow="xs" withBorder>
<Group align="flex-end" justify="space-between" gap="xl">
<div className={styles.heroContent}>
<Text className={styles.eyebrow}>Image Platform Admin</Text>
<Title className={styles.title}>Control plane для image delivery</Title>
<Text className={styles.lead}>
Управление allowed hosts, assets, source versions, presets и variant generation без
прямого доступа к storage-слою.
</Text>
</div>
<Button className={styles.primaryAction} onClick={createAssetModal.open} radius="xl" size="md">
Create asset
</Button>
</Group>
</Paper>
<SummaryCards isLoading={dashboard.isLoading} summary={dashboard.summary} />
<Group gap="xs" role="list" aria-label="Пайплайн генерации изображений">
{DASHBOARD_PIPELINE.map((step) => (
<Text className={styles.pipelineStep} key={step} role="listitem">
{step}
</Text>
))}
</Group>
{dashboard.error ? (
<Alert color="red" radius="lg" title="Backend API недоступен">
Проверьте, что backend запущен на `localhost:3001`, а Vite proxy доступен по `/api`.
</Alert>
) : null}
<div className={styles.workbench}>
<AssetsTable
assets={dashboard.assets}
isLoading={dashboard.isLoading}
onSelect={setSelectedPublicId}
selectedPublicId={effectivePublicId}
/>
<AssetDetailPanel
onCreateVersion={createVersionModal.open}
onGenerateVariants={generateVariantsModal.open}
overview={overview}
publicId={effectivePublicId}
/>
</div>
<PicturePreviewPanel
onPresetChange={setSelectedPicturePreset}
picturePreview={picturePreview}
presets={dashboard.presets}
publicId={effectivePublicId}
selectedPreset={effectivePicturePreset}
/>
<PresetsPanel
allowedSourceHosts={dashboard.allowedSourceHosts}
custom={dashboard.custom}
isLoading={dashboard.isLoading}
presets={dashboard.presets}
/>
</Stack>
<CreateAssetModal
action={createAsset}
onClose={createAssetModal.close}
onCreated={setSelectedPublicId}
opened={isCreateAssetOpen}
/>
<CreateSourceVersionModal
action={createAssetVersion}
onClose={createVersionModal.close}
onCreated={setSelectedPublicId}
opened={isCreateVersionOpen}
publicId={effectivePublicId}
/>
<GenerateVariantsModal
action={generateAssetVariants}
asset={overview.asset}
custom={dashboard.custom}
onClose={generateVariantsModal.close}
onGenerated={setSelectedPublicId}
opened={isGenerateVariantsOpen}
presets={dashboard.presets}
/>
</section>
)
}

View File

@@ -1,61 +0,0 @@
import { useEffect, useState } from "react"
import { DASHBOARD_ASSET_SEARCH_PARAM, DASHBOARD_PRESET_SEARCH_PARAM } from "../config/dashboard.config"
const readSearchParam = (name: string) => {
if (typeof window === "undefined") {
return null
}
return new URLSearchParams(window.location.search).get(name)
}
const replaceSearchParam = (name: string, value: string | null) => {
const url = new URL(window.location.href)
if (value) {
url.searchParams.set(name, value)
} else {
url.searchParams.delete(name)
}
window.history.replaceState(null, "", `${url.pathname}${url.search}${url.hash}`)
}
/**
* URL-state dashboard для выбранного asset и picture preset.
*/
export const useDashboardUrlState = () => {
const [selectedPublicId, setSelectedPublicIdState] = useState(() => readSearchParam(DASHBOARD_ASSET_SEARCH_PARAM))
const [selectedPicturePreset, setSelectedPicturePresetState] = useState(() =>
readSearchParam(DASHBOARD_PRESET_SEARCH_PARAM),
)
useEffect(() => {
const handlePopState = () => {
setSelectedPublicIdState(readSearchParam(DASHBOARD_ASSET_SEARCH_PARAM))
setSelectedPicturePresetState(readSearchParam(DASHBOARD_PRESET_SEARCH_PARAM))
}
window.addEventListener("popstate", handlePopState)
return () => window.removeEventListener("popstate", handlePopState)
}, [])
const setSelectedPublicId = (publicId: string | null) => {
setSelectedPublicIdState(publicId)
replaceSearchParam(DASHBOARD_ASSET_SEARCH_PARAM, publicId)
}
const setSelectedPicturePreset = (preset: string | null) => {
setSelectedPicturePresetState(preset)
replaceSearchParam(DASHBOARD_PRESET_SEARCH_PARAM, preset)
}
return {
selectedPicturePreset,
selectedPublicId,
setSelectedPicturePreset,
setSelectedPublicId,
}
}

View File

@@ -1,2 +0,0 @@
export { DashboardScreen } from "./dashboard.screen"
export type { DashboardScreenProps } from "./types/dashboard.type"

View File

@@ -1,2 +0,0 @@
export { SummaryCards } from "./summary-cards"
export type { SummaryCardsProps } from "./types/summary-cards-props.type"

View File

@@ -1,37 +0,0 @@
import { Paper, SimpleGrid, Skeleton, Stack, Text } from "@mantine/core"
import { DASHBOARD_CARDS } from "../../config/dashboard.config"
import type { SummaryCardsProps } from "./types/summary-cards-props.type"
/**
* Карточки сводных метрик dashboard.
*
* Используется для:
* - отображения количества assets, presets и hosts
* - компактного статуса загрузки данных
*/
export const SummaryCards = (props: SummaryCardsProps) => {
const { isLoading, summary } = props
return (
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
{DASHBOARD_CARDS.map((card) => (
<Paper bg="white" key={card.title} p="xl" radius="xl" shadow="xs" withBorder>
<Stack gap="sm">
{isLoading ? (
<Skeleton height={42} width={86} />
) : (
<Text c="violet.7" fw={850} fz={42} lh={0.9}>
{summary[card.metric]}
</Text>
)}
<Text fw={800}>{card.title}</Text>
<Text c="dimmed" fz="sm" lh={1.55}>
{card.description}
</Text>
</Stack>
</Paper>
))}
</SimpleGrid>
)
}

View File

@@ -1,11 +0,0 @@
import type { AssetsDashboard } from "business/assets"
/**
* Параметры SummaryCards.
*/
export type SummaryCardsProps = {
/** Признак загрузки данных. */
isLoading: boolean
/** Сводные метрики dashboard. */
summary: AssetsDashboard["summary"]
}

View File

@@ -1,4 +0,0 @@
import type { ComponentPropsWithoutRef } from "react"
/** Параметры экрана Dashboard. */
export type DashboardScreenProps = ComponentPropsWithoutRef<"section">

View File

@@ -0,0 +1 @@
export { ProjectAssetsScreen } from "./project-assets.screen"

View File

@@ -0,0 +1,146 @@
import { Button, Group, Paper, SimpleGrid, Skeleton, Tabs, Text, Title } from "@mantine/core"
import { useState } from "react"
import { formatDateTime } from "screens/shared/lib/format-date"
import styles from "./styles/assets-explorer.module.css"
import type { AssetsExplorerProps } from "./types/assets-explorer-props.type"
type AssetItem = AssetsExplorerProps["assets"]["assets"][number]
/**
* Explorer изображений проекта.
*
* Используется для:
* - просмотра добавленных изображений в виде сетки
* - размещения будущего списка изображений по URL
*/
export const AssetsExplorer = (props: AssetsExplorerProps) => {
const { assets, onCreateAsset, onSelectAsset } = props
return (
<Paper className={styles.root} radius="xl" shadow="xs" withBorder>
<div className={styles.header}>
<div>
<Title className={styles.title} order={2}>
Изображения
</Title>
<Text className={styles.subtitle}>Изображения проекта, сгруппированные по способу добавления.</Text>
</div>
</div>
<Tabs classNames={{ list: styles.tabsList, tab: styles.tab }} defaultValue="added" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="added">Добавленные</Tabs.Tab>
<Tabs.Tab value="url">По URL</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="added">
{assets.isLoading ? (
<AddedAssetsSkeleton />
) : assets.assets.length > 0 ? (
<div className={styles.explorerLayout}>
<aside className={styles.folders} aria-label="Папки изображений">
<Text className={styles.foldersTitle}>Папки</Text>
<button className={styles.folderItem} type="button">
<span>Все изображения</span>
<span>{assets.assets.length}</span>
</button>
<div className={styles.folderHint}>Папки для ручной организации изображений появятся позже.</div>
</aside>
<SimpleGrid className={styles.assetsGrid} cols={{ base: 1, sm: 2, lg: 3, xl: 4 }} spacing="md">
{assets.assets.map((asset) => (
<button
className={styles.assetCard}
key={asset.id}
onClick={() => onSelectAsset(asset.publicId)}
type="button"
>
<AssetPreview asset={asset} />
<div className={styles.assetBody}>
<Text className={styles.assetName}>{asset.publicId}</Text>
<Group className={styles.assetMeta} gap="xs">
<Text className={styles.version}>v{asset.currentVersion}</Text>
<Text className={styles.host}>{asset.sourceHost}</Text>
</Group>
<Text className={styles.updatedAt}>Обновлено {formatDateTime(asset.updatedAt)}</Text>
</div>
</button>
))}
</SimpleGrid>
</div>
) : (
<div className={styles.emptyState}>
<div>
<Title className={styles.emptyTitle} order={3}>
Добавленных изображений пока нет
</Title>
<Text className={styles.emptyText}>Добавьте первое изображение, чтобы увидеть его в проводнике.</Text>
</div>
<Button onClick={onCreateAsset} radius="xl">
Добавить изображение
</Button>
</div>
)}
</Tabs.Panel>
<Tabs.Panel value="url">
<div className={styles.emptyState}>
<div>
<Title className={styles.emptyTitle} order={3}>
Изображения по URL пока не отслеживаются
</Title>
<Text className={styles.emptyText}>
Сюда попадут изображения, созданные через доставку по удалённому URL, например из будущей интеграции с загрузчиком изображений.
</Text>
</div>
</div>
</Tabs.Panel>
</Tabs>
</Paper>
)
}
const AssetPreview = (props: { asset: AssetItem }) => {
const { asset } = props
const [hasPreviewError, setHasPreviewError] = useState(false)
if (hasPreviewError) {
return (
<div className={styles.previewFallback}>
<span>{getAssetInitial(asset.publicId)}</span>
</div>
)
}
return (
<div className={styles.preview}>
<img
alt=""
className={styles.previewImage}
loading="lazy"
onError={() => setHasPreviewError(true)}
referrerPolicy="no-referrer"
src={asset.sourceUrl}
/>
</div>
)
}
const AddedAssetsSkeleton = () => (
<div className={styles.explorerLayout}>
<Skeleton className={styles.foldersSkeleton} radius="lg" />
<SimpleGrid className={styles.assetsGrid} cols={{ base: 1, sm: 2, lg: 3, xl: 4 }} spacing="md">
<Skeleton height={260} radius="lg" />
<Skeleton height={260} radius="lg" visibleFrom="sm" />
<Skeleton height={260} radius="lg" visibleFrom="lg" />
<Skeleton height={260} radius="lg" visibleFrom="xl" />
</SimpleGrid>
</div>
)
function getAssetInitial(publicId: string): string {
const [firstLetter = "A"] = publicId.trim()
return firstLetter.toUpperCase()
}

View File

@@ -0,0 +1 @@
export { AssetsExplorer } from "./assets-explorer"

View File

@@ -0,0 +1,216 @@
.root {
padding: var(--space-5);
background: var(--color-surface-solid);
@media (--md) {
padding: var(--space-6);
}
}
.header {
margin-bottom: var(--space-5);
}
.title {
color: var(--color-text);
font-size: 1.5rem;
letter-spacing: -0.035em;
}
.subtitle {
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.55;
}
.tabsList {
margin-bottom: var(--space-5);
}
.tab {
color: var(--color-text-muted);
font-weight: 760;
&[data-active] {
color: var(--color-accent);
}
}
.explorerLayout {
display: grid;
gap: var(--space-5);
@media (--lg) {
grid-template-columns: 16rem minmax(0, 1fr);
align-items: start;
}
}
.folders {
padding: var(--space-4);
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-4);
background: var(--color-surface-muted);
}
.foldersTitle {
margin-bottom: var(--space-3);
color: var(--color-text-subtle);
font-size: 0.75rem;
font-weight: 800;
letter-spacing: 0.12em;
text-transform: uppercase;
}
.folderItem {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 0.75rem 0.875rem;
border: 1px solid var(--color-border);
border-radius: 0.875rem;
background: var(--color-surface-solid);
color: var(--color-text);
font: inherit;
font-size: 0.875rem;
font-weight: 760;
cursor: default;
}
.folderHint {
margin-top: var(--space-3);
color: var(--color-text-muted);
font-size: 0.8125rem;
line-height: 1.5;
}
.foldersSkeleton {
min-height: 12rem;
}
.assetsGrid {
min-width: 0;
}
.assetCard {
overflow: hidden;
border: 1px solid var(--color-border);
border-radius: var(--radius-4);
background: var(--color-surface-solid);
color: var(--color-text);
text-align: left;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
&:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-card);
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
}
.preview,
.previewFallback {
aspect-ratio: 4 / 3;
overflow: hidden;
background:
linear-gradient(135deg, rgb(255 255 255 / 16%), transparent 46%),
linear-gradient(145deg, #c7bcae 0%, #80796d 52%, #455043 100%);
}
.previewImage {
display: block;
width: 100%;
height: 100%;
object-fit: cover;
}
.previewFallback {
display: grid;
place-items: center;
color: var(--color-cover-text);
font-size: 2rem;
font-weight: 820;
letter-spacing: -0.06em;
}
.assetBody {
display: grid;
gap: var(--space-2);
padding: var(--space-4);
}
.assetName {
overflow: hidden;
color: var(--color-text);
font-size: 1rem;
font-weight: 820;
line-height: 1.25;
text-overflow: ellipsis;
white-space: nowrap;
}
.assetMeta {
min-width: 0;
}
.version {
color: var(--color-accent);
font-size: 0.8125rem;
font-weight: 760;
}
.host {
overflow: hidden;
color: var(--color-text-muted);
font-size: 0.8125rem;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.updatedAt {
color: var(--color-text-subtle);
font-size: 0.8125rem;
line-height: 1.35;
}
.emptyState {
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: flex-start;
padding: var(--space-5);
border: 1px solid var(--color-border-soft);
border-radius: var(--radius-4);
background: var(--color-surface-muted);
@media (--md) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.emptyTitle {
color: var(--color-text);
font-size: 1.25rem;
letter-spacing: -0.025em;
}
.emptyText {
max-width: 40rem;
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.6;
}

View File

@@ -0,0 +1,13 @@
import type { ProjectAssetsOverview } from "business/assets"
/**
* Параметры AssetsExplorer.
*/
export type AssetsExplorerProps = {
/** Состояние assets проекта. */
assets: ProjectAssetsOverview
/** Callback создания asset. */
onCreateAsset: () => void
/** Callback выбора asset. */
onSelectAsset: (publicId: string) => void
}

View File

@@ -1,7 +1,7 @@
import { Badge, Group, Paper, ScrollArea, Skeleton, Table, Text, Title } from "@mantine/core"
import { ASSET_STATUS_COLORS } from "../../config/dashboard.config"
import { formatDateTime } from "../../lib/format-date"
import { ASSET_STATUS_COLORS, ASSET_STATUS_LABELS } from "screens/shared/config/image-ui.config"
import { formatDateTime } from "screens/shared/lib/format-date"
import type { AssetsTableProps } from "./types/assets-table-props.type"
/**
@@ -19,14 +19,14 @@ export const AssetsTable = (props: AssetsTableProps) => {
<Group justify="space-between" mb="lg">
<div>
<Title order={2} size="h3">
Assets
Изображения
</Title>
<Text c="dimmed" fz="sm">
Последние зарегистрированные исходные изображения.
Последние зарегистрированные изображения.
</Text>
</div>
<Badge color="violet" radius="xl" variant="light">
{assets.length} loaded
загружено: {assets.length}
</Badge>
</Group>
@@ -37,11 +37,11 @@ export const AssetsTable = (props: AssetsTableProps) => {
<Table highlightOnHover verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>publicId</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Version</Table.Th>
<Table.Th>Host</Table.Th>
<Table.Th>Updated</Table.Th>
<Table.Th>Публичный ID</Table.Th>
<Table.Th>Статус</Table.Th>
<Table.Th>Версия</Table.Th>
<Table.Th>Источник</Table.Th>
<Table.Th>Обновлено</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -57,7 +57,7 @@ export const AssetsTable = (props: AssetsTableProps) => {
</Table.Td>
<Table.Td>
<Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
{asset.status}
{ASSET_STATUS_LABELS[asset.status] ?? asset.status}
</Badge>
</Table.Td>
<Table.Td>v{asset.currentVersion}</Table.Td>
@@ -69,7 +69,7 @@ export const AssetsTable = (props: AssetsTableProps) => {
</Table>
</ScrollArea>
) : (
<Text c="dimmed">Assets пока не зарегистрированы.</Text>
<Text c="dimmed">Изображения пока не зарегистрированы.</Text>
)}
</Paper>
)

View File

@@ -22,7 +22,7 @@ const toErrorMessage = (error: unknown) => (error instanceof Error ? error.messa
* - запуска первого write-сценария admin MVP
*/
export const CreateAssetModal = (props: CreateAssetModalProps) => {
const { action, onClose, onCreated, opened } = props
const { action, onClose, onCreated, opened, projectSlug } = props
const form = useForm<CreateAssetFormValues>({
initialValues: {
@@ -32,7 +32,7 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => {
validate: {
sourceUrl: (value) => {
if (!value.trim()) {
return "Укажите source URL"
return "Укажите URL исходника"
}
try {
@@ -54,6 +54,7 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => {
const handleSubmit = form.onSubmit(async (values) => {
const publicId = values.publicId.trim()
const input: CreateAssetInput = {
...(projectSlug ? { projectSlug } : {}),
sourceUrl: values.sourceUrl.trim(),
...(publicId ? { publicId } : {}),
}
@@ -62,8 +63,8 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => {
const createdAsset = await action.createAsset(input)
notifications.show({
color: "green",
message: `Asset ${createdAsset.publicId} зарегистрирован`,
title: "Asset created",
message: `Изображение ${createdAsset.publicId} зарегистрировано`,
title: "Изображение создано",
})
form.reset()
onCreated(createdAsset.publicId)
@@ -72,28 +73,29 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => {
notifications.show({
color: "red",
message: toErrorMessage(error),
title: "Не удалось создать asset",
title: "Не удалось создать изображение",
})
}
})
return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="Create asset">
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="Добавить изображение">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text c="dimmed" fz="sm">
Backend создаст asset и первую immutable source version. Public ID можно оставить пустым.
Сервер создаст изображение и первую неизменяемую версию источника. Публичный ID можно оставить пустым.
{projectSlug ? ` Проект: ${projectSlug}.` : ""}
</Text>
<TextInput
label="Public ID"
label="Публичный ID"
placeholder="asset_demo"
{...form.getInputProps("publicId")}
disabled={action.isCreating}
/>
<TextInput
label="Source URL"
label="URL исходника"
placeholder={SOURCE_URL_EXAMPLE}
required
{...form.getInputProps("sourceUrl")}
@@ -102,10 +104,10 @@ export const CreateAssetModal = (props: CreateAssetModalProps) => {
<Group justify="flex-end">
<Button color="gray" disabled={action.isCreating} onClick={handleClose} variant="subtle">
Cancel
Отмена
</Button>
<Button loading={action.isCreating} type="submit">
Create asset
Добавить изображение
</Button>
</Group>
</Stack>

View File

@@ -12,4 +12,6 @@ export type CreateAssetModalProps = {
onCreated: (publicId: string) => void
/** Открыта ли modal. */
opened: boolean
/** Slug проекта, внутри которого создаётся asset. */
projectSlug?: string | null
}

View File

@@ -0,0 +1 @@
export { PresetsTable } from "./presets-table"

View File

@@ -0,0 +1,114 @@
import { ActionIcon, Badge, Button, Group, Paper, ScrollArea, Skeleton, Table, Text, Title } from "@mantine/core"
import styles from "./styles/presets-table.module.css"
import type { PresetsTableProps } from "./types/presets-table-props.type"
/**
* Таблица image presets проекта.
*
* Используется для:
* - отображения текущих presets генерации изображений
* - размещения будущих действий управления presets
*/
export const PresetsTable = (props: PresetsTableProps) => {
const { presets } = props
return (
<Paper className={styles.root} radius="xl" shadow="xs" withBorder>
<Group align="start" className={styles.header} justify="space-between">
<div>
<Title className={styles.title} order={2}>
Пресеты
</Title>
<Text className={styles.subtitle}>Наборы трансформаций, которые доступны изображениям проекта.</Text>
</div>
<Button disabled radius="xl" size="sm" variant="default">
Создать пресет
</Button>
</Group>
{presets.isLoading ? (
<Skeleton height={180} radius="lg" />
) : presets.presets.length > 0 ? (
<ScrollArea>
<Table className={styles.table} verticalSpacing="md">
<Table.Thead>
<Table.Tr>
<Table.Th>Название</Table.Th>
<Table.Th>Режим</Table.Th>
<Table.Th>Размер</Table.Th>
<Table.Th>Форматы</Table.Th>
<Table.Th>Качество</Table.Th>
<Table.Th>Изменение размера</Table.Th>
<Table.Th aria-label="Действия" />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{presets.presets.map((preset) => (
<Table.Tr key={preset.name}>
<Table.Td>
<Text className={styles.presetName}>{preset.name}</Text>
</Table.Td>
<Table.Td>
<Badge color="gray" radius="xl" variant="light">
{formatPresetMode(preset.mode)}
</Badge>
</Table.Td>
<Table.Td>{formatPresetSize(preset)}</Table.Td>
<Table.Td>{preset.formats.join(", ")}</Table.Td>
<Table.Td>{preset.quality}</Table.Td>
<Table.Td>{formatResizeMode(preset.resize)}</Table.Td>
<Table.Td>
<Group gap="xs" justify="flex-end" wrap="nowrap">
<ActionIcon disabled radius="xl" variant="subtle" aria-label={`Редактировать ${preset.name}`}>
Р
</ActionIcon>
<ActionIcon disabled radius="xl" variant="subtle" aria-label={`Удалить ${preset.name}`}>
У
</ActionIcon>
</Group>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollArea>
) : (
<Text className={styles.emptyText}>Пресеты пока не настроены.</Text>
)}
</Paper>
)
}
function formatPresetSize(preset: PresetsTableProps["presets"]["presets"][number]): string {
if (preset.mode === "fixed") {
return `${preset.width ?? 0}x${preset.height ?? 0}`
}
return preset.widths?.length ? preset.widths.join(", ") : "-"
}
function formatPresetMode(mode: string): string {
if (mode === "fixed") {
return "фиксированный"
}
if (mode === "responsive") {
return "адаптивный"
}
return mode
}
function formatResizeMode(resize: string): string {
if (resize === "fit") {
return "вписать"
}
if (resize === "fill") {
return "заполнить"
}
return resize
}

View File

@@ -0,0 +1,40 @@
.root {
padding: var(--space-5);
background: var(--color-surface-solid);
@media (--md) {
padding: var(--space-6);
}
}
.header {
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.title {
color: var(--color-text);
font-size: 1.5rem;
letter-spacing: -0.035em;
}
.subtitle {
margin-top: var(--space-1);
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.55;
}
.table {
min-width: 54rem;
}
.presetName {
color: var(--color-text);
font-weight: 800;
}
.emptyText {
color: var(--color-text-muted);
font-size: 0.9375rem;
}

View File

@@ -0,0 +1,9 @@
import type { ImagePresetsOverview } from "business/assets"
/**
* Параметры PresetsTable.
*/
export type PresetsTableProps = {
/** Presets проекта. */
presets: ImagePresetsOverview
}

View File

@@ -0,0 +1 @@
export { ProjectHeader } from "./project-header"

View File

@@ -0,0 +1,66 @@
import { Button, Group, Paper, Skeleton, Text, Title } from "@mantine/core"
import styles from "./styles/project-header.module.css"
import type { ProjectHeaderProps } from "./types/project-header-props.type"
/**
* Шапка страницы проекта.
*
* Используется для:
* - отображения основной информации проекта
* - размещения ключевых действий проекта
*/
export const ProjectHeader = (props: ProjectHeaderProps) => {
const { isLoading, onCreateAsset, onOpenSettings, project, projectSlug } = props
const assetsCount = project?.assetsCount ?? 0
return (
<Paper className={styles.root} radius="xl" shadow="xs" withBorder>
<div className={styles.content}>
<div className={styles.heading}>
{isLoading ? (
<Skeleton height={56} radius="md" width="min(100%, 22rem)" />
) : (
<Title className={styles.title}>{project?.name ?? projectSlug}</Title>
)}
<Group className={styles.meta} gap="xs">
<Text className={styles.slug}>{project?.slug ?? projectSlug}</Text>
<Text className={styles.dot} aria-hidden="true">
/
</Text>
<Text className={styles.assetsCount}>{formatAssetsCount(assetsCount)}</Text>
</Group>
</div>
<Group className={styles.actions} gap="sm">
<Button onClick={onOpenSettings} radius="xl" variant="default">
Настройки проекта
</Button>
<Button onClick={onCreateAsset} radius="xl">
Добавить изображение
</Button>
</Group>
</div>
</Paper>
)
}
function formatAssetsCount(count: number): string {
return `${count} ${getImagesWord(count)}`
}
function getImagesWord(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) {
return "изображение"
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return "изображения"
}
return "изображений"
}

View File

@@ -0,0 +1,64 @@
.root {
padding: var(--space-5);
background: var(--color-surface-solid);
@media (--md) {
padding: var(--space-6);
}
}
.content {
display: grid;
gap: var(--space-5);
@media (--md) {
grid-template-columns: minmax(0, 1fr) auto;
align-items: end;
}
}
.heading {
min-width: 0;
}
.title {
color: var(--color-text);
font-size: clamp(2rem, 4vw, 3.75rem);
line-height: 1;
letter-spacing: -0.06em;
}
.meta {
margin-top: var(--space-3);
}
.slug {
overflow: hidden;
max-width: 100%;
color: var(--color-accent);
font-size: 0.9375rem;
font-weight: 760;
line-height: 1.4;
text-overflow: ellipsis;
white-space: nowrap;
}
.dot {
color: var(--color-text-subtle);
font-size: 0.875rem;
}
.assetsCount {
color: var(--color-text-muted);
font-size: 0.9375rem;
font-weight: 680;
line-height: 1.4;
}
.actions {
width: 100%;
@media (--sm) {
width: auto;
}
}

View File

@@ -0,0 +1,17 @@
import type { ProjectResponseDto } from "infra/backend-api"
/**
* Параметры ProjectHeader.
*/
export type ProjectHeaderProps = {
/** Признак загрузки проекта. */
isLoading: boolean
/** Callback создания asset. */
onCreateAsset: () => void
/** Callback открытия настроек проекта. */
onOpenSettings: () => void
/** Metadata проекта. */
project: ProjectResponseDto | null
/** Slug проекта из route params. */
projectSlug: string
}

View File

@@ -0,0 +1,82 @@
import { Alert, Anchor, Group, Stack, Text } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import { notifications } from "@mantine/notifications"
import cl from "clsx"
import { Link, useNavigate } from "react-router-dom"
import { assetsFactory } from "business/assets"
import { projectsFactory } from "business/projects"
import { AssetsExplorer } from "./parts/assets-explorer"
import { CreateAssetModal } from "./parts/create-asset-modal"
import { PresetsTable } from "./parts/presets-table"
import { ProjectHeader } from "./parts/project-header"
import styles from "./styles/project-assets.module.css"
import type { ProjectAssetsScreenProps } from "./types/project-assets-screen-props.type"
const assets = assetsFactory()
const projects = projectsFactory()
/**
* Страница assets выбранного проекта.
*/
export const ProjectAssetsScreen = (props: ProjectAssetsScreenProps) => {
const { className, projectSlug, ...rootAttrs } = props
const navigate = useNavigate()
const [isCreateAssetOpen, createAssetModal] = useDisclosure(false)
const projectDetail = projects.useProjectDetail(projectSlug)
const projectAssets = assets.useProjectAssets(projectSlug)
const imagePresets = assets.useImagePresets()
const createAsset = assets.useCreateAsset()
const openAsset = (publicId: string) => {
navigate(`/projects/${projectSlug}/assets/${publicId}`)
}
const openProjectSettings = () => {
notifications.show({
color: "gray",
message: "Экран настроек проекта будет добавлен позже.",
title: "Настройки проекта",
})
}
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Stack gap="lg">
<Group className={styles.breadcrumbs} gap="xs">
<Anchor component={Link} to="/">
Проекты
</Anchor>
<Text c="dimmed">/</Text>
<Text className={styles.breadcrumbCurrent}>{projectSlug}</Text>
</Group>
<ProjectHeader
isLoading={projectDetail.isLoading}
onCreateAsset={createAssetModal.open}
onOpenSettings={openProjectSettings}
project={projectDetail.project}
projectSlug={projectSlug}
/>
{projectDetail.error || projectAssets.error || imagePresets.error ? (
<Alert color="red" radius="lg" title="Данные проекта недоступны">
Проверьте backend API и существование проекта `{projectSlug}`.
</Alert>
) : null}
<PresetsTable presets={imagePresets} />
<AssetsExplorer assets={projectAssets} onCreateAsset={createAssetModal.open} onSelectAsset={openAsset} />
</Stack>
<CreateAssetModal
action={createAsset}
onClose={createAssetModal.close}
onCreated={openAsset}
opened={isCreateAssetOpen}
projectSlug={projectSlug}
/>
</section>
)
}

View File

@@ -0,0 +1,13 @@
.root {
min-width: 0;
}
.breadcrumbs {
color: var(--color-text-muted);
font-size: 0.875rem;
}
.breadcrumbCurrent {
color: var(--color-text);
font-weight: 760;
}

View File

@@ -0,0 +1,7 @@
import type { ComponentPropsWithoutRef } from "react"
/** Параметры ProjectAssetsScreen. */
export type ProjectAssetsScreenProps = ComponentPropsWithoutRef<"section"> & {
/** Slug проекта. */
projectSlug: string
}

View File

@@ -0,0 +1 @@
export { ProjectsScreen } from "./projects.screen"

View File

@@ -0,0 +1,98 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import type { CreateProjectInput } from "business/projects"
import type { CreateProjectModalProps } from "./types/create-project-modal-props.type"
type CreateProjectFormValues = {
name: string
slug: string
}
const toErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Неизвестная ошибка")
/**
* Modal создания проекта.
*/
export const CreateProjectModal = (props: CreateProjectModalProps) => {
const { action, onClose, onCreated, opened } = props
const form = useForm<CreateProjectFormValues>({
initialValues: {
name: "",
slug: "",
},
validate: {
name: (value) => (value.trim() ? null : "Укажите название проекта"),
slug: (value) => {
const normalized = value.trim()
if (!normalized) {
return null
}
return /^[a-z0-9][a-z0-9_-]{2,63}$/.test(normalized)
? null
: "Слаг: 3-64 символа, строчные латинские буквы, цифры, _ или -"
},
},
})
const handleClose = () => {
if (!action.isCreating) {
onClose()
}
}
const handleSubmit = form.onSubmit(async (values) => {
const slug = values.slug.trim()
const input: CreateProjectInput = {
name: values.name.trim(),
...(slug ? { slug } : {}),
}
try {
const project = await action.createProject(input)
notifications.show({
color: "green",
message: `Проект ${project.slug} создан`,
title: "Проект создан",
})
form.reset()
onCreated(project.slug)
onClose()
} catch (error) {
notifications.show({
color: "red",
message: toErrorMessage(error),
title: "Не удалось создать проект",
})
}
})
return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="Создать проект">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text c="dimmed" fz="sm">
Проект станет верхним уровнем для изображений, SDK URL и будущих токенов доступа.
</Text>
<TextInput disabled={action.isCreating} label="Название" placeholder="Демо магазин" required {...form.getInputProps("name")} />
<TextInput disabled={action.isCreating} label="Слаг" placeholder="demo-shop" {...form.getInputProps("slug")} />
<Group justify="flex-end">
<Button color="gray" disabled={action.isCreating} onClick={handleClose} variant="subtle">
Отмена
</Button>
<Button loading={action.isCreating} type="submit">
Создать проект
</Button>
</Group>
</Stack>
</form>
</Modal>
)
}

View File

@@ -0,0 +1 @@
export { CreateProjectModal } from "./create-project-modal"

View File

@@ -0,0 +1,15 @@
import type { CreateProjectAction } from "business/projects"
/**
* Параметры CreateProjectModal.
*/
export type CreateProjectModalProps = {
/** Сценарий создания проекта. */
action: CreateProjectAction
/** Callback закрытия modal. */
onClose: () => void
/** Callback успешного создания проекта. */
onCreated: (projectSlug: string) => void
/** Открыта ли modal. */
opened: boolean
}

View File

@@ -0,0 +1 @@
export { ProjectsGrid } from "./projects-grid"

View File

@@ -0,0 +1,116 @@
import { Button, Group, SimpleGrid, Skeleton, Text, Title } from "@mantine/core"
import { formatDateTime } from "screens/shared/lib/format-date"
import styles from "./styles/projects-grid.module.css"
import type { ProjectsGridProps } from "./types/projects-grid-props.type"
/**
* Главная сетка проектов.
*/
export const ProjectsGrid = (props: ProjectsGridProps) => {
const { home, onCreateProject, onSelectProject } = props
return (
<section className={styles.root} aria-labelledby="projects-title">
<Group align="start" className={styles.toolbar} justify="space-between">
<div className={styles.heading}>
<Title className={styles.title} id="projects-title" order={1}>
Проекты
</Title>
<Text className={styles.subtitle}>
Карточки проектов для управления изображениями, версиями источников и пресетами доставки.
</Text>
</div>
<Group className={styles.actions} gap="xs">
<Button loading={home.isRefreshing} onClick={() => void home.refresh()} radius="xl" size="sm" variant="subtle">
Обновить
</Button>
<Button onClick={onCreateProject} radius="xl" size="sm">
Создать проект
</Button>
</Group>
</Group>
{home.isLoading ? (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
<Skeleton className={styles.skeletonCard} radius="lg" />
<Skeleton className={styles.skeletonCard} radius="lg" visibleFrom="md" />
<Skeleton className={styles.skeletonCard} radius="lg" visibleFrom="xl" />
</SimpleGrid>
) : home.projects.length > 0 ? (
<SimpleGrid cols={{ base: 1, md: 2, xl: 3 }} spacing="lg">
{home.projects.map((project) => (
<a
className={styles.card}
href={`/projects/${project.slug}`}
key={project.id}
onClick={(event) => {
event.preventDefault()
onSelectProject(project.slug)
}}
>
<div className={styles.cover} aria-hidden="true">
<span className={styles.coverInitial}>{getProjectInitial(project.name)}</span>
</div>
<div className={styles.cardBody}>
<div className={styles.cardHeading}>
<Title className={styles.cardTitle} order={2}>
{project.name}
</Title>
<Text className={styles.slug}>{project.slug}</Text>
</div>
<div className={styles.cardMeta}>
<Text className={styles.assetsCount}>{formatAssetsCount(project.assetsCount)}</Text>
<Text className={styles.createdAt}>Создан {formatDateTime(project.createdAt)}</Text>
</div>
</div>
</a>
))}
</SimpleGrid>
) : (
<div className={styles.emptyState}>
<div>
<Title className={styles.emptyTitle} order={2}>
Проекты пока не созданы
</Title>
<Text className={styles.emptyText}>
Создайте первый проект, чтобы подключить изображения и настроить URL доставки.
</Text>
</div>
<Button onClick={onCreateProject} radius="xl">
Создать проект
</Button>
</div>
)}
</section>
)
}
function getProjectInitial(name: string): string {
const [firstLetter = "P"] = name.trim()
return firstLetter.toUpperCase()
}
function formatAssetsCount(count: number): string {
return `${count} ${getImagesWord(count)}`
}
function getImagesWord(count: number): string {
const mod10 = count % 10
const mod100 = count % 100
if (mod10 === 1 && mod100 !== 11) {
return "изображение"
}
if (mod10 >= 2 && mod10 <= 4 && (mod100 < 12 || mod100 > 14)) {
return "изображения"
}
return "изображений"
}

View File

@@ -0,0 +1,219 @@
.root {
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-5);
background: var(--color-surface-solid);
box-shadow: var(--shadow-soft);
@media (--md) {
padding: var(--space-6);
}
}
.toolbar {
gap: var(--space-4);
margin-bottom: var(--space-5);
}
.heading {
min-width: 0;
}
.title {
color: var(--color-text);
font-size: clamp(2rem, 4vw, 3.5rem);
line-height: 1;
letter-spacing: -0.055em;
}
.subtitle {
max-width: 38rem;
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.6;
}
.actions {
width: 100%;
@media (--sm) {
width: auto;
}
}
.skeletonCard {
min-height: 24rem;
}
.card {
display: flex;
overflow: hidden;
flex-direction: column;
border: 1px solid var(--color-border);
border-radius: var(--radius-4);
background: var(--color-surface-solid);
color: var(--color-text);
text-align: left;
text-decoration: none;
box-shadow: none;
transition:
border-color 160ms ease,
box-shadow 160ms ease,
transform 160ms ease;
&:hover {
border-color: var(--color-border-strong);
box-shadow: var(--shadow-card);
transform: translateY(-2px);
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 3px;
}
}
.cover {
position: relative;
display: flex;
min-height: 12rem;
align-items: flex-end;
overflow: hidden;
padding: var(--space-5);
background:
linear-gradient(180deg, rgb(29 26 22 / 0%), rgb(29 26 22 / 28%)),
linear-gradient(145deg, #c7bcae 0%, #80796d 48%, #455043 100%);
&::before {
content: '';
position: absolute;
inset: 0;
background:
linear-gradient(90deg, rgb(255 255 255 / 10%) 1px, transparent 1px),
linear-gradient(0deg, rgb(255 255 255 / 8%) 1px, transparent 1px);
background-size: 2.75rem 2.75rem;
mask-image: linear-gradient(135deg, black, transparent 62%);
}
&::after {
content: '';
position: absolute;
right: var(--space-5);
bottom: var(--space-5);
width: 5.5rem;
height: 5.5rem;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 1.25rem;
background: rgb(255 255 255 / 10%);
transform: rotate(6deg);
}
}
.coverInitial {
position: relative;
z-index: 1;
display: inline-flex;
width: 4rem;
height: 4rem;
align-items: center;
justify-content: center;
border: 1px solid rgb(255 255 255 / 24%);
border-radius: 1rem;
background: rgb(29 26 22 / 24%);
color: var(--color-cover-text);
font-size: 1.75rem;
font-weight: 780;
letter-spacing: -0.06em;
backdrop-filter: blur(8px);
}
.cardBody {
display: grid;
grid-template-rows: minmax(4.75rem, auto) auto;
gap: var(--space-5);
padding: var(--space-5);
}
.cardHeading {
display: grid;
gap: var(--space-2);
align-content: start;
}
.cardTitle {
display: -webkit-box;
max-width: 18rem;
min-height: 3.1rem;
overflow: hidden;
color: var(--color-text);
font-size: 1.35rem;
line-height: 1.15;
letter-spacing: -0.035em;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.slug {
overflow: hidden;
color: var(--color-accent);
font-size: 0.875rem;
font-weight: 720;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.cardMeta {
display: grid;
gap: var(--space-2);
padding-top: var(--space-4);
border-top: 1px solid var(--color-border-soft);
}
.assetsCount {
color: var(--color-accent);
font-size: 0.875rem;
font-weight: 760;
line-height: 1.35;
}
.createdAt {
overflow: hidden;
color: var(--color-text-subtle);
font-size: 0.8125rem;
font-weight: 620;
line-height: 1.35;
text-overflow: ellipsis;
white-space: nowrap;
}
.emptyState {
display: flex;
flex-direction: column;
gap: var(--space-4);
align-items: flex-start;
padding: var(--space-5);
border: 1px solid var(--color-border);
border-radius: var(--radius-4);
background: var(--color-surface-muted);
@media (--md) {
flex-direction: row;
align-items: center;
justify-content: space-between;
}
}
.emptyTitle {
color: var(--color-text);
font-size: 1.25rem;
letter-spacing: -0.025em;
}
.emptyText {
margin-top: var(--space-2);
color: var(--color-text-muted);
font-size: 0.9375rem;
line-height: 1.6;
}

View File

@@ -0,0 +1,13 @@
import type { ProjectsHome } from "business/projects"
/**
* Параметры ProjectsGrid.
*/
export type ProjectsGridProps = {
/** Данные главной страницы проектов. */
home: ProjectsHome
/** Callback открытия modal создания проекта. */
onCreateProject: () => void
/** Callback выбора проекта. */
onSelectProject: (projectSlug: string) => void
}

View File

@@ -0,0 +1,48 @@
import { Alert, Stack } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks"
import cl from "clsx"
import { useNavigate } from "react-router-dom"
import { projectsFactory } from "business/projects"
import styles from "screens/shared/styles/screen.module.css"
import { CreateProjectModal } from "./parts/create-project-modal"
import { ProjectsGrid } from "./parts/projects-grid"
import type { ProjectsScreenProps } from "./types/projects-screen-props.type"
const projects = projectsFactory()
/**
* Главная страница проектов.
*/
export const ProjectsScreen = (props: ProjectsScreenProps) => {
const { className, ...rootAttrs } = props
const navigate = useNavigate()
const [isCreateProjectOpen, createProjectModal] = useDisclosure(false)
const projectsHome = projects.useProjectsHome()
const createProject = projects.useCreateProject()
const openProject = (projectSlug: string) => {
navigate(`/projects/${projectSlug}`)
}
return (
<section {...rootAttrs} className={cl(styles.root, className)}>
<Stack gap="lg">
{projectsHome.error ? (
<Alert color="red" radius="lg" title="Бэкенд API недоступен">
Проверьте, что бэкенд запущен на `localhost:3001`, а Vite proxy доступен по `/api`.
</Alert>
) : null}
<ProjectsGrid home={projectsHome} onCreateProject={createProjectModal.open} onSelectProject={openProject} />
</Stack>
<CreateProjectModal
action={createProject}
onClose={createProjectModal.close}
onCreated={openProject}
opened={isCreateProjectOpen}
/>
</section>
)
}

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