Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions apps/web/src/components/sandbox/content/cms-settings-editor.tsx
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<SaveGuard>(() => ({
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 (
<EmptyMessage
icon={AlertCircle}
title={t("sandbox.cmsSettingsBlock.conflictTitle")}
description={t("sandbox.cmsSettingsBlock.conflictDescription", {
type: target.resolveType || "?",
})}
/>
);
}
return (
<AppEditor
// One key across "absent" and "saved": the first save must not reset
// the form under the user.
key={`settings:${CMS_SETTINGS_BLOCK_KEY}`}
orgSlug={orgSlug}
virtualMcpId={virtualMcpId}
branch={branch}
blockKey={CMS_SETTINGS_BLOCK_KEY}
block={target.block}
decofile={decofile}
meta={meta}
title={t("sandbox.collectionsSidebar.settings")}
saveGuard={guard}
notice={
<div className="mb-6 flex flex-col gap-2 text-xs text-muted-foreground">
<p>{t("sandbox.cmsSettingsBlock.description")}</p>
{target.kind === "absent" && (
<p
data-testid="cms-settings-defaults-notice"
className="flex items-start gap-1.5 rounded-lg border bg-muted/40 px-3 py-2"
>
<InfoCircle size={14} className="mt-px shrink-0" />
<span>{t("sandbox.cmsSettingsBlock.defaultsNotice")}</span>
</p>
)}
</div>
}
/>
);
}
79 changes: 53 additions & 26 deletions apps/web/src/components/sandbox/content/collections-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="w-[208px] shrink-0 border-r flex flex-col">
<div className="px-3 h-12 flex items-center border-b shrink-0">
Expand All @@ -65,28 +70,44 @@ export function CollectionsSidebar({
active={active === "redirects"}
onSelect={onSelect}
/>
<CollectionRow
id="site"
icon={Settings01}
label={t("sandbox.collectionsSidebar.site")}
active={active === "site"}
onSelect={onSelect}
/>
{shown("site") && (
<CollectionRow
id="site"
icon={Settings01}
label={t("sandbox.collectionsSidebar.site")}
active={active === "site"}
onSelect={onSelect}
/>
)}
<CollectionRow
id="seo"
icon={CreditCardSearch}
label={t("sandbox.collectionsSidebar.seo")}
active={active === "seo"}
onSelect={onSelect}
/>
{shown("settings") && (
<CollectionRow
id="settings"
icon={Settings01}
label={t("sandbox.collectionsSidebar.settings")}
active={active === "settings"}
onSelect={onSelect}
/>
)}
<CollectionRow
id="calendar"
icon={Calendar}
label={t("sandbox.collectionsSidebar.calendar")}
active={active === "calendar"}
onSelect={onSelect}
/>
<AdvancedGroup active={active} counts={counts} onSelect={onSelect} />
<AdvancedGroup
active={active}
counts={counts}
shown={shown}
onSelect={onSelect}
/>
{showBlog && (
<>
<div className="mt-3 flex items-center gap-1.5 px-2.5 pb-1 pt-1 text-xs font-medium text-muted-foreground/70">
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -182,15 +205,17 @@ function AdvancedGroup({
onSelect={onSelect}
indent
/>
<CollectionRow
id="apps"
icon={Grid01}
label={t("sandbox.collectionsSidebar.apps")}
count={counts.apps}
active={active === "apps"}
onSelect={onSelect}
indent
/>
{shown("apps") && (
<CollectionRow
id="apps"
icon={Grid01}
label={t("sandbox.collectionsSidebar.apps")}
count={counts.apps}
active={active === "apps"}
onSelect={onSelect}
indent
/>
)}
<CollectionRow
id="loaders"
icon={Database01}
Expand All @@ -200,15 +225,17 @@ function AdvancedGroup({
onSelect={onSelect}
indent
/>
<CollectionRow
id="actions"
icon={Zap}
label={t("sandbox.collectionsSidebar.actions")}
count={counts.actions}
active={active === "actions"}
onSelect={onSelect}
indent
/>
{shown("actions") && (
<CollectionRow
id="actions"
icon={Zap}
label={t("sandbox.collectionsSidebar.actions")}
count={counts.actions}
active={active === "actions"}
onSelect={onSelect}
indent
/>
)}
</CollapsibleContent>
</Collapsible>
);
Expand Down
Loading
Loading