From 97b1ed19074b10c1351b0bccad0d107287fb7a97 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Tue, 14 Feb 2023 18:12:58 +0800 Subject: [PATCH 01/10] feat: implement reverse paging Use the new `pre` caveat and `startCursor` and `endCursor` from https://github.com/web3-storage/w3infra/pull/139 to implement reverse paging. One unsatisfying issue with this is that paging backwards reverses the order of items in the uploads list, and that edge conditions behave fairly confusingly. Not entirely sure what to do about this yet, so pushing up for some feedback. This currently only works with a bunch of custom service config and `file:/` dependencies that I am not pushing up for now. --- packages/react-uploads-list/package.json | 2 +- .../react-uploads-list/src/UploadsList.tsx | 24 ++++++++++++++++++- .../src/providers/UploadsList.tsx | 15 ++++++++---- packages/react/src/UploadsList.tsx | 9 ++++--- packages/uploads-list-core/src/index.ts | 4 ++++ 5 files changed, 44 insertions(+), 10 deletions(-) diff --git a/packages/react-uploads-list/package.json b/packages/react-uploads-list/package.json index e39c4355..4e48e313 100644 --- a/packages/react-uploads-list/package.json +++ b/packages/react-uploads-list/package.json @@ -31,7 +31,7 @@ "dependencies": { "@w3ui/react-keyring": "workspace:^", "@w3ui/uploads-list-core": "workspace:^", - "@web3-storage/capabilities": "^2.2.0", + "@web3-storage/capabilities": "^2.3.0", "ariakit-react-utils": "0.17.0-next.27" }, "peerDependencies": { diff --git a/packages/react-uploads-list/src/UploadsList.tsx b/packages/react-uploads-list/src/UploadsList.tsx index ef388a3d..c79cb9a4 100644 --- a/packages/react-uploads-list/src/UploadsList.tsx +++ b/packages/react-uploads-list/src/UploadsList.tsx @@ -27,6 +27,10 @@ export const UploadsListComponentContext = createContext { }, /** * A function that will load the next page of results. */ @@ -75,6 +79,24 @@ export const UploadsListRoot = (props: UploadsListRootProps): JSX.Element => { ) } +export type PrevButtonOptions = Options +export type PrevButtonProps = Props> + +/** + * Button that loads the next page of results. + * + * A 'button' designed to work with `UploadsList`. Any passed props will + * be passed along to the `button` component. + */ +export const PrevButton: Component = createComponent((props: any) => { + const [, { prev }] = useContext(UploadsListComponentContext) + const onClick = useCallback((e: React.MouseEvent) => { + e.preventDefault() + void prev() + }, [prev]) + return createElement('button', { ...props, onClick }) +}) + export type NextButtonOptions = Options export type NextButtonProps = Props> @@ -118,4 +140,4 @@ export function useUploadsListComponent (): UploadsListComponentContextValue { return useContext(UploadsListComponentContext) } -export const UploadsList = Object.assign(UploadsListRoot, { NextButton, ReloadButton }) +export const UploadsList = Object.assign(UploadsListRoot, { PrevButton, NextButton, ReloadButton }) diff --git a/packages/react-uploads-list/src/providers/UploadsList.tsx b/packages/react-uploads-list/src/providers/UploadsList.tsx index f19f4528..c7303150 100644 --- a/packages/react-uploads-list/src/providers/UploadsList.tsx +++ b/packages/react-uploads-list/src/providers/UploadsList.tsx @@ -33,13 +33,14 @@ export interface UploadsListProviderProps extends ServiceConfig { */ export function UploadsListProvider ({ size, servicePrincipal, connection, children }: UploadsListProviderProps): JSX.Element { const [{ space, agent }, { getProofs }] = useKeyring() - const [cursor, setCursor] = useState() + const [startCursor, setStartCursor] = useState() + const [endCursor, setEndCursor] = useState() const [loading, setLoading] = useState(false) const [error, setError] = useState() const [data, setData] = useState() const [controller, setController] = useState(new AbortController()) - const loadPage = async (cursor?: string): Promise => { + const loadPage = async (cursor?: string, pre?: boolean): Promise => { if (space == null) return if (agent == null) return @@ -58,10 +59,12 @@ export function UploadsListProvider ({ size, servicePrincipal, connection, child const page = await list(conf, { cursor, size, + pre, signal: newController.signal, connection }) - setCursor(page.cursor) + setStartCursor(page.startCursor) + setEndCursor(page.endCursor) setData(page.results) } catch (err: any) { if (err.name !== 'AbortError') { @@ -75,9 +78,11 @@ export function UploadsListProvider ({ size, servicePrincipal, connection, child const state = { data, loading, error } const actions = { - next: async (): Promise => { await loadPage(cursor) }, + next: async (): Promise => { await loadPage(endCursor) }, + prev: async (): Promise => { await loadPage(startCursor, true) }, reload: async (): Promise => { - setCursor(undefined) + setStartCursor(undefined) + setEndCursor(undefined) await loadPage() } } diff --git a/packages/react/src/UploadsList.tsx b/packages/react/src/UploadsList.tsx index 5708f1cf..7833f88c 100644 --- a/packages/react/src/UploadsList.tsx +++ b/packages/react/src/UploadsList.tsx @@ -40,12 +40,15 @@ function Uploads ({ uploads }: { uploads?: UploadListResult[] }): JSX.Element { ) diff --git a/packages/uploads-list-core/src/index.ts b/packages/uploads-list-core/src/index.ts index e1aab539..9e86d037 100644 --- a/packages/uploads-list-core/src/index.ts +++ b/packages/uploads-list-core/src/index.ts @@ -26,6 +26,10 @@ export interface UploadsListContextState { } export interface UploadsListContextActions { + /** + * Load the next page of results. + */ + prev: () => Promise /** * Load the next page of results. */ From 8db29b3bdbfedc22ee4a852c55658f80a89ed876 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 16 Feb 2023 23:07:53 +0800 Subject: [PATCH 02/10] fix: update to latest @web3-storage/upload-client this brings in the necessary changes for reverse paging --- packages/uploads-list-core/package.json | 2 +- pnpm-lock.yaml | 61 ++++++++++++++++--------- 2 files changed, 41 insertions(+), 22 deletions(-) diff --git a/packages/uploads-list-core/package.json b/packages/uploads-list-core/package.json index 9ac9646b..a0da9e6c 100644 --- a/packages/uploads-list-core/package.json +++ b/packages/uploads-list-core/package.json @@ -28,6 +28,6 @@ "homepage": "https://github.com/web3-storage/w3ui/tree/main/packages/uploads-list-core", "dependencies": { "@ucanto/interface": "^4.2.3", - "@web3-storage/upload-client": "^5.4.0" + "@web3-storage/upload-client": "^5.6.0" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 80a42d25..8f1ece1d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -526,12 +526,12 @@ importers: '@testing-library/user-event': ^14.4.3 '@w3ui/react-keyring': workspace:^ '@w3ui/uploads-list-core': workspace:^ - '@web3-storage/capabilities': ^2.2.0 + '@web3-storage/capabilities': ^2.3.0 ariakit-react-utils: 0.17.0-next.27 dependencies: '@w3ui/react-keyring': link:../react-keyring '@w3ui/uploads-list-core': link:../uploads-list-core - '@web3-storage/capabilities': 2.2.0 + '@web3-storage/capabilities': 2.3.0 ariakit-react-utils: 0.17.0-next.27 devDependencies: '@testing-library/react': 13.4.0 @@ -587,10 +587,10 @@ importers: packages/uploads-list-core: specifiers: '@ucanto/interface': ^4.2.3 - '@web3-storage/upload-client': ^5.4.0 + '@web3-storage/upload-client': ^5.6.0 dependencies: '@ucanto/interface': 4.2.3 - '@web3-storage/upload-client': 5.4.0 + '@web3-storage/upload-client': 5.6.0 packages/vitest-environment-w3ui: specifiers: @@ -3945,7 +3945,7 @@ packages: dependencies: '@storybook/client-logger': 7.0.0-beta.29 '@storybook/core-events': 7.0.0-beta.29 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/manager-api': 7.0.0-beta.29_biqbaboplfbrettd7655fr4n2y '@storybook/preview-api': 7.0.0-beta.29 @@ -4114,7 +4114,7 @@ packages: '@storybook/client-logger': 7.0.0-beta.29 '@storybook/components': 7.0.0-beta.29_biqbaboplfbrettd7655fr4n2y '@storybook/core-events': 7.0.0-beta.29 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/docs-tools': 7.0.0-beta.29 '@storybook/global': 5.0.0 '@storybook/manager-api': 7.0.0-beta.29_biqbaboplfbrettd7655fr4n2y @@ -4287,7 +4287,7 @@ packages: '@babel/core': 7.20.5 '@babel/preset-env': 7.20.2_@babel+core@7.20.5 '@babel/types': 7.20.7 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/csf-tools': 7.0.0-beta.29 '@storybook/node-logger': 7.0.0-beta.29 '@storybook/types': 7.0.0-beta.29 @@ -4327,7 +4327,7 @@ packages: react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 dependencies: '@storybook/client-logger': 7.0.0-beta.29 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/theming': 7.0.0-beta.29_biqbaboplfbrettd7655fr4n2y '@storybook/types': 7.0.0-beta.29 @@ -4396,7 +4396,7 @@ packages: '@storybook/builder-manager': 7.0.0-beta.29 '@storybook/core-common': 7.0.0-beta.29 '@storybook/core-events': 7.0.0-beta.29 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/csf-tools': 7.0.0-beta.29 '@storybook/docs-mdx': 0.0.1-next.6 '@storybook/global': 5.0.0 @@ -4453,7 +4453,7 @@ packages: resolution: {integrity: sha512-YPVCGBJAJ7Nr7Ldav1I2LW2R3Yv7joneHFj3hqQ248ThNESbsRXTukcaUcCsXvjXFZpWPUzf3LnGvpWt7bciQg==} dependencies: '@babel/types': 7.20.7 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/types': 7.0.0-beta.29 fs-extra: 9.1.0 recast: 0.23.1 @@ -4468,11 +4468,9 @@ packages: lodash: 4.17.21 dev: true - /@storybook/csf/0.0.2-next.8: - resolution: {integrity: sha512-3T6rflW7D9q1iXOR+bidwoNbd9rVUTyjYH/sqsnYjbXhb/aOXsQzGKwNeq9QqZIFVpKfg5BoOF5i7DCMtoGknQ==} + /@storybook/csf/0.0.2-next.10: + resolution: {integrity: sha512-m2PFgBP/xRIF85VrDhvesn9ktaD2pN3VUjvMqkAL/cINp/3qXsCyI81uw7N5VEOkQAbWrY2FcydnvEPDEdE8fA==} dependencies: - expect-type: 0.14.2 - lodash: 4.17.21 type-fest: 2.19.0 dev: true @@ -4533,7 +4531,7 @@ packages: '@storybook/channels': 7.0.0-beta.29 '@storybook/client-logger': 7.0.0-beta.29 '@storybook/core-events': 7.0.0-beta.29 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/router': 7.0.0-beta.29_biqbaboplfbrettd7655fr4n2y '@storybook/theming': 7.0.0-beta.29_biqbaboplfbrettd7655fr4n2y @@ -4579,7 +4577,7 @@ packages: '@storybook/channels': 7.0.0-beta.29 '@storybook/client-logger': 7.0.0-beta.29 '@storybook/core-events': 7.0.0-beta.29 - '@storybook/csf': 0.0.2-next.8 + '@storybook/csf': 0.0.2-next.10 '@storybook/global': 5.0.0 '@storybook/types': 7.0.0-beta.29 '@types/qs': 6.9.7 @@ -5486,6 +5484,16 @@ packages: '@ucanto/transport': 4.2.3 '@ucanto/validator': 4.2.3 + /@web3-storage/capabilities/2.3.0: + resolution: {integrity: sha512-+vg61eqK1eqQ+QD1hvChDDx6CXLGFnUsEA+W+g9yagCpq+H9yAqROncEEt+oluIGvAqNbFMrUb+bWRWxk0Tmuw==} + dependencies: + '@ucanto/core': 4.2.3 + '@ucanto/interface': 4.2.3 + '@ucanto/principal': 4.2.3 + '@ucanto/transport': 4.2.3 + '@ucanto/validator': 4.2.3 + dev: false + /@web3-storage/upload-client/5.4.0: resolution: {integrity: sha512-OMpP2UB3OEukiJvCdbBuK+HcdHyfDBs6+WWxeiVKDjP8YZXrNjBlpWQ7x+spFLxZLERsy8hDJ1i+L2K+enp4gw==} dependencies: @@ -5495,7 +5503,22 @@ packages: '@ucanto/client': 4.2.3 '@ucanto/interface': 4.2.3 '@ucanto/transport': 4.2.3 - '@web3-storage/capabilities': 2.2.0 + '@web3-storage/capabilities': 2.3.0 + multiformats: 11.0.1 + p-queue: 7.3.0 + p-retry: 5.1.2 + dev: false + + /@web3-storage/upload-client/5.6.0: + resolution: {integrity: sha512-Z/qavtj87+3ybM1zrT05j/relhN2WgN6evLksQKtjQm7/h5jbZgqA/u5VqFdNhUJRXcZEYvrvHiboVEYz1Bb8w==} + dependencies: + '@ipld/car': 5.1.0 + '@ipld/dag-ucan': 3.2.0 + '@ipld/unixfs': 2.0.1 + '@ucanto/client': 4.2.3 + '@ucanto/interface': 4.2.3 + '@ucanto/transport': 4.2.3 + '@web3-storage/capabilities': 2.3.0 multiformats: 11.0.1 p-queue: 7.3.0 p-retry: 5.1.2 @@ -7864,10 +7887,6 @@ packages: - supports-color dev: true - /expect-type/0.14.2: - resolution: {integrity: sha512-ed3+tr5ujbIYXZ8Pl/VgIphwJQ0q5tBLGGdn7Zvwt1WyPBRX83xjT5pT77P/GkuQbctx0K2ZNSSan7eruJqTCQ==} - dev: true - /expect/29.3.1: resolution: {integrity: sha512-gGb1yTgU30Q0O/tQq+z30KBWv24ApkMgFUpvKBkyLUBL68Wv8dHdJxTBZFl/iT8K/bqDHvUYRH6IIN3rToopPA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} From 71082f49690f61a62b5dffa92d393f022fb553af Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Thu, 16 Feb 2023 23:16:57 +0800 Subject: [PATCH 03/10] fix: a couple more issues from the merge --- packages/react-uploads-list/src/UploadsList.tsx | 6 +++--- packages/react-uploads-list/src/providers/UploadsList.tsx | 1 + 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/react-uploads-list/src/UploadsList.tsx b/packages/react-uploads-list/src/UploadsList.tsx index 606d2b8d..c48decc8 100644 --- a/packages/react-uploads-list/src/UploadsList.tsx +++ b/packages/react-uploads-list/src/UploadsList.tsx @@ -41,15 +41,15 @@ export const UploadsListComponentContext = createContext { }, + prev: async () => {}, /** * A function that will load the next page of results. */ - next: async () => { }, + next: async () => {}, /** * A function that will reload the uploads list. */ - reload: async () => { } + reload: async () => {} } ]) diff --git a/packages/react-uploads-list/src/providers/UploadsList.tsx b/packages/react-uploads-list/src/providers/UploadsList.tsx index 7679071a..be70d4b5 100644 --- a/packages/react-uploads-list/src/providers/UploadsList.tsx +++ b/packages/react-uploads-list/src/providers/UploadsList.tsx @@ -19,6 +19,7 @@ export const uploadsListContextDefaultValue: UploadsListContextValue = [ loading: false }, { + prev: async () => {}, next: async () => {}, reload: async () => {} } From 7e93971539defd248b141d96f2a75cebefa6aa3c Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Fri, 17 Feb 2023 12:50:53 +0800 Subject: [PATCH 04/10] feat: paging improvements - use icons instead of word buttons - make edge cases seem less weird by not rendering 0 sized pages --- .../react-uploads-list/src/UploadsList.tsx | 7 ++-- .../src/providers/UploadsList.tsx | 11 ++++-- packages/react/src/UploadsList.tsx | 36 +++++++++++-------- 3 files changed, 31 insertions(+), 23 deletions(-) diff --git a/packages/react-uploads-list/src/UploadsList.tsx b/packages/react-uploads-list/src/UploadsList.tsx index c48decc8..9a1afcd6 100644 --- a/packages/react-uploads-list/src/UploadsList.tsx +++ b/packages/react-uploads-list/src/UploadsList.tsx @@ -116,8 +116,7 @@ export const PrevButton: Component = createComponent((props: an }) export type NextButtonOptions = Options -export type NextButtonProps = - Props> +export type NextButtonProps = Props> /** * Button that loads the next page of results. @@ -140,9 +139,7 @@ export const NextButton: Component = createComponent( ) export type ReloadButtonOptions = Options -export type ReloadButtonProps = Props< -ReloadButtonOptions -> +export type ReloadButtonProps = Props> /** * Button that reloads an `UploadsList`. diff --git a/packages/react-uploads-list/src/providers/UploadsList.tsx b/packages/react-uploads-list/src/providers/UploadsList.tsx index be70d4b5..78f0d925 100644 --- a/packages/react-uploads-list/src/providers/UploadsList.tsx +++ b/packages/react-uploads-list/src/providers/UploadsList.tsx @@ -77,9 +77,11 @@ export function UploadsListProvider ({ signal: newController.signal, connection }) - setStartCursor(page.startCursor) - setEndCursor(page.endCursor) - setData(page.results) + if (page.size > 0) { + setStartCursor(page.startCursor) + setEndCursor(page.endCursor) + setData(page.results) + } } catch (error_: any) { if (error_.name !== 'AbortError') { /* eslint-disable no-console */ @@ -105,6 +107,9 @@ export function UploadsListProvider ({ // we should reload the page any time the space or agent change useEffect(() => { + setStartCursor(undefined) + setEndCursor(undefined) + setData([]) void loadPage() }, [space, agent]) diff --git a/packages/react/src/UploadsList.tsx b/packages/react/src/UploadsList.tsx index bd34c75a..84801760 100644 --- a/packages/react/src/UploadsList.tsx +++ b/packages/react/src/UploadsList.tsx @@ -1,19 +1,25 @@ -import type { UploadListResult } from '@w3ui/uploads-list-core' import React from 'react' +import { ChevronLeftIcon, ChevronRightIcon, ArrowPathIcon } from '@heroicons/react/20/solid' +import type { UploadListResult } from '@w3ui/uploads-list-core' import { UploadsList as UploadsListCore } from '@w3ui/react-uploads-list' -function Uploads ({ uploads }: { uploads?: UploadListResult[] }): JSX.Element { +interface UploadsProps { + uploads?: UploadListResult[] + loading?: boolean +} + +function Uploads ({ uploads, loading }: UploadsProps): JSX.Element { return uploads === undefined || uploads.length === 0 ? ( <>
No uploads
- - ) + ) } export const UploadsList = (): JSX.Element => { @@ -62,7 +62,7 @@ export const UploadsList = (): JSX.Element => { {(props) => (
- +
)}
From 1c2f22ce82ce514a33e69a66023a8f7bb83a7c63 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Mon, 20 Feb 2023 13:57:35 +0800 Subject: [PATCH 06/10] chore: eject @w3ui/react we don't have the bandwidth to support @w3ui/react anymore, so start migrating w3console to use the "headless" components directly this takes care of the markup, the next step is to get rid of custom CSS and move entirely over to idiomatic TailwindCSS styling --- examples/react/w3console/package.json | 4 +- examples/react/w3console/src/app.tsx | 35 ++-- .../src/components/Authenticator.tsx | 90 +++++++++ .../w3console/src/components/SpaceCreator.tsx | 104 ++++++++++ .../w3console/src/components/SpaceFinder.tsx | 114 +++++++++++ .../w3console/src/components/Uploader.tsx | 184 ++++++++++++++++++ .../w3console/src/components/UploadsList.tsx | 70 +++++++ .../react/w3console/src/components/W3API.tsx | 61 ++++++ .../w3console/src/components/W3Upload.tsx | 16 ++ pnpm-lock.yaml | 8 +- 10 files changed, 664 insertions(+), 22 deletions(-) create mode 100644 examples/react/w3console/src/components/Authenticator.tsx create mode 100644 examples/react/w3console/src/components/SpaceCreator.tsx create mode 100644 examples/react/w3console/src/components/SpaceFinder.tsx create mode 100644 examples/react/w3console/src/components/Uploader.tsx create mode 100644 examples/react/w3console/src/components/UploadsList.tsx create mode 100644 examples/react/w3console/src/components/W3API.tsx create mode 100644 examples/react/w3console/src/components/W3Upload.tsx diff --git a/examples/react/w3console/package.json b/examples/react/w3console/package.json index d5c94ae0..6212989a 100644 --- a/examples/react/w3console/package.json +++ b/examples/react/w3console/package.json @@ -10,13 +10,15 @@ "preview": "vite preview" }, "dependencies": { + "@headlessui/react": "^1.7.7", "@heroicons/react": "^2.0.13", "@ipld/car": "^5.1.0", "@ipld/dag-ucan": "^3.2.0", "@w3ui/keyring-core": "workspace:^", - "@w3ui/react": "workspace:^", "@w3ui/react-keyring": "workspace:^", + "@w3ui/react-uploader": "workspace:^3.1.0", "@w3ui/react-uploads-list": "workspace:^", + "@w3ui/uploader-core": "workspace:^4.0.0", "blueimp-md5": "^2.19.0", "preact": "^10.11.3" }, diff --git a/examples/react/w3console/src/app.tsx b/examples/react/w3console/src/app.tsx index 1de192eb..0c456b7d 100644 --- a/examples/react/w3console/src/app.tsx +++ b/examples/react/w3console/src/app.tsx @@ -2,30 +2,27 @@ import type { ChangeEvent } from 'react' import type { Space } from '@w3ui/keyring-core' import { useEffect, useState } from 'react' -import { - Authenticator, - Uploader, - UploadsList, - W3APIProvider, - SpaceFinder, - SpaceCreator, -} from '@w3ui/react' +import { DIDKey } from '@ucanto/interface' import { useKeyring } from '@w3ui/react-keyring' import { useUploadsList } from '@w3ui/react-uploads-list' import { ShareIcon } from '@heroicons/react/20/solid' import md5 from 'blueimp-md5' -import '@w3ui/react/src/styles/all.css' import { SpaceShare } from './share' -import { DIDKey } from '@ucanto/interface' +import { Authenticator } from './components/Authenticator' +import { Uploader } from './components/Uploader' +import { UploadsList } from './components/UploadsList' +import { W3APIProvider } from './components/W3API' +import { SpaceFinder } from './components/SpaceFinder' +import { SpaceCreator } from './components/SpaceCreator' -function SpaceRegistrar(): JSX.Element { +function SpaceRegistrar (): JSX.Element { const [, { registerSpace }] = useKeyring() const [email, setEmail] = useState('') const [submitted, setSubmitted] = useState(false) - function resetForm(): void { + function resetForm (): void { setEmail('') } - async function onSubmit(e: React.FormEvent): Promise { + async function onSubmit (e: React.FormEvent): Promise { e.preventDefault() setSubmitted(true) try { @@ -87,7 +84,7 @@ interface SpaceSectionProps { share: boolean } -function SpaceSection(props: SpaceSectionProps): JSX.Element { +function SpaceSection (props: SpaceSectionProps): JSX.Element { const { viewSpace, share, setShare } = props const [{ space }] = useKeyring() const [, { reload }] = useUploadsList() @@ -149,7 +146,7 @@ function SpaceSection(props: SpaceSectionProps): JSX.Element { ) } -function SpaceSelector(props: any): JSX.Element { +function SpaceSelector (props: any): JSX.Element { const { selected, setSelected, spaces } = props return (
@@ -167,7 +164,7 @@ function SpaceSelector(props: any): JSX.Element { ) } -export function Logo(): JSX.Element { +export function Logo (): JSX.Element { return (

diff --git a/examples/react/w3console/src/components/Authenticator.tsx b/examples/react/w3console/src/components/Authenticator.tsx new file mode 100644 index 00000000..f694607c --- /dev/null +++ b/examples/react/w3console/src/components/Authenticator.tsx @@ -0,0 +1,90 @@ +import React from 'react' +import { + Authenticator as AuthCore, + useAuthenticator +} from '@w3ui/react-keyring' + +export function AuthenticationForm (): JSX.Element { + const [{ submitted }] = useAuthenticator() + + return ( +
+ +
+ + +
+ +
+
+ ) +} + +export function AuthenticationSubmitted (): JSX.Element { + const [{ email }] = useAuthenticator() + + return ( +
+
+

Verify your email address!

+

+ Click the link in the email we sent to {email} to sign in. +

+ + Cancel + +
+
+ ) +} + +export function AuthenticationEnsurer ({ + children +}: { + children: JSX.Element | JSX.Element[] +}): JSX.Element { + const [{ spaces, submitted }] = useAuthenticator() + const registered = Boolean(spaces.some((s) => s.registered())) + if (registered) { + return <>{children} + } + if (submitted) { + return + } + return +} + +interface AuthenticatorProps { + children: JSX.Element | JSX.Element[] + className?: string +} + +export function Authenticator ({ + children, + className = '' +}: AuthenticatorProps): JSX.Element { + return ( + + {children} + + ) +} + +/** + * Wrapping a component with this HoC ensures an identity exists. + */ +export function withIdentity, P> ( + Component: C +) { + return (props: any) => ( + + + + ) +} diff --git a/examples/react/w3console/src/components/SpaceCreator.tsx b/examples/react/w3console/src/components/SpaceCreator.tsx new file mode 100644 index 00000000..5b2a035f --- /dev/null +++ b/examples/react/w3console/src/components/SpaceCreator.tsx @@ -0,0 +1,104 @@ +import type { ChangeEvent } from 'react' + +import React, { useState } from 'react' +import { useKeyring } from '@w3ui/react-keyring' +import { ArrowPathIcon } from '@heroicons/react/20/solid' + +export function SpaceCreatorCreating (): JSX.Element { + return ( +
+
Creating Space...
+ +
+ ) +} + +interface SpaceCreatorProps { + className?: string +} + +export function SpaceCreator ({ + className = '' +}: SpaceCreatorProps): JSX.Element { + const [, { createSpace, registerSpace }] = useKeyring() + const [creating, setCreating] = useState(false) + const [submitted, setSubmitted] = useState(false) + const [email, setEmail] = useState('') + const [name, setName] = useState('') + + function resetForm (): void { + setEmail('') + setName('') + } + + async function onSubmit (e: React.FormEvent): Promise { + e.preventDefault() + setSubmitted(true) + try { + await createSpace(name) + // ignore this because the Space UI should handle helping the user recover + // from space registration failure + void registerSpace(email) + } catch (error) { + /* eslint-disable no-console */ + console.error(error) + /* eslint-enable no-console */ + throw new Error('failed to register', { cause: error }) + } finally { + resetForm() + setSubmitted(false) + } + } + /* eslint-disable no-nested-ternary */ + return ( +
+ {creating + ? ( + submitted + ? ( + + ) + : ( +
) => { + void onSubmit(e) + }} + > + ) => { + setEmail(e.target.value) + }} + /> + ) => { + setName(e.target.value) + }} + /> + +
+ ) + ) + : ( + + )} +
+ ) + /* eslint-enable no-nested-ternary */ +} diff --git a/examples/react/w3console/src/components/SpaceFinder.tsx b/examples/react/w3console/src/components/SpaceFinder.tsx new file mode 100644 index 00000000..aaf3f962 --- /dev/null +++ b/examples/react/w3console/src/components/SpaceFinder.tsx @@ -0,0 +1,114 @@ +import type { Space } from '@w3ui/keyring-core' + +import React, { Fragment, useState } from 'react' +import { Combobox, Transition } from '@headlessui/react' +import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/20/solid' + +interface SpaceFinderProps { + spaces: Space[] + selected?: Space + setSelected?: (space: Space) => void + className?: string +} + +export function SpaceFinder ({ + spaces, + selected, + setSelected, + className = '' +}: SpaceFinderProps): JSX.Element { + const [query, setQuery] = useState('') + const filtered = + query === '' + ? spaces + : spaces.filter((space: Space) => + (space.name() ?? space.did()) + .toLowerCase() + .replace(/\s+/g, '') + .includes(query.toLowerCase().replace(/\s+/g, '')) + ) + + return ( +
+ a.sameAs(b)} + > +
+
+ space.name() ?? space.did()} + onChange={(event) => { setQuery(event.target.value) }} + /> + + +
+ { setQuery('') }} + > + + {filtered.length === 0 && query !== '' + ? ( +
+ (╯°□°)╯︵ ┻━┻ +
+ ) + : ( + filtered.map((space) => ( + + `w3ui-space-finder-combobox-option ${ + active ? 'active' : '' + }` + } + value={space} + > + {({ selected, active }) => ( + <> + + {space.name() ?? space.did()} + + {selected + ? ( + + + ) + : null} + + )} + + )) + )} +
+
+
+
+
+ ) +} diff --git a/examples/react/w3console/src/components/Uploader.tsx b/examples/react/w3console/src/components/Uploader.tsx new file mode 100644 index 00000000..73b7e89a --- /dev/null +++ b/examples/react/w3console/src/components/Uploader.tsx @@ -0,0 +1,184 @@ +import type { OnUploadComplete } from '@w3ui/react-uploader' + +import React from 'react' +import { CARMetadata } from '@w3ui/uploader-core' +import { + Status, + Uploader as UploaderCore, + useUploaderComponent +} from '@w3ui/react-uploader' +import { Link, Version } from 'multiformats' + +export const Uploading = ({ + file, + storedDAGShards +}: { + file?: File + storedDAGShards?: CARMetadata[] +}): JSX.Element => ( +
+

Uploading {file?.name}

+ {storedDAGShards?.map(({ cid, size }) => ( +

+ shard {cid.toString()} ({humanFileSize(size)}) uploaded +

+ ))} +
+) + +export const Errored = ({ error }: { error: any }): JSX.Element => ( +
+

+ ⚠️ Error: failed to upload file: {error.message} +

+

Check the browser console for details.

+
+) + +interface DoneProps { + file?: File + dataCID?: Link + storedDAGShards?: CARMetadata[] +} + +export const Done = ({ dataCID }: DoneProps): JSX.Element => { + const [, { setFile }] = useUploaderComponent() + const cid: string = dataCID?.toString() ?? '' + return ( +
+

Uploaded

+ + {cid} + +
+ +
+
+ ) +} + +const UploaderForm = (): JSX.Element => { + const [{ status, file }] = useUploaderComponent() + const hasFile = file !== undefined + return ( + +
+ + + +
+
+ ) +} + +function pickFileIconLabel (file: File): string | undefined { + const type = file.type.split('/') + if (type.length === 0 || type.at(0) === '') { + const ext = file.name.split('.').at(-1) + if (ext !== undefined && ext.length < 5) { + return ext + } + return 'Data' + } + if (type.at(0) === 'image') { + return type.at(-1) + } + return type.at(0) +} + +function humanFileSize (bytes: number): string { + const size = (bytes / (1024 * 1024)).toFixed(2) + return `${size} MiB` +} + +const UploaderContents = (): JSX.Element => { + const [{ status, file }] = useUploaderComponent() + const hasFile = file !== undefined + if (status === Status.Idle) { + return hasFile + ? ( + <> +
+
+ {pickFileIconLabel(file)} +
+
+ {file.name} + + {humanFileSize(file.size)} + +
+
+
+ +
+ + ) + : <> + } else { + return ( +
+ +
+ ) + } +} + +const UploaderConsole = (): JSX.Element => { + const [{ status, file, error, dataCID, storedDAGShards }] = + useUploaderComponent() + switch (status) { + case Status.Uploading: { + return + } + case Status.Succeeded: { + return ( + + ) + } + case Status.Failed: { + return + } + default: { + return <> + } + } +} + +export interface SimpleUploaderProps { + onUploadComplete?: OnUploadComplete +} + +export const Uploader = ({ + onUploadComplete +}: SimpleUploaderProps): JSX.Element => { + return ( + + + + ) +} diff --git a/examples/react/w3console/src/components/UploadsList.tsx b/examples/react/w3console/src/components/UploadsList.tsx new file mode 100644 index 00000000..5190209f --- /dev/null +++ b/examples/react/w3console/src/components/UploadsList.tsx @@ -0,0 +1,70 @@ +import React from 'react' +import { ChevronLeftIcon, ChevronRightIcon, ArrowPathIcon } from '@heroicons/react/20/solid' +import type { UploadListResult } from '@w3ui/uploads-list-core' +import { UploadsList as UploadsListCore } from '@w3ui/react-uploads-list' + +interface UploadsProps { + uploads?: UploadListResult[] + loading: boolean +} + +function Uploads ({ uploads, loading }: UploadsProps): JSX.Element { + return uploads === undefined || uploads.length === 0 + ? ( + <> +
No uploads
+ + + ) + : ( + <> +
+ + + + + + + + {uploads.map(({ root }) => ( + + + + ))} + +
Root CID
+ + {root.toString()} + +
+
+ + + ) +} + +export const UploadsList = (): JSX.Element => { + return ( + + {(props) => ( +
+ +
+ )} +
+ ) +} diff --git a/examples/react/w3console/src/components/W3API.tsx b/examples/react/w3console/src/components/W3API.tsx new file mode 100644 index 00000000..b621dd70 --- /dev/null +++ b/examples/react/w3console/src/components/W3API.tsx @@ -0,0 +1,61 @@ +import React, { useMemo } from 'react' +import { ServiceConfig } from '@w3ui/uploader-core' +import { + useUploader, + UploaderContextValue, + UploaderProvider +} from '@w3ui/react-uploader' +import { + useUploadsList, + UploadsListContextValue, + UploadsListProvider +} from '@w3ui/react-uploads-list' +import { + useKeyring, + KeyringContextValue, + KeyringProvider +} from '@w3ui/react-keyring' + +export interface W3APIContextValue { + keyring: KeyringContextValue + uploader: UploaderContextValue + uploadsList: UploadsListContextValue +} +export interface UploaderProviderProps extends ServiceConfig { + children?: JSX.Element +} + +export interface W3APIProviderProps { + children: JSX.Element | JSX.Element[] + uploadsListPageSize?: number +} + +export function W3APIProvider ({ + children, + uploadsListPageSize +}: W3APIProviderProps): JSX.Element { + return ( + + + + <>{children} + + + + ) +} + +export function useW3API (): W3APIContextValue { + const keyring = useKeyring() + const uploader = useUploader() + const uploadsList = useUploadsList() + const value = useMemo( + () => ({ + keyring, + uploader, + uploadsList + }), + [keyring, uploader, uploadsList] + ) + return value +} diff --git a/examples/react/w3console/src/components/W3Upload.tsx b/examples/react/w3console/src/components/W3Upload.tsx new file mode 100644 index 00000000..9578769d --- /dev/null +++ b/examples/react/w3console/src/components/W3Upload.tsx @@ -0,0 +1,16 @@ +import React from 'react' +import { W3APIProvider } from './providers/W3API' +import { Authenticator } from './Authenticator' +import { Uploader } from './Uploader' +import { UploadsList } from './UploadsList' + +export function W3Upload (): JSX.Element { + return ( + + + + + + + ) +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1521b59a..57736a74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -234,6 +234,7 @@ importers: examples/react/w3console: specifiers: + '@headlessui/react': ^1.7.7 '@heroicons/react': ^2.0.13 '@ipld/car': ^5.1.0 '@ipld/dag-ucan': ^3.2.0 @@ -242,9 +243,10 @@ importers: '@ucanto/core': ^4.1.0 '@ucanto/interface': ^4.1.0 '@w3ui/keyring-core': workspace:^ - '@w3ui/react': workspace:^ '@w3ui/react-keyring': workspace:^ + '@w3ui/react-uploader': workspace:^3.1.0 '@w3ui/react-uploads-list': workspace:^ + '@w3ui/uploader-core': workspace:^4.0.0 autoprefixer: ^10.4.13 blueimp-md5: ^2.19.0 postcss: ^8.4.21 @@ -253,13 +255,15 @@ importers: typescript: ^4.9.3 vite: ^4.0.0 dependencies: + '@headlessui/react': 1.7.7 '@heroicons/react': 2.0.13 '@ipld/car': 5.1.0 '@ipld/dag-ucan': 3.2.0 '@w3ui/keyring-core': link:../../../packages/keyring-core - '@w3ui/react': link:../../../packages/react '@w3ui/react-keyring': link:../../../packages/react-keyring + '@w3ui/react-uploader': link:../../../packages/react-uploader '@w3ui/react-uploads-list': link:../../../packages/react-uploads-list + '@w3ui/uploader-core': link:../../../packages/uploader-core blueimp-md5: 2.19.0 preact: 10.11.3 devDependencies: From 0f166e0d1dec33ed279e17007ee884e180252619 Mon Sep 17 00:00:00 2001 From: Travis Vachon Date: Mon, 20 Feb 2023 23:35:09 +0800 Subject: [PATCH 07/10] chore: Move all styling to idiomatic TailwindCSS. Since we're no longer using the external w3ui React "customizable" component library, we can use idiomatic TailwindCSS across the board. A few years ago I would have considered this a step backwards, moving from semantic classes to tailwind utility classes sprinkled everywhere. While there may be some opportunities for abstraction in a few places, Tailwind's docs make the case for being extremely conservative with this sort of CSS-level componentization: https://tailwindcss.com/docs/reusing-styles I have created a `w3ui-button` class, but it probably needs to be pared down - evidence for this can be found in the authenticator's "Register" button in which I needed to copy most of the styles from the `w3ui-button` class to customize it the way I wanted. --- .../src/components/Authenticator.tsx | 36 ++++++------- .../components/{did-icon.tsx => DidIcon.tsx} | 0 .../w3console/src/components/SpaceCreator.tsx | 16 +++--- .../w3console/src/components/SpaceFinder.tsx | 30 +++++------ .../w3console/src/components/Uploader.tsx | 51 +++++++++---------- .../w3console/src/components/UploadsList.tsx | 24 ++++----- examples/react/w3console/src/index.css | 22 ++------ examples/react/w3console/src/share.tsx | 2 +- examples/react/w3console/tailwind.config.cjs | 3 ++ packages/react/src/styles/base.css | 4 +- packages/react/src/styles/space-finder.css | 10 ++-- packages/react/src/styles/uploader.css | 2 +- packages/react/src/styles/uploads-list.css | 4 +- 13 files changed, 95 insertions(+), 109 deletions(-) rename examples/react/w3console/src/components/{did-icon.tsx => DidIcon.tsx} (100%) diff --git a/examples/react/w3console/src/components/Authenticator.tsx b/examples/react/w3console/src/components/Authenticator.tsx index f694607c..a5499fb6 100644 --- a/examples/react/w3console/src/components/Authenticator.tsx +++ b/examples/react/w3console/src/components/Authenticator.tsx @@ -8,19 +8,19 @@ export function AuthenticationForm (): JSX.Element { const [{ submitted }] = useAuthenticator() return ( -
- -
- - -
- +
+ +
+ + +
+
) @@ -30,13 +30,13 @@ export function AuthenticationSubmitted (): JSX.Element { const [{ email }] = useAuthenticator() return ( -
-
-

Verify your email address!

-

+

+
+

Verify your email address!

+

Click the link in the email we sent to {email} to sign in.

- + Cancel
diff --git a/examples/react/w3console/src/components/did-icon.tsx b/examples/react/w3console/src/components/DidIcon.tsx similarity index 100% rename from examples/react/w3console/src/components/did-icon.tsx rename to examples/react/w3console/src/components/DidIcon.tsx diff --git a/examples/react/w3console/src/components/SpaceCreator.tsx b/examples/react/w3console/src/components/SpaceCreator.tsx index 5b2a035f..de2e1c0b 100644 --- a/examples/react/w3console/src/components/SpaceCreator.tsx +++ b/examples/react/w3console/src/components/SpaceCreator.tsx @@ -6,9 +6,9 @@ import { ArrowPathIcon } from '@heroicons/react/20/solid' export function SpaceCreatorCreating (): JSX.Element { return ( -
+
Creating Space...
- +
) } @@ -51,7 +51,7 @@ export function SpaceCreator ({ } /* eslint-disable no-nested-ternary */ return ( -
+
{creating ? ( submitted @@ -60,13 +60,13 @@ export function SpaceCreator ({ ) : (
) => { void onSubmit(e) }} > ) => { @@ -84,7 +84,7 @@ export function SpaceCreator ({ />
@@ -92,7 +92,7 @@ export function SpaceCreator ({ ) : (