feat: добавить preview image pipeline в admin

- добавлен polling variants и ручной refresh выбранного asset\n- добавлен picture/srcset preview с выбором preset\n- добавлен URL-state и copy actions для рабочих ссылок
This commit is contained in:
2026-05-05 16:41:20 +03:00
parent 8094535747
commit 0faa8b9d2d
18 changed files with 394 additions and 14 deletions

View File

@@ -1,3 +1,4 @@
import { useAssetPicture } from "./hooks/use-asset-picture.hook"
import { useAssetOverview } from "./hooks/use-asset-overview.hook" import { useAssetOverview } from "./hooks/use-asset-overview.hook"
import { useAssetsDashboard } from "./hooks/use-assets-dashboard.hook" import { useAssetsDashboard } from "./hooks/use-assets-dashboard.hook"
import { useCreateAsset } from "./hooks/use-create-asset.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 = () => { export const assetsFactory: AssetsFactory = () => {
return { return {
useAssetOverview, useAssetOverview,
useAssetPicture,
useAssetsDashboard, useAssetsDashboard,
useCreateAsset, useCreateAsset,
useCreateAssetVersion, useCreateAssetVersion,

View File

@@ -4,3 +4,7 @@ export const ASSETS_DASHBOARD_LIST_PARAMS = {
limit: "20", limit: "20",
offset: "0", offset: "0",
} satisfies ListAssetsParams } satisfies ListAssetsParams
export const ASSET_PICTURE_SIZES = "(min-width: 992px) 34vw, 100vw"
export const ASSET_VARIANTS_POLLING_MS = 2000

View File

@@ -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" 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. * Данные выбранного asset и его variants.
*/ */
@@ -10,12 +16,23 @@ export const useAssetOverview = (publicId: string | null): AssetOverview => {
const variantsQuery = useGetAssetVariants( const variantsQuery = useGetAssetVariants(
publicId, publicId,
assetQuery.data?.currentVersion ? String(assetQuery.data.currentVersion) : undefined, 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 { return {
asset: assetQuery.data ?? null, asset: assetQuery.data ?? null,
error: assetQuery.error ?? variantsQuery.error, error: assetQuery.error ?? variantsQuery.error,
hasRunningVariants: hasRunningVariants(variants),
isLoading: assetQuery.isLoading || variantsQuery.isLoading, isLoading: assetQuery.isLoading || variantsQuery.isLoading,
variants: variantsQuery.data?.variants ?? [], isRefreshing: assetQuery.isValidating || variantsQuery.isValidating,
refresh,
variants,
} }
} }

View File

@@ -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,
}
}

View File

@@ -1,6 +1,7 @@
export { assetsFactory } from "./assets.factory" export { assetsFactory } from "./assets.factory"
export type { export type {
AssetOverview, AssetOverview,
AssetPicturePreview,
AssetsApi, AssetsApi,
AssetsDashboard, AssetsDashboard,
AssetVariantFormat, AssetVariantFormat,

View File

@@ -1,5 +1,6 @@
import type { import type {
AssetResponseDto, AssetResponseDto,
AssetPictureResponseDto,
AssetVariantResponseDto, AssetVariantResponseDto,
CreateAssetRequestDto, CreateAssetRequestDto,
CreateAssetResponseDto, CreateAssetResponseDto,
@@ -16,10 +17,21 @@ export type AssetVariantResize = "fill" | "fit"
export type AssetOverview = { export type AssetOverview = {
asset: AssetResponseDto | null asset: AssetResponseDto | null
error?: Error error?: Error
hasRunningVariants: boolean
isLoading: boolean isLoading: boolean
isRefreshing: boolean
refresh: () => Promise<void>
variants: AssetVariantResponseDto[] variants: AssetVariantResponseDto[]
} }
export type AssetPicturePreview = {
error?: Error
isLoading: boolean
isRefreshing: boolean
picture: AssetPictureResponseDto | null
refresh: () => Promise<void>
}
export type AssetsDashboard = { export type AssetsDashboard = {
allowedSourceHosts: string[] allowedSourceHosts: string[]
assets: AssetResponseDto[] assets: AssetResponseDto[]
@@ -77,6 +89,7 @@ export type GenerateAssetVariantsAction = {
*/ */
export type AssetsApi = { export type AssetsApi = {
useAssetOverview: (publicId: string | null) => AssetOverview useAssetOverview: (publicId: string | null) => AssetOverview
useAssetPicture: (publicId: string | null, preset: string | null) => AssetPicturePreview
useAssetsDashboard: () => AssetsDashboard useAssetsDashboard: () => AssetsDashboard
useCreateAsset: () => CreateAssetAction useCreateAsset: () => CreateAssetAction
useCreateAssetVersion: () => CreateAssetVersionAction useCreateAssetVersion: () => CreateAssetVersionAction

View File

@@ -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 { getAssetKey, useGetAsset } from "./use-get-asset.hook"
export { getAssetVariantsKey, useGetAssetVariants } from "./use-get-asset-variants.hook" export { getAssetVariantsKey, useGetAssetVariants } from "./use-get-asset-variants.hook"
export { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook" export { getAssetsListKey, useGetAssetsList } from "./use-get-assets-list.hook"

View File

@@ -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<GetAssetPictureParams, "publicId">
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<AssetPictureResponseDto>,
) => {
const key = publicId !== null && query !== null ? getAssetPictureKey(publicId, query) : null
const fetcher = () => backendApi.assets.getAssetPicture({ publicId: publicId ?? "", ...(query ?? { preset: "" }) })
return useSWR<AssetPictureResponseDto>(key, fetcher, config)
}

View File

@@ -13,7 +13,7 @@ export const getAssetVariantsKey = (publicId: string, version?: string) =>
export const useGetAssetVariants = ( export const useGetAssetVariants = (
publicId: string | null, publicId: string | null,
version?: string, version?: string,
config?: SWRConfiguration, config?: SWRConfiguration<AssetVariantsResponseDto>,
) => { ) => {
const key = publicId !== null ? getAssetVariantsKey(publicId, version) : null const key = publicId !== null ? getAssetVariantsKey(publicId, version) : null
const fetcher = () => backendApi.assets.listAssetVariants({ publicId: publicId ?? "", version }) const fetcher = () => backendApi.assets.listAssetVariants({ publicId: publicId ?? "", version })

View File

@@ -2,6 +2,7 @@ export { backendApi } from "./client"
export * from "./hooks" export * from "./hooks"
export type { export type {
AssetResponseDto, AssetResponseDto,
AssetPictureResponseDto,
AssetVariantResponseDto, AssetVariantResponseDto,
AssetVariantsResponseDto, AssetVariantsResponseDto,
AssetsListResponseDto, AssetsListResponseDto,
@@ -12,6 +13,7 @@ export type {
CreateAssetVariantsRequestDto, CreateAssetVariantsRequestDto,
CreateAssetVariantsResponseDto, CreateAssetVariantsResponseDto,
CustomTransformConfigResponseDto, CustomTransformConfigResponseDto,
GetAssetPictureParams,
ListAssetsParams, ListAssetsParams,
PresetResponseDto, PresetResponseDto,
PresetsResponseDto, PresetsResponseDto,

View File

@@ -18,6 +18,10 @@ export const DASHBOARD_CARDS = [
export const DASHBOARD_PIPELINE = ["Backend", "RabbitMQ", "Worker", "imgproxy", "S3"] 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 = { export const ASSET_STATUS_COLORS = {
active: "green", active: "green",
deleted: "red", deleted: "red",

View File

@@ -1,15 +1,16 @@
import { Alert, Button, Group, Paper, Stack, Text, Title } from "@mantine/core" import { Alert, Button, Group, Paper, Stack, Text, Title } from "@mantine/core"
import { useDisclosure } from "@mantine/hooks" import { useDisclosure } from "@mantine/hooks"
import cl from "clsx" import cl from "clsx"
import { useState } from "react"
import { assetsFactory } from "business/assets" import { assetsFactory } from "business/assets"
import { DASHBOARD_PIPELINE } from "./config/dashboard.config" import { DASHBOARD_PIPELINE } from "./config/dashboard.config"
import { useDashboardUrlState } from "./hooks/use-dashboard-url-state.hook"
import { AssetDetailPanel } from "./parts/asset-detail-panel" import { AssetDetailPanel } from "./parts/asset-detail-panel"
import { AssetsTable } from "./parts/assets-table" import { AssetsTable } from "./parts/assets-table"
import { CreateAssetModal } from "./parts/create-asset-modal" import { CreateAssetModal } from "./parts/create-asset-modal"
import { CreateSourceVersionModal } from "./parts/create-source-version-modal" import { CreateSourceVersionModal } from "./parts/create-source-version-modal"
import { GenerateVariantsModal } from "./parts/generate-variants-modal" import { GenerateVariantsModal } from "./parts/generate-variants-modal"
import { PicturePreviewPanel } from "./parts/picture-preview-panel"
import { PresetsPanel } from "./parts/presets-panel" import { PresetsPanel } from "./parts/presets-panel"
import { SummaryCards } from "./parts/summary-cards" import { SummaryCards } from "./parts/summary-cards"
import styles from "./styles/dashboard.module.css" import styles from "./styles/dashboard.module.css"
@@ -26,7 +27,8 @@ const assets = assetsFactory()
*/ */
export const DashboardScreen = (props: DashboardScreenProps) => { export const DashboardScreen = (props: DashboardScreenProps) => {
const { className, ...rootAttrs } = props const { className, ...rootAttrs } = props
const [selectedPublicId, setSelectedPublicId] = useState<string | null>(null) const { selectedPicturePreset, selectedPublicId, setSelectedPicturePreset, setSelectedPublicId } =
useDashboardUrlState()
const [isCreateAssetOpen, createAssetModal] = useDisclosure(false) const [isCreateAssetOpen, createAssetModal] = useDisclosure(false)
const [isCreateVersionOpen, createVersionModal] = useDisclosure(false) const [isCreateVersionOpen, createVersionModal] = useDisclosure(false)
const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false) const [isGenerateVariantsOpen, generateVariantsModal] = useDisclosure(false)
@@ -35,7 +37,9 @@ export const DashboardScreen = (props: DashboardScreenProps) => {
const createAssetVersion = assets.useCreateAssetVersion() const createAssetVersion = assets.useCreateAssetVersion()
const generateAssetVariants = assets.useGenerateAssetVariants() const generateAssetVariants = assets.useGenerateAssetVariants()
const effectivePublicId = selectedPublicId ?? dashboard.assets[0]?.publicId ?? null const effectivePublicId = selectedPublicId ?? dashboard.assets[0]?.publicId ?? null
const effectivePicturePreset = selectedPicturePreset ?? dashboard.presets[0]?.name ?? null
const overview = assets.useAssetOverview(effectivePublicId) const overview = assets.useAssetOverview(effectivePublicId)
const picturePreview = assets.useAssetPicture(effectivePublicId, effectivePicturePreset)
return ( return (
<section {...rootAttrs} className={cl(styles.root, className)}> <section {...rootAttrs} className={cl(styles.root, className)}>
@@ -88,6 +92,14 @@ export const DashboardScreen = (props: DashboardScreenProps) => {
/> />
</div> </div>
<PicturePreviewPanel
onPresetChange={setSelectedPicturePreset}
picturePreview={picturePreview}
presets={dashboard.presets}
publicId={effectivePublicId}
selectedPreset={effectivePicturePreset}
/>
<PresetsPanel <PresetsPanel
allowedSourceHosts={dashboard.allowedSourceHosts} allowedSourceHosts={dashboard.allowedSourceHosts}
custom={dashboard.custom} custom={dashboard.custom}

View File

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

View File

@@ -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",
})
}
}

View File

@@ -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 { ASSET_STATUS_COLORS, VARIANT_STATUS_COLORS } from "../../config/dashboard.config"
import { copyText } from "../../lib/copy-text"
import { formatDateTime } from "../../lib/format-date" import { formatDateTime } from "../../lib/format-date"
import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type" import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type"
@@ -48,9 +63,20 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Button onClick={onGenerateVariants} radius="xl" size="xs"> <Button onClick={onGenerateVariants} radius="xl" size="xs">
Generate variants Generate variants
</Button> </Button>
<Button onClick={() => void copyText(asset.publicId, "publicId")} radius="xl" size="xs" variant="subtle">
Copy 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"> <Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
{asset.status} {asset.status}
</Badge> </Badge>
{overview.hasRunningVariants ? (
<Badge color="yellow" radius="xl" variant="light">
polling variants
</Badge>
) : null}
</Group> </Group>
) : null} ) : null}
</Group> </Group>
@@ -63,9 +89,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Text c="dimmed" fz="sm"> <Text c="dimmed" fz="sm">
Source URL Source URL
</Text> </Text>
<Anchor href={asset.sourceUrl} target="_blank"> <Group align="center" gap="xs">
{asset.sourceUrl} <Anchor href={asset.sourceUrl} target="_blank">
</Anchor> {asset.sourceUrl}
</Anchor>
<Button onClick={() => void copyText(asset.sourceUrl, "source URL")} radius="xl" size="compact-xs" variant="subtle">
Copy
</Button>
</Group>
</Stack> </Stack>
<Group gap="xs"> <Group gap="xs">
@@ -95,16 +126,26 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
<Table verticalSpacing="sm"> <Table verticalSpacing="sm">
<Table.Thead> <Table.Thead>
<Table.Tr> <Table.Tr>
<Table.Th>Preview</Table.Th>
<Table.Th>Preset</Table.Th> <Table.Th>Preset</Table.Th>
<Table.Th>Format</Table.Th> <Table.Th>Format</Table.Th>
<Table.Th>Size</Table.Th> <Table.Th>Size</Table.Th>
<Table.Th>Status</Table.Th> <Table.Th>Status</Table.Th>
<Table.Th>URL</Table.Th> <Table.Th>URL</Table.Th>
</Table.Tr> </Table.Tr>
</Table.Thead> </Table.Thead>
<Table.Tbody> <Table.Tbody>
{variants.map((variant) => ( {variants.map((variant) => (
<Table.Tr key={variant.id}> <Table.Tr key={variant.id}>
<Table.Td>
{String(variant.status) === "ready" ? (
<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>
<Table.Td> <Table.Td>
<Code>{variant.preset}</Code> <Code>{variant.preset}</Code>
</Table.Td> </Table.Td>
@@ -118,9 +159,14 @@ export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
</Badge> </Badge>
</Table.Td> </Table.Td>
<Table.Td> <Table.Td>
<Anchor href={variant.url} target="_blank"> <Group gap="xs" wrap="nowrap">
open <Anchor href={variant.url} target="_blank">
</Anchor> open
</Anchor>
<Button onClick={() => void copyText(variant.url, "variant URL")} size="compact-xs" variant="subtle">
copy
</Button>
</Group>
</Table.Td> </Table.Td>
</Table.Tr> </Table.Tr>
))} ))}

View File

@@ -0,0 +1,2 @@
export { PicturePreviewPanel } from "./picture-preview-panel"
export type { PicturePreviewPanelProps } from "./types/picture-preview-panel-props.type"

View File

@@ -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 (
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
<Group align="start" justify="space-between" mb="lg">
<div>
<Title order={2} size="h3">
Picture contract
</Title>
<Text c="dimmed" fz="sm">
Preview для `GET /api/assets/:publicId/picture`.
</Text>
</div>
<Button
loading={picturePreview.isRefreshing}
onClick={() => void picturePreview.refresh()}
radius="xl"
size="xs"
variant="light"
>
Refresh
</Button>
</Group>
<Stack gap="lg">
<Select
data={presetOptions}
disabled={!publicId || presetOptions.length === 0}
label="Preset"
onChange={onPresetChange}
placeholder="Select preset"
value={selectedPreset}
/>
{!publicId ? (
<Text c="dimmed">Выберите asset для preview.</Text>
) : picturePreview.isLoading ? (
<Skeleton height={280} radius="lg" />
) : picture ? (
<Stack gap="lg">
<Image alt={`${picture.publicId} ${picture.preset}`} fit="cover" mah={360} radius="lg" src={picture.image.src} />
<Group gap="xs">
<Badge color="violet" radius="xl" variant="light">
{picture.preset}
</Badge>
<Badge radius="xl" variant="light">
v{picture.version}
</Badge>
<Badge radius="xl" variant="light">
q{picture.quality}
</Badge>
<Badge radius="xl" variant="light">
widths {picture.widths.join(", ")}
</Badge>
</Group>
<Stack gap={6}>
<Text c="dimmed" fz="sm" fw={700}>
Fallback 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>
</Group>
</Stack>
<Table verticalSpacing="sm">
<Table.Thead>
<Table.Tr>
<Table.Th>Format</Table.Th>
<Table.Th>Type</Table.Th>
<Table.Th>srcset</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{picture.sources.map((source) => (
<Table.Tr key={source.format}>
<Table.Td>
<Code>{source.format}</Code>
</Table.Td>
<Table.Td>{source.type}</Table.Td>
<Table.Td>
<Text lineClamp={2}>{source.srcSet}</Text>
<Button onClick={() => void copyText(source.srcSet, `${source.format} srcset`)} size="compact-xs" variant="subtle">
copy srcset
</Button>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Stack>
) : (
<Text c="dimmed">Picture contract пока недоступен.</Text>
)}
</Stack>
</Paper>
)
}

View File

@@ -0,0 +1,17 @@
import type { AssetPicturePreview, AssetsDashboard } from "business/assets"
/**
* Параметры PicturePreviewPanel.
*/
export type PicturePreviewPanelProps = {
/** Preview contract `<picture>`. */
picturePreview: AssetPicturePreview
/** Public ID выбранного asset. */
publicId: string | null
/** Callback смены preset. */
onPresetChange: (preset: string | null) => void
/** Static presets. */
presets: AssetsDashboard["presets"]
/** Выбранный preset. */
selectedPreset: string | null
}