feat: добавить workflow управления asset в admin

- добавлены сценарии создания source version и generation jobs\n- добавлены modal формы source version и variants generation\n- обновлена detail panel variants actions и ссылки на public URL
This commit is contained in:
2026-05-05 15:20:24 +03:00
parent 6a018826f5
commit 8094535747
17 changed files with 626 additions and 9 deletions

View File

@@ -1,6 +1,8 @@
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 type { AssetsFactory } from "./types/assets-factory.type"
/**
@@ -11,5 +13,7 @@ export const assetsFactory: AssetsFactory = () => {
useAssetOverview,
useAssetsDashboard,
useCreateAsset,
useCreateAssetVersion,
useGenerateAssetVariants,
}
}

View File

@@ -0,0 +1,48 @@
import { useState } from "react"
import { useSWRConfig } from "swr"
import { backendApi, getAssetKey, getAssetVariantsKey, getAssetsListKey } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error"
import type { CreateAssetVersionAction, CreateAssetVersionInput } from "../types/assets-api.type"
/**
* Сценарий создания новой source version.
*/
export const useCreateAssetVersion = (): CreateAssetVersionAction => {
const { mutate } = useSWRConfig()
const [error, setError] = useState<Error | null>(null)
const [isCreating, setIsCreating] = useState(false)
const createAssetVersion = async (input: CreateAssetVersionInput) => {
setError(null)
setIsCreating(true)
try {
const createdVersion = await backendApi.assets.createAssetVersion(
{ publicId: input.publicId },
{ sourceUrl: input.sourceUrl },
)
await Promise.all([
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
mutate(getAssetKey(input.publicId)),
mutate(getAssetVariantsKey(input.publicId, String(createdVersion.version))),
])
return createdVersion
} catch (caughtError) {
const nextError = toError(caughtError)
setError(nextError)
throw nextError
} finally {
setIsCreating(false)
}
}
return {
createAssetVersion,
error,
isCreating,
}
}

View File

@@ -3,10 +3,9 @@ import { useSWRConfig } from "swr"
import { backendApi, getAssetsListKey } from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error"
import type { CreateAssetAction, CreateAssetInput } from "../types/assets-api.type"
const toError = (error: unknown) => (error instanceof Error ? error : new Error("Неизвестная ошибка"))
/**
* Сценарий создания asset с обновлением списка.
*/

View File

@@ -0,0 +1,68 @@
import { useState } from "react"
import { useSWRConfig } from "swr"
import {
backendApi,
getAssetKey,
getAssetVariantsKey,
getAssetsListKey,
type CreateAssetVariantsRequestDto,
} from "infra/backend-api"
import { ASSETS_DASHBOARD_LIST_PARAMS } from "../config/assets.config"
import { toError } from "../lib/to-error"
import type { GenerateAssetVariantsAction, GenerateAssetVariantsInput } from "../types/assets-api.type"
const toGeneratedRequest = (input: GenerateAssetVariantsInput): CreateAssetVariantsRequestDto => {
return {
preset: input.preset,
format: input.format,
formats: input.formats,
height: input.height,
mode: input.mode,
quality: input.quality,
resize: input.resize,
version: input.version,
width: input.width,
} as CreateAssetVariantsRequestDto
}
/**
* Сценарий постановки generation jobs для variants.
*/
export const useGenerateAssetVariants = (): GenerateAssetVariantsAction => {
const { mutate } = useSWRConfig()
const [error, setError] = useState<Error | null>(null)
const [isGenerating, setIsGenerating] = useState(false)
const generateAssetVariants = async (input: GenerateAssetVariantsInput) => {
setError(null)
setIsGenerating(true)
try {
const response = await backendApi.assets.createAssetVariants(
{ publicId: input.publicId },
toGeneratedRequest(input),
)
await Promise.all([
mutate(getAssetsListKey(ASSETS_DASHBOARD_LIST_PARAMS)),
mutate(getAssetKey(input.publicId)),
mutate(getAssetVariantsKey(input.publicId, String(response.version))),
])
return response
} catch (caughtError) {
const nextError = toError(caughtError)
setError(nextError)
throw nextError
} finally {
setIsGenerating(false)
}
}
return {
error,
generateAssetVariants,
isGenerating,
}
}

View File

@@ -3,7 +3,14 @@ export type {
AssetOverview,
AssetsApi,
AssetsDashboard,
AssetVariantFormat,
AssetVariantMode,
AssetVariantResize,
CreateAssetAction,
CreateAssetInput,
CreateAssetVersionAction,
CreateAssetVersionInput,
GenerateAssetVariantsAction,
GenerateAssetVariantsInput,
} from "./types/assets-api.type"
export type { AssetsFactory } from "./types/assets-factory.type"

View File

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

View File

@@ -3,10 +3,16 @@ import type {
AssetVariantResponseDto,
CreateAssetRequestDto,
CreateAssetResponseDto,
CreateAssetVersionResponseDto,
CreateAssetVariantsResponseDto,
PresetResponseDto,
PresetsResponseDto,
} from "infra/backend-api"
export type AssetVariantFormat = "avif" | "jpg" | "png" | "webp"
export type AssetVariantMode = "family" | "single"
export type AssetVariantResize = "fill" | "fit"
export type AssetOverview = {
asset: AssetResponseDto | null
error?: Error
@@ -36,6 +42,36 @@ export type CreateAssetAction = {
isCreating: boolean
}
export type CreateAssetVersionInput = {
publicId: string
sourceUrl: string
}
export type CreateAssetVersionAction = {
createAssetVersion: (input: CreateAssetVersionInput) => Promise<CreateAssetVersionResponseDto>
error: Error | null
isCreating: boolean
}
export type GenerateAssetVariantsInput = {
format?: AssetVariantFormat
formats?: AssetVariantFormat[]
height?: number
mode: AssetVariantMode
preset: string
publicId: string
quality?: number
resize?: AssetVariantResize
version?: number
width?: number
}
export type GenerateAssetVariantsAction = {
error: Error | null
generateAssetVariants: (input: GenerateAssetVariantsInput) => Promise<CreateAssetVariantsResponseDto>
isGenerating: boolean
}
/**
* Публичный runtime API бизнес-модуля Assets.
*/
@@ -43,4 +79,6 @@ export type AssetsApi = {
useAssetOverview: (publicId: string | null) => AssetOverview
useAssetsDashboard: () => AssetsDashboard
useCreateAsset: () => CreateAssetAction
useCreateAssetVersion: () => CreateAssetVersionAction
useGenerateAssetVariants: () => GenerateAssetVariantsAction
}

View File

@@ -7,6 +7,10 @@ export type {
AssetsListResponseDto,
CreateAssetRequestDto,
CreateAssetResponseDto,
CreateAssetVersionRequestDto,
CreateAssetVersionResponseDto,
CreateAssetVariantsRequestDto,
CreateAssetVariantsResponseDto,
CustomTransformConfigResponseDto,
ListAssetsParams,
PresetResponseDto,

View File

@@ -8,6 +8,8 @@ import { DASHBOARD_PIPELINE } from "./config/dashboard.config"
import { AssetDetailPanel } from "./parts/asset-detail-panel"
import { AssetsTable } from "./parts/assets-table"
import { CreateAssetModal } from "./parts/create-asset-modal"
import { CreateSourceVersionModal } from "./parts/create-source-version-modal"
import { GenerateVariantsModal } from "./parts/generate-variants-modal"
import { PresetsPanel } from "./parts/presets-panel"
import { SummaryCards } from "./parts/summary-cards"
import styles from "./styles/dashboard.module.css"
@@ -26,8 +28,12 @@ export const DashboardScreen = (props: DashboardScreenProps) => {
const { className, ...rootAttrs } = props
const [selectedPublicId, setSelectedPublicId] = useState<string | null>(null)
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 overview = assets.useAssetOverview(effectivePublicId)
@@ -74,7 +80,12 @@ export const DashboardScreen = (props: DashboardScreenProps) => {
onSelect={setSelectedPublicId}
selectedPublicId={effectivePublicId}
/>
<AssetDetailPanel overview={overview} publicId={effectivePublicId} />
<AssetDetailPanel
onCreateVersion={createVersionModal.open}
onGenerateVariants={generateVariantsModal.open}
overview={overview}
publicId={effectivePublicId}
/>
</div>
<PresetsPanel
@@ -91,6 +102,24 @@ export const DashboardScreen = (props: DashboardScreenProps) => {
onCreated={setSelectedPublicId}
opened={isCreateAssetOpen}
/>
<CreateSourceVersionModal
action={createAssetVersion}
onClose={createVersionModal.close}
onCreated={setSelectedPublicId}
opened={isCreateVersionOpen}
publicId={effectivePublicId}
/>
<GenerateVariantsModal
action={generateAssetVariants}
asset={overview.asset}
custom={dashboard.custom}
onClose={generateVariantsModal.close}
onGenerated={setSelectedPublicId}
opened={isGenerateVariantsOpen}
presets={dashboard.presets}
/>
</section>
)
}

View File

@@ -1,4 +1,4 @@
import { Anchor, Badge, Code, Group, Paper, ScrollArea, Skeleton, Stack, Table, Text, Title } from "@mantine/core"
import { Anchor, Badge, Button, Code, Group, Paper, ScrollArea, Skeleton, Stack, Table, Text, Title } from "@mantine/core"
import { ASSET_STATUS_COLORS, VARIANT_STATUS_COLORS } from "../../config/dashboard.config"
import { formatDateTime } from "../../lib/format-date"
@@ -12,7 +12,7 @@ import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.typ
* - отображения статусов generated variants
*/
export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
const { overview, publicId } = props
const { onCreateVersion, onGenerateVariants, overview, publicId } = props
const { asset, variants } = overview
if (!publicId) {
@@ -41,9 +41,17 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
</div>
{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>
<Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
{asset.status}
</Badge>
</Group>
) : null}
</Group>
@@ -91,6 +99,7 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Table.Th>Format</Table.Th>
<Table.Th>Size</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>URL</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
@@ -108,6 +117,11 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
{variant.status}
</Badge>
</Table.Td>
<Table.Td>
<Anchor href={variant.url} target="_blank">
open
</Anchor>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>

View File

@@ -6,6 +6,10 @@ import type { AssetOverview } from "business/assets"
export type AssetDetailPanelProps = {
/** Данные выбранного asset. */
overview: AssetOverview
/** Callback открытия modal новой source version. */
onCreateVersion: () => void
/** Callback открытия modal генерации variants. */
onGenerateVariants: () => void
/** Выбранный publicId. */
publicId: string | null
}

View File

@@ -0,0 +1,108 @@
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import type { CreateSourceVersionModalProps } from "./types/create-source-version-modal-props.type"
type CreateSourceVersionFormValues = {
sourceUrl: string
}
const SOURCE_URL_EXAMPLE = "https://storage.yandexcloud.net/shared1318/img/1.jpg"
const toErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Неизвестная ошибка")
/**
* Modal создания новой source version.
*
* Используется для:
* - регистрации нового source URL
* - обновления immutable version выбранного asset
*/
export const CreateSourceVersionModal = (props: CreateSourceVersionModalProps) => {
const { action, onClose, onCreated, opened, publicId } = props
const form = useForm<CreateSourceVersionFormValues>({
initialValues: {
sourceUrl: SOURCE_URL_EXAMPLE,
},
validate: {
sourceUrl: (value) => {
if (!value.trim()) {
return "Укажите source URL"
}
try {
const url = new URL(value)
return url.protocol === "http:" || url.protocol === "https:" ? null : "URL должен быть http/https"
} catch {
return "Некорректный URL"
}
},
},
})
const handleClose = () => {
if (!action.isCreating) {
onClose()
}
}
const handleSubmit = form.onSubmit(async (values) => {
if (!publicId) {
return
}
try {
const createdVersion = await action.createAssetVersion({
publicId,
sourceUrl: values.sourceUrl.trim(),
})
notifications.show({
color: "green",
message: `Asset ${createdVersion.publicId} обновлён до v${createdVersion.version}`,
title: "Source version created",
})
form.reset()
onCreated(createdVersion.publicId)
onClose()
} catch (error) {
notifications.show({
color: "red",
message: toErrorMessage(error),
title: "Не удалось создать source version",
})
}
})
return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="New source version">
<form onSubmit={handleSubmit}>
<Stack gap="md">
<Text c="dimmed" fz="sm">
Новая source version изменит currentVersion asset. Старые public URLs останутся immutable.
</Text>
<TextInput label="Asset" readOnly value={publicId ?? ""} />
<TextInput
disabled={action.isCreating}
label="New source URL"
placeholder={SOURCE_URL_EXAMPLE}
required
{...form.getInputProps("sourceUrl")}
/>
<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>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,2 @@
export { CreateSourceVersionModal } from "./create-source-version-modal"
export type { CreateSourceVersionModalProps } from "./types/create-source-version-modal-props.type"

View File

@@ -0,0 +1,17 @@
import type { CreateAssetVersionAction } from "business/assets"
/**
* Параметры CreateSourceVersionModal.
*/
export type CreateSourceVersionModalProps = {
/** Сценарий создания source version. */
action: CreateAssetVersionAction
/** Callback закрытия modal. */
onClose: () => void
/** Callback успешного создания version. */
onCreated: (publicId: string) => void
/** Открыта ли modal. */
opened: boolean
/** Публичный идентификатор выбранного asset. */
publicId: string | null
}

View File

@@ -0,0 +1,250 @@
import { Button, Group, Modal, MultiSelect, NumberInput, Select, SegmentedControl, Stack, Text } from "@mantine/core"
import { useForm } from "@mantine/form"
import { notifications } from "@mantine/notifications"
import type {
AssetVariantFormat,
AssetVariantMode,
AssetVariantResize,
GenerateAssetVariantsInput,
} from "business/assets"
import type { GenerateVariantsModalProps } from "./types/generate-variants-modal-props.type"
type NumberFieldValue = number | string
type GenerateVariantsFormValues = {
format: AssetVariantFormat
formats: AssetVariantFormat[]
height: NumberFieldValue
mode: AssetVariantMode
preset: string
quality: NumberFieldValue
resize: AssetVariantResize
version: NumberFieldValue
width: NumberFieldValue
}
const DEFAULT_FORMAT: AssetVariantFormat = "webp"
const DEFAULT_RESIZE: AssetVariantResize = "fit"
const FORMAT_OPTIONS: AssetVariantFormat[] = ["avif", "webp", "jpg", "png"]
const RESIZE_OPTIONS: AssetVariantResize[] = ["fit", "fill"]
const toErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Неизвестная ошибка")
const toOptionalNumber = (value: NumberFieldValue) => {
if (value === "") {
return undefined
}
const parsedValue = Number(value)
return Number.isFinite(parsedValue) ? parsedValue : undefined
}
/**
* Modal постановки generation jobs для variants.
*
* Используется для:
* - генерации family static preset
* - генерации single/custom variant
*/
export const GenerateVariantsModal = (props: GenerateVariantsModalProps) => {
const { action, asset, custom, onClose, onGenerated, opened, presets } = props
const firstPreset = presets[0]?.name ?? ""
const form = useForm<GenerateVariantsFormValues>({
initialValues: {
format: DEFAULT_FORMAT,
formats: [],
height: "",
mode: "family",
preset: firstPreset,
quality: "",
resize: DEFAULT_RESIZE,
version: "",
width: "",
},
validate: {
preset: (value) => (value ? null : "Выберите preset"),
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
},
},
})
const selectedPreset = presets.find((preset) => preset.name === form.values.preset)
const isCustom = form.values.preset === "custom"
const availableFormats = isCustom ? (custom?.formats ?? []) : (selectedPreset?.formats ?? FORMAT_OPTIONS)
const presetOptions = [
...presets.map((preset) => ({
label: `${preset.name} (${preset.mode})`,
value: preset.name,
})),
...(custom?.enabled ? [{ label: "custom", value: "custom" }] : []),
]
const formatOptions = availableFormats.map((format) => ({ label: format, value: format }))
const widthHint = selectedPreset?.widths?.length ? `Allowed: ${selectedPreset.widths.join(", ")}` : undefined
const handleClose = () => {
if (!action.isGenerating) {
onClose()
}
}
const handleSubmit = form.onSubmit(async (values) => {
if (!asset) {
return
}
if (values.mode === "family" && isCustom) {
notifications.show({
color: "red",
message: "Custom transform поддерживает только single generation.",
title: "Некорректный режим",
})
return
}
const input: GenerateAssetVariantsInput = {
mode: values.mode,
preset: values.preset,
publicId: asset.publicId,
...(toOptionalNumber(values.version) ? { version: toOptionalNumber(values.version) } : {}),
}
if (values.mode === "family") {
input.formats = values.formats.length > 0 ? values.formats : undefined
} else {
input.format = values.format
input.height = toOptionalNumber(values.height)
input.quality = toOptionalNumber(values.quality)
input.resize = isCustom ? values.resize : undefined
input.width = toOptionalNumber(values.width)
}
try {
const response = await action.generateAssetVariants(input)
notifications.show({
color: "green",
message: `Поставлено variants: ${response.variants.length}`,
title: "Generation jobs created",
})
onGenerated(response.publicId)
onClose()
} catch (error) {
notifications.show({
color: "red",
message: toErrorMessage(error),
title: "Не удалось поставить generation jobs",
})
}
})
return (
<Modal centered onClose={handleClose} opened={opened} radius="xl" size="lg" title="Generate variants">
<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"
onChange={(value) => form.setFieldValue("preset", value ?? "")}
required
value={form.values.preset}
/>
<SegmentedControl
data={[
{ label: "Family", value: "family" },
{ label: "Single", value: "single" },
]}
disabled={action.isGenerating}
onChange={(value) => form.setFieldValue("mode", value as AssetVariantMode)}
value={form.values.mode}
/>
<NumberInput
disabled={action.isGenerating}
label="Version"
min={1}
placeholder={`current v${asset?.currentVersion ?? ""}`}
{...form.getInputProps("version")}
/>
{form.values.mode === "family" ? (
<MultiSelect
data={formatOptions}
disabled={action.isGenerating}
label="Formats"
placeholder="All preset formats"
{...form.getInputProps("formats")}
/>
) : (
<Stack gap="md">
<Select
data={formatOptions}
disabled={action.isGenerating || formatOptions.length === 0}
label="Format"
onChange={(value) => form.setFieldValue("format", (value ?? DEFAULT_FORMAT) as AssetVariantFormat)}
required
value={form.values.format}
/>
<Group grow>
<NumberInput
description={widthHint}
disabled={action.isGenerating}
label="Width"
min={1}
{...form.getInputProps("width")}
/>
<NumberInput
disabled={action.isGenerating}
label="Height"
min={0}
placeholder="auto"
{...form.getInputProps("height")}
/>
</Group>
<Group grow>
<NumberInput
disabled={action.isGenerating}
label="Quality"
max={100}
min={1}
placeholder={String(selectedPreset?.quality ?? custom?.quality ?? 80)}
{...form.getInputProps("quality")}
/>
<Select
data={RESIZE_OPTIONS.map((resize) => ({ label: resize, value: resize }))}
disabled={action.isGenerating || !isCustom}
label="Resize"
onChange={(value) => form.setFieldValue("resize", (value ?? DEFAULT_RESIZE) as AssetVariantResize)}
value={form.values.resize}
/>
</Group>
</Stack>
)}
<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>
</form>
</Modal>
)
}

View File

@@ -0,0 +1,2 @@
export { GenerateVariantsModal } from "./generate-variants-modal"
export type { GenerateVariantsModalProps } from "./types/generate-variants-modal-props.type"

View File

@@ -0,0 +1,21 @@
import type { AssetOverview, AssetsDashboard, GenerateAssetVariantsAction } from "business/assets"
/**
* Параметры GenerateVariantsModal.
*/
export type GenerateVariantsModalProps = {
/** Выбранный asset. */
asset: AssetOverview["asset"]
/** Custom transform config. */
custom: AssetsDashboard["custom"]
/** Сценарий генерации variants. */
action: GenerateAssetVariantsAction
/** Callback закрытия modal. */
onClose: () => void
/** Callback успешной генерации. */
onGenerated: (publicId: string) => void
/** Открыта ли modal. */
opened: boolean
/** Static presets. */
presets: AssetsDashboard["presets"]
}