diff --git a/crates/web/frontend/src/app/page-utils.ts b/crates/web/frontend/src/app/page-utils.ts index bfff8385..d5bd4b6c 100644 --- a/crates/web/frontend/src/app/page-utils.ts +++ b/crates/web/frontend/src/app/page-utils.ts @@ -1,9 +1,9 @@ +import { notify } from "@/lib/notify"; import type { Completion } from "@/model/completion"; import { Data } from "@/model/data"; import FileType from "@/model/file-type"; import { getShare } from "@/service/share-service"; import type { CompletionContext, CompletionSource } from "@codemirror/autocomplete"; -import { toast } from "sonner"; import type PromiseWorker from "webworker-promise"; export const applyGq = async ( @@ -20,7 +20,7 @@ export const applyGq = async ( outputType: outputType, indent: indent, }); - !silent && toast.success(`Query applied to ${inputData.type.toUpperCase()}`); + !silent && notify.success(`Query applied to ${inputData.type.toUpperCase()}`); return result; }; @@ -53,17 +53,19 @@ export const getQueryCompletionSource = ( }; }; -export const importShare = async (shareId: string): Promise<{ input: Data; query: Data }> => { - const toastId = toast.loading("Importing share..."); +export const importShare = async ( + shareId: string, +): Promise<{ input: Data; query: Data } | undefined> => { + const toastId = notify.loading("Importing share..."); try { const share = await getShare(shareId); - toast.success("Share successfully imported", { id: toastId }); - return Promise.resolve({ + notify.success("Share successfully imported", { id: toastId }); + return { input: new Data(share.json, FileType.JSON), query: new Data(share.query, FileType.GQ), - }); + }; } catch (error) { - toast.error(`Error importing share: ${error.message}`, { id: toastId, duration: 5000 }); - return Promise.reject(error); + notify.error(`Error importing share: ${error.message}`, { id: toastId }); + return undefined; } }; diff --git a/crates/web/frontend/src/app/page.tsx b/crates/web/frontend/src/app/page.tsx index 36438d67..9b67eff1 100644 --- a/crates/web/frontend/src/app/page.tsx +++ b/crates/web/frontend/src/app/page.tsx @@ -6,6 +6,7 @@ import Editor from "@/components/editor/editor"; import Footer from "@/components/footer/footer"; import Header from "@/components/header/header"; import useDebounce from "@/hooks/useDebounce"; +import { notify } from "@/lib/notify"; import { cn, i } from "@/lib/utils"; import { Data } from "@/model/data"; import FileType from "@/model/file-type"; @@ -17,7 +18,6 @@ import type { CompletionSource } from "@codemirror/autocomplete"; import { Link2, Link2Off } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { type MutableRefObject, Suspense, useCallback, useEffect, useRef, useState } from "react"; -import { toast } from "sonner"; import { applyGq, getQueryCompletionSource, importShare } from "./page-utils"; import styles from "./page.module.css"; @@ -32,12 +32,11 @@ const ShareLoader = ({ useEffect(() => { if (!shareId) return; - importShare(shareId) - .then((data) => { - updateInputEditorCallback?.current(data.input); - updateQueryEditorCallback?.current(data.query); - }) - .catch(() => {}); + importShare(shareId).then((data) => { + if (!data) return; + updateInputEditorCallback?.current(data.input); + updateQueryEditorCallback?.current(data.query); + }); }, [shareId, updateInputEditorCallback, updateQueryEditorCallback]); return null; @@ -105,7 +104,7 @@ const Home = () => { updateInputEditorCallback.current(json); updateQueryEditorCallback.current(query); updateOutputData(json.content, json.type, query.content, true); - toast.success("Example loaded!"); + notify.success("Example loaded!"); }, [updateOutputData], ); @@ -122,9 +121,8 @@ const Home = () => { const handleChangeLinked = useCallback(() => { setSettings((prev) => setLinkEditors(prev, !linkEditors)); - toast.info(`${linkEditors ? "Unlinked" : "Linked"} editors!`); - if (linkEditors) return; - convertOutputEditorCallback.current(inputType.current); + notify.info(`${linkEditors ? "Unlinked" : "Linked"} editors!`); + if (!linkEditors) convertOutputEditorCallback.current(inputType.current); }, [linkEditors, setSettings]); const handleChangeInputContent = useCallback( diff --git a/crates/web/frontend/src/components/editor/editor-utils.ts b/crates/web/frontend/src/components/editor/editor-utils.ts index 595fd2ee..63d2ff48 100644 --- a/crates/web/frontend/src/components/editor/editor-utils.ts +++ b/crates/web/frontend/src/components/editor/editor-utils.ts @@ -1,3 +1,4 @@ +import { notify } from "@/lib/notify"; import { isMac } from "@/lib/utils"; import type { Data } from "@/model/data"; import FileType from "@/model/file-type"; @@ -19,7 +20,6 @@ import { } from "@codemirror/language"; import { parser } from "@lezer/json"; import { EditorView, type Extension, Prec, keymap } from "@uiw/react-codemirror"; -import { toast } from "sonner"; import type PromiseWorker from "webworker-promise"; import { validateFile } from "../import-popup/import-utils"; import urlPlugin from "./url-plugin"; @@ -32,7 +32,7 @@ export const exportFile = (data: Data, filename: string) => { a.download = `${filename}.${data.type}`; a.click(); URL.revokeObjectURL(url); - toast.success("File exported successfully!"); + notify.success("File exported successfully!"); }; export const formatCode = async ( @@ -41,13 +41,13 @@ export const formatCode = async ( formatWorker: PromiseWorker, silent = true, ): Promise => { - const toastId = silent ? undefined : toast.loading("Formatting code..."); + const toastId = silent ? undefined : notify.loading("Formatting code..."); try { const response: Data = await formatWorker.postMessage({ data, indent }); - !silent && toast.success("Code formatted!", { id: toastId }); + !silent && notify.success("Code formatted!", { id: toastId }); return response; } catch (err) { - !silent && toast.error(err.message, { id: toastId, duration: 5000 }); + !silent && notify.error(err.message, { id: toastId }); throw err; } }; @@ -59,13 +59,13 @@ export const convertCode = async ( convertWorker: PromiseWorker, silent = true, ): Promise => { - const toastId = silent ? undefined : toast.loading("Converting code..."); + const toastId = silent ? undefined : notify.loading("Converting code..."); try { const result: Data = await convertWorker.postMessage({ data, outputType, indent }); - !silent && toast.success("Code converted!", { id: toastId }); + !silent && notify.success("Code converted!", { id: toastId }); return result; } catch (err) { - !silent && toast.error(err.message, { id: toastId, duration: 5000 }); + !silent && notify.error(err.message, { id: toastId }); throw err; } }; diff --git a/crates/web/frontend/src/components/import-popup/import-utils.ts b/crates/web/frontend/src/components/import-popup/import-utils.ts index 8b062cd4..4bd6b942 100644 --- a/crates/web/frontend/src/components/import-popup/import-utils.ts +++ b/crates/web/frontend/src/components/import-popup/import-utils.ts @@ -1,3 +1,4 @@ +import { notify } from "@/lib/notify"; import { statusTextMap } from "@/lib/utils"; import { Data } from "@/model/data"; import type FileType from "@/model/file-type"; @@ -25,14 +26,14 @@ export const validateFile = ( const error = new Error( `Files of type ${importedFileType} cannot be imported into this editor`, ); - toast.error(error.message); + notify.error(error.message); onError?.(error); return; } onSuccess?.({ f: file, type: importedFileType }); } catch { const error = new Error(`Unable to import files of type ${file.type}`); - toast.error(error.message); + notify.error(error.message); onError?.(error); } }; diff --git a/crates/web/frontend/src/components/share-popover/share-popover-utils.ts b/crates/web/frontend/src/components/share-popover/share-popover-utils.ts index a08f768a..2121f1a2 100644 --- a/crates/web/frontend/src/components/share-popover/share-popover-utils.ts +++ b/crates/web/frontend/src/components/share-popover/share-popover-utils.ts @@ -1,6 +1,7 @@ -import { ExpirationTime, toSeconds } from "@/model/expiration-time"; +import { notify } from "@/lib/notify"; +import { ShareTooLargeError } from "@/model/errors/share-input-too-large-error"; +import { type ExpirationTime, toSeconds } from "@/model/expiration-time"; import { createShare } from "@/service/share-service"; -import { toast } from "sonner"; export const createShareLink = async ( inputContent: string, @@ -9,11 +10,15 @@ export const createShareLink = async ( ): Promise => { try { const shareId = await createShare(inputContent, queryContent, toSeconds(expirationTime)); - toast.success("Share link created!"); + notify.success("Share link created!"); const shareLink = `${window.location.origin}?id=${shareId}`; return Promise.resolve(shareLink); } catch (err) { - toast.error(`An error occurred while creating the share link: ${err.message}`); + if (err instanceof ShareTooLargeError) { + notify.error(err.message); + } else { + notify.error(`Unexpected error while creating the share link: ${err.message}`); + } return Promise.reject(err); } }; diff --git a/crates/web/frontend/src/components/share-popover/share-popover.tsx b/crates/web/frontend/src/components/share-popover/share-popover.tsx index 443fbbfd..7783927b 100644 --- a/crates/web/frontend/src/components/share-popover/share-popover.tsx +++ b/crates/web/frontend/src/components/share-popover/share-popover.tsx @@ -1,5 +1,6 @@ import { cn, copyToClipboard } from "@/lib/utils"; -import { Clipboard, Clock, InfoIcon, Link, Share } from "lucide-react"; +import type { ExpirationTime } from "@/model/expiration-time"; +import { Clipboard, Clock, Share } from "lucide-react"; import { useCallback, useState } from "react"; import ActionButton from "../action-button/action-button"; import { Button } from "../ui/button"; @@ -16,10 +17,7 @@ import { import { RadioGroup, RadioGroupItem } from "../ui/radio-group"; import { Separator } from "../ui/separator"; import { Loader } from "../ui/sonner"; -import { ExpirationTime } from "@/model/expiration-time"; import { createShareLink } from "./share-popover-utils"; -import { Data } from "@/model/data"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; interface SharePopoverProps { inputContent: string; diff --git a/crates/web/frontend/src/lib/constants.ts b/crates/web/frontend/src/lib/constants.ts new file mode 100644 index 00000000..54c45460 --- /dev/null +++ b/crates/web/frontend/src/lib/constants.ts @@ -0,0 +1 @@ +export const MAX_SHARE_SIZE = 2000000; // 2MB diff --git a/crates/web/frontend/src/lib/notify.ts b/crates/web/frontend/src/lib/notify.ts new file mode 100644 index 00000000..38441c9c --- /dev/null +++ b/crates/web/frontend/src/lib/notify.ts @@ -0,0 +1,24 @@ +import { type ExternalToast, toast } from "sonner"; + +const loading = (message: string | React.ReactNode, data?: ExternalToast): string | number => { + return toast.loading(message, data); +}; + +const success = (message: string | React.ReactNode, data?: ExternalToast): string | number => { + return toast.success(message, { ...data, duration: 2000 }); +}; + +const info = (message: string | React.ReactNode, data?: ExternalToast): string | number => { + return toast.info(message, { ...data, duration: 2000 }); +}; + +const error = (message: string | React.ReactNode, data?: ExternalToast): string | number => { + return toast.error(message, { ...data, duration: 5000 }); +}; + +export const notify = { + loading, + success, + info, + error, +}; diff --git a/crates/web/frontend/src/lib/utils.ts b/crates/web/frontend/src/lib/utils.ts index d8dda3bd..c9e60b25 100644 --- a/crates/web/frontend/src/lib/utils.ts +++ b/crates/web/frontend/src/lib/utils.ts @@ -1,6 +1,6 @@ import { type ClassValue, clsx } from "clsx"; -import { toast } from "sonner"; import { twMerge } from "tailwind-merge"; +import { notify } from "./notify"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -31,7 +31,7 @@ export const isMac = navigator.platform.includes("Mac"); // Deprecated navigator export const copyToClipboard = (content: string) => { navigator.clipboard.writeText(content); - toast.success("Copied to your clipboard!"); + notify.success("Copied to your clipboard!"); }; export const statusTextMap = new Map([ diff --git a/crates/web/frontend/src/model/errors/share-input-too-large-error.ts b/crates/web/frontend/src/model/errors/share-input-too-large-error.ts new file mode 100644 index 00000000..4922d6e4 --- /dev/null +++ b/crates/web/frontend/src/model/errors/share-input-too-large-error.ts @@ -0,0 +1,5 @@ +export class ShareTooLargeError extends Error { + constructor() { + super("The playground content is larger than the maximum allowed size (2MB)"); + } +} diff --git a/crates/web/frontend/src/providers/worker-provider.tsx b/crates/web/frontend/src/providers/worker-provider.tsx index e7a266ee..eaa293b0 100644 --- a/crates/web/frontend/src/providers/worker-provider.tsx +++ b/crates/web/frontend/src/providers/worker-provider.tsx @@ -26,10 +26,10 @@ interface Props { } export const WorkerProvider = ({ children }: Props) => { - const [formatWorker, setFormatWorker] = useState(undefined); - const [gqWorker, setGqWorker] = useState(undefined); - const [lspWorker, setLspWorker] = useState(undefined); - const [convertWorker, setConvertWorker] = useState(undefined); + const [formatWorker, setFormatWorker] = useState(); + const [gqWorker, setGqWorker] = useState(); + const [lspWorker, setLspWorker] = useState(); + const [convertWorker, setConvertWorker] = useState(); useEffect(() => { setFormatWorker(new PromiseWorker(new Worker(new URL("../worker/format.ts", import.meta.url)))); diff --git a/crates/web/frontend/src/service/share-service.ts b/crates/web/frontend/src/service/share-service.ts index 8bda8177..af82503f 100644 --- a/crates/web/frontend/src/service/share-service.ts +++ b/crates/web/frontend/src/service/share-service.ts @@ -1,3 +1,5 @@ +import { MAX_SHARE_SIZE } from "@/lib/constants"; +import { ShareTooLargeError } from "@/model/errors/share-input-too-large-error"; import { type Share, ShareSchema } from "@/model/share"; import type { ZodSchema } from "zod"; @@ -13,7 +15,9 @@ export const createShare = async ( queryContent: string, expirationTimeSecs: number, ): Promise => { - console.log("createShare", inputContent, queryContent, expirationTimeSecs); + if (inputContent.length + queryContent.length > MAX_SHARE_SIZE) { + throw new ShareTooLargeError(); + } const res = await fetch(sharesEndpoint, { method: "POST", body: JSON.stringify({ json: inputContent, query: queryContent, expirationTimeSecs }),