From 4d1a3cd8f6be9244807b03f660a30be8ceeb1e14 Mon Sep 17 00:00:00 2001 From: gimenes Date: Thu, 8 Oct 2026 15:08:20 -0300 Subject: [PATCH] feat(web): Content tab switches on the content backend The Content tab switches on the content backend: schemaless editing, CMS settings and redirects on v8 sites. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WNwbSEePYNcY5YCgqZURig --- .../sandbox/content/cms-settings-editor.tsx | 123 ++++++++++++++ .../sandbox/content/collections-sidebar.tsx | 79 ++++++--- .../sandbox/content/content-browser.tsx | 157 +++++++++++++----- .../sandbox/content/redirect-type-badge.tsx | 7 +- apps/web/src/i18n/en/sandbox.ts | 8 + apps/web/src/i18n/pt-br/sandbox.ts | 9 + .../layouts/main-panel-tabs/content-tab.tsx | 22 ++- 7 files changed, 328 insertions(+), 77 deletions(-) create mode 100644 apps/web/src/components/sandbox/content/cms-settings-editor.tsx diff --git a/apps/web/src/components/sandbox/content/cms-settings-editor.tsx b/apps/web/src/components/sandbox/content/cms-settings-editor.tsx new file mode 100644 index 0000000000..94553b11c7 --- /dev/null +++ b/apps/web/src/components/sandbox/content/cms-settings-editor.tsx @@ -0,0 +1,123 @@ +import { AlertCircle, InfoCircle } from "@untitledui/icons"; +import { useQueryClient } from "@tanstack/react-query"; +import { useRef, useState } from "react"; +import type { LiveMeta } from "@/components/sections-editor/resolve-schema"; +import { decofileWriteMutationKey } from "@/components/sections-editor/decofile-api"; +import { useDecofileCacheKey } from "@/components/sections-editor/use-decofile"; +import type { SaveGuard } from "@/components/sections-editor/use-save-block"; +import { KEYS } from "@/lib/query-keys"; +import { useT } from "@/i18n/use-t.ts"; +import { AppEditor } from "./app-editor"; +import { CMS_SETTINGS_BLOCK_KEY, readCmsSettingsBlock } from "./cms-settings"; +import { EmptyMessage } from "./empty-message"; + +/** + * Settings: the form of the site's `CMS` block (type `cms-settings`), from the + * schema. With no block yet the form shows the defaults, and the first change + * creates `CMS.json` with one `blocks.apply` guarded by `ifMatch: { CMS: null }`, + * so a `CMS` created meanwhile (of any type) is never replaced: the guard + * fails, the content is read again and the editor shows what's there. + * + * The site reads these settings from its published release, never from a + * draft, so the intro says when they take effect. + */ +export function CmsSettingsEditor({ + orgSlug, + virtualMcpId, + branch, + decofile, + meta, +}: { + orgSlug: string; + virtualMcpId: string; + branch: string; + decofile: Record; + meta: LiveMeta; +}) { + const t = useT(); + const queryClient = useQueryClient(); + const cacheKey = useDecofileCacheKey({ orgSlug, virtualMcpId, branch }); + const target = readCmsSettingsBlock(decofile); + /** + * The create-only guard: "create" while the block is absent (every save + * sends `ifMatch: { CMS: null }`), "off" once a guarded save lands or the + * block was there already. A refused save moves to "reread": saves queued + * behind it stay create-only (and fail), and the guard turns off only once + * the content has been read again, so the editor shows the block someone + * else saved (or explains one of another type) before any edit replaces it. + */ + const guardState = useRef<"create" | "reread" | "off">( + target.kind === "absent" ? "create" : "off", + ); + const [guard] = useState(() => ({ + ifMatch: (blockKey) => + blockKey === CMS_SETTINGS_BLOCK_KEY && guardState.current !== "off" + ? { [CMS_SETTINGS_BLOCK_KEY]: null } + : undefined, + onApplied: () => { + guardState.current = "off"; + }, + onRejected: () => { + if (guardState.current !== "create") return; + guardState.current = "reread"; + // A read merges around blocks still being saved, so it waits until this + // branch's writes (the refused one and any queued behind it) settle. + const writes = { + mutationKey: decofileWriteMutationKey(orgSlug, virtualMcpId, branch), + }; + const reread = () => { + if (queryClient.isMutating(writes) > 0) { + setTimeout(reread, 100); + return; + } + void queryClient + .invalidateQueries({ queryKey: KEYS.decofile(cacheKey) }) + .finally(() => { + guardState.current = "off"; + }); + }; + setTimeout(reread, 0); + }, + })); + if (target.kind === "conflict") { + return ( + + ); + } + return ( + +

{t("sandbox.cmsSettingsBlock.description")}

+ {target.kind === "absent" && ( +

+ + {t("sandbox.cmsSettingsBlock.defaultsNotice")} +

+ )} + + } + /> + ); +} diff --git a/apps/web/src/components/sandbox/content/collections-sidebar.tsx b/apps/web/src/components/sandbox/content/collections-sidebar.tsx index 597d64dbde..e9c864a6a2 100644 --- a/apps/web/src/components/sandbox/content/collections-sidebar.tsx +++ b/apps/web/src/components/sandbox/content/collections-sidebar.tsx @@ -28,19 +28,24 @@ import { useT } from "@/i18n/use-t.ts"; import type { CollectionCounts, CollectionId } from "./content-browser"; const ADVANCED_IDS = ["sections", "apps", "loaders", "actions"] as const; +const NONE_HIDDEN: readonly CollectionId[] = []; export function CollectionsSidebar({ active, counts, + hidden = NONE_HIDDEN, showBlog, onSelect, }: { active: CollectionId; counts: CollectionCounts; + /** Collections this site has no use for, left out of the list. */ + hidden?: readonly CollectionId[]; showBlog: boolean; onSelect: (id: CollectionId) => void; }) { const t = useT(); + const shown = (id: CollectionId) => !hidden.includes(id); return (
@@ -65,13 +70,15 @@ export function CollectionsSidebar({ active={active === "redirects"} onSelect={onSelect} /> - + {shown("site") && ( + + )} + {shown("settings") && ( + + )} - + {showBlog && ( <>
@@ -141,10 +162,12 @@ export function CollectionsSidebar({ function AdvancedGroup({ active, counts, + shown, onSelect, }: { active: CollectionId; counts: CollectionCounts; + shown: (id: CollectionId) => boolean; onSelect: (id: CollectionId) => void; }) { const t = useT(); @@ -182,15 +205,17 @@ function AdvancedGroup({ onSelect={onSelect} indent /> - + {shown("apps") && ( + + )} - + {shown("actions") && ( + + )} ); diff --git a/apps/web/src/components/sandbox/content/content-browser.tsx b/apps/web/src/components/sandbox/content/content-browser.tsx index d83460372d..9d8207f23e 100644 --- a/apps/web/src/components/sandbox/content/content-browser.tsx +++ b/apps/web/src/components/sandbox/content/content-browser.tsx @@ -42,6 +42,7 @@ import { useChatTask } from "@/components/chat/context"; import { useDecofile } from "@/components/sections-editor/use-decofile"; import { useLiveMeta } from "@/components/sections-editor/use-live-meta"; import { hasEditableAppEditorSchema } from "./app-editor-schema"; +import { hasCmsSettingsType } from "./cms-settings"; import { type LiveMeta } from "@/components/sections-editor/resolve-schema"; import { useSaveBlock } from "@/components/sections-editor/use-save-block"; import { useDeleteBlock } from "@/components/sections-editor/use-delete-block"; @@ -58,6 +59,7 @@ import { import type { AppCatalogEntry } from "./app-catalog"; import { ListEmpty } from "./list-empty"; import { useDecoAppsCatalog } from "@/hooks/use-deco-apps-catalog"; +import { useNewBlocksEditorState } from "@/hooks/use-new-blocks-editor"; import { normalizePagePath } from "@/components/sections-editor/page-path-utils"; import { appendPageVariantSections, @@ -70,6 +72,8 @@ import { CollectionsSidebar } from "./collections-sidebar"; import { useSandboxEvents } from "@/components/sandbox/hooks/use-sandbox-events"; import { useSandboxLifecycle } from "@/components/sandbox/hooks/sandbox-lifecycle-context"; import { SandboxStateRenderer } from "./sandbox-state-renderer"; +import { SchemalessEditor } from "./schemaless-editor"; +import { isNoSchemaMeta } from "@/components/sections-editor/schemaless"; import { resolveContentSandboxGate } from "./content-sandbox-gate"; import { useSessionRuntime } from "@/hooks/use-session-runtime"; import { @@ -124,6 +128,11 @@ import { EmptyMessage } from "./empty-message"; import { SectionsRightPane } from "./sections-right-pane"; import { ItemActions } from "./item-actions"; import { ItemRow } from "./item-row"; +import { useContentBackend } from "@/components/sections-editor/use-content-backend"; +import { + isProtocolProject, + servePreviewUrl, +} from "@/components/sections-editor/content-backend"; import { categoryAncestors, type CategoryTreeRow, @@ -138,6 +147,12 @@ const AppEditor = lazy(() => import("./app-editor").then((m) => ({ default: m.AppEditor })), ); +const CmsSettingsEditor = lazy(() => + import("./cms-settings-editor").then((m) => ({ + default: m.CmsSettingsEditor, + })), +); + const PostEditor = lazy(() => import("./blog/post-editor").then((m) => ({ default: m.PostEditor })), ); @@ -188,6 +203,7 @@ export type CollectionId = | "apps" | "site" | "seo" + | "settings" | "calendar" | "post-schedule" | "loaders" @@ -261,8 +277,11 @@ export function ContentBrowser({ deepLinkPage }: ContentBrowserProps) { const lifecycle = useSandboxLifecycle(); const { runtime, previewServerUrl } = useSessionRuntime(virtualMcpId); const fastPreviewActive = runtime === "cms"; + // A content-protocol backend has nothing to boot either. + const contentBackend = useContentBackend(virtualMcpId, branch); + const protocolActive = isProtocolProject(contentBackend); const gate = resolveContentSandboxGate({ - fastPreviewActive, + fastPreviewActive: fastPreviewActive || protocolActive, previewState: lifecycle.previewState, lifecyclePhase: vmEvents.lifecycle.phase, }); @@ -288,9 +307,14 @@ export function ContentBrowser({ deepLinkPage }: ContentBrowserProps) { orgSlug={org.slug} virtualMcpId={virtualMcpId} branch={branch} - previewUrl={lifecycle.previewUrl} + // The protocol never runs site code: nothing executes against a pod. + previewUrl={protocolActive ? null : lifecycle.previewUrl} sitePreviewUrl={ - fastPreviewActive ? previewServerUrl : lifecycle.previewUrl + contentBackend.kind === "protocol" && contentBackend.source === "local" + ? servePreviewUrl(contentBackend) + : fastPreviewActive + ? previewServerUrl + : lifecycle.previewUrl } deepLinkPage={deepLinkPage} devServerReady={gate.devServerReady} @@ -350,6 +374,13 @@ function ContentBrowserReady({ const t = useT(); const threadId = useOptionalChatTask()?.taskId ?? null; const fetchParams = { orgSlug, virtualMcpId, branch, threadId, previewUrl }; + // The protocol never runs site code: no app install, no Run. + const runsSiteCode = !isProtocolProject( + useContentBackend(virtualMcpId, branch), + ); + // Which editor this site gets (v8: the new one): waited on with the data, + // so neither editor shows and then swaps to the other. + const editorUndecided = useNewBlocksEditorState() === undefined; const { data: decofile, isLoading: decofileLoading } = useDecofile( fetchParams, { fetchEnabled: devServerReady }, @@ -375,38 +406,6 @@ function ContentBrowserReady({ ); // Page that should open with the inline SEO form in SectionsEditor. const [openPageSeoKey, setOpenPageSeoKey] = useState(null); - // Storefront "." deep-link: open the visited page once the decofile loads. One-shot, so a later manual selection is never clobbered. - const hasDeepLink = !!( - deepLinkPage?.pageId || - deepLinkPage?.path || - deepLinkPage?.pathTemplate - ); - const [seededDeepLink, setSeededDeepLink] = useState(false); - if (hasDeepLink && !seededDeepLink && selection === null && decofile) { - setSeededDeepLink(true); - const match = resolveDeepLinkPage( - extractPages(decofile), - deepLinkPage ?? {}, - ); - if (match) { - setActiveCollection("pages"); - setSelection({ collection: "pages", key: match.key, path: match.path }); - } - } - const [searchQuery, setSearchQuery] = useState(""); - // Posts workspace view + grouping — lifted so they survive opening a post. - const [postsView, setPostsView] = useState("list"); - const selectItem = (next: Selection) => { - setSelection(next); - setOpenPageSeoKey(null); - }; - // Reset search when switching collections (derived-state sync pattern). - const [prevCollection, setPrevCollection] = useState(activeCollection); - if (prevCollection !== activeCollection) { - setPrevCollection(activeCollection); - setSearchQuery(""); - } - const { data: meta, isLoading: metaLoading, @@ -451,6 +450,44 @@ function ContentBrowserReady({ return false; }, }); + // Storefront "." deep-link: open the visited page once the decofile loads. One-shot, so a later manual selection is never clobbered. + const hasDeepLink = !!( + deepLinkPage?.pageId || + deepLinkPage?.path || + deepLinkPage?.pathTemplate + ); + const [seededDeepLink, setSeededDeepLink] = useState(false); + // Waits for the schema too: it says which block types are pages. + if ( + hasDeepLink && + !seededDeepLink && + selection === null && + decofile && + !metaLoading + ) { + setSeededDeepLink(true); + const match = resolveDeepLinkPage( + extractPages(decofile, meta), + deepLinkPage ?? {}, + ); + if (match) { + setActiveCollection("pages"); + setSelection({ collection: "pages", key: match.key, path: match.path }); + } + } + const [searchQuery, setSearchQuery] = useState(""); + // Posts workspace view + grouping — lifted so they survive opening a post. + const [postsView, setPostsView] = useState("list"); + const selectItem = (next: Selection) => { + setSelection(next); + setOpenPageSeoKey(null); + }; + // Reset search when switching collections (derived-state sync pattern). + const [prevCollection, setPrevCollection] = useState(activeCollection); + if (prevCollection !== activeCollection) { + setPrevCollection(activeCollection); + setSearchQuery(""); + } const isAppSchemaLoading = ( resolveType: string | undefined, @@ -461,7 +498,7 @@ function ContentBrowserReady({ const { catalog: appCatalog, isLoading: appCatalogLoading } = useDecoAppsCatalog(meta ?? undefined, decofile ?? undefined, { - enabled: activeCollection === "apps", + enabled: runsSiteCode && activeCollection === "apps", }); const saveBlock = useSaveBlock(fetchParams); @@ -498,7 +535,12 @@ function ContentBrowserReady({ // terminal phase — or the dev server is up but the fetch failed — fall // through to the error below instead of spinning forever. const dataMissing = !decofile || !meta; - if (decofileLoading || metaLoading || (dataMissing && sandboxWarming)) { + if ( + decofileLoading || + metaLoading || + editorUndecided || + (dataMissing && sandboxWarming) + ) { return (
@@ -510,7 +552,19 @@ function ContentBrowserReady({ return ; } - const pages = extractPages(decofile).sort((a, b) => + // No schema yet: every block, as plain fields, until it appears. + if (isNoSchemaMeta(meta)) { + return ( + + ); + } + + const pages = extractPages(decofile, meta).sort((a, b) => a.name.localeCompare(b.name), ); const redirects = extractRedirects(decofile).sort((a, b) => @@ -529,8 +583,21 @@ function ContentBrowserReady({ ? allBlogEntries[activeCollection] : []; + // Listed (and their saved blocks edited) on every backend; only Run needs + // site code. const loadersCount = countAvailableRunnables(meta, "loaders"); const actionsCount = countAvailableRunnables(meta, "actions"); + // What a content-protocol site has no use for: app installs (no site code + // runs), v7's site app block, and an empty Actions folder. + const hiddenCollections: CollectionId[] = runsSiteCode + ? ["settings"] + : [ + "apps", + ...(siteApp ? [] : (["site"] as const)), + ...(actionsCount === 0 ? (["actions"] as const) : []), + // The CMS settings block, when the site's framework has the type. + ...(hasCmsSettingsType(meta) ? [] : (["settings"] as const)), + ]; // Loader/action-only sites are still editable — don't gate them out. if ( @@ -551,7 +618,7 @@ function ContentBrowserReady({ const counts: CollectionCounts = { pages: pages.length, sections: globalSections.length, - apps: appCatalog.length, + apps: runsSiteCode ? appCatalog.length : 0, loaders: loadersCount, actions: actionsCount, redirects: redirects.length, @@ -959,6 +1026,7 @@ function ContentBrowserReady({