From d5432c0d5cfef9c4268159e45e9019fb49769671 Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 22 Apr 2021 14:00:57 +0200 Subject: [PATCH 1/2] Add book preview to create blueprint page --- .../src/components/BookChildTree.tsx | 214 ++++++++++++---- .../components/blueprint/BlueprintBook.tsx | 43 ++-- .../src/pages/api/blueprint/create.ts | 1 - .../src/pages/user/blueprint-create.tsx | 231 +++++++++++++----- apps/blueprints/src/pages/user/blueprints.tsx | 4 + apps/blueprints/src/utils/validate.ts | 45 ++-- libs/types/src/lib/data-models.ts | 1 + 7 files changed, 366 insertions(+), 173 deletions(-) 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; From b6e09288f7cff5657dc616996744ff3dbaedc68f Mon Sep 17 00:00:00 2001 From: Bart Date: Thu, 22 Apr 2021 22:26:43 +0200 Subject: [PATCH 2/2] Feat: improve blueprint creation, add comments to blueprints --- .gitignore | 2 +- .../20210422130800_add_comments/migration.sql | 21 ++++ apps/blueprints/prisma/schema.prisma | 22 +++- apps/blueprints/src/components/Comments.tsx | 114 ++++++++++++++++++ apps/blueprints/src/components/Tooltip.tsx | 9 ++ .../src/components/blueprint/Blueprint.tsx | 7 +- .../components/blueprint/BlueprintBook.tsx | 7 +- .../src/pages/api/blueprint/comment.ts | 20 +++ .../src/pages/api/blueprint/comments.ts | 17 +++ libs/database/src/lib/data/blueprint.ts | 4 +- libs/database/src/lib/data/comment.ts | 34 ++++++ libs/database/src/lib/data/index.ts | 1 + libs/types/src/lib/data-models.ts | 17 ++- 13 files changed, 259 insertions(+), 16 deletions(-) create mode 100644 apps/blueprints/prisma/migrations/20210422130800_add_comments/migration.sql create mode 100644 apps/blueprints/src/components/Comments.tsx create mode 100644 apps/blueprints/src/pages/api/blueprint/comment.ts create mode 100644 apps/blueprints/src/pages/api/blueprint/comments.ts create mode 100644 libs/database/src/lib/data/comment.ts diff --git a/.gitignore b/.gitignore index 49c59d0..fe0c4e7 100644 --- a/.gitignore +++ b/.gitignore @@ -6,7 +6,7 @@ /out-tsc # dependencies -/node_modules +node_modules # IDEs and editors /.idea diff --git a/apps/blueprints/prisma/migrations/20210422130800_add_comments/migration.sql b/apps/blueprints/prisma/migrations/20210422130800_add_comments/migration.sql new file mode 100644 index 0000000..94c41e2 --- /dev/null +++ b/apps/blueprints/prisma/migrations/20210422130800_add_comments/migration.sql @@ -0,0 +1,21 @@ +-- CreateTable +CREATE TABLE "comment" ( + "id" UUID NOT NULL, + "blueprint_page_id" UUID NOT NULL, + "user_id" UUID NOT NULL, + "body" TEXT NOT NULL, + "reply_comment_id" UUID, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + PRIMARY KEY ("id") +); + +-- AddForeignKey +ALTER TABLE "comment" ADD FOREIGN KEY ("blueprint_page_id") REFERENCES "blueprint_page"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment" ADD FOREIGN KEY ("user_id") REFERENCES "user"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "comment" ADD FOREIGN KEY ("reply_comment_id") REFERENCES "comment"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/apps/blueprints/prisma/schema.prisma b/apps/blueprints/prisma/schema.prisma index e39a899..f1a9901 100644 --- a/apps/blueprints/prisma/schema.prisma +++ b/apps/blueprints/prisma/schema.prisma @@ -16,7 +16,7 @@ enum enum_user_role { model blueprint { id String @id @default(uuid()) @db.Uuid label String? @db.VarChar(255) - description String? + description String? @db.Text game_version String? @db.VarChar(255) blueprint_hash String @unique @db.VarChar(40) image_hash String @db.VarChar(40) @@ -32,7 +32,7 @@ model blueprint { model blueprint_book { id String @id @default(uuid()) @db.Uuid label String? @db.VarChar(255) - description String? + description String? @db.Text child_tree Json @db.JsonB blueprint_hash String @unique @db.VarChar(40) is_modded Boolean @@ -51,7 +51,7 @@ model blueprint_page { blueprint_book_id String? @unique @db.Uuid blueprint_ids String[] @db.Uuid title String @db.VarChar(255) - description_markdown String? + description_markdown String? @db.Text tags String[] @db.VarChar(255) image_hash String @db.VarChar(40) factorioprints_id String? @unique @db.VarChar(255) @@ -61,6 +61,7 @@ model blueprint_page { blueprint blueprint? @relation(fields: [blueprint_id], references: [id]) user_favorites user_favorites[] user user? @relation(fields: [user_id], references: [id]) + comments comment[] } model session { @@ -94,6 +95,7 @@ model user { session session[] user_favorites user_favorites[] blueprint_pages blueprint_page[] + comment comment[] } model user_favorites { @@ -107,6 +109,20 @@ model user_favorites { @@id([user_id, blueprint_page_id]) } +model comment { + id String @id @default(uuid()) @db.Uuid + blueprint_page_id String @db.Uuid + user_id String @db.Uuid + body String @db.Text + reply_comment_id String? @db.Uuid + created_at DateTime @default(now()) + updated_at DateTime @updatedAt + blueprint_page blueprint_page @relation(fields: [blueprint_page_id], references: [id]) + user user @relation(fields: [user_id], references: [id]) + reply_to comment? @relation("commentToComment", fields: [reply_comment_id], references: [id]) + replies comment[] @relation("commentToComment") +} + model blueprint_entities { entity String @unique } diff --git a/apps/blueprints/src/components/Comments.tsx b/apps/blueprints/src/components/Comments.tsx new file mode 100644 index 0000000..19954e6 --- /dev/null +++ b/apps/blueprints/src/components/Comments.tsx @@ -0,0 +1,114 @@ +import { FormEventHandler, useEffect, useState } from "react"; +import { CommentWithUsername } from "@factorio-sites/types"; +import { useAuth } from "../providers/auth"; +import { Button } from "./Button"; +import styled from "@emotion/styled"; +import { format } from "date-fns"; +import { getLocaleDateFormat } from "@factorio-sites/web-utils"; + +interface CommentsProps { + blueprint_page_id: string; +} + +const AddCommentDiv = styled.div` + border-bottom: 1px solid #ccc; + padding-bottom: 1rem; + margin-bottom: 1rem; + + textarea { + color: #fff; + display: block; + margin-top: 0.5rem; + background: #414040; + border: 1px solid #ddd; + border-radius: 4px; + width: 400px; + min-height: 80px; + } + + button { + margin-top: 0.5rem; + } + .close { + margin-left: 1rem; + } +`; + +const CommentDiv = styled.div` + background: #4e4c4c; + margin: 0.5rem 0; +`; + +export const Comments: React.FC = ({ blueprint_page_id }) => { + const auth = useAuth(); + const [addCommentOpen, setAddCommentOpen] = useState(false); + const [comments, setComments] = useState([]); + const [commentBody, setCommentBody] = useState(""); + + const fetchTopLevelComments = async () => { + const result = await fetch(`/api/blueprint/comments?blueprint_page_id=${blueprint_page_id}`); + const body = await result.json(); + setComments(body.comments); + }; + + useEffect(() => { + fetchTopLevelComments(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + console.log({ comments }); + + const onSubmitComment: FormEventHandler = async (event) => { + event.preventDefault(); + const result = await fetch("/api/blueprint/comment", { + method: "POST", + body: JSON.stringify({ blueprint_page_id, body: commentBody }), + headers: { + "Content-Type": "application/json", + }, + }); + setCommentBody(""); + setAddCommentOpen(false); + console.log("result", await result.json()); + fetchTopLevelComments(); + }; + + return ( +
+ {auth && ( + + {addCommentOpen ? ( +
+
Add a new comment
+