diff --git a/apps/blueprints/src/components/BookChildTree.tsx b/apps/blueprints/src/components/BookChildTree.tsx index a0c9981..41a7611 100644 --- a/apps/blueprints/src/components/BookChildTree.tsx +++ b/apps/blueprints/src/components/BookChildTree.tsx @@ -1,10 +1,18 @@ import { css } from "@emotion/react"; -import Link from "next/link"; -import { ChildTreeBlueprintBookEnriched } from "@factorio-sites/web-utils"; +import Link, { LinkProps } from "next/link"; import { FactorioIcon } from "./FactorioIcon"; import { FactorioCode } from "./FactorioCode"; +import { BlueprintBookData, BlueprintStringData, ChildTree, Icon } from "@factorio-sites/types"; const componentStyles = css` + overflow: hidden; + position: relative; + + .child-tree-wrapper { + height: 480px; + overflow: auto; + } + .blueprint, .book { display: flex; @@ -12,6 +20,7 @@ const componentStyles = css` color: white; padding: 4px 0; &:hover { + cursor: pointer; background: #636363; } } @@ -24,67 +33,164 @@ const componentStyles = css` } `; -interface BookChildTreeProps { - blueprint_book: ChildTreeBlueprintBookEnriched; - base_url: string; - selected_id: string; +interface BlueprintItem { + type: "blueprint"; + id?: string; + name: string; + icons: Icon[]; } +export interface BlueprintBookItem { + type: "blueprint_book"; + id?: string; + name: string; + icons: Icon[]; + children: TreeItem[]; +} + +type TreeItem = BlueprintItem | BlueprintBookItem; + +interface BookChildTreeProps { + book_item: BlueprintBookItem; + base_url?: string; + selected_id: string | null; +} + +const OptionalLink: React.FC = ({ + include, + children, + className, + ...props +}) => { + return include ? ( + + {children} + + ) : ( +
{children}
+ ); +}; + +const InnerBookChildTree: React.FC = ({ book_item, base_url, selected_id }) => { + return ( + <> + + + {book_item.icons && + book_item.icons.map((icon, index) => ( + + ))} + + + + +
+ {book_item.children.map((child, index) => { + return child.type === "blueprint" ? ( + + {child.icons && + child.icons.map((icon, index) => ( + + ))} + + + + + ) : child.type === "blueprint_book" ? ( + + ) : null; + })} +
+ + ); +}; + export const BookChildTree: React.FC = ({ - blueprint_book, + book_item, base_url, selected_id, }) => { return (
-
- - - - {blueprint_book.icons && - blueprint_book.icons.map((icon, index) => ( - - ))} - - - - - -
- {blueprint_book.children.map((child) => { - return child.type === "blueprint" ? ( - - - {child.icons && - child.icons.map((icon, index) => ( - - ))} - - - - - - ) : child.type === "blueprint_book" ? ( - - ) : null; - })} -
+
+
); }; + +const convertBlueprintDataToTree = (data: BlueprintStringData): TreeItem | null => { + if (data.blueprint_book) { + return convertBlueprintBookDataToTree(data.blueprint_book); + } + if (data.blueprint) { + return { + type: "blueprint", + name: data.blueprint.label || "", + icons: data.blueprint.icons, + }; + } + + // console.warn("convertBlueprintBookDataToTree called without a blueprint or blueprint book", data); + return null; +}; + +export const convertBlueprintBookDataToTree = (data: BlueprintBookData): BlueprintBookItem => { + return { + type: "blueprint_book", + name: data.label, + icons: data.icons || [], + children: data.blueprints + .map(convertBlueprintDataToTree) + .filter((x) => x !== null) as TreeItem[], + }; +}; + +export const mergeChildTreeWithTreeItem = ( + tree_item: TreeItem, + id: string, + child_tree?: ChildTree +): TreeItem => { + if (tree_item.type === "blueprint") { + return { ...tree_item, id }; + } + + return { + ...tree_item, + id, + children: tree_item.children + .map((child, index) => { + const child_tree_item = child_tree?.[index]; + if (child_tree_item?.type === "blueprint_book") { + return mergeChildTreeWithTreeItem(child, child_tree_item.id, child_tree_item.children); + } else if (child_tree_item?.type === "blueprint") { + return mergeChildTreeWithTreeItem(child, child_tree_item.id); + } + + // console.warn("mergeChildTreeWithTreeItem called with invalid child_tree", child_tree_item); + return null; + }) + .filter((x) => x !== null) as TreeItem[], + }; +}; diff --git a/apps/blueprints/src/components/blueprint/BlueprintBook.tsx b/apps/blueprints/src/components/blueprint/BlueprintBook.tsx index 8f69057..0a26d77 100644 --- a/apps/blueprints/src/components/blueprint/BlueprintBook.tsx +++ b/apps/blueprints/src/components/blueprint/BlueprintBook.tsx @@ -9,14 +9,15 @@ import { BlueprintPage, BlueprintStringData, } from "@factorio-sites/types"; -import { - chakraResponsive, - mergeBlueprintDataAndChildTree, - parseBlueprintStringClient, -} from "@factorio-sites/web-utils"; +import { chakraResponsive, parseBlueprintStringClient } from "@factorio-sites/web-utils"; import { Panel } from "../../components/Panel"; import { Markdown } from "../../components/Markdown"; -import { BookChildTree } from "../../components/BookChildTree"; +import { + BlueprintBookItem, + BookChildTree, + convertBlueprintBookDataToTree, + mergeChildTreeWithTreeItem, +} from "../../components/BookChildTree"; import { CopyButton } from "../../components/CopyButton"; import { useUrl } from "../../hooks/url.hook"; import { FavoriteButton } from "./FavoriteButton"; @@ -40,17 +41,6 @@ const StyledBlueptintPage = styled(Grid)` margin-right: 0.5rem; flex-grow: 1; } - - .panel { - &.child-tree { - overflow: hidden; - position: relative; - .child-tree-wrapper { - height: 480px; - overflow: auto; - } - } - } `; const descriptionCss = css({ @@ -111,14 +101,15 @@ export const BlueprintBookSubPage: React.FC = ({ }, []); const bookChildTreeData = useMemo(() => { - if (!mainBookData) return null; - return mergeBlueprintDataAndChildTree(mainBookData, { - id: blueprint_book.id, - name: blueprint_book.label, - type: "blueprint_book", - children: blueprint_book.child_tree, - }); - }, [blueprint_book.child_tree, blueprint_book.id, blueprint_book.label, mainBookData]); + if (mainBookData?.blueprint_book) { + return mergeChildTreeWithTreeItem( + convertBlueprintBookDataToTree(mainBookData.blueprint_book), + blueprint_book.id, + blueprint_book.child_tree + ) as BlueprintBookItem; + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mainBookData]); useEffect(() => { fetch(`/api/string/${selectedHash}`) @@ -164,7 +155,7 @@ export const BlueprintBookSubPage: React.FC = ({ {bookChildTreeData && (
diff --git a/apps/blueprints/src/pages/api/blueprint/create.ts b/apps/blueprints/src/pages/api/blueprint/create.ts index 56e044f..a6f4fc4 100644 --- a/apps/blueprints/src/pages/api/blueprint/create.ts +++ b/apps/blueprints/src/pages/api/blueprint/create.ts @@ -23,7 +23,6 @@ const handler = apiHandler(async (req, res, { session }) => { const errors: Record = {}; if (!title) errors.title = "Required"; - if (!description) errors.description = "Required"; if (!string) errors.string = "Required"; if (!parsed) errors.string = "Not recognised as a blueprint string"; diff --git a/apps/blueprints/src/pages/user/blueprint-create.tsx b/apps/blueprints/src/pages/user/blueprint-create.tsx index 7481709..311a96e 100644 --- a/apps/blueprints/src/pages/user/blueprint-create.tsx +++ b/apps/blueprints/src/pages/user/blueprint-create.tsx @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useEffect, useState } from "react"; import { NextPage } from "next"; import { useRouter } from "next/router"; import { @@ -10,63 +10,128 @@ import { Box, Text, } from "@chakra-ui/react"; -import { Formik, Field, FieldProps } from "formik"; +import { Formik, Field, FieldProps, useFormikContext } from "formik"; import { css } from "@emotion/react"; -import { chakraResponsive } from "@factorio-sites/web-utils"; +import styled from "@emotion/styled"; +import { chakraResponsive, parseBlueprintStringClient } from "@factorio-sites/web-utils"; import { TAGS } from "@factorio-sites/common-utils"; import { Panel } from "../../components/Panel"; -import { validateCreateBlueprintForm } from "../../utils/validate"; +import { + joinValidations, + validateBlueprintString, + validateRequired, + validateTags, +} from "../../utils/validate"; import { ImageEditor } from "../../components/ImageEditor"; import { Select } from "../../components/Select"; import { Button } from "../../components/Button"; import { MDEditor } from "../../components/MDEditor"; import { pageHandler } from "../../utils/page-handler"; +import { BlueprintStringData } from "@factorio-sites/types"; +import { BookChildTree, convertBlueprintBookDataToTree } from "../../components/BookChildTree"; +import { Tooltip } from "../../components/Tooltip"; +import { Markdown } from "../../components/Markdown"; const FieldStyle = css` margin-bottom: 1rem; `; -export const UserBlueprintCreate: NextPage = () => { - const router = useRouter(); +const StyledMarkdown = styled(Markdown)` + max-height: 400px; + margin-bottom: 1rem; + border: 1px solid rgb(226, 232, 240); + border-radius: 4px; + padding: 0.5rem 1rem; +`; + +interface FormValues { + title: string; + description: string; + string: string; + tags: string[]; +} + +const FormContent: React.FC = () => { + const { + values, + handleSubmit, + setFieldValue, + isSubmitting, + status, + } = useFormikContext(); + + const [blueprintData, setBlueprintData] = useState(null); + const [step, setStep] = useState(0); const tagsOptions = TAGS.map((tag) => ({ label: `${tag.category}: ${tag.label}`, value: tag.value, })); + const description = + blueprintData?.blueprint?.description || blueprintData?.blueprint_book?.description || ""; + + useEffect(() => { + if (values.string) { + const data = parseBlueprintStringClient(values.string); + if (data) { + setBlueprintData(data); + setFieldValue("title", data.blueprint?.label || data.blueprint_book?.label || ""); + return; + } + } + setBlueprintData(null); + }, [values.string, setFieldValue]); + + const onClickNext = () => { + if (blueprintData) { + setStep(1); + } + }; + return ( - { - setStatus(""); - - const result = await fetch("/api/blueprint/create", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify(values), - }).then((res) => res.json()); - - if (result.status) { - setSubmitting(false); - setStatus(result.status); - } else if (result.errors) { - setSubmitting(false); - setErrors(result.errors); - } else if (result.success) { - router.push(`/blueprint/${result.id}`); - } - }} + - {({ isSubmitting, handleSubmit, status, values, errors, setFieldValue }) => ( - - -
- + + + {step === 0 ? ( + <> + + {({ field, meta }: FieldProps) => ( + + Blueprint string + + {meta.error} + + )} + + + + {status && {status}} + + + ) : ( + <> + {({ field, meta }: FieldProps) => ( { isInvalid={meta.touched && !!meta.error} css={FieldStyle} > - Description + + Description{" "} + + setFieldValue("description", value)} @@ -99,13 +167,20 @@ export const UserBlueprintCreate: NextPage = () => { )} - + {description && ( + <> +
Blueprint description
+ {description} + + )} + + {({ field, meta }: FieldProps) => ( Tags - {meta.error} - - )} - - + {status && {status}} - -
- - - {values.string && !errors.string && ( - - )} - - -
- )} + + )} + + + + + {blueprintData?.blueprint_book ? ( + + ) : values.string ? ( + + ) : null} + + +
+ ); +}; + +export const UserBlueprintCreate: NextPage = () => { + const router = useRouter(); + + return ( + { + setStatus(""); + + const result = await fetch("/api/blueprint/create", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(values), + }).then((res) => res.json()); + + if (result.status) { + setSubmitting(false); + setStatus(result.status); + } else if (result.errors) { + setSubmitting(false); + setErrors(result.errors); + } else if (result.success) { + router.push(`/blueprint/${result.id}`); + } + }} + > + ); }; diff --git a/apps/blueprints/src/pages/user/blueprints.tsx b/apps/blueprints/src/pages/user/blueprints.tsx index 3587580..39c25bb 100644 --- a/apps/blueprints/src/pages/user/blueprints.tsx +++ b/apps/blueprints/src/pages/user/blueprints.tsx @@ -27,6 +27,10 @@ export const UserBlueprints: NextPage = ({ blueprints: blue if (!blueprints) return null; const deleteBlueprint = async (id: string) => { + if (!window.confirm("Are you sure you want to delete this blueprint?")) { + return; + } + setDeleteId(id); try { await fetch(`/api/blueprint/delete/${id}`, { method: "DELETE" }); diff --git a/apps/blueprints/src/utils/validate.ts b/apps/blueprints/src/utils/validate.ts index d0ba143..fbe7126 100644 --- a/apps/blueprints/src/utils/validate.ts +++ b/apps/blueprints/src/utils/validate.ts @@ -15,6 +15,12 @@ export const validateRequired = (value: string) => { } }; +export const validateTags = (value: string[]) => { + if (!Array.isArray(value)) { + return "Invalid value"; + } +}; + const filterUndefined = (errors: Record): Record => { return Object.keys(errors) .filter((key) => errors[key] !== undefined) @@ -75,41 +81,28 @@ export const validateUserForm = (auth: AuthContextProps) => (values: UserFormVal return filterUndefined(errors); }; -interface CreateBlueprintValues { - title: string; - description: string; - string: string; - tags: string[]; -} - -export const validateCreateBlueprintForm = (values: CreateBlueprintValues) => { - const errors = {} as Record; - errors.title = validateRequired(values.title); - errors.string = validateRequired(values.string); - - if (values.string) { - console.log(parseBlueprintStringClient(values.string)); +export const joinValidations = (...validations: Array<(value: T) => string | undefined>) => ( + value: T +): string | undefined => { + for (let i = 0; i < validations.length; i++) { + const error = validations[i](value); + if (error) return error; } - - // If string is set also validate it to be parsable - if (!errors.string && !parseBlueprintStringClient(values.string)) { - errors.string = "Not recognised as a blueprint string"; - } - - if (!Array.isArray(values.tags)) { - errors.tags = "Invalid tags value"; - } - - return filterUndefined(errors); }; export const validateBlueprintString = (value: string) => { if (value) { const parsed = parseBlueprintStringClient(value); - console.log(parsed); + console.log({ parsed }); if (!parsed) { return "Not recognised as a blueprint string"; } + + if (!parsed.blueprint && !parsed.blueprint_book) { + return "Must have a blueprint or blueprint book"; + } + } else { + return "Not recognised as a blueprint string"; } }; diff --git a/libs/types/src/lib/data-models.ts b/libs/types/src/lib/data-models.ts index 2335178..dcc2b98 100644 --- a/libs/types/src/lib/data-models.ts +++ b/libs/types/src/lib/data-models.ts @@ -1,4 +1,5 @@ import { Signal } from "./blueprint-string"; + export interface ChildTreeBlueprint { type: "blueprint"; id: string;