From 13a7c665ea96b2c79250cd57dd3c8c704ee8ec8e Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 16:25:15 +0100 Subject: [PATCH 1/4] fix: Allow publishing to staging with CMS on free plan (#5721) Also Clarify staging vs custom-domain upgrade messaging --- .../builder/features/pages/page-settings.tsx | 9 +-- .../collapsible-domain-section.stories.tsx | 4 +- .../app/builder/features/publish/publish.tsx | 55 +++++++++++++++---- .../settings-panel/settings-panel.tsx | 5 +- 4 files changed, 54 insertions(+), 19 deletions(-) diff --git a/apps/builder/app/builder/features/pages/page-settings.tsx b/apps/builder/app/builder/features/pages/page-settings.tsx index 1e95fe9d42cf..859a86701719 100644 --- a/apps/builder/app/builder/features/pages/page-settings.tsx +++ b/apps/builder/app/builder/features/pages/page-settings.tsx @@ -340,8 +340,8 @@ const PathField = ({
To make the path dynamic and use it with CMS, you can use - parameters and other features. CMS features are part of the - Pro plan. + parameters and other features. You can publish to staging + for free; upgrade to Pro to publish to custom domains. - Dynamic routing and redirect are a part of the CMS - functionality. + Dynamic routing and redirect are part of the CMS functionality. + You can publish to staging for free; upgrade to Pro to publish + to custom domains. diff --git a/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx b/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx index 3fbd20bf83e6..cc01ac317127 100644 --- a/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx +++ b/apps/builder/app/builder/features/publish/collapsible-domain-section.stories.tsx @@ -382,7 +382,9 @@ export const UpgradeBanners = () => (
  • Redirect
  • Custom contact email
  • - You can delete these features or upgrade. + + You can delete these features or upgrade to publish to custom domains. + diff --git a/apps/builder/app/builder/features/publish/publish.tsx b/apps/builder/app/builder/features/publish/publish.tsx index 892353d27265..4c1be4628761 100644 --- a/apps/builder/app/builder/features/publish/publish.tsx +++ b/apps/builder/app/builder/features/publish/publish.tsx @@ -401,19 +401,28 @@ const Publish = ({ timesLeft, disabled, refresh, + restrictedFeatures, }: { project: Project; timesLeft: number; disabled: boolean; refresh: () => Promise; + restrictedFeatures: Map< + string, + | undefined + | { awareness?: Awareness; view?: "pageSettings"; info?: ReactNode } + >; }) => { const { maxDailyPublishesPerUser } = useStore($permissions); + const { userPublishCount } = useUserPublishCount(); const [publishError, setPublishError] = useState< undefined | JSX.Element | string >(); const [isPublishing, setIsPublishing] = useOptimistic(false); const buttonRef = useRef(null); const [hasSelectedDomains, setHasSelectedDomains] = useState(false); + const [hasCustomDomainsSelected, setHasCustomDomainsSelected] = + useState(false); const countdown = usePublishCountdown(isPublishing); useEffect(() => { @@ -425,8 +434,18 @@ const Publish = ({ const handleFormInput = () => { const formData = new FormData(form); - const domainsSelected = formData.getAll(domainToPublishName).length; - setHasSelectedDomains(domainsSelected > 0); + const domainsSelected = formData + .getAll(domainToPublishName) + .map((domain) => domain.toString()); + + setHasSelectedDomains(domainsSelected.length > 0); + + // Check if any custom domains are selected + // Custom domains are those that are NOT the staging domain (project.domain) + const hasCustom = domainsSelected.some( + (domain) => domain !== project.domain + ); + setHasCustomDomainsSelected(hasCustom); }; const observer = new MutationObserver(() => { @@ -445,7 +464,7 @@ const Publish = ({ return () => { observer.disconnect(); }; - }, []); + }, [project.domain]); const handlePublish = async (formData: FormData) => { setPublishError(undefined); @@ -582,7 +601,12 @@ const Publish = ({ formAction={handlePublish} color="positive" state={showPendingState ? "pending" : undefined} - disabled={hasSelectedDomains === false || disabled} + disabled={ + hasSelectedDomains === false || + disabled || + (restrictedFeatures.size > 0 && hasCustomDomainsSelected) || + userPublishCount >= maxDailyPublishesPerUser + } > {countdown !== undefined && countdown > 0 ? `Publishing (${countdown}s)` @@ -789,7 +813,7 @@ const buttonLinkClass = css({ ...textVariants.link, }).toString(); -const UpgradeBanner = () => { +const UpgradeBanner = ({ hasCustomDomains }: { hasCustomDomains: boolean }) => { const restrictedFeatures = useStore($restrictedFeatures); const { canAddDomain } = useCanAddDomain(); const { userPublishCount, maxDailyPublishesPerUser } = useUserPublishCount(); @@ -812,7 +836,9 @@ const UpgradeBanner = () => { ); } - if (restrictedFeatures.size > 0) { + // Only show Pro feature upgrade banner if custom domains are available + // Free tier users can still publish to staging domain with Pro features + if (restrictedFeatures.size > 0 && hasCustomDomains) { return ( { ) )} - You can delete these features or upgrade. + + You can delete these features or upgrade to publish to custom domains. + domain.status === "ACTIVE" && domain.verified + ); + return (
    @@ -947,7 +980,7 @@ const Content = (props: { }} onExportClick={props.onExportClick} /> - + {hasUnpublishedDomains && ( @@ -964,10 +997,8 @@ const Content = (props: { project={project} refresh={refreshProject} timesLeft={maxDailyPublishesPerUser - userPublishCount} - disabled={ - restrictedFeatures.size > 0 || - userPublishCount >= maxDailyPublishesPerUser - } + disabled={false} + restrictedFeatures={restrictedFeatures} /> diff --git a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx index 9e3705db845a..adf7d7912828 100644 --- a/apps/builder/app/builder/features/settings-panel/settings-panel.tsx +++ b/apps/builder/app/builder/features/settings-panel/settings-panel.tsx @@ -45,10 +45,11 @@ export const SettingsPanel = ({ width={rawTheme.spacing[28]} style={{ aspectRatio: "4.1" }} /> - Upgrade for CMS + Upgrade for CMS on custom domains Integrate content from other tools to create blogs, directories, and - any other structured content. + any other structured content. You can preview CMS on staging without + upgrading. From fc70898513ebd96cfef6e8b677e9d04320ec3aa9 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 18:42:42 +0100 Subject: [PATCH 2/4] fix: Preserve whitespace between sibling elements (#5718) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat whitespace-only text nodes between element siblings as part of the previous element instead of creating a separate text node. generateFragmentFromHtml now detects when a text node consisting only of space appears between two elements and appends that space to the previous instance's last text child (if present), avoiding creation of a standalone text child. Added a regression test in plugin-html.test.tsx to verify pasted HTML like ` text` preserves the space and results in two span instances (no separate text node), ensuring text-content control remains correct in the settings panel. --- .../shared/copy-paste/plugin-html.test.tsx | 56 +++++++++++++++++++ .../copy-paste/plugin-markdown.test.tsx | 2 +- apps/builder/app/shared/html.test.tsx | 2 +- apps/builder/app/shared/html.ts | 37 +++++++++++- 4 files changed, 93 insertions(+), 4 deletions(-) diff --git a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx index b8fd1ed6bc4c..88c38206cc10 100644 --- a/apps/builder/app/shared/copy-paste/plugin-html.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-html.test.tsx @@ -63,3 +63,59 @@ test("ignore html without any tags", async () => { expect(await html.onPaste?.(`It works`)).toEqual(false); expect($instances.get()).toEqual(data.instances); }); + +test("skip whitespace-only text nodes between element siblings", async () => { + const data = renderData( + + + + ); + $project.set({ id: "" } as Project); + $instances.set(data.instances); + $pages.set( + createDefaultPages({ rootInstanceId: "bodyId", homePageId: "pageId" }) + ); + $awareness.set({ pageId: "pageId", instanceSelector: ["divId", "bodyId"] }); + + // Regression test: whitespace between elements should not create separate text nodes + // but the space should be preserved as part of one of the adjacent span instances + // This ensures the second span gets text-content control in settings panel + expect( + await html.onPaste?.(`
    text
    `) + ).toEqual(true); + + const instances = Array.from($instances.get().values()).filter( + (i) => i.id !== "bodyId" && i.id !== "divId" + ); + + const divs = instances.filter((i) => i.tag === "div"); + expect(divs.length).toBeGreaterThan(0); + + const pastedDiv = divs[0]; + expect(pastedDiv?.children).toHaveLength(2); + + // Both children should be element ids (the two spans), not a text node for the space + expect(pastedDiv?.children[0].type).toBe("id"); + expect(pastedDiv?.children[1].type).toBe("id"); + expect(pastedDiv?.children.some((c) => c.type === "text")).toBe(false); + + // Verify the space is preserved in one of the spans' text content + const child0 = pastedDiv?.children[0]; + const child1 = pastedDiv?.children[1]; + const span1Id = child0?.type === "id" ? child0.value : undefined; + const span2Id = child1?.type === "id" ? child1.value : undefined; + + const span1 = span1Id ? $instances.get().get(span1Id) : undefined; + const span2 = span2Id ? $instances.get().get(span2Id) : undefined; + + const span1Text = + span1?.children[0]?.type === "text" ? span1.children[0].value : ""; + const span2Text = + span2?.children[0]?.type === "text" ? span2.children[0].value : ""; + + // Space should be preserved as part of one of the spans, not lost + const combinedText = span1Text + span2Text; + expect(combinedText).toContain("✓"); + expect(combinedText).toContain("text"); + expect(combinedText).toContain(" "); +}); diff --git a/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx b/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx index 14831acec914..6a56db80f25c 100644 --- a/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx +++ b/apps/builder/app/shared/copy-paste/plugin-markdown.test.tsx @@ -196,7 +196,7 @@ test("preserve spaces between strong and em", () => { renderTemplate( <> - One{" "} + {"One "} two {" text"} diff --git a/apps/builder/app/shared/html.test.tsx b/apps/builder/app/shared/html.test.tsx index 66ba2e4518d9..4471ffcd8cb0 100644 --- a/apps/builder/app/shared/html.test.tsx +++ b/apps/builder/app/shared/html.test.tsx @@ -219,7 +219,7 @@ test("collapse any spacing characters inside rich text", () => { ).toEqual( renderTemplate( - line{" "} + {"line "} another line text ) diff --git a/apps/builder/app/shared/html.ts b/apps/builder/app/shared/html.ts index 56f179e7d17b..0577c249dae8 100644 --- a/apps/builder/app/shared/html.ts +++ b/apps/builder/app/shared/html.ts @@ -887,13 +887,16 @@ export const generateFragmentFromHtml = ( } } } + let spaceAttachedToPrev = false; for (let index = 0; index < node.childNodes.length; index += 1) { const childNode = node.childNodes[index]; if (defaultTreeAdapter.isElementNode(childNode)) { const lastChild = instance.children.at(-1); const nextPreserveLeadingSpace = + !spaceAttachedToPrev && instance.children.length > 0 && !(lastChild?.type === "text" && lastChild.value.endsWith(" ")); + spaceAttachedToPrev = false; const child = convertElementToInstance(childNode, { preserveLeadingSpace: nextPreserveLeadingSpace, }); @@ -902,12 +905,42 @@ export const generateFragmentFromHtml = ( } } if (defaultTreeAdapter.isTextNode(childNode)) { - // trim spaces around rich text - // do not for code + // trim spaces around rich text, do not for code if (spaceRegex.test(childNode.value) && node.tagName !== "code") { + // Skip whitespace at start or end of parent if (index === 0 || index === node.childNodes.length - 1) { continue; } + const prevChild = node.childNodes[index - 1]; + const nextChild = node.childNodes[index + 1]; + const prevIsElement = defaultTreeAdapter.isElementNode(prevChild); + const nextIsElement = defaultTreeAdapter.isElementNode(nextChild); + + // In rich-text contexts, attach whitespace between two sibling elements + // to the previous element instead of creating a separate text node. + // This avoids a standalone space child that would prevent the next element + // from being recognized as the last child for text-content control. + if ( + prevIsElement && + nextIsElement && + !hasNonRichTextContent && + instance.children.length > 0 + ) { + const lastChild = instance.children.at(-1); + if (lastChild?.type === "id") { + const prevInstanceId = lastChild.value; + const prevInstance = instances.get(prevInstanceId); + if (prevInstance && prevInstance.children.length > 0) { + const prevLastChild = prevInstance.children.at(-1); + if (prevLastChild?.type === "text") { + prevLastChild.value += " "; + spaceAttachedToPrev = true; + continue; + } + } + } + continue; + } } let child: Instance["children"][number] = { type: "text", From c0d2ebf8e0b0d4cf53dcee3650bd7fc9193efdb3 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 18:42:57 +0100 Subject: [PATCH 3/4] feat: Remove starter templates, add welcome screen with guide and social links (#5722) --- apps/builder/app/dashboard/dashboard.test.ts | 32 ++--- apps/builder/app/dashboard/dashboard.tsx | 85 ++++++------- .../app/dashboard/projects/projects.tsx | 11 ++ .../app/dashboard/search/search-results.tsx | 24 +--- apps/builder/app/dashboard/shared/types.ts | 1 - .../app/dashboard/templates/template-card.tsx | 60 --------- .../app/dashboard/templates/templates.tsx | 72 ----------- .../builder/app/dashboard/welcome/welcome.tsx | 114 ++++++++++++++---- apps/builder/app/env/env.server.ts | 12 -- .../app/routes/_ui.dashboard.templates.tsx | 27 ----- apps/builder/app/routes/_ui.dashboard.tsx | 10 -- apps/builder/app/shared/help.tsx | 29 ++++- packages/dashboard/src/db/projects.ts | 19 ++- packages/icons/icons/bluesky.svg | 12 ++ packages/icons/icons/facebook.svg | 12 ++ packages/icons/icons/linkedin.svg | 12 ++ packages/icons/icons/reddit.svg | 14 +++ .../icons/src/__generated__/components.tsx | 90 ++++++++++++++ packages/icons/src/__generated__/svg.ts | 8 ++ 19 files changed, 337 insertions(+), 307 deletions(-) delete mode 100644 apps/builder/app/dashboard/templates/template-card.tsx delete mode 100644 apps/builder/app/dashboard/templates/templates.tsx delete mode 100644 apps/builder/app/routes/_ui.dashboard.templates.tsx create mode 100644 packages/icons/icons/bluesky.svg create mode 100644 packages/icons/icons/facebook.svg create mode 100644 packages/icons/icons/linkedin.svg create mode 100644 packages/icons/icons/reddit.svg diff --git a/apps/builder/app/dashboard/dashboard.test.ts b/apps/builder/app/dashboard/dashboard.test.ts index bf8f80aa39f3..54a7f98082e6 100644 --- a/apps/builder/app/dashboard/dashboard.test.ts +++ b/apps/builder/app/dashboard/dashboard.test.ts @@ -5,38 +5,24 @@ const { getView } = __testing__; describe("getView", () => { test("returns 'search' for search path", () => { - expect(getView("/dashboard/search", true, true)).toBe("search"); - expect(getView("/dashboard/search", false, true)).toBe("search"); + expect(getView("/dashboard/search", true, false)).toBe("search"); + expect(getView("/dashboard/search", false, false)).toBe("search"); }); - test("returns 'welcome' when no projects on default workspace", () => { - expect(getView("/dashboard", false, true)).toBe("welcome"); - expect(getView("/dashboard/templates", false, true)).toBe("welcome"); + test("returns 'welcome' when no projects and workspace not suspended", () => { + expect(getView("/dashboard", false, false)).toBe("welcome"); }); - test("returns 'projects' when no projects on non-default workspace", () => { - // Non-default workspaces should never show the welcome onboarding page - expect(getView("/dashboard", false, false)).toBe("projects"); + test("returns 'projects' when workspace is suspended with no projects", () => { + expect(getView("/dashboard", false, true)).toBe("projects"); }); - test("returns 'templates' on non-default workspace with no projects", () => { - expect(getView("/dashboard/templates", false, false)).toBe("templates"); - }); - - test("returns 'templates' for templates path with projects", () => { - expect(getView("/dashboard/templates", true, true)).toBe("templates"); - }); - - test("returns 'projects' for dashboard root with projects", () => { + test("returns 'projects' when there are projects", () => { + expect(getView("/dashboard", true, false)).toBe("projects"); expect(getView("/dashboard", true, true)).toBe("projects"); }); test("returns 'projects' for unknown paths with projects", () => { - expect(getView("/dashboard/unknown", true, true)).toBe("projects"); - }); - - test("search takes priority over welcome", () => { - // Even with no projects, search view should show - expect(getView("/dashboard/search", false, true)).toBe("search"); + expect(getView("/dashboard/unknown", true, false)).toBe("projects"); }); }); diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx index 02c882f27f63..29beb5d1099e 100644 --- a/apps/builder/app/dashboard/dashboard.tsx +++ b/apps/builder/app/dashboard/dashboard.tsx @@ -17,7 +17,7 @@ import { Grid, IconButton, } from "@webstudio-is/design-system"; -import { BodyIcon, ExtensionIcon } from "@webstudio-is/icons"; +import { BodyIcon } from "@webstudio-is/icons"; import { NavLink, useLocation, @@ -33,10 +33,9 @@ import { dashboardPath } from "~/shared/router-utils"; import { CollapsibleSection } from "~/builder/shared/collapsible-section"; import { ProfileMenu } from "./profile-menu"; import { Projects } from "./projects/projects"; -import { Templates } from "./templates/templates"; import { Welcome } from "./welcome/welcome"; import { Header } from "./shared/layout"; -import { help } from "~/shared/help"; +import { help, socialLinks } from "~/shared/help"; import { SearchResults } from "./search/search-results"; import type { DashboardData } from "./shared/types"; import { Search } from "./search/search-field"; @@ -194,22 +193,16 @@ export const DashboardSetup = ({ data }: { data: DashboardData }) => { const getView = ( pathname: string, hasProjects: boolean, - isDefaultWorkspace: boolean + isWorkspaceSuspended: boolean ) => { if (pathname === dashboardPath("search")) { return "search"; } - // Only show the onboarding welcome page on the default workspace - // when the user has no projects yet. Non-default workspaces that are - // empty should show the normal (empty) projects view. - if (hasProjects === false && isDefaultWorkspace) { + if (hasProjects === false && isWorkspaceSuspended === false) { return "welcome"; } - if (pathname === dashboardPath("templates")) { - return "templates"; - } return "projects"; }; @@ -229,7 +222,6 @@ export const Dashboard = () => { publisherHost, projectToClone, projects, - templates, workspaces, currentWorkspaceId, } = data; @@ -243,38 +235,21 @@ export const Dashboard = () => { } const isWorkspaceSuspended = isDowngradedForMember(currentWorkspace); - const hasProjects = projects.length > 0 || isWorkspaceSuspended; - const isDefaultWorkspace = - currentWorkspaceId === undefined || - workspaces?.find((w) => w.id === currentWorkspaceId)?.isDefault === true; - const view = getView(location.pathname, hasProjects, isDefaultWorkspace); + const hasProjects = projects.length > 0; + const view = getView(location.pathname, hasProjects, isWorkspaceSuspended); const showWorkspaceSelector = workspaces !== undefined && workspaces.length > 0 && currentWorkspaceId !== undefined; - const navItems = - view === "welcome" - ? [ - { - to: dashboardPath(), - prefix: , - children: "Welcome", - }, - ] - : [ - { - to: dashboardPath("projects"), - prefix: , - children: "Projects", - }, - { - to: dashboardPath("templates"), - prefix: , - children: "Starter templates", - }, - ]; + const navItems = [ + { + to: dashboardPath("projects"), + prefix: , + children: "Projects", + }, + ]; return ( @@ -360,6 +335,29 @@ export const Dashboard = () => { children: item.label, }))} /> + + + Follow us: + + {socialLinks.map(({ label, url, icon }) => ( + + {icon} + + ))} + {view === "projects" && ( @@ -372,17 +370,8 @@ export const Dashboard = () => { isWorkspaceSuspended={isWorkspaceSuspended} /> )} - {view === "templates" && ( - - )} {view === "welcome" && ( - + )} {view === "search" && }
    diff --git a/apps/builder/app/dashboard/projects/projects.tsx b/apps/builder/app/dashboard/projects/projects.tsx index 83d2c65b2c85..37025d3a9bd5 100644 --- a/apps/builder/app/dashboard/projects/projects.tsx +++ b/apps/builder/app/dashboard/projects/projects.tsx @@ -10,6 +10,8 @@ import { ToggleGroupButton, PanelBanner, panelBannerIconColor, + Link, + buttonStyle, } from "@webstudio-is/design-system"; import { RepeatGridIcon, ListViewIcon } from "@webstudio-is/icons"; import type { DashboardProject } from "@webstudio-is/dashboard"; @@ -145,6 +147,15 @@ export const Projects = (props: ProjectsProps) => { + + Use template + {permissions.canCreateProject && ( )} diff --git a/apps/builder/app/dashboard/search/search-results.tsx b/apps/builder/app/dashboard/search/search-results.tsx index 18ed6b8e3282..11315e61b6a3 100644 --- a/apps/builder/app/dashboard/search/search-results.tsx +++ b/apps/builder/app/dashboard/search/search-results.tsx @@ -1,42 +1,37 @@ import { useMemo } from "react"; import { matchSorter } from "match-sorter"; import { useSearchParams } from "react-router-dom"; -import { Flex, Separator, Text, theme } from "@webstudio-is/design-system"; +import { Flex, Text, theme } from "@webstudio-is/design-system"; import type { DashboardProject } from "@webstudio-is/dashboard"; import { ProjectsGrid } from "../projects/projects"; import { Header, Main } from "../shared/layout"; import type { DashboardData } from "../shared/types"; import { NothingFound } from "./nothing-found"; -import { TemplatesGrid } from "../templates/templates"; type SearchResults = { projects: Array; - templates: Array; }; const initialSearchResults: SearchResults = { - templates: [], projects: [], } as const; export const SearchResults = (props: DashboardData) => { const [searchParams] = useSearchParams(); - const { projects, templates, publisherHost } = props; + const { projects, publisherHost } = props; const search = searchParams.get("q"); const results = useMemo(() => { - if (!search || !projects || !templates) { + if (!search || !projects) { return initialSearchResults; } const keys = ["title", "domain"]; return { projects: matchSorter(projects, search, { keys }), - templates: matchSorter(templates, search, { keys }), }; - }, [projects, templates, search]); + }, [projects, search]); - const nothingFound = - results.projects.length === 0 && results.templates.length === 0; + const nothingFound = results.projects.length === 0; return (
    @@ -66,15 +61,6 @@ export const SearchResults = (props: DashboardData) => { /> )} - {results.templates.length > 0 && ( - <> - - - Templates - - - - )}
    ); diff --git a/apps/builder/app/dashboard/shared/types.ts b/apps/builder/app/dashboard/shared/types.ts index 35890fedbb13..7c1e2dfa0992 100644 --- a/apps/builder/app/dashboard/shared/types.ts +++ b/apps/builder/app/dashboard/shared/types.ts @@ -7,7 +7,6 @@ import type { Notifications } from "~/shared/polly/types"; export type DashboardData = { user: User; projects: Array; - templates: Array; planFeatures: PlanFeatures; purchases: Array; publisherHost: string; diff --git a/apps/builder/app/dashboard/templates/template-card.tsx b/apps/builder/app/dashboard/templates/template-card.tsx deleted file mode 100644 index 5b29310e53ec..000000000000 --- a/apps/builder/app/dashboard/templates/template-card.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { useState } from "react"; -import { Flex, Text, theme } from "@webstudio-is/design-system"; -import type { DashboardProject } from "@webstudio-is/dashboard"; -import { builderUrl } from "~/shared/router-utils"; -import { Card, CardContent, CardFooter } from "../shared/card"; -import { ThumbnailWithAbbr, ThumbnailWithImage } from "../shared/thumbnail"; -import { CloneProjectDialog } from "~/shared/clone-project"; - -type TemplateCardProps = { - project: DashboardProject; -}; - -export const TemplateCard = ({ project, ...props }: TemplateCardProps) => { - const [isDuplicateDialogOpen, setIsDuplicateDialogOpen] = useState(false); - const { title, previewImageAsset } = project; - return ( - - - {previewImageAsset ? ( - { - setIsDuplicateDialogOpen(true); - }} - /> - ) : ( - { - setIsDuplicateDialogOpen(true); - }} - /> - )} - - - - - {title} - - - - { - window.location.href = builderUrl({ - origin: window.origin, - projectId: projectId, - }); - }} - /> - - ); -}; diff --git a/apps/builder/app/dashboard/templates/templates.tsx b/apps/builder/app/dashboard/templates/templates.tsx deleted file mode 100644 index e51f4114dcfd..000000000000 --- a/apps/builder/app/dashboard/templates/templates.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { - Flex, - Grid, - List, - ListItem, - Text, - rawTheme, - theme, -} from "@webstudio-is/design-system"; -import type { DashboardProject } from "@webstudio-is/dashboard"; -import { Header, Main } from "../shared/layout"; -import { CreateProject } from "../projects/project-dialogs"; -import { useStore } from "@nanostores/react"; -import { TemplateCard } from "./template-card"; -import { $permissions } from "~/shared/nano-states"; - -export const TemplatesGrid = ({ - projects, -}: { - projects: Array; -}) => { - return ( - - - {projects.map((project) => { - return ( - - - - ); - })} - - - ); -}; - -export const Templates = ({ - projects, - currentWorkspaceId, -}: { - projects: Array; - currentWorkspaceId?: string; -}) => { - const permissions = useStore($permissions); - return ( -
    -
    - - Starter templates - - {permissions.canCreateProject && ( - - - - )} -
    - - - -
    - ); -}; diff --git a/apps/builder/app/dashboard/welcome/welcome.tsx b/apps/builder/app/dashboard/welcome/welcome.tsx index 8c1da3c0a7cd..7ecd093236fe 100644 --- a/apps/builder/app/dashboard/welcome/welcome.tsx +++ b/apps/builder/app/dashboard/welcome/welcome.tsx @@ -1,46 +1,116 @@ -import { Flex, Text, theme } from "@webstudio-is/design-system"; -import type { DashboardProject } from "@webstudio-is/dashboard"; +import { Flex, Text, Link, buttonStyle } from "@webstudio-is/design-system"; +import { + YoutubeIcon, + ContentIcon, + DiscordIcon, + XLogoIcon, + BlueskyIcon, + FacebookIcon, + RedditIcon, +} from "@webstudio-is/icons"; import { useStore } from "@nanostores/react"; -import { Header, Main } from "../shared/layout"; +import { Main } from "../shared/layout"; import { CreateProject } from "../projects/project-dialogs"; -import { TemplatesGrid } from "../templates/templates"; import { $permissions } from "~/shared/nano-states"; +const guideItems = [ + { + icon: , + label: "Watch video tutorials", + href: "https://wstd.us/101", + }, + { + icon: , + label: "Read the docs", + href: "https://docs.webstudio.is/", + }, + { + icon: , + label: "Join the community on Discord", + href: "https://wstd.us/community", + }, +]; + +const socialItems = [ + { icon: , label: "X", href: "https://x.com/getwebstudio" }, + { + icon: , + label: "Bluesky", + href: "https://bsky.app/profile/webstudio.is", + }, + { + icon: , + label: "Facebook", + href: "https://www.facebook.com/getwebstudio1/", + }, + { + icon: , + label: "Reddit", + href: "https://www.reddit.com/r/webstudio/", + }, +]; + export const Welcome = ({ - projects, currentWorkspaceId, }: { - projects: Array; currentWorkspaceId?: string; }) => { const permissions = useStore($permissions); return (
    -
    - - - Welcome to Webstudio! - - -
    - - - Start with a template - {permissions.canCreateProject ? " or" : ""} - + + Welcome! + + + + + Start from a template + {permissions.canCreateProject && ( )} - + + + {guideItems.map(({ icon, label, href }) => ( + + {icon} + + {label} + + + ))} + + Follow for updates on: + {socialItems.map(({ icon, label, href }) => ( + + {icon} + + ))} + +
    ); diff --git a/apps/builder/app/env/env.server.ts b/apps/builder/app/env/env.server.ts index 834d7a2eef56..1599cae10f3a 100644 --- a/apps/builder/app/env/env.server.ts +++ b/apps/builder/app/env/env.server.ts @@ -44,17 +44,6 @@ const envSchema = z.object({ ENTRI_APPLICATION_ID: z.string().default("webstudio"), ENTRI_SECRET: z.string().optional(), - // Projects as templates in dashboard (comma-separated IDs) - PROJECT_TEMPLATES: z - .string() - .default("") - .transform((val) => - val - .split(",") - .map((id) => id.trim()) - .filter(Boolean) - ), - PUBLISHER_HOST: z.string().default("wstd.work"), STAGING_USERNAME: z.string().default("admin"), @@ -114,7 +103,6 @@ const rawEnv = { RESIZE_ORIGIN: process.env.RESIZE_ORIGIN, ENTRI_APPLICATION_ID: process.env.ENTRI_APPLICATION_ID, ENTRI_SECRET: process.env.ENTRI_SECRET, - PROJECT_TEMPLATES: process.env.PROJECT_TEMPLATES, PUBLISHER_HOST: process.env.PUBLISHER_HOST, STAGING_USERNAME: process.env.STAGING_USERNAME, STAGING_PASSWORD: process.env.STAGING_PASSWORD, diff --git a/apps/builder/app/routes/_ui.dashboard.templates.tsx b/apps/builder/app/routes/_ui.dashboard.templates.tsx deleted file mode 100644 index 28568f1d55a3..000000000000 --- a/apps/builder/app/routes/_ui.dashboard.templates.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { lazy } from "react"; -import { type MetaFunction } from "@remix-run/react"; -import { ClientOnly } from "~/shared/client-only"; -export { ErrorBoundary } from "~/shared/error/error-boundary"; - -export const meta = () => { - const metas: ReturnType = []; - - metas.push({ title: "Webstudio Dashboard | Templates" }); - - return metas; -}; - -const Dashboard = lazy(async () => { - const { Dashboard } = await import("~/dashboard/index.client"); - return { default: Dashboard }; -}); - -const DashboardRoute = () => { - return ( - - - - ); -}; - -export default DashboardRoute; diff --git a/apps/builder/app/routes/_ui.dashboard.tsx b/apps/builder/app/routes/_ui.dashboard.tsx index a94d71a20200..76ab0be5edc0 100644 --- a/apps/builder/app/routes/_ui.dashboard.tsx +++ b/apps/builder/app/routes/_ui.dashboard.tsx @@ -23,7 +23,6 @@ import { notification as notificationApi } from "@webstudio-is/project/index.ser import type { Role } from "@webstudio-is/project"; import { parseBuilderUrl } from "@webstudio-is/http-client"; import { dashboardProjectRouter } from "@webstudio-is/dashboard/index.server"; -import { db as dashboardDb } from "@webstudio-is/dashboard/index.server"; import { builderUrl, isDashboard, loginPath } from "~/shared/router-utils"; import { getSetting, setSetting } from "~/builder/shared/client-settings"; import env from "~/env/env.server"; @@ -129,12 +128,6 @@ const loadDashboardData = async (request: Request) => { ? [] : await dashboardProjectCaller(context).findMany(findManyInput); - const templates = await dashboardDb.db.findManyByIds( - env.PROJECT_TEMPLATES, - context, - { skipApprovalCheck: true } - ); - const notifications = await notificationApi.list(context); return { @@ -144,7 +137,6 @@ const loadDashboardData = async (request: Request) => { planFeatures, purchases, projects, - templates, workspaces, currentWorkspaceId, role, @@ -198,7 +190,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { purchases, origin, projects, - templates, workspaces, currentWorkspaceId, role, @@ -210,7 +201,6 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { return { user, projects, - templates, planFeatures, purchases, publisherHost: env.PUBLISHER_HOST, diff --git a/apps/builder/app/shared/help.tsx b/apps/builder/app/shared/help.tsx index 57b6a74ecf4c..4c3cee52ff5b 100644 --- a/apps/builder/app/shared/help.tsx +++ b/apps/builder/app/shared/help.tsx @@ -1,4 +1,31 @@ -import { ContentIcon, DiscordIcon, YoutubeIcon } from "@webstudio-is/icons"; +import { + ContentIcon, + DiscordIcon, + YoutubeIcon, + XLogoIcon, + BlueskyIcon, + FacebookIcon, + RedditIcon, +} from "@webstudio-is/icons"; + +export const socialLinks = [ + { label: "X", url: "https://x.com/getwebstudio", icon: }, + { + label: "Bluesky", + url: "https://bsky.app/profile/webstudio.is", + icon: , + }, + { + label: "Facebook", + url: "https://www.facebook.com/webstudiois", + icon: , + }, + { + label: "Reddit", + url: "https://www.reddit.com/r/webstudio/", + icon: , + }, +] as const; export const help = [ { diff --git a/packages/dashboard/src/db/projects.ts b/packages/dashboard/src/db/projects.ts index 06f7582fbd5f..7922c23e4314 100644 --- a/packages/dashboard/src/db/projects.ts +++ b/packages/dashboard/src/db/projects.ts @@ -213,8 +213,7 @@ export const countByUserId = async ({ export const findManyByIds = async ( projectIds: string[], - context: AppContext, - { skipApprovalCheck = false }: { skipApprovalCheck?: boolean } = {} + context: AppContext ) => { if (projectIds.length === 0) { return []; @@ -231,16 +230,12 @@ export const findManyByIds = async ( .in("id", projectIds) .eq("isDeleted", false); - // PROJECT_TEMPLATES IDs are admin-curated via env var and skip approval. - // Other callers require ownership or marketplace approval. - if (skipApprovalCheck === false) { - if (userId !== undefined) { - query = query.or( - `userId.eq.${userId},marketplaceApprovalStatus.eq.APPROVED` - ); - } else { - query = query.eq("marketplaceApprovalStatus", "APPROVED"); - } + if (userId !== undefined) { + query = query.or( + `userId.eq.${userId},marketplaceApprovalStatus.eq.APPROVED` + ); + } else { + query = query.eq("marketplaceApprovalStatus", "APPROVED"); } const data = await query diff --git a/packages/icons/icons/bluesky.svg b/packages/icons/icons/bluesky.svg new file mode 100644 index 000000000000..1688b226c682 --- /dev/null +++ b/packages/icons/icons/bluesky.svg @@ -0,0 +1,12 @@ + + + diff --git a/packages/icons/icons/facebook.svg b/packages/icons/icons/facebook.svg new file mode 100644 index 000000000000..81f07fe051e3 --- /dev/null +++ b/packages/icons/icons/facebook.svg @@ -0,0 +1,12 @@ + + + diff --git a/packages/icons/icons/linkedin.svg b/packages/icons/icons/linkedin.svg new file mode 100644 index 000000000000..d8b98357c775 --- /dev/null +++ b/packages/icons/icons/linkedin.svg @@ -0,0 +1,12 @@ + + + diff --git a/packages/icons/icons/reddit.svg b/packages/icons/icons/reddit.svg new file mode 100644 index 000000000000..3cfc4ebce335 --- /dev/null +++ b/packages/icons/icons/reddit.svg @@ -0,0 +1,14 @@ + + + diff --git a/packages/icons/src/__generated__/components.tsx b/packages/icons/src/__generated__/components.tsx index d9a6ee7dbec4..74b895fd105d 100644 --- a/packages/icons/src/__generated__/components.tsx +++ b/packages/icons/src/__generated__/components.tsx @@ -1171,6 +1171,28 @@ export const BlockquoteIcon: IconComponent = forwardRef( ); BlockquoteIcon.displayName = "BlockquoteIcon"; +export const BlueskyIcon: IconComponent = forwardRef( + ({ fill = "none", size = 16, ...props }, forwardedRef) => { + return ( + + + + ); + } +); +BlueskyIcon.displayName = "BlueskyIcon"; + export const BodyIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( @@ -2636,6 +2658,28 @@ export const EyedropperIcon: IconComponent = forwardRef( ); EyedropperIcon.displayName = "EyedropperIcon"; +export const FacebookIcon: IconComponent = forwardRef( + ({ fill = "none", size = 16, ...props }, forwardedRef) => { + return ( + + + + ); + } +); +FacebookIcon.displayName = "FacebookIcon"; + export const FolderIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( @@ -3439,6 +3483,28 @@ export const LinkIcon: IconComponent = forwardRef( ); LinkIcon.displayName = "LinkIcon"; +export const LinkedinIcon: IconComponent = forwardRef( + ({ fill = "none", size = 16, ...props }, forwardedRef) => { + return ( + + + + ); + } +); +LinkedinIcon.displayName = "LinkedinIcon"; + export const ListItemIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( @@ -4401,6 +4467,30 @@ export const RangeCoverIcon: IconComponent = forwardRef( ); RangeCoverIcon.displayName = "RangeCoverIcon"; +export const RedditIcon: IconComponent = forwardRef( + ({ fill = "none", size = 16, ...props }, forwardedRef) => { + return ( + + + + ); + } +); +RedditIcon.displayName = "RedditIcon"; + export const RefreshCcwIcon: IconComponent = forwardRef( ({ fill = "none", size = 16, ...props }, forwardedRef) => { return ( diff --git a/packages/icons/src/__generated__/svg.ts b/packages/icons/src/__generated__/svg.ts index 91b62917a0e9..4f369bd0b179 100644 --- a/packages/icons/src/__generated__/svg.ts +++ b/packages/icons/src/__generated__/svg.ts @@ -84,6 +84,8 @@ export const BellIcon = ``; +export const BlueskyIcon = ``; + export const BodyIcon = ``; export const BoldIcon = ``; @@ -198,6 +200,8 @@ export const EyeOpenIcon = ``; +export const FacebookIcon = ``; + export const FolderIcon = ``; export const FooterIcon = ``; @@ -256,6 +260,8 @@ export const Link2Icon = ``; +export const LinkedinIcon = ``; + export const ListItemIcon = ``; export const ListViewIcon = ``; @@ -326,6 +332,8 @@ export const RangeContainIcon = ``; +export const RedditIcon = ``; + export const RefreshCcwIcon = ``; export const RefreshIcon = ``; From f0bc73bdac9d8b8fb56dc39029acb715d5ed41e1 Mon Sep 17 00:00:00 2001 From: Oleg Isonen Date: Sat, 25 Apr 2026 19:25:34 +0100 Subject: [PATCH 4/4] feat: Onboard video in dashboard (#5725) --- apps/builder/app/dashboard/dashboard.tsx | 2 +- .../builder/app/dashboard/welcome/welcome.tsx | 80 +++---------------- apps/builder/app/shared/help.tsx | 6 ++ 3 files changed, 17 insertions(+), 71 deletions(-) diff --git a/apps/builder/app/dashboard/dashboard.tsx b/apps/builder/app/dashboard/dashboard.tsx index 29beb5d1099e..aae8b8c3d9b9 100644 --- a/apps/builder/app/dashboard/dashboard.tsx +++ b/apps/builder/app/dashboard/dashboard.tsx @@ -337,7 +337,7 @@ export const Dashboard = () => { /> , - label: "Watch video tutorials", - href: "https://wstd.us/101", - }, - { - icon: , - label: "Read the docs", - href: "https://docs.webstudio.is/", - }, - { - icon: , - label: "Join the community on Discord", - href: "https://wstd.us/community", - }, -]; - -const socialItems = [ - { icon: , label: "X", href: "https://x.com/getwebstudio" }, - { - icon: , - label: "Bluesky", - href: "https://bsky.app/profile/webstudio.is", - }, - { - icon: , - label: "Facebook", - href: "https://www.facebook.com/getwebstudio1/", - }, - { - icon: , - label: "Reddit", - href: "https://www.reddit.com/r/webstudio/", - }, -]; - export const Welcome = ({ currentWorkspaceId, }: { @@ -87,30 +41,16 @@ export const Welcome = ({ )} - - {guideItems.map(({ icon, label, href }) => ( - - {icon} - - {label} - - - ))} - - Follow for updates on: - {socialItems.map(({ icon, label, href }) => ( - - {icon} - - ))} - - +
    ); diff --git a/apps/builder/app/shared/help.tsx b/apps/builder/app/shared/help.tsx index 4c3cee52ff5b..dd637468acdb 100644 --- a/apps/builder/app/shared/help.tsx +++ b/apps/builder/app/shared/help.tsx @@ -5,6 +5,7 @@ import { XLogoIcon, BlueskyIcon, FacebookIcon, + LinkedinIcon, RedditIcon, } from "@webstudio-is/icons"; @@ -20,6 +21,11 @@ export const socialLinks = [ url: "https://www.facebook.com/webstudiois", icon: , }, + { + label: "LinkedIn", + url: "https://www.linkedin.com/company/getwebstudio/", + icon: , + }, { label: "Reddit", url: "https://www.reddit.com/r/webstudio/",