sync
This commit is contained in:
11
apps/admin/src/app/app-router.tsx
Normal file
11
apps/admin/src/app/app-router.tsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Route, Routes } from "react-router-dom"
|
||||
import { AssetDetailPage, NotFoundPage, ProjectAssetsPage, ProjectsPage } from "pages"
|
||||
|
||||
export const AppRouter = () => (
|
||||
<Routes>
|
||||
<Route element={<ProjectsPage />} path="/" />
|
||||
<Route element={<ProjectAssetsPage />} path="/projects/:projectSlug" />
|
||||
<Route element={<AssetDetailPage />} path="/projects/:projectSlug/assets/:publicId" />
|
||||
<Route element={<NotFoundPage />} path="*" />
|
||||
</Routes>
|
||||
)
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ?? [],
|
||||
}
|
||||
}
|
||||
@@ -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))),
|
||||
])
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useGetPresets } from "infra/backend-api"
|
||||
|
||||
import type { ImagePresetsOverview } from "../types/assets-api.type"
|
||||
|
||||
/**
|
||||
* Presets изображений без загрузки общего assets dashboard.
|
||||
*/
|
||||
export const useImagePresets = (): ImagePresetsOverview => {
|
||||
const presetsQuery = useGetPresets()
|
||||
|
||||
const refresh = async () => {
|
||||
await presetsQuery.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
custom: presetsQuery.data?.custom ?? null,
|
||||
error: presetsQuery.error,
|
||||
isLoading: presetsQuery.isLoading,
|
||||
isRefreshing: presetsQuery.isValidating,
|
||||
presets: presetsQuery.data?.presets ?? [],
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { useGetProjectAssets } from "infra/backend-api"
|
||||
|
||||
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
|
||||
import type { ProjectAssetsOverview } from "../types/assets-api.type"
|
||||
|
||||
/**
|
||||
* Assets выбранного проекта.
|
||||
*/
|
||||
export const useProjectAssets = (projectSlug: string | null): ProjectAssetsOverview => {
|
||||
const assetsQuery = useGetProjectAssets(projectSlug, ASSETS_DASHBOARD_LIST_PARAMS)
|
||||
|
||||
const refresh = async () => {
|
||||
await assetsQuery.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
assets: assetsQuery.data?.assets ?? [],
|
||||
error: assetsQuery.error,
|
||||
isLoading: assetsQuery.isLoading,
|
||||
isRefreshing: assetsQuery.isValidating,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useState } from "react"
|
||||
import { useSWRConfig } from "swr"
|
||||
import { backendApi, getProjectsListKey } from "infra/backend-api"
|
||||
|
||||
import { toError } from "../lib/to-error"
|
||||
import type { CreateProjectAction, CreateProjectInput } from "../types/projects-api.type"
|
||||
|
||||
/**
|
||||
* Сценарий создания проекта.
|
||||
*/
|
||||
export const useCreateProject = (): CreateProjectAction => {
|
||||
const { mutate } = useSWRConfig()
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
const [isCreating, setIsCreating] = useState(false)
|
||||
|
||||
const createProject = async (input: CreateProjectInput) => {
|
||||
setError(null)
|
||||
setIsCreating(true)
|
||||
|
||||
try {
|
||||
const project = await backendApi.projects.createProject(input)
|
||||
await mutate(getProjectsListKey())
|
||||
|
||||
return project
|
||||
} catch (caughtError) {
|
||||
const nextError = toError(caughtError)
|
||||
setError(nextError)
|
||||
throw nextError
|
||||
} finally {
|
||||
setIsCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
createProject,
|
||||
error,
|
||||
isCreating,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useGetProject } from "infra/backend-api"
|
||||
|
||||
import type { ProjectDetail } from "../types/projects-api.type"
|
||||
|
||||
/**
|
||||
* Metadata выбранного проекта.
|
||||
*/
|
||||
export const useProjectDetail = (projectSlug: string | null): ProjectDetail => {
|
||||
const projectQuery = useGetProject(projectSlug)
|
||||
|
||||
const refresh = async () => {
|
||||
await projectQuery.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
error: projectQuery.error,
|
||||
isLoading: projectQuery.isLoading,
|
||||
isRefreshing: projectQuery.isValidating,
|
||||
project: projectQuery.data ?? null,
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useGetProjectsList } from "infra/backend-api"
|
||||
|
||||
import type { ProjectsHome } from "../types/projects-api.type"
|
||||
|
||||
/**
|
||||
* Данные главной страницы проектов.
|
||||
*/
|
||||
export const useProjectsHome = (): ProjectsHome => {
|
||||
const projectsQuery = useGetProjectsList()
|
||||
|
||||
const refresh = async () => {
|
||||
await projectsQuery.mutate()
|
||||
}
|
||||
|
||||
return {
|
||||
error: projectsQuery.error,
|
||||
isLoading: projectsQuery.isLoading,
|
||||
isRefreshing: projectsQuery.isValidating,
|
||||
projects: projectsQuery.data?.projects ?? [],
|
||||
refresh,
|
||||
}
|
||||
}
|
||||
9
apps/admin/src/business/projects/index.ts
Normal file
9
apps/admin/src/business/projects/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { projectsFactory } from "./projects.factory"
|
||||
export type {
|
||||
CreateProjectAction,
|
||||
CreateProjectInput,
|
||||
ProjectDetail,
|
||||
ProjectsApi,
|
||||
ProjectsHome,
|
||||
} from "./types/projects-api.type"
|
||||
export type { ProjectsFactory } from "./types/projects-factory.type"
|
||||
1
apps/admin/src/business/projects/lib/to-error.ts
Normal file
1
apps/admin/src/business/projects/lib/to-error.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const toError = (error: unknown) => (error instanceof Error ? error : new Error(String(error)))
|
||||
15
apps/admin/src/business/projects/projects.factory.ts
Normal file
15
apps/admin/src/business/projects/projects.factory.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useCreateProject } from "./hooks/use-create-project.hook"
|
||||
import { useProjectDetail } from "./hooks/use-project-detail.hook"
|
||||
import { useProjectsHome } from "./hooks/use-projects-home.hook"
|
||||
import type { ProjectsFactory } from "./types/projects-factory.type"
|
||||
|
||||
/**
|
||||
* Создаёт runtime API бизнес-модуля Projects.
|
||||
*/
|
||||
export const projectsFactory: ProjectsFactory = () => {
|
||||
return {
|
||||
useCreateProject,
|
||||
useProjectDetail,
|
||||
useProjectsHome,
|
||||
}
|
||||
}
|
||||
34
apps/admin/src/business/projects/types/projects-api.type.ts
Normal file
34
apps/admin/src/business/projects/types/projects-api.type.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import type { CreateProjectRequestDto, ProjectResponseDto } from "infra/backend-api"
|
||||
|
||||
export type ProjectsHome = {
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
projects: ProjectResponseDto[]
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type ProjectDetail = {
|
||||
error?: Error
|
||||
isLoading: boolean
|
||||
isRefreshing: boolean
|
||||
project: ProjectResponseDto | null
|
||||
refresh: () => Promise<void>
|
||||
}
|
||||
|
||||
export type CreateProjectInput = CreateProjectRequestDto
|
||||
|
||||
export type CreateProjectAction = {
|
||||
createProject: (input: CreateProjectInput) => Promise<ProjectResponseDto>
|
||||
error: Error | null
|
||||
isCreating: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Публичный runtime API бизнес-модуля Projects.
|
||||
*/
|
||||
export type ProjectsApi = {
|
||||
useCreateProject: () => CreateProjectAction
|
||||
useProjectDetail: (projectSlug: string | null) => ProjectDetail
|
||||
useProjectsHome: () => ProjectsHome
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { ProjectsApi } from "./projects-api.type"
|
||||
|
||||
/**
|
||||
* Фабрика runtime API бизнес-модуля Projects.
|
||||
*/
|
||||
export type ProjectsFactory = () => ProjectsApi
|
||||
@@ -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,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { AssetsListResponseDto, ListProjectAssetsParams } from "../generated/backend-api.generated"
|
||||
|
||||
export const getProjectAssetsKey = (projectSlug: string, params: Omit<ListProjectAssetsParams, "projectSlug"> = {}) =>
|
||||
["backend-api", "projects", "assets", projectSlug, params.limit ?? null, params.offset ?? null] as const
|
||||
|
||||
/**
|
||||
* Получение assets проекта.
|
||||
*/
|
||||
export const useGetProjectAssets = (
|
||||
projectSlug: string | null,
|
||||
params: Omit<ListProjectAssetsParams, "projectSlug"> = {},
|
||||
config?: SWRConfiguration<AssetsListResponseDto>,
|
||||
) => {
|
||||
const key = projectSlug !== null ? getProjectAssetsKey(projectSlug, params) : null
|
||||
const fetcher = () => backendApi.projects.listProjectAssets({ ...params, projectSlug: projectSlug ?? "" })
|
||||
|
||||
return useSWR<AssetsListResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { ProjectResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getProjectKey = (projectSlug: string) => ["backend-api", "projects", "detail", projectSlug] as const
|
||||
|
||||
/**
|
||||
* Получение проекта по slug.
|
||||
*/
|
||||
export const useGetProject = (projectSlug: string | null, config?: SWRConfiguration<ProjectResponseDto>) => {
|
||||
const key = projectSlug !== null ? getProjectKey(projectSlug) : null
|
||||
const fetcher = () => backendApi.projects.getProject({ projectSlug: projectSlug ?? "" })
|
||||
|
||||
return useSWR<ProjectResponseDto>(key, fetcher, config)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import useSWR from "swr"
|
||||
import type { SWRConfiguration } from "swr"
|
||||
|
||||
import { backendApi } from "../client"
|
||||
import type { ProjectsListResponseDto } from "../generated/backend-api.generated"
|
||||
|
||||
export const getProjectsListKey = () => ["backend-api", "projects", "list"] as const
|
||||
|
||||
/**
|
||||
* Получение списка проектов.
|
||||
*/
|
||||
export const useGetProjectsList = (config?: SWRConfiguration<ProjectsListResponseDto>) => {
|
||||
const fetcher = () => backendApi.projects.listProjects()
|
||||
|
||||
return useSWR<ProjectsListResponseDto>(getProjectsListKey(), fetcher, config)
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx
Normal file
16
apps/admin/src/pages/asset-detail-page/asset-detail.page.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Navigate, useParams } from "react-router-dom"
|
||||
import { AssetDetailScreen } from "screens/asset-detail"
|
||||
|
||||
export const AssetDetailPage = () => {
|
||||
const { projectSlug, publicId } = useParams()
|
||||
|
||||
if (!projectSlug) {
|
||||
return <Navigate replace to="/" />
|
||||
}
|
||||
|
||||
if (!publicId) {
|
||||
return <Navigate replace to={`/projects/${projectSlug}`} />
|
||||
}
|
||||
|
||||
return <AssetDetailScreen projectSlug={projectSlug} publicId={publicId} />
|
||||
}
|
||||
1
apps/admin/src/pages/asset-detail-page/index.ts
Normal file
1
apps/admin/src/pages/asset-detail-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssetDetailPage } from "./asset-detail.page"
|
||||
4
apps/admin/src/pages/index.ts
Normal file
4
apps/admin/src/pages/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { AssetDetailPage } from "./asset-detail-page"
|
||||
export { NotFoundPage } from "./not-found-page"
|
||||
export { ProjectAssetsPage } from "./project-assets-page"
|
||||
export { ProjectsPage } from "./projects-page"
|
||||
1
apps/admin/src/pages/not-found-page/index.ts
Normal file
1
apps/admin/src/pages/not-found-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { NotFoundPage } from "./not-found.page"
|
||||
14
apps/admin/src/pages/not-found-page/not-found.page.tsx
Normal file
14
apps/admin/src/pages/not-found-page/not-found.page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Button, Paper, Stack, Text, Title } from "@mantine/core"
|
||||
import { Link } from "react-router-dom"
|
||||
|
||||
export const NotFoundPage = () => (
|
||||
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
|
||||
<Stack align="start" gap="md">
|
||||
<Title order={1}>Страница не найдена</Title>
|
||||
<Text c="dimmed">Такого маршрута в админке нет.</Text>
|
||||
<Button component={Link} radius="xl" to="/">
|
||||
Вернуться к проектам
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)
|
||||
1
apps/admin/src/pages/project-assets-page/index.ts
Normal file
1
apps/admin/src/pages/project-assets-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectAssetsPage } from "./project-assets.page"
|
||||
@@ -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} />
|
||||
}
|
||||
1
apps/admin/src/pages/projects-page/index.ts
Normal file
1
apps/admin/src/pages/projects-page/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectsPage } from "./projects.page"
|
||||
3
apps/admin/src/pages/projects-page/projects.page.tsx
Normal file
3
apps/admin/src/pages/projects-page/projects.page.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
import { ProjectsScreen } from "screens/projects"
|
||||
|
||||
export const ProjectsPage = () => <ProjectsScreen />
|
||||
119
apps/admin/src/screens/asset-detail/asset-detail.screen.tsx
Normal file
119
apps/admin/src/screens/asset-detail/asset-detail.screen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
1
apps/admin/src/screens/asset-detail/index.ts
Normal file
1
apps/admin/src/screens/asset-detail/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AssetDetailScreen } from "./asset-detail.screen"
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { SourceVersionsPanel } from "./source-versions-panel"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AssetVersionsHistory } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры SourceVersionsPanel.
|
||||
*/
|
||||
export type SourceVersionsPanelProps = {
|
||||
/** История source versions выбранного asset. */
|
||||
history: AssetVersionsHistory
|
||||
/** Выбранный publicId. */
|
||||
publicId: string | null
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
/** Параметры AssetDetailScreen. */
|
||||
export type AssetDetailScreenProps = ComponentPropsWithoutRef<"section"> & {
|
||||
/** Public ID asset. */
|
||||
publicId: string
|
||||
/** Slug проекта. */
|
||||
projectSlug: string
|
||||
}
|
||||
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -1,2 +0,0 @@
|
||||
export { DashboardScreen } from "./dashboard.screen"
|
||||
export type { DashboardScreenProps } from "./types/dashboard.type"
|
||||
@@ -1,2 +0,0 @@
|
||||
export { SummaryCards } from "./summary-cards"
|
||||
export type { SummaryCardsProps } from "./types/summary-cards-props.type"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import type { AssetsDashboard } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры SummaryCards.
|
||||
*/
|
||||
export type SummaryCardsProps = {
|
||||
/** Признак загрузки данных. */
|
||||
isLoading: boolean
|
||||
/** Сводные метрики dashboard. */
|
||||
summary: AssetsDashboard["summary"]
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
import type { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
/** Параметры экрана Dashboard. */
|
||||
export type DashboardScreenProps = ComponentPropsWithoutRef<"section">
|
||||
1
apps/admin/src/screens/project-assets/index.ts
Normal file
1
apps/admin/src/screens/project-assets/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectAssetsScreen } from "./project-assets.screen"
|
||||
@@ -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()
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { AssetsExplorer } from "./assets-explorer"
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
@@ -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>
|
||||
@@ -12,4 +12,6 @@ export type CreateAssetModalProps = {
|
||||
onCreated: (publicId: string) => void
|
||||
/** Открыта ли modal. */
|
||||
opened: boolean
|
||||
/** Slug проекта, внутри которого создаётся asset. */
|
||||
projectSlug?: string | null
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { PresetsTable } from "./presets-table"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { ImagePresetsOverview } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры PresetsTable.
|
||||
*/
|
||||
export type PresetsTableProps = {
|
||||
/** Presets проекта. */
|
||||
presets: ImagePresetsOverview
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectHeader } from "./project-header"
|
||||
@@ -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 "изображений"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
/** Параметры ProjectAssetsScreen. */
|
||||
export type ProjectAssetsScreenProps = ComponentPropsWithoutRef<"section"> & {
|
||||
/** Slug проекта. */
|
||||
projectSlug: string
|
||||
}
|
||||
1
apps/admin/src/screens/projects/index.ts
Normal file
1
apps/admin/src/screens/projects/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ProjectsScreen } from "./projects.screen"
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { CreateProjectModal } from "./create-project-modal"
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { ProjectsGrid } from "./projects-grid"
|
||||
@@ -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 "изображений"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
48
apps/admin/src/screens/projects/projects.screen.tsx
Normal file
48
apps/admin/src/screens/projects/projects.screen.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { ComponentPropsWithoutRef } from "react"
|
||||
|
||||
/** Параметры ProjectsScreen. */
|
||||
export type ProjectsScreenProps = ComponentPropsWithoutRef<"section">
|
||||
37
apps/admin/src/screens/shared/config/image-ui.config.ts
Normal file
37
apps/admin/src/screens/shared/config/image-ui.config.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
export const IMAGE_PIPELINE_STEPS = ["Backend", "RabbitMQ", "Worker", "imgproxy", "S3"] as const
|
||||
|
||||
export const ASSET_STATUS_COLORS = {
|
||||
active: "green",
|
||||
deleted: "red",
|
||||
disabled: "gray",
|
||||
} as const
|
||||
|
||||
export const ASSET_STATUS_LABELS = {
|
||||
active: "активно",
|
||||
deleted: "удалено",
|
||||
disabled: "отключено",
|
||||
} as const
|
||||
|
||||
export const PROJECT_STATUS_COLORS = {
|
||||
active: "green",
|
||||
disabled: "gray",
|
||||
} as const
|
||||
|
||||
export const PROJECT_STATUS_LABELS = {
|
||||
active: "активен",
|
||||
disabled: "отключён",
|
||||
} as const
|
||||
|
||||
export const VARIANT_STATUS_COLORS = {
|
||||
failed: "red",
|
||||
pending: "yellow",
|
||||
processing: "blue",
|
||||
ready: "green",
|
||||
} as const
|
||||
|
||||
export const VARIANT_STATUS_LABELS = {
|
||||
failed: "ошибка",
|
||||
pending: "в очереди",
|
||||
processing: "генерируется",
|
||||
ready: "готово",
|
||||
} as const
|
||||
@@ -5,14 +5,14 @@ export const copyText = async (value: string, label: string) => {
|
||||
await navigator.clipboard.writeText(value)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
message: `${label} скопирован в clipboard`,
|
||||
title: "Copied",
|
||||
message: `${label} скопирован в буфер обмена`,
|
||||
title: "Скопировано",
|
||||
})
|
||||
} catch {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: `Не удалось скопировать ${label}`,
|
||||
title: "Copy failed",
|
||||
title: "Копирование не удалось",
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user