+
{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 (
+
+ );
+ })}
+
+ )}
+ >
+ );
+}