diff --git a/app/hooks/use-debounce.ts b/app/hooks/use-debounce.ts new file mode 100644 index 0000000..56b0b97 --- /dev/null +++ b/app/hooks/use-debounce.ts @@ -0,0 +1,20 @@ +import { debounce } from 'lodash-es'; +import { useEffect, useMemo, useRef } from 'react'; + +export const useDebounce = (callback: () => void) => { + const ref = useRef<() => void>(); + + useEffect(() => { + ref.current = callback; + }, [callback]); + + const debouncedCallback = useMemo(() => { + const func = () => { + ref.current?.(); + }; + + return debounce(func, 500); + }, []); + + return debouncedCallback; +}; diff --git a/components/editor/editor-pane.tsx b/components/editor/editor-pane.tsx index 4ddf60b..50ab543 100644 --- a/components/editor/editor-pane.tsx +++ b/components/editor/editor-pane.tsx @@ -1,6 +1,10 @@ +import { useState } from "react" import dynamic from "next/dynamic" -import { SchemaState } from "@/store/main" +import { SchemaState, useMainStore } from "@/store/main" +import { parse, serialize } from "@/lib/json" + +import { ImportDialog } from "./import-dialog" import { EditorMenu } from "./menu" export interface EditorPane { @@ -24,15 +28,25 @@ export const EditorPane = ({ setValueString, ...props }: EditorPane) => { + const [openImportDialog, setOpenImportDialog] = useState(false) + const editorMode = useMainStore( + (state) => state.editors[editorKey].mode ?? state.userSettings.mode + ) return ( <>
-

{heading}

+

{heading}

setOpenImportDialog(true)} + onFormat={() => { + setValueString( + serialize(editorMode, parse(editorMode, value ?? "")) + ) + }} />
+ ) } diff --git a/components/editor/import-dialog.tsx b/components/editor/import-dialog.tsx new file mode 100644 index 0000000..81e9a00 --- /dev/null +++ b/components/editor/import-dialog.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { Button } from "../ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogPortal, +} from "../ui/dialog" +import { Input } from "../ui/input" +import { Label } from "../ui/label" +import { Separator } from "../ui/separator" +import { JSONModes } from '@/types/editor' +import json5 from 'json5' +import { load as parseYaml } from "js-yaml" +import { Check } from 'lucide-react' +import { serialize } from '@/lib/json' +import { SchemaState, useMainStore } from '@/store/main' +import { useDebounce } from '@/app/hooks/use-debounce' +import { isValidUrl } from '@/lib/utils' + +export interface ImportDialogProps { + open: boolean + onOpenChange?: (open: boolean) => void + heading: string + editorKey: keyof SchemaState["editors"] + setValueString: (val: string) => void +} +export const ImportDialog = ({ open, onOpenChange, heading, editorKey, setValueString }: ImportDialogProps) => { + const [imported, setImported] = useState(undefined) + const [importUrl, setImportUrl] = useState(''); + + const editorMode = useMainStore( + (state) => + state.editors[editorKey].mode ?? + state.userSettings.mode + ) + + const debouncedImportUrlRequest = useDebounce(() => { + console.log('fetch import url', importUrl); + if (isValidUrl(importUrl)) { + fetch(importUrl) + .then((res) => res.text()) + .then((text) => { + if (importUrl.includes(JSONModes.JSON5)) { + setImported(json5.parse(text)) + } else if (importUrl.includes("json")) { + setImported(JSON.parse(text)) + } + + if (importUrl.includes("yaml")) { + setImported(parseYaml(text)) + } + }) + .catch((err) => { + console.error(err) + }) + } + }); + + return ( + + + + +
Import {heading} File...
+ +
+
+ + e.stopPropagation()} + onChange={async (e) => { + // TODO: move to zustand + const file = e?.target?.files?.[0] + if (file) { + const fileText = await file.text() + if (file.type.includes(JSONModes.JSON5)) { + setImported(json5.parse(fileText)) + } else if (file.type.includes("json")) { + setImported(JSON.parse(fileText)) + } + + if (file.type.includes("yaml")) { + setImported(parseYaml(fileText)) + } + } + }} + /> + + + { + console.log(e.target.value) + setImportUrl(e.target.value) + debouncedImportUrlRequest() + }} + value={importUrl} + /> + {imported ? ( +
+ + This file can be imported{" "} +
+ ) : null} +
+ + + + + + + + +
+
+
+ ) +} diff --git a/components/editor/json-editor.tsx b/components/editor/json-editor.tsx index 2019882..d714a51 100644 --- a/components/editor/json-editor.tsx +++ b/components/editor/json-editor.tsx @@ -21,7 +21,8 @@ import { JSONModes } from "@/types/editor" import { serialize } from "@/lib/json" // import { debounce } from "@/lib/utils" -import { jsonDark, jsonDarkTheme } from "./theme" +import { jsonDark, jsonDarkTheme, jsonLight, jsonLightTheme, lightHighlightStyle } from "./theme" +import { useTheme } from 'next-themes' /** * none of these are required for json4 or 5 @@ -30,10 +31,8 @@ import { jsonDark, jsonDarkTheme } from "./theme" const commonExtensions = [ history(), autocompletion(), - jsonDark, EditorView.lineWrapping, EditorState.tabSize.of(2), - syntaxHighlighting(oneDarkHighlightStyle), ] const languageExtensions = { @@ -59,7 +58,10 @@ export const JSONEditor = ({ state.editors[editorKey as keyof SchemaState["editors"]].mode ?? state.userSettings.mode ) + const {theme} = useTheme(); const languageExtension = languageExtensions[editorMode](schema) + const themeExtensions = theme === 'light' ? jsonLight : jsonDark; + const editorRef = useRef(null) useEffect(() => { @@ -73,9 +75,9 @@ export const JSONEditor = ({ return ( void menuPrefix?: React.ReactNode menuSuffix?: React.ReactNode + onOpenImportDialog?: () => void + onFormat?: () => void } export const EditorMenu = ({ @@ -46,9 +50,9 @@ export const EditorMenu = ({ menuPrefix, menuSuffix, value, + onOpenImportDialog, + onFormat, }: EditorMenu) => { - const [imported, setImported] = useState(undefined) - const setEditorSetting = useMainStore((state) => state.setEditorSetting) const editorMode = useMainStore( @@ -67,126 +71,42 @@ export const EditorMenu = ({ - - {menuPrefix && menuPrefix} - - setEditorSetting(editorKey, "mode", val)} - > - - JSON4 - - + + {menuPrefix && menuPrefix} + + setEditorSetting(editorKey, "mode", val)} > - JSON5 - - - - - - e.preventDefault()}> - - - Import - - - -
Import {heading} File...
- -
-
- - e.stopPropagation()} - onChange={async (e) => { - // TODO: move to zustand - const file = e?.target?.files?.[0] - if (file) { - const fileText = await file.text() - if (file.type.includes(JSONModes.JSON5)) { - setImported(json5.parse(fileText)) - } else if (file.type.includes('json')) { - setImported(JSON.parse(fileText)) - } - - if (file.type.includes("yaml")) { - setImported(parseYaml(fileText)) - } - } - }} - /> - - - { - // console.log(e.target.value) - // }} - /> - {imported ? ( -
- - This file can be imported{" "} -
- ) : null} -
- - - - - - - - -
-
-
- - Export -
- - - value && setValueString(value)}> - Format - - - {menuSuffix && menuSuffix} -
+ + JSON4 + + + JSON5 + + + + + + Import + Export + + + + onFormat?.()} className='cursor-pointer'> + Format + + + {menuSuffix && menuSuffix} + + ) } diff --git a/components/editor/theme.ts b/components/editor/theme.ts index 40e90d6..4eae801 100644 --- a/components/editor/theme.ts +++ b/components/editor/theme.ts @@ -1,8 +1,8 @@ -import {EditorView} from "@codemirror/view" -import {Extension} from "@codemirror/state" -import {HighlightStyle, syntaxHighlighting} from "@codemirror/language" -import {tags as t} from "@lezer/highlight" -import { createTheme } from '@uiw/codemirror-themes'; +import { HighlightStyle, syntaxHighlighting } from "@codemirror/language" +import { Extension } from "@codemirror/state" +import { EditorView } from "@codemirror/view" +import { tags as t } from "@lezer/highlight" +import { createTheme } from "@uiw/codemirror-themes" // Using https://github.com/one-dark/vscode-one-dark-theme/ as reference for the colors @@ -16,13 +16,13 @@ const chalky = "#e5c07b", sage = "#98c379", whiskey = "#d19a66", violet = "#c678dd", - darkBackground = "rgb(15 23 42 / 1)", - highlightBackground = "rgb(30 41 59 / 1)", // 71 85 105 + darkBackground = "rgb(15 23 42 / 1)", + highlightBackground = "rgb(30 41 59 / 1)", // 71 85 105 background = "rgb(15 23 42 / 1)", tooltipBackground = "rgb(30 41 59 / 1)", selection = "#3E4451", cursor = "#528bff", - borderRadius = '0px'; + borderRadius = "0px" // --tw-bg-opacity: 1; // background-color: rgb(30 41 59 / var(--tw-bg-opacity)); @@ -44,169 +44,356 @@ export const color = { background, tooltipBackground, selection, - cursor + cursor, } /// The editor theme styles for One Dark. -export const oneDarkTheme = EditorView.theme({ +export const oneDarkTheme = EditorView.theme( + { + "&": { + color: ivory, + backgroundColor: background, + borderRadius: borderRadius, + }, + + ".cm-content": { + caretColor: cursor, + }, + + ".cm-cursor, .cm-dropCursor": { borderLeftColor: cursor }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: selection }, + + ".cm-scroller": { borderRadius: borderRadius }, + ".cm-panels": { backgroundColor: darkBackground, color: ivory }, + ".cm-panels.cm-panels-top": { borderBottom: "2px solid black" }, + ".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" }, + + ".cm-searchMatch": { + backgroundColor: "#72a1ff59", + outline: "1px solid #457dff", + }, + ".cm-searchMatch.cm-searchMatch-selected": { + backgroundColor: "#6199ff2f", + }, + + ".cm-activeLine": { backgroundColor: "#6699ff0b" }, + ".cm-selectionMatch": { backgroundColor: "#aafe661a" }, + + "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { + backgroundColor: "#bad0f847", + }, + + ".cm-gutters": { + backgroundColor: background, + color: stone, + border: "none", + }, + + ".cm-activeLineGutter": { + backgroundColor: highlightBackground, + }, + + ".cm-foldPlaceholder": { + backgroundColor: "transparent", + border: "none", + color: "#ddd", + }, + + ".cm-tooltip": { + border: "none", + backgroundColor: tooltipBackground, + }, + ".cm-tooltip .cm-tooltip-arrow:before": { + borderTopColor: "transparent", + borderBottomColor: "transparent", + }, + ".cm-tooltip .cm-tooltip-arrow:after": { + borderTopColor: tooltipBackground, + borderBottomColor: tooltipBackground, + }, + ".cm-tooltip-autocomplete": { + "& > ul > li[aria-selected]": { + backgroundColor: highlightBackground, + color: ivory, + }, + }, + }, + { dark: true } +) + +/// The highlighting style for code in the One Dark theme. +export const oneDarkHighlightStyle = HighlightStyle.define([ + { tag: t.keyword, color: violet }, + { + tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], + color: coral, + }, + { tag: [t.function(t.variableName), t.labelName], color: malibu }, + { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, + { tag: [t.definition(t.name), t.separator], color: ivory }, + { + tag: [ + t.typeName, + t.className, + t.number, + t.changed, + t.annotation, + t.modifier, + t.self, + t.namespace, + ], + color: chalky, + }, + { + tag: [ + t.operator, + t.operatorKeyword, + t.url, + t.escape, + t.regexp, + t.link, + t.special(t.string), + ], + color: cyan, + }, + { tag: [t.meta, t.comment], color: stone }, + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.link, color: stone, textDecoration: "underline" }, + { tag: t.heading, fontWeight: "bold", color: coral }, + { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, + { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, + { tag: t.invalid, color: invalid }, +]) + +/// Extension to enable the One Dark theme (both the editor theme and +/// the highlight style). +export const jsonDark: Extension = [ + oneDarkTheme, + syntaxHighlighting(oneDarkHighlightStyle), +] + +export const jsonDarkTheme = createTheme({ + theme: "dark", + settings: { + background, + backgroundImage: "", + foreground: ivory, + caret: cursor, + selection: selection, + selectionMatch: selection, + lineHighlight: "#6699ff0b", + gutterBackground: background, + gutterForeground: stone, + }, + styles: [ + { tag: t.keyword, color: violet }, + { + tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], + color: coral, + }, + { tag: [t.function(t.variableName), t.labelName], color: malibu }, + { tag: [t.color, t.constant(t.name), t.standard(t.name)], color: whiskey }, + { tag: [t.definition(t.name), t.separator], color: ivory }, + { + tag: [ + t.typeName, + t.className, + t.number, + t.changed, + t.annotation, + t.modifier, + t.self, + t.namespace, + ], + color: chalky, + }, + { + tag: [ + t.operator, + t.operatorKeyword, + t.url, + t.escape, + t.regexp, + t.link, + t.special(t.string), + ], + color: cyan, + }, + { tag: [t.meta, t.comment], color: stone }, + { tag: t.strong, fontWeight: "bold" }, + { tag: t.emphasis, fontStyle: "italic" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.link, color: stone, textDecoration: "underline" }, + { tag: t.heading, fontWeight: "bold", color: coral }, + { tag: [t.atom, t.bool, t.special(t.variableName)], color: whiskey }, + { tag: [t.processingInstruction, t.string, t.inserted], color: sage }, + { tag: t.invalid, color: invalid }, + ], +}) + +const lightColors = { + chalky: "#e5c07b", + coral: "#e06c75", + cyan: "#56b6c2", + invalid: "#ffffff", + ivory: "#abb2bf", + stone: "#7d8799", // Brightened compared to original to increase contrast + malibu: "#61afef", + sage: "#98c379", + whiskey: "#d19a66", + violet: "#c678dd", + // darkBackground: "rgb(15 23 42 / 1)", + highlightBackground: "rgb(30 41 59 / 1)", // 71 85 105 + // background: "rgb(15 23 42 / 1)", + tooltipBackground: "#f0f0f0", + // selection: "#3E4451", + cursor: "#528bff", + borderRadius: "0px", + background: "#fff", + foreground: "#24292e", + selection: "#BBDFFF", + selectionMatch: "#BBDFFF", + gutterBackground: "#fff", + gutterForeground: "#6e7781", +} +/// The editor theme styles for One Dark. +export const lightTheme = EditorView.theme({ "&": { - color: ivory, - backgroundColor: background, - borderRadius: borderRadius, + color: lightColors.foreground, + backgroundColor: lightColors.background, + borderRadius: lightColors.borderRadius, }, ".cm-content": { - caretColor: cursor + caretColor: lightColors.cursor, }, - ".cm-cursor, .cm-dropCursor": {borderLeftColor: cursor}, - "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {backgroundColor: selection}, + ".cm-cursor, .cm-dropCursor": { borderLeftColor: lightColors.cursor }, + "&.cm-focused > .cm-scroller > .cm-selectionLayer .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": + { backgroundColor: lightColors.selection }, - '.cm-scroller': {borderRadius: borderRadius}, - ".cm-panels": {backgroundColor: darkBackground, color: ivory}, - ".cm-panels.cm-panels-top": {borderBottom: "2px solid black"}, - ".cm-panels.cm-panels-bottom": {borderTop: "2px solid black"}, + ".cm-scroller": { borderRadius: lightColors.borderRadius }, + ".cm-panels": { + backgroundColor: lightColors.background, + color: lightColors.foreground, + }, + ".cm-panels.cm-panels-top": { borderBottom: "2px solid black" }, + ".cm-panels.cm-panels-bottom": { borderTop: "2px solid black" }, ".cm-searchMatch": { backgroundColor: "#72a1ff59", - outline: "1px solid #457dff" + outline: "1px solid #457dff", }, ".cm-searchMatch.cm-searchMatch-selected": { - backgroundColor: "#6199ff2f" + backgroundColor: "#6199ff2f", }, - ".cm-activeLine": {backgroundColor: "#6699ff0b"}, - ".cm-selectionMatch": {backgroundColor: "#aafe661a"}, + ".cm-activeLine": { backgroundColor: "#6699ff0b" }, + ".cm-selectionMatch": { backgroundColor: lightColors.selectionMatch }, "&.cm-focused .cm-matchingBracket, &.cm-focused .cm-nonmatchingBracket": { - backgroundColor: "#bad0f847" + backgroundColor: "#bad0f847", }, ".cm-gutters": { - backgroundColor: background, - color: stone, - border: "none" + backgroundColor: lightColors.gutterBackground, + color: lightColors.gutterForeground, + border: "none", }, ".cm-activeLineGutter": { - backgroundColor: highlightBackground + backgroundColor: '#f0f0f0', }, ".cm-foldPlaceholder": { backgroundColor: "transparent", border: "none", - color: "#ddd" + color: "#ddd", }, ".cm-tooltip": { border: "none", - backgroundColor: tooltipBackground + backgroundColor: lightColors.tooltipBackground, }, ".cm-tooltip .cm-tooltip-arrow:before": { borderTopColor: "transparent", - borderBottomColor: "transparent" + borderBottomColor: "transparent", }, ".cm-tooltip .cm-tooltip-arrow:after": { - borderTopColor: tooltipBackground, - borderBottomColor: tooltipBackground + borderTopColor: lightColors.tooltipBackground, + borderBottomColor: lightColors.tooltipBackground, }, ".cm-tooltip-autocomplete": { "& > ul > li[aria-selected]": { - backgroundColor: highlightBackground, - color: ivory - } - } -}, {dark: true}) + backgroundColor: lightColors.highlightBackground, + color: lightColors.foreground, + }, + }, +}) /// The highlighting style for code in the One Dark theme. -export const oneDarkHighlightStyle = HighlightStyle.define([ - {tag: t.keyword, - color: violet}, - {tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], - color: coral}, - {tag: [t.function(t.variableName), t.labelName], - color: malibu}, - {tag: [t.color, t.constant(t.name), t.standard(t.name)], - color: whiskey}, - {tag: [t.definition(t.name), t.separator], - color: ivory}, - {tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], - color: chalky}, - {tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], - color: cyan}, - {tag: [t.meta, t.comment], - color: stone}, - {tag: t.strong, - fontWeight: "bold"}, - {tag: t.emphasis, - fontStyle: "italic"}, - {tag: t.strikethrough, - textDecoration: "line-through"}, - {tag: t.link, - color: stone, - textDecoration: "underline"}, - {tag: t.heading, - fontWeight: "bold", - color: coral}, - {tag: [t.atom, t.bool, t.special(t.variableName)], - color: whiskey }, - {tag: [t.processingInstruction, t.string, t.inserted], - color: sage}, - {tag: t.invalid, - color: invalid}, +export const lightHighlightStyle = HighlightStyle.define([ + { tag: [t.standard(t.tagName), t.tagName], color: "#116329" }, + { tag: [t.comment, t.bracket], color: "#6a737d" }, + { tag: [t.className, t.propertyName], color: "#6f42c1" }, + { + tag: [t.variableName, t.attributeName, t.number, t.operator], + color: "#005cc5", + }, + { + tag: [t.keyword, t.typeName, t.typeOperator, t.typeName], + color: "#d73a49", + }, + { tag: [t.string, t.meta, t.regexp], color: "#032f62" }, + { tag: [t.name, t.quote], color: "#22863a" }, + { tag: [t.heading, t.strong], color: "#24292e", fontWeight: "bold" }, + { tag: [t.emphasis], color: "#24292e", fontStyle: "italic" }, + { tag: [t.deleted], color: "#b31d28", backgroundColor: "ffeef0" }, + { tag: [t.atom, t.bool, t.special(t.variableName)], color: "#e36209" }, + { tag: [t.url, t.escape, t.regexp, t.link], color: "#032f62" }, + { tag: t.link, textDecoration: "underline" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.invalid, color: "#cb2431" }, ]) +export const jsonLight: Extension = [ + lightTheme, + syntaxHighlighting(lightHighlightStyle), +] -/// Extension to enable the One Dark theme (both the editor theme and -/// the highlight style). -export const jsonDark: Extension = [oneDarkTheme, syntaxHighlighting(oneDarkHighlightStyle)] - -export const jsonDarkTheme = createTheme({ - theme: 'dark', +export const jsonLightTheme = createTheme({ + theme: "light", settings: { - background, - backgroundImage: '', - foreground: ivory, - caret: cursor, - selection: selection, - selectionMatch: selection, - lineHighlight: '#6699ff0b', - gutterBackground: background, - gutterForeground: stone, + background: "#fff", + foreground: "#24292e", + selection: "#BBDFFF", + selectionMatch: "#BBDFFF", + gutterBackground: "#fff", + gutterForeground: "#6e7781", }, styles: [ - {tag: t.keyword, - color: violet}, - {tag: [t.name, t.deleted, t.character, t.propertyName, t.macroName], - color: coral}, - {tag: [t.function(t.variableName), t.labelName], - color: malibu}, - {tag: [t.color, t.constant(t.name), t.standard(t.name)], - color: whiskey}, - {tag: [t.definition(t.name), t.separator], - color: ivory}, - {tag: [t.typeName, t.className, t.number, t.changed, t.annotation, t.modifier, t.self, t.namespace], - color: chalky}, - {tag: [t.operator, t.operatorKeyword, t.url, t.escape, t.regexp, t.link, t.special(t.string)], - color: cyan}, - {tag: [t.meta, t.comment], - color: stone}, - {tag: t.strong, - fontWeight: "bold"}, - {tag: t.emphasis, - fontStyle: "italic"}, - {tag: t.strikethrough, - textDecoration: "line-through"}, - {tag: t.link, - color: stone, - textDecoration: "underline"}, - {tag: t.heading, - fontWeight: "bold", - color: coral}, - {tag: [t.atom, t.bool, t.special(t.variableName)], - color: whiskey }, - {tag: [t.processingInstruction, t.string, t.inserted], - color: sage}, - {tag: t.invalid, - color: invalid}, + { tag: [t.standard(t.tagName), t.tagName], color: "#116329" }, + { tag: [t.comment, t.bracket], color: "#6a737d" }, + { tag: [t.className, t.propertyName], color: "#6f42c1" }, + { + tag: [t.variableName, t.attributeName, t.number, t.operator], + color: "#005cc5", + }, + { + tag: [t.keyword, t.typeName, t.typeOperator, t.typeName], + color: "#d73a49", + }, + { tag: [t.string, t.meta, t.regexp], color: "#032f62" }, + { tag: [t.name, t.quote], color: "#22863a" }, + { tag: [t.heading, t.strong], color: "#24292e", fontWeight: "bold" }, + { tag: [t.emphasis], color: "#24292e", fontStyle: "italic" }, + { tag: [t.deleted], color: "#b31d28", backgroundColor: "ffeef0" }, + { tag: [t.atom, t.bool, t.special(t.variableName)], color: "#e36209" }, + { tag: [t.url, t.escape, t.regexp, t.link], color: "#032f62" }, + { tag: t.link, textDecoration: "underline" }, + { tag: t.strikethrough, textDecoration: "line-through" }, + { tag: t.invalid, color: "#cb2431" }, ], -}); +}) diff --git a/lib/utils.ts b/lib/utils.ts index ad4fdcf..c98c9c8 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -27,3 +27,12 @@ export function debounce( return [debouncedFunc, teardown]; } + +export const isValidUrl = (url: string) => { + try { + new URL(url); + return true; + } catch (e) { + return false; + } +}