Skip to content

Commit 96c7476

Browse files
committed
platform(images): create image uploader
1 parent c0dcab2 commit 96c7476

File tree

18 files changed

+3425
-2077
lines changed

18 files changed

+3425
-2077
lines changed
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import React from "react";
2+
import { Box, Text } from "@radix-ui/themes";
3+
4+
export function DropZone({ onFiles }: { onFiles: (fl: FileList | null) => void }) {
5+
const ref = React.useRef<HTMLDivElement>(null);
6+
const [active, setActive] = React.useState(false);
7+
8+
React.useEffect(() => {
9+
const el = ref.current!;
10+
const prevent = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); };
11+
const onEnter = (e: DragEvent) => { prevent(e); setActive(true); };
12+
const onOver = (e: DragEvent) => { prevent(e); setActive(true); };
13+
const onLeave = (e: DragEvent) => { prevent(e); setActive(false); };
14+
const onDrop = (e: DragEvent) => {
15+
prevent(e); setActive(false);
16+
const dt = e.dataTransfer;
17+
onFiles(dt?.files ?? null);
18+
};
19+
el.addEventListener("dragenter", onEnter);
20+
el.addEventListener("dragover", onOver);
21+
el.addEventListener("dragleave", onLeave);
22+
el.addEventListener("drop", onDrop);
23+
return () => {
24+
el.removeEventListener("dragenter", onEnter);
25+
el.removeEventListener("dragover", onOver);
26+
el.removeEventListener("dragleave", onLeave);
27+
el.removeEventListener("drop", onDrop);
28+
};
29+
}, [onFiles]);
30+
31+
return (
32+
<Box
33+
ref={ref}
34+
style={{
35+
border: `2px dashed #FF3366`,
36+
background: active ? "rgba(255,51,102,0.06)" : "transparent",
37+
borderRadius: 16,
38+
padding: 24,
39+
textAlign: "center",
40+
margin: "10px",
41+
}}
42+
>
43+
<Text size="2" color="gray">
44+
Arrastra tus imágenes aquí o usa <Text weight="bold" color="crimson">Seleccionar archivos</Text>
45+
</Text>
46+
</Box>
47+
);
48+
}
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React from "react";
2+
import { Box, Button, Card, Flex, Progress, Text } from "@radix-ui/themes";
3+
import { ItemState, UploadItem } from "./types";
4+
import { formatBytes } from "./utils";
5+
6+
function labelForState(s: ItemState) {
7+
switch (s) {
8+
case "QUEUED": return "En cola";
9+
case "SIGNING": return "Solicitando URL…";
10+
case "UPLOADING": return "Subiendo…";
11+
case "PROCESSING": return "Procesando…";
12+
case "DONE": return "Listo";
13+
case "ERROR": return "Error";
14+
case "CANCELED": return "Cancelado";
15+
default: return s;
16+
}
17+
}
18+
19+
export function ItemRow({ item, onCancel }: { item: UploadItem; onCancel: () => void }) {
20+
const stateColor: Record<ItemState, "gray" | "crimson" | "green" | "amber" | "red"> = {
21+
QUEUED: "gray",
22+
SIGNING: "amber",
23+
UPLOADING: "crimson",
24+
PROCESSING: "amber",
25+
DONE: "green",
26+
ERROR: "red",
27+
CANCELED: "gray",
28+
};
29+
30+
const humanSpeed = item.speedBps ? formatBytes(item.speedBps) + "/s" : "";
31+
const humanEta = item.etaSec ? `${item.etaSec}s` : "";
32+
33+
return (
34+
<Card className="my-3">
35+
<Flex align="center" gap="3">
36+
<img
37+
src={item.previewUrl}
38+
alt={item.file.name}
39+
style={{ width: 56, height: 56, objectFit: "cover", borderRadius: 12, border: "1px solid #eee" }}
40+
/>
41+
<Box style={{ flex: 1 }}>
42+
<Flex justify="between" align="center">
43+
<Text weight="bold">{item.file.name}</Text>
44+
<Text color="gray">{formatBytes(item.file.size)}</Text>
45+
</Flex>
46+
<Progress value={item.progress} color={stateColor[item.state]} mt="2" />
47+
<Flex justify="between" mt="1">
48+
<Text size="1" color="gray">
49+
{labelForState(item.state)} {item.message ? ${item.message}` : ""}
50+
</Text>
51+
<Text size="1" color="gray">
52+
{item.state === "UPLOADING" && (humanSpeed || humanEta) ? `${humanSpeed} · ${humanEta}` : ""}
53+
</Text>
54+
</Flex>
55+
</Box>
56+
<Flex direction="column" gap="2">
57+
{(item.state === "UPLOADING" || item.state === "SIGNING") && (
58+
<Button color="red" variant="soft" onClick={onCancel}>Cancelar</Button>
59+
)}
60+
{item.state === "DONE" && item.urls?.Optimised && (
61+
<a href={item.urls.Optimised} target="_blank" rel="noreferrer">
62+
<Button variant="soft">Ver</Button>
63+
</a>
64+
)}
65+
</Flex>
66+
</Flex>
67+
</Card>
68+
);
69+
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import { UploadItem, UploadUrlResponse } from "./types";
2+
3+
export const getPresignedUrl = async(input: {
4+
url: string,
5+
item: UploadItem,
6+
OwnerId: string,
7+
OwnerType: string,
8+
token: string,
9+
}) => {
10+
try {
11+
const response = await fetch(input.url, {
12+
method: "POST",
13+
headers: {
14+
"Content-Type": "application/json",
15+
Authorization: `Bearer ${input.token}`,
16+
},
17+
body: JSON.stringify({
18+
OwnerId: input.OwnerId,
19+
OwnerType: input.OwnerType,
20+
ContentType: input.item.file.type,
21+
}),
22+
});
23+
24+
if (!response.ok) {
25+
throw new Error(`Failed to get upload URL (${response.status})`);
26+
}
27+
28+
const data = (await response.json()) as UploadUrlResponse;
29+
return data
30+
} catch (error) {
31+
throw new Error(`Failed to get upload URL (${JSON.stringify(error)})`);
32+
}
33+
}
34+
35+
export const resizeImage = async(input: {
36+
url: string,
37+
key: string,
38+
token: string,
39+
}) => {
40+
try {
41+
const response = await fetch(input.url, {
42+
method: "POST",
43+
headers: {
44+
"Content-Type": "application/json",
45+
Authorization: `Bearer ${input.token}`,
46+
},
47+
body: JSON.stringify({ key: input.key }),
48+
});
49+
50+
if (!response.ok) {
51+
throw new Error(`Failed to resize Image (${response.status})`);
52+
}
53+
54+
const data = (await response.json()) as Omit<UploadUrlResponse, "PresignedUrl">;
55+
return data
56+
} catch (error) {
57+
throw new Error(`Failed to resize Image (${JSON.stringify(error)})`);
58+
}
59+
}

0 commit comments

Comments
 (0)