From 3c394feecd52306b8649a22aa50e6aa13b4ace5d Mon Sep 17 00:00:00 2001 From: Ivan <45982459+ivntsng@users.noreply.github.com> Date: Mon, 28 Oct 2024 18:23:53 +0800 Subject: [PATCH] Reworked build page UI (#512) * Reworked build page UI * Resolved frontend build errors --- .../src/components/listing/ListingBody.tsx | 84 +++- .../src/components/listing/ListingHeader.tsx | 267 ----------- .../components/listing/ListingVoteButtons.tsx | 16 +- frontend/src/components/pages/Create.tsx | 41 +- .../src/components/pages/ListingDetails.tsx | 4 +- frontend/src/components/pages/StompyMini.tsx | 1 - frontend/src/components/pages/StompyPro.tsx | 1 - .../src/components/products/ProductPage.tsx | 425 ++++++++++++++---- frontend/src/lib/types/index.ts | 5 +- store/app/model.py | 3 - store/app/routers/listings.py | 6 - tests/test_images.py | 1 - tests/test_listings.py | 1 - 13 files changed, 444 insertions(+), 411 deletions(-) delete mode 100644 frontend/src/components/listing/ListingHeader.tsx diff --git a/frontend/src/components/listing/ListingBody.tsx b/frontend/src/components/listing/ListingBody.tsx index 3e6f5105..c0baa2a0 100644 --- a/frontend/src/components/listing/ListingBody.tsx +++ b/frontend/src/components/listing/ListingBody.tsx @@ -1,10 +1,15 @@ import React, { useEffect, useState } from "react"; +import Masonry from "react-masonry-css"; import ListingOnshape from "@/components/listing/onshape/ListingOnshape"; import ProductPage from "@/components/products/ProductPage"; +import { Card, CardContent } from "@/components/ui/Card"; import { useAlertQueue } from "@/hooks/useAlertQueue"; import { useAuthentication } from "@/hooks/useAuth"; +import ArtifactCard from "./artifacts/ArtifactCard"; +import LoadingArtifactCard from "./artifacts/LoadingArtifactCard"; + // Update the ListingResponse type to match the actual structure type ListingResponse = { id: string; @@ -20,7 +25,26 @@ type ListingResponse = { creator_name: string | null; uploaded_files?: { url: string }[]; price?: number; - key_features?: string; + artifacts?: + | { + artifact_id: string; + listing_id: string; + name: string; + artifact_type: + | "image" + | "urdf" + | "mjcf" + | "stl" + | "obj" + | "dae" + | "ply" + | "tgz" + | "zip"; + description: string | null; + timestamp: number; + urls: { large: string }; + }[] + | undefined; }; interface ListingBodyProps { @@ -32,9 +56,28 @@ const ListingBody: React.FC = ({ listing, newTitle }) => { const [isModalOpen, setIsModalOpen] = useState(false); const [selectedImage, setSelectedImage] = useState(null); const [images, setImages] = useState([]); + const [artifacts, setArtifacts] = useState< + ListingResponse["artifacts"] | null + >(null); const auth = useAuthentication(); const { addErrorAlert } = useAlertQueue(); + const breakpointColumnsObj = { + default: 3, + 1024: 2, + 640: 1, + }; + + const handleDeleteArtifact = (artifactId: string) => { + setArtifacts((prevArtifacts) => + prevArtifacts + ? prevArtifacts.filter( + (artifact) => artifact.artifact_id !== artifactId, + ) + : null, + ); + }; + useEffect(() => { const fetchArtifacts = async () => { try { @@ -48,6 +91,7 @@ const ListingBody: React.FC = ({ listing, newTitle }) => { if (error) { addErrorAlert(error); } else { + setArtifacts(data.artifacts); const artifactImages = data.artifacts .filter( (artifact: { artifact_type: string }) => @@ -72,20 +116,13 @@ const ListingBody: React.FC = ({ listing, newTitle }) => { fetchArtifacts(); }, [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); @@ -104,8 +141,6 @@ const ListingBody: React.FC = ({ listing, newTitle }) => { productId={productInfo.productId} checkoutLabel={`Buy ${productInfo.name}`} description={productInfo.description} - features={productInfo.features} - keyFeatures={productInfo.specs} price={productInfo.price} onImageClick={openModal} /> @@ -118,6 +153,35 @@ const ListingBody: React.FC = ({ listing, newTitle }) => { /> +
+ + {artifacts === null ? ( + + ) : artifacts ? ( + artifacts + .slice() + .reverse() + .map((artifact) => ( + + + + handleDeleteArtifact(artifact.artifact_id) + } + canEdit={listing.can_edit} + /> + + + )) + ) : null} + +
+ {isModalOpen && selectedImage && (
diff --git a/frontend/src/components/listing/ListingHeader.tsx b/frontend/src/components/listing/ListingHeader.tsx deleted file mode 100644 index 07ca6085..00000000 --- a/frontend/src/components/listing/ListingHeader.tsx +++ /dev/null @@ -1,267 +0,0 @@ -import { useEffect, useState } from "react"; -import { FaCheck, FaEye, FaHome, FaList, FaPen } from "react-icons/fa"; -import { useNavigate } from "react-router-dom"; - -import ListingVoteButtons from "@/components/listing/ListingVoteButtons"; -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"; -import { paths } from "@/gen/api"; -import { useAlertQueue } from "@/hooks/useAlertQueue"; -import { useAuthentication } from "@/hooks/useAuth"; -import { formatNumber } from "@/lib/utils/formatNumber"; -import { formatTimeSince } from "@/lib/utils/formatTimeSince"; - -import ListingDeleteButton from "./ListingDeleteButton"; - -type ListingResponse = - paths["/listings/{id}"]["get"]["responses"][200]["content"]["application/json"]; - -interface Props { - listing: ListingResponse; -} - -const ListingTitle = (props: Props) => { - const { listing } = props; - - const auth = useAuthentication(); - const { addAlert, addErrorAlert } = useAlertQueue(); - - const [isEditing, setIsEditing] = useState(false); - const [newTitle, setNewTitle] = useState(listing.name); - const [hasChanged, setHasChanged] = useState(false); - const [submitting, setSubmitting] = useState(false); - - const handleSave = async () => { - if (!hasChanged) { - setIsEditing(false); - return; - } - if (newTitle.length < 4) { - addErrorAlert("Title must be at least 4 characters long."); - return; - } - setSubmitting(true); - const { error } = await auth.client.PUT("/listings/edit/{id}", { - params: { - path: { id: listing.id }, - }, - body: { - name: newTitle, - }, - }); - if (error) { - addErrorAlert(error); - } else { - addAlert("Listing updated successfully", "success"); - setIsEditing(false); - } - setSubmitting(false); - }; - - return ( -
- {submitting ? ( - - ) : ( - <> - {isEditing ? ( - { - if (e.key === "Enter") { - handleSave(); - } - }} - onChange={(e) => { - setNewTitle(e.target.value); - setHasChanged(true); - }} - className="border-b border-gray-5" - autoFocus - /> - ) : ( -

{newTitle}

- )} - {listing.can_edit && ( - - )} - - )} -
- ); -}; - -const NavigationButtons = ({ listing }: Props) => { - const navigate = useNavigate(); - - return ( -
- - - {listing.can_edit && } -
- ); -}; - -const ListingHeader = (props: Props) => { - const { listing } = props; - const navigate = useNavigate(); - const auth = useAuthentication(); - const { addAlert, addErrorAlert } = useAlertQueue(); - - const [isEditingSlug, setIsEditingSlug] = useState(false); - const [newSlug, setNewSlug] = useState(listing.slug || ""); - const [previewUrl, setPreviewUrl] = useState(""); - - useEffect(() => { - if (auth.currentUser && newSlug) { - setPreviewUrl(`/item/${auth.currentUser.username}/${newSlug}`); - } - }, [auth.currentUser, newSlug]); - - const handleSaveSlug = async () => { - if (newSlug === listing.slug) { - setIsEditingSlug(false); - return; - } - - const { error } = await auth.client.PUT(`/listings/edit/{id}/slug`, { - params: { path: { id: listing.id }, query: { new_slug: newSlug } }, - }); - - if (error) { - addErrorAlert(error); - } else { - addAlert("Listing URL updated successfully", "success"); - setIsEditingSlug(false); - // Redirect to the new URL - if (newSlug !== "") { - navigate(`/item/${auth.currentUser?.username}/${newSlug}`); - } - } - }; - - const sanitizeSlug = (input: string) => { - // Allow alphanumeric characters and dashes, replace spaces with dashes - return input - .toLowerCase() - .replace(/\s+/g, "-") - .replace(/[^a-z0-9-]/g, "") - .replace(/-+/g, "-") // Replace multiple consecutive dashes with a single dash - .replace(/^-|-$/g, ""); // Remove leading and trailing dashes - }; - - return ( - - - -
- -
- -
-
- - {formatNumber(listing.views)} views -
-
- Posted {formatTimeSince(new Date(listing.created_at * 1000))} -
-
- {listing.creator_name && ( -
-

navigate(`/profile/${listing.creator_id}`)}> - By {listing.creator_name} -

{" "} -
- )} - {/* URL editing section */} - {listing.can_edit && ( -
- {isEditingSlug ? ( -
- - setNewSlug(sanitizeSlug(e.target.value)) - } - className="border-b border-gray-5" - placeholder="Enter new URL slug" - /> - {previewUrl && ( -
- Preview: {previewUrl} -
- )} -
- - -
-
- ) : ( - - )} -
- )} -
- -
-
-
-
- ); -}; - -export default ListingHeader; diff --git a/frontend/src/components/listing/ListingVoteButtons.tsx b/frontend/src/components/listing/ListingVoteButtons.tsx index 63d522ce..0312681e 100644 --- a/frontend/src/components/listing/ListingVoteButtons.tsx +++ b/frontend/src/components/listing/ListingVoteButtons.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { FaChevronDown, FaChevronUp } from "react-icons/fa"; import { useNavigate } from "react-router-dom"; @@ -23,7 +23,11 @@ const ListingVoteButtons = ({ const { addErrorAlert } = useAlertQueue(); const [isVoting, setIsVoting] = useState(false); const [score, setScore] = useState(initialScore); - const [userVote, setUserVote] = useState(initialUserVote); + const [userVote, setUserVote] = useState(initialUserVote); + + useEffect(() => { + setUserVote(initialUserVote); + }, [initialUserVote]); const handleVote = async (upvote: boolean, event: React.MouseEvent) => { event.stopPropagation(); @@ -33,12 +37,9 @@ const ListingVoteButtons = ({ return; } - if (isVoting) { - return; // Prevent double-clicking - } + if (isVoting) return; setIsVoting(true); - const previousVote = userVote; const previousScore = score; @@ -61,12 +62,13 @@ const ListingVoteButtons = ({ }); } else { // Add or change vote - await auth.client.POST(`/listings/{id}/vote`, { + const { error } = await auth.client.POST(`/listings/{id}/vote`, { params: { path: { id: listingId }, query: { upvote }, }, }); + if (error) throw error; } } catch (error) { // Revert changes if API call fails diff --git a/frontend/src/components/pages/Create.tsx b/frontend/src/components/pages/Create.tsx index 6394abdb..d9a13766 100644 --- a/frontend/src/components/pages/Create.tsx +++ b/frontend/src/components/pages/Create.tsx @@ -26,8 +26,8 @@ const Create = () => { const [slug, setSlug] = useState(""); const [previewUrl, setPreviewUrl] = useState(""); const [images, setImages] = useState([]); - const [keyFeatures, setKeyFeatures] = useState(""); const [displayPrice, setDisplayPrice] = useState(""); + const [isSubmitting, setIsSubmitting] = useState(false); const { register, @@ -77,15 +77,15 @@ const Create = () => { description, slug, stripe_link, - keyFeatures, price, }: NewListingType) => { + setIsSubmitting(true); + 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()); @@ -113,6 +113,8 @@ const Create = () => { } catch (error) { addErrorAlert("Failed to create listing"); console.error("Error creating listing:", error); + } finally { + setIsSubmitting(false); } }; @@ -176,35 +178,6 @@ const Create = () => {
)} - {/* Key Features */} -
- -