Apply lot of improvements

This commit is contained in:
Arnaud 2024-08-29 18:45:52 +02:00
parent 9324c5ec0e
commit 8fcaa80248
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
52 changed files with 1826 additions and 814 deletions

26
package-lock.json generated
View File

@ -16,6 +16,7 @@
"classnames": "^2.5.1", "classnames": "^2.5.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.424.0", "lucide-react": "^0.424.0",
"pretty-ms": "^9.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1" "react-dom": "^18.3.1"
}, },
@ -3048,6 +3049,17 @@
"node": ">=6" "node": ">=6"
} }
}, },
"node_modules/parse-ms": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/path-exists": { "node_modules/path-exists": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@ -3154,6 +3166,20 @@
"url": "https://github.com/prettier/prettier?sponsor=1" "url": "https://github.com/prettier/prettier?sponsor=1"
} }
}, },
"node_modules/pretty-ms": {
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.1.0.tgz",
"integrity": "sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==",
"dependencies": {
"parse-ms": "^4.0.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/punycode": { "node_modules/punycode": {
"version": "2.3.1", "version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",

View File

@ -22,15 +22,16 @@
"React" "React"
], ],
"dependencies": { "dependencies": {
"@codex/marketplace-ui-components": "@codex/marketplace-ui-components#master",
"@codex/sdk-js": "@codex/marketplace-ui#master",
"@tanstack/react-query": "^5.51.15", "@tanstack/react-query": "^5.51.15",
"@tanstack/react-router": "^1.45.7", "@tanstack/react-router": "^1.45.7",
"classnames": "^2.5.1", "classnames": "^2.5.1",
"idb-keyval": "^6.2.1", "idb-keyval": "^6.2.1",
"lucide-react": "^0.424.0", "lucide-react": "^0.424.0",
"pretty-ms": "^9.1.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1"
"@codex/sdk-js": "@codex/marketplace-ui#master",
"@codex/marketplace-ui-components": "@codex/marketplace-ui-components#master"
}, },
"devDependencies": { "devDependencies": {
"@tanstack/router-devtools": "^1.45.7", "@tanstack/router-devtools": "^1.45.7",

View File

@ -1,23 +0,0 @@
<svg
version="1.1"
id="loader-1"
xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
x="0px"
y="0px"
viewBox="0 0 50 50"
style="enable-background: new 0 0 50 50"
xml:space="preserve">
<path
fill="#FFF"
d="M43.935,25.145c0-10.318-8.364-18.683-18.683-18.683c-10.318,0-18.683,8.365-18.683,18.683h4.068c0-8.071,6.543-14.615,14.615-14.615c8.072,0,14.615,6.543,14.615,14.615H43.935z">
<animateTransform
attributeType="xml"
attributeName="transform"
type="rotate"
from="0 25 25"
to="360 25 25"
dur="0.6s"
repeatCount="indefinite"></animateTransform>
</path>
</svg>

Before

Width:  |  Height:  |  Size: 660 B

View File

@ -1,6 +1,5 @@
import { ReactNode, useEffect } from "react"; import { ReactNode } from "react";
import "./App.css"; import "./App.css";
import { useNetwork } from "./network/useNetwork.ts";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@ -10,12 +9,6 @@ interface Props {
} }
function App({ children }: Props) { function App({ children }: Props) {
const online = useNetwork();
useEffect(() => {
console.info("The network is now", online ? "online" : "offline");
}, [online]);
return ( return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider> <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
); );

View File

@ -0,0 +1,51 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useState } from "react";
import { Button, Input, Toast } from "@codex/marketplace-ui-components";
import { CircleCheck } from "lucide-react";
import { CodexSdk } from "../sdk/codex";
export function CodexUrlSettings() {
const queryClient = useQueryClient();
const [url, setUrl] = useState("");
const [toast, setToast] = useState({ time: 0, message: "" });
useEffect(() => {
CodexSdk.url().then((u) => setUrl(u));
}, []);
const onChange = (e: React.FormEvent<HTMLInputElement>) => {
const value = e.currentTarget.value;
if (value) {
setUrl(value);
}
};
const onClick = () => {
CodexSdk.updateURL(url).then(() => {
queryClient.invalidateQueries();
setToast({ message: "Settigns saved successfully.", time: Date.now() });
});
};
const Check = () => (
<CircleCheck
size="1.25rem"
fill="var(--codex-color-primary)"
stroke="var(--codex-background-light)"></CircleCheck>
);
console.info({ url });
return (
<>
<Input
id="url"
label="Codex client node URL"
onChange={onChange}
value={url}
className="settings-input"></Input>
<Button variant="primary" label="Save changes" onClick={onClick}></Button>
<Toast message={toast.message} time={toast.time} Icon={Check} />
</>
);
}

View File

@ -2,17 +2,29 @@
border-radius: var(--codex-border-radius); border-radius: var(--codex-border-radius);
border: 1px solid var(--codex-border-color); border: 1px solid var(--codex-border-color);
font-family: var(--codex-font-family); font-family: var(--codex-font-family);
padding: 1.5rem; padding: 0.5rem 1rem;
background-color: rgb(56 56 56); background-color: rgb(56 56 56);
display: flex;
flex-direction: column;
}
.cardNumber--error {
border-color: rgb(var(--codex-color-error));
}
.cardNumber--error .cardNumber-tooltip {
color: rgb(var(--codex-color-error));
} }
.cardNumber-title { .cardNumber-title {
display: inline-block; display: inline-block;
font-size: 0.9rem;
} }
.cardNumber-data { .cardNumber-data {
font-size: 3rem; font-size: 2rem;
color: var(--codex-color-primary); color: var(--codex-color-primary);
margin-bottom: 0.5rem;
} }
.cardNumber-data:focus-visible { .cardNumber-data:focus-visible {
@ -22,10 +34,36 @@
.cardNumber-dataContainer { .cardNumber-dataContainer {
position: relative; position: relative;
flex: 1;
display: flex;
align-items: flex-start;
gap: 0.5rem;
position: relative;
top: 0px;
/* --codex-button-icon-background: var(--codex-color-primary); */
justify-content: space-between;
align-items: center;
} }
.cardNumber-dataIcon { .cardNumber-dataContainer .buttonIcon {
position: absolute; position: relative;
right: 0; top: 3px;
bottom: 0; }
.cardNumber-tooltip {
color: var(--codex-color-disabled);
display: flex;
}
.cardNumber-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cardNumber .input {
min-width: 0;
width: 100px;
height: 2.5rem;
} }

View File

@ -1,33 +1,107 @@
import { SimpleText } from "@codex/marketplace-ui-components"; import { ButtonIcon, Input, Tooltip } from "@codex/marketplace-ui-components";
import "./CardNumbers.css"; import "./CardNumbers.css";
import { Pencil } from "lucide-react"; import { Check, Info, Pencil, ShieldAlert } from "lucide-react";
import { ChangeEvent, useEffect, useState } from "react";
import { classnames } from "../../utils/classnames";
type Props = { type Props = {
title: string; title: string;
data: string; data: string;
comment?: string; comment?: string;
editable?: boolean; onChange?: (value: number) => void;
hasError?: boolean;
}; };
export function CardNumbers({ title, data, comment, editable }: Props) { export function CardNumbers({
title,
data,
comment,
hasError = false,
onChange,
}: Props) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(data);
useEffect(() => {
setValue(data);
}, [data]);
const onEditingClick = () => setEditing(!editing);
const onInputChange = (e: ChangeEvent<HTMLInputElement>) => {
setValue(e.currentTarget.value);
};
const onButtonClick = () => {
setEditing(false);
onChange?.(parseInt(value, 10));
};
if (editing) {
return ( return (
<div className="cardNumber"> <div
<b className="cardNumber-title">{title}</b> className={classnames(["cardNumber"], ["cardNumber--error", hasError])}>
<div className="cardNumber-dataContainer"> <div className="cardNumber-dataContainer">
<p className="cardNumber-data" contentEditable={editable}> <Input
{data} id={title}
</p> value={value}
{editable && ( onChange={onInputChange}
<div className="cardNumber-dataIcon"> type="number"></Input>
<Pencil size={"0.85rem"} /> <ButtonIcon
</div> Icon={Check}
)} variant="small"
onClick={onButtonClick}></ButtonIcon>
</div> </div>
<div className="cardNumber-info">
<b className="cardNumber-title">{title}</b>
{comment && ( {comment && (
<SimpleText variant="light" size="small"> <Tooltip message={comment} className="cardNumber-tooltip">
{comment} {hasError ? (
</SimpleText> <ShieldAlert size={"1rem"} />
) : (
<Info size={"1rem"} />
)} )}
</Tooltip>
)}
</div>
</div>
);
}
const DataContainer = editing ? (
<>
<Input
id={title}
value={value}
onChange={onInputChange}
type="number"></Input>
<ButtonIcon
Icon={Check}
variant="small"
onClick={onButtonClick}></ButtonIcon>
</>
) : (
<>
<p className="cardNumber-data">{data}</p>
<ButtonIcon
onClick={onEditingClick}
variant="small"
Icon={() => <Pencil size={"1rem"} />}></ButtonIcon>
</>
);
return (
<div
className={classnames(["cardNumber"], ["cardNumber--error", hasError])}>
<div className="cardNumber-dataContainer">{DataContainer}</div>
<div className="cardNumber-info">
<b className="cardNumber-title">{title}</b>
{comment && (
<Tooltip message={comment} className="cardNumber-tooltip">
{hasError ? <ShieldAlert size={"1rem"} /> : <Info size={"1rem"} />}
</Tooltip>
)}
</div>
</div> </div>
); );
} }

View File

@ -0,0 +1,9 @@
.cell-state--custom {
display: flex;
}
.cell-stateIcon {
position: relative;
top: 2px;
margin-right: 0.5rem;
}

View File

@ -0,0 +1,49 @@
import { CheckCircle, CircleDashed, ShieldAlert } from "lucide-react";
import "./CustomStateCellRender.css";
import { StateCell, Tooltip } from "@codex/marketplace-ui-components";
// Import css
StateCell;
type Props = {
state: string;
message: string | undefined;
};
export const CustomStateCellRender = ({ state, message }: Props) => {
const icons = {
pending: CircleDashed,
submitted: CircleDashed,
started: CircleDashed,
finished: CheckCircle,
cancelled: ShieldAlert,
};
const states = {
cancelled: "error",
pending: "warning",
started: "loading",
submitted: "loading",
finished: "success",
};
const Icon = icons[state as keyof typeof icons] || CircleDashed;
return (
<p
className={
"cell-state cell-state--custom cell-state--" +
states[state as keyof typeof states]
}>
{message ? (
<Tooltip message={message}>
<Icon size={"1rem"} className="cell-stateIcon" />
</Tooltip>
) : (
<Icon size={"1rem"} className="cell-stateIcon" />
)}
<span>{state}</span>
</p>
);
};

View File

@ -0,0 +1,58 @@
import { useEffect, useState } from "react";
import { Tooltip, WebFileIcon } from "@codex/marketplace-ui-components";
import "./FileCell.css";
import { FileMetadata, FilesStorage } from "../../utils/file-storage";
import { PurchaseStorage } from "../../utils/purchases-storage";
type Props = {
requestId: string;
purchaseCid: string;
index: number;
};
export function FileCell({ requestId, purchaseCid }: Props) {
const [cid, setCid] = useState(purchaseCid);
const [metadata, setMetadata] = useState<FileMetadata>({
name: "N/A.jpg",
mimetype: "N/A",
uploadedAt: new Date(0, 0, 0, 0, 0, 0),
});
useEffect(() => {
PurchaseStorage.get(requestId).then((cid) => {
if (cid) {
setCid(cid);
FilesStorage.get<FileMetadata>(cid).then((data) => {
if (data) {
console.info("data", data);
setMetadata(data);
}
});
}
});
}, [requestId]);
let name = metadata.name;
if (name.length > 10) {
const [filename, ext] = metadata.name.split(".");
name = filename.slice(0, 10) + "..." + ext;
}
const cidTruncated = cid.slice(0, 5) + ".".repeat(5) + cid.slice(-5);
return (
<>
<div className="fileCell">
<WebFileIcon type={metadata.mimetype} />
<div>
<span className="fileCell-title">{name}</span>
<span className="fileCell-subtitle">
<Tooltip message={cid}>{cidTruncated}</Tooltip>
</span>
</div>
</div>
</>
);
}

View File

@ -0,0 +1,20 @@
.fileCell {
display: flex;
gap: 0.75rem;
}
.fileCell-subtitle {
display: flex;
align-items: center;
gap: 0.5rem;
position: relative;
}
.fileCell-tooltip {
display: flex;
align-items: center;
}
.fileCell .tooltip:hover:after {
left: -33%;
}

View File

@ -0,0 +1,39 @@
import { useRef, useState } from "react";
import { COPY_DURATION, ICON_SIZE } from "../../utils/constants.ts";
import { Copy } from "lucide-react";
import { Button } from "@codex/marketplace-ui-components";
type CopyButtonProps = {
cid: string;
};
export function CidCopyButton({ cid }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const timeout = useRef<number | null>(null);
const onCopy = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
navigator.clipboard.writeText(cid);
setCopied(true);
timeout.current = setTimeout(() => {
setCopied(false);
}, COPY_DURATION);
};
const label = copied ? "Copied !" : "Copy CID";
const Icon = () => <Copy size={ICON_SIZE} />;
return (
<Button
label={label}
variant="outline"
onClick={onCopy}
Icon={Icon}></Button>
);
}

View File

@ -0,0 +1,78 @@
.fileDetails {
position: fixed;
transition: transform 0.25s;
background-color: var(--codex-background-secondary);
z-index: 2;
justify-content: space-between;
}
.fileDetails-header {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--codex-border-color);
display: flex;
align-items: center;
}
.fileDetails-headerTitle {
flex-grow: 1;
}
.files-backdrop {
z-index: 2;
}
.fileDetails-body {
padding: 0;
}
.fileDetails-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
display: grid;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--codex-border-color);
}
.fileDetails-gridColumn {
grid-column: span 2 / span 2;
color: var(--codex-text-contrast);
}
.fileDetails-actions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
}
@media (min-width: 1000px) {
.fileDetails {
width: 300px;
height: 100%;
bottom: 0;
top: 0;
transform: translatex(300px);
right: 0;
}
.fileDetails[aria-expanded] {
transform: translatex(0);
z-index: 10;
}
}
@media (max-width: 999px) {
.fileDetails {
width: 100%;
height: auto;
bottom: 0;
top: auto;
transform: translatey(1000px);
left: 0;
padding-bottom: 1.5rem;
}
.fileDetails[aria-expanded] {
transform: translatey(0);
z-index: 10;
}
}

View File

@ -0,0 +1,92 @@
import { ButtonIcon, Button } from "@codex/marketplace-ui-components";
import { CodexDataContent } from "@codex/sdk-js";
import { X, DownloadIcon } from "lucide-react";
import { attributes } from "../../utils/attributes";
import { PrettyBytes } from "../../utils/bytes";
import { ICON_SIZE } from "../../utils/constants";
import { Dates } from "../../utils/dates";
import { CidCopyButton } from "./CidCopyButton";
import "./FileDetails.css";
import { FileMetadata } from "../../utils/file-storage";
type Props = {
details: (CodexDataContent & FileMetadata) | undefined;
onClose: () => void;
expanded: boolean;
};
export function FileDetails({ onClose, details, expanded }: Props) {
const attr = attributes({ "aria-expanded": expanded });
const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/";
const Icon = () => <X size={ICON_SIZE} onClick={onClose} />;
const onDownload = () => window.open(url + details?.cid, "_target");
return (
<>
<div
className="files-backdrop backdrop"
onClick={onClose}
{...attr}></div>
<div className="fileDetails" {...attr}>
{details && (
<>
<div className="fileDetails-header">
<b className="fileDetails-headerTitle">File details</b>
<ButtonIcon variant="small" Icon={Icon}></ButtonIcon>
</div>
<div className="fileDetails-body">
<div className="fileDetails-grid">
<p className="text-secondary">CID:</p>
<p className="fileDetails-gridColumn">{details.cid}</p>
</div>
<div className="fileDetails-grid">
<p className="text-secondary">File name:</p>
<p className="fileDetails-gridColumn">{details.name}</p>
</div>
<div className="fileDetails-grid">
<p className="text-secondary">Date:</p>
<p className="fileDetails-gridColumn">
{Dates.format(details.uploadedAt).toString()}
</p>
</div>
<div className="fileDetails-grid">
<p className="text-secondary">Mimetype:</p>
<p className="fileDetails-gridColumn">{details.mimetype}</p>
</div>
<div className="fileDetails-grid">
<p className="text-secondary">Size:</p>
<p className="fileDetails-gridColumn">
{PrettyBytes(details.manifest.datasetSize)}
</p>
</div>
<div className="fileDetails-grid">
<p className="text-secondary">Protected:</p>
<p className="fileDetails-gridColumn">
{details.manifest.protected ? "Yes" : "No"}
</p>
</div>
<div className="fileDetails-actions">
<CidCopyButton cid={details.cid} />
<Button
Icon={() => <DownloadIcon size={ICON_SIZE} />}
label="Download"
onClick={onDownload}></Button>
</div>
</div>
</>
)}
</div>
</>
);
}

View File

@ -0,0 +1,110 @@
.files {
border-radius: var(--codex-border-radius);
border: 1px solid var(--codex-border-color);
background-color: var(--codex-background-secondary);
padding: 1rem 1.5rem;
}
.files-title {
font-weight: bold;
font-size: 1.125rem;
line-height: 1.75rem;
}
.files-file:not(:last-child) {
padding-bottom: 0.75rem;
}
.files-fileContent {
display: flex;
gap: 0.75rem;
}
.files-file:not(:last-child) .files-fileContent {
border-bottom: 1px solid var(--codex-border-color);
}
.files-file:not(:last-child) .files-fileContent {
padding-bottom: 0.75rem;
}
.files-fileIcon {
padding: 0.5rem;
border: 1px solid var(--codex-border-color);
border-radius: var(--codex-border-radius);
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.files-fileData {
display: flex;
justify-content: space-between;
align-items: center;
flex-grow: 1;
}
.files-fileActions {
display: flex;
align-items: center;
border: 1px solid var(--codex-border-color);
border-radius: var(--codex-border-radius);
padding: 0.5rem;
gap: 0.75rem;
}
.files-fileStar {
transition:
fill 0.35s,
stroke 0.35s;
}
.files-fileFavorite {
fill: yellow;
stroke: yellow;
}
.files-header {
margin-bottom: 0.75rem;
}
.files-headerTabs {
display: flex;
margin-top: 1rem;
gap: 1rem;
position: relative;
}
.files-headerTabs::after {
width: 100%;
background-color: var(--codex-background-light);
content: " ";
position: absolute;
height: 2px;
top: 11px;
top: 31px;
}
.files-headerTab {
display: flex;
align-items: center;
gap: 0.25rem;
padding-bottom: 1rem;
cursor: pointer;
transition: 0.35s opacity;
z-index: 1;
}
.files-headerTab:not(.files-headerTab--active) {
opacity: 0.7;
}
.files-headerTab:hover {
opacity: 0.85;
}
.files-headerTab--active {
border-bottom: 2px solid var(--codex-color-contrast);
}

View File

@ -0,0 +1,163 @@
import { Download, FilesIcon, ReceiptText, Star } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { PrettyBytes } from "../../utils/bytes.ts";
import { Dates } from "../../utils/dates.ts";
import "./Files.css";
import { ICON_SIZE, SIDE_DURATION } from "../../utils/constants.ts";
import {
ButtonIcon,
EmptyPlaceholder,
WebFileIcon,
} from "@codex/marketplace-ui-components";
import { FileDetails } from "./FileDetails.tsx";
import { classnames } from "../../utils/classnames.ts";
import { FavoriteStorage } from "../../utils/favorite-storage.tsx";
import { useData } from "../../hooks/useData.tsx";
type StarIconProps = {
isFavorite: boolean;
};
function StarIcon({ isFavorite }: StarIconProps) {
if (isFavorite) {
return (
<Star size={ICON_SIZE} className="files-fileFavorite files-fileStar" />
);
}
return <Star size={ICON_SIZE} className="files-star" />;
}
export function Files() {
const files = useData();
const cid = useRef<string | null>("");
const [expanded, setExpanded] = useState(false);
const [favorites, setFavorites] = useState<string[]>([]);
const [selected, setSelected] = useState<"all" | "favorites">("all");
useEffect(() => {
FavoriteStorage.list().then((cids) => setFavorites(cids));
}, []);
const onClose = () => {
setExpanded(false);
setTimeout(() => {
cid.current = "";
}, SIDE_DURATION);
};
const onSelected = () =>
setSelected(selected === "all" ? "favorites" : "all");
const onDetails = (id: string) => {
cid.current = id;
setExpanded(true);
};
const onToggleFavorite = (cid: string) => {
if (favorites.includes(cid)) {
FavoriteStorage.delete(cid);
setFavorites(favorites.filter((c) => c !== cid));
} else {
FavoriteStorage.add(cid);
setFavorites([...favorites, cid]);
}
};
const items = [];
if (selected === "favorites") {
items.push(...files.filter((f) => favorites.includes(f.cid)));
} else {
items.push(...files);
}
const details = items.find((c) => c.cid === cid.current);
const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/";
return (
<div className="files">
<div className="files-header">
<div className="files-title">Files</div>
<div className="files-headerTabs">
<div
className={classnames(
["files-headerTab"],
["files-headerTab--active", selected === "all"]
)}
onClick={onSelected}>
<FilesIcon size={"1rem"}></FilesIcon>
<span>All files</span>
</div>
<div
className={classnames(
["files-headerTab"],
["files-headerTab--active", selected === "favorites"]
)}
onClick={onSelected}>
<Star size={"1rem"}></Star>
<span>Favorites</span>
</div>
</div>
</div>
<div className="files-fileBody">
{items.length ? (
items.map((c) => (
<div className="files-file" key={c.cid}>
<div className="files-fileContent">
<div className="files-fileIcon">
<WebFileIcon type={c.mimetype} />
</div>
<div className="files-fileData">
<div>
<b>{c.name}</b>
<div>
<small className="files-fileMeta">
{PrettyBytes(c.manifest.datasetSize)} -{" "}
{Dates.format(c.uploadedAt).toString()} - ...
{c.cid.slice(-5)}
</small>
</div>
</div>
<div className="files-fileActions">
<ButtonIcon
variant="small"
onClick={() => window.open(url + c.cid, "_blank")}
Icon={() => <Download size={ICON_SIZE} />}></ButtonIcon>
<ButtonIcon
variant="small"
onClick={() => onToggleFavorite(c.cid)}
Icon={() => (
<StarIcon isFavorite={favorites.includes(c.cid)} />
)}></ButtonIcon>
<ButtonIcon
variant="small"
onClick={() => onDetails(c.cid)}
Icon={() => (
<ReceiptText size={ICON_SIZE} />
)}></ButtonIcon>
</div>
</div>
</div>
</div>
))
) : (
<div className="files-placeholder">
<EmptyPlaceholder
title="Nothing to show"
message="No data here yet. Start to upload files to see data here."
/>
</div>
)}
</div>
<FileDetails onClose={onClose} details={details} expanded={expanded} />
</div>
);
}

View File

@ -4,7 +4,7 @@ import { useContext, useState } from "react";
import { ErrorBoundaryContext } from "../../contexts/ErrorBoundaryContext"; import { ErrorBoundaryContext } from "../../contexts/ErrorBoundaryContext";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import "./LogLevel.css"; import "./LogLevel.css";
import { Button, Card, Select, Toast } from "@codex/marketplace-ui-components"; import { Button, Select, Toast } from "@codex/marketplace-ui-components";
import { CircleCheck } from "lucide-react"; import { CircleCheck } from "lucide-react";
export function LogLevel() { export function LogLevel() {
@ -60,7 +60,7 @@ export function LogLevel() {
); );
return ( return (
<Card className="logLevel" title="Debug"> <>
<Select <Select
className="logLevel-select" className="logLevel-select"
id="level" id="level"
@ -73,6 +73,6 @@ export function LogLevel() {
fetching={isPending} fetching={isPending}
onClick={onClick}></Button> onClick={onClick}></Button>
<Toast message={toast.message} time={toast.time} Icon={Check} /> <Toast message={toast.message} time={toast.time} Icon={Check} />
</Card> </>
); );
} }

View File

@ -1,129 +0,0 @@
.manifest {
padding: 0.75rem;
border-radius: var(--codex-border-radius);
background-color: var(--codex-background-secondary);
margin-bottom: 1.5rem;
}
.manifest-content {
display: flex;
gap: 0.75rem;
}
.manifest-icon {
padding: 0.5rem;
border: 1px solid var(--codex-border-color);
border-radius: var(--codex-border-radius);
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
}
.manifest-data {
display: flex;
justify-content: space-between;
align-items: center;
flex-grow: 1;
}
.manifest-actions {
display: flex;
align-items: center;
border: 1px solid var(--codex-border-color);
border-radius: var(--codex-border-radius);
padding: 0.5rem;
gap: 0.75rem;
}
.manifest-details {
position: fixed;
transition: transform 0.25s;
background-color: var(--codex-background-secondary);
z-index: 2;
}
.manifest-detailsHeaderTitle {
flex-grow: 1;
}
.manifest-backdrop {
z-index: 2;
}
.manifest-details {
justify-content: space-between;
}
.manifest-detailsHeader {
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--codex-border-color);
display: flex;
align-items: center;
}
.manifest-detailsBody {
padding: 0;
}
.manifest-detailsGrid {
grid-template-columns: repeat(3, minmax(0, 1fr));
display: grid;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--codex-border-color);
}
.manifest-detailsGridColumn {
grid-column: span 2 / span 2;
color: var(--codex-text-contrast);
}
.manifest-detailsActions {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.75rem 1.5rem;
}
.manifest-star {
transition:
fill 0.35s,
stroke 0.35s;
}
.manifest-favorite {
fill: yellow;
stroke: yellow;
}
@media (min-width: 1000px) {
.manifest-details {
width: 300px;
height: 100%;
bottom: 0;
top: 0;
transform: translatex(300px);
right: 0;
}
.manifest-details[aria-expanded] {
transform: translatex(0);
}
}
@media (max-width: 999px) {
.manifest-details {
width: 100%;
height: auto;
bottom: 0;
top: auto;
transform: translatey(1000px);
left: 0;
padding-bottom: 1.5rem;
}
.manifest-details[aria-expanded] {
transform: translatey(0);
}
}

View File

@ -1,256 +0,0 @@
import { CodexDataContent } from "@codex/sdk-js";
import { useQuery } from "@tanstack/react-query";
import {
Copy,
Download,
DownloadIcon,
ReceiptText,
Star,
X,
} from "lucide-react";
import { useRef, useState } from "react";
import { CodexSdk } from "../../sdk/codex";
import { attributes } from "../../utils/attributes.ts";
import { BrowserStorage } from "../../utils/browser-storage.ts";
import { PrettyBytes } from "../../utils/bytes";
import { Dates } from "../../utils/dates.ts";
import "./Manitests.css";
import {
COPY_DURATION,
ICON_SIZE,
SIDE_DURATION,
} from "../../utils/constants.ts";
import {
Button,
ButtonIcon,
WebFileIcon,
} from "@codex/marketplace-ui-components";
type StarIconProps = {
favorites: string;
cid: string;
};
function StarIcon({ favorites, cid }: StarIconProps) {
if (favorites.includes(cid)) {
return (
<Star size={ICON_SIZE} className="manifest-favorite manifest-star" />
);
}
return <Star size={ICON_SIZE} className="manifest-star" />;
}
export function Manifests() {
const { data } = useQuery({
queryFn: () => CodexSdk.data().then((data) => data.cids()),
queryKey: ["cids"],
// refetchOnWindowFocus: false,
// refetchOnMount: false,
});
const cid = useRef<string | null>("");
const [expanded, setExpanded] = useState(false);
const onClose = () => {
setExpanded(false);
setTimeout(() => {
cid.current = "";
}, SIDE_DURATION);
};
const onDetails = (id: string) => {
// router.navigate({
// to: "/dashboard/about",
// params: data?.data.content.find((c) => c.cid === cid) || {},
// state: data?.data.content.find((c) => c.cid === cid) || true,
// });
cid.current = id;
setExpanded(true);
};
const onToggleFavorite = (cid: string) =>
BrowserStorage.toggle("favorites", cid);
if (data?.error) {
// TODO error
return "";
}
const details = data?.data.content.find((c) => c.cid === cid.current);
const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/";
const favorites = BrowserStorage.values("favorites");
return (
<>
{data?.data.content.map((c) => (
<div className="manifest" key={c.cid}>
<div className="manifest-content">
<div className="manifest-icon">
<WebFileIcon type={c.manifest.mimetype} />
</div>
<div className="manifest-data">
<div>
<b>{c.manifest.filename}</b>
<div>
<small className="manifest-meta">
{PrettyBytes(c.manifest.datasetSize)} -{" "}
{Dates.format(c.manifest.uploadedAt).toString()} - ...
{c.cid.slice(-5)}
</small>
</div>
</div>
<div className="manifest-actions">
<ButtonIcon
variant="small"
onClick={() => window.open(url + c.cid, "_blank")}
Icon={() => <Download size={ICON_SIZE} />}></ButtonIcon>
<ButtonIcon
variant="small"
onClick={() => onToggleFavorite(c.cid)}
Icon={() => (
<StarIcon favorites={favorites} cid={c.cid} />
)}></ButtonIcon>
<ButtonIcon
variant="small"
onClick={() => onDetails(c.cid)}
Icon={() => <ReceiptText size={ICON_SIZE} />}></ButtonIcon>
</div>
</div>
</div>
</div>
))}
<ManifestDetails
onClose={onClose}
details={details}
expanded={expanded}
/>
</>
);
}
type ManifestDetailsProps = {
details: CodexDataContent | undefined;
onClose: () => void;
expanded: boolean;
};
function ManifestDetails({ onClose, details, expanded }: ManifestDetailsProps) {
const attr = attributes({ "aria-expanded": expanded });
const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/";
const Icon = () => <X size={ICON_SIZE} onClick={onClose} />;
const onDownload = () => window.open(url + details?.cid, "_target");
return (
<>
<div
className="manifest-backdrop backdrop"
onClick={onClose}
{...attr}></div>
<div className="manifest-details" {...attr}>
{details && (
<>
<div className="manifest-detailsHeader">
<b className="manifest-detailsHeaderTitle">File details</b>
<ButtonIcon variant="small" Icon={Icon}></ButtonIcon>
</div>
<div className="manifest-detailsBody">
<div className="manifest-detailsGrid">
<p className="text-secondary">CID:</p>
<p className="manifest-detailsGridColumn">{details.cid}</p>
</div>
<div className="manifest-detailsGrid">
<p className="text-secondary">File name:</p>
<p className="manifest-detailsGridColumn">
{details.manifest.filename}
</p>
</div>
<div className="manifest-detailsGrid">
<p className="text-secondary">Date:</p>
<p className="manifest-detailsGridColumn">
{Dates.format(details.manifest.uploadedAt).toString()}
</p>
</div>
<div className="manifest-detailsGrid">
<p className="text-secondary">Mimetype:</p>
<p className="manifest-detailsGridColumn">
{details.manifest.mimetype}
</p>
</div>
<div className="manifest-detailsGrid">
<p className="text-secondary">Size:</p>
<p className="manifest-detailsGridColumn">
{PrettyBytes(details.manifest.datasetSize)}
</p>
</div>
<div className="manifest-detailsGrid">
<p className="text-secondary">Protected:</p>
<p className="manifest-detailsGridColumn">
{details.manifest.protected ? "Yes" : "No"}
</p>
</div>
<div className="manifest-detailsActions">
<CopyButton cid={details.cid} />
<Button
Icon={() => <DownloadIcon size={ICON_SIZE} />}
label="Download"
onClick={onDownload}></Button>
</div>
</div>
</>
)}
</div>
</>
);
}
type CopyButtonProps = {
cid: string;
};
function CopyButton({ cid }: CopyButtonProps) {
const [copied, setCopied] = useState(false);
const timeout = useRef<number | null>(null);
const onCopy = () => {
if (timeout.current) {
clearTimeout(timeout.current);
}
navigator.clipboard.writeText(cid);
setCopied(true);
timeout.current = setTimeout(() => {
setCopied(false);
}, COPY_DURATION);
};
const label = copied ? "Copied !" : "Copy CID";
const Icon = () => <Copy size={ICON_SIZE} />;
return (
<Button
label={label}
variant="outline"
onClick={onCopy}
Icon={Icon}></Button>
);
}

View File

@ -0,0 +1,11 @@
.range {
width: 100%;
accent-color: var(--codex-color-primary);
height: 4px;
outline: none;
}
.range-labels {
display: flex;
justify-content: space-between;
}

View File

@ -0,0 +1,45 @@
import { ChangeEvent } from "react";
import "./Range.css";
type Props = {
label?: string;
max: number;
labels: string[];
className?: string;
onChange: (e: ChangeEvent<HTMLInputElement>) => void;
defaultValue?: number | string;
value?: number | string;
};
export function Range({
label,
max,
labels,
onChange,
defaultValue,
value,
className = "",
}: Props) {
return (
<div className={className}>
{label}
<input
type="range"
max={max}
min={1}
step="1"
className="range"
onChange={onChange}
defaultValue={defaultValue}
value={value}
/>
<div className="range-labels">
{labels.map((l) => (
<div className="range-label" key={l}>
{l}
</div>
))}
</div>
</div>
);
}

View File

@ -0,0 +1,30 @@
import { CircleCheck } from "lucide-react";
import { useEffect } from "react";
type Props = {
onChangeNextState: (value: "enable" | "disable") => void;
};
// TODO define style in placeholder component
export function StorageRequestDone({ onChangeNextState }: Props) {
useEffect(() => {
onChangeNextState("enable");
}, [onChangeNextState]);
return (
<div className="emptyPlaceholder" style={{ margin: "auto" }}>
<div
style={{
marginBottom: "1rem",
color: "var(--codex-color-primary)",
}}>
<CircleCheck size="4rem" />
</div>
<b className="emptyPlaceholder-title">Your request is being processed.</b>
<div className="emptyPlaceholder-message">
Processing your request may take some time. Once completed, it will
appear in your purchase list. You can safely close this dialog.
</div>
</div>
);
}

View File

@ -1,4 +1,3 @@
import { useQuery } from "@tanstack/react-query";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import "./StorageRequestFileChooser.css"; import "./StorageRequestFileChooser.css";
import { ChangeEvent, useEffect, useRef, useState } from "react"; import { ChangeEvent, useEffect, useRef, useState } from "react";
@ -10,18 +9,14 @@ import {
Upload, Upload,
WebFileIcon, WebFileIcon,
} from "@codex/marketplace-ui-components"; } from "@codex/marketplace-ui-components";
import { useData } from "../../hooks/useData";
type Props = { type Props = {
onToggleNext: (enable: boolean) => void; onChangeNextState: (value: "enable" | "disable") => void;
}; };
export function StorageRequestFileChooser({ onToggleNext }: Props) { export function StorageRequestFileChooser({ onChangeNextState }: Props) {
const { data } = useQuery({ const files = useData();
queryFn: () => CodexSdk.data().then((data) => data.cids()),
queryKey: ["cids"],
refetchOnWindowFocus: false,
refetchOnMount: false,
});
const [cid, setCid] = useState(""); const [cid, setCid] = useState("");
const cache = useRef(""); const cache = useRef("");
@ -30,48 +25,54 @@ export function StorageRequestFileChooser({ onToggleNext }: Props) {
cache.current = val || ""; cache.current = val || "";
setCid(val || ""); setCid(val || "");
onToggleNext(!!val); onChangeNextState(!val ? "disable" : "enable");
}); });
return () => { return () => {
WebStorage.set("storage-request-step-1", cache.current || ""); WebStorage.set("storage-request-step-1", cache.current || "");
}; };
}, [onToggleNext]); }, [onChangeNextState]);
if (data?.error) { // if (data?.error) {
// TODO error // // TODO error
return ""; // return "";
} // }
const onSelected = (o: DropdownOption) => { const onSelected = (o: DropdownOption) => {
onToggleNext(!!o.subtitle);
setCid(o.subtitle || ""); setCid(o.subtitle || "");
onChangeNextState(!o.subtitle ? "disable" : "enable");
cache.current = o.subtitle || ""; cache.current = o.subtitle || "";
}; };
const onChange = (e: ChangeEvent<HTMLInputElement>) => { const onChange = (e: ChangeEvent<HTMLInputElement>) => {
onToggleNext(!!e.currentTarget.value);
setCid(e.currentTarget.value); setCid(e.currentTarget.value);
onChangeNextState(!e.currentTarget.value ? "disable" : "enable");
cache.current = e.currentTarget.value; cache.current = e.currentTarget.value;
}; };
const onSuccess = (data: string) => { const onSuccess = (data: string, file: File) => {
onToggleNext(true); WebStorage.set(data, {
type: file.type,
name: file.name,
});
onChangeNextState("enable");
setCid(data); setCid(data);
cache.current = data; cache.current = data;
}; };
const onDelete = () => { const onDelete = () => {
setCid(""); setCid("");
onToggleNext(false); onChangeNextState("disable");
}; };
const options = const options =
data?.data.content.map((c) => { files.map((f) => {
return { return {
Icon: () => <WebFileIcon type={c.manifest.mimetype} size={24} />, Icon: () => <WebFileIcon type={f.mimetype} size={24} />,
title: c.manifest.filename, title: f.name,
subtitle: c.cid, subtitle: f.cid,
}; };
}) || []; }) || [];
@ -84,6 +85,8 @@ export function StorageRequestFileChooser({ onToggleNext }: Props) {
</label> </label>
<Dropdown <Dropdown
label=""
id="cid"
placeholder="Select or type your CID" placeholder="Select or type your CID"
onChange={onChange} onChange={onChange}
value={cid} value={cid}
@ -114,9 +117,7 @@ export function StorageRequestFileChooser({ onToggleNext }: Props) {
onSuccess={onSuccess} onSuccess={onSuccess}
editable={false} editable={false}
onDeleteItem={onDelete} onDeleteItem={onDelete}
provider={() => provider={() => CodexSdk.data().then((data) => data.upload.bind(data))}
CodexSdk.data().then((data) => data.upload.bind(CodexSdk))
}
/> />
</> </>
); );

View File

@ -60,6 +60,39 @@
.storageRequestReview-numbers { .storageRequestReview-numbers {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 0.5rem; gap: 0.5rem;
} }
.storageRequestReview-range {
margin: 0.5rem 0 1rem 0;
font-size: 0.85rem;
}
.storageRequestReview-alert .alert-message {
font-size: 0.9rem;
}
.storageRequestReview-range--disabled .range {
opacity: 0.5;
}
/*
.storageRequestReview-range--disabled .range::-webkit-slider-thumb {
background-color: var(--codex-background-light);
} */
@media (max-width: 800px) {
.storageRequestReview-numbers {
grid-template-columns: 1fr 1fr;
}
.storageRequestReview-legend {
flex-direction: column;
align-items: flex-start;
}
}
@media (min-width: 801px) {
.storageRequestReview-numbers {
grid-template-columns: 1fr 1fr 1fr;
}
}

View File

@ -1,126 +1,364 @@
import { useEffect, useState } from "react"; import { ChangeEvent, useEffect, useState } from "react";
import { WebStorage } from "../../utils/web-storage"; import { WebStorage } from "../../utils/web-storage";
import {
StorageAvailabilityValue,
StorageDurabilityStepValue,
StoragePriceStepValue,
} from "./types";
import "./StorageRequestReview.css"; import "./StorageRequestReview.css";
import { Alert, SimpleText } from "@codex/marketplace-ui-components"; import { Alert } from "@codex/marketplace-ui-components";
import { CardNumbers } from "../CardNumbers/CardNumbers"; import { CardNumbers } from "../CardNumbers/CardNumbers";
import { Range } from "../Range/Range";
import { FileWarning } from "lucide-react";
import { classnames } from "../../utils/classnames";
const plurals = (type: "node" | "token" | "second" | "minute", value: number) => const plurals = (type: "node" | "token" | "second" | "minute", value: number) =>
`${value} ${type}` + (value > 1 ? "s" : ""); `${value} ${type}` + (value > 1 ? "s" : "");
type Props = { type Props = {
onToggleNext: (next: boolean) => void; onChangeNextState: (value: "enable" | "disable") => void;
}; };
export function StorageRequestReview({ onToggleNext }: Props) { export type AvailabilityUnit =
| "days"
| "months"
| "years"
| "minutes"
| "hours";
type Data = {
availability: number;
availabilityUnit: AvailabilityUnit;
tolerance: number;
proofProbability: number;
nodes: number;
reward: number;
collateral: number;
expiration: number;
};
type Durability = {
nodes: number;
tolerance: number;
proofProbability: number;
};
const durabilities = [
{ nodes: 2, tolerance: 0, proofProbability: 1 },
{ nodes: 3, tolerance: 1, proofProbability: 2 },
{ nodes: 4, tolerance: 2, proofProbability: 3 },
{ nodes: 5, tolerance: 3, proofProbability: 4 },
{ nodes: 6, tolerance: 4, proofProbability: 5 },
];
type Price = {
reward: number;
collateral: number;
};
const prices = [
{
reward: 5,
collateral: 5,
},
{
reward: 10,
collateral: 10,
},
{
reward: 50,
collateral: 20,
},
];
const findDurabilityIndex = (d: Durability) => {
const s = JSON.stringify({
nodes: d.nodes,
tolerance: d.tolerance,
proofProbability: d.proofProbability,
});
return durabilities.findIndex((d) => JSON.stringify(d) === s);
};
const findPriceIndex = (d: Price) => {
const s = JSON.stringify({
reward: d.reward,
collateral: d.collateral,
});
return prices.findIndex((p) => JSON.stringify(p) === s);
};
export function StorageRequestReview({ onChangeNextState }: Props) {
const [cid, setCid] = useState(""); const [cid, setCid] = useState("");
const [availability, setAvailability] = useState<StorageAvailabilityValue>({ const [errors, setErrors] = useState({
unit: "days", nodes: "",
value: 0, tolerance: "",
proofProbability: "",
}); });
const [durability, setDurability] = useState<StorageDurabilityStepValue>({ const [durability, setDurability] = useState<number>(1);
nodes: 0, const [price, setPrice] = useState<number>(1);
proofProbability: 0, const [data, setData] = useState<Data>({
tolerance: 0, availabilityUnit: "days",
}); availability: 1,
const [price, setPrice] = useState<StoragePriceStepValue>({ tolerance: 1,
collateral: 0, proofProbability: 1,
expiration: 0, nodes: 3,
reward: 0, reward: 10,
collateral: 10,
expiration: 300,
}); });
useEffect(() => { useEffect(() => {
Promise.all([ Promise.all([
WebStorage.get<Data>("storage-request-criteria"),
WebStorage.get<string>("storage-request-step-1"), WebStorage.get<string>("storage-request-step-1"),
WebStorage.get<StorageAvailabilityValue>("storage-request-step-2"), ]).then(([d, cid]) => {
WebStorage.get<StorageDurabilityStepValue>("storage-request-step-3"), if (d) {
WebStorage.get<StoragePriceStepValue>("storage-request-step-4"), setData(d);
]).then(([cid, availability, durability, price]) => {
setCid(cid || "");
if (availability) { const index = findDurabilityIndex({
setAvailability(availability); nodes: d.nodes,
} tolerance: d.tolerance,
proofProbability: d.proofProbability,
if (durability) {
setDurability(durability);
}
if (price) {
setPrice(price);
}
onToggleNext(true);
}); });
}, [onToggleNext]);
setDurability(index + 1);
const pindex = findPriceIndex({
reward: d.reward,
collateral: d.collateral,
});
setPrice(pindex + 1);
} else {
WebStorage.set("storage-request-criteria", {
availabilityUnit: "days",
availability: 1,
tolerance: 1,
proofProbability: 1,
nodes: 3,
reward: 10,
collateral: 10,
expiration: 300,
});
}
if (cid) {
setCid(cid);
}
onChangeNextState("enable");
});
}, [onChangeNextState]);
const updateData = (p: Partial<Data>) => {
setData((d) => {
const newData = { ...d, ...p };
WebStorage.set("storage-request-criteria", newData);
return newData;
});
};
const onDurabilityRangeChange = (e: ChangeEvent<HTMLInputElement>) => {
const l = parseInt(e.currentTarget.value, 10);
const durability = durabilities[l - 1];
updateData(durability);
setDurability(l);
setErrors({ nodes: "", tolerance: "", proofProbability: "" });
};
const onPriceRangeChange = (e: ChangeEvent<HTMLInputElement>) => {
const l = parseInt(e.currentTarget.value, 10);
const price = prices[l - 1];
updateData(price);
setPrice(l);
};
const isUnvalidConstrainst = (nodes: number, tolerance: number) => {
const ecK = nodes - tolerance;
const ecM = tolerance;
return ecK <= 1 || ecK < ecM;
};
const onNodesChange = (nodes: number) => {
setErrors((e) => ({ ...e, tolerance: "" }));
if (isUnvalidConstrainst(nodes, data.tolerance)) {
setErrors((e) => ({
...e,
nodes:
"The data does not match Codex contrainst. Try with other values.",
}));
return;
}
updateData({ nodes });
const index = findDurabilityIndex({
nodes: nodes,
tolerance: data.tolerance,
proofProbability: data.proofProbability,
});
setDurability(index + 1);
};
const onToleranceChange = (tolerance: number) => {
setErrors((e) => ({ ...e, tolerance: "" }));
if (tolerance > data.nodes) {
setErrors((e) => ({
...e,
tolerance: "The tolerance cannot be greater than the nodes.",
}));
return;
}
if (isUnvalidConstrainst(data.nodes, tolerance)) {
setErrors((e) => ({
...e,
tolerance:
"The data does not match Codex contrainst. Try with other values.",
}));
return;
}
updateData({ tolerance });
const index = findDurabilityIndex({
nodes: data.nodes,
tolerance: tolerance,
proofProbability: data.proofProbability,
});
setDurability(index + 1);
};
const onProofProbabilityChange = (proofProbability: number) => {
updateData({ proofProbability });
const index = findDurabilityIndex({
nodes: data.nodes,
tolerance: data.tolerance,
proofProbability: proofProbability,
});
setDurability(index + 1);
};
const onAvailabilityChange = (availability: number) =>
updateData({ availability });
const onRewardChange = (reward: number) => {
updateData({ reward });
const index = findPriceIndex({
reward,
collateral: data.collateral,
});
setPrice(index + 1);
};
const onCollateralChange = (collateral: number) => {
updateData({ collateral });
const index = findPriceIndex({
collateral,
reward: data.reward,
});
setPrice(index + 1);
};
return ( return (
<div> <div>
<span className="storageRequest-title">Review your request</span> <span className="storageRequest-title">Choose your criteria</span>
<div className="storageRequestReview-numbers">
<CardNumbers
title={"Nodes"}
data={data.nodes.toString()}
comment={errors.nodes || "Storage nodes required"}
onChange={onNodesChange}
hasError={!!errors.nodes}></CardNumbers>
<CardNumbers
title={"Tolerance"}
data={data.tolerance.toString()}
comment={errors.tolerance || "Failure nodes tolerated"}
onChange={onToleranceChange}
hasError={!!errors.tolerance}></CardNumbers>
<CardNumbers
title={"Proof probability"}
data={data.proofProbability.toString()}
comment={errors.proofProbability || "Proof request frequency"}
onChange={onProofProbabilityChange}
hasError={!!errors.proofProbability}></CardNumbers>
</div>
<Range
onChange={onDurabilityRangeChange}
className={classnames(
["storageRequestReview-range"],
["storageRequestReview-range--disabled", durability === 0]
)}
labels={["Weak", "Low", "Medium", "High", "Confident"]}
max={5}
label=""
value={durability}
/>
<div className="storageRequestReview-numbers"> <div className="storageRequestReview-numbers">
<CardNumbers <CardNumbers
title={"Contract duration"} title={"Contract duration"}
data={availability.value.toString()} data={data.availability.toString()}
comment={"Contract duration in " + availability.unit} comment={"Contract duration in " + data.availabilityUnit}
editable></CardNumbers> onChange={onAvailabilityChange}></CardNumbers>
<CardNumbers
title={"Nodes"}
data={durability.nodes.toString()}
comment={"Storage nodes required"}
editable></CardNumbers>
<CardNumbers
title={"Tolerance"}
data={durability.tolerance.toString()}
comment={"Failure nodes tolerated"}
editable></CardNumbers>
<CardNumbers
title={"Proof probability"}
data={durability.proofProbability.toString()}
comment={"Proof request frequency"}
editable></CardNumbers>
<CardNumbers <CardNumbers
title={"Reward"} title={"Reward"}
data={price.reward.toString()} data={data.reward.toString()}
comment={"Reward tokens"} comment={"Reward tokens"}
editable></CardNumbers> onChange={onRewardChange}></CardNumbers>
<CardNumbers <CardNumbers
title={"Collateral"} title={"Collateral"}
data={price.reward.toString()} data={data.reward.toString()}
comment={"Penality tokens"} comment={"Penality tokens"}
editable></CardNumbers> onChange={onCollateralChange}></CardNumbers>
</div> </div>
<Range
<p> className={classnames(
<SimpleText variant="light">This request with CID </SimpleText>{" "} ["storageRequestReview-range"],
<b>{cid}</b> <SimpleText variant="light">will expire in </SimpleText> ["storageRequestReview-range--disabled", price === 0]
<b>{plurals("minute", price.expiration)} </b> )}
<SimpleText variant="light">after the start.</SimpleText> labels={["Low", "Average", "Attractive"]}
</p> max={3}
label=""
<Alert onChange={onPriceRangeChange}
message="If no suitable hosts are found matching your storage
requirements, you will incur a charge of X tokens."
variant="warning"
/> />
<Alert
Icon={<FileWarning />}
title="Warning"
variant="warning"
className="storageRequestReview-alert">
This request with CID
<b> {cid}</b> will expire in
<b> {plurals("minute", data.expiration)} </b>
after the start. <br />
If no suitable hosts are found matching your storage requirements, you
will incur a charge of X tokens.
</Alert>
<hr className="storageRequestReview-hr" /> <hr className="storageRequestReview-hr" />
<p className="text-center"> <p className="text-center">
<b className=" storageRequestReview-title"> <b className=" storageRequestReview-title">
Price comparaison with the market Price comparaison with the market
</b> </b>
</p> </p>
<div className="storageRequestReview-legend"> <div className="storageRequestReview-legend">
<div className="storageRequestReview-legendItem"> <div className="storageRequestReview-legendItem">
<span className="storageRequestReview-legendItemColor storageRequestReview-legendItemColor-cheap"></span> <span className="storageRequestReview-legendItemColor storageRequestReview-legendItemColor-cheap"></span>
@ -142,7 +380,6 @@ export function StorageRequestReview({ onToggleNext }: Props) {
<span>Excellent</span> <span>Excellent</span>
</div> </div>
</div> </div>
<div className="storageRequestReview-bar"> <div className="storageRequestReview-bar">
<div className="storageRequestReview-barIndicator"></div> <div className="storageRequestReview-barIndicator"></div>
</div> </div>

View File

@ -1,21 +1,18 @@
.storageRequest { .storageRequest {
background-color: var(--codex-background); background-color: var(--codex-background);
background-color: var(--codex-background); background-color: var(--codex-background);
padding: 0.5rem 1rem;
border-radius: var(--codex-border-radius); border-radius: var(--codex-border-radius);
transition: transform 0.15s; transition: transform 0.15s;
position: fixed;
max-width: 800px; max-width: 800px;
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0);
overflow-y: auto; overflow-y: auto;
overflow-x: hidden;
opacity: 0; opacity: 0;
z-index: -1;
} }
.storageRequest-open { .storageRequest-open {
transform: translate(-50%, -50%) scale(1);
opacity: 1; opacity: 1;
z-index: 10;
} }
.storageRequest-title { .storageRequest-title {
@ -81,14 +78,26 @@
text-align: center; text-align: center;
} }
.storageRequest .inputGroup-input {
width: 100%;
}
@media (max-width: 800px) { @media (max-width: 800px) {
.storageRequest { .storageRequest {
margin: auto; margin: auto;
width: 100%; width: 100%;
position: absolute;
top: 0;
left: 0;
min-height: 100%;
display: flex;
align-items: center;
.alert {
flex-direction: column;
align-items: flex-start;
}
.stepper-body,
.stepper {
width: calc(100% - 3rem);
}
} }
} }
@ -97,4 +106,15 @@
margin: auto; margin: auto;
width: 85%; width: 85%;
} }
.storageRequest {
left: 50%;
top: 50%;
transform: translate(-50%, -50%) scale(0);
position: fixed;
}
.storageRequest-open {
transform: translate(-50%, -50%) scale(1);
}
} }

View File

@ -1,34 +1,29 @@
import { StorageRequestFileChooser } from "../../components/StorageRequestSetup/StorageRequestFileChooser"; import { StorageRequestFileChooser } from "../../components/StorageRequestSetup/StorageRequestFileChooser";
import { useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { StorageRequestAvailability } from "../../components/StorageRequestSetup/StorageRequestAvailability";
import { StorageRequestDurability } from "../../components/StorageRequestSetup/StorageRequestDurability";
import { StorageRequestPrice } from "../../components/StorageRequestSetup/StorageRequestPrice";
import { WebStorage } from "../../utils/web-storage"; import { WebStorage } from "../../utils/web-storage";
import { STEPPER_DURATION } from "../../utils/constants"; import { STEPPER_DURATION } from "../../utils/constants";
import { StorageRequestReview } from "./StorageRequestReview"; import { StorageRequestReview } from "./StorageRequestReview";
import { CodexCreateStorageRequestInput } from "@codex/sdk-js"; import { CodexCreateStorageRequestInput } from "@codex/sdk-js";
import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMutation, useQueryClient } from "@tanstack/react-query";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import { import { StorageAvailabilityUnit } from "./types";
StorageAvailabilityValue,
StorageDurabilityStepValue,
StoragePriceStepValue,
} from "./types";
import { Backdrop, Stepper } from "@codex/marketplace-ui-components"; import { Backdrop, Stepper } from "@codex/marketplace-ui-components";
import { classnames } from "../../utils/classnames"; import { classnames } from "../../utils/classnames";
import { StorageRequestDone } from "./StorageRequestDone";
import { PurchaseStorage } from "../../utils/purchases-storage";
function calculateAvailability(value: StorageAvailabilityValue) { function calculateAvailability(value: number, unit: StorageAvailabilityUnit) {
switch (value.unit) { switch (unit) {
case "minutes": case "minutes":
return 60 * value.value; return 60 * value;
case "hours": case "hours":
return 60 * 60 * value.value; return 60 * 60 * value;
case "days": case "days":
return 24 * 60 * 60 * value.value; return 24 * 60 * 60 * value;
case "months": case "months":
return 30 * 24 * 60 * 60 * value.value; return 30 * 24 * 60 * 60 * value;
case "years": case "years":
return 365 * 30 * 60 * 60 * value.value; return 365 * 30 * 60 * 60 * value;
} }
} }
@ -41,37 +36,32 @@ type Props = {
export function StorageRequestStepper({ className, open, onClose }: Props) { export function StorageRequestStepper({ className, open, onClose }: Props) {
const [progress, setProgress] = useState(true); const [progress, setProgress] = useState(true);
const [step, setStep] = useState(0); const [step, setStep] = useState(0);
const steps = useRef([ const steps = useRef(["File", "Criteria", "Success"]);
"File",
"Availability",
"Durability",
"Price",
"Review",
]);
const [isNextDisable, setIsNextDisable] = useState(true); const [isNextDisable, setIsNextDisable] = useState(true);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { mutateAsync, isPending, isError, error } = useMutation({ const { mutateAsync, isPending, isError, error } = useMutation({
mutationKey: ["debug"], mutationKey: ["debug"],
mutationFn: (input: CodexCreateStorageRequestInput) => mutationFn: (input: CodexCreateStorageRequestInput) =>
CodexSdk.marketplace().then((marketplace) => CodexSdk.marketplace().then((marketplace) =>
marketplace.createStorageRequest(input) marketplace.createStorageRequest(input)
), ),
onSuccess: async (data) => { onSuccess: async (data, { cid }) => {
if (data.error) { if (data.error) {
// TODO report error // TODO report error
console.error(data); console.error(data);
} else { } else {
await Promise.all([ // setStep((s) => (s = 1));
WebStorage.delete("storage-request-step"),
WebStorage.delete("storage-request-step-1"),
WebStorage.delete("storage-request-step-2"),
WebStorage.delete("storage-request-step-3"),
WebStorage.delete("storage-request-step-4"),
]);
setStep(0);
queryClient.invalidateQueries({ queryKey: ["purchases"] }); queryClient.invalidateQueries({ queryKey: ["purchases"] });
onClose();
let requestId = data.data;
if (!requestId.startsWith("0x")) {
console.debug("No prefix detected");
requestId = "0x" + requestId;
}
PurchaseStorage.set(requestId, cid);
} }
}, },
}); });
@ -86,6 +76,11 @@ export function StorageRequestStepper({ className, open, onClose }: Props) {
}); });
}, []); }, []);
const onChangeNextState = useCallback(
(s: "enable" | "disable") => setIsNextDisable(s === "disable"),
[]
);
if (isError) { if (isError) {
// TODO Report error // TODO Report error
console.error(error); console.error(error);
@ -94,53 +89,37 @@ export function StorageRequestStepper({ className, open, onClose }: Props) {
const components = [ const components = [
StorageRequestFileChooser, StorageRequestFileChooser,
StorageRequestAvailability, // StorageRequestAvailability,
StorageRequestDurability, // StorageRequestDurability,
StorageRequestPrice, // StorageRequestPrice,
StorageRequestReview, StorageRequestReview,
StorageRequestDone,
]; ];
const onChangeStep = async (s: number, state: "before" | "end") => { const onChangeStep = async (s: number, state: "before" | "end") => {
if (s === -1) {
setStep(0);
setIsNextDisable(true);
onClose();
return;
}
if (state === "before") { if (state === "before") {
setProgress(true); setProgress(true);
return; return;
} }
if (s === -1) { if (s >= steps.current.length) {
setIsNextDisable(true); setIsNextDisable(true);
setProgress(false); setProgress(false);
if (s >= steps.current.length) {
setStep(0);
WebStorage.delete("storage-request-step");
WebStorage.delete("storage-request-criteria");
}
onClose(); onClose();
return;
}
if (s === steps.current.length) {
setIsNextDisable(true);
setProgress(false);
const [cid, availability, durability, price] = await Promise.all([
WebStorage.get<string>("storage-request-step-1"),
WebStorage.get<StorageAvailabilityValue>("storage-request-step-2"),
WebStorage.get<StorageDurabilityStepValue>("storage-request-step-3"),
WebStorage.get<StoragePriceStepValue>("storage-request-step-4"),
]);
if (!cid || !availability || !durability || !price) {
return;
}
const { reward, collateral, expiration } = price;
const { nodes, proofProbability, tolerance } = durability;
mutateAsync({
cid,
collateral,
duration: calculateAvailability(availability),
expiry: expiration * 60,
nodes,
proofProbability,
tolerance,
reward,
});
return; return;
} }
@ -150,9 +129,49 @@ export function StorageRequestStepper({ className, open, onClose }: Props) {
setIsNextDisable(true); setIsNextDisable(true);
setProgress(false); setProgress(false);
setStep(s); setStep(s);
if (s == 2) {
setIsNextDisable(true);
setProgress(false);
const [cid, criteria] = await Promise.all([
WebStorage.get<string>("storage-request-step-1"),
// TODO define criteria interface
// eslint-disable-next-line
WebStorage.get<any>("storage-request-criteria"),
]);
if (!cid || !criteria) {
return;
}
const {
availabilityUnit = "days",
availability,
reward,
collateral,
expiration,
nodes,
proofProbability,
tolerance,
} = criteria;
mutateAsync({
cid,
collateral,
duration: calculateAvailability(availability, availabilityUnit),
expiry: expiration * 60,
nodes,
proofProbability,
tolerance,
reward,
});
} else {
setIsNextDisable(false);
}
}; };
const Body = components[step]; const Body = components[step] || components[0];
return ( return (
<> <>
@ -165,7 +184,7 @@ export function StorageRequestStepper({ className, open, onClose }: Props) {
)}> )}>
<Stepper <Stepper
titles={steps.current} titles={steps.current}
Body={() => <Body onToggleNext={() => setIsNextDisable(false)} />} Body={<Body onChangeNextState={onChangeNextState} />}
step={step} step={step}
onChangeStep={onChangeStep} onChangeStep={onChangeStep}
progress={progress || isPending} progress={progress || isPending}

View File

@ -0,0 +1,4 @@
export function TruncateCellRender(cid: string) {
const truncated = cid.slice(0, 5) + ".".repeat(5) + cid.slice(-5);
return <span>{truncated}</span>;
}

View File

@ -0,0 +1,30 @@
.welcome {
border-radius: var(--codex-border-radius);
border: 1px solid var(--codex-border-color);
background-color: var(--codex-background-secondary);
padding: 1rem 1.5rem;
display: flex;
align-items: flex-start;
flex-direction: column;
}
.welcome-title {
font-weight: bold;
font-size: 1.125rem;
line-height: 1.75rem;
margin-bottom: 0.75rem;
}
.welcome-body {
flex: 1;
}
.welcome-link {
display: flex;
align-items: center;
color: var(--codex-color-primary);
}
.welcome-link:hover {
text-decoration: underline;
}

View File

@ -0,0 +1,21 @@
import { SimpleText } from "@codex/marketplace-ui-components";
import "./Welcome.css";
import { Link } from "@tanstack/react-router";
import { ChevronRight } from "lucide-react";
export function Welcome() {
return (
<div className="welcome">
<p className="welcome-title">Welcome to Codex Marketplace</p>
<div className="welcome-body">
<SimpleText variant="light">
You can start using Codex for testing purpose by uploading new files.
</SimpleText>
</div>
<Link to="/dashboard/help" className="welcome-link">
Explore more content <ChevronRight size={"1.5rem"} />
</Link>
</div>
);
}

49
src/hooks/useData.tsx Normal file
View File

@ -0,0 +1,49 @@
import { useQuery } from "@tanstack/react-query";
import { FilesStorage } from "../utils/file-storage";
import { CodexSdk } from "../sdk/codex";
export function useData() {
const { data = [] } = useQuery({
queryFn: () =>
CodexSdk.data().then(async (data) => {
const res = await data.cids();
if (res.error) {
// TODO error
return [];
}
const metadata = await FilesStorage.list();
return res.data.content.map((content, index) => {
const value = metadata.find(([cid]) => content.cid === cid);
if (!value) {
return {
...content,
mimetype: "N/A",
uploadedAt: new Date(0, 0, 0, 0, 0, 0),
name: "N/A" + index,
};
}
const {
mimetype = "",
name = "",
uploadedAt = new Date(0, 0, 0, 0, 0, 0),
} = value[1];
return {
...content,
mimetype,
name,
uploadedAt,
};
});
}),
queryKey: ["cids"],
refetchOnWindowFocus: false,
});
return data;
}

View File

@ -6,7 +6,11 @@
--codex-background: rgb(23 23 23); --codex-background: rgb(23 23 23);
--codex-color: #e1e4d9; --codex-color: #e1e4d9;
--codex-color-contrast: #f8f8f8; --codex-color-contrast: #f8f8f8;
--codex-color-error: #f85723; --codex-color-error: 239, 68, 68;
--codex-color-warning: 234, 179, 8;
--codex-color-success: 20, 184, 166;
--codex-color-blue: 30, 64, 175;
--codex-color-grey: 170, 170, 170;
--codex-color-primary: #c1f0a4; --codex-color-primary: #c1f0a4;
--codex-color-primary-variant: #c1f0a4cc; --codex-color-primary-variant: #c1f0a4cc;
--codex-color-on-primary: #333; --codex-color-on-primary: #333;
@ -22,7 +26,6 @@
BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans, BlinkMacSystemFont, Segoe UI, Roboto, Helvetica Neue, Arial, Noto Sans,
sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol, sans-serif, Apple Color Emoji, Segoe UI Emoji, Segoe UI Symbol,
Noto Color Emoji; Noto Color Emoji;
--codex-color-warning: rgb(234 179 8);
-webkit-tap-highlight-color: transparent; -webkit-tap-highlight-color: transparent;
-webkit-text-size-adjust: 100%; -webkit-text-size-adjust: 100%;
@ -31,7 +34,7 @@
font-feature-settings: normal; font-feature-settings: normal;
font-variation-settings: normal; font-variation-settings: normal;
tab-size: 4; tab-size: 4;
font-size: 0.875rem; font-size: 1.15rem;
font-size: var(--codex-font-size); font-size: var(--codex-font-size);
color-scheme: dark; color-scheme: dark;
color: var(--codex-color); color: var(--codex-color);
@ -62,12 +65,14 @@
html { html {
min-height: 100%; min-height: 100%;
display: flex; display: flex;
max-width: 100%;
} }
body { body {
margin: 0; margin: 0;
flex: 1; flex: 1;
display: flex; display: flex;
max-width: 100%;
} }
ul, ul,
@ -92,6 +97,7 @@ main {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
max-width: 100%;
} }
hr { hr {
@ -124,4 +130,9 @@ a {
.root { .root {
display: flex; display: flex;
flex: 1; flex: 1;
max-width: 100%;
}
.page {
max-width: 100%;
} }

View File

@ -3,17 +3,9 @@ import "./dashboard.css";
import { import {
MenuItem, MenuItem,
MenuItemComponentProps, MenuItemComponentProps,
NetworkIndicator,
Page, Page,
} from "@codex/marketplace-ui-components"; } from "@codex/marketplace-ui-components";
import { import { Home, ShoppingBag, Server, Settings, HelpCircle } from "lucide-react";
Home,
Star,
ShoppingBag,
Server,
Settings,
HelpCircle,
} from "lucide-react";
import { ICON_SIZE } from "../utils/constants"; import { ICON_SIZE } from "../utils/constants";
import { NodeIndicator } from "../components/NodeIndicator/NodeIndicator"; import { NodeIndicator } from "../components/NodeIndicator/NodeIndicator";
import { HttpNetworkIndicator } from "../components/HttpNetworkIndicator/HttpNetworkIndicator"; import { HttpNetworkIndicator } from "../components/HttpNetworkIndicator/HttpNetworkIndicator";
@ -36,15 +28,6 @@ const Layout = () => {
</Link> </Link>
), ),
}, },
{
type: "menu-item",
Component: (p: MenuItemComponentProps) => (
<Link to="/dashboard/favorites" {...p}>
<Star size={ICON_SIZE} />
Favorites
</Link>
),
},
{ {
type: "separator", type: "separator",
}, },
@ -77,6 +60,17 @@ const Layout = () => {
</Link> </Link>
), ),
}, },
{
type: "separator",
},
{
type: "menu-item",
Component: (p: MenuItemComponentProps) => (
<Link to="/dashboard/help" {...p}>
<HelpCircle size={"1.25rem"} /> Help
</Link>
),
},
{ {
type: "menu-item", type: "menu-item",
Component: (p: MenuItemComponentProps) => ( Component: (p: MenuItemComponentProps) => (
@ -86,17 +80,6 @@ const Layout = () => {
</Link> </Link>
), ),
}, },
{
type: "separator",
},
{
type: "menu-item",
Component: (p: MenuItemComponentProps) => (
<a {...p}>
<HelpCircle size={"1.25rem"} /> Help
</a>
),
},
] satisfies MenuItem[]; ] satisfies MenuItem[];
return <Page children={<Outlet />} items={items} Right={Right} />; return <Page children={<Outlet />} items={items} Right={Right} />;

View File

@ -88,7 +88,7 @@ const About = () => {
<a <a
className="button" className="button"
target="_blank" target="_blank"
href={"http://localhost:8002/api/codex/v1/data/" + c.cid}> href={"/api/codex/v1/data/" + c.cid}>
Download Download
</a> </a>
</div> </div>

View File

@ -1,13 +1,13 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ErrorBoundary } from "../../components/ErrorBoundary/ErrorBoundary"; import { ErrorBoundary } from "../../components/ErrorBoundary/ErrorBoundary";
import { Manifests } from "../../components/Manifests/Manitests"; import { Files } from "../../components/Files/Files";
export const Route = createFileRoute("/dashboard/favorites")({ export const Route = createFileRoute("/dashboard/favorites")({
component: () => ( component: () => (
<> <>
<ErrorBoundary fallback={() => ""}> <ErrorBoundary fallback={() => ""}>
<div className="container"> <div className="container">
<Manifests /> <Files />
</div> </div>
</ErrorBoundary> </ErrorBoundary>
</> </>

View File

@ -0,0 +1,35 @@
.help {
max-width: 600px;
margin: auto;
}
.help-title {
margin-bottom: 4rem;
margin-top: 4rem;
}
.help-itemTitle {
font-size: 1.125rem;
line-height: 1.75rem;
font-weight: bold;
margin-bottom: 0.75rem;
}
.help-itemIcon {
color: var(--codex-color-disabled);
width: 2rem;
height: auto;
}
.help-itemBody {
font-size: 1.125rem;
}
.help-item {
padding-bottom: 2rem;
margin-bottom: 2rem;
border-bottom: 1px solid var(--codex-border-color);
gap: 1rem;
display: flex;
align-items: flex-start;
}

View File

@ -1,5 +1,41 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import "./help.css";
import { HelpCircle } from "lucide-react";
import { SimpleText } from "@codex/marketplace-ui-components";
export const Route = createFileRoute("/dashboard/help")({ export const Route = createFileRoute("/dashboard/help")({
component: () => <div className="container">Hello /dashboard/help!</div>, component: () => (
<div className="container">
<div className="container-fluid">
<div className="help">
<h1 className="help-title">You might be wondering...</h1>
<div className="help-item">
<HelpCircle className="help-itemIcon" size={"1.5rem"} />
<div className="help-itemBody">
<p className="help-itemTitle">Looking for help to build Codex?</p>
<SimpleText variant="light">
Yes, you should refer to the documentation. If you do need more
help, ask to the github project.
</SimpleText>
</div>
</div>
<div className="help-item">
<HelpCircle className="help-itemIcon" size={"1rem"} />
<div className="help-itemBody">
<p className="help-itemTitle">Looking for help to build Codex?</p>
<SimpleText variant="light">
Yes, you should refer to the documentation. If you do need more
help, ask to the github project.
</SimpleText>
</div>
</div>
</div>
{/* <ErrorBoundary fallback={() => ""}>
<Debug />
</ErrorBoundary> */}
</div>
</div>
),
}); });

View File

@ -1,20 +1,23 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { Debug } from "../../components/Debug/Debug.tsx";
import { ErrorBoundary } from "../../components/ErrorBoundary/ErrorBoundary.tsx"; import { ErrorBoundary } from "../../components/ErrorBoundary/ErrorBoundary.tsx";
import { LogLevel } from "../../components/LogLevel/LogLevel.tsx"; import { Files } from "../../components/Files/Files.tsx";
import { Manifests } from "../../components/Manifests/Manitests.tsx"; import { Card, Upload } from "@codex/marketplace-ui-components";
import { NodeSpaceAllocation } from "../../components/NodeSpaceAllocation/NodeSpaceAllocation.tsx";
import {
Card,
EmptyPlaceholder,
Upload,
} from "@codex/marketplace-ui-components";
import { CodexSdk } from "../../sdk/codex.ts"; import { CodexSdk } from "../../sdk/codex.ts";
import { Welcome } from "../../components/Welcome/Welcome.tsx";
import { FilesStorage } from "../../utils/file-storage.ts";
export const Route = createFileRoute("/dashboard/")({ export const Route = createFileRoute("/dashboard/")({
component: About, component: About,
}); });
const onSuccess = (cid: string, file: File) => {
FilesStorage.set(cid, {
name: file.name,
mimetype: file.type,
uploadedAt: new Date(),
});
};
function About() { function About() {
return ( return (
<> <>
@ -24,41 +27,21 @@ function About() {
<Upload <Upload
multiple multiple
provider={() => provider={() =>
CodexSdk.data().then((data) => data.upload.bind(CodexSdk)) CodexSdk.data().then((data) => data.upload.bind(data))
} }
onSuccess={onSuccess}
/> />
</Card> </Card>
</ErrorBoundary> </ErrorBoundary>
<ErrorBoundary fallback={() => ""}> <ErrorBoundary fallback={() => ""}>
<LogLevel /> <Welcome />
</ErrorBoundary>
<ErrorBoundary fallback={() => ""}>
<Card title="Node space allocation">
<NodeSpaceAllocation />
</Card>
</ErrorBoundary>
<ErrorBoundary fallback={() => ""}>
<Card title="Empty state">
<EmptyPlaceholder
title="Nothing to show"
message="No data here yet. We will notify you when there's an update."
/>
</Card>
</ErrorBoundary> </ErrorBoundary>
</div> </div>
<div className="container-fluid"> <div className="container-fluid">
<ErrorBoundary fallback={() => ""}> <ErrorBoundary fallback={() => ""}>
<Manifests /> <Files />
</ErrorBoundary>
</div>
<div className="container-fluid">
<ErrorBoundary fallback={() => ""}>
<Debug />
</ErrorBoundary> </ErrorBoundary>
</div> </div>
</> </>

View File

@ -1,13 +1,7 @@
.purchases-modal { .purchases-modal {
z-index: -1;
position: fixed;
margin: auto; margin: auto;
} }
.purchases-modal-open {
z-index: 1;
}
.purchases-actions { .purchases-actions {
padding: 1rem; padding: 1rem;
display: flex; display: flex;

View File

@ -3,17 +3,13 @@ import { createFileRoute } from "@tanstack/react-router";
import { CodexSdk } from "../../sdk/codex"; import { CodexSdk } from "../../sdk/codex";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { import { Button, Cell, Table } from "@codex/marketplace-ui-components";
BreakCellRender,
Button,
DefaultCellRender,
DurationCellRender,
StateCellRender,
Table,
} from "@codex/marketplace-ui-components";
import { StorageRequestStepper } from "../../components/StorageRequestSetup/StorageRequestStepper"; import { StorageRequestStepper } from "../../components/StorageRequestSetup/StorageRequestStepper";
import "./purchases.css"; import "./purchases.css";
import { classnames } from "../../utils/classnames"; import { classnames } from "../../utils/classnames";
import { FileCell } from "../../components/FileCellRender/FIleCell";
import { CustomStateCellRender } from "../../components/CustomStateCellRender/CustomStateCellRender";
import prettyMilliseconds from "pretty-ms";
const Purchases = () => { const Purchases = () => {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
@ -28,41 +24,36 @@ const Purchases = () => {
} }
if (data?.error) { if (data?.error) {
console.error(data.data);
return <div>Error: {data.data.message}</div>; return <div>Error: {data.data.message}</div>;
// TODO Manage error // TODO Manage error
} }
const headers = [ const headers = [
"id", "cid",
"state",
"duration", "duration",
"slots", "slots",
"reward", "reward",
"proof probability", "proof probability",
"error", "state",
]; ];
const cells = [ const sorted = [...(data?.data || [])].reverse();
BreakCellRender, const cells =
StateCellRender({ cancelling: "success" }), sorted.map((p, index) => {
DurationCellRender, const r = p.request;
DefaultCellRender, const ask = p.request.ask;
DefaultCellRender, const duration = parseInt(p.request.ask.duration, 10) * 1000;
DefaultCellRender, const pf = parseInt(p.request.ask.proofProbability, 10) * 1000;
DefaultCellRender,
];
const purchases = return [
data?.data.map((p) => [ <FileCell requestId={r.id} purchaseCid={r.content.cid} index={index} />,
p.requestId.toString(), <Cell value={prettyMilliseconds(duration)} />,
p.state, <Cell value={ask.slots + " hosts"} />,
p.request.ask.duration.toString(), <Cell value={ask.reward + " tokens"} />,
p.request.ask.slots.toString(), <Cell value={"Every " + prettyMilliseconds(pf)} />,
p.request.ask.reward.toString(), <CustomStateCellRender state={p.state} message={p.error} />,
p.request.ask.proofProbability.toString(), ];
p.error, }) || [];
]) || [];
return ( return (
<div className="container"> <div className="container">
@ -83,7 +74,8 @@ const Purchases = () => {
open={open} open={open}
onClose={() => setOpen(false)} onClose={() => setOpen(false)}
/> />
<Table headers={headers} data={purchases} cells={cells} />
{!open && <Table headers={headers} cells={cells} />}
</div> </div>
); );
}; };

View File

@ -1,5 +1,18 @@
.settings { .settings {
display: flex; border-radius: var(--codex-border-radius);
flex-direction: column; border: 1px solid var(--codex-border-color);
gap: 0.75rem; background-color: var(--codex-background-secondary);
padding: 1rem 1.5rem;
margin: 1rem 1.5rem;
}
.settings-title {
font-weight: bold;
font-size: 1.125rem;
line-height: 1.75rem;
margin-bottom: 0.75rem;
}
.settings-input {
margin-bottom: 0.75rem;
} }

View File

@ -1,13 +1,24 @@
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { ErrorBoundary } from "../../components/ErrorBoundary/ErrorBoundary"; import { ErrorBoundary } from "../../components/ErrorBoundary/ErrorBoundary";
import "./settings.css"; import "./settings.css";
import { LogLevel } from "../../components/LogLevel/LogLevel";
import { CodexUrlSettings } from "../../CodexUrllSettings/CodexUrlSettings";
export const Route = createFileRoute("/dashboard/settings")({ export const Route = createFileRoute("/dashboard/settings")({
component: () => ( component: () => (
<> <>
<ErrorBoundary fallback={() => ""}> <ErrorBoundary fallback={() => ""}>
<div className="container"> <div className="settings">
<p>Settings</p> <ErrorBoundary fallback={() => ""}>
<LogLevel />
</ErrorBoundary>
</div>
<div className="settings">
<ErrorBoundary fallback={() => ""}>
<CodexUrlSettings />
</ErrorBoundary>
</div>
{/* <div className="input-floating"> {/* <div className="input-floating">
<input <input
@ -33,7 +44,6 @@ export const Route = createFileRoute("/dashboard/settings")({
Floating Floating
</label> </label>
</div> */} </div> */}
</div>
</ErrorBoundary> </ErrorBoundary>
</> </>
), ),

View File

@ -1,7 +1,12 @@
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute, Link, redirect } from "@tanstack/react-router";
export const Route = createFileRoute("/")({ export const Route = createFileRoute("/")({
component: Index, component: Index,
beforeLoad: async () => {
throw redirect({
to: "/dashboard",
});
},
}); });
function Index() { function Index() {
@ -9,7 +14,7 @@ function Index() {
<div className="p-2"> <div className="p-2">
<h3>Welcome Home!</h3> <h3>Welcome Home!</h3>
<Link to="/dashboard/index">Go to dashboard</Link> <Link to="/dashboard">Go to dashboard</Link>
</div> </div>
); );
} }

View File

@ -1,3 +1,49 @@
import { Codex } from "@codex/sdk-js"; import { Codex } from "@codex/sdk-js";
import { WebStorage } from "../utils/web-storage";
export const CodexSdk = new Codex(import.meta.env.VITE_CODEX_API_URL); export class CodexSdk {
static _client: Codex;
static _url: string;
static client() {
if (this._client) {
return Promise.resolve(this._client);
}
return WebStorage.get<string>("codex-node-url")
.then((url) => {
this._url = url || import.meta.env.VITE_CODEX_API_URL;
this._client = new Codex(this._url);
console.info({ url: this._url });
})
.then(() => this._client);
}
static url() {
return this.client().then(() => this._url);
}
static updateURL(url: string) {
this._url = url;
this._client = new Codex(url);
return WebStorage.set("codex-node-url", url);
}
static debug() {
return this.client().then((client) => client.debug());
}
static data() {
return this.client().then((client) => client.data());
}
static node() {
return this.client().then((client) => client.node());
}
static marketplace() {
return this.client().then((client) => client.marketplace());
}
}

View File

@ -1,18 +0,0 @@
// TODO remove this for WebStorage
export const BrowserStorage = {
toggle(key: string, value: string) {
const previous = JSON.parse(window.localStorage.getItem(key) || "[]");
if (previous.includes(value)) {
const values = previous.filter((v: string) => v !== value);
window.localStorage.setItem(key, JSON.stringify(values));
return;
}
window.localStorage.setItem(key, JSON.stringify([...previous, value]));
},
values(key: string) {
return JSON.parse(window.localStorage.getItem(key) || "[]");
},
};

View File

@ -7,3 +7,5 @@ export const SIDE_DURATION = 3000;
export const ICON_SIZE = "1.25rem"; export const ICON_SIZE = "1.25rem";
export const STEPPER_DURATION = 500; export const STEPPER_DURATION = 500;
export const EXPLORER_URL = "https://explorer.testnet.codex.storage/tx";

View File

@ -1,5 +1,9 @@
export const Dates = { export const Dates = {
format(date: string) { format(date: string | Date) {
if (!date) {
return "N/A";
}
return new Intl.DateTimeFormat("en-GB", { return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium", dateStyle: "medium",
timeStyle: "short", timeStyle: "short",

View File

@ -0,0 +1,17 @@
import { createStore, del, keys, set } from "idb-keyval";
const store = createStore("favorites", "favorites");
export const FavoriteStorage = {
list() {
return keys<string>(store);
},
delete(key: string) {
return del(key, store);
},
async add(key: string) {
return set(key, "1", store);
},
};

27
src/utils/file-storage.ts Normal file
View File

@ -0,0 +1,27 @@
import { createStore, entries, get, set } from "idb-keyval";
const store = createStore("files", "files");
export type FileMetadata = {
mimetype: string;
uploadedAt: Date;
name: string;
};
export const FilesStorage = {
list() {
return entries<string, FileMetadata>(store);
},
// delete(key: string) {
// return del(key, store);
// },
async get<T>(cid: string) {
return get<T>(cid, store);
},
async set(cid: string, metadata: FileMetadata) {
return set(cid, metadata, store);
},
};

View File

@ -3,3 +3,8 @@ export const Files = {
return type.startsWith("image"); return type.startsWith("image");
}, },
}; };
export type CodexFileMetadata = {
type: string;
name: string;
};

View File

@ -0,0 +1,13 @@
import { createStore, get, set } from "idb-keyval";
const store = createStore("purchases", "purchases");
export const PurchaseStorage = {
async get(key: string) {
return get<string>(key, store);
},
async set(key: string, cid: string) {
return set(key, cid, store);
},
};

View File

@ -1,39 +0,0 @@
import { Codex } from "@codex/sdk-js";
const codex = new Codex(import.meta.env.VITE_CODEX_API_URL);
let abort: () => void;
self.addEventListener("message", function (e) {
const { type, ...rest } = e.data;
if (type === "abort") {
console.debug("Aborting request");
abort?.();
return;
}
const onProgress = (loaded: number, total: number) => {
self.postMessage({
type: "progress",
loaded,
total,
});
};
return codex
.data()
.then((data) => data.upload(rest.file, onProgress))
.then((result) => {
abort = result.abort;
return result.result;
})
.then((value) => {
self.postMessage({
type: "completed",
value,
});
});
});