diff --git a/package-lock.json b/package-lock.json index ee7716d..27483ac 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "@sentry/react": "^8.31.0", "@tanstack/react-query": "^5.51.15", "@tanstack/react-router": "^1.58.7", + "dotted-map": "^2.2.3", "idb-keyval": "^6.2.1", "lucide-react": "^0.445.0", "react": "^18.3.1", @@ -39,47 +40,6 @@ "node": ">=18" } }, - "../storybook": { - "name": "@codex-storage/marketplace-ui-components", - "version": "0.0.16", - "license": "MIT", - "dependencies": { - "lucide-react": "^0.441.0" - }, - "devDependencies": { - "@chromatic-com/storybook": "^2.0.2", - "@storybook/addon-essentials": "^8.2.9", - "@storybook/addon-interactions": "^8.2.9", - "@storybook/addon-links": "^8.2.9", - "@storybook/addon-onboarding": "^8.2.9", - "@storybook/blocks": "^8.2.9", - "@storybook/react": "^8.2.9", - "@storybook/react-vite": "^8.2.9", - "@storybook/test": "^8.2.9", - "@typescript-eslint/eslint-plugin": "^8.6.0", - "@typescript-eslint/parser": "^8.0.0", - "@vitejs/plugin-react": "^4.3.1", - "eslint": "^8.57.0", - "eslint-plugin-react-hooks": "^4.6.2", - "eslint-plugin-react-refresh": "^0.4.7", - "glob": "^9.3.5", - "prettier": "^3.3.3", - "react": "^18.3.1", - "react-dom": "^18.3.1", - "storybook": "^8.2.9", - "typescript": "5.5.2", - "vite-plugin-dts": "^4.0.3", - "vite-plugin-lib-inject-css": "^2.1.1" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "@codex-storage/sdk-js": "^0.0.6", - "react": "^18.3.1", - "react-dom": "^18.3.1" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "dev": true, @@ -383,8 +343,28 @@ } }, "node_modules/@codex-storage/marketplace-ui-components": { - "resolved": "../storybook", - "link": true + "version": "0.0.16", + "resolved": "https://registry.npmjs.org/@codex-storage/marketplace-ui-components/-/marketplace-ui-components-0.0.16.tgz", + "integrity": "sha512-49RQB/ld3EIZrFr/1GL7NB/cEI7TjnHRIBD3LJqZ2/ml+0gj//Mep85yV9Df1owiE2yyFh76bAeC8rUTo9cA/g==", + "dependencies": { + "lucide-react": "^0.441.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@codex-storage/sdk-js": "^0.0.6", + "react": "^18.3.1", + "react-dom": "^18.3.1" + } + }, + "node_modules/@codex-storage/marketplace-ui-components/node_modules/lucide-react": { + "version": "0.441.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.441.0.tgz", + "integrity": "sha512-0vfExYtvSDhkC2lqg0zYVW1Uu9GsI4knuV9GP9by5z0Xhc4Zi5RejTxfz9LsjRmCyWVzHCJvxGKZWcRyvQCWVg==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } }, "node_modules/@codex-storage/sdk-js": { "version": "0.0.7", @@ -1595,6 +1575,37 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, + "node_modules/@turf/boolean-point-in-polygon": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/boolean-point-in-polygon/-/boolean-point-in-polygon-6.5.0.tgz", + "integrity": "sha512-DtSuVFB26SI+hj0SjrvXowGTUCHlgevPAIsukssW6BG5MlNSBQAo70wpICBNJL6RjukXg8d2eXaAWuD/CqL00A==", + "dependencies": { + "@turf/helpers": "^6.5.0", + "@turf/invariant": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/helpers": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/helpers/-/helpers-6.5.0.tgz", + "integrity": "sha512-VbI1dV5bLFzohYYdgqwikdMVpe7pJ9X3E+dlr425wa2/sMJqYDhTO++ec38/pcPvPE6oD9WEEeU3Xu3gza+VPw==", + "funding": { + "url": "https://opencollective.com/turf" + } + }, + "node_modules/@turf/invariant": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/@turf/invariant/-/invariant-6.5.0.tgz", + "integrity": "sha512-Wv8PRNCtPD31UVbdJE/KVAWKe7l6US+lJItRR/HOEW3eh+U/JwRCSUl/KZ7bmjM/C+zLNoreM2TU6OoLACs4eg==", + "dependencies": { + "@turf/helpers": "^6.5.0" + }, + "funding": { + "url": "https://opencollective.com/turf" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "dev": true, @@ -2190,6 +2201,15 @@ "node": ">=6.0.0" } }, + "node_modules/dotted-map": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/dotted-map/-/dotted-map-2.2.3.tgz", + "integrity": "sha512-8hyOOHHLLVCcCisM3yb9hqp+3bJ7TSMcr1SfrUw8Wxp5UMqih35jIvUyagweCooJbz/EH1nC9GGuPysh7+YlAg==", + "dependencies": { + "@turf/boolean-point-in-polygon": "^6.0.1", + "proj4": "^2.6.1" + } + }, "node_modules/escalade": { "version": "3.2.0", "dev": true, @@ -2899,6 +2919,11 @@ "node": ">= 8" } }, + "node_modules/mgrs": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz", + "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==" + }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3119,6 +3144,15 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/proj4": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.12.1.tgz", + "integrity": "sha512-vmhP3hmstjXjzFwg8QXJwpoj4n7GVrXk3ZW3DzNK/Ur4cuwXq7ZiMXaWYvLYLQbX8n4MXgbwTr4lthOUZltBpA==", + "dependencies": { + "mgrs": "1.0.0", + "wkt-parser": "^1.3.3" + } + }, "node_modules/punycode": { "version": "2.3.1", "dev": true, @@ -4019,6 +4053,11 @@ "node": ">= 8" } }, + "node_modules/wkt-parser": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.3.3.tgz", + "integrity": "sha512-ZnV3yH8/k58ZPACOXeiHaMuXIiaTk1t0hSUVisbO0t4RjA5wPpUytcxeyiN2h+LZRrmuHIh/1UlrR9e7DHDvTw==" + }, "node_modules/word-wrap": { "version": "1.2.5", "dev": true, diff --git a/package.json b/package.json index 95a1b8b..26a0ab3 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,7 @@ "@sentry/react": "^8.31.0", "@tanstack/react-query": "^5.51.15", "@tanstack/react-router": "^1.58.7", + "dotted-map": "^2.2.3", "idb-keyval": "^6.2.1", "lucide-react": "^0.445.0", "react": "^18.3.1", diff --git a/src/components/Files/FileDetails.tsx b/src/components/Files/FileDetails.tsx index 98e1915..6de4a6c 100644 --- a/src/components/Files/FileDetails.tsx +++ b/src/components/Files/FileDetails.tsx @@ -10,6 +10,7 @@ import { Dates } from "../../utils/dates"; import { CidCopyButton } from "./CidCopyButton"; import "./FileDetails.css"; import { DownloadIcon, X } from "lucide-react"; +import { CodexSdk } from "../../sdk/codex"; type Props = { details: CodexDataContent | undefined; @@ -18,7 +19,7 @@ type Props = { }; export function FileDetails({ onClose, details, expanded }: Props) { - const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/"; + const url = CodexSdk.url() + "/api/codex/v1/data/"; const Icon = () => ; diff --git a/src/components/Files/Files.css b/src/components/Files/Files.css index e5427f7..02a9d19 100644 --- a/src/components/Files/Files.css +++ b/src/components/Files/Files.css @@ -69,4 +69,24 @@ .files-header { margin-bottom: 0.75rem; + display: flex; +} + +.files-headerLeft { + flex: 1; +} + +.files-headerRight { + display: flex; + align-items: flex-start; + gap: 0.5rem; +} + +.files-fileBody { + margin-top: 1.25rem; +} + +.files-folders { + width: 200px; + min-width: 0px; } diff --git a/src/components/Files/Files.tsx b/src/components/Files/Files.tsx index 65d4d34..cc95e8d 100644 --- a/src/components/Files/Files.tsx +++ b/src/components/Files/Files.tsx @@ -1,5 +1,14 @@ -import { Download, FilesIcon, ReceiptText, Star } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; +import { + ChevronDown, + Download, + FilesIcon, + Folder, + Plus, + ReceiptText, + Star, + X, +} from "lucide-react"; +import { ChangeEvent, useEffect, useRef, useState } from "react"; import { PrettyBytes } from "../../utils/bytes"; import { Dates } from "../../utils/dates"; import "./Files.css"; @@ -9,10 +18,17 @@ import { EmptyPlaceholder, WebFileIcon, Tabs, + Input, + Button, + TabProps, } from "@codex-storage/marketplace-ui-components"; import { FileDetails } from "./FileDetails.tsx"; import { FavoriteStorage } from "../../utils/favorite-storage.tsx"; import { useData } from "../../hooks/useData.tsx"; +import { WebStorage } from "../../utils/web-storage.ts"; +import { CodexSdk } from "../../sdk/codex.ts"; +import { FolderButton } from "./FolderButton.tsx"; +import { classnames } from "../../utils/classnames.ts"; type StarIconProps = { isFavorite: boolean; @@ -32,11 +48,14 @@ export function Files() { const files = useData(); const cid = useRef(""); const [expanded, setExpanded] = useState(false); - const [favorites, setFavorites] = useState([]); const [index, setIndex] = useState(0); + const [folder, setFolder] = useState(""); + const [folders, setFolders] = useState<[string, string[]][]>([]); + const [error, setError] = useState(""); + const [details, setDetails] = useState([]); useEffect(() => { - FavoriteStorage.list().then((cids) => setFavorites(cids)); + WebStorage.folders.list().then((items) => setFolders(items)); }, []); const onClose = () => { @@ -47,59 +66,159 @@ export function Files() { }, SIDE_DURATION); }; - const onTabChange = (i: number) => setIndex(i); + const onTabChange = async (i: number) => setIndex(i); 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)); + // const details = items.find((c) => c.cid === cid.current); + + const onFolderChange = (e: ChangeEvent) => { + const val = e.currentTarget.value; + setFolder(val); + setError(""); + + if (!val) { + return; + } + + if (e.currentTarget.checkValidity()) { + if (folders.length >= 5) { + setError("5 folders limit reached"); + return; + } } else { - FavoriteStorage.add(cid); - setFavorites([...favorites, cid]); + setError("9 alpha characters maximum"); } }; - const items = []; + const onFolderCreate = () => { + WebStorage.folders.create(folder); - if (index === 1) { - items.push(...files.filter((f) => favorites.includes(f.cid))); - } else { - items.push(...files); - } + setFolder(""); + setFolders([...folders, [folder, []]]); + }; - const details = items.find((c) => c.cid === cid.current); + const onFolderDelete = (val: string) => { + WebStorage.folders.delete(val); - const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/"; + const currentIndex = folders.findIndex(([name]) => name === val); + + if (currentIndex + 1 == index) { + setIndex(index - 1); + } + + setFolders(folders.filter(([name]) => name !== val)); + }; + + const onFolderToggle = (cid: string, folder: string) => { + const current = folders.find(([name]) => name === folder); + + if (!current) { + return; + } + + const [, files] = current; + + if (files.includes(cid)) { + WebStorage.folders.deleteFile(folder, cid); + + setFolders( + folders.map(([name, files]) => + name === folder + ? [name, files.filter((id) => id !== cid)] + : [name, files] + ) + ); + } else { + WebStorage.folders.addFile(folder, cid); + + setFolders( + folders.map(([name, files]) => + name === folder ? [name, [...files, cid]] : [name, files] + ) + ); + } + }; + + const tabs: TabProps[] = folders.map(([folder]) => ({ + label: folder, + Icon: () => , + IconAfter: () => ( + { + e.preventDefault(); + e.stopPropagation(); + + onFolderDelete(folder); + }}> + ), + })); + + const onToggleDetails = (cid: string) => { + if (details.includes(cid)) { + setDetails(details.filter((val) => val !== cid)); + } else { + setDetails([...details, cid]); + } + }; + + tabs.unshift({ + label: "All files", + Icon: () => , + }); + const items = + index === 0 + ? files + : files.filter((file) => folders[index - 1][1].includes(file.cid)); + + const url = CodexSdk.url() + "/api/codex/v1/data/"; return (
-
Files
- , - }, - { - label: "Favorites", - Icon: () => , - }, - ]}> +
+
Files
+
+
+
+ +
+ + +
+ +
{items.length ? ( items.map((c) => (
+ onToggleDetails(c.cid)} + className={classnames( + ["availabilityTable-chevron"], + ["availabilityTable-chevron--open", details.includes(c.cid)] + )}> +
@@ -120,12 +239,13 @@ export function Files() { onClick={() => window.open(url + c.cid, "_blank")} Icon={() => }> - onToggleFavorite(c.cid)} - Icon={() => ( - - )}> + [ + folder, + files.includes(c.cid), + ])} + onFolderToggle={(folder) => onFolderToggle(c.cid, folder)} + />
+ + {details.includes(c.cid) && ( + <> +
CID : {c.cid}
+
Filename : {c.manifest.filename}
+
Protected: {c.manifest.protected}
+ + )}
)) ) : ( @@ -148,7 +276,7 @@ export function Files() { )}
- + {/* */} ); } diff --git a/src/components/Files/FolderButton.css b/src/components/Files/FolderButton.css new file mode 100644 index 0000000..237bc41 --- /dev/null +++ b/src/components/Files/FolderButton.css @@ -0,0 +1,38 @@ +.folderButton { + position: relative; +} + +.folderButton-options { + position: absolute; + transform: translateY(200px); + opacity: 0; + transition: + transform 0.25s, + opacity 0.15s; + background-color: var(--codex-background); + padding: 0.5rem; + border-radius: var(--codex-border-radius); + width: 150px; + right: -40px; + border: 1px solid var(--codex-border-color); +} + +.folderButton-options[aria-expanded] { + z-index: 12; + transform: translateY(0px); + opacity: 1; +} + +.folderButton-option { + padding: 0.75rem; + transition: background-color 0.35s; + cursor: pointer; + border-radius: var(--codex-border-radius); + display: flex; + align-items: center; + justify-content: space-between; +} + +.folderButton-option:hover { + background-color: var(--codex-background-light); +} diff --git a/src/components/Files/FolderButton.tsx b/src/components/Files/FolderButton.tsx new file mode 100644 index 0000000..47a7a1f --- /dev/null +++ b/src/components/Files/FolderButton.tsx @@ -0,0 +1,53 @@ +import { + Backdrop, + ButtonIcon, + SimpleText, +} from "@codex-storage/marketplace-ui-components"; +import { CheckCircle, Folder } from "lucide-react"; +import "./FolderButton.css"; +import { useState } from "react"; +import { attributes } from "../../utils/attributes"; + +type Props = { + folders: [string, boolean][]; + onFolderToggle: (folder: string) => void; +}; + +export function FolderButton({ folders, onFolderToggle }: Props) { + const [open, setOpen] = useState(false); + + const onClose = () => setOpen(false); + + const onOpen = () => setOpen(true); + + const attr = attributes({ "aria-expanded": open }); + + return ( +
+ + + }> + +
+ {folders.map(([folder, isActive]) => ( +
onFolderToggle(folder)}> +
{folder}
+
+ {isActive && ( + + + + )} +
+
+ ))} +
+
+ ); +} diff --git a/src/utils/web-storage.ts b/src/utils/web-storage.ts index 9f1c8a3..bcf5751 100644 --- a/src/utils/web-storage.ts +++ b/src/utils/web-storage.ts @@ -1,4 +1,6 @@ -import { del, get, set } from "idb-keyval"; +import { createStore, del, entries, get, keys, set } from "idb-keyval"; + +const folders = createStore("folders", "folders"); export const WebStorage = { set(key: string, value: unknown) { @@ -12,4 +14,44 @@ export const WebStorage = { delete(key: string) { return del(key); }, + + folders: { + create(folder: string) { + return set(folder, [], folders); + }, + + async list(): Promise<[string, string[]][]> { + const items = await entries(folders) || [] + + if (items.length == 0) { + return [["Favorites", []]] + } + + if (items[0][0] !== "Favorites") { + return [["Favorites", []], ...items] + } + + + return items + + }, + + delete(key: string) { + return del(key, folders); + }, + + + async addFile(folder: string, cid: string) { + const files = await get(folder, folders) || [] + + return set(folder, [...files, cid], folders) + }, + + async deleteFile(folder: string, cid: string) { + const files = await get(folder, folders) || [] + + return set(folder, files.filter(item => item !== cid), folders) + + }, + } };