Merge branch 'feat/dashboard/folders' into feat/ui/integration

This commit is contained in:
Arnaud 2024-10-31 10:42:33 +01:00
commit 9ce4e55593
No known key found for this signature in database
GPG Key ID: 69D6CE281FCAE663
15 changed files with 679 additions and 136 deletions

View File

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

View File

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

View File

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

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

View 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>
</>
);
}

View File

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

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

View File

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

View File

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

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

View File

@ -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 = {

View File

@ -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 = {

View File

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