From 739be1f6dc0b6fd664ac03023fca570de5ae301f Mon Sep 17 00:00:00 2001 From: Florian Pagnoux Date: Tue, 12 Dec 2023 10:36:04 -0500 Subject: [PATCH] Support adding photos (#66) --- package.json | 1 + src/apiClient/client.ts | 51 +++++++-- src/apiClient/gear.ts | 24 ++-- src/pages/Gear/GearItemEditForm.tsx | 9 +- src/pages/Gear/GearItemPage.tsx | 5 +- src/pages/Gear/GearPicture.tsx | 74 ++++++++++++ src/pages/Gear/PicturePickerModal.tsx | 122 ++++++++++++++++++++ src/pages/Gear/PicturePlaceholder.tsx | 155 ++++++++++++++++++++++++++ src/redux/api.ts | 4 + yarn.lock | 7 ++ 10 files changed, 422 insertions(+), 30 deletions(-) create mode 100644 src/pages/Gear/GearPicture.tsx create mode 100644 src/pages/Gear/PicturePickerModal.tsx create mode 100644 src/pages/Gear/PicturePlaceholder.tsx diff --git a/package.json b/package.json index 7cff7db..da19d62 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@types/jest": "^24.0.0", "@types/node": "^12.0.0", "@types/react": "^18.0.9", + "@types/react-modal": "^3.16.3", "@types/react-redux": "^7.1.7", "eslint-config-prettier": "^9.1.0", "eslint-plugin-prettier": "^5.0.1", diff --git a/src/apiClient/client.ts b/src/apiClient/client.ts index 5d6c9c3..7f4c33f 100644 --- a/src/apiClient/client.ts +++ b/src/apiClient/client.ts @@ -10,28 +10,59 @@ export async function request( path: string, method: string, data?: Data, - maxRetry: number = 3, + maxRetry?: number, ): Promise { + return requestInternal({ + path, + method, + data, + maxRetry, + contentType: "application/json", + }); +} + +export async function uploadFile(path: string, file: File) { + const formData = new FormData(); + formData.append("file", file); + return requestInternal({ path, method: "POST", body: formData }); +} + +async function requestInternal(args: { + path: string; + method: string; + data?: Data; + body?: BodyInit; + maxRetry?: number; + contentType?: string; +}): Promise { + const { path, method, data, maxRetry = 3, body: rawBody, contentType } = args; if (maxRetry <= 0) { return; } + const isGet = method === "GET"; const queryParams = data != null && method === "GET" ? "?" + getQueryParams(data) : ""; + const body = isGet + ? undefined + : rawBody != null + ? rawBody + : data != null + ? JSON.stringify(data) + : undefined; + + const headers = { + ...(!isGet && { "X-CSRFTOKEN": await getCsrfToken() }), + ...(contentType != null && { "Content-Type": contentType }), + }; const response = await fetch(`${API_HOST}${path}${queryParams}`, { method: method, - headers: - method !== "GET" - ? { - "X-CSRFTOKEN": await getCsrfToken(), - "Content-Type": "application/json", - } - : { "Content-Type": "application/json" }, + headers, credentials: "include", - ...(data != null && method !== "GET" && { body: JSON.stringify(data) }), + ...(body && { body }), }); if (response.status === 403) { await refreshCsrfToken(); - return request(path, method, data, maxRetry - 1); + return requestInternal({ ...args, maxRetry: maxRetry - 1 }); } const jsonResponse = await parseJson(response); if (!response.ok) { diff --git a/src/apiClient/gear.ts b/src/apiClient/gear.ts index a442d96..1522915 100644 --- a/src/apiClient/gear.ts +++ b/src/apiClient/gear.ts @@ -17,6 +17,7 @@ export interface GearSummary { size?: string; specification?: string; type: GearTypeWithFee; + picture?: string; location: { id: number; shorthand: string; @@ -127,24 +128,22 @@ async function createGear( async function editGearItem( id: string, - specification: string, - description: string, - size: string, - depositAmount: number, - location: number, + item: { + specification?: string; + description?: string; + size?: string; + depositAmount?: number; + location?: number; + picture?: string; + }, ) { - return request(`/gear/${id}/`, "PATCH", { - specification, - description, - size, - depositAmount, - location, - }); + return request(`/gear/${id}/`, "PATCH", item); } export { addNote, createGear, + editGearItem, getGearRentalHistory, markBroken, markFixed, @@ -152,5 +151,4 @@ export { markMissing, markRetired, markUnretired, - editGearItem, }; diff --git a/src/pages/Gear/GearItemEditForm.tsx b/src/pages/Gear/GearItemEditForm.tsx index f936858..5174ede 100644 --- a/src/pages/Gear/GearItemEditForm.tsx +++ b/src/pages/Gear/GearItemEditForm.tsx @@ -48,14 +48,13 @@ export function GearItemEditForm({ gearItem, closeForm, refreshGear }: Props) { if (deposit == null) { return; } - editGearItem( - gearItem.id, + editGearItem(gearItem.id, { specification, description, size, - deposit, - location.id, - ).then(() => { + depositAmount: deposit, + location: location.id, + }).then(() => { closeForm(); refreshGear(); }); diff --git a/src/pages/Gear/GearItemPage.tsx b/src/pages/Gear/GearItemPage.tsx index c6b00a8..e93e5c4 100644 --- a/src/pages/Gear/GearItemPage.tsx +++ b/src/pages/Gear/GearItemPage.tsx @@ -1,12 +1,12 @@ -import { useParams } from "react-router-dom"; - import { addNote } from "apiClient/gear"; import { Notes } from "components/Notes"; import { useSetPageTitle } from "hooks"; +import { useParams } from "react-router-dom"; import { useGetGearItemQuery } from "redux/api"; import { GearInfoPanel } from "./GearInfoPanel"; import { GearRentalsHistory } from "./GearRentalsHistory"; +import { GearPicture } from "./GearPicture"; export function GearItemPage() { const { gearId } = useParams<{ gearId: string }>(); @@ -20,6 +20,7 @@ export function GearItemPage() {
+ addNote(gearId, note).then(refreshGear)} diff --git a/src/pages/Gear/GearPicture.tsx b/src/pages/Gear/GearPicture.tsx new file mode 100644 index 0000000..455e52e --- /dev/null +++ b/src/pages/Gear/GearPicture.tsx @@ -0,0 +1,74 @@ +import { GearItem } from "apiClient/gear"; +import { useState } from "react"; + +import { PicturePickerModal } from "./PicturePickerModal"; +import { PicturePlaceholder } from "./PicturePlaceholder"; +import { faCamera } from "@fortawesome/free-solid-svg-icons"; +import { faEdit } from "@fortawesome/free-solid-svg-icons"; + +import styled from "styled-components"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; + +type Props = { + gearItem: GearItem; + refreshGear: () => void; +}; + +export function GearPicture({ gearItem, refreshGear }: Props) { + const [isModalOpen, setIsModalOpen] = useState(false); + + return ( + <> + {gearItem.picture ? ( + setIsModalOpen(true)}> + + + + + + ) : ( +
+ setIsModalOpen(true)} + icon={faCamera} + text="Add picture" + fontSize={3} + /> +
+ )} + {isModalOpen && ( + setIsModalOpen(false)} + item={gearItem} + refreshGear={refreshGear} + /> + )} + + ); +} + +const PictureButton = styled.button` + padding: 0; + border: none; + position: relative; + + &:hover .top-right-icon { + opacity: 1; + } +`; + +const GearPic = styled.img` + width: 100%; +`; + +const TopRightIcon = styled.span` + position: absolute; + top: 0; + right: 0; + font-size: 3rem; + padding: 1rem; + opacity: 0; + transition: opacity 0.3s ease; + color: var(--bs-link-color); +`; diff --git a/src/pages/Gear/PicturePickerModal.tsx b/src/pages/Gear/PicturePickerModal.tsx new file mode 100644 index 0000000..87d1084 --- /dev/null +++ b/src/pages/Gear/PicturePickerModal.tsx @@ -0,0 +1,122 @@ +import { GearItem, editGearItem } from "apiClient/gear"; +import Modal from "react-bootstrap/Modal"; +import { useGetGearTypePicturesQuery } from "redux/api"; +import styled from "styled-components"; + +import { PicturePlaceholder } from "./PicturePlaceholder"; +import { faUpload } from "@fortawesome/free-solid-svg-icons"; +import { uploadFile } from "apiClient/client"; +import { useRef, useState } from "react"; + +type Props = { + isOpen: boolean; + close: () => void; + item: GearItem; + refreshGear: () => void; +}; + +export function PicturePickerModal({ + isOpen, + close, + item, + refreshGear, +}: Props) { + const { data: pictures, refetch: refetchPictures } = + useGetGearTypePicturesQuery(item.type.id); + const fileInputRef = useRef(null); + const [selected, setSelected] = useState(item.picture); + + return ( + + + Add picture for {item.id} + + + +
+ { + if (fileInputRef.current != null) { + fileInputRef.current.click(); + } + }} + text="Upload picture" + size="200px" + /> + { + const file = event.target.files?.[0]; + if (file == null) { + return; + } + uploadFile(`/gear/${item.id}/picture/`, file).then(() => { + refreshGear(); + refetchPictures(); + close(); + }); + }} + accept="image/*" + /> +
+ {pictures?.map((pic) => ( + { + setSelected(pic); + }} + selected={selected === pic} + > + + + ))} +
+
+ + + + + + +
+ ); +} + +const GridContainer = styled.div` + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 16px; +`; + +const PicContainer = styled.button<{ selected?: boolean }>` + width: 200px; + height: 200px; + padding: 0; + outline: grey solid 1px; + border: none; + + ${({ selected }) => + selected ? "outline: var(--bs-success) 6px solid !important" : ""} +`; + +const Pic = styled.img` + width: 100%; + height: 100%; + object-fit: cover; +`; diff --git a/src/pages/Gear/PicturePlaceholder.tsx b/src/pages/Gear/PicturePlaceholder.tsx new file mode 100644 index 0000000..f909f90 --- /dev/null +++ b/src/pages/Gear/PicturePlaceholder.tsx @@ -0,0 +1,155 @@ +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import styled from "styled-components"; + +type Props = { + onClick: () => void; + icon: IconProp; + text: string; + size?: string; + fontSize?: number; +}; + +export function PicturePlaceholder({ + onClick, + icon, + text, + size, + fontSize = 1, +}: Props) { + const iconFontSize = Math.min(fontSize * 50, 100); + const thickness = `${fontSize * 4}px`; + return ( + +
+
+ +
+

{text}

+
+
+ ); +} + +const PictureButton = styled.button.attrs({ + className: "d-flex align-items-center justify-content-center text-center", +})<{ size?: string; thickness: string }>` + &:hover { + color: var(--bs-link-color); + background: + linear-gradient( + to right, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 0, + linear-gradient( + to right, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 100%, + linear-gradient( + to left, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 0, + linear-gradient( + to left, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 100%, + linear-gradient( + to bottom, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 0, + linear-gradient( + to bottom, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 0, + linear-gradient( + to top, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 100%, + linear-gradient( + to top, + var(--bs-link-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 100%; + background-repeat: no-repeat; + background-size: 50px 50px; + } + + color: var(--bs-body-color); + width: 100%; + border: none; + + & :focus, + & :focus-visible { + border: none; + } + aspect-ratio: 1/1; + background: + linear-gradient( + to right, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 0, + linear-gradient( + to right, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 100%, + linear-gradient( + to left, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 0, + linear-gradient( + to left, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 100%, + linear-gradient( + to bottom, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 0, + linear-gradient( + to bottom, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 0, + linear-gradient( + to top, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 0 100%, + linear-gradient( + to top, + var(--bs-body-color) ${({ thickness }) => thickness}, + transparent ${({ thickness }) => thickness} + ) + 100% 100%; + + background-repeat: no-repeat; + background-size: 50px 50px; + + ${(props) => (props.size != null ? `max-width: ${props.size}` : "")} +`; diff --git a/src/redux/api.ts b/src/redux/api.ts index e89b255..4c32398 100644 --- a/src/redux/api.ts +++ b/src/redux/api.ts @@ -79,6 +79,9 @@ export const gearDbApi = createApi({ }, }), }), + getGearTypePictures: builder.query({ + query: (gearType) => `/gear-types/${gearType}/pictures`, + }), getPurchasables: builder.query({ query: () => "/purchasable/", }), @@ -163,6 +166,7 @@ export const { useGetSignupsQuery, useGetApprovalsQuery, useGetGearLocationsQuery, + useGetGearTypePicturesQuery, } = gearDbApi; export function useGearList({ diff --git a/yarn.lock b/yarn.lock index a0e325d..01027d9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2219,6 +2219,13 @@ dependencies: "@types/react" "*" +"@types/react-modal@^3.16.3": + version "3.16.3" + resolved "https://registry.yarnpkg.com/@types/react-modal/-/react-modal-3.16.3.tgz#250f32c07f1de28e2bcf9c3e84b56adaa6897013" + integrity sha512-xXuGavyEGaFQDgBv4UVm8/ZsG+qxeQ7f77yNrW3n+1J6XAstUy5rYHeIHPh1KzsGc6IkCIdu6lQ2xWzu1jBTLg== + dependencies: + "@types/react" "*" + "@types/react-redux@^7.1.20", "@types/react-redux@^7.1.7": version "7.1.24" resolved "https://registry.yarnpkg.com/@types/react-redux/-/react-redux-7.1.24.tgz#6caaff1603aba17b27d20f8ad073e4c077e975c0"