1
0
mirror of https://github.com/barthuijgen/factorio-sites.git synced 2024-11-21 18:16:33 +02:00

Fix edit blueprint, db improvements, tag search

This commit is contained in:
Bart Huijgen 2021-03-05 16:34:22 +01:00
parent 7e77230fac
commit 00358228dc
16 changed files with 371 additions and 77 deletions

View File

@ -27,7 +27,7 @@ const boxShadow = `inset 0 0 3px 0 #000, 0 -2px 2px -1px #000, -2px 0 2px -2px #
0 3px 3px -3px #8f8c8b, 0 2px 2px -2px #8f8c8b, 0 1px 1px -1px #8f8c8b`;
export const Panel: React.FC<
BoxProps & { title?: string | ReactNode | null; css?: SerializedStyles }
Omit<BoxProps, "title"> & { title?: ReactNode; css?: SerializedStyles }
> = ({ children, title, css: prop_css, ...props }) => (
<Box css={prop_css ? [panelStyles, prop_css] : [panelStyles]} {...props}>
{title ? <h2>{title}</h2> : null}

View File

@ -0,0 +1,38 @@
import MultiSelect from "react-multi-select-component";
import { useFbeData } from "../hooks/fbe.hook";
interface Tag {
value: string;
label: string;
}
interface TagsSelectProps {
value: string[];
onChange: (tags: string[]) => void;
className?: string;
}
export const TagsSelect: React.FC<TagsSelectProps> = ({ value, onChange, className }) => {
const { data } = useFbeData();
if (!data) return null;
const TAGS = Object.keys(data.entities)
.filter((key) => !key.startsWith("factorio_logo") && !key.startsWith("crash_site"))
.map((key) => {
const item = data.entities[key];
return { value: item.name, label: item.name.replace(/_/g, " ") };
});
return (
<MultiSelect
css={{ color: "black" }}
className={className}
options={TAGS}
value={value.map((value) => ({ value, label: value.replace(/_/g, " ") }))}
onChange={(tags: Tag[]) => onChange(tags.map((tag) => tag.value))}
labelledBy="Select"
hasSelectAll={false}
/>
);
};

View File

@ -0,0 +1,48 @@
import { useEffect, useState, RefObject } from "react";
export type FBE = typeof import("@fbe/editor");
export type Editor = InstanceType<FBE["Editor"]>;
export const useFBE = (canvasRef?: RefObject<HTMLCanvasElement>) => {
const [FBE, setFBE] = useState<FBE | null>(null);
const [editor, setEditor] = useState<Editor | null>(null);
useEffect(() => {
(async () => {
const _FBE = await import("@fbe/editor");
setFBE(_FBE);
if (canvasRef) {
const _editor = new _FBE.Editor();
const canvas = canvasRef.current;
if (!canvas) return;
await _editor.init(canvas);
canvas.style.width = "100%";
canvas.style.height = "auto";
_editor.setRendererSize(canvas.offsetWidth, canvas.offsetHeight);
setEditor(_editor);
}
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { FBE, editor };
};
export const useFbeData = () => {
const [FBE, setFBE] = useState<FBE | null>(null);
useEffect(() => {
(async () => {
const _FBE = await import("@fbe/editor");
if (!_FBE.FD.items) {
await fetch("/api/fbe-proxy/data.json")
.then((res) => res.text())
.then((modules) => _FBE.FD.loadData(modules));
}
setFBE(_FBE);
})();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return { data: FBE?.FD };
};

View File

@ -4,13 +4,16 @@ import { useCallback } from "react";
export const useRouterQueryToHref = () => {
const router = useRouter();
return useCallback(
(override: Record<string, string>) => {
(override: Record<string, string | string[] | null>) => {
const query = { ...router.query, ...override };
const href =
"/?" +
Object.keys(query)
.map((key) => `${key}=${query[key]}`)
.join("&");
const keys = Object.keys(query).filter((key) => query[key] !== null);
const href = keys.length
? "/?" +
Object.keys(query)
.filter((key) => query[key] !== null)
.map((key) => `${key}=${query[key]}`)
.join("&")
: "/";
return href;
},
[router]

View File

@ -0,0 +1,58 @@
import { createBlueprint, editBlueprintPage, createBlueprintBook } from "@factorio-sites/database";
import { parseBlueprintString } from "@factorio-sites/node-utils";
import { parseDatabaseError } from "../../../utils/api.utils";
import { apiHandler } from "../../../utils/api-handler";
const handler = apiHandler(async (req, res, { session }) => {
if (!session) {
return res.status(401).json({ status: "Not authenticated" });
}
const { id, title, description, string, tags } = req.body;
const parsed = await parseBlueprintString(string).catch(() => null);
// Validation
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";
if (Object.keys(errors).length) {
return res.status(400).json({ errors });
}
try {
const info = {
user_id: session.user.id,
title,
tags: Array.isArray(tags) ? tags : [],
description_markdown: description,
};
console.log(info);
if (parsed?.data.blueprint) {
const { insertedId } = await createBlueprint(parsed.data.blueprint, info);
const page = await editBlueprintPage(id, "blueprint", insertedId, info);
return res.status(200).json({ success: true, id: page.id });
} else if (parsed?.data.blueprint_book) {
const { insertedId } = await createBlueprintBook(parsed.data.blueprint_book, info);
const page = await editBlueprintPage(id, "blueprint_book", insertedId, info);
return res.status(200).json({ success: true, id: page.id });
}
} catch (reason) {
const insert_errors = parseDatabaseError(reason);
if (insert_errors) {
if (insert_errors.blueprint_id || insert_errors.blueprint_book_id) {
insert_errors.string = "This string already exists";
}
return res.status(400).json({ errors: insert_errors });
}
}
res.status(500).json({ status: "Failed to create blueprint" });
});
export default handler;

View File

@ -17,9 +17,10 @@ const handler = apiHandler(async (req, res, { session }) => {
update.username = username;
}
if (email) {
update.email = email;
} else if (user.email && user.steam_id) {
// User currently has email but wants to delete it, allow if steam_id exists
update.email = email.toLowerCase();
}
// User currently has email but wants to delete it, allow if steam_id exists
else if (user.email && user.steam_id) {
update.email = null;
}

View File

@ -1,7 +1,7 @@
import React, { useEffect, useState } from "react";
import { NextPage } from "next";
import BBCode from "bbcode-to-react";
import { Button, Grid, Image, Box } from "@chakra-ui/react";
import { Button, Grid, Image } from "@chakra-ui/react";
import {
BlueprintBook,
Blueprint,
@ -20,6 +20,8 @@ import { CopyButton } from "../../components/CopyButton";
import { ImageEditor } from "../../components/ImageEditor";
import { useAuth } from "../../providers/auth";
import { pageHandler } from "../../utils/page-handler";
import styled from "@emotion/styled";
import { AiOutlineHeart, AiFillHeart } from "react-icons/ai";
type Selected =
| { type: "blueprint"; data: Pick<Blueprint, "id" | "blueprint_hash" | "image_hash"> }
@ -34,6 +36,15 @@ interface IndexProps {
favorite: boolean;
}
const StyledTable = styled.table`
td {
border: 1px solid #909090;
}
td:not(.no-padding) {
padding: 5px 10px;
}
`;
export const Index: NextPage<IndexProps> = ({
image_exists,
selected,
@ -120,53 +131,88 @@ export const Index: NextPage<IndexProps> = ({
templateColumns={chakraResponsive({ mobile: "1fr", desktop: "1fr 1fr" })}
gap={6}
>
<Panel title={blueprint_page.title} gridColumn="1">
{auth && (
<Box>
<Button colorScheme="green" onClick={onClickFavorite}>
Favorite ({isFavorite ? "yes" : "no"})
</Button>
</Box>
)}
<Panel
title={
<div css={{ position: "relative" }}>
<span>{blueprint_page.title}</span>
{auth && (
<Button
colorScheme="green"
onClick={onClickFavorite}
css={{ position: "absolute", right: "10px", top: "-7px", height: "35px" }}
>
Favorite {isFavorite ? <AiFillHeart /> : <AiOutlineHeart />}
</Button>
)}
</div>
}
gridColumn="1"
>
{blueprint_book ? (
<>
<div>This string contains a blueprint book </div>
<br />
<BookChildTree
child_tree={[
{
id: blueprint_book.id,
name: blueprint_book.label,
type: "blueprint_book",
children: blueprint_book.child_tree,
},
]}
base_url={`/blueprint/${blueprint_page.id}`}
selected_id={selected.data.id}
/>
<div css={{ maxHeight: "400px", overflow: "auto" }}>
<BookChildTree
child_tree={[
{
id: blueprint_book.id,
name: blueprint_book.label,
type: "blueprint_book",
children: blueprint_book.child_tree,
},
]}
base_url={`/blueprint/${blueprint_page.id}`}
selected_id={selected.data.id}
/>
</div>
</>
) : blueprint ? (
<>
<div>This string contains one blueprint</div>
<div>tags: {blueprint.tags.join(", ")}</div>
</>
<Markdown>{blueprint_page.description_markdown}</Markdown>
) : null}
</Panel>
<Panel title={"Info"}>
<StyledTable>
<tbody>
<tr>
<td>User</td>
<td>-</td>
</tr>
<tr>
<td>Tags</td>
<td>{blueprint_page.tags.join(", ")}</td>
</tr>
<tr>
<td>Last updated</td>
<td>{new Date(blueprint_page.updated_at * 1000).toLocaleDateString()}</td>
</tr>
<tr>
<td>Created</td>
<td>{new Date(blueprint_page.created_at * 1000).toLocaleDateString()}</td>
</tr>
<tr>
<td>Favorites</td>
<td>{blueprint_page.favorite_count || "0"}</td>
</tr>
</tbody>
</StyledTable>
</Panel>
<Panel
title={"Image"}
gridColumn={chakraResponsive({ mobile: "1", desktop: "2" })}
gridRow={chakraResponsive({ mobile: "1", desktop: null })}
gridRow={chakraResponsive({ mobile: "1", desktop: "1 / span 2" })}
>
{/* {renderImage()} */}
{blueprintString && <ImageEditor string={blueprintString}></ImageEditor>}
</Panel>
<Panel
title="Description"
gridColumn={chakraResponsive({ mobile: "1", desktop: "1 / span 2" })}
>
<Markdown>{blueprint_page.description_markdown}</Markdown>
</Panel>
{blueprint_book && (
<Panel
title="Description"
gridColumn={chakraResponsive({ mobile: "1", desktop: "1 / span 2" })}
>
<Markdown>{blueprint_page.description_markdown}</Markdown>
</Panel>
)}
{selected.type === "blueprint" && data?.blueprint && (
<Panel
title={
@ -179,7 +225,7 @@ export const Index: NextPage<IndexProps> = ({
}
gridColumn={chakraResponsive({ mobile: "1", desktop: "1 / span 2" })}
>
<table>
<StyledTable>
<tbody>
{Object.entries(
data.blueprint.entities.reduce<Record<string, number>>((entities, entity) => {
@ -194,7 +240,7 @@ export const Index: NextPage<IndexProps> = ({
.sort((a, b) => b[1] - a[1])
.map(([entry_name, entry]) => (
<tr key={entry_name} css={{}}>
<td css={{ border: "1px solid #909090" }}>
<td className="no-padding">
<Image
alt={entry_name.replace(/-/g, " ")}
src={`https://factorioprints.com/icons/${entry_name}.png`}
@ -203,12 +249,12 @@ export const Index: NextPage<IndexProps> = ({
height="32px"
/>
</td>
<td css={{ padding: "5px 10px", border: "1px solid #909090" }}>{entry_name}</td>
<td css={{ padding: "5px 10px", border: "1px solid #909090" }}>{entry}</td>
<td>{entry_name}</td>
<td>{entry}</td>
</tr>
))}
</tbody>
</table>
</StyledTable>
</Panel>
)}
<Panel title="string" gridColumn={chakraResponsive({ mobile: "1", desktop: "1 / span 2" })}>

View File

@ -1,12 +1,15 @@
import React from "react";
import { NextPage, NextPageContext } from "next";
import { BlueprintPage, getMostRecentBlueprintPages, init } from "@factorio-sites/database";
import Link from "next/link";
import { BlueprintPage, searchBlueprintPages, init } from "@factorio-sites/database";
import { SimpleGrid, Box, RadioGroup, Stack, Radio } from "@chakra-ui/react";
import { Panel } from "../components/Panel";
import { Pagination } from "../components/Pagination";
import { useRouterQueryToHref } from "../hooks/query.hook";
import { useRouter } from "next/router";
import { BlueprintLink } from "../components/BlueprintLink";
import { TagsSelect } from "../components/TagsSelect";
import { queryValueAsArray } from "../utils/query.utils";
interface IndexProps {
totalItems: number;
@ -23,18 +26,20 @@ export const Index: NextPage<IndexProps> = ({
}) => {
const router = useRouter();
const routerQueryToHref = useRouterQueryToHref();
return (
<SimpleGrid columns={1} margin="0.7rem">
<Panel title="Blueprints">
<Box
css={{
display: "flex",
alignItems: "center",
borderBottom: "1px solid #b7b7b7",
paddingBottom: "0.3rem",
}}
>
<Box css={{ marginRight: "1rem" }}>Sort order</Box>
<Box>
<Box css={{ marginRight: "1rem" }}>
<RadioGroup
onChange={(value: string) => router.push(routerQueryToHref({ order: value }))}
value={(router.query.order as string) || "date"}
@ -45,6 +50,22 @@ export const Index: NextPage<IndexProps> = ({
</Stack>
</RadioGroup>
</Box>
<TagsSelect
value={queryValueAsArray(router.query.tags)}
onChange={(tags) => router.push(routerQueryToHref({ tags }))}
css={{ width: "200px", marginRight: "1rem" }}
/>
{router.query.q && (
<Box>
<span>Search term:</span>
<span
css={{ margin: "0 5px 0 0.5rem", border: "1px solid #ababab", padding: "0px 10px" }}
>
{router.query.q}
</span>
<Link href={routerQueryToHref({ q: null })}>x</Link>
</Box>
)}
</Box>
<Box>
{blueprints.map((bp) => (
@ -62,11 +83,14 @@ export async function getServerSideProps({ query }: NextPageContext) {
const page = Number(query.page || "1");
const perPage = Number(query["per-page"] || "10");
const order = (query["order"] as string) || "date";
const { count, rows } = await getMostRecentBlueprintPages({
const tags = query.tags ? String(query.tags).split(",") : undefined;
console.log(tags);
const { count, rows } = await searchBlueprintPages({
page,
perPage,
query: query.q as string,
order,
tags,
});
return {

View File

@ -30,17 +30,18 @@ import { Panel } from "../../../components/Panel";
import { validateCreateBlueprintForm } from "../../../utils/validate";
import { useAuth } from "../../../providers/auth";
import { ImageEditor } from "../../../components/ImageEditor";
import { useFbeData } from "../../../hooks/fbe.hook";
const FieldStyle = css`
margin-bottom: 1rem;
`;
const TAGS = [
{ value: "foo", label: "foo" },
{ value: "bar", label: "bar" },
{ value: "x", label: "x" },
{ value: "y", label: "y" },
];
// const TAGS = [
// { value: "foo", label: "foo" },
// { value: "bar", label: "bar" },
// { value: "x", label: "x" },
// { value: "y", label: "y" },
// ];
type Selected =
| { type: "blueprint"; data: Pick<Blueprint, "id" | "blueprint_hash">; string: string }
@ -53,6 +54,7 @@ interface UserBlueprintProps {
export const UserBlueprint: NextPage<UserBlueprintProps> = ({ blueprintPage, selected }) => {
const auth = useAuth();
const router = useRouter();
const { data } = useFbeData();
if (!auth) {
router.push("/");
@ -60,6 +62,15 @@ export const UserBlueprint: NextPage<UserBlueprintProps> = ({ blueprintPage, sel
if (!blueprintPage) return null;
if (!data) return null;
const TAGS = Object.keys(data.entities)
.filter((key) => !key.startsWith("factorio_logo") && !key.startsWith("crash_site"))
.map((key) => {
const item = data.entities[key];
return { value: item.name, label: item.name.replace(/_/g, " ") };
});
return (
<div css={{ margin: "0.7rem" }}>
<Formik
@ -67,16 +78,20 @@ export const UserBlueprint: NextPage<UserBlueprintProps> = ({ blueprintPage, sel
title: blueprintPage.title,
description: blueprintPage.description_markdown,
string: selected.string,
tags: [],
tags: [] as { value: string; label: string }[],
}}
validate={validateCreateBlueprintForm}
onSubmit={async (values, { setSubmitting, setErrors, setStatus }) => {
setStatus("");
const result = await fetch("/api/blueprint/create", {
const result = await fetch("/api/blueprint/edit", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(values),
body: JSON.stringify({
...values,
id: blueprintPage.id,
tags: values.tags.map((tag) => tag.value),
}),
}).then((res) => res.json());
if (result.status) {

View File

@ -12,6 +12,8 @@ interface UserBlueprintsProps {
}
export const UserBlueprints: NextPage<UserBlueprintsProps> = ({ blueprints }) => {
if (!blueprints) return null;
return (
<div css={{ margin: "0.7rem" }}>
<SimpleGrid columns={1} margin="0 auto" maxWidth="800px">
@ -42,6 +44,10 @@ export const UserBlueprints: NextPage<UserBlueprintsProps> = ({ blueprints }) =>
export const getServerSideProps = pageHandler(async (context, { session }) => {
if (!session) {
if (context.res) {
context.res.statusCode = 302;
context.res.setHeader("Location", "/");
}
return { props: {} };
}

View File

@ -0,0 +1,9 @@
export const queryValueAsArray = (value: string | string[] | undefined) => {
if (value) {
if (Array.isArray(value)) {
return value;
}
return value.split(",");
}
return [];
};

View File

@ -1,4 +1,6 @@
import { blueprint_page } from "@prisma/client";
import { join, raw, sqltag } from "@prisma/client/runtime";
import { prisma } from "../postgres/database";
import { BlueprintPage } from "../types";
@ -8,6 +10,7 @@ const mapBlueprintPageEntityToObject = (entity: blueprint_page): BlueprintPage =
blueprint_book_id: entity.blueprint_book_id ?? null,
title: entity.title,
description_markdown: entity.description_markdown || "",
tags: entity.tags,
created_at: entity.created_at && entity.created_at.getTime() / 1000,
updated_at: entity.updated_at && entity.updated_at.getTime() / 1000,
factorioprints_id: entity.factorioprints_id ?? null,
@ -31,32 +34,35 @@ export async function getBlueprintPageByFactorioprintsId(
return result ? mapBlueprintPageEntityToObject(result) : null;
}
export async function getMostRecentBlueprintPages({
export async function searchBlueprintPages({
page = 1,
perPage = 10,
query,
order,
tags,
}: {
page: number;
perPage: number;
query?: string;
order: "date" | "favorites" | string;
tags?: string[];
}): Promise<{ count: number; rows: BlueprintPage[] }> {
const orderMap: Record<string, string> = {
date: "updated_at",
favorites: "favorite_count",
};
const tagsFragment = tags
? sqltag`AND blueprint_page.tags @> array[${join(tags)}::varchar]`
: sqltag``;
const result = (
await prisma.$queryRaw<(blueprint_page & { favorite_count: number })[]>(
`SELECT *, (SELECT COUNT(*) FROM user_favorites where user_favorites.blueprint_page_id = blueprint_page.id) AS favorite_count
FROM public.blueprint_page
WHERE blueprint_page.title ILIKE $1
ORDER BY ${orderMap[order] || orderMap.date} DESC
LIMIT $2 OFFSET $3`,
query ? `%${query}%` : "%",
perPage,
(page - 1) * perPage
)
await prisma.$queryRaw<(blueprint_page & { favorite_count: number })[]>`
SELECT *, (SELECT COUNT(*) FROM user_favorites where user_favorites.blueprint_page_id = blueprint_page.id) AS favorite_count
FROM public.blueprint_page
WHERE blueprint_page.title ILIKE ${query ? `%${query}%` : "%"}
${tagsFragment}
ORDER BY ${raw(orderMap[order] || orderMap.date)} DESC
LIMIT ${perPage} OFFSET ${(page - 1) * perPage}`
).map((blueprintPage) => ({
...blueprintPage,
created_at: new Date(blueprintPage.created_at),
@ -99,3 +105,36 @@ export async function createBlueprintPage(
console.log(`Created Blueprint Page`);
return page;
}
export async function editBlueprintPage(
blueprintPageId: string,
type: "blueprint" | "blueprint_book",
targetId: string,
extraInfo: {
title: string;
user_id: string | null;
description_markdown: string;
tags?: string[];
created_at?: number;
updated_at?: number;
factorioprints_id?: string;
}
) {
const page = await prisma.blueprint_page.update({
where: { id: blueprintPageId },
data: {
user_id: extraInfo.user_id,
title: extraInfo.title,
description_markdown: extraInfo.description_markdown,
factorioprints_id: extraInfo.factorioprints_id,
blueprint_id: type === "blueprint" ? targetId : undefined,
blueprint_book_id: type === "blueprint_book" ? targetId : undefined,
tags: extraInfo.tags ? extraInfo.tags : [],
updated_at: extraInfo.updated_at ? new Date(extraInfo.updated_at * 1000) : new Date(),
created_at: extraInfo.created_at ? new Date(extraInfo.created_at * 1000) : new Date(),
},
});
console.log(`Updated Blueprint Page`);
return page;
}

View File

@ -16,7 +16,7 @@ export const createUserWithEmail = async (
const hash = await bcrypt.hash(password, 10);
return prisma.user.create({
data: {
email,
email: email.toLowerCase(),
username,
password: hash,
last_login_ip: ip,
@ -46,7 +46,10 @@ export const loginUserWithEmail = async ({
useragent: string;
ip: string;
}): Promise<(user & { session: session }) | null> => {
const user = await prisma.user.findUnique({ where: { email } });
const user = await prisma.user.findUnique({
where: { email: email.toLowerCase() },
});
if (!user || !user.password) {
return null;
}

View File

@ -1,10 +1,12 @@
import { PrismaClient } from "@prisma/client";
import { getEnvOrThrow, getSecretOrThrow } from "../env.util";
const globalWithPrisma = globalThis as typeof globalThis & { prisma: PrismaClient };
export let prisma: PrismaClient;
const _init = async () => {
if (prisma) return prisma;
if (globalWithPrisma.prisma) return globalWithPrisma.prisma;
const databasePassword = getEnvOrThrow("POSTGRES_PASSWORD");
@ -19,8 +21,6 @@ const _init = async () => {
},
});
// const x = await prismaClient.user.create({ data: { username: 1 } });
await prismaClient.$connect();
return prismaClient;
@ -29,6 +29,9 @@ const _init = async () => {
const promise = _init()
.then((result) => {
prisma = result;
if (!globalWithPrisma.prisma) {
globalWithPrisma.prisma = prisma;
}
return result;
})
.catch((reason) => {

View File

@ -46,6 +46,7 @@ export interface BlueprintPage {
blueprint_book_id: string | null;
title: string;
description_markdown: string;
tags: string[];
created_at: number;
updated_at: number;
factorioprints_id: string | null;

View File

@ -2,7 +2,7 @@
"version": "v1",
"packages": {
"@fbe/editor": {
"signature": "4394b199f59f3db8ce4aa6b1c9801472",
"signature": "77d812874aacc6417c4ac5324c11cddf",
"file": true,
"replaced": "./fbe-editor-v1.0.0.tgz"
}