feat: добавить рабочий dashboard admin
- добавлен Mantine theme provider и AppShell layout\n- сгенерирован Backend API клиент и добавлены infra/business хуки\n- добавлены таблица assets, detail/presets panels и create asset modal
This commit is contained in:
@@ -1,16 +1,32 @@
|
||||
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 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,9 +1,20 @@
|
||||
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_CARDS, DASHBOARD_PIPELINE } from "./config/dashboard.config"
|
||||
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 { 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-приложения.
|
||||
*
|
||||
@@ -13,34 +24,73 @@ import type { DashboardScreenProps } from "./types/dashboard.type"
|
||||
*/
|
||||
export const DashboardScreen = (props: DashboardScreenProps) => {
|
||||
const { className, ...rootAttrs } = props
|
||||
const [selectedPublicId, setSelectedPublicId] = useState<string | null>(null)
|
||||
const [isCreateAssetOpen, createAssetModal] = useDisclosure(false)
|
||||
const dashboard = assets.useAssetsDashboard()
|
||||
const createAsset = assets.useCreateAsset()
|
||||
const effectivePublicId = selectedPublicId ?? dashboard.assets[0]?.publicId ?? null
|
||||
const overview = assets.useAssetOverview(effectivePublicId)
|
||||
|
||||
return (
|
||||
<section {...rootAttrs} className={cl(styles.root, className)}>
|
||||
<div className={styles.hero}>
|
||||
<p className={styles.eyebrow}>Image Platform Admin</p>
|
||||
<h1 className={styles.title}>Control plane для image delivery</h1>
|
||||
<p className={styles.lead}>
|
||||
Админка будет управлять allowed hosts, assets, source versions, presets и variant
|
||||
generation без прямого доступа к storage-слою.
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
|
||||
<div className={styles.grid} aria-label="Будущие разделы admin">
|
||||
{DASHBOARD_CARDS.map((card) => (
|
||||
<article className={styles.card} key={card.title}>
|
||||
<h2 className={styles.cardTitle}>{card.title}</h2>
|
||||
<p className={styles.cardDescription}>{card.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
<Button className={styles.primaryAction} onClick={createAssetModal.open} radius="xl" size="md">
|
||||
Create asset
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
<div className={styles.pipeline} aria-label="Пайплайн генерации изображений">
|
||||
{DASHBOARD_PIPELINE.map((step) => (
|
||||
<span className={styles.pipelineStep} key={step}>
|
||||
{step}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<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 overview={overview} publicId={effectivePublicId} />
|
||||
</div>
|
||||
|
||||
<PresetsPanel
|
||||
allowedSourceHosts={dashboard.allowedSourceHosts}
|
||||
custom={dashboard.custom}
|
||||
isLoading={dashboard.isLoading}
|
||||
presets={dashboard.presets}
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<CreateAssetModal
|
||||
action={createAsset}
|
||||
onClose={createAssetModal.close}
|
||||
onCreated={setSelectedPublicId}
|
||||
opened={isCreateAssetOpen}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
15
apps/admin/src/screens/dashboard/lib/format-date.ts
Normal file
15
apps/admin/src/screens/dashboard/lib/format-date.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const formatDateTime = (value: string) => {
|
||||
const date = new Date(value)
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("ru-RU", {
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
}).format(date)
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Anchor, Badge, 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"
|
||||
import type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type"
|
||||
|
||||
/**
|
||||
* Детали выбранного asset и его variants.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения source metadata
|
||||
* - отображения статусов generated variants
|
||||
*/
|
||||
export const AssetDetailPanel = (props: AssetDetailPanelProps) => {
|
||||
const { overview, publicId } = props
|
||||
const { asset, variants } = overview
|
||||
|
||||
if (!publicId) {
|
||||
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.
|
||||
</Text>
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
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">
|
||||
Asset detail
|
||||
</Title>
|
||||
<Text c="dimmed" fz="sm">
|
||||
{publicId}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{asset ? (
|
||||
<Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
|
||||
{asset.status}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{overview.isLoading ? (
|
||||
<Skeleton height={260} radius="lg" />
|
||||
) : asset ? (
|
||||
<Stack gap="lg">
|
||||
<Stack gap={6}>
|
||||
<Text c="dimmed" fz="sm">
|
||||
Source URL
|
||||
</Text>
|
||||
<Anchor href={asset.sourceUrl} target="_blank">
|
||||
{asset.sourceUrl}
|
||||
</Anchor>
|
||||
</Stack>
|
||||
|
||||
<Group gap="xs">
|
||||
<Badge color="violet" radius="xl" variant="light">
|
||||
v{asset.currentVersion}
|
||||
</Badge>
|
||||
<Badge color="gray" radius="xl" variant="light">
|
||||
{asset.sourceHost}
|
||||
</Badge>
|
||||
<Badge color="gray" radius="xl" variant="light">
|
||||
updated {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}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{variants.length > 0 ? (
|
||||
<ScrollArea>
|
||||
<Table verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Preset</Table.Th>
|
||||
<Table.Th>Format</Table.Th>
|
||||
<Table.Th>Size</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{variants.map((variant) => (
|
||||
<Table.Tr key={variant.id}>
|
||||
<Table.Td>
|
||||
<Code>{variant.preset}</Code>
|
||||
</Table.Td>
|
||||
<Table.Td>{variant.format}</Table.Td>
|
||||
<Table.Td>
|
||||
{variant.width}x{variant.height || "auto"} q{variant.quality}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={VARIANT_STATUS_COLORS[variant.status] ?? "gray"} radius="xl" variant="light">
|
||||
{variant.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Text c="dimmed">Variants для текущей версии пока не созданы.</Text>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed">Asset не найден или ещё загружается.</Text>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AssetDetailPanel } from "./asset-detail-panel"
|
||||
export type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type"
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AssetOverview } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры AssetDetailPanel.
|
||||
*/
|
||||
export type AssetDetailPanelProps = {
|
||||
/** Данные выбранного asset. */
|
||||
overview: AssetOverview
|
||||
/** Выбранный publicId. */
|
||||
publicId: string | null
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 type { AssetsTableProps } from "./types/assets-table-props.type"
|
||||
|
||||
/**
|
||||
* Таблица assets.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения реального списка assets из Backend API
|
||||
* - выбора asset для detail-панели
|
||||
*/
|
||||
export const AssetsTable = (props: AssetsTableProps) => {
|
||||
const { assets, isLoading, onSelect, selectedPublicId } = props
|
||||
|
||||
return (
|
||||
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
|
||||
<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
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton height={220} radius="lg" />
|
||||
) : assets.length > 0 ? (
|
||||
<ScrollArea>
|
||||
<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.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{assets.map((asset) => (
|
||||
<Table.Tr
|
||||
bg={asset.publicId === selectedPublicId ? "violet.0" : undefined}
|
||||
key={asset.id}
|
||||
onClick={() => onSelect(asset.publicId)}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text fw={800}>{asset.publicId}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={ASSET_STATUS_COLORS[asset.status] ?? "gray"} radius="xl" variant="light">
|
||||
{asset.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>v{asset.currentVersion}</Table.Td>
|
||||
<Table.Td>{asset.sourceHost}</Table.Td>
|
||||
<Table.Td>{formatDateTime(asset.updatedAt)}</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</ScrollArea>
|
||||
) : (
|
||||
<Text c="dimmed">Assets пока не зарегистрированы.</Text>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { AssetsTable } from "./assets-table"
|
||||
export type { AssetsTableProps } from "./types/assets-table-props.type"
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AssetsDashboard } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры AssetsTable.
|
||||
*/
|
||||
export type AssetsTableProps = {
|
||||
/** Список assets. */
|
||||
assets: AssetsDashboard["assets"]
|
||||
/** Признак загрузки списка. */
|
||||
isLoading: boolean
|
||||
/** Callback выбора asset. */
|
||||
onSelect: (publicId: string) => void
|
||||
/** Выбранный publicId. */
|
||||
selectedPublicId: string | null
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import { Button, Group, Modal, Stack, Text, TextInput } from "@mantine/core"
|
||||
import { useForm } from "@mantine/form"
|
||||
import { notifications } from "@mantine/notifications"
|
||||
|
||||
import type { CreateAssetInput } from "business/assets"
|
||||
import type { CreateAssetModalProps } from "./types/create-asset-modal-props.type"
|
||||
|
||||
type CreateAssetFormValues = {
|
||||
publicId: string
|
||||
sourceUrl: string
|
||||
}
|
||||
|
||||
const SOURCE_URL_EXAMPLE = "https://storage.yandexcloud.net/shared1318/img/1.jpg"
|
||||
|
||||
const toErrorMessage = (error: unknown) => (error instanceof Error ? error.message : "Неизвестная ошибка")
|
||||
|
||||
/**
|
||||
* Modal создания asset.
|
||||
*
|
||||
* Используется для:
|
||||
* - регистрации source image
|
||||
* - запуска первого write-сценария admin MVP
|
||||
*/
|
||||
export const CreateAssetModal = (props: CreateAssetModalProps) => {
|
||||
const { action, onClose, onCreated, opened } = props
|
||||
|
||||
const form = useForm<CreateAssetFormValues>({
|
||||
initialValues: {
|
||||
publicId: "",
|
||||
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) => {
|
||||
const publicId = values.publicId.trim()
|
||||
const input: CreateAssetInput = {
|
||||
sourceUrl: values.sourceUrl.trim(),
|
||||
...(publicId ? { publicId } : {}),
|
||||
}
|
||||
|
||||
try {
|
||||
const createdAsset = await action.createAsset(input)
|
||||
notifications.show({
|
||||
color: "green",
|
||||
message: `Asset ${createdAsset.publicId} зарегистрирован`,
|
||||
title: "Asset created",
|
||||
})
|
||||
form.reset()
|
||||
onCreated(createdAsset.publicId)
|
||||
onClose()
|
||||
} catch (error) {
|
||||
notifications.show({
|
||||
color: "red",
|
||||
message: toErrorMessage(error),
|
||||
title: "Не удалось создать asset",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<Modal centered onClose={handleClose} opened={opened} radius="xl" title="Create asset">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="md">
|
||||
<Text c="dimmed" fz="sm">
|
||||
Backend создаст asset и первую immutable source version. Public ID можно оставить пустым.
|
||||
</Text>
|
||||
|
||||
<TextInput
|
||||
label="Public ID"
|
||||
placeholder="asset_demo"
|
||||
{...form.getInputProps("publicId")}
|
||||
disabled={action.isCreating}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
label="Source URL"
|
||||
placeholder={SOURCE_URL_EXAMPLE}
|
||||
required
|
||||
{...form.getInputProps("sourceUrl")}
|
||||
disabled={action.isCreating}
|
||||
/>
|
||||
|
||||
<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>
|
||||
</form>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { CreateAssetModal } from "./create-asset-modal"
|
||||
export type { CreateAssetModalProps } from "./types/create-asset-modal-props.type"
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { CreateAssetAction } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры CreateAssetModal.
|
||||
*/
|
||||
export type CreateAssetModalProps = {
|
||||
/** Сценарий создания asset. */
|
||||
action: CreateAssetAction
|
||||
/** Callback закрытия modal. */
|
||||
onClose: () => void
|
||||
/** Callback успешного создания asset. */
|
||||
onCreated: (publicId: string) => void
|
||||
/** Открыта ли modal. */
|
||||
opened: boolean
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { PresetsPanel } from "./presets-panel"
|
||||
export type { PresetsPanelProps } from "./types/presets-panel-props.type"
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Badge, Code, Group, Paper, SimpleGrid, Skeleton, Stack, Text, Title } from "@mantine/core"
|
||||
|
||||
import type { PresetsPanelProps } from "./types/presets-panel-props.type"
|
||||
|
||||
/**
|
||||
* Панель presets и allowlist hosts.
|
||||
*
|
||||
* Используется для:
|
||||
* - отображения static presets
|
||||
* - отображения custom transform limits
|
||||
*/
|
||||
export const PresetsPanel = (props: PresetsPanelProps) => {
|
||||
const { allowedSourceHosts, custom, isLoading, presets } = props
|
||||
|
||||
return (
|
||||
<Paper bg="white" p="xl" radius="xl" shadow="xs" withBorder>
|
||||
<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"}
|
||||
</Badge>
|
||||
) : null}
|
||||
</Group>
|
||||
|
||||
{isLoading ? (
|
||||
<Skeleton height={180} radius="lg" />
|
||||
) : (
|
||||
<Stack gap="lg">
|
||||
<SimpleGrid cols={{ base: 1, md: 3 }} spacing="md">
|
||||
{presets.map((preset) => (
|
||||
<Paper bg="gray.0" key={preset.name} p="md" radius="lg" withBorder>
|
||||
<Stack gap="xs">
|
||||
<Group justify="space-between">
|
||||
<Text fw={800}>{preset.name}</Text>
|
||||
<Badge color="violet" radius="xl" variant="light">
|
||||
{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">
|
||||
sizes: {preset.widths?.join(", ") ?? `${preset.width}x${preset.height}`}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Paper>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
|
||||
{custom ? (
|
||||
<Group gap="xs">
|
||||
<Badge radius="xl" variant="light">
|
||||
max {custom.maxWidth}x{custom.maxHeight}
|
||||
</Badge>
|
||||
<Badge radius="xl" variant="light">
|
||||
q{custom.quality}
|
||||
</Badge>
|
||||
<Badge radius="xl" variant="light">
|
||||
{custom.formats.join(", ")}
|
||||
</Badge>
|
||||
</Group>
|
||||
) : null}
|
||||
|
||||
<Stack gap={6}>
|
||||
<Text c="dimmed" fz="sm" fw={700}>
|
||||
Allowed source hosts
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
{allowedSourceHosts.map((host) => (
|
||||
<Code key={host}>{host}</Code>
|
||||
))}
|
||||
</Group>
|
||||
</Stack>
|
||||
</Stack>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { AssetsDashboard } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры PresetsPanel.
|
||||
*/
|
||||
export type PresetsPanelProps = {
|
||||
/** Разрешённые source hosts. */
|
||||
allowedSourceHosts: string[]
|
||||
/** Custom transform config. */
|
||||
custom: AssetsDashboard["custom"]
|
||||
/** Признак загрузки presets. */
|
||||
isLoading: boolean
|
||||
/** Список presets. */
|
||||
presets: AssetsDashboard["presets"]
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
export { SummaryCards } from "./summary-cards"
|
||||
export type { SummaryCardsProps } from "./types/summary-cards-props.type"
|
||||
@@ -0,0 +1,37 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { AssetsDashboard } from "business/assets"
|
||||
|
||||
/**
|
||||
* Параметры SummaryCards.
|
||||
*/
|
||||
export type SummaryCardsProps = {
|
||||
/** Признак загрузки данных. */
|
||||
isLoading: boolean
|
||||
/** Сводные метрики dashboard. */
|
||||
summary: AssetsDashboard["summary"]
|
||||
}
|
||||
@@ -1,79 +1,44 @@
|
||||
.root {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero {
|
||||
padding: var(--space-6);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-5);
|
||||
background: var(--color-surface);
|
||||
box-shadow: var(--shadow-panel);
|
||||
overflow: hidden;
|
||||
background:
|
||||
linear-gradient(135deg, rgb(255 255 255 / 92%), rgb(255 255 255 / 72%)),
|
||||
radial-gradient(circle at 92% 8%, rgb(123 76 255 / 18%), transparent 18rem);
|
||||
}
|
||||
|
||||
@media (--md) {
|
||||
padding: var(--space-8);
|
||||
}
|
||||
.heroContent {
|
||||
max-width: 54rem;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 var(--space-4);
|
||||
margin-bottom: var(--space-4);
|
||||
color: var(--color-accent);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 800;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.title {
|
||||
max-width: 48rem;
|
||||
margin: 0;
|
||||
font-size: clamp(2.75rem, 7vw, 5.5rem);
|
||||
max-width: 50rem;
|
||||
font-size: clamp(2.75rem, 7vw, 5.75rem);
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.07em;
|
||||
letter-spacing: -0.075em;
|
||||
}
|
||||
|
||||
.lead {
|
||||
max-width: 43rem;
|
||||
margin: var(--space-5) 0 0;
|
||||
max-width: 42rem;
|
||||
margin-top: var(--space-5);
|
||||
color: var(--color-text-muted);
|
||||
font-size: 1.0625rem;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-3);
|
||||
|
||||
@media (--md) {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.card {
|
||||
min-height: 9.5rem;
|
||||
padding: var(--space-5);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-4);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.cardTitle {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
letter-spacing: -0.03em;
|
||||
}
|
||||
|
||||
.cardDescription {
|
||||
margin: var(--space-3) 0 0;
|
||||
color: var(--color-text-muted);
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.pipeline {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-2);
|
||||
.primaryAction {
|
||||
box-shadow: var(--shadow-soft);
|
||||
}
|
||||
|
||||
.pipelineStep {
|
||||
@@ -85,3 +50,13 @@
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workbench {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--space-4);
|
||||
|
||||
@media (--md) {
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(22rem, 0.65fr);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user