diff --git a/app/components/general-layout.tsx b/app/components/general-layout.tsx index c0b079b..add18d6 100644 --- a/app/components/general-layout.tsx +++ b/app/components/general-layout.tsx @@ -108,8 +108,8 @@ export const GeneralLayout = ({ children }: React.PropsWithChildren<{}>) => { logout()}> - Log Out - + Logout + diff --git a/app/components/lib/sdk-context.tsx b/app/components/lib/sdk-context.tsx new file mode 100644 index 0000000..a2d455e --- /dev/null +++ b/app/components/lib/sdk-context.tsx @@ -0,0 +1,18 @@ +import React from "react"; +import {Sdk} from "@lumeweb/portal-sdk"; + +export const SdkContext = React.createContext< + Partial +>({}); + +export const SdkContextProvider: React.FC< {sdk: Sdk, children: React.ReactNode}> = ({sdk, children}) => { + return ( + + {children} + + ); +}; + +export function useSdk(): Partial{ + return React.useContext(SdkContext); +} diff --git a/app/components/lib/uppy-file-upload.ts b/app/components/lib/uppy-file-upload.ts new file mode 100644 index 0000000..a6224ec --- /dev/null +++ b/app/components/lib/uppy-file-upload.ts @@ -0,0 +1,63 @@ +import Uppy, {BasePlugin, DefaultPluginOptions} from '@uppy/core'; +import {PROTOCOL_S5, Sdk} from "@lumeweb/portal-sdk"; +import {S5Client} from "@lumeweb/s5-js"; +import {AxiosProgressEvent} from "axios"; + + +interface UppyFileUploadOptions extends DefaultPluginOptions { + sdk: Sdk; +} + +export default class UppyFileUpload extends BasePlugin { + private _sdk: Sdk; + + constructor(uppy: Uppy, opts?: UppyFileUploadOptions) { + super(uppy, opts); + this.id = opts?.id || 'file-upload'; + this.type = 'uploader'; + this._sdk = opts?.sdk as Sdk; + } + + install() { + this.uppy.addUploader(this.handleUpload.bind(this)); + } + + private async handleUpload(fileIDs: string[]) { + for (const fileID of fileIDs) { + const file = this.uppy.getFile(fileID); + if (!file) { + continue; + } + + // @ts-ignore + if (file.uploader !== 'file') { + continue; + } + + const uploadLimit = await this._sdk.account().uploadLimit(); + + let data = file.data; + + if (file.data instanceof Blob) { + data = new File([data], file.name, {type: file.type}); + } + + try { + await this._sdk.protocols().get(PROTOCOL_S5).getSdk().uploadFile(data as File, { + largeFileSize: uploadLimit, + onUploadProgress: (progressEvent: AxiosProgressEvent) => { + this.uppy.emit('upload-progress', this.uppy.getFile(file.id), { + uploader: this, + bytesUploaded: progressEvent.loaded, + bytesTotal: progressEvent.total, + }) + } + }); + + this.uppy.emit('upload-success', file, {uploadURL: null}); + } catch (err) { + this.uppy.emit('upload-error', file, err); + } + } + } +} diff --git a/app/components/lib/uppy.ts b/app/components/lib/uppy.ts index 6b725f8..3dc123c 100644 --- a/app/components/lib/uppy.ts +++ b/app/components/lib/uppy.ts @@ -1,17 +1,14 @@ -import Uppy, { type State, debugLogger } from "@uppy/core" +import Uppy, {debugLogger, type State, UppyFile} from "@uppy/core" import Tus from "@uppy/tus" import toArray from "@uppy/utils/lib/toArray" -import { - type ChangeEvent, - useCallback, - useEffect, - useMemo, - useRef, - useState -} from "react" -import DropTarget, { type DropTargetOptions } from "./uppy-dropzone" +import {type ChangeEvent, useCallback, useEffect, useMemo, useRef, useState} from "react" +import DropTarget, {type DropTargetOptions} from "./uppy-dropzone" +import {useSdk} from "~/components/lib/sdk-context.js"; +import UppyFileUpload from "~/components/lib/uppy-file-upload.js"; +import {PROTOCOL_S5, Sdk} from "@lumeweb/portal-sdk"; +import {S5Client} from "@lumeweb/s5-js"; const LISTENING_EVENTS = [ "upload", @@ -23,12 +20,26 @@ const LISTENING_EVENTS = [ ] as const export function useUppy({ - uploader, endpoint }: { - uploader: "tus" endpoint: string }) { + const sdk = useSdk() + + const [uploadLimit, setUploadLimit] = useState(0) + + useEffect(() => { + async function getUploadLimit() { + try { + const limit = await sdk.account!().uploadLimit(); + setUploadLimit(limit); + } catch (err) { + console.log('Error occured while fetching upload limit', err); + } + } + getUploadLimit(); + }, []); + const inputRef = useRef(null) const [targetRef, _setTargetRef] = useState(null) const uppyInstance = useRef() @@ -82,7 +93,29 @@ export function useUppy({ useEffect(() => { if (!targetRef) return - const uppy = new Uppy({ logger: debugLogger }).use(DropTarget, { + const tusPreprocessor = async (fileIDs: string[]) => { + for(const fileID of fileIDs) { + const file = uppyInstance.current?.getFile(fileID) as UppyFile + // @ts-ignore + if (file.uploader === "tus") { + uppyInstance.current?.setFileState(fileID, { + tus: await sdk.protocols!().get(PROTOCOL_S5).getSdk().getTusOptions(file.data as File) + }) + } + } + } + + const uppy = new Uppy({ + logger: debugLogger, + onBeforeUpload: (files) => { + for (const file of Object.entries(files)) { + // @ts-ignore + file[1].uploader = file[1].size > uploadLimit ? "tus" : "file"; + } + + return true; + }, + }).use(DropTarget, { target: targetRef } as DropTargetOptions) @@ -97,6 +130,25 @@ export function useUppy({ uppyInstance.current?.addFiles(files) } + uppy.iteratePlugins((plugin) => { + uppy.removePlugin(plugin); + }); + + uppy.use(UppyFileUpload, { sdk: sdk as Sdk }) + + let useTus = false; + + uppyInstance.current?.getFiles().forEach((file) => { + if (file.size > uploadLimit) { + useTus = true; + } + }) + + if (useTus) { + uppy.use(Tus, { endpoint: endpoint, limit: 6 }) + uppy.addPreProcessor(tusPreprocessor) + } + // We clear the input after a file is selected, because otherwise // change event is not fired in Chrome and Safari when a file // with the same name is selected. @@ -109,12 +161,7 @@ export function useUppy({ } }) - switch (uploader) { - case "tus": - uppy.use(Tus, { endpoint: endpoint, limit: 6 }) - break - default: - } + uppy.on("complete", (result) => { if (result.failed.length === 0) { @@ -148,7 +195,7 @@ export function useUppy({ }) } setState("idle") - }, [targetRef, endpoint, uploader]) + }, [targetRef, endpoint, uploadLimit]) useEffect(() => { return () => { diff --git a/app/data/auth-provider.ts b/app/data/auth-provider.ts index e29e9fe..55188b6 100644 --- a/app/data/auth-provider.ts +++ b/app/data/auth-provider.ts @@ -33,10 +33,8 @@ export type Identity = { } export class PortalAuthProvider implements RequiredAuthProvider { - private sdk: Sdk; - constructor(apiUrl: string) { - this.sdk = Sdk.create(apiUrl); + this._sdk = Sdk.create(apiUrl); const methods: Array = [ 'login', @@ -55,9 +53,18 @@ export class PortalAuthProvider implements RequiredAuthProvider { }); } + private _sdk: Sdk; + + get sdk(): Sdk { + return this._sdk; + } + + public static create(apiUrl: string): AuthProvider { + return new PortalAuthProvider(apiUrl); + } + async login(params: AuthFormRequest): Promise { - const cookies = new Cookies(); - const ret = await this.sdk.account().login({ + const ret = await this._sdk.account().login({ email: params.email, password: params.password, }) @@ -65,11 +72,11 @@ export class PortalAuthProvider implements RequiredAuthProvider { let redirectTo: string | undefined; if (ret) { - cookies.set('jwt', this.sdk.account().jwtToken, {path: '/'}); redirectTo = params.redirectTo; if (!redirectTo) { redirectTo = ret ? "/dashboard" : "/login"; } + this._sdk.setAuthToken(this._sdk.account().jwtToken); } return { @@ -79,38 +86,25 @@ export class PortalAuthProvider implements RequiredAuthProvider { } async logout(params: any): Promise { - let ret = await this.sdk.account().logout(); - if (ret) { - const cookies = new Cookies(); - cookies.remove('jwt'); - } + let ret = await this._sdk.account().logout(); return {success: ret, redirectTo: "/login"}; } async check(params?: any): Promise { - const cookies = new Cookies(); + this.maybeSetupAuth(); - const jwtCookie = cookies.get('jwt'); - - if (jwtCookie) { - this.sdk.setAuthToken(jwtCookie); - } - - const ret = await this.sdk.account().ping(); - - if (!ret) { - cookies.remove('jwt'); - } + const ret = await this._sdk.account().ping(); return {authenticated: ret, redirectTo: ret ? undefined : "/login"}; } async onError(error: any): Promise { + const cookies = new Cookies(); return {logout: true}; } async register(params: RegisterFormRequest): Promise { - const ret = await this.sdk.account().register({ + const ret = await this._sdk.account().register({ email: params.email, password: params.password, first_name: params.firstName, @@ -132,7 +126,8 @@ export class PortalAuthProvider implements RequiredAuthProvider { } async getIdentity(params?: Identity): Promise { - const ret = await this.sdk.account().info(); + this.maybeSetupAuth(); + const ret = await this._sdk.account().info(); if (!ret) { return {identity: null}; @@ -148,12 +143,16 @@ export class PortalAuthProvider implements RequiredAuthProvider { }; } - public static create(apiUrl: string): AuthProvider { - return new PortalAuthProvider(apiUrl); + maybeSetupAuth(): void { + const cookies = new Cookies(); + const jwtCookie = cookies.get('auth_token'); + if (jwtCookie) { + this._sdk.setAuthToken(jwtCookie); + } } } -interface RequiredAuthProvider extends AuthProvider { +export interface RequiredAuthProvider extends AuthProvider { login: AuthProvider['login']; logout: AuthProvider['logout']; check: AuthProvider['check']; diff --git a/app/data/pinning.ts b/app/data/pinning.ts new file mode 100644 index 0000000..80d9540 --- /dev/null +++ b/app/data/pinning.ts @@ -0,0 +1,55 @@ +interface PinningStatus { + id: string; + progress: number; + status: 'inprogress' | 'completed' | 'stale'; +} + +// biome-ignore lint/complexity/noStaticOnlyClass: +class PinningProcess { + private static instances: Map = new Map(); + + static async pin(id: string): Promise<{ success: boolean; message: string }> { + if (PinningProcess.instances.has(id)) { + return { success: false, message: "ID is already being processed" }; + } + + const pinningStatus: PinningStatus = { id, progress: 0, status: 'inprogress' }; + PinningProcess.instances.set(id, pinningStatus); + + // Simulate async progress + (async () => { + for (let progress = 1; progress <= 100; progress++) { + await new Promise(resolve => setTimeout(resolve, Math.floor(Math.random() * (500 - 100 + 1)) + 100)); // Simulate time passing with random duration between 100 and 500 + pinningStatus.progress = progress; + if (progress === 100) { + pinningStatus.status = 'completed'; + } + } + })(); + + return { success: true, message: "Pinning process started" }; + } + + static *pollProgress(id: string): Generator { + let status = PinningProcess.instances.get(id); + while (status && status.status !== 'completed') { + yield status; + status = PinningProcess.instances.get(id); + } + yield status ?? null; // Yield the final status, could be null if ID doesn't exist + } +} + +// Example usage: +// (async () => { +// const { success, message } = await PinningProcess.pin("123"); +// console.log(message); +// if (success) { +// const progressGenerator = PinningProcess.pollProgress("123"); +// let result = progressGenerator.next(); +// while (!result.done) { +// console.log(result.value); // Log the progress +// result = progressGenerator.next(); +// } +// } +// })(); diff --git a/app/root.tsx b/app/root.tsx index d4286cf..96d1531 100644 --- a/app/root.tsx +++ b/app/root.tsx @@ -15,6 +15,7 @@ import {Refine} from "@refinedev/core"; import {PortalAuthProvider} from "~/data/auth-provider.js"; import routerProvider from "@refinedev/remix-router"; import { defaultProvider } from "./data/file-provider"; +import {SdkContextProvider} from "~/components/lib/sdk-context.js"; export const links: LinksFunction = () => [ { rel: "stylesheet", href: stylesheet }, @@ -39,9 +40,10 @@ export function Layout({children}: { children: React.ReactNode }) { } export default function App() { + const auth = PortalAuthProvider.create("https://alpha.pinner.xyz") return ( - + + + ); } diff --git a/app/routes/account.tsx b/app/routes/account.tsx index b8d32f7..41a4717 100644 --- a/app/routes/account.tsx +++ b/app/routes/account.tsx @@ -1,12 +1,23 @@ import { getFormProps, useForm } from "@conform-to/react"; import { getZodConstraint, parseWithZod } from "@conform-to/zod"; -import { BaseKey, useGetIdentity, useUpdate, useUpdatePassword } from "@refinedev/core"; +import { + BaseKey, + useGetIdentity, + useUpdate, + useUpdatePassword, +} from "@refinedev/core"; import { useState } from "react"; import { z } from "zod"; import { Field } from "~/components/forms"; import { GeneralLayout } from "~/components/general-layout"; import { AddIcon, CloudIcon, CrownIcon } from "~/components/icons"; -import { ManagementCard, ManagementCardAvatar, ManagementCardContent, ManagementCardFooter, ManagementCardTitle } from "~/components/management-card"; +import { + ManagementCard, + ManagementCardAvatar, + ManagementCardContent, + ManagementCardFooter, + ManagementCardTitle, +} from "~/components/management-card"; import { Button } from "~/components/ui/button"; import { Dialog, @@ -51,10 +62,12 @@ export default function MyAccount() { Email Address - {identity?.email} + {identity?.email} - @@ -63,8 +76,8 @@ export default function MyAccount() { Account Type - Lite Premium Account - + Lite Premium Account + @@ -91,35 +106,27 @@ export default function MyAccount() { Two-Factor Authentication - Improve security by enabling 2FA. + Improve security by enabling 2FA. - - - Backup Key - - Never share this code with anyone. - - - - -

More

Invite a Friend - Get 1 GB per friend invited for free (max 5 GB). + + Get 1 GB per friend invited for free (max 5 GB). + - @@ -127,7 +134,9 @@ export default function MyAccount() { Read our Resources - Navigate helpful articles or get assistance. + + Navigate helpful articles or get assistance. +