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:
2026-05-05 15:02:55 +03:00
parent 72f9386f57
commit 6a018826f5
50 changed files with 2870 additions and 120 deletions

View File

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

View File

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

View 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)
}

View File

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

View File

@@ -0,0 +1,2 @@
export { AssetDetailPanel } from "./asset-detail-panel"
export type { AssetDetailPanelProps } from "./types/asset-detail-panel-props.type"

View File

@@ -0,0 +1,11 @@
import type { AssetOverview } from "business/assets"
/**
* Параметры AssetDetailPanel.
*/
export type AssetDetailPanelProps = {
/** Данные выбранного asset. */
overview: AssetOverview
/** Выбранный publicId. */
publicId: string | null
}

View File

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

View File

@@ -0,0 +1,2 @@
export { AssetsTable } from "./assets-table"
export type { AssetsTableProps } from "./types/assets-table-props.type"

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
export { CreateAssetModal } from "./create-asset-modal"
export type { CreateAssetModalProps } from "./types/create-asset-modal-props.type"

View File

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

View File

@@ -0,0 +1,2 @@
export { PresetsPanel } from "./presets-panel"
export type { PresetsPanelProps } from "./types/presets-panel-props.type"

View File

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

View File

@@ -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"]
}

View File

@@ -0,0 +1,2 @@
export { SummaryCards } from "./summary-cards"
export type { SummaryCardsProps } from "./types/summary-cards-props.type"

View File

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

View File

@@ -0,0 +1,11 @@
import type { AssetsDashboard } from "business/assets"
/**
* Параметры SummaryCards.
*/
export type SummaryCardsProps = {
/** Признак загрузки данных. */
isLoading: boolean
/** Сводные метрики dashboard. */
summary: AssetsDashboard["summary"]
}

View File

@@ -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);
}
}