Merge branch 'main' into 252-fonts-manager-and-fonts-selector
No files matched your search
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 7.0 KiB |
|
Before Width: | Height: | Size: 6.3 KiB After Width: | Height: | Size: 6.3 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.8 KiB |
|
Before Width: | Height: | Size: 8.7 KiB After Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 19 KiB |
|
Before Width: | Height: | Size: 6.9 KiB After Width: | Height: | Size: 6.8 KiB |
@@ -4,31 +4,27 @@ import { Cross1Icon } from "@webstudio-is/icons";
|
||||
|
||||
type HeaderProps = {
|
||||
title: string;
|
||||
isClosable?: boolean;
|
||||
onClose?: () => void;
|
||||
suffix: React.ReactNode;
|
||||
};
|
||||
|
||||
export const Header = ({ title, isClosable = true, onClose }: HeaderProps) => {
|
||||
export const Header = ({ title, suffix }: HeaderProps) => {
|
||||
return (
|
||||
<>
|
||||
<Flex
|
||||
css={{ height: 40, paddingLeft: "$3" }}
|
||||
css={{ height: 40, paddingLeft: "$3", flexShrink: 0 }}
|
||||
align="center"
|
||||
justify="between"
|
||||
>
|
||||
<Text variant="title">{title}</Text>
|
||||
{isClosable && (
|
||||
<IconButton
|
||||
onClick={() => onClose?.()}
|
||||
size="1"
|
||||
css={{ marginRight: "$2" }}
|
||||
aria-label="Close"
|
||||
>
|
||||
<Cross1Icon />
|
||||
</IconButton>
|
||||
)}
|
||||
{suffix && <Flex css={{ marginRight: "$1" }}>{suffix}</Flex>}
|
||||
</Flex>
|
||||
<Separator css={{ height: 2 }} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const CloseButton = ({ onClick }: { onClick: () => void }) => (
|
||||
<IconButton onClick={onClick} size="2" aria-label="Close">
|
||||
<Cross1Icon />
|
||||
</IconButton>
|
||||
);
|
||||
@@ -4,7 +4,7 @@ import { type Publish } from "~/shared/pubsub";
|
||||
import { useCallback } from "react";
|
||||
import { useSelectedInstanceData } from "~/designer/shared/nano-states";
|
||||
import { useRootInstance } from "~/shared/nano-states";
|
||||
import { Header } from "../header";
|
||||
import { Header, CloseButton } from "../header";
|
||||
import { InstanceTree } from "~/designer/shared/tree";
|
||||
|
||||
declare module "~/shared/pubsub" {
|
||||
@@ -81,7 +81,10 @@ export const Navigator = ({ publish, isClosable, onClose }: NavigatorProps) => {
|
||||
if (rootInstance === undefined) return null;
|
||||
return (
|
||||
<Flex css={{ height: "100%", flexDirection: "column" }}>
|
||||
<Header title="Navigator" onClose={onClose} isClosable={isClosable} />
|
||||
<Header
|
||||
title="Navigator"
|
||||
suffix={isClosable && <CloseButton onClick={() => onClose?.()} />}
|
||||
/>
|
||||
<Flex css={{ flexGrow: 1, flexDirection: "column" }}>
|
||||
<InstanceTree
|
||||
root={rootInstance}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ImageIcon } from "@webstudio-is/icons";
|
||||
import { ImageManager } from "~/designer/shared/image-manager";
|
||||
import { TabName } from "../../types";
|
||||
import { Header } from "../../lib/header";
|
||||
import { Header, CloseButton } from "../../lib/header";
|
||||
|
||||
export const TabContent = ({
|
||||
onSetActiveTab,
|
||||
@@ -12,9 +12,7 @@ export const TabContent = ({
|
||||
<>
|
||||
<Header
|
||||
title="Assets"
|
||||
onClose={() => {
|
||||
onSetActiveTab("none");
|
||||
}}
|
||||
suffix={<CloseButton onClick={() => onSetActiveTab("none")} />}
|
||||
/>
|
||||
<ImageManager />
|
||||
</>
|
||||
|
||||
@@ -9,7 +9,7 @@ import { createInstance } from "~/shared/tree-utils";
|
||||
import type { TabName } from "../../types";
|
||||
import { ComponentThumb } from "./component-thumb";
|
||||
import { useCanvasRect, useZoom } from "~/designer/shared/nano-states";
|
||||
import { Header } from "../../lib/header";
|
||||
import { Header, CloseButton } from "../../lib/header";
|
||||
|
||||
const componentNames = (
|
||||
Object.keys(components) as Array<Instance["component"]>
|
||||
@@ -132,17 +132,15 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex css={{ height: "100%", flexDirection: "column" }}>
|
||||
<Header
|
||||
title="Add"
|
||||
onClose={() => {
|
||||
onSetActiveTab("none");
|
||||
}}
|
||||
suffix={<CloseButton onClick={() => onSetActiveTab("none")} />}
|
||||
/>
|
||||
<Flex
|
||||
gap="1"
|
||||
wrap="wrap"
|
||||
css={{ padding: "$1" }}
|
||||
css={{ padding: "$1", overflow: "auto" }}
|
||||
ref={useDragHandlers.rootRef}
|
||||
>
|
||||
{componentNames.map((component: Instance["component"]) => (
|
||||
@@ -160,7 +158,7 @@ export const TabContent = ({ publish, onSetActiveTab }: TabContentProps) => {
|
||||
))}
|
||||
{dragComponent && <DragLayer component={dragComponent} point={point} />}
|
||||
</Flex>
|
||||
</>
|
||||
</Flex>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,14 @@
|
||||
import { TreeNodeLabel, TreeNode } from "@webstudio-is/design-system";
|
||||
import {
|
||||
TreeNodeLabel,
|
||||
TreeNode,
|
||||
IconButton,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { type Publish } from "~/shared/pubsub";
|
||||
import { PageIcon } from "@webstudio-is/icons";
|
||||
import { NewPageIcon, PageIcon } from "@webstudio-is/icons";
|
||||
import type { TabName } from "../../types";
|
||||
import { Header } from "../../lib/header";
|
||||
import { CloseButton, Header } from "../../lib/header";
|
||||
import { type Page, type Pages } from "@webstudio-is/project";
|
||||
import { useMemo } from "react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { type Config } from "~/config";
|
||||
import {
|
||||
@@ -12,6 +16,8 @@ import {
|
||||
usePages,
|
||||
useProject,
|
||||
} from "~/designer/shared/nano-states";
|
||||
import { SettingsPanel } from "./settings-panel";
|
||||
import { NewPageSettings } from "./settings";
|
||||
|
||||
type TabContentProps = {
|
||||
onSetActiveTab: (tabName: TabName) => void;
|
||||
@@ -60,20 +66,24 @@ const staticTreeProps = {
|
||||
}
|
||||
|
||||
return (
|
||||
<TreeNodeLabel
|
||||
isSelected={props.isSelected}
|
||||
text={props.data.data.name}
|
||||
/>
|
||||
<>
|
||||
<TreeNodeLabel
|
||||
isSelected={props.isSelected}
|
||||
text={props.data.data.name}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
const PagesPanel = ({
|
||||
onClose,
|
||||
onNewPage,
|
||||
onSelect,
|
||||
selectedPageId,
|
||||
}: {
|
||||
onClose: () => void;
|
||||
onClose?: () => void;
|
||||
onNewPage?: () => void;
|
||||
onSelect: (pageId: string) => void;
|
||||
selectedPageId: string;
|
||||
}) => {
|
||||
@@ -86,7 +96,23 @@ const PagesPanel = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header title="Pages" onClose={onClose} />
|
||||
<Header
|
||||
title="Pages"
|
||||
suffix={
|
||||
<>
|
||||
{onNewPage && (
|
||||
<IconButton
|
||||
size="2"
|
||||
onClick={() => onNewPage()}
|
||||
aria-label="New Page"
|
||||
>
|
||||
<NewPageIcon />
|
||||
</IconButton>
|
||||
)}
|
||||
{onClose && <CloseButton onClick={onClose} />}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
<TreeNode
|
||||
hideRoot
|
||||
selectedItemId={selectedPageId}
|
||||
@@ -110,16 +136,31 @@ export const TabContent = (props: TabContentProps) => {
|
||||
navigate(`${props.config.designerPath}/${project.id}?pageId=${pageId}`);
|
||||
};
|
||||
|
||||
if (currentPageId === undefined) {
|
||||
const [newPageOpen, setNewPageOpen] = useState(false);
|
||||
|
||||
if (currentPageId === undefined || project === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<PagesPanel
|
||||
onClose={() => props.onSetActiveTab("none")}
|
||||
onSelect={handleSelect}
|
||||
selectedPageId={currentPageId}
|
||||
/>
|
||||
<>
|
||||
<PagesPanel
|
||||
onClose={() => props.onSetActiveTab("none")}
|
||||
onNewPage={() => setNewPageOpen((current) => !current)}
|
||||
onSelect={handleSelect}
|
||||
selectedPageId={currentPageId}
|
||||
/>
|
||||
<SettingsPanel isOpen={newPageOpen}>
|
||||
<NewPageSettings
|
||||
projectId={project.id}
|
||||
onClose={() => setNewPageOpen(false)}
|
||||
onSuccess={(page) => {
|
||||
setNewPageOpen(false);
|
||||
handleSelect(page.id);
|
||||
}}
|
||||
/>
|
||||
</SettingsPanel>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
styled,
|
||||
keyframes,
|
||||
Collapsible,
|
||||
Box,
|
||||
} from "@webstudio-is/design-system";
|
||||
|
||||
const CollapsibleRoot = styled(Collapsible.Root, {
|
||||
position: "absolute",
|
||||
left: "100%",
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
marginLeft: 1,
|
||||
});
|
||||
|
||||
const openKeyframes = keyframes({
|
||||
from: { width: 0 },
|
||||
to: { width: "var(--radix-collapsible-content-width)" },
|
||||
});
|
||||
|
||||
const closeKeyframes = keyframes({
|
||||
from: { width: "var(--radix-collapsible-content-width)" },
|
||||
to: { width: 0 },
|
||||
});
|
||||
|
||||
const CollapsibleContent = styled(Collapsible.Content, {
|
||||
overflow: "hidden",
|
||||
flexGrow: "1",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
'&[data-state="open"]': { animation: `${openKeyframes} 200ms ease-out` },
|
||||
'&[data-state="closed"]': { animation: `${closeKeyframes} 200ms ease-out` },
|
||||
});
|
||||
|
||||
export const SettingsPanel = ({
|
||||
children,
|
||||
isOpen,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
isOpen: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<CollapsibleRoot open={isOpen}>
|
||||
<CollapsibleContent>
|
||||
<Box
|
||||
css={{
|
||||
flexGrow: 1,
|
||||
width: 424,
|
||||
background: "$loContrast",
|
||||
borderRight: "1px solid $slate7",
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Box>
|
||||
</CollapsibleContent>
|
||||
</CollapsibleRoot>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
IconButton,
|
||||
Button,
|
||||
Box,
|
||||
Label,
|
||||
TextField,
|
||||
useId,
|
||||
styled,
|
||||
Flex,
|
||||
toast,
|
||||
InputErrorsTooltip,
|
||||
} from "@webstudio-is/design-system";
|
||||
import { useFetcher } from "@remix-run/react";
|
||||
import { ChevronDoubleLeftIcon } from "@webstudio-is/icons";
|
||||
import type { ZodError } from "zod";
|
||||
import { Header } from "../../lib/header";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { type Page } from "@webstudio-is/project";
|
||||
|
||||
const Group = styled(Flex, {
|
||||
marginBottom: "$3",
|
||||
gap: "$2",
|
||||
defaultVariants: { direction: "column" },
|
||||
});
|
||||
|
||||
export type CreatePageData =
|
||||
| { ok: true; page: Page }
|
||||
| { errors: string | ZodError["formErrors"] };
|
||||
|
||||
export const NewPageSettings = ({
|
||||
onClose,
|
||||
onSuccess,
|
||||
projectId,
|
||||
}: {
|
||||
onClose?: () => void;
|
||||
onSuccess?: (page: Page) => void;
|
||||
projectId: string;
|
||||
}) => {
|
||||
const nameFieldId = useId();
|
||||
const pathFieldId = useId();
|
||||
|
||||
const fetcher = useFetcher<CreatePageData>();
|
||||
|
||||
const [fieldErrors, setFieldErrors] = useState<
|
||||
ZodError["formErrors"]["fieldErrors"]
|
||||
>({});
|
||||
|
||||
const isSubmitting = fetcher.state !== "idle";
|
||||
|
||||
const prevFetcher = useRef(fetcher);
|
||||
useEffect(() => {
|
||||
if (prevFetcher.current.state !== fetcher.state) {
|
||||
if (fetcher.state === "idle" && fetcher.data !== undefined) {
|
||||
if ("ok" in fetcher.data) {
|
||||
onSuccess?.(fetcher.data.page);
|
||||
} else {
|
||||
const errors =
|
||||
typeof fetcher.data.errors === "string"
|
||||
? { formErrors: [fetcher.data.errors], fieldErrors: {} }
|
||||
: fetcher.data.errors;
|
||||
|
||||
if (errors.formErrors.length > 0) {
|
||||
for (const message of errors.formErrors) {
|
||||
toast.error(message);
|
||||
}
|
||||
}
|
||||
setFieldErrors(errors.fieldErrors);
|
||||
}
|
||||
}
|
||||
}
|
||||
prevFetcher.current = fetcher;
|
||||
}, [fetcher, onSuccess]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="New Page Settings"
|
||||
suffix={
|
||||
onClose && (
|
||||
<IconButton size="2" onClick={onClose} aria-label="Cancel">
|
||||
<ChevronDoubleLeftIcon />
|
||||
</IconButton>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<Box css={{ overflow: "auto", padding: "$2 $3" }}>
|
||||
<fetcher.Form method="put" action={`/rest/pages/${projectId}`}>
|
||||
<Group>
|
||||
<Label htmlFor={nameFieldId} size={2}>
|
||||
Page Name
|
||||
</Label>
|
||||
<InputErrorsTooltip errors={fieldErrors.name}>
|
||||
<TextField
|
||||
state={fieldErrors.name && "invalid"}
|
||||
id={nameFieldId}
|
||||
name="name"
|
||||
disabled={isSubmitting}
|
||||
onChange={() => {
|
||||
setFieldErrors(({ name, ...rest }) => rest);
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</Group>
|
||||
<Group>
|
||||
<Label htmlFor={pathFieldId} size={2}>
|
||||
Path
|
||||
</Label>
|
||||
<InputErrorsTooltip errors={fieldErrors.path}>
|
||||
<TextField
|
||||
state={fieldErrors.path && "invalid"}
|
||||
id={pathFieldId}
|
||||
name="path"
|
||||
disabled={isSubmitting}
|
||||
onChange={() => {
|
||||
setFieldErrors(({ path, ...rest }) => rest);
|
||||
}}
|
||||
/>
|
||||
</InputErrorsTooltip>
|
||||
</Group>
|
||||
<Group css={{ alignItems: "end" }}>
|
||||
<Button type="submit" variant="green" disabled={isSubmitting}>
|
||||
{isSubmitting ? "Creating..." : "Create"}
|
||||
</Button>
|
||||
</Group>
|
||||
</fetcher.Form>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -77,7 +77,6 @@ export const SidebarLeft = ({ publish, config }: SidebarLeftProps) => {
|
||||
dragAndDropState.isDragging && dragAndDropState.origin === "panel"
|
||||
? "hidden"
|
||||
: "visible",
|
||||
overflow: "auto",
|
||||
}}
|
||||
>
|
||||
<TabContent
|
||||
|
||||
@@ -1,6 +1,89 @@
|
||||
import type { LoaderFunction } from "@remix-run/node";
|
||||
import { LoaderFunction, ActionFunction } from "@remix-run/node";
|
||||
import { db } from "@webstudio-is/project/index.server";
|
||||
import { Pages } from "@webstudio-is/project";
|
||||
import { type Pages, utils } from "@webstudio-is/project";
|
||||
import { zfd } from "zod-form-data";
|
||||
import { z } from "zod";
|
||||
import { CreatePageData } from "~/designer/features/sidebar-left/panels/pages/settings";
|
||||
import { sentryException } from "~/shared/sentry";
|
||||
|
||||
const nonEmptyString = z
|
||||
.string({
|
||||
invalid_type_error: "Can't be empty",
|
||||
required_error: "Can't be empty",
|
||||
})
|
||||
.transform((value) => value.trim())
|
||||
.refine((value) => value !== "", "Can't be empty");
|
||||
|
||||
const CreatePageInput = zfd.formData({
|
||||
name: nonEmptyString,
|
||||
path: nonEmptyString
|
||||
.refine((path) => path !== "/", "Can't be just a /")
|
||||
.transform((path) => (path.endsWith("/") ? path.slice(0, -1) : path))
|
||||
.transform((path) => path.replace(/\/\//g, "/"))
|
||||
.refine(
|
||||
(path) => path === "" || path.startsWith("/"),
|
||||
"Must start with a /"
|
||||
)
|
||||
.refine(
|
||||
(path) => /^[-_a-z0-9\\/]*$/.test(path),
|
||||
"Only a-z, 0-9, -, _ and / are allowed"
|
||||
),
|
||||
});
|
||||
|
||||
const handlePut = async (
|
||||
projectId: string,
|
||||
request: Request
|
||||
): Promise<CreatePageData> => {
|
||||
const result = CreatePageInput.safeParse(await request.formData());
|
||||
if (result.success === false) {
|
||||
return { errors: result.error.formErrors };
|
||||
}
|
||||
const data = result.data;
|
||||
|
||||
const devBuild = await db.build.loadByProjectId(projectId, "dev");
|
||||
|
||||
const existingPage = utils.pages.findByPath(devBuild.pages, data.path);
|
||||
if (existingPage !== undefined) {
|
||||
return {
|
||||
errors: {
|
||||
formErrors: [],
|
||||
fieldErrors: {
|
||||
path: [`Already used for "${existingPage.name}"`],
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const updatedBuild = await db.build.addPage(devBuild.id, data);
|
||||
|
||||
const newPage = utils.pages.findByPath(updatedBuild.pages, data.path);
|
||||
|
||||
if (newPage === undefined) {
|
||||
throw new Error("New page not found");
|
||||
}
|
||||
|
||||
return { ok: true, page: newPage };
|
||||
};
|
||||
|
||||
export const action: ActionFunction = async ({ request, params }) => {
|
||||
try {
|
||||
if (params.projectId === undefined) {
|
||||
throw new Error(`Project ID required`);
|
||||
}
|
||||
|
||||
if (request.method === "PUT") {
|
||||
const result = await handlePut(params.projectId, request);
|
||||
return result;
|
||||
}
|
||||
|
||||
throw new Error(`Method ${request.method} is not supported`);
|
||||
} catch (error) {
|
||||
sentryException({ error });
|
||||
return {
|
||||
errors: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export type ErrorData = {
|
||||
errors: string;
|
||||
@@ -24,11 +107,9 @@ export const loader: LoaderFunction = async ({
|
||||
|
||||
return prodBuild.pages;
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
return {
|
||||
errors: error.message,
|
||||
};
|
||||
}
|
||||
sentryException({ error });
|
||||
return {
|
||||
errors: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
return { errors: "Unexpected error" };
|
||||
};
|
||||
@@ -149,3 +149,13 @@
|
||||
- Check if gap inputs work.
|
||||
- Check if tooltips are present on all icons(including gap input icons).
|
||||
- Check that tooltips have a first time interaction delay.
|
||||
|
||||
1. Creating a new page
|
||||
|
||||
- Open the pages panel
|
||||
- Click on the "New Page" button in the panel header
|
||||
- Enter a page name
|
||||
- Enter a page path
|
||||
- Click on the "Create" button
|
||||
- Check than the page is created and selected as the current page
|
||||
- Repeat the proccess and make sure it doens't allow you to create a page with an empty name or a path that's used for another page
|
||||
@@ -64,7 +64,7 @@ export const WithComplexItems: ComponentStory<typeof Select> = () => {
|
||||
export const Boundaries: ComponentStory<typeof Select> = () => {
|
||||
const items = Array(100)
|
||||
.fill(0)
|
||||
.map((_, i) => `Item ${i}`);
|
||||
.map((_, index) => `Item ${index}`);
|
||||
const [value, setValue] = React.useState(items[0]);
|
||||
return (
|
||||
<Select name="fruit" options={items} value={value} onChange={setValue} />
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import React, { Ref } from "react";
|
||||
import React, { Ref, ComponentProps, Fragment } from "react";
|
||||
import { styled } from "../stitches.config";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
import { Box } from "./box";
|
||||
import { Paragraph } from "./paragraph";
|
||||
import type { CSS } from "../stitches.config";
|
||||
|
||||
type TooltipProps = React.ComponentProps<typeof TooltipPrimitive.Root> &
|
||||
React.ComponentProps<typeof TooltipPrimitive.Content> & {
|
||||
type TooltipProps = ComponentProps<typeof TooltipPrimitive.Root> &
|
||||
ComponentProps<typeof TooltipPrimitive.Content> & {
|
||||
children: React.ReactElement;
|
||||
content: React.ReactNode;
|
||||
multiline?: boolean;
|
||||
@@ -82,3 +82,31 @@ export const Tooltip = React.forwardRef(function TooltipWrapper(
|
||||
</TooltipPrimitive.Root>
|
||||
);
|
||||
});
|
||||
|
||||
export const InputErrorsTooltip = ({
|
||||
errors,
|
||||
children,
|
||||
}: {
|
||||
errors?: string[];
|
||||
children: ComponentProps<typeof Tooltip>["children"];
|
||||
}) => {
|
||||
const content = errors?.map((error, index) => (
|
||||
<Fragment key={index}>
|
||||
{index > 0 && <br />}
|
||||
{error}
|
||||
</Fragment>
|
||||
));
|
||||
return (
|
||||
// We intentionally always pass non empty content to avoid optimization inside Tooltip
|
||||
// where it renders {children} directly if content is empty.
|
||||
// If this optimization accur, the input will remount which will cause focus loss
|
||||
// and current value loss.
|
||||
<Tooltip
|
||||
content={content || " "}
|
||||
open={errors !== undefined && errors.length !== 0}
|
||||
side="right"
|
||||
>
|
||||
{children}
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
@@ -53,8 +53,8 @@ const NestingLines = ({
|
||||
}) =>
|
||||
level > 1 ? (
|
||||
<>
|
||||
{Array.from({ length: level - 1 }, (_, i) => (
|
||||
<NestingLine key={i} isSelected={isSelected} />
|
||||
{Array.from({ length: level - 1 }, (_, index) => (
|
||||
<NestingLine key={index} isSelected={isSelected} />
|
||||
))}
|
||||
</>
|
||||
) : null;
|
||||
|
||||
@@ -20,31 +20,39 @@ export const StressTest = ({ animate }: { animate: boolean }) => {
|
||||
canAcceptChildren: true,
|
||||
children: Array.from(
|
||||
{ length: 100 },
|
||||
(_, i): Item => ({
|
||||
id: `box-${i}`,
|
||||
(_, index): Item => ({
|
||||
id: `box-${index}`,
|
||||
canAcceptChildren: true,
|
||||
children: [
|
||||
{ id: `heading-${i}.0`, canAcceptChildren: false, children: [] },
|
||||
{ id: `paragraph-${i}.0`, canAcceptChildren: false, children: [] },
|
||||
{
|
||||
id: `box-${i}.0`,
|
||||
id: `heading-${index}.0`,
|
||||
canAcceptChildren: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `paragraph-${index}.0`,
|
||||
canAcceptChildren: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `box-${index}.0`,
|
||||
canAcceptChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: `box-${i}.0.0`,
|
||||
id: `box-${index}.0.0`,
|
||||
canAcceptChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: `box-${i}.0.0.0`,
|
||||
id: `box-${index}.0.0.0`,
|
||||
canAcceptChildren: true,
|
||||
children: [
|
||||
{
|
||||
id: `heading-${i}.1`,
|
||||
id: `heading-${index}.1`,
|
||||
canAcceptChildren: false,
|
||||
children: [],
|
||||
},
|
||||
{
|
||||
id: `paragraph-${i}.1`,
|
||||
id: `paragraph-${index}.1`,
|
||||
canAcceptChildren: false,
|
||||
children: [],
|
||||
},
|
||||
@@ -52,7 +60,11 @@ export const StressTest = ({ animate }: { animate: boolean }) => {
|
||||
},
|
||||
],
|
||||
},
|
||||
{ id: `box-${i}.0.1`, canAcceptChildren: true, children: [] },
|
||||
{
|
||||
id: `box-${index}.0.1`,
|
||||
canAcceptChildren: true,
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
@@ -24,7 +24,7 @@ export {
|
||||
export { Card } from "./components/card";
|
||||
export { SimpleToggle } from "./components/simple-toggle";
|
||||
export { ScrollArea } from "./components/scrollbar";
|
||||
export { Tooltip } from "./components/tooltip";
|
||||
export { Tooltip, InputErrorsTooltip } from "./components/tooltip";
|
||||
export { Button } from "./components/button";
|
||||
export { IconButton } from "./components/icon-button";
|
||||
export { Box } from "./components/box";
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronDoubleDownIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M13 2L8 7L3 2M13 9L8 14L3 9"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronDoubleDownIcon.displayName = "ChevronDoubleDownIcon";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronDoubleLeftIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M14 13L9 8L14 3M7 13L2 8L7 3"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronDoubleLeftIcon.displayName = "ChevronDoubleLeftIcon";
|
||||
@@ -0,0 +1,29 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronDoubleRightIcon = React.forwardRef<
|
||||
SVGSVGElement,
|
||||
IconProps
|
||||
>(({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M2 3L7 8L2 13M9 3L14 8L9 13"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
});
|
||||
|
||||
ChevronDoubleRightIcon.displayName = "ChevronDoubleRightIcon";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronDoubleUpIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M3 14L8 9L13 14M3 7L8 2L13 7"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronDoubleUpIcon.displayName = "ChevronDoubleUpIcon";
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronDownIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M4.04008 6.28383C4.29431 6.03037 4.70587 6.031 4.95932 6.28523L7.99985 9.33506L11.0404 6.28523C11.2938 6.031 11.7054 6.03037 11.9596 6.28383C12.2139 6.53728 12.2145 6.94884 11.961 7.20306L8.46017 10.7146C8.33822 10.837 8.17259 10.9057 7.99985 10.9057C7.82712 10.9057 7.66149 10.837 7.53953 10.7146L4.03868 7.20306C3.78523 6.94884 3.78585 6.53728 4.04008 6.28383Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronDownIcon.displayName = "ChevronDownIcon";
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronLeftIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M9.7162 4.04011C9.96966 4.29434 9.96903 4.7059 9.7148 4.95935L6.66497 7.99988L9.7148 11.0404C9.96903 11.2939 9.96966 11.7054 9.7162 11.9597C9.46275 12.2139 9.05119 12.2145 8.79697 11.9611L5.28541 8.4602C5.16308 8.33825 5.09432 8.17262 5.09432 7.99988C5.09432 7.82715 5.16308 7.66152 5.28541 7.53956L8.79697 4.03871C9.05119 3.78526 9.46275 3.78588 9.7162 4.04011Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronLeftIcon.displayName = "ChevronLeftIcon";
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronRightIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M6.2838 11.9599C6.03034 11.7057 6.03097 11.2941 6.2852 11.0407L9.33503 8.00013L6.2852 4.95959C6.03097 4.70614 6.03034 4.29458 6.2838 4.04036C6.53725 3.78613 6.94881 3.7855 7.20303 4.03895L10.7146 7.5398C10.8369 7.66176 10.9057 7.82739 10.9057 8.00013C10.9057 8.17286 10.8369 8.33849 10.7146 8.46045L7.20303 11.9613C6.94881 12.2148 6.53725 12.2141 6.2838 11.9599Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronRightIcon.displayName = "ChevronRightIcon";
|
||||
@@ -0,0 +1,27 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const ChevronUpIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
clipRule="evenodd"
|
||||
d="M11.9599 9.71617C11.7057 9.96963 11.2941 9.969 11.0407 9.71477L8.00015 6.66494L4.95962 9.71477C4.70616 9.969 4.29461 9.96963 4.04038 9.71617C3.78615 9.46272 3.78552 9.05116 4.03897 8.79694L7.53983 5.28538C7.66178 5.16305 7.82741 5.09429 8.00015 5.09429C8.17288 5.09429 8.33851 5.16305 8.46047 5.28538L11.9613 8.79694C12.2148 9.05116 12.2141 9.46272 11.9599 9.71617Z"
|
||||
fill={color}
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
ChevronUpIcon.displayName = "ChevronUpIcon";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const CopyIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M5.6 5.6V3.92C5.6 3.24794 5.6 2.91191 5.73079 2.65521C5.84584 2.42942 6.02942 2.24584 6.25521 2.13079C6.51191 2 6.84794 2 7.52 2H12.08C12.7521 2 13.0881 2 13.3448 2.13079C13.5706 2.24584 13.7542 2.42942 13.8692 2.65521C14 2.91191 14 3.24794 14 3.92V8.48C14 9.15206 14 9.4881 13.8692 9.74479C13.7542 9.97058 13.5706 10.1542 13.3448 10.2692C13.0881 10.4 12.7521 10.4 12.08 10.4H10.4M3.92 14H8.48C9.15206 14 9.48809 14 9.74479 13.8692C9.97058 13.7542 10.1542 13.5706 10.2692 13.3448C10.4 13.0881 10.4 12.7521 10.4 12.08V7.52C10.4 6.84794 10.4 6.51191 10.2692 6.25521C10.1542 6.02942 9.97058 5.84584 9.74479 5.73079C9.48809 5.6 9.15206 5.6 8.48 5.6H3.92C3.24794 5.6 2.91191 5.6 2.65521 5.73079C2.42942 5.84584 2.24584 6.02942 2.13079 6.25521C2 6.51191 2 6.84794 2 7.52V12.08C2 12.7521 2 13.0881 2.13079 13.3448C2.24584 13.5706 2.42942 13.7542 2.65521 13.8692C2.91191 14 3.24794 14 3.92 14Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
CopyIcon.displayName = "CopyIcon";
|
||||
@@ -49,3 +49,14 @@ export { SizeIcon } from "./size";
|
||||
export { UploadIcon } from "./upload";
|
||||
export { GearIcon, gearIconCssVars } from "./gear";
|
||||
export * from "./page";
|
||||
export { CopyIcon } from "./copy";
|
||||
export * from "./new-page";
|
||||
export * from "./new-folder";
|
||||
export * from "./chevron-double-up";
|
||||
export * from "./chevron-double-down";
|
||||
export * from "./chevron-double-left";
|
||||
export * from "./chevron-double-right";
|
||||
export { ChevronUpIcon } from "./chevron-up";
|
||||
export { ChevronDownIcon } from "./chevron-down";
|
||||
export { ChevronLeftIcon } from "./chevron-left";
|
||||
export { ChevronRightIcon } from "./chevron-right";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const NewFolderIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M8.6 5L7.93068 3.66135C7.73804 3.27608 7.64172 3.08344 7.49802 2.9427C7.37095 2.81824 7.21779 2.72358 7.04965 2.66558C6.8595 2.6 6.64412 2.6 6.21337 2.6H3.92C3.24794 2.6 2.91191 2.6 2.65521 2.73079C2.42942 2.84584 2.24584 3.02942 2.13079 3.25521C2 3.5119 2 3.84794 2 4.52V5M2 5H11.12C12.1281 5 12.6321 5 13.0172 5.19619C13.3559 5.36876 13.6312 5.64412 13.8038 5.98282C14 6.36786 14 6.8719 14 7.88V10.52C14 11.5281 14 12.0321 13.8038 12.4172C13.6312 12.7559 13.3559 13.0312 13.0172 13.2038C12.6321 13.4 12.1281 13.4 11.12 13.4H4.88C3.87191 13.4 3.36786 13.4 2.98282 13.2038C2.64413 13.0312 2.36876 12.7559 2.19619 12.4172C2 12.0321 2 11.5281 2 10.52V5ZM8 11V7.4M6.2 9.2H9.8"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NewFolderIcon.displayName = "NewFolderIcon";
|
||||
@@ -0,0 +1,28 @@
|
||||
import * as React from "react";
|
||||
import { IconProps } from "./types";
|
||||
|
||||
export const NewPageIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
{...props}
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M9.19995 2.16172V4.64004C9.19995 4.97607 9.19995 5.14409 9.26535 5.27244C9.32287 5.38533 9.41466 5.47712 9.52756 5.53465C9.6559 5.60004 9.82392 5.60004 10.16 5.60004H12.6383M7.99995 11.6V8M6.19995 9.8H9.79995M9.19995 2H6.07995C5.07186 2 4.56781 2 4.18277 2.19619C3.84408 2.36876 3.56871 2.64413 3.39614 2.98282C3.19995 3.36786 3.19995 3.87191 3.19995 4.88V11.12C3.19995 12.1281 3.19995 12.6321 3.39614 13.0172C3.56871 13.3559 3.84408 13.6312 4.18277 13.8038C4.56781 14 5.07186 14 6.07995 14H9.91995C10.928 14 11.4321 14 11.8171 13.8038C12.1558 13.6312 12.4312 13.3559 12.6038 13.0172C12.8 12.6321 12.8 12.1281 12.8 11.12V5.6L9.19995 2Z"
|
||||
stroke={color}
|
||||
strokeWidth="1.3"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
NewPageIcon.displayName = "NewPageIcon";
|
||||
@@ -5,8 +5,8 @@ export const TrashIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
({ color = "currentColor", ...props }, forwardedRef) => {
|
||||
return (
|
||||
<svg
|
||||
width={16}
|
||||
height={16}
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -14,16 +14,9 @@ export const TrashIcon = React.forwardRef<SVGSVGElement, IconProps>(
|
||||
ref={forwardedRef}
|
||||
>
|
||||
<path
|
||||
d="M3.556 5.222h8.889m-.556 0l-.482 6.746A1.111 1.111 0 0110.3 13H5.701a1.111 1.111 0 01-1.108-1.032L4.11 5.222h7.778zm-2.222 0V3.556A.556.556 0 009.11 3H6.89a.556.556 0 00-.556.556v1.666h3.334z"
|
||||
d="M12.2127 4.9909L11.6908 12.2986C11.6692 12.6023 11.5333 12.8865 11.3105 13.094C11.0878 13.3015 10.7946 13.4168 10.4902 13.4168H5.50923C5.20477 13.4168 4.91163 13.3015 4.68883 13.094C4.46604 12.8865 4.33015 12.6023 4.30853 12.2986L3.78673 4.9909M12.2127 4.9909H9.80525M12.2127 4.9909H12.8145M3.78673 4.9909H6.19413M3.78673 4.9909H3.18488M6.19413 4.9909H9.80525M6.19413 4.9909V3.18535C6.19413 3.02573 6.25754 2.87264 6.37041 2.75977C6.48328 2.64691 6.63637 2.5835 6.79599 2.5835H9.20339C9.36302 2.5835 9.5161 2.64691 9.62897 2.75977C9.74184 2.87264 9.80525 3.02573 9.80525 3.18535V4.9909M6.79604 7.35518V10.9663M9.20345 7.35518V10.9663"
|
||||
stroke={color}
|
||||
strokeWidth={1.25}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.889 7.405v3.333M9.11 7.405v3.333"
|
||||
stroke={color}
|
||||
strokeWidth={1.1}
|
||||
strokeWidth="1.2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
|
||||
@@ -61,10 +61,42 @@ export async function loadByProjectId(
|
||||
return parseBuild(build);
|
||||
}
|
||||
|
||||
export const addPage = async (
|
||||
buildId: Build["id"],
|
||||
data: { name: string; path: string }
|
||||
) => {
|
||||
const build = await loadById(buildId);
|
||||
|
||||
const breakpoints = await db.breakpoints.load(buildId);
|
||||
const tree = await db.tree.create(
|
||||
db.tree.createRootInstance(breakpoints.values)
|
||||
);
|
||||
|
||||
const nextPages = Pages.parse({
|
||||
homePage: build.pages.homePage,
|
||||
pages: [
|
||||
...build.pages.pages,
|
||||
{
|
||||
id: uuid(),
|
||||
name: data.name,
|
||||
path: data.path,
|
||||
title: data.name,
|
||||
meta: {},
|
||||
treeId: tree.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const newBuild = await prisma.build.update({
|
||||
where: { id: buildId },
|
||||
data: { pages: JSON.stringify(nextPages) },
|
||||
});
|
||||
|
||||
return parseBuild(newBuild);
|
||||
};
|
||||
|
||||
const createPages = async (breakpoints: Array<Breakpoint>) => {
|
||||
// const breakpoints = db.breakpoints.getBreakpointsWithId();
|
||||
const tree = await db.tree.create(db.tree.createRootInstance(breakpoints));
|
||||
// await db.breakpoints.create(tree.id, breakpoints);
|
||||
return Pages.parse({
|
||||
homePage: {
|
||||
id: uuid(),
|
||||
|
||||
@@ -9,21 +9,50 @@ export type Project = Omit<BaseProject, "assets"> & {
|
||||
assets?: Array<Asset>;
|
||||
};
|
||||
|
||||
const Page = z.object({
|
||||
const commonPageFields = {
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
path: z.string(),
|
||||
name: z.string().refine((val) => val !== "", "Can't be empty"),
|
||||
title: z.string(),
|
||||
meta: z.record(z.string(), z.string()),
|
||||
treeId: z.string(),
|
||||
} as const;
|
||||
|
||||
const HomePage = z.object({
|
||||
...commonPageFields,
|
||||
path: z
|
||||
.string()
|
||||
.refine((path) => path === "", "Home page path must be empty"),
|
||||
});
|
||||
|
||||
const Page = z.object({
|
||||
...commonPageFields,
|
||||
path: z
|
||||
.string()
|
||||
.refine((path) => path !== "" && path !== "/", "Can't be empty")
|
||||
.refine((path) => path.startsWith("/"), "Must start with a /")
|
||||
.refine((path) => path.endsWith("/") === false, "Can't end with a /")
|
||||
.refine(
|
||||
(path) => path.includes("//") === false,
|
||||
"Can't contain repeating /"
|
||||
)
|
||||
.refine(
|
||||
(path) => /^[-_a-z0-9\\/]*$/.test(path),
|
||||
"Only a-z, 0-9, -, _ and / are allowed"
|
||||
),
|
||||
});
|
||||
|
||||
export type Page = z.infer<typeof Page>;
|
||||
|
||||
export const Pages: z.ZodType<{ homePage: Page; pages: Array<Page> }> =
|
||||
z.object({
|
||||
homePage: Page,
|
||||
pages: z.array(Page),
|
||||
homePage: HomePage,
|
||||
pages: z
|
||||
.array(Page)
|
||||
.refine(
|
||||
(array) =>
|
||||
new Set(array.map((page) => page.path)).size === array.length,
|
||||
"All paths must be unique"
|
||||
),
|
||||
});
|
||||
export type Pages = z.infer<typeof Pages>;
|
||||
|
||||
|
||||