mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-04-10 21:00:17 +02:00
More fixes for STL viewer
This commit is contained in:
parent
a7f56df8d0
commit
cd05507da8
12
src/components/back-button.tsx
Normal file
12
src/components/back-button.tsx
Normal file
@ -0,0 +1,12 @@
|
||||
import { Button, ButtonProps } from "@chakra-ui/react";
|
||||
import { ChevronLeftIcon } from "./icons";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function BackButton({ ...props }: Omit<ButtonProps, "onClick" | "children">) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<Button leftIcon={<ChevronLeftIcon />} {...props} onClick={() => navigate(-1)}>
|
||||
Back
|
||||
</Button>
|
||||
);
|
||||
}
|
@ -1,68 +1,69 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { forwardRef } from "react";
|
||||
import { Box, BoxProps } from "@chakra-ui/react";
|
||||
import { Color, Fog, PCFSoftShadowMap } from "three";
|
||||
import { Canvas, ThreeElements, useFrame, useLoader } from "@react-three/fiber";
|
||||
import { Color, Fog, Vector3 } from "three";
|
||||
import { Canvas, useLoader } from "@react-three/fiber";
|
||||
import { OrbitControls } from "@react-three/drei";
|
||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader";
|
||||
|
||||
// http://cdn.thingiverse.com/assets/3c/fe/bc/3b/9e/3dae334c-589e-4d59-97e6-3578426751d1.stl
|
||||
const STLViewer = forwardRef<HTMLCanvasElement, Omit<BoxProps, "children"> & { url: string }>(
|
||||
({ url, ...props }, ref) => {
|
||||
const geometry = useLoader(STLLoader, url);
|
||||
|
||||
function Cube(props: ThreeElements["mesh"]) {
|
||||
const ref = useRef<THREE.Mesh>(null!);
|
||||
const [hovered, hover] = useState(false);
|
||||
const [clicked, click] = useState(false);
|
||||
useFrame((state, delta) => (ref.current.rotation.x += delta));
|
||||
if (!geometry.boundingBox) geometry.computeBoundingBox();
|
||||
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
|
||||
|
||||
return (
|
||||
<mesh
|
||||
{...props}
|
||||
ref={ref}
|
||||
scale={clicked ? 1.5 : 1}
|
||||
onClick={(event) => click(!clicked)}
|
||||
onPointerOver={(event) => hover(true)}
|
||||
onPointerOut={(event) => hover(false)}
|
||||
>
|
||||
<boxGeometry args={[1, 1, 1]} />
|
||||
<meshStandardMaterial color={hovered ? "hotpink" : "orange"} />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
const objectScale = 2 / geometry.boundingSphere!.radius;
|
||||
const bb = geometry.boundingBox!;
|
||||
const center = bb.getCenter(new Vector3()).multiplyScalar(objectScale);
|
||||
|
||||
export default function STLViewer({ url, ...props }: Omit<BoxProps, "children"> & { url: string }) {
|
||||
const geometry = useLoader(STLLoader, url);
|
||||
return (
|
||||
<Box {...props} position="relative">
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{
|
||||
antialias: true,
|
||||
shadowMapEnabled: true,
|
||||
preserveDrawingBuffer: true,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
scene={{ background: new Color(0xa0a0a0), fog: new Fog(0xa0a0a0, 4, 20) }}
|
||||
camera={{ position: [-2, 2, -2.5] }}
|
||||
ref={ref}
|
||||
>
|
||||
<OrbitControls enableDamping enablePan enableRotate enableZoom />
|
||||
<hemisphereLight color={0xffffff} groundColor={0x444444} intensity={3} position={[0, 20, 0]} />
|
||||
<ambientLight color={0xffffff} intensity={0.5} />
|
||||
<directionalLight color={0xffffff} position={[-5, 15, 10]} castShadow>
|
||||
<orthographicCamera attach="shadow-camera" args={[-2, 2, 2, -2]} />
|
||||
</directionalLight>
|
||||
<mesh
|
||||
rotation={[-Math.PI / 2, 0, 0]}
|
||||
receiveShadow
|
||||
position={[0, ((bb.min.z - bb.max.z) / 2) * objectScale, 0]}
|
||||
>
|
||||
<planeGeometry args={[40, 40]} />
|
||||
<meshPhongMaterial color={0xbbbbbb} depthWrite={false} />
|
||||
</mesh>
|
||||
<gridHelper
|
||||
args={[40, 40, 0x000000, 0x000000]}
|
||||
material-opacity={0.2}
|
||||
material-transparent={true}
|
||||
position={[0, ((bb.min.z - bb.max.z) / 2) * objectScale, 0]}
|
||||
/>
|
||||
<mesh
|
||||
geometry={geometry}
|
||||
scale={objectScale}
|
||||
rotation={[Math.PI * -0.5, 0, 0]}
|
||||
castShadow
|
||||
receiveShadow
|
||||
position={[-center.x, -center.z, center.y]}
|
||||
>
|
||||
<meshPhongMaterial color={0x1a5fb4} shininess={60} flatShading />
|
||||
</mesh>
|
||||
</Canvas>
|
||||
</Box>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
const radius = useMemo(() => {
|
||||
geometry.computeBoundingSphere();
|
||||
return geometry.boundingSphere?.radius ?? 10;
|
||||
}, [geometry]);
|
||||
|
||||
return (
|
||||
<Box {...props} position="relative">
|
||||
<Canvas
|
||||
shadows
|
||||
gl={{
|
||||
antialias: true,
|
||||
shadowMapEnabled: true,
|
||||
}}
|
||||
style={{ width: "100%", height: "100%" }}
|
||||
scene={{ background: new Color(0xa0a0a0), fog: new Fog(0xa0a0a0, 4, 20) }}
|
||||
camera={{ position: [-2, 2, -2.5] }}
|
||||
>
|
||||
<OrbitControls enableDamping enablePan enableRotate enableZoom />
|
||||
<hemisphereLight color={0xffffff} groundColor={0x444444} intensity={3} position={[0, 20, 0]} />
|
||||
<ambientLight color={0xffffff} intensity={0.5} />
|
||||
<directionalLight color={0xffffff} position={[-5, 15, 10]} castShadow>
|
||||
<orthographicCamera attach="shadow-camera" args={[-2, 2, 2, -2]} />
|
||||
</directionalLight>
|
||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
||||
<planeGeometry args={[40, 40]} />
|
||||
<meshPhongMaterial color={0xbbbbbb} depthWrite={false} />
|
||||
</mesh>
|
||||
<gridHelper args={[40, 40, 0x000000, 0x000000]} material-opacity={0.2} material-transparent={true} />
|
||||
<mesh geometry={geometry} scale={2 / radius} rotation={[Math.PI * 1.5, 0, 0]} castShadow receiveShadow>
|
||||
<meshPhongMaterial color={0x1a5fb4} shininess={60} flatShading />
|
||||
</mesh>
|
||||
</Canvas>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
export default STLViewer;
|
||||
|
@ -4,3 +4,19 @@ export function unique<T>(arr: T[]): T[] {
|
||||
export function random<T>(arr: T[]): T {
|
||||
return arr[Math.round(Math.random() * (arr.length - 1))];
|
||||
}
|
||||
|
||||
// copied from https://stackoverflow.com/a/55200387
|
||||
const byteToHex: string[] = [];
|
||||
for (let n = 0; n <= 0xff; ++n) {
|
||||
const hexOctet = n.toString(16).padStart(2, "0");
|
||||
byteToHex.push(hexOctet);
|
||||
}
|
||||
|
||||
export function arrayBufferToHex(arrayBuffer: ArrayBufferLike) {
|
||||
const buff = new Uint8Array(arrayBuffer);
|
||||
const hexOctets = []; // new Array(buff.length) is even faster (preallocates necessary array size), then use hexOctets[i] instead of .push()
|
||||
|
||||
for (let i = 0; i < buff.length; ++i) hexOctets.push(byteToHex[buff[i]]);
|
||||
|
||||
return hexOctets.join("");
|
||||
}
|
||||
|
15
src/hooks/use-object-url.ts
Normal file
15
src/hooks/use-object-url.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export default function useObjectURL(object?: File | Blob | null) {
|
||||
const [url, setUrl] = useState<string>();
|
||||
|
||||
useEffect(() => {
|
||||
if (object) {
|
||||
const u = URL.createObjectURL(object);
|
||||
setUrl(u);
|
||||
return () => URL.revokeObjectURL(u);
|
||||
}
|
||||
}, [object]);
|
||||
|
||||
return url;
|
||||
}
|
@ -2,7 +2,7 @@ import { useCallback, useRef } from "react";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
type Actions<T> = {
|
||||
setValue: (v: T | ((v: T | undefined) => T)) => void;
|
||||
setValue: (v: T | ((v: T | undefined) => T), replace?: boolean) => void;
|
||||
clearValue: () => void;
|
||||
};
|
||||
|
||||
@ -21,7 +21,7 @@ export default function useRouteStateValue<T extends unknown>(key: string, fallb
|
||||
valueRef.current = stateRef.current[key] ?? fallback;
|
||||
|
||||
const setValue = useCallback(
|
||||
(valueOrSetter: T | ((v: T) => T)) => {
|
||||
(valueOrSetter: T | ((v: T) => T), replace = true) => {
|
||||
const newState = { ...stateRef.current };
|
||||
if (typeof valueOrSetter === "function") {
|
||||
// @ts-ignore
|
||||
@ -29,7 +29,7 @@ export default function useRouteStateValue<T extends unknown>(key: string, fallb
|
||||
} else newState[key] = valueOrSetter;
|
||||
|
||||
if (stateRef.current[key] !== newState[key]) {
|
||||
navigate(".", { state: newState, replace: true });
|
||||
navigate(".", { state: newState, replace });
|
||||
}
|
||||
},
|
||||
[key],
|
||||
|
@ -1,10 +0,0 @@
|
||||
import STLViewer from "../../components/stl-viewer";
|
||||
import VerticalPageLayout from "../../components/vertical-page-layout";
|
||||
|
||||
export default function ThingUploadView() {
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<STLViewer aspectRatio={16 / 9} url="https://tonybox.net/objects/keystone/keystone.stl" />
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
43
src/views/things/upload/confirm-step.tsx
Normal file
43
src/views/things/upload/confirm-step.tsx
Normal file
@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { Button, ButtonGroup, Flex, Heading, Image, Text } from "@chakra-ui/react";
|
||||
|
||||
import useObjectURL from "../../../hooks/use-object-url";
|
||||
import BackButton from "../../../components/back-button";
|
||||
|
||||
export default function ConfirmStep({
|
||||
screenshot,
|
||||
name,
|
||||
hash,
|
||||
summary,
|
||||
onConfirm,
|
||||
}: {
|
||||
screenshot: Blob;
|
||||
name: string;
|
||||
summary: string;
|
||||
hash: string;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const objectURL = useObjectURL(screenshot);
|
||||
|
||||
const confirm = async () => {
|
||||
setLoading(true);
|
||||
await onConfirm();
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column" maxW="40rem" w="full" mx="auto">
|
||||
<Image src={objectURL} maxW="2xl" />
|
||||
<Heading size="md">{name}</Heading>
|
||||
<Text>File Hash: {hash}</Text>
|
||||
<Text whiteSpace="pre-line">{summary}</Text>
|
||||
<ButtonGroup ml="auto">
|
||||
<BackButton />
|
||||
<Button onClick={confirm} isLoading={loading} colorScheme="primary">
|
||||
Upload
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
}
|
46
src/views/things/upload/details-step.tsx
Normal file
46
src/views/things/upload/details-step.tsx
Normal file
@ -0,0 +1,46 @@
|
||||
import { Button, ButtonGroup, Flex, FormControl, FormLabel, Input, Textarea } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import BackButton from "../../../components/back-button";
|
||||
|
||||
type FormValues = {
|
||||
name: string;
|
||||
summary: string;
|
||||
};
|
||||
|
||||
export default function DetailsStep({ onSubmit }: { onSubmit: (values: FormValues) => void }) {
|
||||
const { register, getValues, setValue, handleSubmit, watch } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
name: "",
|
||||
summary: "",
|
||||
},
|
||||
mode: "all",
|
||||
});
|
||||
|
||||
const submit = handleSubmit(onSubmit);
|
||||
|
||||
return (
|
||||
<Flex as="form" onSubmit={submit} gap="2" direction="column" maxW="40rem" w="full" mx="auto">
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Name</FormLabel>
|
||||
<Input {...register("name", { required: true })} placeholder="Thing name" autoComplete="off" />
|
||||
</FormControl>
|
||||
<FormControl isRequired>
|
||||
<FormLabel>Summary</FormLabel>
|
||||
<Textarea
|
||||
rows={3}
|
||||
isRequired
|
||||
placeholder="A short summary of the thing"
|
||||
autoComplete="off"
|
||||
{...register("summary", { required: true })}
|
||||
/>
|
||||
</FormControl>
|
||||
<ButtonGroup ml="auto">
|
||||
<BackButton />
|
||||
<Button type="submit" colorScheme="primary">
|
||||
Next
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
}
|
112
src/views/things/upload/index.tsx
Normal file
112
src/views/things/upload/index.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { useState } from "react";
|
||||
import {
|
||||
Box,
|
||||
Flex,
|
||||
Step,
|
||||
StepDescription,
|
||||
StepIcon,
|
||||
StepIndicator,
|
||||
StepNumber,
|
||||
StepSeparator,
|
||||
StepStatus,
|
||||
StepTitle,
|
||||
Stepper,
|
||||
useToast,
|
||||
} from "@chakra-ui/react";
|
||||
|
||||
import VerticalPageLayout from "../../../components/vertical-page-layout";
|
||||
import { useSigningContext } from "../../../providers/signing-provider";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import useRouteStateValue from "../../../hooks/use-route-state-value";
|
||||
import SelectFileStep from "./select-file-step";
|
||||
import DetailsStep from "./details-step";
|
||||
import PreviewStep from "./preview-step";
|
||||
import ConfirmStep from "./confirm-step";
|
||||
|
||||
const steps = [{ title: "Select File" }, { title: "Details" }, { title: "Upload" }];
|
||||
|
||||
export default function ThingUploadView() {
|
||||
const navigate = useNavigate();
|
||||
const toast = useToast();
|
||||
const { requestSignature } = useSigningContext();
|
||||
|
||||
const step = useRouteStateValue("step", 0);
|
||||
|
||||
const [file, setFile] = useState<Blob>();
|
||||
const [fileURL, setFileURL] = useState<string>();
|
||||
const [hash, setHash] = useState<string>();
|
||||
const [name, setName] = useState<string>();
|
||||
const [summary, setSummary] = useState<string>();
|
||||
const [screenshot, setScreenshot] = useState<Blob>();
|
||||
|
||||
const upload = async () => {
|
||||
try {
|
||||
// const signed = await requestSignature(getDraft());
|
||||
// const pub = new NostrPublishAction("Post", clientRelaysService.getWriteUrls(), signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
toast({ description: e.message, status: "error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
switch (step.value) {
|
||||
case 0:
|
||||
return (
|
||||
<SelectFileStep
|
||||
onSubmit={(values) => {
|
||||
setFile(values.file);
|
||||
if (values.fileURL) setFileURL(values.fileURL);
|
||||
setHash(values.hash);
|
||||
step.setValue(1, false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 1:
|
||||
return (
|
||||
<DetailsStep
|
||||
onSubmit={(values) => {
|
||||
setName(values.name);
|
||||
setSummary(values.summary);
|
||||
step.setValue(2, false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 2:
|
||||
return (
|
||||
<PreviewStep
|
||||
file={file!}
|
||||
onSubmit={(values) => {
|
||||
setScreenshot(values.screenshot);
|
||||
step.setValue(3, false);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
case 3:
|
||||
return <ConfirmStep name={name!} hash={hash!} summary={summary!} screenshot={screenshot!} onConfirm={upload} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<VerticalPageLayout>
|
||||
<Stepper index={step.value}>
|
||||
{steps.map((step, index) => (
|
||||
<Step key={index}>
|
||||
<StepIndicator>
|
||||
<StepStatus complete={<StepIcon />} incomplete={<StepNumber />} active={<StepNumber />} />
|
||||
</StepIndicator>
|
||||
|
||||
<Box flexShrink="0">
|
||||
<StepTitle>{step.title}</StepTitle>
|
||||
{/* {step.description && <StepDescription>{step.description}</StepDescription>} */}
|
||||
</Box>
|
||||
|
||||
<StepSeparator />
|
||||
</Step>
|
||||
))}
|
||||
</Stepper>
|
||||
{renderContent()}
|
||||
</VerticalPageLayout>
|
||||
);
|
||||
}
|
54
src/views/things/upload/preview-step.tsx
Normal file
54
src/views/things/upload/preview-step.tsx
Normal file
@ -0,0 +1,54 @@
|
||||
import { useRef } from "react";
|
||||
import { Button, ButtonGroup, Flex, useToast } from "@chakra-ui/react";
|
||||
|
||||
import STLViewer from "../../../components/stl-viewer";
|
||||
import useObjectURL from "../../../hooks/use-object-url";
|
||||
import BackButton from "../../../components/back-button";
|
||||
|
||||
type FormValues = {
|
||||
screenshot: Blob;
|
||||
};
|
||||
|
||||
function canvasToBlob(canvas: HTMLCanvasElement, type?: string): Promise<Blob> {
|
||||
return new Promise((res, rej) => {
|
||||
canvas.toBlob((blob) => {
|
||||
if (blob) res(blob);
|
||||
else rej(new Error("Failed to get blob"));
|
||||
}, type);
|
||||
});
|
||||
}
|
||||
|
||||
export default function PreviewStep({ file, onSubmit }: { file: Blob; onSubmit: (values: FormValues) => void }) {
|
||||
const toast = useToast();
|
||||
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
const takeScreenshot = async () => {
|
||||
if (!canvasRef.current) return;
|
||||
try {
|
||||
const blob = await canvasToBlob(canvasRef.current, "image/jpeg");
|
||||
onSubmit({ screenshot: blob });
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
toast({ description: e.message, status: "error" });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const previewURL = useObjectURL(file);
|
||||
|
||||
return (
|
||||
<Flex gap="2" direction="column">
|
||||
{previewURL && (
|
||||
<>
|
||||
<STLViewer aspectRatio={16 / 10} url={previewURL} ref={canvasRef} />
|
||||
<ButtonGroup ml="auto">
|
||||
<BackButton />
|
||||
<Button onClick={takeScreenshot} colorScheme="primary">
|
||||
Take Screenshot
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</>
|
||||
)}
|
||||
</Flex>
|
||||
);
|
||||
}
|
87
src/views/things/upload/select-file-step.tsx
Normal file
87
src/views/things/upload/select-file-step.tsx
Normal file
@ -0,0 +1,87 @@
|
||||
import { ChangeEventHandler, useCallback } from "react";
|
||||
import { Button, ButtonGroup, Divider, Flex, FormControl, FormLabel, Heading, Input, useToast } from "@chakra-ui/react";
|
||||
import { useForm } from "react-hook-form";
|
||||
|
||||
import useObjectURL from "../../../hooks/use-object-url";
|
||||
import { arrayBufferToHex } from "../../../helpers/array";
|
||||
import BackButton from "../../../components/back-button";
|
||||
|
||||
type FormValues = {
|
||||
file: Blob;
|
||||
fileURL?: string;
|
||||
hash: string;
|
||||
};
|
||||
|
||||
// example file https://tonybox.net/objects/keystone/keystone.stl
|
||||
|
||||
export default function SelectFileStep({ onSubmit }: { onSubmit: (values: FormValues) => void }) {
|
||||
const toast = useToast();
|
||||
const { register, getValues, setValue, handleSubmit, watch, resetField } = useForm<FormValues>({
|
||||
defaultValues: {
|
||||
fileURL: "",
|
||||
},
|
||||
mode: "all",
|
||||
});
|
||||
watch("file");
|
||||
watch("fileURL");
|
||||
|
||||
const handleFileChange = useCallback<ChangeEventHandler<HTMLInputElement>>(
|
||||
(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) setValue("file", file);
|
||||
else resetField("file");
|
||||
},
|
||||
[setValue, resetField],
|
||||
);
|
||||
|
||||
const submit = handleSubmit(async (values) => {
|
||||
try {
|
||||
let file: Blob | undefined = values.file;
|
||||
if (!file && values.fileURL) file = await fetch(values.fileURL).then((res) => res.blob());
|
||||
if (!file) throw new Error("Cant access file");
|
||||
|
||||
// get file hash
|
||||
const buffer = await file.arrayBuffer();
|
||||
const hash = await window.crypto.subtle.digest("SHA-256", buffer);
|
||||
onSubmit({ hash: arrayBufferToHex(hash), file, fileURL: values.fileURL });
|
||||
|
||||
// const signed = await requestSignature(getDraft());
|
||||
// const pub = new NostrPublishAction("Post", clientRelaysService.getWriteUrls(), signed);
|
||||
} catch (e) {
|
||||
if (e instanceof Error) {
|
||||
toast({ description: e.message, status: "error" });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const fileObjectURL = useObjectURL(getValues().file);
|
||||
const stlURL = fileObjectURL || getValues().fileURL;
|
||||
let step = 0;
|
||||
if (getValues().file) step = 1;
|
||||
|
||||
return (
|
||||
<Flex as="form" onSubmit={submit} direction="column" gap="2" maxW="40rem" w="full" mx="auto">
|
||||
<Heading size="md">Upload File</Heading>
|
||||
<Input type="file" accept="model/stl" placeholder="Select STL file" onChange={handleFileChange} isDisabled />
|
||||
<Flex gap="4" alignItems="center" my="4">
|
||||
<Divider />
|
||||
<Heading size="sm">OR</Heading>
|
||||
<Divider />
|
||||
</Flex>
|
||||
<Heading size="md">Use Remote File</Heading>
|
||||
<Input
|
||||
type="url"
|
||||
{...register("fileURL", { validate: (str) => str && str.endsWith(".stl"), required: true })}
|
||||
placeholder="https://example.com/files/things/cube.stl"
|
||||
onChange={handleFileChange}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<ButtonGroup ml="auto">
|
||||
<BackButton />
|
||||
<Button type="submit" colorScheme="primary">
|
||||
Next
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</Flex>
|
||||
);
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user