diff --git a/apps/admin/src/business/assets/assets.factory.ts b/apps/admin/src/business/assets/assets.factory.ts index 9afdb52..e096807 100644 --- a/apps/admin/src/business/assets/assets.factory.ts +++ b/apps/admin/src/business/assets/assets.factory.ts @@ -1,3 +1,4 @@ +import { useAssetPicture } from "./hooks/use-asset-picture.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" @@ -11,6 +12,7 @@ import type { AssetsFactory } from "./types/assets-factory.type" export const assetsFactory: AssetsFactory = () => { return { useAssetOverview, + useAssetPicture, useAssetsDashboard, useCreateAsset, useCreateAssetVersion, diff --git a/apps/admin/src/business/assets/config/assets.config.ts b/apps/admin/src/business/assets/config/assets.config.ts index 776f097..946dbf2 100644 --- a/apps/admin/src/business/assets/config/assets.config.ts +++ b/apps/admin/src/business/assets/config/assets.config.ts @@ -4,3 +4,7 @@ export const ASSETS_DASHBOARD_LIST_PARAMS = { limit: "20", offset: "0", } satisfies ListAssetsParams + +export const ASSET_PICTURE_SIZES = "(min-width: 992px) 34vw, 100vw" + +export const ASSET_VARIANTS_POLLING_MS = 2000 diff --git a/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts b/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts index c28cfe5..402f72b 100644 --- a/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts +++ b/apps/admin/src/business/assets/hooks/use-asset-overview.hook.ts @@ -1,7 +1,13 @@ -import { useGetAsset, useGetAssetVariants } from "infra/backend-api" +import { useGetAsset, useGetAssetVariants, type AssetVariantsResponseDto } from "infra/backend-api" +import { ASSET_VARIANTS_POLLING_MS } from "../config/assets.config" import type { AssetOverview } from "../types/assets-api.type" +const isRunningVariantStatus = (status: string) => status === "pending" || status === "processing" + +const hasRunningVariants = (variants: AssetVariantsResponseDto["variants"] = []) => + variants.some((variant) => isRunningVariantStatus(variant.status)) + /** * Данные выбранного asset и его variants. */ @@ -10,12 +16,23 @@ export const useAssetOverview = (publicId: string | null): AssetOverview => { const variantsQuery = useGetAssetVariants( publicId, assetQuery.data?.currentVersion ? String(assetQuery.data.currentVersion) : undefined, + { + refreshInterval: (data) => (hasRunningVariants(data?.variants) ? ASSET_VARIANTS_POLLING_MS : 0), + }, ) + const variants = variantsQuery.data?.variants ?? [] + + const refresh = async () => { + await Promise.all([assetQuery.mutate(), variantsQuery.mutate()]) + } return { asset: assetQuery.data ?? null, error: assetQuery.error ?? variantsQuery.error, + hasRunningVariants: hasRunningVariants(variants), isLoading: assetQuery.isLoading || variantsQuery.isLoading, - variants: variantsQuery.data?.variants ?? [], + isRefreshing: assetQuery.isValidating || variantsQuery.isValidating, + refresh, + variants, } } diff --git a/apps/admin/src/business/assets/hooks/use-asset-picture.hook.ts b/apps/admin/src/business/assets/hooks/use-asset-picture.hook.ts new file mode 100644 index 0000000..2b1958b --- /dev/null +++ b/apps/admin/src/business/assets/hooks/use-asset-picture.hook.ts @@ -0,0 +1,24 @@ +import { useGetAssetPicture } from "infra/backend-api" + +import { ASSET_PICTURE_SIZES } from "../config/assets.config" +import type { AssetPicturePreview } from "../types/assets-api.type" + +/** + * Picture/srcset preview contract выбранного asset. + */ +export const useAssetPicture = (publicId: string | null, preset: string | null): AssetPicturePreview => { + const pictureQuery = preset ? { preset, sizes: ASSET_PICTURE_SIZES } : null + const picture = useGetAssetPicture(publicId, pictureQuery) + + const refresh = async () => { + await picture.mutate() + } + + return { + error: picture.error, + isLoading: picture.isLoading, + isRefreshing: picture.isValidating, + picture: picture.data ?? null, + refresh, + } +} diff --git a/apps/admin/src/business/assets/index.ts b/apps/admin/src/business/assets/index.ts index 7ab0ef6..51c28e6 100644 --- a/apps/admin/src/business/assets/index.ts +++ b/apps/admin/src/business/assets/index.ts @@ -1,6 +1,7 @@ export { assetsFactory } from "./assets.factory" export type { AssetOverview, + AssetPicturePreview, AssetsApi, AssetsDashboard, AssetVariantFormat, 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 95cabe2..9469b9f 100644 --- a/apps/admin/src/business/assets/types/assets-api.type.ts +++ b/apps/admin/src/business/assets/types/assets-api.type.ts @@ -1,5 +1,6 @@ import type { AssetResponseDto, + AssetPictureResponseDto, AssetVariantResponseDto, CreateAssetRequestDto, CreateAssetResponseDto, @@ -16,10 +17,21 @@ export type AssetVariantResize = "fill" | "fit" export type AssetOverview = { asset: AssetResponseDto | null error?: Error + hasRunningVariants: boolean isLoading: boolean + isRefreshing: boolean + refresh: () => Promise variants: AssetVariantResponseDto[] } +export type AssetPicturePreview = { + error?: Error + isLoading: boolean + isRefreshing: boolean + picture: AssetPictureResponseDto | null + refresh: () => Promise +} + export type AssetsDashboard = { allowedSourceHosts: string[] assets: AssetResponseDto[] @@ -77,6 +89,7 @@ export type GenerateAssetVariantsAction = { */ export type AssetsApi = { useAssetOverview: (publicId: string | null) => AssetOverview + useAssetPicture: (publicId: string | null, preset: string | null) => AssetPicturePreview useAssetsDashboard: () => AssetsDashboard useCreateAsset: () => CreateAssetAction useCreateAssetVersion: () => CreateAssetVersionAction diff --git a/apps/admin/src/infra/backend-api/hooks/index.ts b/apps/admin/src/infra/backend-api/hooks/index.ts index 916fb7b..8340a2f 100644 --- a/apps/admin/src/infra/backend-api/hooks/index.ts +++ b/apps/admin/src/infra/backend-api/hooks/index.ts @@ -1,3 +1,5 @@ +export { getAssetPictureKey, useGetAssetPicture } from "./use-get-asset-picture.hook" +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 { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook" diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-asset-picture.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-asset-picture.hook.ts new file mode 100644 index 0000000..072f7d2 --- /dev/null +++ b/apps/admin/src/infra/backend-api/hooks/use-get-asset-picture.hook.ts @@ -0,0 +1,33 @@ +import useSWR from "swr" +import type { SWRConfiguration } from "swr" + +import { backendApi } from "../client" +import type { AssetPictureResponseDto, GetAssetPictureParams } from "../generated/backend-api.generated" + +export type AssetPictureQuery = Omit + +export const getAssetPictureKey = (publicId: string, query: AssetPictureQuery) => + [ + "backend-api", + "assets", + "picture", + publicId, + query.preset, + query.version ?? null, + query.quality ?? null, + query.sizes ?? null, + ] as const + +/** + * Получение picture/srcset contract asset. + */ +export const useGetAssetPicture = ( + publicId: string | null, + query: AssetPictureQuery | null, + config?: SWRConfiguration, +) => { + const key = publicId !== null && query !== null ? getAssetPictureKey(publicId, query) : null + const fetcher = () => backendApi.assets.getAssetPicture({ publicId: publicId ?? "", ...(query ?? { preset: "" }) }) + + return useSWR(key, fetcher, config) +} diff --git a/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts b/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts index f0518b1..98ecdb8 100644 --- a/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts +++ b/apps/admin/src/infra/backend-api/hooks/use-get-asset-variants.hook.ts @@ -13,7 +13,7 @@ export const getAssetVariantsKey = (publicId: string, version?: string) => export const useGetAssetVariants = ( publicId: string | null, version?: string, - config?: SWRConfiguration, + config?: SWRConfiguration, ) => { const key = publicId !== null ? getAssetVariantsKey(publicId, version) : null const fetcher = () => backendApi.assets.listAssetVariants({ publicId: publicId ?? "", version }) diff --git a/apps/admin/src/infra/backend-api/index.ts b/apps/admin/src/infra/backend-api/index.ts index 5731506..e9f5872 100644 --- a/apps/admin/src/infra/backend-api/index.ts +++ b/apps/admin/src/infra/backend-api/index.ts @@ -2,6 +2,7 @@ export { backendApi } from "./client" export * from "./hooks" export type { AssetResponseDto, + AssetPictureResponseDto, AssetVariantResponseDto, AssetVariantsResponseDto, AssetsListResponseDto, @@ -12,6 +13,7 @@ export type { CreateAssetVariantsRequestDto, CreateAssetVariantsResponseDto, CustomTransformConfigResponseDto, + GetAssetPictureParams, ListAssetsParams, PresetResponseDto, PresetsResponseDto, diff --git a/apps/admin/src/screens/dashboard/config/dashboard.config.ts b/apps/admin/src/screens/dashboard/config/dashboard.config.ts index 9d376cb..5fecfe5 100644 --- a/apps/admin/src/screens/dashboard/config/dashboard.config.ts +++ b/apps/admin/src/screens/dashboard/config/dashboard.config.ts @@ -18,6 +18,10 @@ export const DASHBOARD_CARDS = [ 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", diff --git a/apps/admin/src/screens/dashboard/dashboard.screen.tsx b/apps/admin/src/screens/dashboard/dashboard.screen.tsx index d955389..10e4ff2 100644 --- a/apps/admin/src/screens/dashboard/dashboard.screen.tsx +++ b/apps/admin/src/screens/dashboard/dashboard.screen.tsx @@ -1,15 +1,16 @@ import { Alert, Button, Group, Paper, Stack, Text, Title } from "@mantine/core" import { useDisclosure } from "@mantine/hooks" import cl from "clsx" -import { useState } from "react" import { assetsFactory } from "business/assets" import { DASHBOARD_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" @@ -26,7 +27,8 @@ const assets = assetsFactory() */ export const DashboardScreen = (props: DashboardScreenProps) => { const { className, ...rootAttrs } = props - const [selectedPublicId, setSelectedPublicId] = useState(null) + const { selectedPicturePreset, selectedPublicId, setSelectedPicturePreset, setSelectedPublicId } = + useDashboardUrlState() const [isCreateAssetOpen, createAssetModal] = useDisclosure(false) const [isCreateVersionOpen, createVersionModal] = useDisclosure(false) const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false) @@ -35,7 +37,9 @@ export const DashboardScreen = (props: DashboardScreenProps) => { 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 (
@@ -88,6 +92,14 @@ export const DashboardScreen = (props: DashboardScreenProps) => { /> + + { + 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, + } +} diff --git a/apps/admin/src/screens/dashboard/lib/copy-text.ts b/apps/admin/src/screens/dashboard/lib/copy-text.ts new file mode 100644 index 0000000..1fd8b76 --- /dev/null +++ b/apps/admin/src/screens/dashboard/lib/copy-text.ts @@ -0,0 +1,18 @@ +import { notifications } from "@mantine/notifications" + +export const copyText = async (value: string, label: string) => { + try { + await navigator.clipboard.writeText(value) + notifications.show({ + color: "green", + message: `${label} скопирован в clipboard`, + title: "Copied", + }) + } catch { + notifications.show({ + color: "red", + message: `Не удалось скопировать ${label}`, + title: "Copy failed", + }) + } +} 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 c0cc3d8..b4ce9fd 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,6 +1,21 @@ -import { Anchor, Badge, Button, Code, Group, Paper, ScrollArea, Skeleton, Stack, Table, Text, Title } from "@mantine/core" +import { + Anchor, + Badge, + Button, + Code, + Group, + Image, + Paper, + ScrollArea, + Skeleton, + Stack, + Table, + Text, + 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 type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type" @@ -48,9 +63,20 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { + + {asset.status} + {overview.hasRunningVariants ? ( + + polling variants + + ) : null} ) : null} @@ -63,9 +89,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { Source URL - - {asset.sourceUrl} - + + + {asset.sourceUrl} + + + @@ -95,16 +126,26 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { + Preview Preset Format Size - Status - URL + Status + URL {variants.map((variant) => ( + + {String(variant.status) === "ready" ? ( + + ) : ( + + not ready + + )} + {variant.preset} @@ -118,9 +159,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => { - - open - + + + open + + + ))} diff --git a/apps/admin/src/screens/dashboard/parts/picture-preview-panel/index.ts b/apps/admin/src/screens/dashboard/parts/picture-preview-panel/index.ts new file mode 100644 index 0000000..1416844 --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/picture-preview-panel/index.ts @@ -0,0 +1,2 @@ +export { PicturePreviewPanel } from "./picture-preview-panel" +export type { PicturePreviewPanelProps } from "./types/picture-preview-panel-props.type" diff --git a/apps/admin/src/screens/dashboard/parts/picture-preview-panel/picture-preview-panel.tsx b/apps/admin/src/screens/dashboard/parts/picture-preview-panel/picture-preview-panel.tsx new file mode 100644 index 0000000..02d3c9f --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/picture-preview-panel/picture-preview-panel.tsx @@ -0,0 +1,122 @@ +import { Anchor, Badge, Button, Code, Group, Image, Paper, Select, Skeleton, Stack, Table, Text, Title } from "@mantine/core" + +import { copyText } from "../../lib/copy-text" +import type { PicturePreviewPanelProps } from "./types/picture-preview-panel-props.type" + +/** + * Preview Backend picture/srcset contract. + * + * Используется для: + * - проверки consumer-facing picture contract + * - просмотра fallback image и generated srcset URLs + */ +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})`, + value: preset.name, + })) + + return ( + + +
+ + Picture contract + + + Preview для `GET /api/assets/:publicId/picture`. + +
+ +
+ + +
+ + + Format + Type + srcset + + + + {picture.sources.map((source) => ( + + + {source.format} + + {source.type} + + {source.srcSet} + + + + ))} + +
+ + ) : ( + Picture contract пока недоступен. + )} + + + ) +} diff --git a/apps/admin/src/screens/dashboard/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts b/apps/admin/src/screens/dashboard/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts new file mode 100644 index 0000000..835dddd --- /dev/null +++ b/apps/admin/src/screens/dashboard/parts/picture-preview-panel/types/picture-preview-panel-props.type.ts @@ -0,0 +1,17 @@ +import type { AssetPicturePreview, AssetsDashboard } from "business/assets" + +/** + * Параметры PicturePreviewPanel. + */ +export type PicturePreviewPanelProps = { + /** Preview contract ``. */ + picturePreview: AssetPicturePreview + /** Public ID выбранного asset. */ + publicId: string | null + /** Callback смены preset. */ + onPresetChange: (preset: string | null) => void + /** Static presets. */ + presets: AssetsDashboard["presets"] + /** Выбранный preset. */ + selectedPreset: string | null +}