From 4f6a17ffa384e6696f6d11f04ae64ad7f930137e Mon Sep 17 00:00:00 2001 From: Ivan <45982459+ivntsng@users.noreply.github.com> Date: Mon, 28 Oct 2024 11:39:39 +0800 Subject: [PATCH] Updated Creating and build details page (#509) * Updated Creating and build details page * Resolved unit tests issues * Resolved frontend issues * Additional frontend changes * Frontend changes * Resolved slugify issues * updates --- frontend/package-lock.json | 28 +++ frontend/package.json | 2 + .../src/components/listing/ListingBody.tsx | 235 +++++++++--------- .../src/components/listing/UploadContent.tsx | 115 +++++++++ .../listing/onshape/ListingOnshape.tsx | 159 ++++++------ frontend/src/components/pages/Create.tsx | 169 +++++++++++-- .../src/components/products/ProductPage.tsx | 18 +- frontend/src/lib/types/index.ts | 3 + store/app/model.py | 11 + store/app/routers/listings.py | 88 +++++-- tests/test_images.py | 20 +- tests/test_listings.py | 22 +- 12 files changed, 622 insertions(+), 248 deletions(-) create mode 100644 frontend/src/components/listing/UploadContent.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 10ee8735..1f3efb5d 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -42,6 +42,7 @@ "pako": "^2.1.0", "radix-ui": "^1.0.1", "react": "^18.0.0", + "react-bootstrap-icons": "^1.11.4", "react-device-detect": "^2.2.3", "react-dom": "^18.0.0", "react-dropzone": "^14.2.3", @@ -50,6 +51,7 @@ "react-highlight": "^0.15.0", "react-hook-form": "^7.52.2", "react-icons": "^5.2.1", + "react-images-uploading": "^3.1.7", "react-intersection-observer": "^9.13.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", @@ -9569,6 +9571,18 @@ "node": ">=0.10.0" } }, + "node_modules/react-bootstrap-icons": { + "version": "1.11.4", + "resolved": "https://registry.npmjs.org/react-bootstrap-icons/-/react-bootstrap-icons-1.11.4.tgz", + "integrity": "sha512-lnkOpNEZ/Zr7mNxvjA9efuarCPSgtOuGA55XiRj7ASJnBjb1wEAdtJOd2Aiv9t07r7FLI1IgyZPg9P6jqWD/IA==", + "license": "MIT", + "dependencies": { + "prop-types": "^15.7.2" + }, + "peerDependencies": { + "react": ">=16.8.6" + } + }, "node_modules/react-colorful": { "version": "5.6.1", "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", @@ -9703,6 +9717,20 @@ "react": "*" } }, + "node_modules/react-images-uploading": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/react-images-uploading/-/react-images-uploading-3.1.7.tgz", + "integrity": "sha512-woET50eCezm645iIeP4gCoN7HjdR3T64UXC5l53yd+2vHFp+pwABH8Z/aAO5IXDeC1aP6doQ+K738L701zswAw==", + "license": "MIT", + "engines": { + "node": ">=8", + "npm": ">=5" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, "node_modules/react-intersection-observer": { "version": "9.13.1", "resolved": "https://registry.npmjs.org/react-intersection-observer/-/react-intersection-observer-9.13.1.tgz", diff --git a/frontend/package.json b/frontend/package.json index 42ce11cd..54804aaa 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -57,6 +57,7 @@ "pako": "^2.1.0", "radix-ui": "^1.0.1", "react": "^18.0.0", + "react-bootstrap-icons": "^1.11.4", "react-device-detect": "^2.2.3", "react-dom": "^18.0.0", "react-dropzone": "^14.2.3", @@ -65,6 +66,7 @@ "react-highlight": "^0.15.0", "react-hook-form": "^7.52.2", "react-icons": "^5.2.1", + "react-images-uploading": "^3.1.7", "react-intersection-observer": "^9.13.1", "react-markdown": "^9.0.1", "react-masonry-css": "^1.0.16", diff --git a/frontend/src/components/listing/ListingBody.tsx b/frontend/src/components/listing/ListingBody.tsx index a6db7f81..3e6f5105 100644 --- a/frontend/src/components/listing/ListingBody.tsx +++ b/frontend/src/components/listing/ListingBody.tsx @@ -1,143 +1,140 @@ -import { useEffect, useState } from "react"; -import Masonry from "react-masonry-css"; +import React, { useEffect, useState } from "react"; -import ListingChildren from "@/components/listing/ListingChildren"; -import ListingDescription from "@/components/listing/ListingDescription"; import ListingOnshape from "@/components/listing/onshape/ListingOnshape"; -import { Card, CardContent } from "@/components/ui/Card"; -import { components, paths } from "@/gen/api"; +import ProductPage from "@/components/products/ProductPage"; import { useAlertQueue } from "@/hooks/useAlertQueue"; import { useAuthentication } from "@/hooks/useAuth"; -import ArtifactCard from "./artifacts/ArtifactCard"; -import LoadingArtifactCard from "./artifacts/LoadingArtifactCard"; - -type ListingResponse = - paths["/listings/{id}"]["get"]["responses"][200]["content"]["application/json"]; +// Update the ListingResponse type to match the actual structure +type ListingResponse = { + id: string; + name: string; + username: string | null; + slug: string | null; + description: string | null; + child_ids: string[]; + tags: string[]; + onshape_url: string | null; + can_edit: boolean; + created_at: number; + creator_name: string | null; + uploaded_files?: { url: string }[]; + price?: number; + key_features?: string; +}; interface ListingBodyProps { listing: ListingResponse; + newTitle?: string; } -const ListingBody = (props: ListingBodyProps) => { - const { listing } = props; - const { addErrorAlert } = useAlertQueue(); +const ListingBody: React.FC = ({ listing, newTitle }) => { + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedImage, setSelectedImage] = useState(null); + const [images, setImages] = useState([]); const auth = useAuthentication(); - const [artifacts, setArtifacts] = useState< - components["schemas"]["ListArtifactsResponse"]["artifacts"] | null - >(null); - - const addArtifactId = async (newArtifactId: string) => { - const { data, error } = await auth.client.GET( - "/artifacts/info/{artifact_id}", - { - params: { path: { artifact_id: newArtifactId } }, - }, - ); - - if (error) { - addErrorAlert(error); - } else { - setArtifacts((prev) => { - if (prev === null) return prev; - return [...prev, data]; - }); - } - }; - - const handleDeleteArtifact = (artifactId: string) => { - setArtifacts((prevArtifacts) => - prevArtifacts - ? prevArtifacts.filter( - (artifact) => artifact.artifact_id !== artifactId, - ) - : null, - ); - }; - - const breakpointColumnsObj = { - default: 3, - 1024: 2, - 640: 1, - }; + const { addErrorAlert } = useAlertQueue(); useEffect(() => { - if (artifacts !== null) return; - const fetchArtifacts = async () => { - const { data, error } = await auth.client.GET( - "/artifacts/list/{listing_id}", - { - params: { path: { listing_id: listing.id } }, - }, - ); - - if (error) { - addErrorAlert(error); - } else { - setArtifacts(data.artifacts); + try { + const { data, error } = await auth.client.GET( + "/artifacts/list/{listing_id}", + { + params: { path: { listing_id: listing.id } }, + }, + ); + + if (error) { + addErrorAlert(error); + } else { + const artifactImages = data.artifacts + .filter( + (artifact: { artifact_type: string }) => + artifact.artifact_type === "image", + ) + .map( + (artifact: { urls: { large: string } }) => artifact.urls.large, + ); + + const uploadedImages = + listing.uploaded_files?.map((file: { url: string }) => file.url) || + []; + setImages([...uploadedImages, ...artifactImages]); + } + } catch (err) { + addErrorAlert( + `Error fetching artifacts: ${err instanceof Error ? err.message : String(err)}`, + ); } }; + fetchArtifacts(); - }, [listing.id, artifacts, auth.client, addErrorAlert]); + }, [listing.id, auth.client, addErrorAlert]); + + console.log("Raw listing price:", listing.price); + console.log("Listing price type:", typeof listing.price); + + const productInfo = { + name: newTitle || listing.name, + description: listing.description || "Product Description", + specs: listing.key_features ? listing.key_features.split("\n") : [], + features: [], + price: listing.price ?? 0, + productId: listing.id, + }; + + console.log("Product info:", productInfo); + + const openModal = (image: string) => { + setSelectedImage(image); + setIsModalOpen(true); + }; + + const closeModal = () => { + setSelectedImage(null); + setIsModalOpen(false); + }; return (
- - - - - - - - - - - - - - - -
- - {artifacts === null ? ( - - ) : ( - artifacts - .slice() - .reverse() - .map((artifact) => ( - - - - handleDeleteArtifact(artifact.artifact_id) - } - canEdit={listing.can_edit} - /> - - - )) - )} - + +
+ {}} + edit={listing.can_edit} + />
+ + {isModalOpen && selectedImage && ( +
+
+ Selected + +
+
+ )}
); }; diff --git a/frontend/src/components/listing/UploadContent.tsx b/frontend/src/components/listing/UploadContent.tsx new file mode 100644 index 00000000..1c5109bf --- /dev/null +++ b/frontend/src/components/listing/UploadContent.tsx @@ -0,0 +1,115 @@ +// src/components/UploadContent.tsx +import { FC, useEffect } from "react"; +import { XCircleFill } from "react-bootstrap-icons"; +import ImageUploading, { ImageListType } from "react-images-uploading"; + +import { useAlertQueue } from "@/hooks/useAlertQueue"; + +interface UploadContentProps { + images: ImageListType; + onChange: (imageList: ImageListType) => void; +} + +const UploadContent: FC = ({ images, onChange }) => { + const { addAlert } = useAlertQueue(); + const maxNumber = 10; // Set the maximum number of files allowed + + // Handle image pasting from the clipboard + const handlePaste = (event: ClipboardEvent) => { + const clipboardItems = event.clipboardData?.items; + if (!clipboardItems) return; + + for (let i = 0; i < clipboardItems.length; i++) { + const item = clipboardItems[i]; + if (item.type.startsWith("image")) { + const file = item.getAsFile(); + if (file) { + const newImageList = [ + ...images, + { file, data_url: URL.createObjectURL(file) }, + ]; + console.log("Image pasted, updating image list:", newImageList); + onChange(newImageList); + addAlert("Image pasted from clipboard!", "success"); + } + } + } + }; + + useEffect(() => { + const pasteListener = (event: Event) => + handlePaste(event as ClipboardEvent); + window.addEventListener("paste", pasteListener); + return () => { + window.removeEventListener("paste", pasteListener); + }; + }, [images]); + + return ( +
+ { + console.log("Images updated:", imageList); + onChange(imageList); + }} + maxNumber={maxNumber} + dataURLKey="data_url" + > + {({ + imageList, + onImageUpload, + onImageRemove, + isDragging, + dragProps, + }) => ( +
+ {/* Dropzone Area */} +
+

+ Drag & drop images here, click to select files, or paste an + image from your clipboard +

+
+ + {/* Display uploaded images below the dropzone */} +
+ {imageList.length > 0 ? ( + imageList.map((image, index) => ( +
+ + onImageRemove(index)} + > + + +
+ )) + ) : ( +

+ No images uploaded yet. +

+ )} +
+
+ )} +
+
+ ); +}; + +export default UploadContent; diff --git a/frontend/src/components/listing/onshape/ListingOnshape.tsx b/frontend/src/components/listing/onshape/ListingOnshape.tsx index 0b699bf7..845651f3 100644 --- a/frontend/src/components/listing/onshape/ListingOnshape.tsx +++ b/frontend/src/components/listing/onshape/ListingOnshape.tsx @@ -9,6 +9,7 @@ import { } from "react-icons/fa"; import ListingOnshapeUpdate from "@/components/listing/onshape/ListingOnshapeUpdate"; +import { Card, CardHeader, CardTitle } from "@/components/ui/Card"; import { Input } from "@/components/ui/Input/Input"; import Spinner from "@/components/ui/Spinner"; import { Button } from "@/components/ui/button"; @@ -257,82 +258,88 @@ const ListingOnshape = (props: Props) => {
) : ( -
- {isEditing ? ( -
- - {edit && ( - - )} -
- ) : url === null ? ( -
- - {edit && ( - - )} -
- ) : ( -
- {edit ? ( - - ) : ( -
- -
- )} - {updateOnshape && ( - setUpdateOnshape(false)} - addArtifactId={addArtifactId} - /> - )} -
- )} - {showInstructions && renderUrdfInstructions()} -
+ + + +
+ {isEditing ? ( +
+ + {edit && ( + + )} +
+ ) : url === null ? ( +
+ + {edit && ( + + )} +
+ ) : ( +
+ {edit ? ( + + ) : ( +
+ +
+ )} + {updateOnshape && ( + setUpdateOnshape(false)} + addArtifactId={addArtifactId} + /> + )} +
+ )} + {showInstructions && renderUrdfInstructions()} +
+
+
+
); }; diff --git a/frontend/src/components/pages/Create.tsx b/frontend/src/components/pages/Create.tsx index 20772187..6394abdb 100644 --- a/frontend/src/components/pages/Create.tsx +++ b/frontend/src/components/pages/Create.tsx @@ -1,9 +1,11 @@ import { useEffect, useState } from "react"; import { useForm } from "react-hook-form"; +import { ImageListType } from "react-images-uploading"; import { useNavigate } from "react-router-dom"; import RequireAuthentication from "@/components/auth/RequireAuthentication"; import { RenderDescription } from "@/components/listing/ListingDescription"; +import UploadContent from "@/components/listing/UploadContent"; import { Card, CardContent, CardHeader } from "@/components/ui/Card"; import ErrorMessage from "@/components/ui/ErrorMessage"; import Header from "@/components/ui/Header"; @@ -23,6 +25,9 @@ const Create = () => { const [description, setDescription] = useState(""); const [slug, setSlug] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); + const [images, setImages] = useState([]); + const [keyFeatures, setKeyFeatures] = useState(""); + const [displayPrice, setDisplayPrice] = useState(""); const { register, @@ -50,26 +55,64 @@ const Create = () => { } }, [auth.currentUser, slug]); - // On submit, add the listing to the database and navigate to the - // newly-created listing. - const onSubmit = async ({ name, description, slug }: NewListingType) => { - const { data: responseData, error } = await auth.client.POST( - "/listings/add", - { - body: { - name, - description, - child_ids: [], - slug, - }, - }, - ); - - if (error) { - addErrorAlert(error); - } else { - addAlert("New listing was created successfully", "success"); - navigate(`/item/${responseData.username}/${responseData.slug}`); + const handleImageChange = (imageList: ImageListType) => { + setImages(imageList); + }; + + const convertToDecimal = (value: string) => { + const numericValue = parseFloat(value); + if (isNaN(numericValue)) return ""; + return (numericValue / 100).toFixed(2); + }; + + const handlePriceChange = (e: React.ChangeEvent) => { + const inputValue = e.target.value.replace(/[^0-9]/g, ""); + const decimalValue = convertToDecimal(inputValue); + setDisplayPrice(decimalValue); + setValue("price", parseFloat(decimalValue), { shouldValidate: true }); + }; + + const onSubmit = async ({ + name, + description, + slug, + stripe_link, + keyFeatures, + price, + }: NewListingType) => { + const formData = new FormData(); + formData.append("name", name); + formData.append("description", description || ""); + formData.append("slug", slug || slugify(name)); + formData.append("stripe_link", stripe_link || ""); + formData.append("key_features", keyFeatures || ""); + if (price !== undefined && price !== null) { + const priceInCents = Math.round(price * 100); + formData.append("price", priceInCents.toString()); + } + + // Append photos to formData + images.forEach((image) => { + if (image.file) { + formData.append(`photos`, image.file); + } + }); + + try { + // @ts-expect-error Server accepts FormData but TypeScript doesn't recognize it + const { data: responseData } = await auth.client.POST("/listings/add", { + body: formData, + } as { body: FormData }); + + if (responseData && responseData.username && responseData.slug) { + addAlert("New listing was created successfully", "success"); + navigate(`/item/${responseData.username}/${responseData.slug}`); + } else { + throw new Error("Invalid response data"); + } + } catch (error) { + addErrorAlert("Failed to create listing"); + console.error("Error creating listing:", error); } }; @@ -117,10 +160,7 @@ const Create = () => { placeholder="Description (at least 6 characters)" rows={4} {...register("description", { - setValueAs: (value) => { - setDescription(value); - return value; - }, + onChange: (e) => setDescription(e.target.value), })} /> {errors?.description && ( @@ -136,6 +176,35 @@ const Create = () => { )} + {/* Key Features */} +
+ +