mirror of
https://github.com/hzrd149/nostrudel.git
synced 2025-07-16 15:22:23 +02:00
More fixes for STL viewer
This commit is contained in:
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,40 +1,20 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { forwardRef } from "react";
|
||||||
import { Box, BoxProps } from "@chakra-ui/react";
|
import { Box, BoxProps } from "@chakra-ui/react";
|
||||||
import { Color, Fog, PCFSoftShadowMap } from "three";
|
import { Color, Fog, Vector3 } from "three";
|
||||||
import { Canvas, ThreeElements, useFrame, useLoader } from "@react-three/fiber";
|
import { Canvas, useLoader } from "@react-three/fiber";
|
||||||
import { OrbitControls } from "@react-three/drei";
|
import { OrbitControls } from "@react-three/drei";
|
||||||
import { STLLoader } from "three/examples/jsm/loaders/STLLoader";
|
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) => {
|
||||||
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));
|
|
||||||
|
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function STLViewer({ url, ...props }: Omit<BoxProps, "children"> & { url: string }) {
|
|
||||||
const geometry = useLoader(STLLoader, url);
|
const geometry = useLoader(STLLoader, url);
|
||||||
|
|
||||||
const radius = useMemo(() => {
|
if (!geometry.boundingBox) geometry.computeBoundingBox();
|
||||||
geometry.computeBoundingSphere();
|
if (!geometry.boundingSphere) geometry.computeBoundingSphere();
|
||||||
return geometry.boundingSphere?.radius ?? 10;
|
|
||||||
}, [geometry]);
|
const objectScale = 2 / geometry.boundingSphere!.radius;
|
||||||
|
const bb = geometry.boundingBox!;
|
||||||
|
const center = bb.getCenter(new Vector3()).multiplyScalar(objectScale);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box {...props} position="relative">
|
<Box {...props} position="relative">
|
||||||
@ -43,10 +23,12 @@ export default function STLViewer({ url, ...props }: Omit<BoxProps, "children">
|
|||||||
gl={{
|
gl={{
|
||||||
antialias: true,
|
antialias: true,
|
||||||
shadowMapEnabled: true,
|
shadowMapEnabled: true,
|
||||||
|
preserveDrawingBuffer: true,
|
||||||
}}
|
}}
|
||||||
style={{ width: "100%", height: "100%" }}
|
style={{ width: "100%", height: "100%" }}
|
||||||
scene={{ background: new Color(0xa0a0a0), fog: new Fog(0xa0a0a0, 4, 20) }}
|
scene={{ background: new Color(0xa0a0a0), fog: new Fog(0xa0a0a0, 4, 20) }}
|
||||||
camera={{ position: [-2, 2, -2.5] }}
|
camera={{ position: [-2, 2, -2.5] }}
|
||||||
|
ref={ref}
|
||||||
>
|
>
|
||||||
<OrbitControls enableDamping enablePan enableRotate enableZoom />
|
<OrbitControls enableDamping enablePan enableRotate enableZoom />
|
||||||
<hemisphereLight color={0xffffff} groundColor={0x444444} intensity={3} position={[0, 20, 0]} />
|
<hemisphereLight color={0xffffff} groundColor={0x444444} intensity={3} position={[0, 20, 0]} />
|
||||||
@ -54,15 +36,34 @@ export default function STLViewer({ url, ...props }: Omit<BoxProps, "children">
|
|||||||
<directionalLight color={0xffffff} position={[-5, 15, 10]} castShadow>
|
<directionalLight color={0xffffff} position={[-5, 15, 10]} castShadow>
|
||||||
<orthographicCamera attach="shadow-camera" args={[-2, 2, 2, -2]} />
|
<orthographicCamera attach="shadow-camera" args={[-2, 2, 2, -2]} />
|
||||||
</directionalLight>
|
</directionalLight>
|
||||||
<mesh rotation={[-Math.PI / 2, 0, 0]} receiveShadow>
|
<mesh
|
||||||
|
rotation={[-Math.PI / 2, 0, 0]}
|
||||||
|
receiveShadow
|
||||||
|
position={[0, ((bb.min.z - bb.max.z) / 2) * objectScale, 0]}
|
||||||
|
>
|
||||||
<planeGeometry args={[40, 40]} />
|
<planeGeometry args={[40, 40]} />
|
||||||
<meshPhongMaterial color={0xbbbbbb} depthWrite={false} />
|
<meshPhongMaterial color={0xbbbbbb} depthWrite={false} />
|
||||||
</mesh>
|
</mesh>
|
||||||
<gridHelper args={[40, 40, 0x000000, 0x000000]} material-opacity={0.2} material-transparent={true} />
|
<gridHelper
|
||||||
<mesh geometry={geometry} scale={2 / radius} rotation={[Math.PI * 1.5, 0, 0]} castShadow receiveShadow>
|
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 />
|
<meshPhongMaterial color={0x1a5fb4} shininess={60} flatShading />
|
||||||
</mesh>
|
</mesh>
|
||||||
</Canvas>
|
</Canvas>
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default STLViewer;
|
||||||
|
@ -4,3 +4,19 @@ export function unique<T>(arr: T[]): T[] {
|
|||||||
export function random<T>(arr: T[]): T {
|
export function random<T>(arr: T[]): T {
|
||||||
return arr[Math.round(Math.random() * (arr.length - 1))];
|
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";
|
import { useLocation, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
type Actions<T> = {
|
type Actions<T> = {
|
||||||
setValue: (v: T | ((v: T | undefined) => T)) => void;
|
setValue: (v: T | ((v: T | undefined) => T), replace?: boolean) => void;
|
||||||
clearValue: () => void;
|
clearValue: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -21,7 +21,7 @@ export default function useRouteStateValue<T extends unknown>(key: string, fallb
|
|||||||
valueRef.current = stateRef.current[key] ?? fallback;
|
valueRef.current = stateRef.current[key] ?? fallback;
|
||||||
|
|
||||||
const setValue = useCallback(
|
const setValue = useCallback(
|
||||||
(valueOrSetter: T | ((v: T) => T)) => {
|
(valueOrSetter: T | ((v: T) => T), replace = true) => {
|
||||||
const newState = { ...stateRef.current };
|
const newState = { ...stateRef.current };
|
||||||
if (typeof valueOrSetter === "function") {
|
if (typeof valueOrSetter === "function") {
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
@ -29,7 +29,7 @@ export default function useRouteStateValue<T extends unknown>(key: string, fallb
|
|||||||
} else newState[key] = valueOrSetter;
|
} else newState[key] = valueOrSetter;
|
||||||
|
|
||||||
if (stateRef.current[key] !== newState[key]) {
|
if (stateRef.current[key] !== newState[key]) {
|
||||||
navigate(".", { state: newState, replace: true });
|
navigate(".", { state: newState, replace });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[key],
|
[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>
|
||||||
|
);
|
||||||
|
}
|
Reference in New Issue
Block a user