fix: UX changes regarding showing dates on table and actions pannel

This commit is contained in:
Juan Di Toro 2024-03-22 13:47:50 +01:00
parent 9d9aa4e9c9
commit e401889b04
10 changed files with 266 additions and 243 deletions

View File

@ -1,8 +1,8 @@
import { useMemo} from "react";
import { BaseRecord } from "@refinedev/core";
import type { BaseRecord } from "@refinedev/core";
import { useTable } from "@refinedev/react-table";
import {
ColumnDef,
type ColumnDef,
flexRender,
} from "@tanstack/react-table";

View File

@ -29,10 +29,10 @@ import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuGr
import { Avatar } from "@radix-ui/react-avatar";
import { cn } from "~/utils";
import { useGetIdentity, useLogout } from "@refinedev/core";
import { Identity } from "~/data/auth-provider";
import type { Identity } from "~/data/auth-provider";
export const GeneralLayout = ({ children }: React.PropsWithChildren<{}>) => {
export const GeneralLayout = ({ children }: React.PropsWithChildren) => {
const location = useLocation();
const { data: identity } = useGetIdentity<Identity>();
const{ mutate: logout } = useLogout()

View File

@ -1,14 +1,21 @@
import Uppy, {debugLogger, type State, UppyFile} from "@uppy/core"
import Uppy, { debugLogger, type State, type UppyFile } from "@uppy/core";
import Tus from "@uppy/tus"
import toArray from "@uppy/utils/lib/toArray"
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 {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, HashProgressEvent} from "@lumeweb/s5-js";
import {
type ChangeEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import DropTarget, { type DropTargetOptions } from "./uppy-dropzone";
import { useSdk } from "~/components/lib/sdk-context";
import UppyFileUpload from "~/components/lib/uppy-file-upload";
import { PROTOCOL_S5, type Sdk } from "@lumeweb/portal-sdk";
import type { S5Client, HashProgressEvent } from "@lumeweb/s5-js";
const LISTENING_EVENTS = [
"upload",
@ -16,46 +23,46 @@ const LISTENING_EVENTS = [
"upload-error",
"file-added",
"file-removed",
"files-added"
] as const
"files-added",
] as const;
export function useUppy() {
const sdk = useSdk()
const sdk = useSdk();
const [uploadLimit, setUploadLimit] = useState<number>(0)
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();
}, []);
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();
}, [sdk.account]);
const inputRef = useRef<HTMLInputElement>(null)
const [targetRef, _setTargetRef] = useState<HTMLElement | null>(null)
const uppyInstance = useRef<Uppy>()
const inputRef = useRef<HTMLInputElement>(null);
const [targetRef, _setTargetRef] = useState<HTMLElement | null>(null);
const uppyInstance = useRef<Uppy>();
const setRef = useCallback(
(element: HTMLElement | null) => _setTargetRef(element),
[]
)
const [, setUppyState] = useState<State>()
[],
);
const [, setUppyState] = useState<State>();
const [state, setState] = useState<
"completed" | "idle" | "initializing" | "error" | "uploading"
>("initializing")
>("initializing");
const [inputProps, setInputProps] = useState<
| {
ref: typeof inputRef
type: "file"
onChange: (event: ChangeEvent<HTMLInputElement>) => void
ref: typeof inputRef;
type: "file";
onChange: (event: ChangeEvent<HTMLInputElement>) => void;
}
| object
>({})
>({});
const getRootProps = useMemo(
() => () => {
return {
@ -63,103 +70,110 @@ export function useUppy() {
onClick: () => {
if (inputRef.current) {
//@ts-expect-error -- dumb html
inputRef.current.value = null
inputRef.current.click()
console.log("clicked", { input: inputRef.current })
inputRef.current.value = null;
inputRef.current.click();
console.log("clicked", { input: inputRef.current });
}
},
role: "presentation"
}
role: "presentation",
};
},
[setRef]
)
[setRef],
);
const removeFile = useCallback(
(id: string) => {
uppyInstance.current?.removeFile(id)
uppyInstance.current?.removeFile(id);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[targetRef, uppyInstance]
)
[targetRef, uppyInstance],
);
const cancelAll = useCallback(
() => uppyInstance.current?.cancelAll({ reason: "user" }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[targetRef, uppyInstance]
)
[targetRef, uppyInstance],
);
useEffect(() => {
if (!targetRef) return
if (!targetRef) return;
const tusPreprocessor = async (fileIDs: string[]) => {
for(const fileID of fileIDs) {
const file = uppyInstance.current?.getFile(fileID) as UppyFile
// @ts-ignore
if (file.uploader === "tus") {
const hashProgressCb = (event: HashProgressEvent) => {
uppyInstance.current?.emit("preprocess-progress", file, {
uploadStarted: false,
bytesUploaded: 0,
preprocess: {
mode: "determinate",
message: "Hashing file...",
value: Math.round((event.total / event.total) * 100)
}
})
}
const options = await sdk.protocols!().get<S5Client>(PROTOCOL_S5).getSdk().getTusOptions(file.data as File, {}, {onHashProgress: hashProgressCb})
uppyInstance.current?.setFileState(fileID, {
tus: options,
meta: {
...options.metadata,
...file.meta,
}
})
}
const tusPreprocessor = async (fileIDs: string[]) => {
for (const fileID of fileIDs) {
const file = uppyInstance.current?.getFile(fileID) as UppyFile;
// @ts-ignore
if (file.uploader === "tus") {
const hashProgressCb = (event: HashProgressEvent) => {
uppyInstance.current?.emit("preprocess-progress", file, {
uploadStarted: false,
bytesUploaded: 0,
preprocess: {
mode: "determinate",
message: "Hashing file...",
value: Math.round((event.total / event.total) * 100),
},
});
};
const options = await sdk.protocols!()
.get<S5Client>(PROTOCOL_S5)
.getSdk()
.getTusOptions(
file.data as File,
{},
{ onHashProgress: hashProgressCb },
);
uppyInstance.current?.setFileState(fileID, {
tus: options,
meta: {
...options.metadata,
...file.meta,
},
});
}
}
};
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";
}
logger: debugLogger,
onBeforeUpload: (files) => {
for (const file of Object.entries(files)) {
// @ts-ignore
file[1].uploader = file[1].size > uploadLimit ? "tus" : "file";
}
return true;
},
return true;
},
}).use(DropTarget, {
target: targetRef
} as DropTargetOptions)
target: targetRef,
} as DropTargetOptions);
uppyInstance.current = uppy
uppyInstance.current = uppy;
setInputProps({
ref: inputRef,
type: "file",
onChange: (event) => {
const files = toArray(event.target.files)
const files = toArray(event.target.files);
if (files.length > 0) {
uppyInstance.current?.log("[DragDrop] Files selected through input")
uppyInstance.current?.addFiles(files)
uppyInstance.current?.log("[DragDrop] Files selected through input");
uppyInstance.current?.addFiles(files);
}
uppy.iteratePlugins((plugin) => {
uppy.removePlugin(plugin);
uppy.removePlugin(plugin);
});
uppy.use(UppyFileUpload, { sdk: sdk as Sdk })
uppy.use(UppyFileUpload, { sdk: sdk as Sdk });
let useTus = false;
let useTus = false;
uppyInstance.current?.getFiles().forEach((file) => {
if (file.size > uploadLimit) {
useTus = true;
}
})
if (useTus) {
uppy.use(Tus, { limit: 6, parallelUploads: 10 })
uppy.addPreProcessor(tusPreprocessor)
uppyInstance.current?.getFiles().forEach((file) => {
if (file.size > uploadLimit) {
useTus = true;
}
});
if (useTus) {
uppy.use(Tus, { limit: 6, parallelUploads: 10 });
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
@ -169,54 +183,52 @@ export function useUppy() {
// Chrome will not trigger change if we drop the same file twice (Issue #768).
// @ts-expect-error TS freaks out, but this is fine
// eslint-disable-next-line no-param-reassign
event.target.value = null
}
})
event.target.value = null;
},
});
uppy.on("complete", (result) => {
if (result.failed.length === 0) {
console.log("Upload successful üòÄ")
setState("completed")
console.log("Upload successful üòÄ");
setState("completed");
} else {
console.warn("Upload failed üòû")
setState("error")
console.warn("Upload failed üòû");
setState("error");
}
console.log("successful files:", result.successful)
console.log("failed files:", result.failed)
})
console.log("successful files:", result.successful);
console.log("failed files:", result.failed);
});
const setStateCb = (event: (typeof LISTENING_EVENTS)[number]) => {
switch (event) {
case "upload":
setState("uploading")
break
setState("uploading");
break;
case "upload-error":
setState("error")
break
setState("error");
break;
default:
break
break;
}
setUppyState(uppy.getState())
}
setUppyState(uppy.getState());
};
for (const event of LISTENING_EVENTS) {
uppy.on(event, function cb() {
setStateCb(event)
})
setStateCb(event);
});
}
setState("idle")
}, [targetRef, uploadLimit])
setState("idle");
}, [targetRef, uploadLimit]);
useEffect(() => {
return () => {
uppyInstance.current?.cancelAll({ reason: "unmount" })
uppyInstance.current?.logout()
uppyInstance.current?.close()
uppyInstance.current = undefined
}
}, [])
uppyInstance.current?.cancelAll({ reason: "unmount" });
uppyInstance.current?.logout();
uppyInstance.current?.close();
uppyInstance.current = undefined;
};
}, []);
return {
getFiles: () => uppyInstance.current?.getFiles() ?? [],
error: uppyInstance.current?.getState,
@ -227,6 +239,6 @@ export function useUppy() {
getInputProps: () => inputProps,
getRootProps,
removeFile,
cancelAll
}
cancelAll,
};
}

View File

@ -60,7 +60,7 @@ const ToastAction = React.forwardRef<
<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",
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-background 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}

View File

@ -14,7 +14,7 @@ export function Toaster() {
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, cancelMutation, ...props }) {
{toasts.map(({ id, title, description, action, cancelMutation, ...props }) => {
const undoButton = cancelMutation ? <ToastAction altText="Undo" onClick={cancelMutation}>Undo</ToastAction> : undefined
return (
<Toast key={id} {...props}>

View File

@ -28,7 +28,7 @@ export function Layout({children}: { children: React.ReactNode }) {
<Meta/>
<Links/>
</head>
<body>
<body className="overflow-hidden">
{children}
<Toaster/>
<ScrollRestoration/>

View File

@ -2,88 +2,94 @@ import { DrawingPinIcon, TrashIcon } from "@radix-ui/react-icons";
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 {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "~/components/ui/dropdown-menu";
import { cn } from "~/utils";
import {FileItem} from "~/data/file-provider.js";
import {format} from "date-fns/fp";
import type { FileItem } from "~/data/file-provider";
declare module '@tanstack/table-core' {
declare module "@tanstack/table-core" {
interface TableMeta<TData extends RowData> {
hoveredRowId: string,
hoveredRowId: string;
}
}
export const columns: ColumnDef<FileItem>[] = [
{
id: "select",
size: 20,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div className="flex items-center gap-x-2">
<FileIcon />
{row.getValue("name")}
</div>
)
},
{
accessorKey: "cid",
header: "CID",
},
{
accessorKey: "size",
header: "Size",
},
{
accessorKey: "pinned",
size: 200,
header: "Pinned On",
cell: ({ row }) => (
<div className="flex items-center justify-between">
{format(row.getValue("pinned")) as unknown as string}
<DropdownMenu>
<DropdownMenuTrigger className={
cn("hidden group-hover:block data-[state=open]:block", row.getIsSelected() && "block")
}>
<MoreIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem>
<DrawingPinIcon className="mr-2" />
Ping CID
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem variant="destructive">
<TrashIcon className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
)
}
{
id: "select",
size: 20,
header: ({ table }) => (
<Checkbox
checked={
table.getIsAllPageRowsSelected() ||
(table.getIsSomePageRowsSelected() && "indeterminate")
}
onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)}
aria-label="Select all"
/>
),
cell: ({ row }) => (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
aria-label="Select row"
/>
),
enableSorting: false,
enableHiding: false,
},
{
accessorKey: "name",
header: "Name",
cell: ({ row }) => (
<div className="flex items-center gap-x-2">
<FileIcon />
{row.getValue("name")}
</div>
),
},
{
accessorKey: "cid",
header: "CID",
},
{
accessorKey: "size",
header: "Size",
},
{
accessorKey: "pinned",
header: "Pinned On",
cell: ({ row }) => new Date(row.getValue("pinned")).toLocaleString(),
},
{
accessorKey: "actions",
header: () => null,
size: 20,
cell: ({ row }) => (
<div className="flex w-5 items-center justify-between">
<DropdownMenu>
<DropdownMenuTrigger
className={cn(
"hidden group-hover:block data-[state=open]:block",
row.getIsSelected() && "block",
)}>
<MoreIcon />
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuGroup>
<DropdownMenuItem variant="destructive">
<TrashIcon className="mr-2" />
Delete
</DropdownMenuItem>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
),
},
];

View File

@ -18,8 +18,8 @@ import { Field } from "~/components/forms";
export default function FileManager() {
return (
<Authenticated key="dashboard" v3LegacyAuthProviderCompatible>
<Dialog>
<GeneralLayout>
<GeneralLayout>
<Dialog>
<h1 className="font-bold mb-4 text-lg">File Manager</h1>
<FileCardList>
<FileCard
@ -67,23 +67,26 @@ export default function FileManager() {
</DialogTrigger>
</div>
<DataTable columns={columns} />
</GeneralLayout>
<DialogHeader>
<DialogTitle>Pinning Contnet</DialogTitle>
</DialogHeader>
<DialogContent>
<form action="" className="w-full flex flex-col gap-y-4">
<Field
inputProps={{ name: "cids", placeholder: "Comma separated CIDs" }}
labelProps={{ htmlFor: "cids", children: "Content to Pin" }}
/>
<DialogContent>
<DialogHeader>
<DialogTitle>Pin Content</DialogTitle>
</DialogHeader>
<form action="" className="w-full flex flex-col gap-y-4">
<Field
inputProps={{
name: "cids",
placeholder: "Comma separated CIDs",
}}
labelProps={{ htmlFor: "cids", children: "Content to Pin" }}
/>
<Button type="submit" className="w-full">
Pin Content
</Button>
</form>
</DialogContent>
</Dialog>
<Button type="submit" className="w-full">
Pin Content
</Button>
</form>
</DialogContent>
</Dialog>
</GeneralLayout>
</Authenticated>
);
}

View File

@ -47,7 +47,7 @@ export default function Login() {
go({ to, type: "push" });
}
}
}, [isAuthLoading, authData]);
}, [isAuthLoading, authData, parsed, go]);
return (
<div className="p-10 h-screen relative">
@ -109,16 +109,18 @@ const LoginForm = () => {
return parseWithZod(formData, { schema: LoginSchema });
},
shouldValidate: "onSubmit",
onSubmit(e) {
onSubmit(e, { submission }) {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget).entries());
login.mutate({
email: data.email.toString(),
password: data.password.toString(),
rememberMe: data.rememberMe.toString() === "on",
redirectTo: parsed.params?.to,
});
if (submission?.status === "success") {
const data = submission.value;
login.mutate({
email: data.email,
password: data.password,
rememberMe: data.rememberMe ?? false,
redirectTo: parsed.params?.to,
});
}
},
});

View File

@ -4,8 +4,8 @@
@layer base {
:root {
--background: 240, 33%, 3%;
--foreground: 0, 0%, 88%;
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 0%;
--card-foreground: 0 0% 3.9%;