Skip to content

Commit

Permalink
Support adding photos (#66)
Browse files Browse the repository at this point in the history
  • Loading branch information
fpagnoux authored Dec 12, 2023
1 parent 08cbbbc commit 739be1f
Show file tree
Hide file tree
Showing 10 changed files with 422 additions and 30 deletions.
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
51 changes: 41 additions & 10 deletions src/apiClient/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,59 @@ export async function request(
path: string,
method: string,
data?: Data,
maxRetry: number = 3,
maxRetry?: number,
): Promise<any> {
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<any> {
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) {
Expand Down
24 changes: 11 additions & 13 deletions src/apiClient/gear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export interface GearSummary {
size?: string;
specification?: string;
type: GearTypeWithFee;
picture?: string;
location: {
id: number;
shorthand: string;
Expand Down Expand Up @@ -127,30 +128,27 @@ 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,
markFound,
markMissing,
markRetired,
markUnretired,
editGearItem,
};
9 changes: 4 additions & 5 deletions src/pages/Gear/GearItemEditForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
5 changes: 3 additions & 2 deletions src/pages/Gear/GearItemPage.tsx
Original file line number Diff line number Diff line change
@@ -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 }>();
Expand All @@ -20,6 +20,7 @@ export function GearItemPage() {
<div className="row">
<div className="col-12 col-md-5 p-2">
<GearInfoPanel gearItem={gearItem} refreshGear={refreshGear} />
<GearPicture gearItem={gearItem} refreshGear={refreshGear} />
<Notes
notes={gearItem.notes}
onAdd={(note) => addNote(gearId, note).then(refreshGear)}
Expand Down
74 changes: 74 additions & 0 deletions src/pages/Gear/GearPicture.tsx
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);

return (
<>
{gearItem.picture ? (
<PictureButton onClick={() => setIsModalOpen(true)}>
<GearPic src={gearItem.picture} alt="Gear item" />
<TopRightIcon className="top-right-icon">
<FontAwesomeIcon icon={faEdit} />
</TopRightIcon>
</PictureButton>
) : (
<div className="border rounded-2 p-2 mb-3 bg-light">
<PicturePlaceholder
onClick={() => setIsModalOpen(true)}
icon={faCamera}
text="Add picture"
fontSize={3}
/>
</div>
)}
{isModalOpen && (
<PicturePickerModal
isOpen={isModalOpen}
close={() => 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);
`;
122 changes: 122 additions & 0 deletions src/pages/Gear/PicturePickerModal.tsx
Original file line number Diff line number Diff line change
@@ -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<HTMLInputElement | null>(null);
const [selected, setSelected] = useState<string | undefined>(item.picture);

return (
<Modal show={isOpen} onHide={close} size="xl">
<Modal.Header closeButton>
<Modal.Title>Add picture for {item.id}</Modal.Title>
</Modal.Header>
<Modal.Body>
<GridContainer>
<div style={{ width: "200px" }}>
<PicturePlaceholder
icon={faUpload}
onClick={() => {
if (fileInputRef.current != null) {
fileInputRef.current.click();
}
}}
text="Upload picture"
size="200px"
/>
<input
ref={fileInputRef}
type="file"
className="sr-only"
onChange={(event) => {
const file = event.target.files?.[0];
if (file == null) {
return;
}
uploadFile(`/gear/${item.id}/picture/`, file).then(() => {
refreshGear();
refetchPictures();
close();
});
}}
accept="image/*"
/>
</div>
{pictures?.map((pic) => (
<PicContainer
key={pic}
onClick={() => {
setSelected(pic);
}}
selected={selected === pic}
>
<Pic src={pic} />
</PicContainer>
))}
</GridContainer>
</Modal.Body>

<Modal.Footer>
<button className="btn btn-secondary" onClick={close}>
Close
</button>

<button
className="btn btn-primary"
onClick={() => {
editGearItem(item.id, { picture: selected }).then(() => {
close();
refreshGear();
});
}}
disabled={selected == null}
>
Save
</button>
</Modal.Footer>
</Modal>
);
}

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;
`;
Loading

0 comments on commit 739be1f

Please sign in to comment.