From cd95fbc19180ab6601fd052feea943295af0aa96 Mon Sep 17 00:00:00 2001 From: Ben Bolte Date: Fri, 16 Aug 2024 18:20:51 -0700 Subject: [PATCH] multi-file urdf uploading (#295) * multi-file urdf uploading * format * fix test --- frontend/src/components/auth/AuthBlock.tsx | 5 +- .../components/auth/RequireAuthentication.tsx | 25 +- .../src/components/listing/FileUpload.tsx | 45 +--- .../components/listing/ListingArtifacts.tsx | 11 +- .../components/listing/ListingFileUpload.tsx | 42 ++-- .../src/components/listing/ListingImages.tsx | 88 ++++--- .../src/components/listing/ListingMeshes.tsx | 163 ++++++++++++ .../src/components/listing/ListingSTLs.tsx | 147 ----------- .../src/components/listing/ListingURDFs.tsx | 134 ---------- .../listing/{renderers => }/Loader.tsx | 0 .../src/components/listing/MeshRenderer.tsx | 237 ++++++++++++++++++ .../listing/renderers/StlRenderer.tsx | 108 -------- .../listing/renderers/UrdfRenderer.tsx | 97 ------- .../components/listings/ListingGridCard.tsx | 2 +- frontend/src/components/ui/Button/Button.tsx | 49 +++- frontend/src/components/ui/Header.tsx | 13 +- frontend/src/components/ui/ToolTip.tsx | 48 ++-- frontend/src/gen/api.ts | 10 +- frontend/src/hooks/api.tsx | 7 +- store/app/model.py | 26 ++ store/app/routers/artifacts.py | 64 +++-- tests/test_images.py | 6 +- tests/test_listings.py | 23 +- 23 files changed, 691 insertions(+), 659 deletions(-) create mode 100644 frontend/src/components/listing/ListingMeshes.tsx delete mode 100644 frontend/src/components/listing/ListingSTLs.tsx delete mode 100644 frontend/src/components/listing/ListingURDFs.tsx rename frontend/src/components/listing/{renderers => }/Loader.tsx (100%) create mode 100644 frontend/src/components/listing/MeshRenderer.tsx delete mode 100644 frontend/src/components/listing/renderers/StlRenderer.tsx delete mode 100644 frontend/src/components/listing/renderers/UrdfRenderer.tsx diff --git a/frontend/src/components/auth/AuthBlock.tsx b/frontend/src/components/auth/AuthBlock.tsx index d1fb0a53..e1b0eda0 100644 --- a/frontend/src/components/auth/AuthBlock.tsx +++ b/frontend/src/components/auth/AuthBlock.tsx @@ -74,13 +74,14 @@ export const AuthBlockInner = () => { interface AuthBlockProps { title?: string; + onClosed?: () => void; } -const AuthBlock: React.FC = ({ title }) => { +const AuthBlock: React.FC = ({ title, onClosed }) => { return ( -
+
diff --git a/frontend/src/components/auth/RequireAuthentication.tsx b/frontend/src/components/auth/RequireAuthentication.tsx index 0a99870a..cad75845 100644 --- a/frontend/src/components/auth/RequireAuthentication.tsx +++ b/frontend/src/components/auth/RequireAuthentication.tsx @@ -1,4 +1,5 @@ import React from "react"; +import { useNavigate } from "react-router-dom"; import { useAuthentication } from "hooks/useAuth"; @@ -6,20 +7,34 @@ import AuthBlock from "./AuthBlock"; interface Props { children: React.ReactNode; + onClosed?: () => void; } const RequireAuthentication = (props: Props) => { - const { children } = props; + const { children, onClosed: onClosedDefault } = props; const { isAuthenticated } = useAuthentication(); + const navigate = useNavigate(); + + const onClosed = + onClosedDefault || + (() => { + navigate(-1); + }); + return isAuthenticated ? ( <>{children} ) : ( -
-
- + <> +
+
+
+ +
+
-
+
+ ); }; diff --git a/frontend/src/components/listing/FileUpload.tsx b/frontend/src/components/listing/FileUpload.tsx index 75755527..70bca858 100644 --- a/frontend/src/components/listing/FileUpload.tsx +++ b/frontend/src/components/listing/FileUpload.tsx @@ -27,7 +27,6 @@ type DirectionOptions = "rtl" | "ltr" | undefined; type FileUploaderContextType = { dropzoneState: DropzoneState; - isLOF: boolean; isFileTooBig: boolean; removeFileFromSet: (index: number) => void; activeIndex: number; @@ -75,19 +74,16 @@ export const FileUploader = forwardRef< const { addErrorAlert } = useAlertQueue(); const [isFileTooBig, setIsFileTooBig] = useState(false); - const [isLOF, setIsLOF] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const { accept = { "image/*": [".jpg", ".jpeg", ".png", ".gif", ".webp"], - "application/xm": [".urdf"], + "application/xml": [".urdf"], }, - maxFiles = 1, maxSize = 4 * 1024 * 1024, multiple = true, } = dropzoneOptions; - const reSelectAll = maxFiles === 1 ? true : reSelect; const direction: DirectionOptions = dir === "rtl" ? "rtl" : "ltr"; const removeFileFromSet = useCallback( @@ -165,14 +161,12 @@ export const FileUploader = forwardRef< const newValues: File[] = value ? [...value] : []; - if (reSelectAll) { + if (reSelect) { newValues.splice(0, newValues.length); } files.forEach((file) => { - if (newValues.length < maxFiles) { - newValues.push(file); - } + newValues.push(file); }); onValueChange(newValues); @@ -192,21 +186,16 @@ export const FileUploader = forwardRef< } } }, - [reSelectAll, value], + [reSelect, value], ); useEffect(() => { if (!value) return; - if (value.length === maxFiles) { - setIsLOF(true); - return; - } - setIsLOF(false); - }, [value, maxFiles]); + }, [value]); const opts = dropzoneOptions ? dropzoneOptions - : { accept, maxFiles, maxSize, multiple }; + : { accept, maxSize, multiple }; const dropzoneState = useDropzone({ ...opts, @@ -219,7 +208,6 @@ export const FileUploader = forwardRef< >(({ className, children, ...props }, ref) => { - const { dropzoneState, isFileTooBig, isLOF } = useFileUpload(); - const rootProps = isLOF ? {} : dropzoneState.getRootProps(); + const { dropzoneState, isFileTooBig } = useFileUpload(); + const rootProps = dropzoneState.getRootProps(); return ( -
+
{children}
- +
); }); @@ -361,12 +338,10 @@ export const FileSubmitButton = forwardRef< HTMLButtonElement, React.ButtonHTMLAttributes >(({ children, className, ...props }, ref) => { - const { isLOF } = useFileUpload(); return (
) : ( -
+
- -
); }; diff --git a/frontend/src/components/listing/ListingFileUpload.tsx b/frontend/src/components/listing/ListingFileUpload.tsx index 90cfa0fb..eec605f8 100644 --- a/frontend/src/components/listing/ListingFileUpload.tsx +++ b/frontend/src/components/listing/ListingFileUpload.tsx @@ -15,15 +15,16 @@ import { import Spinner from "components/ui/Spinner"; interface Props { - artifactType: string; - fileExtensions: string[]; + accept: { + [key: string]: string[]; + }; maxSize: number; listingId: string; onUpload: (artifact: components["schemas"]["UploadArtifactResponse"]) => void; } const ListingFileUpload = (props: Props) => { - const { artifactType, fileExtensions, maxSize, listingId, onUpload } = props; + const { accept, maxSize, listingId, onUpload } = props; const { addErrorAlert } = useAlertQueue(); const auth = useAuthentication(); @@ -38,27 +39,24 @@ const ListingFileUpload = (props: Props) => { setUploading(true); (async () => { - await Promise.all( - files.map(async (file: File) => { - const { data, error } = await auth.api.upload(file, { - artifact_type: artifactType, - listing_id: listingId, - }); + const { data, error } = await auth.api.upload(files, { + listing_id: listingId, + }); - if (error) { - addErrorAlert(error); - } else { - setFiles(null); - onUpload(data); - } - }), - ); + if (error) { + addErrorAlert(error); + } else { + setFiles(null); + onUpload(data); + } setUploading(false); })(); }, [files, auth, listingId, addErrorAlert]); + const fileExtensions = Object.values(accept).flat(); + return uploading ? ( -
+
) : ( @@ -66,15 +64,13 @@ const ListingFileUpload = (props: Props) => { value={files} onValueChange={setFiles} dropzoneOptions={{ - accept: { - "image/*": fileExtensions, - }, + accept, maxSize, }} - className="relative bg-background rounded-lg pt-4 pb-2 px-2" + className="relative bg-background mt-4 rounded-lg" > -
+
Drag and drop or click to browse
diff --git a/frontend/src/components/listing/ListingImages.tsx b/frontend/src/components/listing/ListingImages.tsx index e187cec7..6b2674d6 100644 --- a/frontend/src/components/listing/ListingImages.tsx +++ b/frontend/src/components/listing/ListingImages.tsx @@ -8,10 +8,13 @@ import { useAuthentication } from "hooks/useAuth"; import ListingFileUpload from "components/listing/ListingFileUpload"; import { Button } from "components/ui/Button/Button"; +type AllArtifactsType = + components["schemas"]["ListArtifactsResponse"]["artifacts"]; + interface Props { listingId: string; edit: boolean; - allArtifacts: components["schemas"]["ListArtifactsResponse"]["artifacts"]; + allArtifacts: AllArtifactsType; } const ListingImages = (props: Props) => { @@ -20,12 +23,14 @@ const ListingImages = (props: Props) => { const auth = useAuthentication(); const { addErrorAlert } = useAlertQueue(); - const [images, setImages] = useState< - components["schemas"]["ListArtifactsResponse"]["artifacts"] - >(allArtifacts.filter((a) => a.artifact_type === "image")); + const [images, setImages] = useState( + allArtifacts.filter((a) => a.artifact_type === "image"), + ); const [deletingIds, setDeletingIds] = useState([]); const [collapsed, setCollapsed] = useState(false); + const [showImageModal, setShowImageModal] = useState(null); + const onDelete = async (imageId: string) => { setDeletingIds([...deletingIds, imageId]); @@ -47,13 +52,13 @@ const ListingImages = (props: Props) => { }; return images.length > 0 || edit ? ( -
+
{images.length > 0 ? ( <> {!collapsed && ( -
- {images.map((image) => ( + <> +
+ {images.map((image, idx) => ( +
+
+ {image.name} setShowImageModal(idx)} + /> +
+ {edit && ( + + )} +
+ ))} +
+ {showImageModal !== null && (
setShowImageModal(null)} > - {image.name} - {edit && ( - - )} +
e.stopPropagation()} + > + {images[showImageModal].name} +
- ))} -
+ )} + )} ) : ( @@ -96,12 +123,13 @@ const ListingImages = (props: Props) => { )} {edit && ( { - setImages([...images, artifact.artifact]); + setImages([...images, ...artifact.artifacts]); }} /> )} diff --git a/frontend/src/components/listing/ListingMeshes.tsx b/frontend/src/components/listing/ListingMeshes.tsx new file mode 100644 index 00000000..bbc7bbb7 --- /dev/null +++ b/frontend/src/components/listing/ListingMeshes.tsx @@ -0,0 +1,163 @@ +import { useEffect, useState } from "react"; +import { FaCaretSquareDown, FaCaretSquareUp } from "react-icons/fa"; + +import { components } from "gen/api"; +import { useAlertQueue } from "hooks/useAlertQueue"; +import { useAuthentication } from "hooks/useAuth"; + +import ListingFileUpload from "components/listing/ListingFileUpload"; +import MeshRenderer from "components/listing/MeshRenderer"; +import { Button } from "components/ui/Button/Button"; +import { Tooltip } from "components/ui/ToolTip"; + +type MeshType = "stl" | "urdf"; +type AllArtifactsType = + components["schemas"]["ListArtifactsResponse"]["artifacts"]; +type ArtifactType = AllArtifactsType[0]; +type MeshAndArtifactType = [MeshType, ArtifactType]; + +interface Props { + listingId: string; + edit: boolean; + allArtifacts: AllArtifactsType; +} + +const getMeshType = (artifactType: ArtifactType["artifact_type"]): MeshType => { + switch (artifactType) { + case "stl": + case "urdf": + return artifactType; + default: + throw new Error(`Unknown artifact type: ${artifactType}`); + } +}; + +const ListingMeshes = (props: Props) => { + const { listingId, edit, allArtifacts } = props; + + const auth = useAuthentication(); + const { addErrorAlert } = useAlertQueue(); + + const [meshes, setMeshes] = useState( + allArtifacts + .filter((a) => ["stl", "urdf"].includes(a.artifact_type)) + .sort((a) => (a.artifact_type === "urdf" ? 1 : -1)), + ); + const [mesh, setMesh] = useState(null); + const [deletingIds, setDeletingIds] = useState([]); + const [collapsed, setCollapsed] = useState(true); + const [currentId, setCurrentId] = useState(0); + const [changeMeshDisabled, setChangeMeshDisabled] = useState(false); + + useEffect(() => { + if (mesh !== null || meshes === null) { + return; + } + + if (currentId >= meshes.length || currentId < 0) { + setCurrentId(Math.min(Math.max(currentId, 0), meshes.length - 1)); + } else { + const currentMesh = meshes[currentId]; + setMesh([getMeshType(currentMesh.artifact_type), currentMesh]); + } + }, [mesh, meshes, currentId]); + + if (meshes === null) return null; + + const onDelete = async (meshId: string) => { + setDeletingIds([...deletingIds, meshId]); + + const { error } = await auth.client.DELETE( + "/artifacts/delete/{artifact_id}", + { + params: { + path: { artifact_id: meshId }, + }, + }, + ); + + if (error) { + addErrorAlert(error); + } else { + setMeshes(meshes.filter((mesh) => mesh.artifact_id !== meshId)); + setMesh(null); + setDeletingIds(deletingIds.filter((id) => id !== meshId)); + } + }; + + return mesh !== null || edit ? ( +
+ {mesh !== null ? ( + <> + + {!collapsed && ( + <> +
+ {meshes.map((mesh, idx) => ( + + + + ))} +
+ onDelete(mesh[1].artifact_id)} + disabled={deletingIds.includes(mesh[1].artifact_id)} + /> + + )} + + ) : ( +

+ Meshes +

+ )} + {edit && ( + { + setMeshes([...meshes, ...artifact.artifacts]); + }} + /> + )} +
+ ) : null; +}; + +export default ListingMeshes; diff --git a/frontend/src/components/listing/ListingSTLs.tsx b/frontend/src/components/listing/ListingSTLs.tsx deleted file mode 100644 index 51abab27..00000000 --- a/frontend/src/components/listing/ListingSTLs.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { useEffect, useState } from "react"; -import { FaCaretSquareDown, FaCaretSquareUp } from "react-icons/fa"; - -import { cx } from "class-variance-authority"; -import { components } from "gen/api"; -import { useAlertQueue } from "hooks/useAlertQueue"; -import { useAuthentication } from "hooks/useAuth"; - -import ListingFileUpload from "components/listing/ListingFileUpload"; -import StlRenderer from "components/listing/renderers/StlRenderer"; -import { Button } from "components/ui/Button/Button"; - -interface Props { - listingId: string; - edit: boolean; - allArtifacts: components["schemas"]["ListArtifactsResponse"]["artifacts"]; -} - -const ListingSTLs = (props: Props) => { - const { listingId, edit, allArtifacts } = props; - - const auth = useAuthentication(); - const { addErrorAlert } = useAlertQueue(); - - const [stls, setStls] = useState< - components["schemas"]["ListArtifactsResponse"]["artifacts"] - >(allArtifacts.filter((a) => a.artifact_type === "stl")); - const [stl, setStl] = useState< - components["schemas"]["ListArtifactsResponse"]["artifacts"][0] | null - >(null); - const [deletingIds, setDeletingIds] = useState([]); - const [collapsed, setCollapsed] = useState(true); - const [currentId, setCurrentId] = useState(0); - - useEffect(() => { - if (stl !== null) { - return; - } - - if (currentId >= stls.length || currentId < 0) { - setCurrentId(Math.min(Math.max(currentId, 0), stls.length - 1)); - } else { - setStl(stls[currentId]); - } - }, [stl, stls, currentId]); - - const onDelete = async (stlId: string) => { - setDeletingIds([...deletingIds, stlId]); - - const { error } = await auth.client.DELETE( - "/artifacts/delete/{artifact_id}", - { - params: { - path: { artifact_id: stlId }, - }, - }, - ); - - if (error) { - addErrorAlert(error); - } else { - setStls(stls.filter((stl) => stl.artifact_id !== stlId)); - setStl(null); - setDeletingIds(deletingIds.filter((id) => id !== stlId)); - } - }; - - return stl !== null || edit ? ( -
- {stl !== null ? ( - <> - - {!collapsed && stls.length > 1 && ( -
- {stls.map((stl, idx) => ( - - ))} -
- )} - {!collapsed && ( -
-
- onDelete(stl.artifact_id)} - disabled={deletingIds.includes(stl.artifact_id)} - /> -
-
- )} - - ) : ( -

- STLs -

- )} - {edit && ( - { - setStls([...stls, artifact.artifact]); - }} - /> - )} -
- ) : ( - <> - ); -}; - -export default ListingSTLs; diff --git a/frontend/src/components/listing/ListingURDFs.tsx b/frontend/src/components/listing/ListingURDFs.tsx deleted file mode 100644 index 0adb9b7a..00000000 --- a/frontend/src/components/listing/ListingURDFs.tsx +++ /dev/null @@ -1,134 +0,0 @@ -import { useState } from "react"; -import { FaCaretSquareDown, FaCaretSquareUp } from "react-icons/fa"; - -import { cx } from "class-variance-authority"; -import { components } from "gen/api"; -import { useAlertQueue } from "hooks/useAlertQueue"; -import { useAuthentication } from "hooks/useAuth"; - -import ListingFileUpload from "components/listing/ListingFileUpload"; -import UrdfRenderer from "components/listing/renderers/UrdfRenderer"; -import { Button } from "components/ui/Button/Button"; - -interface Props { - listingId: string; - edit: boolean; - allArtifacts: components["schemas"]["ListArtifactsResponse"]["artifacts"]; -} - -const ListingURDFs = (props: Props) => { - const { listingId, edit, allArtifacts } = props; - - const auth = useAuthentication(); - const { addErrorAlert } = useAlertQueue(); - - const [urdfs, setUrdfs] = useState< - components["schemas"]["ListArtifactsResponse"]["artifacts"] - >(allArtifacts.filter((a) => a.artifact_type === "urdf")); - const [deletingIds, setDeletingIds] = useState([]); - const [collapsed, setCollapsed] = useState(true); - - const [currentIdUnchecked, setCurrentId] = useState(0); - const currentId = Math.min(Math.max(currentIdUnchecked, 0), urdfs.length - 1); - const urdf = urdfs.length === 0 ? null : urdfs[currentId]; - - const onDelete = async (urdfId: string) => { - setDeletingIds([...deletingIds, urdfId]); - - const { error } = await auth.client.DELETE( - "/artifacts/delete/{artifact_id}", - { - params: { - path: { artifact_id: urdfId }, - }, - }, - ); - - if (error) { - addErrorAlert(error); - } else { - if (currentId >= urdfs.length) { - setCurrentId(urdfs.length - 1); - } - setUrdfs(urdfs.filter((urdf) => urdf.artifact_id !== urdfId)); - setDeletingIds(deletingIds.filter((id) => id !== urdfId)); - } - }; - - return urdf !== null || edit ? ( -
- {urdf !== null ? ( - <> - - {!collapsed && urdfs.length > 1 && ( -
- {urdfs.map((urdf, idx) => ( - - ))} -
- )} - {!collapsed && ( -
-
- onDelete(urdf.artifact_id)} - disabled={deletingIds.includes(urdf.artifact_id)} - /> -
-
- )} - - ) : ( -

- URDFs -

- )} - {edit && ( - { - setUrdfs([...urdfs, artifact.artifact]); - }} - /> - )} -
- ) : ( - <> - ); -}; - -export default ListingURDFs; diff --git a/frontend/src/components/listing/renderers/Loader.tsx b/frontend/src/components/listing/Loader.tsx similarity index 100% rename from frontend/src/components/listing/renderers/Loader.tsx rename to frontend/src/components/listing/Loader.tsx diff --git a/frontend/src/components/listing/MeshRenderer.tsx b/frontend/src/components/listing/MeshRenderer.tsx new file mode 100644 index 00000000..74883197 --- /dev/null +++ b/frontend/src/components/listing/MeshRenderer.tsx @@ -0,0 +1,237 @@ +/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ +// @ts-nocheck + +/* eslint-disable react/no-unknown-property */ +import { Suspense, useRef, useState } from "react"; + +import { + Center, + OrbitControls, + PerspectiveCamera, + Plane, +} from "@react-three/drei"; +import { Canvas, useLoader } from "@react-three/fiber"; +import { cx } from "class-variance-authority"; +import { Group } from "three"; +import { STLLoader } from "three/examples/jsm/loaders/STLLoader"; +import URDFLoader from "urdf-loader"; + +import Loader from "components/listing/Loader"; +import { Button } from "components/ui/Button/Button"; + +type MeshType = "wireframe" | "basic"; + +const MeshTypes: MeshType[] = ["wireframe", "basic"]; + +const getMaterial = (meshType: MeshType) => { + switch (meshType) { + case "wireframe": + return ; + case "basic": + default: + return ( + + ); + } +}; + +interface UrdfModelProps { + url: string; + meshType: MeshType; +} + +const UrdfModel = ({ url, meshType }: UrdfModelProps) => { + const ref = useRef(); + const [robot, setRobot] = useState(); + + const loader = new URDFLoader(); + + loader.load(url, (robot) => { + setRobot(robot); + }); + + return robot ? ( + + + + {getMaterial(meshType)} + + + + + + ) : null; +}; + +interface StlModelProps { + url: string; + meshType: MeshType; +} + +const StlModel = ({ url, meshType }: StlModelProps) => { + const geom = useLoader(STLLoader, url); + + return ( + + + {getMaterial(meshType)} + + ); +}; + +interface ModelProps { + url: string; + meshType: MeshType; + kind: "stl" | "urdf"; +} + +const Model = ({ url, meshType, kind }: ModelProps) => { + switch (kind) { + case "stl": + return ; + case "urdf": + return ; + default: + return null; + } +}; + +interface Props { + url: string; + name: string; + edit?: boolean; + onDelete?: () => void; + disabled?: boolean; + kind: "stl" | "urdf"; +} + +const MeshRenderer = ({ url, name, edit, onDelete, disabled, kind }: Props) => { + const [meshType, setMeshType] = useState("basic"); + const [clickedCopyButton, setClickedCopyButton] = useState(false); + const [confirmDelete, setConfirmDelete] = useState(false); + + return ( +
+ {/* Title */} +
+

{name}

+
+ + {/* Canvas */} + + + + + + }> +
+ +
+
+
+ + {/* Button grid */} +
+ +
+ +
+ + {edit && ( + <> + + {confirmDelete && ( +
+
+

+ Are you sure you want to delete this artifact? +

+
+ + +
+
+
+ )} + + )} +
+
+ ); +}; + +export default MeshRenderer; diff --git a/frontend/src/components/listing/renderers/StlRenderer.tsx b/frontend/src/components/listing/renderers/StlRenderer.tsx deleted file mode 100644 index cdd8684d..00000000 --- a/frontend/src/components/listing/renderers/StlRenderer.tsx +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable react/no-unknown-property */ -import { Suspense, useState } from "react"; -import { FaTimes } from "react-icons/fa"; - -import { Center, OrbitControls, PerspectiveCamera } from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; -import { useLoader } from "@react-three/fiber"; -import { STLLoader } from "three/examples/jsm/loaders/STLLoader"; - -import Loader from "components/listing/renderers/Loader"; -import { Button } from "components/ui/Button/Button"; - -type MeshType = "wireframe" | "basic"; - -const MeshTypes: MeshType[] = ["wireframe", "basic"]; - -const getMaterial = (meshType: MeshType) => { - switch (meshType) { - case "wireframe": - return ; - case "basic": - default: - return ( - - ); - } -}; - -interface ModelProps { - url: string; - meshType: MeshType; -} - -const Model = ({ url, meshType }: ModelProps) => { - const geom = useLoader(STLLoader, url); - - return ( - - - {getMaterial(meshType)} - - ); -}; - -interface Props { - url: string; - edit?: boolean; - onDelete?: () => void; - disabled?: boolean; -} - -const StlRenderer = ({ url, edit, onDelete, disabled }: Props) => { - const [meshType, setMeshType] = useState("basic"); - - console.log("stl", url); - - return ( - <> - - - - - - }> -
- -
-
-
- {edit && ( - - )} - - - ); -}; - -export default StlRenderer; diff --git a/frontend/src/components/listing/renderers/UrdfRenderer.tsx b/frontend/src/components/listing/renderers/UrdfRenderer.tsx deleted file mode 100644 index 35f59376..00000000 --- a/frontend/src/components/listing/renderers/UrdfRenderer.tsx +++ /dev/null @@ -1,97 +0,0 @@ -/* eslint-disable-next-line @typescript-eslint/ban-ts-comment */ -// @ts-nocheck - -/* eslint-disable react/no-unknown-property */ -import { Suspense, useRef } from "react"; -import { FaTimes } from "react-icons/fa"; - -import { - Center, - OrbitControls, - PerspectiveCamera, - Plane, -} from "@react-three/drei"; -import { Canvas } from "@react-three/fiber"; -import { useLoader } from "@react-three/fiber"; -import { Group } from "three"; -import URDFLoader from "urdf-loader"; - -import Loader from "components/listing/renderers/Loader"; -import { Button } from "components/ui/Button/Button"; - -interface ModelProps { - url: string; -} - -const Model = ({ url }: ModelProps) => { - const ref = useRef(); - const robot = useLoader(URDFLoader, url); - - return ( - - - - - - - - - ); -}; - -interface Props { - url: string; - edit?: boolean; - onDelete?: () => void; - disabled?: boolean; -} - -const UrdfRenderer = ({ url, edit, onDelete, disabled }: Props) => { - return ( - <> - - - - - - }> -
- -
-
-
- {edit && ( - - )} - - ); -}; - -export default UrdfRenderer; diff --git a/frontend/src/components/listings/ListingGridCard.tsx b/frontend/src/components/listings/ListingGridCard.tsx index a0d15d5d..75b8e7b1 100644 --- a/frontend/src/components/listings/ListingGridCard.tsx +++ b/frontend/src/components/listings/ListingGridCard.tsx @@ -36,7 +36,7 @@ const ListingGridCard = (props: Props) => { onClick={() => navigate(`/item/${listingId}`)} > {listing?.image_url ? ( -
+
{listing.name}( +const Button = forwardRef( ({ className, variant, size, asChild = false, ...props }, ref) => { const Comp = asChild ? Slot : "button"; + return ( ( ); Button.displayName = "Button"; -export { Button, buttonVariants }; +const ScrollableButton = ({ children, ...props }: ButtonProps) => { + const codeRef = useRef(null); + const [isOverflowing, setIsOverflowing] = useState(false); + const [mouseOver, setMouseOver] = useState(false); + const [scrollWidth, setScrollWidth] = useState(0); + const [clientWidth, setClientWidth] = useState(0); + + useEffect(() => { + const codeElement = codeRef.current; + if (codeElement) { + const isOverflow = codeElement.scrollWidth > codeElement.clientWidth; + setIsOverflowing(isOverflow); + setScrollWidth(codeElement.scrollWidth); + setClientWidth(codeElement.clientWidth); + } + }, [children]); + + return ( + + ); +}; + +export { Button, ScrollableButton, buttonVariants }; diff --git a/frontend/src/components/ui/Header.tsx b/frontend/src/components/ui/Header.tsx index 695ad08d..df0094d0 100644 --- a/frontend/src/components/ui/Header.tsx +++ b/frontend/src/components/ui/Header.tsx @@ -1,17 +1,28 @@ +import { FaTimes } from "react-icons/fa"; + import { cn } from "utils"; interface HeaderProps { title?: string; label?: string; + onClosed?: () => void; } -const Header = ({ title, label }: HeaderProps) => { +const Header = ({ title, label, onClosed }: HeaderProps) => { return (

{title ?? "K-Scale Store"}

{label &&

{label}

} + {onClosed && ( + + )}
); }; diff --git a/frontend/src/components/ui/ToolTip.tsx b/frontend/src/components/ui/ToolTip.tsx index eb4fbe70..c8a50d65 100644 --- a/frontend/src/components/ui/ToolTip.tsx +++ b/frontend/src/components/ui/ToolTip.tsx @@ -1,28 +1,34 @@ import * as React from "react"; -import * as TooltipPrimitive from "@radix-ui/react-tooltip"; import { cn } from "utils"; -const TooltipProvider = TooltipPrimitive.Provider; +interface TooltipProps { + content: string; + children: React.ReactNode; +} -const Tooltip = TooltipPrimitive.Root; +const Tooltip: React.FC = ({ content, children }) => { + const [visible, setVisible] = React.useState(false); -const TooltipTrigger = TooltipPrimitive.Trigger; + return ( +
setVisible(true)} + onMouseLeave={() => setVisible(false)} + > + {children} + {visible && ( +
+ {content} +
+ )} +
+ ); +}; -const TooltipContent = React.forwardRef< - React.ElementRef, - React.ComponentPropsWithoutRef ->(({ className, sideOffset = 4, ...props }, ref) => ( - -)); -TooltipContent.displayName = TooltipPrimitive.Content.displayName; - -export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; +export { Tooltip }; diff --git a/frontend/src/gen/api.ts b/frontend/src/gen/api.ts index 5fa83940..0238176e 100644 --- a/frontend/src/gen/api.ts +++ b/frontend/src/gen/api.ts @@ -546,11 +546,8 @@ export interface components { }; /** Body_upload_artifacts_upload_post */ Body_upload_artifacts_upload_post: { - /** - * File - * Format: binary - */ - file: string; + /** Files */ + files: string[]; /** Metadata */ metadata: string; }; @@ -784,7 +781,8 @@ export interface components { }; /** UploadArtifactResponse */ UploadArtifactResponse: { - artifact: components["schemas"]["ListArtifactsItem"]; + /** Artifacts */ + artifacts: components["schemas"]["ListArtifactsItem"][]; }; /** UserInfoResponseItem */ UserInfoResponseItem: { diff --git a/frontend/src/hooks/api.tsx b/frontend/src/hooks/api.tsx index ccb5584b..f4d5ff62 100644 --- a/frontend/src/hooks/api.tsx +++ b/frontend/src/hooks/api.tsx @@ -9,20 +9,19 @@ export default class api { } public async upload( - file: File, + files: File[], request: { - artifact_type: string; listing_id: string; }, ) { return await this.client.POST("/artifacts/upload", { body: { - file: "", + files: [], metadata: "image", }, bodySerializer() { const fd = new FormData(); - fd.append("file", file); + files.forEach((file) => fd.append("files", file)); fd.append("metadata", JSON.stringify(request)); return fd; }, diff --git a/store/app/model.py b/store/app/model.py index f76203d2..b21c7ebd 100644 --- a/store/app/model.py +++ b/store/app/model.py @@ -175,6 +175,32 @@ def create(cls, user_id: str, source: APIKeySource, permissions: APIKeyPermissio } +def get_artifact_type(content_type: str, filename: str) -> ArtifactType: + # Attempts to determine from file extension. + extension = filename.split(".")[-1].lower() + if extension in ("png", "jpeg", "jpg", "gif", "webp"): + return "image" + if extension in ("urdf", "xml"): + return "urdf" + if extension in ("mjcf",): + return "mjcf" + if extension in ("stl",): + return "stl" + + # Attempts to determine from content type. + if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["image"]: + return "image" + if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["urdf"]: + return "urdf" + if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["mjcf"]: + return "mjcf" + if content_type in UPLOAD_CONTENT_TYPE_OPTIONS["stl"]: + return "stl" + + # Throws a value error if the type cannot be determined. + raise ValueError(f"Unknown content type for file: {filename}") + + def get_artifact_name(id: str, artifact_type: ArtifactType, size: ArtifactSize = "large") -> str: match artifact_type: case "image": diff --git a/store/app/routers/artifacts.py b/store/app/routers/artifacts.py index 3e33e3ff..c2892f3a 100644 --- a/store/app/routers/artifacts.py +++ b/store/app/routers/artifacts.py @@ -1,5 +1,6 @@ """Defines the router endpoints for handling listing artifacts.""" +import asyncio import logging from typing import Annotated @@ -8,7 +9,14 @@ from pydantic.main import BaseModel from store.app.db import Crud -from store.app.model import UPLOAD_CONTENT_TYPE_OPTIONS, ArtifactSize, ArtifactType, User, get_artifact_url +from store.app.model import ( + UPLOAD_CONTENT_TYPE_OPTIONS, + ArtifactSize, + ArtifactType, + User, + get_artifact_type, + get_artifact_url, +) from store.app.routers.users import get_session_user_with_write_permission from store.settings import settings @@ -57,7 +65,7 @@ async def list_artifacts(listing_id: str, crud: Annotated[Crud, Depends(Crud.get ) -def validate_file(file: UploadFile, artifact_type: ArtifactType) -> str: +def validate_file(file: UploadFile) -> tuple[str, ArtifactType]: if file.filename is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, @@ -83,35 +91,38 @@ def validate_file(file: UploadFile, artifact_type: ArtifactType) -> str: status_code=status.HTTP_400_BAD_REQUEST, detail="Artifact content type was not provided", ) + + # Parses the artifact type from the content type and filename. + artifact_type = get_artifact_type(content_type, file.filename) if content_type not in UPLOAD_CONTENT_TYPE_OPTIONS[artifact_type]: content_type_options_string = ", ".join(UPLOAD_CONTENT_TYPE_OPTIONS[artifact_type]) raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Invalid content type {content_type}; expected one of {content_type_options_string}", ) - return file.filename + + return file.filename, artifact_type class UploadArtifactRequest(BaseModel): - artifact_type: ArtifactType listing_id: str description: str | None = None class UploadArtifactResponse(BaseModel): - artifact: ListArtifactsItem + artifacts: list[ListArtifactsItem] @artifacts_router.post("/upload", response_model=UploadArtifactResponse) async def upload( user: Annotated[User, Depends(get_session_user_with_write_permission)], crud: Annotated[Crud, Depends(Crud.get)], - file: UploadFile, + files: list[UploadFile], metadata: Annotated[str, Form()], ) -> UploadArtifactResponse: # Converts the metadata JSON string to a Pydantic model. data = UploadArtifactRequest.model_validate_json(metadata) - filename = validate_file(file, data.artifact_type) + filenames = [validate_file(file) for file in files] # Checks that the listing is valid. listing = await crud.get_listing(data.listing_id) @@ -122,23 +133,32 @@ async def upload( ) # Uploads the artifact and adds it to the listing. - artifact = await crud.upload_artifact( - file=file.file, - name=filename, - listing=listing, - user_id=user.id, - artifact_type=data.artifact_type, - description=data.description, + artifacts = await asyncio.gather( + *( + crud.upload_artifact( + file=file.file, + name=filename, + listing=listing, + user_id=user.id, + artifact_type=artifact_type, + description=data.description, + ) + for file, (filename, artifact_type) in zip(files, filenames) + ) ) + return UploadArtifactResponse( - artifact=ListArtifactsItem( - artifact_id=artifact.id, - name=artifact.name, - artifact_type=artifact.artifact_type, - description=artifact.description, - timestamp=artifact.timestamp, - url=get_artifact_url(artifact.id, artifact.artifact_type), - ), + artifacts=[ + ListArtifactsItem( + artifact_id=artifact.id, + name=artifact.name, + artifact_type=artifact.artifact_type, + description=artifact.description, + timestamp=artifact.timestamp, + url=get_artifact_url(artifact.id, artifact.artifact_type), + ) + for artifact in artifacts + ] ) diff --git a/tests/test_images.py b/tests/test_images.py index 6e7165a6..a826ffe9 100644 --- a/tests/test_images.py +++ b/tests/test_images.py @@ -39,13 +39,13 @@ async def test_user_auth_functions(app_client: AsyncClient, tmpdir: Path) -> Non data_json = json.dumps({"artifact_type": "image", "listing_id": listing_id}) response = await app_client.post( "/artifacts/upload", - files={"file": ("test.png", open(image_path, "rb"), "image/png"), "metadata": (None, data_json)}, + files={"files": ("test.png", open(image_path, "rb"), "image/png"), "metadata": (None, data_json)}, headers=auth_headers, ) assert response.status_code == status.HTTP_200_OK, response.json() data = response.json() - assert data["artifact"] is not None - image_id = data["artifact"]["artifact_id"] + assert data["artifacts"] is not None + image_id = data["artifacts"][0]["artifact_id"] # Gets the URLs for various sizes of images. response = await app_client.get(f"/artifacts/url/image/{image_id}", params={"size": "small"}) diff --git a/tests/test_listings.py b/tests/test_listings.py index 63bf8875..426830d2 100644 --- a/tests/test_listings.py +++ b/tests/test_listings.py @@ -36,44 +36,47 @@ async def test_listings(app_client: AsyncClient, tmpdir: Path) -> None: image = Image.new("RGB", (100, 100)) image_path = Path(tmpdir) / "test.png" image.save(image_path) - data_json = json.dumps({"artifact_type": "image", "listing_id": listing_id}) + data_json = json.dumps({"listing_id": listing_id}) response = await app_client.post( "/artifacts/upload", headers=auth_headers, - files={"file": ("test.png", open(image_path, "rb"), "image/png"), "metadata": (None, data_json)}, + files={"files": ("test.png", open(image_path, "rb"), "image/png"), "metadata": (None, data_json)}, ) assert response.status_code == status.HTTP_200_OK, response.json() data = response.json() - assert data["artifact"]["artifact_id"] is not None + assert data["artifacts"][0]["artifact_id"] is not None # Uploads a URDF. urdf_path = Path(__file__).parent / "assets" / "sample.urdf" - data_json = json.dumps({"artifact_type": "urdf", "listing_id": listing_id}) + data_json = json.dumps({"listing_id": listing_id}) response = await app_client.post( "/artifacts/upload", headers=auth_headers, - files={"file": ("box.urdf", open(urdf_path, "rb"), "application/xml"), "metadata": (None, data_json)}, + files={"files": ("box.urdf", open(urdf_path, "rb"), "application/xml"), "metadata": (None, data_json)}, ) assert response.status_code == status.HTTP_200_OK, response.json() data = response.json() - assert data["artifact"]["artifact_id"] is not None + assert data["artifacts"][0]["artifact_id"] is not None # Gets the URDF URL. - artifact_id = data["artifact"]["artifact_id"] + artifact_id = data["artifacts"][0]["artifact_id"] response = await app_client.get(f"/artifacts/url/urdf/{artifact_id}", headers=auth_headers) assert response.status_code == status.HTTP_307_TEMPORARY_REDIRECT, response.content # Uploads an STL. stl_path = Path(__file__).parent / "assets" / "teapot.stl" - data_json = json.dumps({"artifact_type": "stl", "listing_id": listing_id}) + data_json = json.dumps({"listing_id": listing_id}) response = await app_client.post( "/artifacts/upload", headers=auth_headers, - files={"file": ("teapot.stl", open(stl_path, "rb"), "application/octet-stream"), "metadata": (None, data_json)}, + files={ + "files": ("teapot.stl", open(stl_path, "rb"), "application/octet-stream"), + "metadata": (None, data_json), + }, ) assert response.status_code == status.HTTP_200_OK, response.json() data = response.json() - assert data["artifact"]["artifact_id"] is not None + assert data["artifacts"][0]["artifact_id"] is not None # Searches for listings. response = await app_client.get(