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 { useMemo} from "react";
import { BaseRecord } from "@refinedev/core"; import type { BaseRecord } from "@refinedev/core";
import { useTable } from "@refinedev/react-table"; import { useTable } from "@refinedev/react-table";
import { import {
ColumnDef, type ColumnDef,
flexRender, flexRender,
} from "@tanstack/react-table"; } from "@tanstack/react-table";

View File

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

View File

@ -60,7 +60,7 @@ const ToastAction = React.forwardRef<
<ToastPrimitives.Action <ToastPrimitives.Action
ref={ref} ref={ref}
className={cn( 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 className
)} )}
{...props} {...props}

View File

@ -14,7 +14,7 @@ export function Toaster() {
return ( return (
<ToastProvider> <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 const undoButton = cancelMutation ? <ToastAction altText="Undo" onClick={cancelMutation}>Undo</ToastAction> : undefined
return ( return (
<Toast key={id} {...props}> <Toast key={id} {...props}>

View File

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

View File

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

View File

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

View File

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

View File

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