1
0
mirror of https://github.com/barthuijgen/factorio-sites.git synced 2025-02-01 13:27:43 +02:00

Add book preview to create blueprint page

This commit is contained in:
Bart 2021-04-22 14:00:57 +02:00 committed by Bart
parent c6d0bc8788
commit d5432c0d5c
7 changed files with 366 additions and 173 deletions

View File

@ -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<LinkProps & { include: boolean; className?: string }> = ({
include,
children,
className,
...props
}) => {
return include ? (
<Link {...props}>
<a className={className}>{children}</a>
</Link>
) : (
<div className={className}>{children}</div>
);
};
const InnerBookChildTree: React.FC<BookChildTreeProps> = ({ book_item, base_url, selected_id }) => {
return (
<>
<OptionalLink
include={Boolean(base_url && book_item.id)}
href={`${base_url}?selected=${book_item.id}&type=book`}
replace
className={"book" + (selected_id === book_item.id ? " active" : "")}
>
<FactorioIcon type="item" icon="blueprint-book" size={20} />
{book_item.icons &&
book_item.icons.map((icon, index) => (
<FactorioIcon key={index} type={icon.signal.type} icon={icon.signal.name} size={20} />
))}
<span className="label">
<FactorioCode code={book_item.name || ""} />
</span>
</OptionalLink>
<div css={{ marginLeft: `20px` }}>
{book_item.children.map((child, index) => {
return child.type === "blueprint" ? (
<OptionalLink
include={Boolean(base_url && child.id)}
key={child.id || index}
href={`${base_url}?selected=${child.id}`}
replace
className={"blueprint" + (selected_id === child.id ? " active" : "")}
>
{child.icons &&
child.icons.map((icon, index) => (
<FactorioIcon
key={index}
type={icon.signal.type}
icon={icon.signal.name}
size={20}
/>
))}
<span className="label">
<FactorioCode code={child.name || ""} />
</span>
</OptionalLink>
) : child.type === "blueprint_book" ? (
<InnerBookChildTree
key={child.id || index}
book_item={child}
base_url={base_url}
selected_id={selected_id}
/>
) : null;
})}
</div>
</>
);
};
export const BookChildTree: React.FC<BookChildTreeProps> = ({
blueprint_book,
book_item,
base_url,
selected_id,
}) => {
return (
<div css={componentStyles}>
<div>
<Link href={`${base_url}?selected=${blueprint_book.id}&type=book`} replace>
<a className={"book" + (selected_id === blueprint_book.id ? " active" : "")}>
<FactorioIcon type="item" icon="blueprint-book" size={20} />
{blueprint_book.icons &&
blueprint_book.icons.map((icon, index) => (
<FactorioIcon
key={index}
type={icon.signal.type}
icon={icon.signal.name}
size={20}
/>
))}
<span className="label">
<FactorioCode code={blueprint_book.name || ""} />
</span>
</a>
</Link>
<div css={{ marginLeft: `20px` }}>
{blueprint_book.children.map((child) => {
return child.type === "blueprint" ? (
<Link key={child.id} href={`${base_url}?selected=${child.id}`} replace>
<a className={"blueprint" + (selected_id === child.id ? " active" : "")}>
{child.icons &&
child.icons.map((icon, index) => (
<FactorioIcon
key={index}
type={icon.signal.type}
icon={icon.signal.name}
size={20}
/>
))}
<span className="label">
<FactorioCode code={child.name || ""} />
</span>
</a>
</Link>
) : child.type === "blueprint_book" ? (
<BookChildTree
key={child.id}
blueprint_book={child}
base_url={base_url}
selected_id={selected_id}
/>
) : null;
})}
</div>
<div className="child-tree-wrapper ">
<InnerBookChildTree book_item={book_item} base_url={base_url} selected_id={selected_id} />
</div>
</div>
);
};
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[],
};
};

View File

@ -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<BlueprintBookSubPageProps> = ({
}, []);
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<BlueprintBookSubPageProps> = ({
{bookChildTreeData && (
<div className="child-tree-wrapper">
<BookChildTree
blueprint_book={bookChildTreeData}
book_item={bookChildTreeData}
base_url={`/blueprint/${blueprint_page.id}`}
selected_id={selected.data.id}
/>

View File

@ -23,7 +23,6 @@ const handler = apiHandler(async (req, res, { session }) => {
const errors: Record<string, string> = {};
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";

View File

@ -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<FormValues>();
const [blueprintData, setBlueprintData] = useState<BlueprintStringData | null>(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 (
<Formik
initialValues={{ title: "", description: "", string: "", tags: [] }}
validate={validateCreateBlueprintForm}
onSubmit={async (values, { setSubmitting, setErrors, setStatus }) => {
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}`);
}
}}
<SimpleGrid
columns={2}
gap={6}
templateColumns={chakraResponsive({ mobile: "1fr", desktop: "1fr 1fr" })}
>
{({ isSubmitting, handleSubmit, status, values, errors, setFieldValue }) => (
<SimpleGrid
columns={2}
gap={6}
templateColumns={chakraResponsive({ mobile: "1fr", desktop: "1fr 1fr" })}
>
<Panel title="Create new blueprint">
<form onSubmit={handleSubmit}>
<Field name="title">
<Panel title="Create new blueprint">
<form onSubmit={handleSubmit}>
{step === 0 ? (
<>
<Field
name="string"
validate={joinValidations(validateRequired, validateBlueprintString)}
>
{({ field, meta }: FieldProps) => (
<FormControl
id="string"
isRequired
isInvalid={meta.touched && !!meta.error}
css={FieldStyle}
>
<FormLabel>Blueprint string</FormLabel>
<Input type="text" {...field} />
<FormErrorMessage>{meta.error}</FormErrorMessage>
</FormControl>
)}
</Field>
<Box css={{ display: "flex", alignItems: "center" }}>
<Button
primary
type="button"
css={{ marginRight: "1rem" }}
disabled={!blueprintData}
onClick={onClickNext}
>
Next
</Button>
{status && <Text css={{ marginLeft: "1rem", color: "red" }}>{status}</Text>}
</Box>
</>
) : (
<>
<Field name="title" validate={validateRequired}>
{({ field, meta }: FieldProps) => (
<FormControl
id="title"
@ -89,7 +154,10 @@ export const UserBlueprintCreate: NextPage = () => {
isInvalid={meta.touched && !!meta.error}
css={FieldStyle}
>
<FormLabel>Description</FormLabel>
<FormLabel>
Description{" "}
<Tooltip text="This description is shown next to the description already stored in the blueprint." />
</FormLabel>
<MDEditor
value={field.value}
onChange={(value) => setFieldValue("description", value)}
@ -99,13 +167,20 @@ export const UserBlueprintCreate: NextPage = () => {
)}
</Field>
<Field name="tags">
{description && (
<>
<div css={{ marginBottom: "0.5rem" }}>Blueprint description</div>
<StyledMarkdown>{description}</StyledMarkdown>
</>
)}
<Field name="tags" validate={validateTags}>
{({ field, meta }: FieldProps) => (
<FormControl id="tags" isInvalid={meta.touched && !!meta.error} css={FieldStyle}>
<FormLabel>Tags</FormLabel>
<Select
options={tagsOptions}
value={field.value}
value={field.value || []}
onChange={(tags) => setFieldValue("tags", tags)}
/>
<FormErrorMessage>{meta.error}</FormErrorMessage>
@ -113,38 +188,62 @@ export const UserBlueprintCreate: NextPage = () => {
)}
</Field>
<Field name="string">
{({ field, meta }: FieldProps) => (
<FormControl
id="string"
isRequired
isInvalid={meta.touched && !!meta.error}
css={FieldStyle}
>
<FormLabel>Blueprint string</FormLabel>
<Input type="text" {...field} />
<FormErrorMessage>{meta.error}</FormErrorMessage>
</FormControl>
)}
</Field>
<Box css={{ display: "flex", alignItems: "center" }}>
<Button type="button" css={{ marginRight: "1rem" }} onClick={() => setStep(0)}>
Previus
</Button>
<Button primary type="submit" disabled={isSubmitting}>
Submit
</Button>
{status && <Text css={{ marginLeft: "1rem", color: "red" }}>{status}</Text>}
</Box>
</form>
</Panel>
<Panel title="Preview">
<Box>
{values.string && !errors.string && (
<ImageEditor string={values.string}></ImageEditor>
)}
</Box>
</Panel>
</SimpleGrid>
)}
</>
)}
</form>
</Panel>
<Panel title="Preview">
<Box>
{blueprintData?.blueprint_book ? (
<BookChildTree
book_item={convertBlueprintBookDataToTree(blueprintData.blueprint_book)}
selected_id={null}
/>
) : values.string ? (
<ImageEditor string={values.string} />
) : null}
</Box>
</Panel>
</SimpleGrid>
);
};
export const UserBlueprintCreate: NextPage = () => {
const router = useRouter();
return (
<Formik
initialValues={{ title: "", description: "", string: "", tags: [] }}
onSubmit={async (values, { setSubmitting, setErrors, setStatus }) => {
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}`);
}
}}
>
<FormContent />
</Formik>
);
};

View File

@ -27,6 +27,10 @@ export const UserBlueprints: NextPage<UserBlueprintsProps> = ({ 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" });

View File

@ -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<string, string | undefined>): Record<string, string> => {
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<keyof CreateBlueprintValues, string | undefined>;
errors.title = validateRequired(values.title);
errors.string = validateRequired(values.string);
if (values.string) {
console.log(parseBlueprintStringClient(values.string));
export const joinValidations = <T>(...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";
}
};

View File

@ -1,4 +1,5 @@
import { Signal } from "./blueprint-string";
export interface ChildTreeBlueprint {
type: "blueprint";
id: string;