Compare commits

...

36 Commits

Author SHA1 Message Date
Tania Gutierrez 37ad1d1dc9
feat: Added Dialog to Edit Avatar Card on Account View 2024-03-18 21:42:32 -04:00
Tania Gutierrez add532aa51
feat: Added Notification provided and toast to verify email and recovery password 2024-03-18 21:41:53 -04:00
Tania Gutierrez 43ac8560cb
Merge branch 'develop' into riobuenoDevelops/refine-integration 2024-03-18 15:52:14 -04:00
Tania Gutierrez 85615f53f5
fix: Added Row Skeletons with loading table data 2024-03-18 15:51:02 -04:00
Derrick Hammer dc4f6f7e23
refactor: need to add an uppy pre-processor to configure the tus options per file, including the hashing 2024-03-18 15:32:47 -04:00
Derrick Hammer 043c9b8375
dep: update portal-sdk 2024-03-18 14:32:33 -04:00
Derrick Hammer 7f8310ea9c
debug: disable minify 2024-03-18 13:02:48 -04:00
Derrick Hammer 6794bb2a4a
dep: update portal-sdk 2024-03-18 13:02:22 -04:00
Derrick Hammer 7779f794e4
fix: bind handleUpload to current instance 2024-03-18 12:50:44 -04:00
Derrick Hammer 2402d3e076
fix: set auth token globally on sdk after login 2024-03-18 12:50:26 -04:00
Derrick Hammer 2140a63add
fix: put plugin logic inside setInputProps onChange 2024-03-18 12:37:22 -04:00
Derrick Hammer 756a505590
fix: uppy requires a type 2024-03-18 12:36:58 -04:00
Derrick Hammer 7e78f17937
ci: disable protocolImports 2024-03-18 11:57:38 -04:00
Derrick Hammer ed410051e2
ci: add vite-plugin-node-polyfills plugin 2024-03-18 11:37:44 -04:00
Derrick Hammer 42de97519b
dep: update portal-sdk 2024-03-18 11:37:17 -04:00
Derrick Hammer d11bf0861c
dep: update portal-sdk 2024-03-18 11:18:29 -04:00
Derrick Hammer a3d958065c
dep: update portal-sdk 2024-03-18 11:13:06 -04:00
Juan Di Toro 26b0246429 Merge branch 'develop' of git.lumeweb.com:LumeWeb/portal-dashboard into develop 2024-03-18 16:07:24 +01:00
Juan Di Toro c9d956e1b6 fix: remove the backup key card from my account 2024-03-18 16:07:11 +01:00
Derrick Hammer ec9509ef6c
dep: update portal-sdk 2024-03-18 11:06:45 -04:00
Derrick Hammer 8a3181177b
dep: update portal-sdk 2024-03-18 11:01:13 -04:00
Derrick Hammer 84f7585a66
dep: update portal-sdk 2024-03-18 10:56:20 -04:00
Derrick Hammer 3a4b40ef27
refactor: remove uploader from uppy hook and check by the upload size limit if we use tus plugin or just file post. additionally add a onBeforeUpload filter to set the custom uploader property. 2024-03-18 10:12:27 -04:00
Derrick Hammer 988780b25f
feat: add initial plugin for a post file upload via s5 sdk 2024-03-18 10:11:07 -04:00
Derrick Hammer dca77ba71a
fix: add patch for uppy tus to filter on files with a custom uploader property set to tus 2024-03-18 10:10:33 -04:00
Derrick Hammer 7f26bc1060
feat: add sdk context and useSdk 2024-03-18 10:09:37 -04:00
Derrick Hammer 8643363736
refactor: export RequiredAuthProvider 2024-03-18 10:09:08 -04:00
Derrick Hammer 7c332d0f43
refactor: add sdk getter 2024-03-18 10:08:57 -04:00
Derrick Hammer d3e847baf8
dep: update portal-sdk 2024-03-18 10:08:15 -04:00
Derrick Hammer 06db71bd8a
dep: update portal-sdk 2024-03-17 11:26:23 -04:00
Derrick Hammer 928deb89b6
refactor: update logout text name 2024-03-17 09:48:21 -04:00
Derrick Hammer 245467155d
refactor: don't try to delete the auth cookie 2024-03-17 09:41:52 -04:00
Derrick Hammer fd7fec8580
dep: just use universal-cookie 2024-03-17 09:41:12 -04:00
Derrick Hammer 0e083c7a58
dep: update portal-sdk 2024-03-17 09:37:53 -04:00
Derrick Hammer 9fd55bf994
refactor: update auth token name 2024-03-17 08:18:33 -04:00
Derrick Hammer 20533913bd
refactor: switch to reading cookie only and let server handle it for security, add maybeSetupAuth helper. 2024-03-16 15:41:31 -04:00
21 changed files with 926 additions and 170 deletions

View File

@ -1,4 +1,4 @@
import { useState } from "react";
import { useMemo} from "react";
import { BaseRecord } from "@refinedev/core";
import { useTable } from "@refinedev/react-table";
import {
@ -14,6 +14,7 @@ import {
TableHeader,
TableRow,
} from "./ui/table"
import { Skeleton } from "./ui/skeleton";
import { DataTablePagination } from "./table-pagination"
interface DataTableProps<TData extends BaseRecord = BaseRecord, TValue = unknown> {
@ -21,22 +22,31 @@ interface DataTableProps<TData extends BaseRecord = BaseRecord, TValue = unknown
}
export function DataTable<TData extends BaseRecord, TValue>({
columns
columns,
}: DataTableProps<TData, TValue>) {
const [hoveredRowId, setHoveredRowId] = useState<string>("");
const table = useTable({
columns,
meta: {
hoveredRowId,
},
refineCoreProps: {
resource: "files"
}
})
const loadingRows = useMemo(() => Array(4).fill({}).map(() => ({
getIsSelected: () => false,
getVisibleCells: () => columns.map(() => ({
column: {
columnDef: {
cell: <Skeleton className="h-4 w-full bg-primary-1/30" />,
}
},
getContext: () => null
})),
})), [])
const rows = table.refineCore.tableQueryResult.isLoading ? loadingRows : table.getRowModel().rows
return (
<div className="rounded-lg">
<>
<Table>
<TableHeader>
{table.getHeaderGroups().map((headerGroup) => (
@ -57,15 +67,12 @@ export function DataTable<TData extends BaseRecord, TValue>({
))}
</TableHeader>
<TableBody>
{table.getRowModel().rows?.length ? (
table.getRowModel().rows.map((row) => (
{rows.length ? (
rows.map((row) => (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onMouseEnter={() => {
console.log(hoveredRowId, row.id);
setHoveredRowId(row.id)
}}
className="group"
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
@ -84,6 +91,6 @@ export function DataTable<TData extends BaseRecord, TValue>({
</TableBody>
</Table>
<DataTablePagination table={table} />
</div>
</>
)
}

View File

@ -108,7 +108,7 @@ export const GeneralLayout = ({ children }: React.PropsWithChildren<{}>) => {
<DropdownMenuGroup>
<DropdownMenuItem onClick={() => logout()}>
<ExitIcon className="mr-2" />
Log Out
Logout
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>

View File

@ -0,0 +1,18 @@
import React from "react";
import {Sdk} from "@lumeweb/portal-sdk";
export const SdkContext = React.createContext<
Partial<Sdk>
>({});
export const SdkContextProvider: React.FC< {sdk: Sdk, children: React.ReactNode}> = ({sdk, children}) => {
return (
<SdkContext.Provider value={sdk}>
{children}
</SdkContext.Provider>
);
};
export function useSdk(): Partial<Sdk>{
return React.useContext(SdkContext);
}

View File

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

View File

@ -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 {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<number>(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<HTMLInputElement>(null)
const [targetRef, _setTargetRef] = useState<HTMLElement | null>(null)
const uppyInstance = useRef<Uppy>()
@ -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<S5Client>(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 () => {

View File

@ -3,12 +3,13 @@ import { Avatar } from "./ui/avatar";
import { Button } from "./ui/button";
import { EditIcon, FingerPrintIcon } from "./icons";
const ManagementCardAvatar = ({ src }: { src?: string }) => {
const ManagementCardAvatar = ({ src, onClick }: { src?: string; onClick?: () => void }) => {
return (
<div className="flex justify-center">
<div className="relative w-fit h-fit">
<Avatar className="border-2 border-ring h-28 w-28" />
<Button
onClick={onClick}
variant="outline"
className="absolute bottom-0 right-0 z-50 flex items-center w-10 h-10 p-0 border-white rounded-full justiyf-center hover:bg-secondary-2">
<EditIcon />

View File

@ -0,0 +1,15 @@
import { cn } from "~/utils"
function Skeleton({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn("animate-pulse rounded-md bg-primary/10", className)}
{...props}
/>
)
}
export { Skeleton }

127
app/components/ui/toast.tsx Normal file
View File

@ -0,0 +1,127 @@
import * as React from "react"
import { Cross2Icon } from "@radix-ui/react-icons"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "~/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<Cross2Icon className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold [&+div]:text-xs", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "~/components/ui/toast"
import { useToast } from "~/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "~/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -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<keyof AuthProvider> = [
'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<AuthActionResponse> {
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<AuthActionResponse> {
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<CheckResponse> {
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<OnErrorResponse> {
const cookies = new Cookies();
return {logout: true};
}
async register(params: RegisterFormRequest): Promise<AuthActionResponse> {
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<IdentityResponse> {
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'];

View File

@ -0,0 +1,26 @@
import { NotificationProvider } from "@refinedev/core";
import { ToastAction } from "~/components/ui/toast";
import { toast } from "~/components/ui/use-toast";
export const notificationProvider = (): NotificationProvider => {
return {
open: ({
key,
message,
type,
description,
undoableTimeout,
cancelMutation,
}) => {
toast({
variant: type,
key,
title: message,
description,
duration: undoableTimeout,
})
},
close: () => {}
}
};

55
app/data/pinning.ts Normal file
View File

@ -0,0 +1,55 @@
interface PinningStatus {
id: string;
progress: number;
status: 'inprogress' | 'completed' | 'stale';
}
// biome-ignore lint/complexity/noStaticOnlyClass: <explanation>
class PinningProcess {
private static instances: Map<string, PinningStatus> = 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<PinningStatus | null, void, unknown> {
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();
// }
// }
// })();

View File

@ -12,9 +12,13 @@ import type { LinksFunction } from "@remix-run/node";
// Supports weights 200-800
import '@fontsource-variable/manrope';
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 { defaultProvider } from "~/data/file-provider";
import {PortalAuthProvider} from "~/data/auth-provider";
import { notificationProvider } from "~/data/notification-provider";
import {SdkContextProvider} from "~/components/lib/sdk-context";
import { Toaster } from "~/components/ui/toaster";
export const links: LinksFunction = () => [
{ rel: "stylesheet", href: stylesheet },
@ -31,6 +35,7 @@ export function Layout({children}: { children: React.ReactNode }) {
</head>
<body>
{children}
<Toaster />
<ScrollRestoration/>
<Scripts/>
</body>
@ -39,17 +44,21 @@ export function Layout({children}: { children: React.ReactNode }) {
}
export default function App() {
const auth = PortalAuthProvider.create("https://alpha.pinner.xyz")
return (
<Refine
authProvider={PortalAuthProvider.create("https://alpha.pinner.xyz")}
authProvider={auth}
routerProvider={routerProvider}
dataProvider={defaultProvider}
notificationProvider={notificationProvider}
resources={[
{ name: 'files' },
{ name: 'users' }
]}
>
<SdkContextProvider sdk={(auth as PortalAuthProvider).sdk}>
<Outlet/>
</SdkContextProvider>
</Refine>
);
}

View File

@ -1,12 +1,26 @@
import { getFormProps, useForm } from "@conform-to/react";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { BaseKey, useGetIdentity, useUpdate, useUpdatePassword } from "@refinedev/core";
import { DialogClose } from "@radix-ui/react-dialog";
import { Cross2Icon } from "@radix-ui/react-icons";
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 { AddIcon, CloudCheckIcon, CloudIcon, CloudUploadIcon, CrownIcon, TrashIcon } from "~/components/icons";
import { useUppy } from "~/components/lib/uppy";
import {
ManagementCard,
ManagementCardAvatar,
ManagementCardContent,
ManagementCardFooter,
ManagementCardTitle,
} from "~/components/management-card";
import { Button } from "~/components/ui/button";
import {
Dialog,
@ -26,6 +40,7 @@ export default function MyAccount() {
changeEmail: false,
changePassword: false,
setupTwoFactor: false,
changeAvatar: false,
});
return (
@ -46,7 +61,9 @@ export default function MyAccount() {
<h2 className="font-bold my-8">Account Management</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard>
<ManagementCardAvatar />
<ManagementCardAvatar
onClick={() => setModal({ ...openModal, changeAvatar: true })}
/>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Email Address</ManagementCardTitle>
@ -54,7 +71,9 @@ export default function MyAccount() {
{identity?.email}
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2" onClick={() => setModal({ ...openModal, changeEmail: true })}>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, changeEmail: true })}>
<AddIcon />
Change Email Address
</Button>
@ -82,7 +101,9 @@ export default function MyAccount() {
<PasswordDots className="mt-6" />
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2" onClick={() => setModal({ ...openModal, changePassword: true })}>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, changePassword: true })}>
<AddIcon />
Change Password
</Button>
@ -94,30 +115,22 @@ export default function MyAccount() {
Improve security by enabling 2FA.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2" onClick={() => setModal({ ...openModal, setupTwoFactor: true })}>
<Button
className="h-12 gap-x-2"
onClick={() => setModal({ ...openModal, setupTwoFactor: true })}>
<AddIcon />
Enable Two-Factor Authorization
</Button>
</ManagementCardFooter>
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Backup Key</ManagementCardTitle>
<ManagementCardContent>
Never share this code with anyone.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2">
<AddIcon />
Export Backup Key
</Button>
</ManagementCardFooter>
</ManagementCard>
</div>
<h2 className="font-bold my-8">More</h2>
<div className="grid grid-cols-3 gap-x-8">
<ManagementCard variant="accent">
<ManagementCardTitle>Invite a Friend</ManagementCardTitle>
<ManagementCardContent>Get 1 GB per friend invited for free (max 5 GB).</ManagementCardContent>
<ManagementCardContent>
Get 1 GB per friend invited for free (max 5 GB).
</ManagementCardContent>
<ManagementCardFooter>
<Button variant="accent" className="h-12 gap-x-2">
<AddIcon />
@ -127,7 +140,9 @@ export default function MyAccount() {
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Read our Resources</ManagementCardTitle>
<ManagementCardContent>Navigate helpful articles or get assistance.</ManagementCardContent>
<ManagementCardContent>
Navigate helpful articles or get assistance.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2">
<AddIcon />
@ -137,7 +152,9 @@ export default function MyAccount() {
</ManagementCard>
<ManagementCard>
<ManagementCardTitle>Delete Account</ManagementCardTitle>
<ManagementCardContent>Once initiated, this action cannot be undone.</ManagementCardContent>
<ManagementCardContent>
Once initiated, this action cannot be undone.
</ManagementCardContent>
<ManagementCardFooter>
<Button className="h-12 gap-x-2" variant="destructive">
<AddIcon />
@ -147,6 +164,12 @@ export default function MyAccount() {
</ManagementCard>
</div>
{/* Dialogs must be near to body as possible to open the modal, otherwise will be restricted to parent height-width */}
<ChangeAvatarForm
open={openModal.changeAvatar}
setOpen={(value: boolean) =>
setModal({ ...openModal, changeAvatar: value })
}
/>
<ChangeEmailForm
open={openModal.changeEmail}
setOpen={(value: boolean) =>
@ -170,7 +193,8 @@ export default function MyAccount() {
);
}
const ChangeEmailSchema = z.object({
const ChangeEmailSchema = z
.object({
email: z.string().email(),
password: z.string(),
retypePassword: z.string(),
@ -210,18 +234,18 @@ const ChangeEmailForm = ({
const data = Object.fromEntries(new FormData(e.currentTarget).entries());
console.log(identity);
updateEmail({
resource: 'users',
resource: "users",
id: identity?.id || "",
values: {
email: data.email.toString()
}
})
}
email: data.email.toString(),
},
});
},
});
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="p-8">
<DialogContent className="p-8" forceMount>
<DialogHeader>
<DialogTitle className="mb-8">Change Email</DialogTitle>
<div className="rounded-full px-4 py-2 w-fit text-sm bg-ring font-bold text-secondary-1">
@ -255,7 +279,8 @@ const ChangeEmailForm = ({
);
};
const ChangePasswordSchema = z.object({
const ChangePasswordSchema = z
.object({
currentPassword: z.string().email(),
newPassword: z.string(),
retypePassword: z.string(),
@ -292,9 +317,8 @@ const ChangePasswordForm = ({
const data = Object.fromEntries(new FormData(e.currentTarget).entries());
updatePassword({
password: data.newPassword.toString()
password: data.newPassword.toString(),
});
},
});
@ -387,9 +411,109 @@ const SetupTwoFactorDialog = ({
);
};
const ChangeAvatarForm = ({
open,
setOpen,
}: {
open: boolean;
setOpen: (value: boolean) => void;
}) => {
const {
getRootProps,
getInputProps,
getFiles,
upload,
state,
removeFile,
cancelAll,
} = useUppy({
uploader: "tus",
endpoint: import.meta.env.VITE_PUBLIC_TUS_ENDPOINT,
});
console.log({ state, files: getFiles() });
const isUploading = state === "uploading";
const isCompleted = state === "completed";
const hasStarted = state !== "idle" && state !== "initializing";
return (
<Dialog
open={open}
onOpenChange={(value) => {
setOpen(value);
}}>
<DialogContent className="p-8">
<DialogHeader className="mb-6">
<DialogTitle>Edit Avatar</DialogTitle>
</DialogHeader>
{!hasStarted && !getFiles().length ? (
<div
{...getRootProps()}
className="border border-border rounded text-primary-2 bg-primary-dark h-48 flex flex-col items-center justify-center">
<input
hidden
aria-hidden
name="uppyFiles[]"
key={new Date().toISOString()}
multiple
{...getInputProps()}
/>
<CloudUploadIcon className="w-24 h-24 stroke stroke-primary-dark" />
<p>Drag & Drop Files or Browse</p>
</div>
) : null}
{(!hasStarted && getFiles().length > 0) && (
<div className="border border-border rounded p-4 bg-primary-dark relative">
<Button
className="absolute top-4 right-4 rounded-full aspect-square bg-primary-dark hover:bg-primary p-2 text-sm"
onClick={() => removeFile(getFiles()[0].id)}>
<Cross2Icon />
</Button>
<img className="w-full h-48" src={URL.createObjectURL(getFiles()[0].data)} alt="New Avatar Preview" />
</div>
)}
{hasStarted ? (
<div className="flex flex-col items-center gap-y-2 w-full text-primary-1">
<CloudCheckIcon className="w-32 h-32" />
{isCompleted
? "Upload completed"
: `0% completed`}
</div>
) : null}
{isUploading ? (
<DialogClose asChild onClick={cancelAll}>
<Button size={"lg"} className="mt-6">
Cancel
</Button>
</DialogClose>
) : null}
{isCompleted ? (
<DialogClose asChild>
<Button size={"lg"} className="mt-6">
Close
</Button>
</DialogClose>
) : null}
{!hasStarted && !isCompleted && !isUploading ? (
<Button size={"lg"} className="mt-6" onClick={upload}>
Upload
</Button>
) : null}
</DialogContent>
</Dialog>
);
};
const PasswordDots = ({ className }: { className?: string }) => {
return (
<svg
aria-hidden="true"
width="219"
height="7"
viewBox="0 0 219 7"

View File

@ -3,6 +3,7 @@ import type { ColumnDef, RowData } from "@tanstack/react-table";
import { FileIcon, MoreIcon } from "~/components/icons";
import { Checkbox } from "~/components/ui/checkbox";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "~/components/ui/dropdown-menu";
import { cn } from "~/utils";
// This type is used to define the shape of our data.
// You can use a Zod schema here if you want.
@ -66,12 +67,13 @@ export const columns: ColumnDef<File>[] = [
accessorKey: "createdOn",
size: 200,
header: "Created On",
cell: ({ row, table }) => (
cell: ({ row }) => (
<div className="flex items-center justify-between">
{row.getValue("createdOn")}
{(row.getIsSelected() || table.options.meta?.hoveredRowId === row.id) && (
<DropdownMenu>
<DropdownMenuTrigger>
<DropdownMenuTrigger className={
cn("hidden group-hover:block data-[state=open]:block", row.getIsSelected() && "block")
}>
<MoreIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
@ -88,7 +90,6 @@ export const columns: ColumnDef<File>[] = [
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
)
}

View File

@ -9,7 +9,7 @@ import { Field, FieldCheckbox } from "~/components/forms"
import { getFormProps, useForm } from "@conform-to/react"
import { z } from "zod"
import { getZodConstraint, parseWithZod } from "@conform-to/zod"
import {useLogin, useRegister} from "@refinedev/core";
import {useLogin, useNotification, useRegister} from "@refinedev/core";
import {AuthFormRequest, RegisterFormRequest} from "~/data/auth-provider.js";
export const meta: MetaFunction = () => {
@ -45,6 +45,7 @@ const RegisterSchema = z
export default function Register() {
const register = useRegister<RegisterFormRequest>()
const login = useLogin<AuthFormRequest>();
const { open } = useNotification();
const [form, fields] = useForm({
id: "register",
constraint: getZodConstraint(RegisterSchema),
@ -62,6 +63,12 @@ export default function Register() {
lastName: data.lastName.toString(),
}, {
onSuccess: () => {
open?.({
type: "success",
message: "Verify your Email",
description: "An Email was sent to your email address. Please verify your email address to activate your account.",
key: "register-success"
})
login.mutate({
email: data.email.toString(),
password: data.password.toString(),

View File

@ -9,6 +9,7 @@ import { Field } from "~/components/forms";
import { getFormProps, useForm } from "@conform-to/react";
import { z } from "zod";
import { getZodConstraint, parseWithZod } from "@conform-to/zod";
import { useToast } from "~/components/ui/use-toast";
export const meta: MetaFunction = () => {
return [{ title: "Sign Up" }];
@ -18,12 +19,23 @@ const RecoverPasswordSchema = z.object({
email: z.string().email(),
});
export default function RecoverPassword() {
const { toast } = useToast();
const [form, fields] = useForm({
id: "sign-up",
constraint: getZodConstraint(RecoverPasswordSchema),
onValidate({ formData }) {
return parseWithZod(formData, { schema: RecoverPasswordSchema });
},
onSubmit(e) {
e.preventDefault();
toast({
title: "Password reset email sent",
description: "Check your email for a link to reset your password. If it doesnt appear within a few minutes, check your spam folder.",
variant: "success",
key: "reset-password-email-sent",
});
}
});
// TODO: another detail is the reset password has no screen to either accept a new pass or

View File

@ -14,7 +14,7 @@
"@conform-to/react": "^1.0.2",
"@conform-to/zod": "^1.0.2",
"@fontsource-variable/manrope": "^5.0.19",
"@lumeweb/portal-sdk": "0.0.0-20240314110748",
"@lumeweb/portal-sdk": "0.0.0-20240318183202",
"@radix-ui/react-avatar": "^1.0.4",
"@radix-ui/react-checkbox": "^1.0.4",
"@radix-ui/react-dialog": "^1.0.5",
@ -24,6 +24,7 @@
"@radix-ui/react-progress": "^1.0.3",
"@radix-ui/react-select": "^2.0.0",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@refinedev/cli": "^2.16.1",
"@refinedev/core": "https://gitpkg.now.sh/LumeWeb/refine/packages/core?remix",
"@refinedev/devtools-internal": "https://gitpkg.now.sh/LumeWeb/refine/packages/devtools-internal?remix",
@ -40,10 +41,10 @@
"class-variance-authority": "^0.7.0",
"clsx": "^2.1.0",
"react": "^18.2.0",
"react-cookie": "^7.1.0",
"react-dom": "^18.2.0",
"tailwind-merge": "^2.2.1",
"tailwindcss-animate": "^1.0.7",
"universal-cookie": "^7.1.0",
"zod": "^3.22.4"
},
"devDependencies": {
@ -62,6 +63,7 @@
"tailwindcss": "^3.4.1",
"typescript": "^5.1.6",
"vite": "^5.1.0",
"vite-plugin-node-polyfills": "^0.21.0",
"vite-tsconfig-paths": "^4.2.1"
},
"engines": {

View File

@ -0,0 +1,13 @@
diff --git a/node_modules/@uppy/tus/lib/index.js b/node_modules/@uppy/tus/lib/index.js
index 1e0a1bb..ba95bb5 100644
--- a/node_modules/@uppy/tus/lib/index.js
+++ b/node_modules/@uppy/tus/lib/index.js
@@ -506,7 +506,7 @@ function _getCompanionClientArgs2(file) {
}
async function _uploadFiles2(files) {
const filesFiltered = filterNonFailedFiles(files);
- const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered);
+ const filesToEmit = filterFilesToEmitUploadStarted(filesFiltered).filter(file => file?.uploader == 'tus');
this.uppy.emit('upload-start', filesToEmit);
await Promise.allSettled(filesFiltered.map(file => {
if (file.isRemote) {

View File

@ -1,6 +1,7 @@
import {vitePlugin as remix} from "@remix-run/dev"
import {defineConfig} from "vite"
import tsconfigPaths from "vite-tsconfig-paths"
import {nodePolyfills} from 'vite-plugin-node-polyfills'
export default defineConfig({
plugins: [
@ -8,8 +9,12 @@ export default defineConfig({
ssr: false,
ignoredRouteFiles: ["**/*.css"]
}),
tsconfigPaths()
tsconfigPaths(),
nodePolyfills({protocolImports: false}),
],
build: {
minify: false
},
server: {
fs: {
// Restrict files that could be served by Vite's dev server. Accessing