diff --git a/apps/mesh/src/web/components/sandbox/content/content-browser.tsx b/apps/mesh/src/web/components/sandbox/content/content-browser.tsx index 82961343d1..196bb5e13c 100644 --- a/apps/mesh/src/web/components/sandbox/content/content-browser.tsx +++ b/apps/mesh/src/web/components/sandbox/content/content-browser.tsx @@ -1,6 +1,7 @@ import { Suspense, lazy, useState } from "react"; import { AlertCircle, + Code01, Copy01, DotsHorizontal, Edit01, @@ -10,6 +11,7 @@ import { Loading01, Plus, SearchLg, + CreditCardSearch, Trash01, } from "@untitledui/icons"; import { toast } from "sonner"; @@ -88,6 +90,7 @@ import { } from "./content-mutations"; import { PageFormDialog, type PageFormMode } from "./page-form-dialog"; import { SectionRenameDialog } from "./section-rename-dialog"; +import { PageJsonDialog } from "@/web/components/sections-editor/page-json-dialog"; const SectionsEditor = lazy(() => import("@/web/components/sections-editor/sections-editor").then((m) => ({ @@ -95,6 +98,12 @@ const SectionsEditor = lazy(() => })), ); +const SeoEditor = lazy(() => + import("@/web/components/sections-editor/seo-editor").then((m) => ({ + default: m.SeoEditor, + })), +); + const AddSectionModal = lazy(() => import("@/web/components/sections-editor/add-section-modal").then((m) => ({ default: m.AddSectionModal, @@ -103,7 +112,7 @@ const AddSectionModal = lazy(() => const VARIANT_GREEN = "oklch(0.65 0.15 160)"; -type CollectionId = "pages" | "sections"; +type CollectionId = "pages" | "sections" | "seo"; type Selection = | { collection: "pages"; key: string; path: string } @@ -264,6 +273,8 @@ function ContentBrowserReady({ const [activeCollection, setActiveCollection] = useState("pages"); const [selection, setSelection] = useState(null); + // Page whose SEO is being edited in the two-pane SEO editor (null = none). + const [seoPageKey, setSeoPageKey] = useState(null); const [searchQuery, setSearchQuery] = useState(""); // Reset search when switching collections (derived-state sync pattern) const [prevCollection, setPrevCollection] = useState(activeCollection); @@ -278,6 +289,7 @@ function ContentBrowserReady({ const [addSectionOpen, setAddSectionOpen] = useState(false); const [renameSectionKey, setRenameSectionKey] = useState(null); const [deleteTarget, setDeleteTarget] = useState(null); + const [jsonPageKey, setJsonPageKey] = useState(null); if (decofileLoading || metaLoading) { return ( @@ -306,7 +318,7 @@ function ContentBrowserReady({ ); } - const counts: Record = { + const counts: Record<"pages" | "sections", number> = { pages: pages.length, sections: globalSections.length, }; @@ -537,51 +549,99 @@ function ContentBrowserReady({ onSelect={(id) => { setActiveCollection(id); setSelection(null); + setSeoPageKey(null); }} /> - { - if (activeCollection === "pages") { - openCreatePage(); - } else if (!previewUrl) { - toast.error("Start the preview dev server to add sections."); - } else { - setAddSectionOpen(true); + {activeCollection !== "seo" && ( + { + setSelection(next); + setSeoPageKey(null); + }} + previewUrl={previewUrl} + onCreate={() => { + if (activeCollection === "pages") { + openCreatePage(); + } else if (!previewUrl) { + toast.error("Start the preview dev server to add sections."); + } else { + setAddSectionOpen(true); + } + }} + onDuplicatePage={openDuplicatePage} + onRenamePage={openRenamePage} + onAddPageVariant={handleAddPageVariant} + onDeletePage={(page) => + setDeleteTarget({ kind: "page", key: page.key, label: page.name }) } - }} - onDuplicatePage={openDuplicatePage} - onRenamePage={openRenamePage} - onAddPageVariant={handleAddPageVariant} - onDeletePage={(page) => - setDeleteTarget({ kind: "page", key: page.key, label: page.name }) - } - onDuplicateSection={handleDuplicateSection} - onRenameSection={(s) => setRenameSectionKey(s.key)} - onDeleteSection={(s) => - setDeleteTarget({ kind: "section", key: s.key, label: s.name }) - } - /> + onEditPageSeo={(page) => { + setSelection({ + collection: "pages", + key: page.key, + path: page.path, + }); + setSeoPageKey(page.key); + }} + onViewPageJson={(page) => setJsonPageKey(page.key)} + onDuplicateSection={handleDuplicateSection} + onRenameSection={(s) => setRenameSectionKey(s.key)} + onDeleteSection={(s) => + setDeleteTarget({ kind: "section", key: s.key, label: s.name }) + } + /> + )}
- {selection ? ( - - -
- } - > + + + + } + > + {activeCollection === "seo" ? ( + + ) : seoPageKey ? ( + p.key === seoPageKey)?.name ?? "Page", + path: pages.find((p) => p.key === seoPageKey)?.path ?? "/", + }} + previewBaseUrl={previewUrl} + onBack={() => setSeoPageKey(null)} + onEditDefaultSeo={() => { + setSeoPageKey(null); + setSelection(null); + setActiveCollection("seo"); + }} + /> + ) : selection ? ( setSeoPageKey(pageKey)} /> - - ) : ( - - )} + ) : ( + + )} + {/* Page create/duplicate/rename dialog */} @@ -671,6 +732,18 @@ function ContentBrowserReady({ )} + {/* Page JSON dialog */} + {jsonPageKey && ( + { + if (!open) setJsonPageKey(null); + }} + pageKey={jsonPageKey} + decofile={decofile} + /> + )} + {/* Delete confirmation */} ; + counts: Record<"pages" | "sections", number>; onSelect: (id: CollectionId) => void; }) { return ( @@ -750,6 +823,13 @@ function CollectionsSidebar({ active={active === "sections"} onSelect={onSelect} /> + ); @@ -766,7 +846,7 @@ function CollectionRow({ id: CollectionId; icon: React.ComponentType<{ size?: number; className?: string }>; label: string; - count: number; + count?: number; active: boolean; onSelect: (id: CollectionId) => void; }) { @@ -783,14 +863,16 @@ function CollectionRow({ > {label} - - {count} - + {count !== undefined && ( + + {count} + + )} ); } @@ -810,6 +892,8 @@ function ItemList({ onRenamePage, onAddPageVariant, onDeletePage, + onEditPageSeo, + onViewPageJson, onDuplicateSection, onRenameSection, onDeleteSection, @@ -828,6 +912,8 @@ function ItemList({ onRenamePage: (page: PageEntry) => void; onAddPageVariant: (page: PageEntry) => void; onDeletePage: (page: PageEntry) => void; + onEditPageSeo: (page: PageEntry) => void; + onViewPageJson: (page: PageEntry) => void; onDuplicateSection: (section: GlobalSectionEntry) => void; onRenameSection: (section: GlobalSectionEntry) => void; onDeleteSection: (section: GlobalSectionEntry) => void; @@ -927,6 +1013,8 @@ function ItemList({ onDuplicate={() => onDuplicatePage(page)} onRename={() => onRenamePage(page)} onAddVariant={() => onAddPageVariant(page)} + onEditSeo={() => onEditPageSeo(page)} + onViewJson={() => onViewPageJson(page)} onDelete={() => onDeletePage(page)} /> } @@ -1061,11 +1149,15 @@ function ItemActions({ onDuplicate, onRename, onAddVariant, + onEditSeo, + onViewJson, onDelete, }: { onDuplicate: () => void; onRename: () => void; onAddVariant?: () => void; + onEditSeo?: () => void; + onViewJson?: () => void; onDelete: () => void; }) { return ( @@ -1102,6 +1194,23 @@ function ItemActions({ )} + {(onEditSeo || onViewJson) && ( + <> + + {onEditSeo && ( + + + Edit SEO + + )} + {onViewJson && ( + + + View JSON + + )} + + )} .json`). + */ + openPath?: string | null; } function buildApiUrl( @@ -56,10 +65,49 @@ function toDaemonPath(treePath: string) { return treePath.startsWith("/") ? treePath.slice(1) : treePath; } +type FileIcon = React.ComponentType<{ + size?: number; + className?: string; + style?: React.CSSProperties; +}>; + +/** Warm folder tone — reads well on both light and dark backgrounds. */ +const FOLDER_COLOR = "#d9a441"; +const DEFAULT_FILE_COLOR = "#94a3b8"; + +/** + * Maps a filename to an icon + accent color by extension, so the tree reads at + * a glance (blue for TS, amber for JSON, purple for images, …). Colors are + * inline (not Tailwind scale tokens) and chosen to work in light and dark. + */ +function getFileVisual(name: string): { Icon: FileIcon; color: string } { + const n = name.toLowerCase(); + if (n.endsWith(".tsx") || n.endsWith(".ts")) + return { Icon: FileCode01, color: "#3b82f6" }; + if ( + n.endsWith(".jsx") || + n.endsWith(".js") || + n.endsWith(".mjs") || + n.endsWith(".cjs") + ) + return { Icon: FileCode01, color: "#d9a441" }; + if (n.endsWith(".json")) return { Icon: Brackets, color: "#cb9b3f" }; + if (n.endsWith(".css") || n.endsWith(".scss")) + return { Icon: FileCode01, color: "#06b6d4" }; + if (n.endsWith(".html") || n.endsWith(".xml")) + return { Icon: FileCode01, color: "#f97316" }; + if (/\.(png|jpe?g|gif|webp|avif|ico|svg)$/.test(n)) + return { Icon: Image01, color: "#a855f7" }; + if (n.endsWith(".md") || n.endsWith(".mdx")) + return { Icon: File02, color: "#64748b" }; + return { Icon: File02, color: DEFAULT_FILE_COLOR }; +} + export function FileExplorer({ orgSlug, virtualMcpId, branch, + openPath, }: FileExplorerProps) { // File tree state const [files, setFiles] = useState([]); @@ -96,6 +144,15 @@ export function FileExplorer({ fetchFileTree(); } + // Deep-link: open the requested file when `openPath` is set or changes. + // The file opens via its path directly (read endpoint), so it works even + // when the tree hides the folder (e.g. dot-directories like `.deco`). + const [prevOpenPath, setPrevOpenPath] = useState(null); + if (openPath && openPath !== prevOpenPath) { + setPrevOpenPath(openPath); + handleFileClick(openPath); + } + async function fetchFileTree() { setLoading(true); try { @@ -345,15 +402,19 @@ export function FileExplorer({ const isExpanded = expandedDirs.has(node.path); const isSelected = selectedFile === node.path; + const { Icon: FileVisualIcon, color: fileColor } = getFileVisual( + node.name, + ); + return ( - + Hard Reload Copy Current URL + {decofile && meta && ( + <> + + {currentPageKey && ( + setSeoPageKey(currentPageKey)} + > + + Edit SEO + + )} + {currentPageKey && ( + { + try { + setCodeFilePath( + decoBlockFileViewPath(currentPageKey), + ); + setViewMode("code"); + } catch { + toast.error("Invalid page block key"); + } + }} + > + + View JSON + + )} + setSiteSeoOpen(true)}> + + Site SEO + + + )} @@ -1058,6 +1115,15 @@ export function PreviewContent() { setTimeout(restore, 3000); }, DEV_SERVER_SETTLE_MS); }} + onEditPageSeo={(pageKey) => setSeoPageKey(pageKey)} + onViewJsonFile={(pageKey) => { + try { + setCodeFilePath(decoBlockFileViewPath(pageKey)); + setViewMode("code"); + } catch { + toast.error("Invalid page block key"); + } + }} /> @@ -1209,6 +1275,7 @@ export function PreviewContent() { orgSlug={org.slug} virtualMcpId={virtualMcpId} branch={branch} + openPath={codeFilePath} /> @@ -1291,6 +1358,67 @@ export function PreviewContent() { error={createPageError} onSubmit={handleCreatePage} /> + + {seoPageKey && decofile && meta && ( + + { + if (!open) setSeoPageKey(null); + }} + orgSlug={org.slug} + virtualMcpId={virtualMcpId ?? ""} + branch={branch ?? ""} + decofile={decofile} + meta={meta} + onSaved={() => { + setTimeout(() => { + const iframe = previewIframeRef.current; + if (!iframe) return; + try { + iframe.contentWindow?.location.reload(); + } catch { + const src = iframeSrcRef.current; + if (src) iframe.src = src; + } + }, DEV_SERVER_SETTLE_MS); + }} + target={{ + kind: "page", + pageKey: seoPageKey, + pageName: pages.find((p) => p.key === seoPageKey)?.name ?? "Page", + path: pages.find((p) => p.key === seoPageKey)?.path ?? "/", + }} + /> + + )} + + {siteSeoOpen && decofile && meta && ( + + { + setTimeout(() => { + const iframe = previewIframeRef.current; + if (!iframe) return; + try { + iframe.contentWindow?.location.reload(); + } catch { + const src = iframeSrcRef.current; + if (src) iframe.src = src; + } + }, DEV_SERVER_SETTLE_MS); + }} + target={{ kind: "site" }} + /> + + )} ); } diff --git a/apps/mesh/src/web/components/sections-editor/deco-block-key.ts b/apps/mesh/src/web/components/sections-editor/deco-block-key.ts index 4448fdc218..03f5d466c8 100644 --- a/apps/mesh/src/web/components/sections-editor/deco-block-key.ts +++ b/apps/mesh/src/web/components/sections-editor/deco-block-key.ts @@ -17,3 +17,8 @@ export function decoBlockFilePath(blockKey: string): string { assertSafeDecoBlockKey(blockKey); return `.deco/blocks/${blockKey}.json`; } + +/** Path shape used by the sandbox file explorer deep-link (`openPath`). */ +export function decoBlockFileViewPath(blockKey: string): string { + return `/${decoBlockFilePath(blockKey)}`; +} diff --git a/apps/mesh/src/web/components/sections-editor/fields/file-field.tsx b/apps/mesh/src/web/components/sections-editor/fields/file-field.tsx index d541656742..adc91940a4 100644 --- a/apps/mesh/src/web/components/sections-editor/fields/file-field.tsx +++ b/apps/mesh/src/web/components/sections-editor/fields/file-field.tsx @@ -115,8 +115,10 @@ export function FileField({ return ( // See ImageField for the grid-cols-[minmax(0,1fr)] rationale — - // bulletproofs against any broken min-w-0 chain above. -
+ // bulletproofs against any broken min-w-0 chain above. No + // `overflow-hidden`: it clips the input/button focus rings and right + // borders; the grid track already caps width. +