Skip to content

Commit

Permalink
feat: add upload file to cell (#62)
Browse files Browse the repository at this point in the history
* chore: update dep

* feat: add file-upload util

* feat: add toast (sonner)

* feat: add file upload for cell

* chore: clean up

* feat: add error toast for unauthenticated user
  • Loading branch information
rin-yato authored Mar 30, 2024
1 parent 634ee41 commit 1f0a06a
Show file tree
Hide file tree
Showing 9 changed files with 207 additions and 48 deletions.
10 changes: 10 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"react": "^18",
"react-dom": "^18",
"react-resizable-panels": "^1.0.9",
"sonner": "^1.4.41",
"sql-query-identifier": "^2.6.0",
"tailwind-merge": "^2.2.0",
"tailwindcss-animate": "^1.0.7",
Expand Down
8 changes: 7 additions & 1 deletion src/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import "./globals.css";
import Script from "next/script";
import ThemeProvider from "@/context/theme-provider";
import { cookies } from "next/headers";
import { Toaster } from "@/components/ui/sonner";
import { Fragment } from "react";

const inter = Inter({ subsets: ["latin"] });

Expand All @@ -26,7 +28,11 @@ export default async function RootLayout({
{process.env.ENABLE_ANALYTIC && (
<Script async defer src="https://scripts.withcabin.com/hello.js" />
)}
<ThemeProvider defaultTheme={theme}>{children}</ThemeProvider>
<ThemeProvider defaultTheme={theme}>
<Fragment>{children}</Fragment>

<Toaster />
</ThemeProvider>
</body>
</html>
);
Expand Down
9 changes: 5 additions & 4 deletions src/components/block-editor/extensions/file-upload.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { uploadFile as uploadUserFile } from "@/lib/utils";
import { uploadFile as uploadUserFile } from "@/lib/file-upload";
import { toast } from "sonner";

export async function uploadFile(file: File) {
const { data, error } = await uploadUserFile(file);

if (error) {
// handle error here, throwing is okay here, it will be caught by the block editor
// TODO: we should toast the error message
console.error(error);
toast.error("Upload failed!", {
description: error.message,
});
throw new Error(error.message);
}

Expand Down
41 changes: 33 additions & 8 deletions src/components/query-result-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
DropdownMenuItem,
DropdownMenuSeparator,
} from "./ui/dropdown-menu";
import { triggerSelectFiles, uploadFile } from "@/lib/file-upload";
import { toast } from "sonner";

interface ResultTableProps {
data: OptimizeTableState;
Expand Down Expand Up @@ -97,7 +99,7 @@ export default function ResultTable({
<DropdownMenuItem
onClick={() => {
setStickHeaderIndex(
header.index === stickyHeaderIndex ? undefined : header.index
header.index === stickyHeaderIndex ? undefined : header.index,
);
}}
>
Expand Down Expand Up @@ -130,7 +132,7 @@ export default function ResultTable({
</Header>
);
},
[stickyHeaderIndex, tableName, onSortColumnChange]
[stickyHeaderIndex, tableName, onSortColumnChange],
);

const renderCell = useCallback(
Expand Down Expand Up @@ -179,7 +181,7 @@ export default function ResultTable({

return <GenericCell value={state.getValue(y, x) as string} />;
},
[]
[],
);

const onHeaderContextMenu = useCallback((e: React.MouseEvent) => {
Expand Down Expand Up @@ -286,6 +288,29 @@ export default function ResultTable({
});
},
},

{
title: "Upload File",
onClick: async () => {
const files = await triggerSelectFiles();

if (files.error) return toast.error(files.error.message);

const file = files.value[0];

const toastId = toast.loading("Uploading file...");
const { data, error } = await uploadFile(file);
if (error)
return toast.error("Upload failed!", {
id: toastId,
description: error.message,
});

setFocusValue(data.url);
return toast.success("File uploaded!", { id: toastId });
},
},

{
separator: true,
},
Expand Down Expand Up @@ -314,7 +339,7 @@ export default function ResultTable({
onClick: () => {
if (state.getSelectedRowCount() > 0) {
window.navigator.clipboard.writeText(
exportRowsToExcel(state.getSelectedRowsArray())
exportRowsToExcel(state.getSelectedRowsArray()),
);
}
},
Expand All @@ -331,8 +356,8 @@ export default function ResultTable({
exportRowsToSqlInsert(
tableName ?? "UnknownTable",
headers,
state.getSelectedRowsArray()
)
state.getSelectedRowsArray(),
),
);
}
},
Expand All @@ -356,7 +381,7 @@ export default function ResultTable({
},
])(event);
},
[data, tableName, copyCallback, pasteCallback, openBlockEditor]
[data, tableName, copyCallback, pasteCallback, openBlockEditor],
);

const onKeyDown = useCallback(
Expand Down Expand Up @@ -410,7 +435,7 @@ export default function ResultTable({

e.preventDefault();
},
[copyCallback, pasteCallback]
[copyCallback, pasteCallback],
);

return (
Expand Down
33 changes: 33 additions & 0 deletions src/components/ui/sonner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
"use client";

import { useTheme } from "@/context/theme-provider";
import { Toaster as Sonner } from "sonner";

type ToasterProps = React.ComponentProps<typeof Sonner>;

const Toaster = ({ ...props }: ToasterProps) => {
const { theme } = useTheme();

return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
toastOptions={{
classNames: {
toast:
"group toast group-[.toaster]:bg-background group-[.toaster]:text-foreground group-[.toaster]:border-border group-[.toaster]:shadow-lg",
description: "group-[.toast]:text-muted-foreground",
actionButton:
"group-[.toast]:bg-primary group-[.toast]:text-primary-foreground",
cancelButton:
"group-[.toast]:bg-muted group-[.toast]:text-muted-foreground",
success: "[&>[data-icon]]:text-green-500",
error: "[&>[data-icon]]:text-red-500",
},
}}
{...props}
/>
);
};

export { Toaster };
107 changes: 107 additions & 0 deletions src/lib/file-upload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { Result, err, ok } from "@justmiracle/result";
import { safeFetch } from "./utils";

export interface TriggerFileUploadOptions {
/**
* Maximum file size in bytes
* @default 10MB
**/
maxSize?: number;

/**
* Maximum number of files that can be selected
* @default 1
**/
maxFiles?: number;

/**
* Accept attribute for the file input
* @default ""
* @see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/file#accept
*
* @example
* // Accepts only images
* accept="image/*"
*
* // Accepts only images and videos
* accept="image/*,video/*"
*
* // Accepts only .jpg files
* accept="image/jpg"
**/
accept?: string;
}

const DEFAULT_ACCEPT = ""; // No restrictions
const DEFAULT_MAX_SIZE = 1024 * 1024 * 10; // 10MB
const DEFAULT_MAX_FILES = 1;

export const triggerSelectFiles = (
options?: TriggerFileUploadOptions,
): Promise<Result<File[]>> => {
const {
maxSize = DEFAULT_MAX_SIZE,
maxFiles = DEFAULT_MAX_FILES,
accept = DEFAULT_ACCEPT,
} = options || {};

return new Promise<Result<File[]>>((resolve, reject) => {
const fileInput = document.createElement("input");
fileInput.type = "file";

// options
fileInput.accept = accept;
fileInput.multiple = maxFiles > 1;

fileInput.onchange = (event) => {
const files = Array.from((event.target as HTMLInputElement).files || []);

if (!files.length) {
return reject(err("No files selected"));
}

// Check if the number of files exceeds the maximum limit
if (maxFiles && files.length > maxFiles) {
return reject(
err("Too many files selected, maximum allowed is " + maxFiles),
);
}

const invalidFileSizes = Array.from(files).some(
(file) => file.size > maxSize,
);

if (invalidFileSizes) {
return reject(
err("Some file's size exceeds the maximum limit of " + maxSize),
);
}

return resolve(ok(Array.from(files)));
};

fileInput.click();
});
};

/**
* Upload a file to the server
*
* @param file - The file to upload
* @returns The url of the uploaded file
*
* @example
*
* const { data } = await uploadFile(file)
* console.log(data?.url)
* // https://r2.example.com/filename.jpg
*/
export async function uploadFile(file: File) {
const formData = new FormData();
formData.append("file", file);

return safeFetch<{ url: string }>("/api/upload", {
method: "POST",
body: formData,
});
}
22 changes: 0 additions & 22 deletions src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,25 +100,3 @@ export async function safeFetch<Success>(
};
}
}

/**
* Upload a file to the server
*
* @param file - The file to upload
* @returns The url of the uploaded file
*
* @example
*
* const { data } = await uploadFile(file)
* console.log(data?.url)
* // https://r2.example.com/filename.jpg
*/
export async function uploadFile(file: File) {
const formData = new FormData();
formData.append("file", file);

return safeFetch<{ url: string }>("/api/upload", {
method: "POST",
body: formData,
});
}
24 changes: 11 additions & 13 deletions src/lib/with-user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,22 +15,20 @@ export default function withUser<ParamsType = unknown>(
handler: WithUserHandler<ParamsType>,
) {
return async function (req: NextRequest, params: ParamsType) {
const session = await getSession();

if (!session) {
return new Response("Unauthorized", { status: HttpStatus.UNAUTHORIZED });
}

const user = session.user;
const sess = session.session;
try {
const { session, user } = await getSession();

if (!user || !sess) {
return new Response("Unauthorized", { status: HttpStatus.UNAUTHORIZED });
}
if (!session || !user) {
throw new ApiError({
message: "You are not authenticated. Please login to continue.",
detailedMessage: "Unauthorized",
status: HttpStatus.UNAUTHORIZED,
});
}

try {
return await handler({ req, user, session: sess, params });
return await handler({ req, user, session, params });
} catch (e) {
// TODO: can extract this to a separate function
if (e instanceof ApiError) {
return new Response(
JSON.stringify({
Expand Down

0 comments on commit 1f0a06a

Please sign in to comment.