diff --git a/apps/admin/src/business/assets/assets.factory.ts b/apps/admin/src/business/assets/assets.factory.ts index 13e3cb3..9afdb52 100644 --- a/apps/admin/src/business/assets/assets.factory.ts +++ b/apps/admin/src/business/assets/assets.factory.ts @@ -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, } } diff --git a/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts b/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts new file mode 100644 index 0000000..91e6fb3 --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-create-asset-version.hook.ts @@ -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(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, + } +} diff --git a/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts b/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts index 0f19cdf..f4fffc6 100644 --- a/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts +++ b/apps/admin/src/business/assets/hooks/use-create-asset.hook.ts @@ -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 с обновлением списка. */ diff --git a/apps/admin/src/business/assets/hooks/use-generate-asset-variants.hook.ts b/apps/admin/src/business/assets/hooks/use-generate-asset-variants.hook.ts new file mode 100644 index 0000000..0748d16 --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-generate-asset-variants.hook.ts @@ -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(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, + } +} diff --git a/apps/admin/src/business/assets/index.ts b/apps/admin/src/business/assets/index.ts index 611fddd..7ab0ef6 100644 --- a/apps/admin/src/business/assets/index.ts +++ b/apps/admin/src/business/assets/index.ts @@ -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" diff --git a/apps/admin/src/business/assets/lib/to-error.ts b/apps/admin/src/business/assets/lib/to-error.ts new file mode 100644 index 0000000..1da6f88 --- /dev/null +++ b/apps/admin/src/business/assets/lib/to-error.ts @@ -0,0 +1,2 @@ +export const toError = (error: unknown) => + error instanceof Error ? error : new Error("Неизвестная ошибка") diff --git a/apps/admin/src/business/assets/types/assets-api.type.ts b/apps/admin/src/business/assets/types/assets-api.type.ts index 1af75e0..95cabe2 100644 --- a/apps/admin/src/business/assets/types/assets-api.type.ts +++ b/apps/admin/src/business/assets/types/assets-api.type.ts @@ -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 + 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 + 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 } diff --git a/apps/admin/src/infra/backend-api/index.ts b/apps/admin/src/infra/backend-api/index.ts index f5c6243..5731506 100644 --- a/apps/admin/src/infra/backend-api/index.ts +++ b/apps/admin/src/infra/backend-api/index.ts @@ -7,6 +7,10 @@ export type { AssetsListResponseDto, CreateAssetRequestDto, CreateAssetResponseDto, + CreateAssetVersionRequestDto, + CreateAssetVersionResponseDto, + CreateAssetVariantsRequestDto, + CreateAssetVariantsResponseDto, CustomTransformConfigResponseDto, ListAssetsParams, PresetResponseDto, diff --git a/apps/admin/src/screens/dashboard/dashboard.screen.tsx b/apps/admin/src/screens/dashboard/dashboard.screen.tsx index ec10f9e..d955389 100644 --- a/apps/admin/src/screens/dashboard/dashboard.screen.tsx +++ b/apps/admin/src/screens/dashboard/dashboard.screen.tsx @@ -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(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} /> - + { onCreated={setSelectedPublicId} opened={isCreateAssetOpen} /> + + + + ) } diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx index 44e51a6..c0cc3d8 100644 --- a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx +++ b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/asset-detail-panel.tsx @@ -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) => { {asset ? ( - - {asset.status} - + + + + + {asset.status} + + ) : null} @@ -90,7 +98,8 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { Preset Format Size - Status + Status + URL @@ -108,6 +117,11 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { {variant.status} + + + open + + ))} diff --git a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts index 31f57a4..8e0f081 100644 --- a/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts +++ b/apps/admin/src/screens/dashboard/parts/asset-detail-panel/types/asset-detail-panel-props.type.ts @@ -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 } diff --git a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/create-source-version-modal.tsx b/apps/admin/src/screens/dashboard/parts/create-source-version-modal/create-source-version-modal.tsx new file mode 100644 index 0000000..9d51846 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/create-source-version-modal/create-source-version-modal.tsx @@ -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({ + 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 ( + +
+ + + Новая source version изменит currentVersion asset. Старые public URLs останутся immutable. + + + + + + + + + + + +
+
+ ) +} diff --git a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/index.ts b/apps/admin/src/screens/dashboard/parts/create-source-version-modal/index.ts new file mode 100644 index 0000000..035e205 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/create-source-version-modal/index.ts @@ -0,0 +1,2 @@ +export { CreateSourceVersionModal } from "./create-source-version-modal" +export type { CreateSourceVersionModalProps } from "./types/create-source-version-modal-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts b/apps/admin/src/screens/dashboard/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts new file mode 100644 index 0000000..055abc3 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/create-source-version-modal/types/create-source-version-modal-props.type.ts @@ -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 +} diff --git a/apps/admin/src/screens/dashboard/parts/generate-variants-modal/generate-variants-modal.tsx b/apps/admin/src/screens/dashboard/parts/generate-variants-modal/generate-variants-modal.tsx new file mode 100644 index 0000000..6f4fd6f --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/generate-variants-modal/generate-variants-modal.tsx @@ -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({ + 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 ( + +
+ + + Endpoint создаёт rows variants и RabbitMQ jobs. Worker сгенерирует bytes асинхронно. + + + form.setFieldValue("format", (value ?? DEFAULT_FORMAT) as AssetVariantFormat)} + required + value={form.values.format} + /> + + + + + + + + +