Add folders feature

This commit is contained in:
Arnaud 2024-10-10 10:31:30 +02:00
parent 6c96cbe295
commit 10f6a8b1b3
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
8 changed files with 407 additions and 85 deletions

125
package-lock.json generated
View File

@ -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,

View File

@ -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",

View File

@ -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 = () => <X size={ICON_SIZE} onClick={onClose} />;

View File

@ -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;
}

View File

@ -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<string | null>("");
const [expanded, setExpanded] = useState(false);
const [favorites, setFavorites] = useState<string[]>([]);
const [index, setIndex] = useState(0);
const [folder, setFolder] = useState("");
const [folders, setFolders] = useState<[string, string[]][]>([]);
const [error, setError] = useState("");
const [details, setDetails] = useState<string[]>([]);
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<HTMLInputElement>) => {
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: () => <Folder size={"1rem"}></Folder>,
IconAfter: () => (
<X
size={"1rem"}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
onFolderDelete(folder);
}}></X>
),
}));
const onToggleDetails = (cid: string) => {
if (details.includes(cid)) {
setDetails(details.filter((val) => val !== cid));
} else {
setDetails([...details, cid]);
}
};
tabs.unshift({
label: "All files",
Icon: () => <FilesIcon size={"1rem"}></FilesIcon>,
});
const items =
index === 0
? files
: files.filter((file) => folders[index - 1][1].includes(file.cid));
const url = CodexSdk.url() + "/api/codex/v1/data/";
return (
<div className="files">
<div className="files-header">
<div className="files-title">Files</div>
<Tabs
onTabChange={onTabChange}
tabIndex={index}
tabs={[
{
label: "All files",
Icon: () => <FilesIcon size={"1rem"}></FilesIcon>,
},
{
label: "Favorites",
Icon: () => <Star size={"1rem"}></Star>,
},
]}></Tabs>
<div className="files-headerLeft">
<div className="files-title">Files</div>
</div>
<div className="files-headerRight">
<div>
<Input
id="folder"
inputClassName={classnames(["files-folders"])}
isInvalid={folder !== "" && !!error}
value={folder}
required={true}
pattern="[A-Za-z]*"
maxLength={9}
helper={error || "Enter the folder name"}
onChange={onFolderChange}></Input>
</div>
<Button
label="Folder"
Icon={Plus}
disabled={!!error || !folder}
onClick={onFolderCreate}></Button>
</div>
</div>
<Tabs onTabChange={onTabChange} tabIndex={index} tabs={tabs}></Tabs>
<div className="files-fileBody">
{items.length ? (
items.map((c) => (
<div className="files-file" key={c.cid}>
<div className="files-fileContent">
<ChevronDown
onClick={() => onToggleDetails(c.cid)}
className={classnames(
["availabilityTable-chevron"],
["availabilityTable-chevron--open", details.includes(c.cid)]
)}></ChevronDown>
<div className="files-fileIcon">
<WebFileIcon type={c.manifest.mimetype} />
</div>
@ -120,12 +239,13 @@ export function Files() {
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>
<FolderButton
folders={folders.map(([folder, files]) => [
folder,
files.includes(c.cid),
])}
onFolderToggle={(folder) => onFolderToggle(c.cid, folder)}
/>
<ButtonIcon
variant="small"
@ -136,6 +256,14 @@ export function Files() {
</div>
</div>
</div>
{details.includes(c.cid) && (
<>
<div>CID : {c.cid}</div>
<div>Filename : {c.manifest.filename}</div>
<div>Protected: {c.manifest.protected}</div>
</>
)}
</div>
))
) : (
@ -148,7 +276,7 @@ export function Files() {
)}
</div>
<FileDetails onClose={onClose} details={details} expanded={expanded} />
{/* <FileDetails onClose={onClose} details={details} expanded={expanded} /> */}
</div>
);
}

View File

@ -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);
}

View File

@ -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 (
<div className="folderButton">
<Backdrop open={open} onClose={onClose}></Backdrop>
<ButtonIcon
variant="small"
onClick={onOpen}
Icon={() => <Folder />}></ButtonIcon>
<div className="folderButton-options" {...attr}>
{folders.map(([folder, isActive]) => (
<div
key={folder}
className="folderButton-option"
onClick={() => onFolderToggle(folder)}>
<div>{folder}</div>
<div>
{isActive && (
<SimpleText variant="primary">
<CheckCircle size={"1rem"}></CheckCircle>
</SimpleText>
)}
</div>
</div>
))}
</div>
</div>
);
}

View File

@ -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<string, string[]>(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<string[]>(folder, folders) || []
return set(folder, [...files, cid], folders)
},
async deleteFile(folder: string, cid: string) {
const files = await get<string[]>(folder, folders) || []
return set(folder, files.filter(item => item !== cid), folders)
},
}
};