mirror of
https://github.com/codex-storage/codex-marketplace-ui.git
synced 2025-02-23 21:28:26 +00:00
Merge branch 'feat/dashboard/folders' into feat/ui/integration
This commit is contained in:
commit
9ce4e55593
@ -1,6 +1,6 @@
|
|||||||
import test, { expect } from "@playwright/test";
|
import test, { expect } from "@playwright/test";
|
||||||
|
|
||||||
test('creates an availability', async ({ page }) => {
|
test('create an availability', async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
await page.goto('/dashboard');
|
||||||
await page.getByRole('link', { name: 'Sales' }).click();
|
await page.getByRole('link', { name: 'Sales' }).click();
|
||||||
await page.waitForTimeout(500);
|
await page.waitForTimeout(500);
|
||||||
|
20
e2e/folders.spec.ts
Normal file
20
e2e/folders.spec.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
import test, { expect } from "@playwright/test";
|
||||||
|
|
||||||
|
test('create a folder', async ({ page }) => {
|
||||||
|
await page.goto('/dashboard');
|
||||||
|
await page.locator('#folder').click();
|
||||||
|
await page.locator('#folder').fill('abc');
|
||||||
|
await expect(page.getByText('Enter the folder name')).toBeVisible();
|
||||||
|
await page.locator('#folder').fill('abc ');
|
||||||
|
await expect(page.getByText('9 alpha characters maximum')).toBeVisible();
|
||||||
|
await page.locator('#folder').fill('abc !');
|
||||||
|
await expect(page.getByText('9 alpha characters maximum')).toBeVisible();
|
||||||
|
await page.locator('#folder').fill('abc )');
|
||||||
|
await expect(page.getByText('9 alpha characters maximum')).toBeVisible();
|
||||||
|
await page.locator('#folder').fill('Favorites )');
|
||||||
|
await expect(page.getByText('This folder already exists')).toBeVisible();
|
||||||
|
await page.locator('#folder').fill('abc-_');
|
||||||
|
await expect(page.getByText('Enter the folder name')).toBeVisible();
|
||||||
|
await page.getByRole('button', { name: 'Folder' }).click();
|
||||||
|
await expect(page.locator('span').filter({ hasText: 'abc-_' })).toBeVisible();
|
||||||
|
})
|
@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
|
|||||||
const __filename = fileURLToPath(import.meta.url);
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = dirname(__filename);
|
const __dirname = dirname(__filename);
|
||||||
|
|
||||||
test('creates a storage request', async ({ page }) => {
|
test('create a storage request', async ({ page }) => {
|
||||||
await page.goto('/dashboard');
|
await page.goto('/dashboard');
|
||||||
await page.getByRole('link', { name: 'Purchases' }).click();
|
await page.getByRole('link', { name: 'Purchases' }).click();
|
||||||
await page.getByRole('button', { name: 'Storage Request' }).click();
|
await page.getByRole('button', { name: 'Storage Request' }).click();
|
||||||
|
@ -2,7 +2,7 @@ import {
|
|||||||
Stepper,
|
Stepper,
|
||||||
useStepperReducer,
|
useStepperReducer,
|
||||||
Button,
|
Button,
|
||||||
Sheets,
|
Modal,
|
||||||
} from "@codex-storage/marketplace-ui-components";
|
} from "@codex-storage/marketplace-ui-components";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { AvailabilityForm } from "./AvailabilityForm";
|
import { AvailabilityForm } from "./AvailabilityForm";
|
||||||
@ -140,7 +140,7 @@ export function AvailabilitySheetCreate({
|
|||||||
className={className}
|
className={className}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Sheets open={state.open} onClose={onClose}>
|
<Modal open={state.open} onClose={onClose}>
|
||||||
<Stepper
|
<Stepper
|
||||||
className="availabilityCreate"
|
className="availabilityCreate"
|
||||||
titles={steps.current}
|
titles={steps.current}
|
||||||
@ -159,7 +159,7 @@ export function AvailabilitySheetCreate({
|
|||||||
error={error}
|
error={error}
|
||||||
/>
|
/>
|
||||||
</Stepper>
|
</Stepper>
|
||||||
</Sheets>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
49
src/components/Files/FileActions.tsx
Normal file
49
src/components/Files/FileActions.tsx
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import { ButtonIcon, Cell } from "@codex-storage/marketplace-ui-components";
|
||||||
|
import { Download, ReceiptText } from "lucide-react";
|
||||||
|
import { ICON_SIZE } from "../../utils/constants";
|
||||||
|
import { FolderButton } from "./FolderButton";
|
||||||
|
import { CodexDataContent } from "@codex-storage/sdk-js";
|
||||||
|
import { CodexSdk } from "../../sdk/codex";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: CodexDataContent;
|
||||||
|
folders: [string, string[]][];
|
||||||
|
onFolderToggle: (cid: string, folder: string) => void;
|
||||||
|
onDetails: (cid: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileActions({
|
||||||
|
content,
|
||||||
|
folders,
|
||||||
|
onFolderToggle,
|
||||||
|
onDetails,
|
||||||
|
}: Props) {
|
||||||
|
const url = CodexSdk.url() + "/api/codex/v1/data/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Cell>
|
||||||
|
<div className="files-fileActions">
|
||||||
|
<ButtonIcon
|
||||||
|
variant="small"
|
||||||
|
animation="bounce"
|
||||||
|
onClick={() => window.open(url + content.cid, "_blank")}
|
||||||
|
Icon={(props) => (
|
||||||
|
<Download size={ICON_SIZE} {...props} />
|
||||||
|
)}></ButtonIcon>
|
||||||
|
|
||||||
|
<FolderButton
|
||||||
|
folders={folders.map(([folder, files]) => [
|
||||||
|
folder,
|
||||||
|
files.includes(content.cid),
|
||||||
|
])}
|
||||||
|
onFolderToggle={(folder) => onFolderToggle(content.cid, folder)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ButtonIcon
|
||||||
|
variant="small"
|
||||||
|
onClick={() => onDetails(content.cid)}
|
||||||
|
Icon={() => <ReceiptText size={ICON_SIZE} />}></ButtonIcon>
|
||||||
|
</div>
|
||||||
|
</Cell>
|
||||||
|
);
|
||||||
|
}
|
48
src/components/Files/FileCell.tsx
Normal file
48
src/components/Files/FileCell.tsx
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import {
|
||||||
|
ButtonIcon,
|
||||||
|
Cell,
|
||||||
|
Toast,
|
||||||
|
WebFileIcon,
|
||||||
|
} from "@codex-storage/marketplace-ui-components";
|
||||||
|
import { CodexDataContent } from "@codex-storage/sdk-js";
|
||||||
|
import { Copy } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
content: CodexDataContent;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileCell({ content }: Props) {
|
||||||
|
const [toast, setToast] = useState({ time: 0, message: "" });
|
||||||
|
|
||||||
|
const onCopy = (cid: string) => {
|
||||||
|
navigator.clipboard.writeText(cid);
|
||||||
|
setToast({ message: "CID copied to the clipboard.", time: Date.now() });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Cell>
|
||||||
|
<div className="files-cell-file">
|
||||||
|
<WebFileIcon type={content.manifest.mimetype} />
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<b>{content.manifest.filename}</b>
|
||||||
|
<div className="files-fileMeta">
|
||||||
|
<small className="files-fileMeta-cid">{content.cid}</small>
|
||||||
|
<ButtonIcon
|
||||||
|
variant="small"
|
||||||
|
onClick={() => onCopy(content.cid)}
|
||||||
|
animation="buzz"
|
||||||
|
Icon={(props) => (
|
||||||
|
<Copy size={"1rem"} {...props} />
|
||||||
|
)}></ButtonIcon>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Toast message={toast.message} time={toast.time} variant={"success"} />
|
||||||
|
</Cell>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
@ -10,22 +10,35 @@ import { Dates } from "../../utils/dates";
|
|||||||
import { CidCopyButton } from "./CidCopyButton";
|
import { CidCopyButton } from "./CidCopyButton";
|
||||||
import "./FileDetails.css";
|
import "./FileDetails.css";
|
||||||
import { DownloadIcon, X } from "lucide-react";
|
import { DownloadIcon, X } from "lucide-react";
|
||||||
|
import { CodexSdk } from "../../sdk/codex";
|
||||||
|
import { Files } from "../../utils/files";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { PurchaseStorage } from "../../utils/purchases-storage";
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
details: CodexDataContent | undefined;
|
details: CodexDataContent | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
expanded: boolean;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function FileDetails({ onClose, details, expanded }: Props) {
|
export function FileDetails({ onClose, details }: Props) {
|
||||||
const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/";
|
const [purchases, setPurchases] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
PurchaseStorage.entries().then((entries) =>
|
||||||
|
setPurchases(
|
||||||
|
entries.filter((e) => e[1] === details?.cid).reduce((acc) => acc + 1, 0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
}, [details?.cid]);
|
||||||
|
|
||||||
|
const url = CodexSdk.url() + "/api/codex/v1/data/";
|
||||||
|
|
||||||
const Icon = () => <X size={ICON_SIZE} onClick={onClose} />;
|
const Icon = () => <X size={ICON_SIZE} onClick={onClose} />;
|
||||||
|
|
||||||
const onDownload = () => window.open(url + details?.cid, "_target");
|
const onDownload = () => window.open(url + details?.cid, "_target");
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Sheets open={expanded} onClose={onClose}>
|
<Sheets open={!!details} onClose={onClose}>
|
||||||
<>
|
<>
|
||||||
{details && (
|
{details && (
|
||||||
<>
|
<>
|
||||||
@ -34,6 +47,19 @@ export function FileDetails({ onClose, details, expanded }: Props) {
|
|||||||
<ButtonIcon variant="small" Icon={Icon}></ButtonIcon>
|
<ButtonIcon variant="small" Icon={Icon}></ButtonIcon>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{Files.isImage(details.manifest.mimetype) && (
|
||||||
|
<div className="fileDetails-imageContainer">
|
||||||
|
<img
|
||||||
|
className="fileDetails-image"
|
||||||
|
src={
|
||||||
|
import.meta.env.VITE_CODEX_API_URL +
|
||||||
|
"/api/codex/v1/data/" +
|
||||||
|
details.cid
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="fileDetails-body">
|
<div className="fileDetails-body">
|
||||||
<div className="fileDetails-grid">
|
<div className="fileDetails-grid">
|
||||||
<p className="text-secondary">CID:</p>
|
<p className="text-secondary">CID:</p>
|
||||||
@ -75,6 +101,13 @@ export function FileDetails({ onClose, details, expanded }: Props) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="fileDetails-grid">
|
||||||
|
<p className="text-secondary">Used:</p>
|
||||||
|
<p className="fileDetails-gridColumn">
|
||||||
|
{purchases + " purchase(s)"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="fileDetails-actions">
|
<div className="fileDetails-actions">
|
||||||
<CidCopyButton cid={details.cid} />
|
<CidCopyButton cid={details.cid} />
|
||||||
|
|
||||||
|
36
src/components/Files/FileFilters.tsx
Normal file
36
src/components/Files/FileFilters.tsx
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { CodexDataContent } from "@codex-storage/sdk-js";
|
||||||
|
import { Files } from "../../utils/files";
|
||||||
|
import { classnames } from "../../utils/classnames";
|
||||||
|
import { Check } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
files: CodexDataContent[];
|
||||||
|
onFilterToggle: (filter: string) => void;
|
||||||
|
selected: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FilterFilters({ selected, files, onFilterToggle }: Props) {
|
||||||
|
const filters = Array.from(
|
||||||
|
new Set(
|
||||||
|
files
|
||||||
|
.filter((f) => f.manifest.mimetype !== "")
|
||||||
|
.map((file) =>
|
||||||
|
Files.isArchive(file.manifest.mimetype)
|
||||||
|
? "archive"
|
||||||
|
: Files.type(file.manifest.mimetype)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
return filters.map((type) => (
|
||||||
|
<span
|
||||||
|
key={type}
|
||||||
|
className={classnames(
|
||||||
|
["files-filter"],
|
||||||
|
["files-filter--active", !!filters.find((f) => selected.includes(f))]
|
||||||
|
)}
|
||||||
|
onClick={() => onFilterToggle(type)}>
|
||||||
|
<span>{type}</span> <Check size={"1rem"}></Check>
|
||||||
|
</span>
|
||||||
|
));
|
||||||
|
}
|
@ -6,6 +6,22 @@
|
|||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.files-cell-file {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-fileMeta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-fileMeta-cid {
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
.files-title {
|
.files-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
@ -40,15 +56,8 @@
|
|||||||
height: 2rem;
|
height: 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-fileData {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
flex-grow: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.files-fileActions {
|
.files-fileActions {
|
||||||
display: flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid var(--codex-border-color);
|
border: 1px solid var(--codex-border-color);
|
||||||
border-radius: var(--codex-border-radius);
|
border-radius: var(--codex-border-radius);
|
||||||
@ -69,4 +78,58 @@
|
|||||||
|
|
||||||
.files-header {
|
.files-header {
|
||||||
margin-bottom: 0.75rem;
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-filters {
|
||||||
|
display: flex;
|
||||||
|
margin: 1rem 0;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-filter {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
background-color: var(--codex-background-light);
|
||||||
|
border-radius: var(--codex-border-radius);
|
||||||
|
border: 1px solid var(--codex-border-color);
|
||||||
|
opacity: 0.5;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
transition: opacity 0.35s;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.files-filter--active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileDetails-imageContainer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fileDetails-image {
|
||||||
|
max-width: 200px;
|
||||||
|
max-height: 200px;
|
||||||
|
margin: auto;
|
||||||
}
|
}
|
||||||
|
@ -1,154 +1,284 @@
|
|||||||
import { Download, FilesIcon, ReceiptText, Star } from "lucide-react";
|
import { FilesIcon, Folder, Plus, X } from "lucide-react";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { ChangeEvent, useEffect, useState } from "react";
|
||||||
import { PrettyBytes } from "../../utils/bytes";
|
import { PrettyBytes } from "../../utils/bytes";
|
||||||
import { Dates } from "../../utils/dates";
|
import { Dates } from "../../utils/dates";
|
||||||
import "./Files.css";
|
import "./Files.css";
|
||||||
import { ICON_SIZE, SIDE_DURATION } from "../../utils/constants";
|
|
||||||
import {
|
import {
|
||||||
ButtonIcon,
|
|
||||||
EmptyPlaceholder,
|
|
||||||
WebFileIcon,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
|
Input,
|
||||||
|
Button,
|
||||||
|
TabProps,
|
||||||
|
Table,
|
||||||
|
Row,
|
||||||
|
Cell,
|
||||||
|
TabSortState,
|
||||||
} from "@codex-storage/marketplace-ui-components";
|
} from "@codex-storage/marketplace-ui-components";
|
||||||
import { FileDetails } from "./FileDetails.tsx";
|
import { FileDetails } from "./FileDetails.tsx";
|
||||||
import { FavoriteStorage } from "../../utils/favorite-storage.tsx";
|
|
||||||
import { useData } from "../../hooks/useData.tsx";
|
import { useData } from "../../hooks/useData.tsx";
|
||||||
|
import { WebStorage } from "../../utils/web-storage.ts";
|
||||||
|
import { classnames } from "../../utils/classnames.ts";
|
||||||
|
import { CodexDataContent } from "@codex-storage/sdk-js";
|
||||||
|
import { Files as F } from "../../utils/files.ts";
|
||||||
|
import { FilterFilters } from "./FileFilters.tsx";
|
||||||
|
import { FileCell } from "./FileCell.tsx";
|
||||||
|
import { FileActions } from "./FileActions.tsx";
|
||||||
|
|
||||||
type StarIconProps = {
|
type SortFn = (a: CodexDataContent, b: CodexDataContent) => number;
|
||||||
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() {
|
export function Files() {
|
||||||
const files = useData();
|
const files = useData();
|
||||||
const cid = useRef<string | null>("");
|
|
||||||
const [expanded, setExpanded] = useState(false);
|
|
||||||
const [favorites, setFavorites] = useState<string[]>([]);
|
|
||||||
const [index, setIndex] = useState(0);
|
const [index, setIndex] = useState(0);
|
||||||
|
const [folder, setFolder] = useState("");
|
||||||
|
const [folders, setFolders] = useState<[string, string[]][]>([]);
|
||||||
|
const [error, setError] = useState("");
|
||||||
|
const [details, setDetails] = useState<CodexDataContent | null>(null);
|
||||||
|
const [sortFn, setSortFn] = useState<SortFn | null>(null);
|
||||||
|
const [selectedFilters, setSelectedFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
FavoriteStorage.list().then((cids) => setFavorites(cids));
|
WebStorage.folders.list().then((items) => setFolders(items));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const onClose = () => {
|
const onClose = () => setDetails(null);
|
||||||
setExpanded(false);
|
|
||||||
|
|
||||||
setTimeout(() => {
|
const onTabChange = async (i: number) => setIndex(i);
|
||||||
cid.current = "";
|
|
||||||
}, SIDE_DURATION);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onTabChange = (i: number) => setIndex(i);
|
const onDetails = (cid: string) => {
|
||||||
|
const d = files.find((file) => file.cid === cid);
|
||||||
|
|
||||||
const onDetails = (id: string) => {
|
if (d) {
|
||||||
cid.current = id;
|
setDetails(d);
|
||||||
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 = [];
|
const onFolderChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const val = e.currentTarget.value;
|
||||||
|
setFolder(val);
|
||||||
|
setError("");
|
||||||
|
|
||||||
if (index === 1) {
|
if (!val) {
|
||||||
items.push(...files.filter((f) => favorites.includes(f.cid)));
|
return;
|
||||||
} else {
|
}
|
||||||
items.push(...files);
|
|
||||||
}
|
|
||||||
|
|
||||||
const details = items.find((c) => c.cid === cid.current);
|
if (e.currentTarget.checkValidity()) {
|
||||||
|
if (folders.length >= 5) {
|
||||||
|
setError("5 folders limit reached");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const url = import.meta.env.VITE_CODEX_API_URL + "/api/codex/v1/data/";
|
if (folders.find(([folder]) => folder === val)) {
|
||||||
|
setError("This folder already exists");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setError("9 alpha characters maximum");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFolderCreate = () => {
|
||||||
|
WebStorage.folders.create(folder);
|
||||||
|
|
||||||
|
setFolder("");
|
||||||
|
setFolders([...folders, [folder, []]]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFolderDelete = (val: string) => {
|
||||||
|
WebStorage.folders.delete(val);
|
||||||
|
|
||||||
|
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:
|
||||||
|
folder === "Favorites"
|
||||||
|
? undefined
|
||||||
|
: () => (
|
||||||
|
<X
|
||||||
|
size={"1rem"}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
|
||||||
|
onFolderDelete(folder);
|
||||||
|
}}></X>
|
||||||
|
),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const onSortByFilename = (state: TabSortState) => {
|
||||||
|
if (!state) {
|
||||||
|
setSortFn(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortFn(
|
||||||
|
() => (a: CodexDataContent, b: CodexDataContent) =>
|
||||||
|
state === "desc"
|
||||||
|
? b.manifest.filename
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.localeCompare(a.manifest.filename.toLocaleLowerCase())
|
||||||
|
: a.manifest.filename
|
||||||
|
.toLocaleLowerCase()
|
||||||
|
.localeCompare(b.manifest.filename.toLocaleLowerCase())
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSortBySize = (state: TabSortState) => {
|
||||||
|
if (!state) {
|
||||||
|
setSortFn(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortFn(
|
||||||
|
() => (a: CodexDataContent, b: CodexDataContent) =>
|
||||||
|
state === "desc"
|
||||||
|
? b.manifest.datasetSize - a.manifest.datasetSize
|
||||||
|
: a.manifest.datasetSize - b.manifest.datasetSize
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onSortByDate = (state: TabSortState) => {
|
||||||
|
if (!state) {
|
||||||
|
setSortFn(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSortFn(
|
||||||
|
() => (a: CodexDataContent, b: CodexDataContent) =>
|
||||||
|
state === "desc"
|
||||||
|
? new Date(b.manifest.uploadedAt).getTime() -
|
||||||
|
new Date(a.manifest.uploadedAt).getTime()
|
||||||
|
: new Date(a.manifest.uploadedAt).getTime() -
|
||||||
|
new Date(b.manifest.uploadedAt).getTime()
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const onToggleFilter = (filter: string) =>
|
||||||
|
selectedFilters.includes(filter)
|
||||||
|
? setSelectedFilters(selectedFilters.filter((f) => f !== filter))
|
||||||
|
: setSelectedFilters([...selectedFilters, filter]);
|
||||||
|
|
||||||
|
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 headers = [
|
||||||
|
["file", onSortByFilename],
|
||||||
|
["size", onSortBySize],
|
||||||
|
["date", onSortByDate],
|
||||||
|
["actions"],
|
||||||
|
] satisfies [string, ((state: TabSortState) => void)?][];
|
||||||
|
|
||||||
|
const filtered = items.filter(
|
||||||
|
(item) =>
|
||||||
|
selectedFilters.length === 0 ||
|
||||||
|
selectedFilters.includes(F.type(item.manifest.mimetype)) ||
|
||||||
|
(selectedFilters.includes("archive") &&
|
||||||
|
F.isArchive(item.manifest.mimetype))
|
||||||
|
);
|
||||||
|
|
||||||
|
const sorted = sortFn ? [...filtered].sort(sortFn) : filtered;
|
||||||
|
const rows =
|
||||||
|
sorted.map((c) => (
|
||||||
|
<Row
|
||||||
|
cells={[
|
||||||
|
<FileCell content={c}></FileCell>,
|
||||||
|
<Cell>{PrettyBytes(c.manifest.datasetSize)}</Cell>,
|
||||||
|
<Cell>{Dates.format(c.manifest.uploadedAt).toString()}</Cell>,
|
||||||
|
<FileActions
|
||||||
|
content={c}
|
||||||
|
folders={folders}
|
||||||
|
onDetails={onDetails}
|
||||||
|
onFolderToggle={onFolderToggle}></FileActions>,
|
||||||
|
]}></Row>
|
||||||
|
)) || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="files">
|
<div className="files">
|
||||||
<div className="files-header">
|
<div className="files-header">
|
||||||
<div className="files-title">Files</div>
|
<div className="files-headerLeft">
|
||||||
<Tabs
|
<div className="files-title">Files</div>
|
||||||
onTabChange={onTabChange}
|
</div>
|
||||||
tabIndex={index}
|
<div className="files-headerRight">
|
||||||
tabs={[
|
<div>
|
||||||
{
|
<Input
|
||||||
label: "All files",
|
id="folder"
|
||||||
Icon: () => <FilesIcon size={"1rem"}></FilesIcon>,
|
inputClassName={classnames(["files-folders"])}
|
||||||
},
|
isInvalid={folder !== "" && !!error}
|
||||||
{
|
value={folder}
|
||||||
label: "Favorites",
|
required={true}
|
||||||
Icon: () => <Star size={"1rem"}></Star>,
|
pattern="[A-Za-z0-9_\-]*"
|
||||||
},
|
maxLength={9}
|
||||||
]}></Tabs>
|
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-filters">
|
||||||
|
<FilterFilters
|
||||||
|
files={files}
|
||||||
|
onFilterToggle={onToggleFilter}
|
||||||
|
selected={selectedFilters}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="files-fileBody">
|
<div className="files-fileBody">
|
||||||
{items.length ? (
|
<Table headers={headers} rows={rows} defaultSortIndex={2} />
|
||||||
items.map((c) => (
|
|
||||||
<div className="files-file" key={c.cid}>
|
|
||||||
<div className="files-fileContent">
|
|
||||||
<div className="files-fileIcon">
|
|
||||||
<WebFileIcon type={c.manifest.mimetype} />
|
|
||||||
</div>
|
|
||||||
<div className="files-fileData">
|
|
||||||
<div>
|
|
||||||
<b>{c.manifest.filename}</b>
|
|
||||||
<div>
|
|
||||||
<small className="files-fileMeta">
|
|
||||||
{PrettyBytes(c.manifest.datasetSize)} -{" "}
|
|
||||||
{Dates.format(c.manifest.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>
|
</div>
|
||||||
|
|
||||||
<FileDetails onClose={onClose} details={details} expanded={expanded} />
|
<FileDetails onClose={onClose} details={details} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
38
src/components/Files/FolderButton.css
Normal file
38
src/components/Files/FolderButton.css
Normal 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);
|
||||||
|
}
|
62
src/components/Files/FolderButton.tsx
Normal file
62
src/components/Files/FolderButton.tsx
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
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 });
|
||||||
|
|
||||||
|
const doesFolderContainFile = folders.reduce(
|
||||||
|
(prev, [, isActive]) => isActive || prev,
|
||||||
|
false
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="folderButton">
|
||||||
|
<Backdrop open={open} onClose={onClose}></Backdrop>
|
||||||
|
|
||||||
|
<ButtonIcon
|
||||||
|
variant="small"
|
||||||
|
onClick={onOpen}
|
||||||
|
Icon={() => (
|
||||||
|
<Folder
|
||||||
|
fill={doesFolderContainFile ? "var(--codex-color-primary)" : ""}
|
||||||
|
/>
|
||||||
|
)}></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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,7 +1,25 @@
|
|||||||
|
const archiveMimetypes = [
|
||||||
|
"application/zip",
|
||||||
|
"application/x-rar-compressed",
|
||||||
|
"application/x-tar",
|
||||||
|
"application/gzip",
|
||||||
|
"application/x-7z-compressed",
|
||||||
|
"application/gzip", // for .tar.gz
|
||||||
|
"application/x-bzip2",
|
||||||
|
"application/x-xz",
|
||||||
|
];
|
||||||
|
|
||||||
export const Files = {
|
export const Files = {
|
||||||
isImage(type: string) {
|
isImage(type: string) {
|
||||||
return type.startsWith("image");
|
return type.startsWith("image");
|
||||||
},
|
},
|
||||||
|
type(mimetype: string) {
|
||||||
|
const [type] = mimetype.split("/")
|
||||||
|
return type
|
||||||
|
},
|
||||||
|
isArchive(mimetype: string) {
|
||||||
|
return archiveMimetypes.includes(mimetype)
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CodexFileMetadata = {
|
export type CodexFileMetadata = {
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { createStore, get, set } from "idb-keyval";
|
import { createStore, entries, get, set } from "idb-keyval";
|
||||||
|
|
||||||
const store = createStore("purchases", "purchases");
|
const store = createStore("purchases", "purchases");
|
||||||
const storeDates = createStore("purchases", "dates");
|
const storeDates = createStore("purchases", "dates");
|
||||||
@ -11,6 +11,10 @@ export const PurchaseStorage = {
|
|||||||
async set(key: string, cid: string) {
|
async set(key: string, cid: string) {
|
||||||
return set(key, cid, store);
|
return set(key, cid, store);
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async entries() {
|
||||||
|
return entries(store);
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const PurchaseDatesStorage = {
|
export const PurchaseDatesStorage = {
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { del, get, set } from "idb-keyval";
|
import { createStore, del, entries, get, set } from "idb-keyval";
|
||||||
|
|
||||||
|
const folders = createStore("folders", "folders");
|
||||||
|
|
||||||
export const WebStorage = {
|
export const WebStorage = {
|
||||||
set(key: string, value: unknown) {
|
set(key: string, value: unknown) {
|
||||||
@ -12,4 +14,44 @@ export const WebStorage = {
|
|||||||
delete(key: string) {
|
delete(key: string) {
|
||||||
return del(key);
|
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)
|
||||||
|
|
||||||
|
},
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user