Add pink media stage and sharing modal system

This commit is contained in:
DCCONSTRUCTIONS
2026-07-10 18:21:14 +03:00
parent ebae662b52
commit f1e28c267a
29 changed files with 917 additions and 64 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "@nodedc/ui-catalog",
"version": "0.4.0",
"version": "0.5.0",
"private": true,
"type": "module",
"scripts": {
@@ -9,8 +9,8 @@
"typecheck": "tsc -b --pretty false"
},
"dependencies": {
"@nodedc/ui-core": "0.4.0",
"@nodedc/ui-react": "0.4.0",
"@nodedc/ui-core": "0.5.0",
"@nodedc/ui-react": "0.5.0",
"react": "^19.1.0",
"react-dom": "^19.1.0"
},
+208 -22
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, type CSSProperties, type ReactNode } from "react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react";
import { applyNodedcTheme, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core";
import {
AdminNavigationPanel,
@@ -24,6 +24,8 @@ import {
MediaSourceField,
RangeControl,
Select,
ShareAccessModal,
ShareLinkModal,
SettingsCard,
StatusBadge,
Switch,
@@ -33,22 +35,31 @@ import {
Window,
WindowFooterActions,
type IconName,
type ShareAccessMember,
} from "@nodedc/ui-react";
type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "inspector" | "icons";
const engineAccentStyle = {
"--nodedc-accent-rgb": "148 123 219",
"--nodedc-on-accent-rgb": "247 248 244",
"--nodedc-accent-rgb": "255 47 146",
"--nodedc-on-accent-rgb": "11 17 23",
} as CSSProperties;
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" },
{ label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" },
{ label: "Rose", value: [255, 62, 108], hex: "#ff3e6c" },
{ label: "Blush", value: [255, 108, 175], hex: "#ff6caf" },
{ label: "Magenta", value: [215, 70, 255], hex: "#d746ff" },
];
type ShareRole = "viewer" | "editor" | "admin";
const shareRoleOptions = [
{ value: "viewer", label: "Просмотр" },
{ value: "editor", label: "Редактор" },
{ value: "admin", label: "Соавтор" },
] satisfies Array<{ value: ShareRole; label: string }>;
const selectOptions = [
{ value: "active", label: "Активен", description: "Сервис доступен пользователям" },
{ value: "maintenance", label: "Техработы", description: "Временно недоступен" },
@@ -83,7 +94,7 @@ const sectionDefinitions: Record<CatalogSection, { eyebrow: string; title: strin
modals: {
eyebrow: "05 / MODALS",
title: "Модалки",
description: "Создание, подтверждение и разрушительные действия в адаптивном формате.",
description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.",
icon: "clipboard",
},
inspector: {
@@ -207,7 +218,7 @@ function Preview({ title, note, children, className }: {
export function CatalogApp() {
const [theme, setTheme] = useState<NodedcTheme>("dark");
const [accentHex, setAccentHex] = useState("#c3ff66");
const [accentHex, setAccentHex] = useState("#ff2f92");
const workspace = useApplicationWorkspace<CatalogSection>();
const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace;
const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active");
@@ -216,20 +227,41 @@ export function CatalogApp() {
const [glowDistance, setGlowDistance] = useState(105);
const [usePortColors, setUsePortColors] = useState(false);
const [connectionType, setConnectionType] = useState("spline");
const [lightColor, setLightColor] = useState("#7a3cff");
const [lightColor, setLightColor] = useState("#ff2f92");
const [connectionColor, setConnectionColor] = useState("#404040");
const [fillColor, setFillColor] = useState("#8f83ff");
const [fillColor, setFillColor] = useState("#ff6caf");
const [strokeColor, setStrokeColor] = useState("#2b2b36");
const [windowOpen, setWindowOpen] = useState(false);
const [inspectorOpen, setInspectorOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [confirmOpen, setConfirmOpen] = useState(false);
const [shareAccessOpen, setShareAccessOpen] = useState(false);
const [shareLinkOpen, setShareLinkOpen] = useState(false);
const [contextActionOpen, setContextActionOpen] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [detailExpanded, setDetailExpanded] = useState(false);
const [projectName, setProjectName] = useState("NODE.DC Platform");
const [notes, setNotes] = useState("Общий контракт компонентов без доменной логики приложения.");
const [mediaSource, setMediaSource] = useState<"file" | "url">("file");
const [mediaUrl, setMediaUrl] = useState("");
const [mediaFileName, setMediaFileName] = useState("hub_vitrina-mqv7yd7k.mov");
const [mediaFileName, setMediaFileName] = useState("launcher-stage.mp4");
const [fileMediaSrc, setFileMediaSrc] = useState("/launcher-stage.mp4");
const [mediaError, setMediaError] = useState("");
const [mediaVisible, setMediaVisible] = useState(true);
const mediaObjectUrlRef = useRef<string | null>(null);
const [shareEmail, setShareEmail] = useState("");
const [shareRole, setShareRole] = useState<ShareRole>("editor");
const [shareMessage, setShareMessage] = useState("");
const [shareMembers, setShareMembers] = useState<Array<ShareAccessMember<ShareRole>>>([
{ id: "owner", name: "DC Constructions", email: "dc@nodedc.ru", role: "admin", roleLabel: "Владелец", immutable: true },
{ id: "editor", name: "Maria Petrova", email: "maria@nodedc.ru", role: "editor", roleLabel: "Редактор" },
]);
const externalMediaSrc = /^(https?:)?\/\//i.test(mediaUrl) && /\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(mediaUrl)
? mediaUrl
: null;
const stageMediaSrc = mediaSource === "url" && externalMediaSrc ? externalMediaSrc : fileMediaSrc;
const accent = useMemo(() => rgbFromHex(accentHex) ?? accents[0].value, [accentHex]);
@@ -237,6 +269,38 @@ export function CatalogApp() {
applyNodedcTheme(document.documentElement, { theme, accent });
}, [accent, theme]);
useEffect(() => () => {
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
}, []);
const handleStageMediaFile = (file?: File) => {
if (!file) return;
const isMp4 = file.type === "video/mp4" || /\.mp4$/i.test(file.name);
if (!isMp4) {
setMediaError("Для заставки нужен MP4-файл.");
return;
}
if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current);
const objectUrl = URL.createObjectURL(file);
mediaObjectUrlRef.current = objectUrl;
setFileMediaSrc(objectUrl);
setMediaFileName(file.name);
setMediaSource("file");
setMediaVisible(true);
setMediaError("");
};
const inviteShareMember = () => {
const email = shareEmail.trim().toLowerCase();
if (!email) return;
setShareMembers((current) => [
...current.filter((member) => member.email !== email),
{ id: email, name: email.split("@")[0], email, role: shareRole, roleLabel: shareRoleOptions.find((item) => item.value === shareRole)?.label },
]);
setShareMessage(`Доступ выдан пользователю ${email}`);
setShareEmail("");
};
const openSection = (next: string) => {
workspace.openView(next as CatalogSection);
};
@@ -425,15 +489,18 @@ export function CatalogApp() {
source={mediaSource}
url={mediaUrl}
fileName={mediaFileName}
previewSrc={mediaSource === "url" && mediaUrl ? mediaUrl : "/launcher-stage-poster.png"}
previewKind={mediaSource === "file" ? "image" : undefined}
path="content.videoSrc → assets/uploads/media/component-library"
hint="Адаптер приложения сохраняет файл и возвращает URL; компонент не знает API storage."
previewSrc={stageMediaSrc}
previewKind="video"
accept="video/mp4,.mp4"
path="stage.videoSrc → runtime media source"
hint="Выбранный MP4 сразу подставляется в главное фоновое окно; backend-adapter может сохранить его в storage."
error={mediaError}
onSourceChange={setMediaSource}
onUrlChange={setMediaUrl}
onFileChange={(file) => {
if (file) setMediaFileName(file.name);
onUrlChange={(nextUrl) => {
setMediaUrl(nextUrl);
setMediaError(nextUrl && !/\.(mp4|webm|mov|m4v)(\?.*)?$/i.test(nextUrl) ? "Ссылка должна вести на видеофайл." : "");
}}
onFileChange={handleStageMediaFile}
/>
</SettingsCard>
<Preview title="Граница ответственности" note="component / adapter" className="catalog-preview--wide">
@@ -474,6 +541,26 @@ export function CatalogApp() {
<p className="catalog-preview__explanation">Destructive-цвет появляется у подтверждения, а не окрашивает всё окно заранее.</p>
<Button variant="danger" shape="pill" icon={<Icon name="trash" />} onClick={() => setConfirmOpen(true)}>Проверить удаление</Button>
</Preview>
<Preview title="Workflow sharing" note="ENGINE / access modal">
<p className="catalog-preview__explanation">Участники, роли, приглашение и immutable-владелец. API доступа остаётся в Engine.</p>
<Button variant="accent" shape="pill" icon={<Icon name="users" />} onClick={() => setShareAccessOpen(true)}>Поделиться графом</Button>
</Preview>
<Preview title="Ссылка на модель" note="BIM / share link">
<p className="catalog-preview__explanation">Read-only ссылка, copy-state и единая механика focus/Escape/backdrop.</p>
<Button shape="pill" icon={<Icon name="copy" />} onClick={() => setShareLinkOpen(true)}>Открыть ссылку</Button>
</Preview>
<Preview title="Контекстные действия" note="BIM / measurement & object">
<p className="catalog-preview__explanation">Короткое окно действий над выбранным размером или объектом без отдельной modal-системы.</p>
<Button shape="pill" icon={<Icon name="sliders" />} onClick={() => setContextActionOpen(true)}>Действия размера</Button>
</Preview>
<Preview title="История версий" note="BIM / large data modal">
<p className="catalog-preview__explanation">Большое прокручиваемое data-окно; BIM-таблица остаётся доменной композицией внутри общего Window.</p>
<Button shape="pill" icon={<Icon name="list" />} onClick={() => setHistoryOpen(true)}>Открыть историю</Button>
</Preview>
<Preview title="Комментарий к модели" note="BIM / expandable detail">
<p className="catalog-preview__explanation">Рабочая detail-modal с контентом и вложениями, которая может расширяться без смены механики слоя.</p>
<Button shape="pill" icon={<Icon name="clipboard" />} onClick={() => setDetailOpen(true)}>Открыть комментарий</Button>
</Preview>
</div>
);
case "inspector":
@@ -553,8 +640,8 @@ export function CatalogApp() {
}
stage={
<section className="catalog-launcher-stage">
<video className="catalog-launcher-stage__media" autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<source src="/launcher-stage.mp4" type="video/mp4" />
<video key={stageMediaSrc} className="catalog-launcher-stage__media" hidden={!mediaVisible} autoPlay muted loop playsInline poster="/launcher-stage-poster.png" aria-hidden="true">
<source src={stageMediaSrc} type="video/mp4" />
</video>
<div className="catalog-launcher-stage__shade" />
<div className="catalog-launcher-stage__title">
@@ -581,7 +668,7 @@ export function CatalogApp() {
footer={
<>
<span className="nodedc-admin-panel__nav-icon" aria-hidden="true"><Icon name="shield" /></span>
<span>Design System 0.4</span>
<span>Design System 0.5</span>
</>
}
/>
@@ -651,6 +738,105 @@ export function CatalogApp() {
</div>
</Window>
<ShareAccessModal<ShareRole>
open={shareAccessOpen}
resourceName="Workflow / Rail infrastructure analysis"
members={shareMembers}
roleOptions={shareRoleOptions}
inviteEmail={shareEmail}
inviteRole={shareRole}
message={shareMessage}
messageTone="accent"
onInviteEmailChange={(email) => {
setShareEmail(email);
setShareMessage("");
}}
onInviteRoleChange={setShareRole}
onInvite={inviteShareMember}
onMemberRoleChange={(member, role) => {
setShareMembers((current) => current.map((item) => item.id === member.id
? { ...item, role, roleLabel: shareRoleOptions.find((option) => option.value === role)?.label }
: item));
}}
onMemberRemove={(member) => setShareMembers((current) => current.filter((item) => item.id !== member.id))}
onClose={() => setShareAccessOpen(false)}
/>
<ShareLinkModal
open={shareLinkOpen}
link="https://bim.nodedc.ru/model/rail-hub?view=main"
onCopy={() => undefined}
onClose={() => setShareLinkOpen(false)}
/>
<Window
open={contextActionOpen}
title="Размер"
subtitle="BIM / contextual actions"
size="sm"
onClose={() => setContextActionOpen(false)}
footer={<WindowFooterActions><Button onClick={() => setContextActionOpen(false)}>Закрыть</Button></WindowFooterActions>}
>
<div className="catalog-modal-action-list">
<Button width="full" icon={<Icon name="activity" />}>Скрыть размер</Button>
<Button width="full" variant="danger" icon={<Icon name="trash" />}>Удалить размер</Button>
</div>
</Window>
<Window
open={historyOpen}
title="История версий"
subtitle="rail-hub-model.glb"
size="lg"
onClose={() => setHistoryOpen(false)}
>
<div className="catalog-history-table" role="table" aria-label="История версий модели">
<div className="catalog-history-row catalog-history-row--head" role="row">
<span>Файл</span><span>Версия</span><span>Автор</span><span>Статус</span><span>Действия</span>
</div>
{[
["rail-hub-model.glb", "v12 · текущая", "DC", "Модель готова"],
["rail-hub-model.glb", "v11", "Maria", "Модель готова"],
["rail-hub-model.glb", "v10", "Alex", "Ожидает подготовки"],
].map((row) => (
<div className="catalog-history-row" role="row" key={row[1]}>
<strong>{row[0]}</strong><span>{row[1]}</span><span>{row[2]}</span><span>{row[3]}</span>
<span className="catalog-history-actions">
<IconButton label="Посмотреть"><Icon name="external" /></IconButton>
<IconButton label="Скачать"><Icon name="download" /></IconButton>
</span>
</div>
))}
</div>
</Window>
<Window
open={detailOpen}
title="Комментарий к модели"
subtitle="Опора №42 · рабочая detail-modal"
size={detailExpanded ? "lg" : "md"}
onClose={() => setDetailOpen(false)}
footer={
<>
<Button icon={<Icon name={detailExpanded ? "minimize" : "expand"} />} onClick={() => setDetailExpanded((current) => !current)}>
{detailExpanded ? "Свернуть" : "Развернуть"}
</Button>
<WindowFooterActions>
<Button onClick={() => setDetailOpen(false)}>Отмена</Button>
<Button variant="accent" onClick={() => setDetailOpen(false)}>Сохранить</Button>
</WindowFooterActions>
</>
}
>
<div className="catalog-detail-modal">
<TextField label="Заголовок" value="Проверить узел крепления" readOnly />
<TextAreaField label="Описание" value="Нужно сверить положение опоры с последней версией модели." readOnly />
<SettingsCard title="Вложения" description="Файлы и изображения остаются BIM-domain content.">
<Button icon={<Icon name="upload" />}>Выбрать файл</Button>
</SettingsCard>
</div>
</Window>
<ConfirmationModal
open={confirmOpen}
title="Удалить конфигурацию?"
+50
View File
@@ -209,6 +209,56 @@ textarea {
gap: 0.75rem;
}
.catalog-modal-action-list,
.catalog-detail-modal {
display: grid;
gap: 0.75rem;
}
.catalog-history-table {
display: grid;
min-width: 46rem;
overflow: hidden;
border-radius: var(--nodedc-radius-card);
background: var(--nodedc-glass-control-bg);
}
.catalog-history-row {
display: grid;
min-height: 3.5rem;
grid-template-columns: minmax(12rem, 1.7fr) minmax(7rem, 0.7fr) minmax(6rem, 0.6fr) minmax(9rem, 0.9fr) 6rem;
align-items: center;
gap: 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
color: var(--nodedc-text-secondary);
padding: 0.62rem 0.8rem;
font-size: var(--nodedc-font-size-sm);
}
.catalog-history-row:last-child {
border-bottom: 0;
}
.catalog-history-row--head {
min-height: 2.35rem;
color: var(--nodedc-text-muted);
font-size: var(--nodedc-font-size-xs);
font-weight: 760;
text-transform: uppercase;
}
.catalog-history-actions {
display: flex;
justify-content: flex-end;
gap: 0.25rem;
}
.catalog-history-actions .nodedc-icon-button {
width: 2rem;
height: 2rem;
flex-basis: 2rem;
}
.catalog-surface-stack .nodedc-glass {
display: flex;
min-height: 4rem;