diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 29bf122..098dc4a 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -9,6 +9,7 @@ "typecheck": "tsc -b --pretty false" }, "dependencies": { + "@nodedc/page-patterns": "0.1.0", "@nodedc/ui-core": "0.6.0", "@nodedc/ui-react": "0.6.0", "react": "^19.1.0", diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index b26ac59..f9e7e08 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties, type ReactNode } from "react"; import { applyGlassMaterial, applyNodedcTheme, defaultGlassMaterial, type GlassMaterialSettings, type NodedcTheme, type RgbTuple } from "@nodedc/ui-core"; +import { createTemplateFeatures, getPageTemplate, pageTemplates, type PageTemplateDefinition } from "@nodedc/page-patterns"; import { AdminNavigationPanel, AppHeader, @@ -11,6 +12,9 @@ import { ConfirmationModal, ControlRow, Dropdown, + DragDropRoot, + DraggableItem, + DropZone, FieldFrame, GlassSurface, GlassMaterialSurface, @@ -28,6 +32,8 @@ import { Select, ShareAccessModal, ShareLinkModal, + SortableItem, + SortableScope, SettingsCard, Switch, TextAreaField, @@ -41,8 +47,23 @@ import { type ToolbarPlacement, } from "@nodedc/ui-react"; import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js"; +import { + applicationIdFromView, + applicationPageIdFromView, + applicationPageViewId, + applicationViewId, + type ApplicationDraftSaveState, + type ApplicationManifestV01, + type ApplicationSummary, + type DesignProfileSummary, +} from "./applicationManifest.js"; type CatalogSection = "controls" | "media" | "glass" | "modals" | "icons"; +type StudioContext = "visual" | "pages" | "applications"; +type StudioView = CatalogSection | `page-template:${string}` | `application:${string}` | `application:${string}/page:${string}`; + +const pageTemplateViewId = (id: string) => `page-template:${id}` as const; +const pageTemplateIdFromView = (view: string | null) => view?.startsWith("page-template:") ? view.slice("page-template:".length) : null; const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [ { label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" }, @@ -278,8 +299,31 @@ export function CatalogApp() { light: { ...materialDefaults.light }, })); const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); - const workspace = useApplicationWorkspace(); - const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace; + const [studioContext, setStudioContext] = useState("visual"); + const [applicationSummaries, setApplicationSummaries] = useState([]); + const [designProfiles, setDesignProfiles] = useState([]); + const [applicationDraft, setApplicationDraft] = useState(null); + const [applicationSaveState, setApplicationSaveState] = useState("idle"); + const [applicationError, setApplicationError] = useState(""); + const [createModuleOpen, setCreateModuleOpen] = useState(false); + const [createModuleName, setCreateModuleName] = useState(""); + const [createModuleSlug, setCreateModuleSlug] = useState(""); + const [createModuleDescription, setCreateModuleDescription] = useState(""); + const [deleteModuleOpen, setDeleteModuleOpen] = useState(false); + const [pageRemovalId, setPageRemovalId] = useState(null); + const [applicationMode, setApplicationMode] = useState<"edit" | "preview">("edit"); + const [designProfileSaveOpen, setDesignProfileSaveOpen] = useState(false); + const [designProfileSaveMode, setDesignProfileSaveMode] = useState<"save" | "save-as">("save"); + const [designProfileName, setDesignProfileName] = useState(""); + const [activeDesignProfileId, setActiveDesignProfileId] = useState("default"); + const workspace = useApplicationWorkspace(); + const { navigationOpen: guidelineOpen, activeView, contentExpanded: panelExpanded } = workspace; + const activeSection = studioContext === "visual" && activeView && !activeView.startsWith("application:") + ? activeView as CatalogSection + : null; + const activeApplicationId = applicationIdFromView(activeView); + const activeApplicationPageId = applicationPageIdFromView(activeView); + const activePageTemplateId = pageTemplateIdFromView(activeView); const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active"); const [brightness, setBrightness] = useState(49); const [glowDistance, setGlowDistance] = useState(105); @@ -450,6 +494,40 @@ export function CatalogApp() { return () => { active = false; }; }, []); + useEffect(() => { + let active = true; + fetch("/api/design-profiles", { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) throw new Error("design_profiles_load_failed"); + return await response.json() as DesignProfileSummary[]; + }) + .then((profiles) => { + if (!active) return; + setDesignProfiles(profiles); + if (profiles.length > 0 && !profiles.some((profile) => profile.id === activeDesignProfileId)) setActiveDesignProfileId(profiles[0].id); + }) + .catch(() => { + if (active) setApplicationError("Не удалось загрузить Design Profiles."); + }); + return () => { active = false; }; + }, []); + + useEffect(() => { + let active = true; + fetch("/api/applications", { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) throw new Error("applications_load_failed"); + return await response.json() as ApplicationSummary[]; + }) + .then((summaries) => { + if (active) setApplicationSummaries(summaries); + }) + .catch(() => { + if (active) setApplicationError("Не удалось загрузить Application Drafts."); + }); + return () => { active = false; }; + }, []); + useEffect(() => () => { if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); if (logoObjectUrlRef.current) URL.revokeObjectURL(logoObjectUrlRef.current); @@ -524,9 +602,156 @@ export function CatalogApp() { }; const openSection = (next: string) => { + setStudioContext("visual"); workspace.openView(next as CatalogSection); }; + const openDesignProfile = async (id: string) => { + setActiveDesignProfileId(id); + const response = await fetch(`/api/design-profiles/${id}`, { cache: "no-store" }); + if (!response.ok) return setLayoutSaveState("error"); + const profile = await response.json() as { layout: StoredLayout }; + const stored = profile.layout; + if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme); + if (stored.accentHex) setAccentHex(stored.accentHex); + if (stored.materialByTheme) setMaterialByTheme((current) => ({ dark: { ...current.dark, ...stored.materialByTheme?.dark }, light: { ...current.light, ...stored.materialByTheme?.light } })); + if (stored.glass) setGlassMaterial({ ...defaultGlassMaterial, ...stored.glass }); + if (stored.toolbar?.placement) setToolbarPlacement(stored.toolbar.placement); + if (stored.toolbar?.background) setToolbarBg(stored.toolbar.background); + if (stored.toolbar?.border) setToolbarBorder(stored.toolbar.border); + if (stored.toolbar?.outline) setToolbarOutline(stored.toolbar.outline); + setLayoutSaveState("saved"); + }; + + const openApplicationDraft = async (id: string) => { + setStudioContext("applications"); + setApplicationSaveState("loading"); + setApplicationError(""); + workspace.openView(applicationViewId(id)); + try { + const response = await fetch(`/api/applications/${id}`, { cache: "no-store" }); + if (!response.ok) throw new Error("application_load_failed"); + const manifest = await response.json() as ApplicationManifestV01; + setApplicationDraft(manifest); + setTheme(manifest.designProfile.theme); + setApplicationSaveState("saved"); + } catch { + setApplicationDraft(null); + setApplicationSaveState("error"); + setApplicationError("Не удалось открыть Application Draft."); + } + }; + + const openPageTemplate = (id: string) => { + setStudioContext("pages"); + workspace.openView(pageTemplateViewId(id)); + }; + + const createApplicationDraft = async (template?: PageTemplateDefinition, metadata?: { name: string; slug: string; description: string }) => { + setStudioContext("applications"); + setApplicationSaveState("saving"); + setApplicationError(""); + try { + const response = await fetch("/api/applications", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + templateId: template?.id, + templateVersion: template?.version, + name: metadata?.name || (template ? `NODE.DC ${template.title} Demo` : "Новый модуль"), + slug: metadata?.slug || (template ? `nodedc-${template.id}-demo` : "new-module"), + description: metadata?.description, + theme, + }), + }); + if (!response.ok) throw new Error("application_create_failed"); + const manifest = await response.json() as ApplicationManifestV01; + setApplicationDraft(manifest); + setApplicationSummaries((current) => [ + { + id: manifest.id, + name: manifest.metadata.name, + slug: manifest.metadata.slug, + status: manifest.status, + version: manifest.version, + theme: manifest.designProfile.theme, + pageCount: manifest.pages.length, + updatedAt: manifest.timestamps.updatedAt, + }, + ...current.filter((item) => item.id !== manifest.id), + ]); + workspace.openView(applicationViewId(manifest.id)); + setApplicationSaveState("saved"); + setCreateModuleOpen(false); + setCreateModuleName(""); + setCreateModuleSlug(""); + setCreateModuleDescription(""); + } catch { + setApplicationSaveState("error"); + setApplicationError(`Не удалось создать ${template ? `draft из шаблона ${template.title}` : "модуль"}.`); + } + }; + + const deleteApplicationDraft = async () => { + if (!applicationDraft) return; + const response = await fetch(`/api/applications/${applicationDraft.id}`, { method: "DELETE" }); + if (!response.ok) throw new Error("application_delete_failed"); + setApplicationSummaries((current) => current.filter((item) => item.id !== applicationDraft.id)); + setApplicationDraft(null); + setDeleteModuleOpen(false); + workspace.closeView(); + }; + + const updateApplicationDraft = (update: (current: ApplicationManifestV01) => ApplicationManifestV01) => { + setApplicationDraft((current) => current ? update(current) : current); + setApplicationSaveState("idle"); + setApplicationError(""); + }; + + const saveApplicationDraft = async () => { + if (!applicationDraft) return; + setApplicationSaveState("saving"); + setApplicationError(""); + try { + const response = await fetch(`/api/applications/${applicationDraft.id}`, { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(applicationDraft), + }); + if (!response.ok) throw new Error("application_save_failed"); + const saved = await response.json() as ApplicationManifestV01; + setApplicationDraft(saved); + setApplicationSummaries((current) => [ + { + id: saved.id, + name: saved.metadata.name, + slug: saved.metadata.slug, + status: saved.status, + version: saved.version, + theme: saved.designProfile.theme, + pageCount: saved.pages.length, + updatedAt: saved.timestamps.updatedAt, + }, + ...current.filter((item) => item.id !== saved.id), + ]); + setApplicationSaveState("saved"); + } catch { + setApplicationSaveState("error"); + setApplicationError("Draft не сохранён. Проверьте название и slug."); + } + }; + + const changeStudioContext = (next: StudioContext) => { + setStudioContext(next); + workspace.openNavigation(); + if (next === "applications" && applicationSummaries.length > 0) { + void openApplicationDraft(applicationSummaries[0].id); + } else { + workspace.closeView(); + } + setApplicationError(""); + }; + const closeGuideline = workspace.closeNavigation; const updateMaterial = (changes: Partial) => { @@ -639,11 +864,94 @@ export function CatalogApp() { }); if (!response.ok) throw new Error("layout_save_failed"); setLayoutSaveState("saved"); + return true; } catch { setLayoutSaveState("error"); + return false; } }; + const saveDesignProfile = async (mode = designProfileSaveMode) => { + const layoutSaved = await saveEnvironmentLayout(); + if (!layoutSaved) return; + const layoutResponse = await fetch("/api/layout", { cache: "no-store" }); + if (!layoutResponse.ok) return setLayoutSaveState("error"); + const layout = await layoutResponse.json() as StoredLayout; + const creating = mode === "save-as"; + const selected = designProfiles.find((profile) => profile.id === activeDesignProfileId); + const response = await fetch(creating ? "/api/design-profiles" : `/api/design-profiles/${activeDesignProfileId}`, { + method: creating ? "POST" : "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ name: creating ? designProfileName : selected?.name, layout }), + }); + if (!response.ok) return setLayoutSaveState("error"); + const saved = await response.json() as { id: string; name: string; version: string; layout: StoredLayout; timestamps: { updatedAt: string } }; + const summary: DesignProfileSummary = { id: saved.id, name: saved.name, version: saved.version, theme: saved.layout.theme === "light" ? "light" : "dark", updatedAt: saved.timestamps.updatedAt }; + setDesignProfiles((current) => [...current.filter((profile) => profile.id !== summary.id), summary].sort((left, right) => left.name.localeCompare(right.name))); + setActiveDesignProfileId(summary.id); + setDesignProfileSaveOpen(false); + setDesignProfileName(""); + setLayoutSaveState("saved"); + }; + + const addPageTemplateToApplication = (template: PageTemplateDefinition) => { + updateApplicationDraft((current) => { + const order = current.pages.length; + const duplicateIndex = current.pages.filter((page) => page.template.id === template.id).length + 1; + const instanceSuffix = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; + const instanceId = `${template.page.id}-${instanceSuffix}`; + const instanceLabel = duplicateIndex === 1 ? template.page.navigationLabel : `${template.page.navigationLabel} ${duplicateIndex}`; + return { + ...current, + pages: [...current.pages, { + id: instanceId, + title: duplicateIndex === 1 ? template.page.title : `${template.page.title} ${duplicateIndex}`, + path: order === 0 ? "/" : `/${template.page.id}-${duplicateIndex}`, + template: { id: template.id, version: template.version }, + navigation: { visible: true, label: instanceLabel, order }, + features: createTemplateFeatures(template), + }], + }; + }); + }; + + const removePageFromApplication = (pageId: string) => { + updateApplicationDraft((current) => ({ + ...current, + pages: current.pages.filter((page) => page.id !== pageId).map((page, order) => ({ ...page, navigation: { ...page.navigation, order } })), + })); + }; + + const confirmPageRemoval = () => { + if (!pageRemovalId) return; + removePageFromApplication(pageRemovalId); + if (activeApplicationPageId === pageRemovalId && applicationDraft) { + workspace.openView(applicationViewId(applicationDraft.id)); + } + setPageRemovalId(null); + }; + + const reorderApplicationPages = (activeId: string, overId: string) => { + updateApplicationDraft((current) => { + const pages = [...current.pages]; + const index = pages.findIndex((page) => page.id === activeId); + const target = pages.findIndex((page) => page.id === overId); + if (index < 0 || target < 0 || index === target) return current; + const [moved] = pages.splice(index, 1); + pages.splice(target, 0, moved); + return { ...current, pages: pages.map((page, order) => ({ ...page, navigation: { ...page.navigation, order } })) }; + }); + }; + + const setApplicationPageOrder = (ids: string[]) => { + updateApplicationDraft((current) => { + const pagesById = new Map(current.pages.map((page) => [page.id, page])); + const pages = ids.map((id) => pagesById.get(id)).filter((page): page is ApplicationManifestV01["pages"][number] => Boolean(page)); + if (pages.length !== current.pages.length) return current; + return { ...current, pages: pages.map((page, order) => ({ ...page, navigation: { ...page.navigation, order } })) }; + }); + }; + const environmentSections = [ { id: "application-material", @@ -940,6 +1248,10 @@ export function CatalogApp() {

Участники, роли, приглашение и immutable-владелец.

+ +

Обновляет текущий preset или создаёт новый именованный профиль.

+ +
@@ -997,7 +1309,237 @@ export function CatalogApp() { } }; + const renderApplicationDraft = () => { + if (!applicationDraft || applicationDraft.id !== activeApplicationId) return null; + const selectedProfile = designProfiles.find((profile) => profile.id === applicationDraft.designProfile.id) ?? designProfiles[0]; + const availableTemplates = pageTemplates; + + if (applicationMode === "preview") { + return ( +
+
+ {applicationDraft.pages.length ? applicationDraft.pages.map((page) => {page.navigation.label}) : В приложении пока нет страниц} +
+
+ + {applicationDraft.metadata.name} + {applicationDraft.pages.length ? "Выберите страницу в левой панели для полного preview." : "Вернитесь в режим настройки и добавьте готовый Page Template."} +
+
+ ); + } + + return ( +
+ +
+ updateApplicationDraft((current) => ({ + ...current, + metadata: { ...current.metadata, name: event.target.value }, + }))} + /> + updateApplicationDraft((current) => ({ + ...current, + metadata: { ...current.metadata, slug: event.target.value.toLowerCase().replace(/[^a-z0-9-]+/g, "-") }, + }))} + /> + updateApplicationDraft((current) => ({ + ...current, + metadata: { ...current.metadata, description: event.target.value }, + }))} + /> +
+
+ + + ({ value: application.id, label: application.name, description: `${application.version} · ${application.status}`, icon: }))} + searchable + searchPlaceholder="Поиск модулей" + emptyLabel="Модули не созданы" + onChange={(id) => { void openApplicationDraft(id); }} + /> + ) : studioContext === "visual" ? ( +