diff --git a/.eslintrc.json b/.eslintrc.json index 34b4f96..6077d4e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -25,6 +25,8 @@ "error", { "controlComponents": [ + "HiddenInput", + "InlineStyledInput", "StyledInput" ] } diff --git a/components/icon-button.js b/components/icon-button.js new file mode 100644 index 0000000..d0aa48b --- /dev/null +++ b/components/icon-button.js @@ -0,0 +1,70 @@ +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import styled, { css } from "styled-components"; +import Link from "./link"; + +const HiddenInput = styled.input` + display: none; +`; + +const elementStyle = css` + display: inline-block; + + user-select: none; + cursor: pointer; + + margin: 10px 0; + padding: 10px; + + font-size: calc(40px / 3); + + border-radius: 2px; + border: 1px solid + ${(props) => props.theme.colors[props.danger ? "danger" : "primary"]}; + + transition: background-color 0.25s, color 0.25s; + + background-color: ${(props) => + props.theme.colors[props.danger ? "danger" : "primary"]}; + color: ${(props) => props.theme.colors.background}; + + &:hover { + background-color: ${(props) => props.theme.colors.background}; + color: ${(props) => + props.theme.colors[props.danger ? "danger" : "primary"]}; + } +`; + +const IconButtonLabel = styled.label` + ${elementStyle} +`; + +export function InputIconButton({ + icon, + danger, + children, + inputRef, + ...props +}) { + return ( + + + {children} + + ); +} + +const IconButtonLink = styled(Link)` + ${elementStyle} + + &:hover { + text-decoration: none; + } +`; + +export function LinkIconButton({ icon, danger, children, href }) { + return ( + + {children} + + ); +} diff --git a/components/projects/divisions-row.js b/components/projects/divisions-row.js new file mode 100644 index 0000000..9563c8a --- /dev/null +++ b/components/projects/divisions-row.js @@ -0,0 +1,342 @@ +import styled from "styled-components"; +import { useState, useRef, useEffect } from "react"; +import { + faDownload, + faTrash, + faTrashArrowUp, + faUpload, +} from "@fortawesome/free-solid-svg-icons"; +import { API_BASE } from "../../lib/api"; +import Card from "../card"; +import SubmitButton, { InlineStyledInput } from "../submit-button"; +import { InputIconButton, LinkIconButton } from "../icon-button"; + +export const InputBlock = styled.p` + margin-top: 10px; +`; + +const InputNote = styled.span` + color: ${(props) => props.theme.colors.primaryMuted}; + font-size: 0.8rem; +`; + +function getNumberOrDefault(value, defaultValue = 0) { + return Number.isNaN(value) ? defaultValue : value; +} + +function DivisionOrigin({ project, division, updateDivision }) { + const [x, y] = division.origin; + + if (!project.can_edit) { + return ( + <> + ({x}, {y}) + + ); + } + + return ( + <> + ( + { + updateDivision({ + ...division, + origin: [ + getNumberOrDefault(event.target.valueAsNumber), + division.origin[1], + ], + }); + }} + /> + ,{" "} + { + updateDivision({ + ...division, + origin: [ + division.origin[0], + getNumberOrDefault(event.target.valueAsNumber), + ], + }); + }} + /> + ) + + ); +} + +function DivisionCoordinates({ + project, + division, + divisionUpload, + updateDivision, +}) { + const [x, y] = division.origin; + const [width, height] = divisionUpload ? [null, null] : division.dimensions; + + if (width !== null && height !== null) { + const maxX = Math.max(0, x + width - 1); + const maxY = Math.max(0, y + height - 1); + + return ( + <> + + Coordinates:{" "} + {" "} + (top left) to ({maxX}, {maxY}){" "} + (bottom right) + + + Dimensions: {width}×{height} + + + ); + } + + return ( + + Offset:{" "} + {" "} + (top left) + + ); +} + +function DeleteButton({ division, updateDivision }) { + return ( + { + updateDivision({ + ...division, + delete: !division.delete, + }); + }} + > + {division.delete ? "Cancel Deletion" : "Delete"} + + ); +} + +const ActionRowBlock = styled(InputBlock)` + display: flex; + flex-wrap: wrap; + gap: 0 12px; +`; + +function ActionsBlock({ + imageUrl, + division, + divisionUpload, + updateDivision, + setDivisionUpload, +}) { + const uploadRef = useRef(null); + + useEffect(() => { + if (uploadRef.current) { + const dataTransfer = new DataTransfer(); + + if (divisionUpload) { + dataTransfer.items.add(divisionUpload); + } + + uploadRef.current.files = dataTransfer.files; + } + }, [divisionUpload]); + + return ( + + {imageUrl == null ? null : ( + + Download Image + + )} + { + const file = event.target.files?.[0]; + + if (file) { + setDivisionUpload(division, file); + } + }} + > + {imageUrl || divisionUpload ? "Replace" : "Upload"} Image + + + + ); +} + +function DivisionCard({ + project, + division, + divisionUpload, + updateDivision, + setDivisionUpload, +}) { + const [width, height] = division.dimensions; + + const imageUrl = + width !== null && height !== null + ? `${API_BASE}/y22/projects/${project.uuid}/divisions/${division.uuid}/bitmap` + : null; + + const [uploadUrl, setUploadUrl] = useState(null); + + useEffect(() => { + if (!divisionUpload) { + setUploadUrl(null); + return; + } + + const reader = new FileReader(); + + reader.addEventListener( + "load", + (event) => { + setUploadUrl(event.target.result); + }, + { + once: true, + } + ); + + reader.readAsDataURL(divisionUpload); + }, [divisionUpload]); + + return ( + + {project.can_edit ? ( + { + updateDivision({ + ...division, + name: event.target.value, + }); + }} + /> + ) : ( +

{division.name}

+ )} + + + + + + + + {project.can_edit ? ( + + ) : null} +
+ ); +} + +export default function DivisionsRow({ + project, + divisionUploads, + updateDivision, + setDivisionUpload, + addDivision, +}) { + if (!Array.isArray(project.divisions)) { + return null; + } + + return ( + <> +

Divisions

+ {project.divisions.map((division) => ( + + ))} + {project.can_edit ? ( +
+ +
+ ) : null} + + ); +} diff --git a/components/projects/members-row.js b/components/projects/members-row.js new file mode 100644 index 0000000..3559880 --- /dev/null +++ b/components/projects/members-row.js @@ -0,0 +1,58 @@ +import Card from "../card"; +import { InputBlock } from "./divisions-row"; + +function isNonEmptyArray(array) { + return Array.isArray(array) && array.length > 0; +} + +const roles = new Map() + .set("owner", { + name: "Owner", + priority: 0, + }) + .set("manager", { + name: "Manager", + priority: 1, + }) + .set("user", { + name: "User", + priority: 2, + }); + +function MemberCard({ member }) { + return ( + +

{member.username}

+ + Role: {roles.get(member.role)?.name ?? member.role} + +
+ ); +} + +export default function MembersRow({ members }) { + if (!isNonEmptyArray(members)) { + return null; + } + + return ( + <> +

Members

+ {members + .sort((a, b) => { + let sort = + (roles.get(a.role)?.priority ?? 0) - + (roles.get(b.role)?.priority ?? 0); + if (sort !== 0) return sort; + + sort = a.username.localeCompare(b.username); + if (sort !== 0) return sort; + + return a.uid.localeCompare(b.uid); + }) + .map((member) => ( + + ))} + + ); +} diff --git a/components/projects/project-panel.js b/components/projects/project-panel.js new file mode 100644 index 0000000..a16627d --- /dev/null +++ b/components/projects/project-panel.js @@ -0,0 +1,119 @@ +import styled from "styled-components"; +import { useCallback, useState } from "react"; +import { v4 as uuidv4 } from "uuid"; +import DivisionsRow from "./divisions-row"; +import MembersRow from "./members-row"; +import ProjectPreview from "./project-preview"; +import SaveChangesRow from "./save-changes-row"; + +const ProjectPanelContainer = styled.div` + margin: 0 auto; + width: 100%; + + @media (min-width: 1200px) { + max-width: 1200px; + } + + display: flex; + flex-direction: column; + gap: 12px; +`; + +function isDirty(project, initialProject, divisionUploads) { + // If a division image will be uploaded, then there are modifications + if (Object.keys(divisionUploads).length > 0) { + return true; + } + + // If any division has changed, then there are modifications + return project.divisions.some((division) => { + const initialDivision = initialProject.divisions.find( + (d) => d.uuid === division.uuid + ); + + // Newly added division + if (initialDivision === undefined) return true; + + // Deleted division + if (division.delete) return true; + + // Division with changed values + if (division.name !== initialDivision.name) return true; + if (division.priority !== initialDivision.priority) return true; + if (division.enabled !== initialDivision.enabled) return true; + + if (division.origin[0] !== initialDivision.origin[0]) return true; + if (division.origin[1] !== initialDivision.origin[1]) return true; + + return false; + }); +} + +export default function ProjectPanel({ initialProject }) { + const [project, setProject] = useState(initialProject); + const [divisionUploads, setDivisionUploads] = useState({}); + + const dirty = isDirty(project, initialProject, divisionUploads); + + const updateDivision = useCallback( + (division) => { + setProject({ + ...project, + divisions: project.divisions.map((d) => { + return d.uuid === division.uuid ? division : d; + }), + }); + }, + [project] + ); + + const setDivisionUpload = useCallback( + (division, file) => { + setDivisionUploads({ + ...divisionUploads, + [division.uuid]: file, + }); + }, + [divisionUploads] + ); + + const addDivision = useCallback(() => { + setProject({ + ...project, + divisions: [ + ...project.divisions, + { + create: true, + uuid: uuidv4(), + origin: [0, 0], + dimensions: [null, null], + priority: 0, + enabled: true, + }, + ], + }); + }, [project]); + + return ( + + + + + {project.can_edit ? ( + + ) : null} + + ); +} diff --git a/pages/projects.js b/components/projects/project-preview.js similarity index 50% rename from pages/projects.js rename to components/projects/project-preview.js index d6c7c77..deec33e 100644 --- a/pages/projects.js +++ b/components/projects/project-preview.js @@ -1,18 +1,8 @@ -import Head from "next/head"; -import styled from "styled-components"; -import { useEffect, useState } from "react"; -import { Box, PageTitle } from "../lib/common-style"; -import { API_BASE, makeApiRequest } from "../lib/api"; -import Card from "../components/card"; -import SubmitButton, { StyledInput } from "../components/submit-button"; -import useFilter from "../lib/hooks/useFilter"; -import { isMatchingString, filterArray } from "../lib/filter"; - -const ProjectsContainer = styled.div` - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 10px; -`; +import { useState } from "react"; +import { API_BASE, makeApiRequest } from "../../lib/api"; +import Card from "../card"; +import Link from "../link"; +import SubmitButton from "../submit-button"; class MembershipStatus { static Joining = new MembershipStatus("Joining..."); @@ -31,7 +21,21 @@ class MembershipStatus { } } -function ProjectPreview({ project }) { +function ProjectName({ project, summary }) { + const name = project.name || "Unnamed Project"; + + if (summary) { + return ( +

+ {name} +

+ ); + } + + return

{name}

; +} + +function ProjectPreview({ project, summary }) { const initialMembershipStatus = () => { if (typeof project.joined === "boolean") { return project.joined @@ -81,15 +85,16 @@ function ProjectPreview({ project }) { } }; + const [width, height] = project.dimensions; + + const imageUrl = + width !== null && height !== null + ? `${API_BASE}/y22/projects/${project.uuid}/bitmap` + : null; + return ( - -

{project.name || "Unnamed Project"}

+ + {typeof membershipStatus === "object" && (

Membership Status: {`${membershipStatus.text}`}

)} @@ -114,63 +119,4 @@ function ProjectPreview({ project }) { ); } -export default function ProjectsPage() { - const [projects, setProjects] = useState(null); - const [originalFilter, setFilter] = useFilter(); - - useEffect(() => { - makeApiRequest("/y22/projects") - .then((res) => res.json()) - .then((data) => { - if (data.projects) { - setProjects(data.projects); - } - }); - }, []); - - return ( - <> - - Projects - The Snakeroom - - - Projects - {projects === null ? ( -

Loading projects

- ) : ( -

- {projects.length === 1 - ? "There is 1 project available." - : `There are ${projects.length} projects available.`} -

- )} - setFilter(event.target.value)} - /> -
-
- {projects !== null && ( - - {filterArray( - projects, - originalFilter, - (project, filter) => { - return isMatchingString(project.name, filter); - } - ).map((project) => { - return ( - - ); - })} - - )} - - ); -} +export default ProjectPreview; diff --git a/components/projects/save-changes-row.js b/components/projects/save-changes-row.js new file mode 100644 index 0000000..6da49fe --- /dev/null +++ b/components/projects/save-changes-row.js @@ -0,0 +1,130 @@ +import { useCallback } from "react"; +import { makeApiRequest } from "../../lib/api"; +import SubmitRow from "../submit-row"; + +export default function SaveChangesRow({ + project, + divisionUploads, + dirty, + setProject, + setDivisionUploads, +}) { + const onClick = useCallback(async () => { + // Create new divisions + const remappedUuids = new Map(); + + // eslint-disable-next-line no-restricted-syntax + for await (const division of project.divisions) { + if (division.create && !division.delete) { + const res = await makeApiRequest( + `/y22/projects/${project.uuid}/create_division`, + { + method: "POST", + } + ); + + const json = await res.json(); + + if (res.ok) { + remappedUuids.set(division.uuid, json.uuid); + } else if (json.error) { + throw new Error(json.error); + } + } else { + remappedUuids.set(division.uuid, division.uuid); + } + } + + // Update division images + const newUploads = { ...divisionUploads }; + + // eslint-disable-next-line no-restricted-syntax + for await (const [divisionUuid, file] of Object.entries( + divisionUploads + )) { + const division = project.divisions.find( + (d) => d.uuid === divisionUuid + ); + + if (!division.delete) { + const uuid = remappedUuids.get(division.uuid); + + const res = await makeApiRequest( + `/y22/projects/${project.uuid}/divisions/${uuid}/bitmap`, + { + method: "POST", + body: file, + } + ); + + if (res.ok) { + delete newUploads[division]; + } else { + const json = await res.json(); + + if (json.error) { + throw new Error(json.error); + } + } + } + } + + // Delete divisions + // eslint-disable-next-line no-restricted-syntax + for await (const division of project.divisions) { + if (!division.create && division.delete) { + const res = await makeApiRequest( + `/y22/projects/${project.uuid}/divisions/${division.uuid}`, + { + method: "DELETE", + } + ); + + if (!res.ok) { + const json = await res.json(); + + if (json.error) { + throw new Error(json.error); + } + } + } + } + + // Update divisions + const newDivisions = []; + + // eslint-disable-next-line no-restricted-syntax + for await (const division of project.divisions) { + if (!division.delete) { + const uuid = remappedUuids.get(division.uuid); + + const res = await makeApiRequest( + `/y22/projects/${project.uuid}/divisions/${uuid}`, + { + method: "POST", + body: JSON.stringify(division), + } + ); + + const json = await res.json(); + + if (res.ok) { + newDivisions.push(json.division); + } else if (json.error) { + throw new Error(json.error); + } + } + } + + setProject({ + ...project, + divisions: newDivisions, + }); + + setDivisionUploads(newUploads); + }); + + return ( + + ); +} diff --git a/components/submit-button.js b/components/submit-button.js index 513309b..92ea22e 100644 --- a/components/submit-button.js +++ b/components/submit-button.js @@ -1,8 +1,15 @@ import styled from "styled-components"; -export const StyledInput = styled.input` - border: 1px solid ${(props) => props.theme.colors.primary}; +export const InlineStyledInput = styled.input` + &:not([type="file"]) { + border: 1px solid ${(props) => props.theme.colors.primary}; + } + border-radius: 2px; + padding: 2px; +`; + +export const StyledInput = styled(InlineStyledInput)` margin: 10px 0; padding: 10px; display: block; @@ -18,5 +25,10 @@ const SubmitButton = styled(StyledInput)` background-color: ${(props) => props.theme.colors.background}; color: ${(props) => props.theme.colors.primary}; } + + &:disabled { + background-color: ${(props) => props.theme.colors.backgroundMuted}; + color: ${(props) => props.theme.colors.primaryMuted}; + } `; export default SubmitButton; diff --git a/components/submit-row.js b/components/submit-row.js new file mode 100644 index 0000000..8bf9c23 --- /dev/null +++ b/components/submit-row.js @@ -0,0 +1,60 @@ +import styled from "styled-components"; +import { useCallback, useState } from "react"; +import SubmitButton from "./submit-button"; + +const SubmitRowBox = styled.div` + display: flex; + gap: 12px; + + flex-flow: row-reverse; + text-align: right; +`; + +const SubmitRowNag = styled.div` + margin: 10px 0; + padding: 9px; + + animation: fade-in linear 0.2s; + + @keyframes fade-in { + from { + opacity: 0; + } + + to { + opacity: 1; + } + } + + background-color: ${(props) => props.theme.colors.danger}; + color: #121212; + border-radius: 8px; +`; + +export default function SubmitRow({ name, onClick, disabled }) { + const [error, setError] = useState(null); + + const callback = useCallback(async (event) => { + try { + await onClick(event); + setError(null); + } catch (err) { + setError(err.message); + + // eslint-disable-next-line no-console + console.error(err); + } + }); + + return ( + + + {error === null ? null : {error}} + + ); +} diff --git a/lib/theme.js b/lib/theme.js index 5b774b7..586d978 100644 --- a/lib/theme.js +++ b/lib/theme.js @@ -8,6 +8,7 @@ export const lightTheme = { accent: "#557528", background: "#ffffff", backgroundMuted: "#eee", + danger: "#ff7777", cardImageBackgroundGradient: "#00000022", primary: "#000", primaryMuted: "#888", @@ -22,6 +23,7 @@ export const darkTheme = { background: "#121212", backgroundMuted: "#1f1f1f", cardImageBackgroundGradient: "#ffffff22", + danger: "#ff7777", primary: "#fff", primaryMuted: "#888", primaryVeryMuted: "#191919", diff --git a/package.json b/package.json index 6a2c1ae..e0a736f 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "next-offline": "^5.0.5", "react": "^18.2.0", "react-dom": "^18.2.0", - "styled-components": "^5.3.6" + "styled-components": "^5.3.6", + "uuid": "^9.0.0" }, "devDependencies": { "eslint": "^8.26.0", diff --git a/pages/projects/[uuid].js b/pages/projects/[uuid].js new file mode 100644 index 0000000..1d2d815 --- /dev/null +++ b/pages/projects/[uuid].js @@ -0,0 +1,57 @@ +import Head from "next/head"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/router"; +import { PageTitle } from "../../lib/common-style"; +import { makeApiRequest } from "../../lib/api"; +import ProjectPanel from "../../components/projects/project-panel"; +import NotFoundPage from "../404"; + +export default function ProjectPage() { + const [project, setProject] = useState(null); + const [error, setError] = useState(false); + + const router = useRouter(); + + useEffect(() => { + if (router.isReady) { + const { uuid } = router.query; + + makeApiRequest(`/y22/projects/${uuid}`) + .then((res) => res.json()) + .then((data) => { + if (data.project) { + setProject(data.project); + return; + } + + setError(true); + }) + .catch((err) => { + // eslint-disable-next-line no-console + console.error(err); + setError(true); + }); + } + }, [router.isReady]); + + if (error) { + return NotFoundPage(); + } + + const title = project?.name ? `${project.name} Project` : "Projects"; + + return ( + <> + + {title} - The Snakeroom + + Projects +
+ {project ? ( + + ) : ( +

Loading...

+ )} + + ); +} diff --git a/pages/projects/index.js b/pages/projects/index.js new file mode 100644 index 0000000..1468273 --- /dev/null +++ b/pages/projects/index.js @@ -0,0 +1,77 @@ +import Head from "next/head"; +import styled from "styled-components"; +import { useEffect, useState } from "react"; +import { Box, PageTitle } from "../../lib/common-style"; +import { makeApiRequest } from "../../lib/api"; +import ProjectPreview from "../../components/projects/project-preview"; +import { StyledInput } from "../../components/submit-button"; +import useFilter from "../../lib/hooks/useFilter"; +import { isMatchingString, filterArray } from "../../lib/filter"; + +const ProjectsContainer = styled.div` + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +`; + +export default function ProjectsPage() { + const [projects, setProjects] = useState(null); + const [originalFilter, setFilter] = useFilter(); + + useEffect(() => { + makeApiRequest("/y22/projects") + .then((res) => res.json()) + .then((data) => { + if (data.projects) { + setProjects(data.projects); + } + }); + }, []); + + return ( + <> + + Projects - The Snakeroom + + + Projects + {projects === null ? ( +

Loading projects

+ ) : ( +

+ {projects.length === 1 + ? "There is 1 project available." + : `There are ${projects.length} projects available.`} +

+ )} + setFilter(event.target.value)} + /> +
+
+ {projects !== null && ( + + {filterArray( + projects, + originalFilter, + (project, filter) => { + return isMatchingString(project.name, filter); + } + ).map((project) => { + return ( + + ); + })} + + )} + + ); +}