Add pink media stage and sharing modal system
This commit is contained in:
+208
-22
@@ -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="Удалить конфигурацию?"
|
||||
|
||||
Reference in New Issue
Block a user