NODEDC_DESIGN_GUIDELINE/apps/catalog/src/CatalogApp.tsx

332 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import {
AppHeader,
Button,
Checker,
ColorField,
ConfirmationModal,
ControlRow,
Dropdown,
FieldFrame,
GlassSurface,
HeaderProfile,
HeaderWorkspace,
IconButton,
Inspector,
RangeControl,
SegmentedControl,
Select,
StatusBadge,
TextAreaField,
TextField,
Window,
WindowFooterActions,
} from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "windows" | "inspector";
const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [
{ label: "Hub", value: [195, 255, 102], hex: "#c3ff66" },
{ label: "Engine", value: [148, 123, 219], hex: "#947bdb" },
{ label: "BIM", value: [78, 164, 255], hex: "#4ea4ff" },
{ label: "SEO", value: [32, 32, 34], hex: "#202022" },
];
const selectOptions = [
{ value: "active", label: "Активен", description: "Сервис доступен пользователям" },
{ value: "maintenance", label: "Техработы", description: "Временно недоступен" },
{ value: "disabled", label: "Отключён", description: "Запуск запрещён" },
] as const;
function rgbFromHex(hex: string): RgbTuple | null {
const normalized = hex.trim().replace(/^#/, "");
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null;
return [
Number.parseInt(normalized.slice(0, 2), 16),
Number.parseInt(normalized.slice(2, 4), 16),
Number.parseInt(normalized.slice(4, 6), 16),
];
}
function Section({ id, eyebrow, title, description, children }: {
id: string;
eyebrow: string;
title: string;
description: string;
children: ReactNode;
}) {
return (
<section id={id} className="catalog-section">
<header className="catalog-section__head">
<span>{eyebrow}</span>
<h2>{title}</h2>
<p>{description}</p>
</header>
{children}
</section>
);
}
function Preview({ title, note, children, className }: {
title: string;
note?: string;
children: ReactNode;
className?: string;
}) {
return (
<GlassSurface className={`catalog-preview ${className ?? ""}`} padding="lg">
<div className="catalog-preview__head">
<strong>{title}</strong>
{note ? <span>{note}</span> : null}
</div>
<div className="catalog-preview__body">{children}</div>
</GlassSurface>
);
}
export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66");
const [section, setSection] = useState<CatalogSection>("foundation");
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
const [checker, setChecker] = useState(true);
const [range, setRange] = useState(64);
const [modalOpen, setModalOpen] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [projectName, setProjectName] = useState("NODE.DC Platform");
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
useEffect(() => {
applyNodedcTheme(document.documentElement, { theme, accent });
}, [accent, theme]);
const openSection = (next: CatalogSection) => {
setSection(next);
document.getElementById(next)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const inspectorSections = [
{
id: "definition",
label: "Definition Control",
description: "Параметры агентной ноды",
content: (
<>
<ControlRow label="Активность"><Checker checked={checker} label="Агент активен" onChange={setChecker} /></ControlRow>
<ControlRow label="Модель">
<Select
label="Модель"
value="codex"
options={[
{ value: "codex", label: "Codex" },
{ value: "local", label: "Local executor" },
]}
onChange={() => undefined}
/>
</ControlRow>
<RangeControl label="Температура" value={range} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setRange} />
</>
),
},
{
id: "appearance",
label: "Внешний вид",
description: "Тема и акцент",
content: (
<ControlRow label="Акцент"><ColorField value={accentHex} onChange={setAccentHex} /></ControlRow>
),
},
{
id: "runtime",
label: "Выполнение",
description: "Параметры запуска",
content: <Checker checked={false} label="Запускать автоматически" onChange={() => undefined} />,
},
];
return (
<div className="catalog-app nodedc-ui-root" style={{ "--catalog-accent": accentHex } as CSSProperties}>
<AppHeader
brand={<img src="/nodedc-logo.svg" alt="NODE.DC" />}
center={
<>
<HeaderWorkspace label="UI" />
<SegmentedControl
label="Разделы каталога"
value={section}
items={[
{ value: "foundation", label: "Основа" },
{ value: "controls", label: "Контролы" },
{ value: "windows", label: "Окна" },
{ value: "inspector", label: "Инспектор" },
]}
onChange={openSection}
/>
</>
}
right={
<HeaderProfile>
<IconButton label="Сменить тему" onClick={() => setTheme((current) => current === "dark" ? "light" : "dark")}>
{theme === "dark" ? "☼" : "◐"}
</IconButton>
<span className="catalog-avatar">DC</span>
</HeaderProfile>
}
/>
<main className="catalog-main">
<div className="catalog-hero">
<StatusBadge tone="accent">baseline 0.1.0</StatusBadge>
<h1>Один UI-контракт.<br />Разные приложения.</h1>
<p>Геометрия, окна и поведение остаются общими. Приложение выбирает тему и акцент, затем использует готовые компоненты.</p>
</div>
<Section id="foundation" eyebrow="01 / Foundation" title="Материал и тема" description="Одинаковые поверхности проверяются в тёмной и светлой схеме без fork компонентов.">
<div className="catalog-grid catalog-grid--foundation">
<Preview title="Theme contract" note={theme === "dark" ? "dark glass" : "light glass"}>
<div className="catalog-theme-row">
<Button variant={theme === "dark" ? "accent" : "secondary"} onClick={() => setTheme("dark")}>Dark</Button>
<Button variant={theme === "light" ? "accent" : "secondary"} onClick={() => setTheme("light")}>Light</Button>
</div>
<div className="catalog-swatches">
{accents.map((item) => (
<button
key={item.label}
type="button"
className="catalog-swatch"
data-active={item.hex.toLowerCase() === accentHex.toLowerCase() ? "true" : undefined}
style={{ "--swatch": item.hex } as CSSProperties}
onClick={() => setAccentHex(item.hex)}
>
<span />{item.label}
</button>
))}
</div>
<ColorField label="Свой accent" value={accentHex} onChange={setAccentHex} />
</Preview>
<Preview title="Glass hierarchy" note="default / strong / soft">
<div className="catalog-surface-stack">
<GlassSurface padding="md">Default surface</GlassSurface>
<GlassSurface tone="strong" padding="md">Strong floating surface</GlassSurface>
<GlassSurface tone="soft" padding="md">Soft nested surface</GlassSurface>
</div>
</Preview>
</div>
</Section>
<Section id="controls" eyebrow="02 / Primitives" title="Контролы" description="Общие actions, fields и selection primitives для React и DOM consumers.">
<div className="catalog-grid">
<Preview title="Actions" note="shared geometry">
<div className="catalog-inline catalog-inline--wrap">
<Button variant="accent">Сохранить</Button>
<Button>Отмена</Button>
<Button variant="ghost">Подробнее</Button>
<Button variant="danger">Удалить</Button>
<IconButton label="Настройки"></IconButton>
</div>
</Preview>
<Preview title="Fields" note="label / hint / control">
<div className="catalog-form">
<TextField label="Название проекта" hint="обязательно" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<TextAreaField label="Описание" value={notes} onChange={(event) => setNotes(event.target.value)} />
</div>
</Preview>
<Preview title="Selection" note="portal dropdown">
<div className="catalog-form">
<FieldFrame label="Статус">
<Select label="Статус" value={selectedStatus} options={[...selectOptions]} searchable onChange={setSelectedStatus} />
</FieldFrame>
<Dropdown
trigger={({ open, toggle, setTriggerRef, surfaceId }) => (
<Button ref={setTriggerRef} aria-expanded={open} aria-controls={surfaceId} onClick={toggle}>Действия</Button>
)}
>
{({ close }) => (
<>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><span /><span className="nodedc-dropdown-option__label">Переименовать</span></button>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><span /><span className="nodedc-dropdown-option__label">Дублировать</span></button>
<button className="nodedc-dropdown-option" data-nodedc-option onClick={close}><span /><span className="nodedc-dropdown-option__label">Архивировать</span></button>
</>
)}
</Dropdown>
</div>
</Preview>
<Preview title="Settings controls" note="new Engine generation">
<div className="catalog-form">
<Checker checked={checker} label="Автоматическое обновление" description="Применять изменения без перезапуска" onChange={setChecker} />
<RangeControl label="Интенсивность стекла" value={range} min={0} max={100} formatValue={(value) => `${value}%`} onChange={setRange} />
</div>
</Preview>
<Preview title="Semantic status" note="not tied to accent" className="catalog-preview--wide">
<div className="catalog-inline catalog-inline--wrap">
<StatusBadge>Черновик</StatusBadge>
<StatusBadge tone="success">Готово</StatusBadge>
<StatusBadge tone="warning">Нужна проверка</StatusBadge>
<StatusBadge tone="danger">Ошибка</StatusBadge>
<StatusBadge tone="accent">В работе</StatusBadge>
</div>
</Preview>
</div>
</Section>
<Section id="windows" eyebrow="03 / Layers" title="Окна и подтверждения" description="Modal и side panel используют одну механику portal, Escape, focus trap и focus restore.">
<Preview title="Controlled windows" note="center / end / confirm">
<div className="catalog-inline catalog-inline--wrap">
<Button variant="accent" onClick={() => setModalOpen(true)}>Открыть окно</Button>
<Button onClick={() => setInspectorOpen(true)}>Открыть инспектор</Button>
<Button variant="danger" onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
</div>
</Preview>
</Section>
<Section id="inspector" eyebrow="04 / Pattern" title="Inspector / Settings" description="Канон основан только на новом Environment Settings и NDC Agent Inspector, без legacy-инспекторов Engine.">
<div className="catalog-inspector-stage">
<GlassSurface tone="strong" radius="panel" padding="md" className="catalog-inspector-card">
<div className="catalog-inspector-title"><strong>Инспектор</strong><span>Agent Definition</span></div>
<Inspector sections={inspectorSections} defaultOpen={["definition"]} activeId="definition" />
</GlassSurface>
</div>
</Section>
</main>
<Window
open={modalOpen}
title="Настройки сервиса"
subtitle="Единое окно Launcher / CMS / SEO"
onClose={() => setModalOpen(false)}
footer={
<>
<Button variant="ghost" onClick={() => setModalOpen(false)}>Отмена</Button>
<WindowFooterActions><Button variant="accent" onClick={() => setModalOpen(false)}>Сохранить</Button></WindowFooterActions>
</>
}
>
<div className="catalog-form">
<TextField label="Название" value={projectName} onChange={(event) => setProjectName(event.target.value)} />
<FieldFrame label="Статус"><Select label="Статус" value={selectedStatus} options={[...selectOptions]} onChange={setSelectedStatus} /></FieldFrame>
<Checker checked={checker} label="Сервис активен" onChange={setChecker} />
</div>
</Window>
<Window open={inspectorOpen} title="Инспектор" subtitle="Agent Definition" placement="end" onClose={() => setInspectorOpen(false)}>
<Inspector sections={inspectorSections} defaultOpen={["definition"]} activeId="definition" />
</Window>
<ConfirmationModal
open={confirmOpen}
title="Удалить конфигурацию?"
description={<>Конфигурация будет удалена. Это действие нельзя отменить.</>}
confirmLabel="Удалить"
danger
onClose={() => setConfirmOpen(false)}
onConfirm={() => setConfirmOpen(false)}
/>
</div>
);
}