diff --git a/crates/web/frontend/package-lock.json b/crates/web/frontend/package-lock.json index eab4be9..7e59a23 100644 --- a/crates/web/frontend/package-lock.json +++ b/crates/web/frontend/package-lock.json @@ -11,6 +11,7 @@ "@codemirror/autocomplete": "^6.18.1", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-yaml": "^6.1.1", + "@codemirror/legacy-modes": "^6.4.2", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-alert-dialog": "^1.1.2", @@ -45,6 +46,7 @@ "lucide-react": "^0.447.0", "next": "14.2.14", "next-themes": "^0.3.0", + "nunjucks": "^3.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-resizable-panels": "^2.1.4", @@ -60,6 +62,7 @@ "devDependencies": { "@biomejs/biome": "1.9.3", "@types/node": "^22.7.4", + "@types/nunjucks": "^3.2.6", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.20", @@ -325,6 +328,15 @@ "style-mod": "^4.0.0" } }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.4.2.tgz", + "integrity": "sha512-HsvWu08gOIIk303eZQCal4H4t65O/qp1V4ul4zVa3MHK5FJ0gz3qz3O55FIkm+aQUcshUOjBx38t2hPiJwW5/g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, "node_modules/@codemirror/lint": { "version": "6.8.2", "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.2.tgz", @@ -1992,6 +2004,13 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/nunjucks": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/@types/nunjucks/-/nunjucks-3.2.6.tgz", + "integrity": "sha512-pHiGtf83na1nCzliuAdq8GowYiXvH5l931xZ0YEHaLMNFgynpEqx+IPStlu7UaDkehfvl01e4x/9Tpwhy7Ue3w==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/prop-types": { "version": "15.7.13", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.13.tgz", @@ -2268,6 +2287,12 @@ "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", "license": "Apache-2.0" }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "license": "MIT" + }, "node_modules/acorn": { "version": "8.12.1", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz", @@ -2395,6 +2420,12 @@ "node": ">=10" } }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "license": "MIT" + }, "node_modules/autoprefixer": { "version": "10.4.20", "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.4.20.tgz", @@ -3954,6 +3985,40 @@ "node": ">=0.10.0" } }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", diff --git a/crates/web/frontend/package.json b/crates/web/frontend/package.json index 3356464..62502ee 100644 --- a/crates/web/frontend/package.json +++ b/crates/web/frontend/package.json @@ -14,6 +14,7 @@ "@codemirror/autocomplete": "^6.18.1", "@codemirror/lang-json": "^6.0.1", "@codemirror/lang-yaml": "^6.1.1", + "@codemirror/legacy-modes": "^6.4.2", "@monaco-editor/react": "^4.6.0", "@radix-ui/react-accordion": "^1.2.1", "@radix-ui/react-alert-dialog": "^1.1.2", @@ -48,6 +49,7 @@ "lucide-react": "^0.447.0", "next": "14.2.14", "next-themes": "^0.3.0", + "nunjucks": "^3.2.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-resizable-panels": "^2.1.4", @@ -63,6 +65,7 @@ "devDependencies": { "@biomejs/biome": "1.9.3", "@types/node": "^22.7.4", + "@types/nunjucks": "^3.2.6", "@types/react": "^18.3.11", "@types/react-dom": "^18.3.0", "autoprefixer": "^10.4.20", diff --git a/crates/web/frontend/src/app/global-error.tsx b/crates/web/frontend/src/app/global-error.tsx index 4a74799..3d44bde 100644 --- a/crates/web/frontend/src/app/global-error.tsx +++ b/crates/web/frontend/src/app/global-error.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { Fira_Mono, Montserrat } from "next/font/google"; import { useEffect } from "react"; import "./globals.css"; -import { deleteDatabase } from "@/services/queries/query-service"; +import { deleteDatabase } from "@/services/history/history-service"; const montserrat = Montserrat({ subsets: ["latin"], variable: "--font-sans" }); const firaCode = Fira_Mono({ diff --git a/crates/web/frontend/src/app/globals.css b/crates/web/frontend/src/app/globals.css index bf4df24..7678643 100644 --- a/crates/web/frontend/src/app/globals.css +++ b/crates/web/frontend/src/app/globals.css @@ -28,7 +28,7 @@ --background: #10151d; --foreground: #e1e5ec; --accent: #959efd; - --accent-subtle: #626da7; + --accent-subtle: #7d89c0; --accent-background: #2e3c51; --muted: #222b37; @@ -106,3 +106,7 @@ h4 { p { @apply text-xs; } + +.cm-editor { + outline: none !important; +} diff --git a/crates/web/frontend/src/app/page-utils.ts b/crates/web/frontend/src/app/page-utils.ts index a8ba13d..5f8ccb0 100644 --- a/crates/web/frontend/src/app/page-utils.ts +++ b/crates/web/frontend/src/app/page-utils.ts @@ -2,8 +2,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 "@/services/shares/share-service"; +import { getShare } from "@/services/share/share-service"; import type { CompletionContext, CompletionSource } from "@codemirror/autocomplete"; +import nunjucks from "nunjucks"; import type PromiseWorker from "webworker-promise"; export const applyGq = async ( @@ -24,6 +25,18 @@ export const applyGq = async ( return result; }; +export const applyTemplate = async ( + inputContent: string, + inputType: FileType, + jinjaContent: string, + silent = true, +): Promise => { + const input = { data: JSON.parse(inputContent) }; + const result = nunjucks.renderString(jinjaContent, input); + !silent && notify.success(`Template applied to ${inputType.toUpperCase()}`); + return result; +}; + const triggerBlacklist = new Set(["{", ":", " "]); export const getQueryCompletionSource = ( diff --git a/crates/web/frontend/src/app/page.tsx b/crates/web/frontend/src/app/page.tsx index f2e8f76..f6b844d 100644 --- a/crates/web/frontend/src/app/page.tsx +++ b/crates/web/frontend/src/app/page.tsx @@ -1,10 +1,11 @@ "use client"; +import ActionButton from "@/components/action-button/action-button"; import Editor from "@/components/editor/editor"; import Footer from "@/components/footer/footer"; import { LeftSidebar } from "@/components/left-sidebar/left-sidebar"; import useDebounce from "@/hooks/use-debounce"; import { notify } from "@/lib/notify"; -import { i } from "@/lib/utils"; +import { cn, i, isMac } from "@/lib/utils"; import { Data } from "@/model/data"; import FileType from "@/model/file-type"; import { type LoadingState, loading, notLoading } from "@/model/loading-state"; @@ -12,10 +13,11 @@ import { setLinkEditors } from "@/model/settings"; import { useSettings } from "@/providers/settings-provider"; import { useWorker } from "@/providers/worker-provider"; import type { CompletionSource } from "@codemirror/autocomplete"; +import { ArrowUp } from "lucide-react"; import { useSearchParams } from "next/navigation"; import { type MutableRefObject, Suspense, useCallback, useEffect, useRef, useState } from "react"; import type PromiseWorker from "webworker-promise"; -import { applyGq, getQueryCompletionSource, importShare } from "./page-utils"; +import { applyGq, applyTemplate, getQueryCompletionSource, importShare } from "./page-utils"; const ShareLoader = ({ updateInputEditorCallback, @@ -61,22 +63,31 @@ const ShareLoader = ({ const Home = () => { const [errorMessage, setErrorMessage] = useState(); + const [templateErrorMessage, setTemplateErrorMessage] = useState(); const [warningMessages, setWarningMessages] = useState([]); const inputContent = useRef(""); const queryContent = useRef(""); + const jinjaContent = useRef(""); + const outputContent = useRef(""); const inputType = useRef(FileType.JSON); const outputType = useRef(FileType.JSON); const convertInputEditorCallback = useRef<(fileType: FileType) => void>(i); const convertOutputEditorCallback = useRef<(fileType: FileType) => void>(i); const outputEditorLoadingCallback = useRef<(loading: LoadingState) => void>(i); + const renderEditorLoadingCallback = useRef<(loading: LoadingState) => void>(i); const updateInputEditorCallback = useRef<(data: Data) => void>(i); const updateQueryEditorCallback = useRef<(data: Data) => void>(i); const updateOutputEditorCallback = useRef<(data: Data) => void>(i); + const updateJinjaEditorCallback = useRef<(data: Data) => void>(i); + const updateRenderEditorCallback = useRef<(data: Data) => void>(i); const [queryCompletionSource, setQueryCompletionSource] = useState(); const [isApplying, setIsApplying] = useState(false); + const [isTemplateApplying, setIsTemplateApplying] = useState(false); const [shareLink, setShareLink] = useState(); const [sidebarOpen, setSidebarOpen] = useState(false); + const [templateOpen, setTemplateOpen] = useState(false); const addNewQueryCallback = useRef<(queryContent: string) => void>(i); + const addNewTemplateCallback = useRef<(templateContent: string) => void>(i); const { settings: { autoApplySettings: { autoApply, debounceTime }, @@ -88,6 +99,41 @@ const Home = () => { const debounce = useDebounce(); const { gqWorker, lspWorker } = useWorker(); + const updateTemplateOutput = useCallback( + async (inputContent: string, inputType: FileType, jinjaContent: string, silent = true) => { + if (!gqWorker || isApplying) return; + setIsTemplateApplying(true); + renderEditorLoadingCallback.current(loading("Applying template...")); + try { + const result = await applyTemplate(inputContent, inputType, jinjaContent, silent); + addNewTemplateCallback.current(jinjaContent); + updateRenderEditorCallback.current(new Data(result, FileType.UNKNOWN)); + setTemplateErrorMessage(undefined); + } catch (err) { + setTemplateErrorMessage(err.message); + } finally { + setIsTemplateApplying(false); + renderEditorLoadingCallback.current(notLoading()); + } + }, + [gqWorker, isApplying], + ); + + const handleChangeTemplateContent = useCallback( + (content: string) => { + autoApply && + debounce(debounceTime, () => + updateTemplateOutput( + outputContent.current, + outputType.current, + content, + debounceTime < 500, + ), + ); + }, + [autoApply, debounce, updateTemplateOutput, debounceTime], + ); + const updateOutputData = useCallback( async ( inputContent: string, @@ -114,6 +160,7 @@ const Home = () => { addNewQueryCallback.current(queryContent); setErrorMessage(undefined); updateOutputEditorCallback.current(result); + handleChangeTemplateContent(jinjaContent.current); } catch (err) { setErrorMessage(err.message); setWarningMessages([]); @@ -122,7 +169,7 @@ const Home = () => { outputEditorLoadingCallback.current(notLoading()); } }, - [gqWorker, dataTabSize, isApplying], + [gqWorker, dataTabSize, isApplying, handleChangeTemplateContent], ); const handleClickExample = useCallback( @@ -135,10 +182,15 @@ const Home = () => { [updateOutputData], ); - const handleClickQuery = useCallback((queryContent: string) => { + const handleClickHistoryQuery = useCallback((queryContent: string) => { updateQueryEditorCallback.current(new Data(queryContent, FileType.GQ)); }, []); + const handleClickHistoryTemplate = useCallback((templateContent: string) => { + setTemplateOpen(true); + updateJinjaEditorCallback.current(new Data(templateContent, FileType.JINJA)); + }, []); + const handleChangeInputDataFileType = useCallback( (fileType: FileType) => { setShareLink(undefined); @@ -186,14 +238,28 @@ const Home = () => { [autoApply, debounce, updateOutputData, debounceTime], ); + const handleKeyDown = useCallback((e: KeyboardEvent) => { + if ((isMac ? e.metaKey : e.ctrlKey) && (e.key === "m" || e.key === "M")) { + e.preventDefault(); + setTemplateOpen((prev) => !prev); + } + }, []); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + return (
{ shareLink={shareLink} setShareLink={setShareLink} /> -
-
+
+
{ +const ApplyQueryButton = ({ className, onClick }: Props) => { if (!onClick) return null; const { @@ -20,7 +20,7 @@ const ApplyButton = ({ className, onClick }: Props) => { const handleKeyDown = useCallback( (e: KeyboardEvent) => { - if ((isMac ? e.metaKey : e.ctrlKey) && e.key === "Enter") { + if ((isMac ? e.metaKey : e.ctrlKey) && !e.altKey && e.key === "Enter") { e.preventDefault(); onClick(); } @@ -51,4 +51,4 @@ const ApplyButton = ({ className, onClick }: Props) => { ); }; -export default ApplyButton; +export default ApplyQueryButton; diff --git a/crates/web/frontend/src/components/apply-template-button/apply-template-button.tsx b/crates/web/frontend/src/components/apply-template-button/apply-template-button.tsx new file mode 100644 index 0000000..86605b0 --- /dev/null +++ b/crates/web/frontend/src/components/apply-template-button/apply-template-button.tsx @@ -0,0 +1,54 @@ +import ActionButton from "@/components/action-button/action-button"; +import { cn, isMac } from "@/lib/utils"; +import { useSettings } from "@/providers/settings-provider"; +import { CirclePlay, Play } from "lucide-react"; +import { useCallback, useEffect } from "react"; + +interface Props { + onClick?: () => void; + className?: string; +} + +const ApplyTemplateButton = ({ className, onClick }: Props) => { + if (!onClick) return null; + + const { + settings: { + autoApplySettings: { autoApply }, + }, + } = useSettings(); + + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if ((isMac ? e.metaKey : e.ctrlKey) && e.altKey && e.key === "Enter") { + e.preventDefault(); + onClick(); + } + }, + [onClick], + ); + + useEffect(() => { + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleKeyDown]); + + return autoApply ? ( + + + + ) : ( + + + + ); +}; +export default ApplyTemplateButton; diff --git a/crates/web/frontend/src/components/dump-block/dump-block.tsx b/crates/web/frontend/src/components/dump-block/dump-block.tsx new file mode 100644 index 0000000..6086e0a --- /dev/null +++ b/crates/web/frontend/src/components/dump-block/dump-block.tsx @@ -0,0 +1,72 @@ +import { notify } from "@/lib/notify"; +import { cn } from "@/lib/utils"; +import { Redo, Trash } from "lucide-react"; +import ActionButton from "../action-button/action-button"; + +interface Props extends React.HTMLAttributes { + onDump: () => void; + onDumpMessage?: string; + lines: number; + onDelete?: () => void; + onDeleteMessage?: string; +} + +export const DumpBlock = ({ + onDump, + onDumpMessage, + lines, + onDelete, + onDeleteMessage, + children, + className, + ...props +}: Props) => { + const handleDump = () => { + onDumpMessage && notify.success(onDumpMessage); + onDump(); + }; + + const handleDelete = () => { + onDeleteMessage && notify.success(onDeleteMessage); + onDelete?.(); + }; + + return ( +
+ {children} +
2 ? "group-hover:max-w-10" : "group-hover:max-w-20 flex", + )} + > + 2 ? "right" : "bottom"} + containerClassName={cn( + "min-h-10 flex items-center justify-center border-l", + lines > 2 && onDelete ? "h-1/2 border-b" : "h-full", + )} + className="h-full w-10 border-0" + description="Dump content into the editor" + onClick={handleDump} + > + + + {onDelete && ( + 2 ? "right" : "bottom"} + containerClassName={cn( + "min-h-10 flex items-center justify-center border-l", + lines > 2 ? "h-1/2" : "h-full", + )} + className="h-full w-10 border-0" + description="Delete from history" + onClick={handleDelete} + > + + + )} +
+
+ ); +}; diff --git a/crates/web/frontend/src/components/editor-overlay/editor-overlay.module.css b/crates/web/frontend/src/components/editor-overlay/editor-overlay.module.css index bc02443..40f1231 100644 --- a/crates/web/frontend/src/components/editor-overlay/editor-overlay.module.css +++ b/crates/web/frontend/src/components/editor-overlay/editor-overlay.module.css @@ -61,9 +61,11 @@ &[data-visible="true"] { opacity: 1; + pointer-events: all; } &[data-visible="false"] { opacity: 0; + pointer-events: none; } } diff --git a/crates/web/frontend/src/components/editor/editor-menu.tsx b/crates/web/frontend/src/components/editor/editor-menu.tsx index 5c8347f..610c365 100644 --- a/crates/web/frontend/src/components/editor/editor-menu.tsx +++ b/crates/web/frontend/src/components/editor/editor-menu.tsx @@ -3,10 +3,11 @@ import ExportPopover from "@/components/export-popover/export-popover"; import ImportPopup from "@/components/import-popup/import-popup"; import { cn } from "@/lib/utils"; import type { Data } from "@/model/data"; -import type FileType from "@/model/file-type"; +import FileType from "@/model/file-type"; import type { LoadingState } from "@/model/loading-state"; import { Braces, Clipboard } from "lucide-react"; -import ApplyButton from "../apply-button/apply-button"; +import ApplyQueryButton from "../apply-query-button/apply-query-button"; +import ApplyTemplateButton from "../apply-template-button/apply-template-button"; interface Props { currentType: FileType; @@ -66,7 +67,11 @@ const EditorMenu = ({ onExportFile={onExportFile} className={cn(onApply && "border-r")} /> - + {currentType === FileType.GQ ? ( + + ) : ( + + )}
); }; diff --git a/crates/web/frontend/src/components/editor/editor-utils.ts b/crates/web/frontend/src/components/editor/editor-utils.ts index 63d2ff4..326ad44 100644 --- a/crates/web/frontend/src/components/editor/editor-utils.ts +++ b/crates/web/frontend/src/components/editor/editor-utils.ts @@ -13,11 +13,13 @@ import { yaml } from "@codemirror/lang-yaml"; import { LRLanguage, LanguageSupport, + StreamLanguage, continuedIndent, foldInside, foldNodeProp, indentNodeProp, } from "@codemirror/language"; +import { jinja2 } from "@codemirror/legacy-modes/mode/jinja2"; import { parser } from "@lezer/json"; import { EditorView, type Extension, Prec, keymap } from "@uiw/react-codemirror"; import type PromiseWorker from "webworker-promise"; @@ -29,7 +31,7 @@ export const exportFile = (data: Data, filename: string) => { const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; - a.download = `${filename}.${data.type}`; + a.download = `${filename}${data.type === FileType.UNKNOWN ? "" : data.type}`; a.click(); URL.revokeObjectURL(url); notify.success("File exported successfully!"); @@ -92,9 +94,12 @@ const gqLanguageParser = LRLanguage.define({ const jsonLanguage = json(); const gqLanguage = new LanguageSupport(gqLanguageParser); const yamlLanguage = yaml(); +const jinjaLanguage = StreamLanguage.define(jinja2); const modKey = isMac ? "Cmd" : "Ctrl"; -const getCodemirrorLanguageByFileType = (fileType: FileType): LanguageSupport => { +const getCodemirrorLanguageByFileType = ( + fileType: FileType, +): LanguageSupport | StreamLanguage | undefined => { switch (fileType) { case FileType.JSON: return jsonLanguage; @@ -102,8 +107,10 @@ const getCodemirrorLanguageByFileType = (fileType: FileType): LanguageSupport => return gqLanguage; case FileType.YAML: return yamlLanguage; + case FileType.JINJA: + return jinjaLanguage; default: - throw new Error("Invalid file type"); + return undefined; } }; @@ -120,6 +127,9 @@ export const getCodemirrorExtensionsByFileType = ( completionSource?: CompletionSource, ): Extension[] => { const language = getCodemirrorLanguageByFileType(fileType); + if (!language) { + return []; + } switch (fileType) { case FileType.JSON: case FileType.YAML: @@ -129,6 +139,12 @@ export const getCodemirrorExtensionsByFileType = ( Prec.highest(keymap.of([{ key: `${modKey}-Enter`, run: () => true }])), getDragAndDropExtension([FileType.JSON, FileType.YAML]), ]; + case FileType.JINJA: + return [ + language, + Prec.highest(keymap.of([{ key: `${modKey}-Enter`, run: () => true }])), + getDragAndDropExtension([FileType.JINJA]), + ]; case FileType.GQ: return [ language, @@ -148,6 +164,6 @@ export const getCodemirrorExtensionsByFileType = ( getDragAndDropExtension([FileType.GQ]), ]; default: - throw new Error("Invalid file type"); + return []; } }; diff --git a/crates/web/frontend/src/components/editor/editor.tsx b/crates/web/frontend/src/components/editor/editor.tsx index d705949..95fde9f 100644 --- a/crates/web/frontend/src/components/editor/editor.tsx +++ b/crates/web/frontend/src/components/editor/editor.tsx @@ -90,11 +90,11 @@ const Editor = ({ const available = content.length < MAX_RENDER_SIZE; const handleFormatCode = useCallback( - async (cont: string, type: FileType) => { - if (!formatWorker || loadingState.isLoading || cont === "") return; + async (content: string, type: FileType) => { + if (!formatWorker || !editable || loadingState.isLoading || content === "") return; setLoadingState(loading("Formatting code...")); try { - const data = new Data(cont, type); + const data = new Data(content, type); const result = await formatCode(data, indentSize, formatWorker); setEditorErrorMessage(undefined); setContent(result.content); @@ -104,7 +104,7 @@ const Editor = ({ setLoadingState(notLoading); } }, - [indentSize, formatWorker, loadingState, setContent], + [indentSize, formatWorker, loadingState, setContent, editable], ); const handleImportFile = useCallback( diff --git a/crates/web/frontend/src/components/export-popover/export-popover.tsx b/crates/web/frontend/src/components/export-popover/export-popover.tsx index 97ae53d..fe4a606 100644 --- a/crates/web/frontend/src/components/export-popover/export-popover.tsx +++ b/crates/web/frontend/src/components/export-popover/export-popover.tsx @@ -66,9 +66,11 @@ const ExportPopover = ({ defaultFilename, fileType, onExportFile, className }: P value={fileName} onChange={(e) => setFileName(e.target.value)} /> - - .{getFileExtensions(fileType)[0]} - + {getFileExtensions(fileType).length > 0 && ( + + .{getFileExtensions(fileType)[0]} + + )} + )} + + ); +}; interface Props { onClickQuery: (queryContent: string) => void; + onClickTemplate: (templateContent: string) => void; addNewQueryCallback: MutableRefObject<(queryContent: string) => void>; + addNewTemplateCallback: MutableRefObject<(templateContent: string) => void>; className?: string; } -const HistoryTab = ({ onClickQuery, addNewQueryCallback, className }: Props) => { +const HistoryTab = ({ + onClickQuery, + onClickTemplate, + addNewQueryCallback, + addNewTemplateCallback, + className, +}: Props) => { const [search, setSearch] = useState(""); - const [currentPage, setCurrentPage] = useState(0); - const [hasMore, setHasMore] = useState(false); - const [queries, setQueries] = useState([]); + const [currentQueriesPage, setCurrentQueriesPage] = useState(0); + const [currentTemplatesPage, setCurrentTemplatesPage] = useState(0); + const [hasMoreQueries, setHasMoreQueries] = useState(false); + const [hasMoreTemplates, setHasMoreTemplates] = useState(false); + const [queries, setQueries] = useState([]); + const [templates, setTemplates] = useState([]); const debounce = useDebounce(); const handleSearch = useCallback(async (value: string) => { - setCurrentPage(0); - const [matchingQueries, hasMore] = await getPaginatedQueries(0, HISTORY_PAGE_SIZE, value); - setHasMore(hasMore); + setCurrentQueriesPage(0); + setCurrentTemplatesPage(0); + const [matchingQueries, hasMore] = await getPaginatedQueries(0, QUERY_HISTORY_PAGE_SIZE, value); + const [matchingTemplates, hasMoreTemplates] = await getPaginatedTemplates( + 0, + TEMPLATE_HISTORY_PAGE_SIZE, + value, + ); + setHasMoreQueries(hasMore); + setHasMoreTemplates(hasMoreTemplates); setQueries(matchingQueries); + setTemplates(matchingTemplates); }, []); const handleAddNewQuery = useCallback( @@ -53,33 +145,68 @@ const HistoryTab = ({ onClickQuery, addNewQueryCallback, className }: Props) => [queries], ); - const handleLoadMore = useCallback(async () => { - const nextPage = currentPage + 1; + const handleLoadMoreQueries = useCallback(async () => { + const nextPage = currentQueriesPage + 1; const [matchingQueries, hasMore] = await getPaginatedQueries( nextPage, - HISTORY_PAGE_SIZE, + QUERY_HISTORY_PAGE_SIZE, search, ); - setCurrentPage(nextPage); - setHasMore(hasMore); + setCurrentQueriesPage(nextPage); + setHasMoreQueries(hasMore); setQueries((prevQueries) => [...prevQueries, ...matchingQueries]); - }, [currentPage, search]); + }, [currentQueriesPage, search]); + + const handleAddNewTemplate = useCallback( + async (content: string) => { + const [addedTemplate, deletedTemplate] = await addTemplate(content); + const newTemplates = deletedTemplate + ? templates.filter((template) => template.id !== deletedTemplate.id) + : templates; + addedTemplate && newTemplates.unshift(addedTemplate); + setTemplates([...newTemplates]); + }, + [templates], + ); + + const handleDeleteTemplate = useCallback( + async (id: number) => { + await deleteTemplate(id); + setTemplates(templates.filter((template) => template.id !== id)); + }, + [templates], + ); + + const handleLoadMoreTemplates = useCallback(async () => { + const nextPage = currentTemplatesPage + 1; + const [matchingTemplates, hasMore] = await getPaginatedTemplates( + nextPage, + TEMPLATE_HISTORY_PAGE_SIZE, + search, + ); + setCurrentTemplatesPage(nextPage); + setHasMoreTemplates(hasMore); + setTemplates((prevTemplates) => [...prevTemplates, ...matchingTemplates]); + }, [currentTemplatesPage, search]); useEffect(() => debounce(200, () => handleSearch(search)), [search, debounce, handleSearch]); useEffect(() => { addNewQueryCallback.current = handleAddNewQuery; - }, [handleAddNewQuery, addNewQueryCallback]); + addNewTemplateCallback.current = handleAddNewTemplate; + }, [handleAddNewQuery, addNewQueryCallback, handleAddNewTemplate, addNewTemplateCallback]); return ( - Query history - Check the previous queries you have made. + History + + Check the previous queries and templates you have made. +
e.preventDefault()}> setSearch(e.target.value)} value={search} placeholder="Type to search..." @@ -96,71 +223,43 @@ const HistoryTab = ({ onClickQuery, addNewQueryCallback, className }: Props) => - {Object.entries(groupQueries(queries)).map((entry) => ( -
-
- {capitalize(entry[0])} -
- - {entry[1].map((query) => ( - - -
2 - ? "group-hover:max-w-10" - : "group-hover:max-w-20 flex", - )} - > - 2 ? "right" : "bottom"} - containerClassName={cn( - "min-h-10 flex items-center justify-center border-l", - countLines(query.content) > 2 ? "h-1/2 border-b" : "h-full", - )} - className="h-full w-10 border-0" - description="Dump query into the editor" - onClick={() => onClickQuery(query.content)} - > - - - 2 ? "right" : "bottom"} - containerClassName={cn( - "min-h-10 flex items-center justify-center border-l", - countLines(query.content) > 2 ? "h-1/2" : "h-full", - )} - className="h-full w-10 border-0" - description="Delete from history" - onClick={() => handleDeleteQuery(query.id)} - > - - -
-
- ))} -
-
- ))} - {hasMore && ( - - )} + + + + + Queries + + + + + Templates + + + + +
); }; diff --git a/crates/web/frontend/src/components/left-sidebar/left-sidebar.tsx b/crates/web/frontend/src/components/left-sidebar/left-sidebar.tsx index bc24948..133a0af 100644 --- a/crates/web/frontend/src/components/left-sidebar/left-sidebar.tsx +++ b/crates/web/frontend/src/components/left-sidebar/left-sidebar.tsx @@ -2,16 +2,17 @@ import { useOnboarding } from "@/hooks/use-onboarding"; import { cn, isMac } from "@/lib/utils"; import type { Data } from "@/model/data"; import type FileType from "@/model/file-type"; -import { BookMarked, History, Settings, Share } from "lucide-react"; +import { BookMarked, History, Settings, Share, SquareDashed } from "lucide-react"; import { type MutableRefObject, useCallback, useEffect, useState } from "react"; import ActionButton from "../action-button/action-button"; import ExamplesTab from "../examples-tab/examples-tab"; import HistoryTab from "../history-tab/history-tab"; import SettingsTab from "../settings-tab/settings-tab"; import ShareTab from "../share-tab/share-tab"; +import { TemplatesTab } from "../templates-tab/templates-tab"; import ThemeButton from "../theme-button/theme-button"; -type Tab = "examples" | "share" | "history" | "settings"; +type Tab = "examples" | "share" | "history" | "templates" | "settings"; interface Props { open: boolean; @@ -19,7 +20,9 @@ interface Props { className?: string; onClickExample: (json: Data, query: Data) => void; onClickQuery: (queryContent: string) => void; + onClickTemplate: (templateContent: string) => void; addNewQueryCallback: MutableRefObject<(queryContent: string) => void>; + addNewTemplateCallback: MutableRefObject<(templateContent: string) => void>; inputContent: MutableRefObject; inputType: MutableRefObject; queryContent: MutableRefObject; @@ -33,7 +36,9 @@ export const LeftSidebar = ({ setOpen, onClickExample, onClickQuery, + onClickTemplate, addNewQueryCallback, + addNewTemplateCallback, inputContent, inputType, queryContent, @@ -122,6 +127,24 @@ export const LeftSidebar = ({ )} /> + handleClick("templates")} + > + + @@ -159,12 +182,18 @@ export const LeftSidebar = ({ + diff --git a/crates/web/frontend/src/components/share-tab/share-tab-utils.ts b/crates/web/frontend/src/components/share-tab/share-tab-utils.ts index 95e89cd..a0018ce 100644 --- a/crates/web/frontend/src/components/share-tab/share-tab-utils.ts +++ b/crates/web/frontend/src/components/share-tab/share-tab-utils.ts @@ -2,7 +2,7 @@ import { notify } from "@/lib/notify"; import { ShareTooLargeError } from "@/model/errors/share-input-too-large-error"; import { type ExpirationTime, toSeconds } from "@/model/expiration-time"; import type FileType from "@/model/file-type"; -import { createShare } from "@/services/shares/share-service"; +import { createShare } from "@/services/share/share-service"; export const createShareLink = async ( inputContent: string, diff --git a/crates/web/frontend/src/components/shortcut-popup/shortcut-popup.tsx b/crates/web/frontend/src/components/shortcut-popup/shortcut-popup.tsx index a056310..2792898 100644 --- a/crates/web/frontend/src/components/shortcut-popup/shortcut-popup.tsx +++ b/crates/web/frontend/src/components/shortcut-popup/shortcut-popup.tsx @@ -33,7 +33,7 @@ const ShortcutPopup = ({ className }: Props) => { - + setOpen(false)} diff --git a/crates/web/frontend/src/components/shortcut-popup/shortcuts.tsx b/crates/web/frontend/src/components/shortcut-popup/shortcuts.tsx index b7f7f55..6261cc5 100644 --- a/crates/web/frontend/src/components/shortcut-popup/shortcuts.tsx +++ b/crates/web/frontend/src/components/shortcut-popup/shortcuts.tsx @@ -16,10 +16,18 @@ const globalShortcuts = (isMac: boolean) => [ description: "Apply the current query", shortcut: `${isMac ? "⌘" : "Ctrl"} + Enter`, }, + { + description: "Apply the current template", + shortcut: `${isMac ? "⌘" : "Ctrl"} + Alt + Enter`, + }, { description: "Toggle the left sidebar", shortcut: `${isMac ? "⌘" : "Ctrl"} + B`, }, + { + description: "Toggle template editors", + shortcut: `${isMac ? "⌘" : "Ctrl"} + M`, + }, ]; const editorShortcuts = (isMac: boolean) => [ diff --git a/crates/web/frontend/src/components/templates-tab/templates-tab.tsx b/crates/web/frontend/src/components/templates-tab/templates-tab.tsx new file mode 100644 index 0000000..bc419e2 --- /dev/null +++ b/crates/web/frontend/src/components/templates-tab/templates-tab.tsx @@ -0,0 +1,75 @@ +import { gqThemeInit } from "@/lib/theme"; +import { countLines } from "@/lib/utils"; +import FileType from "@/model/file-type"; +import CodeMirror, { type Extension } from "@uiw/react-codemirror"; +import { Dot, Info } from "lucide-react"; +import { useMemo } from "react"; +import { DumpBlock } from "../dump-block/dump-block"; +import { getCodemirrorExtensionsByFileType } from "../editor/editor-utils"; +import { SidebarContent, SidebarDescription, SidebarHeader, SidebarTitle } from "../ui/sidebar"; +import { templates } from "./templates"; + +interface Props { + onClickTemplate: (templateContent: string) => void; + className?: string; +} + +export const TemplatesTab = ({ className, onClickTemplate }: Props) => { + const extensions: Extension[] = useMemo( + () => getCodemirrorExtensionsByFileType(FileType.JINJA), + [], + ); + + return ( + + + Templates + Explore useful templates to transform your data + + {templates.map((template) => ( +
+
+ {template.title} +

{template.description}

+
+ onClickTemplate(template.content)} + onDumpMessage="Template dumped into the editor" + > + + +
+
+ +

Notes

+
+
+ {template.notes.map((note) => ( +
+ + + {note} + +
+ ))} +
+
+
+ ))} +
+ ); +}; diff --git a/crates/web/frontend/src/components/templates-tab/templates.ts b/crates/web/frontend/src/components/templates-tab/templates.ts new file mode 100644 index 0000000..7d3dceb --- /dev/null +++ b/crates/web/frontend/src/components/templates-tab/templates.ts @@ -0,0 +1,77 @@ +interface Template { + title: string; + description: string; + content: string; + notes: string[]; +} + +export const templates: Template[] = [ + { + title: "Generic CSV", + description: "Convert your output array into a CSV file", + content: `{# Headers #} +{%- for key, value in data[0] -%} +{{ key }}{% if not loop.last %},{% endif %} +{%- endfor -%} + +{# Content #} +{% for item in data -%} +{% for key, value in item -%} +{{ value }}{% if not loop.last %},{% endif %} +{%- endfor -%} +{% if not loop.last %} +{% endif %} +{%- endfor %}`, + notes: [ + "The content int the output editor must be an array", + "Each object in the array must only contain primitive values", + "All of them should have the same keys", + ], + }, + { + title: "Generic XML", + description: "Generic", + content: ` + {%- for item in data %} + + {%- for key, value in item %} + <{{ key }}>{{ value }} + {%- endfor %} + + {%- endfor %} +`, + notes: ["Template development in progress"], + }, + // { + // title: "Generic Jinja XML", + // description: "Generic", + // content: `{% macro render_element(key, value, indent=0) -%} + // {%- set current_indent = ' ' * indent -%} + // {%- if value is mapping -%} + // {{ current_indent }}<{{ key }}> + // {%- for k, v in value.items() %} + // {{ render_element(k, v, indent + 1) }} + // {%- endfor %} + // {{ current_indent }} + // {%- elif value is sequence and value is not string -%} + // {{ current_indent }}<{{ key }}> + // {%- for item in value %} + // {{ current_indent }} + // {%- if item is mapping -%} + // {%- for k, v in item.items() %} + // {{ render_element(k, v, indent + 2) }} + // {%- endfor %} + // {%- else %} + // {{ current_indent }} {{ item }} + // {%- endif %} + // {{ current_indent }} + // {%- endfor %} + // {{ current_indent }} + // {%- else -%} + // {{ current_indent }}<{{ key }}>{{ value }} + // {%- endif %} + // {%- endmacro -%} + // {{ render_element("data", data) }}`, + // notes: [], + // }, +]; diff --git a/crates/web/frontend/src/components/ui/button.module.css b/crates/web/frontend/src/components/ui/button.module.css index 22821d4..942289c 100644 --- a/crates/web/frontend/src/components/ui/button.module.css +++ b/crates/web/frontend/src/components/ui/button.module.css @@ -1,7 +1,7 @@ .ripple { position: absolute; border-radius: 50%; - background-image: radial-gradient(circle, var(--accent) 10%, var(--background) 100%); + background-image: radial-gradient(circle, var(--accent-subtle) 10%, var(--background) 100%); animation: ripple 0.5s ease-out forwards; } diff --git a/crates/web/frontend/src/lib/constants.ts b/crates/web/frontend/src/lib/constants.ts index 504dc87..d799a9f 100644 --- a/crates/web/frontend/src/lib/constants.ts +++ b/crates/web/frontend/src/lib/constants.ts @@ -11,6 +11,11 @@ export const STATE_DEBOUNCE_TIME = 50; export const MAX_RENDER_SIZE = 100000000; // 100MB // Query history -export const MAX_HISTORY_SIZE = 10000; -export const HISTORY_PAGE_SIZE = 50; -export const MAX_QUERY_SIZE = 2000; // 2KB +export const MAX_QUERY_HISTORY_SIZE = 10000; +export const QUERY_HISTORY_PAGE_SIZE = 50; +export const MAX_HISTORY_QUERY_SIZE = 2000; // 2KB + +// Template history +export const MAX_TEMPLATE_HISTORY_SIZE = 10000; +export const TEMPLATE_HISTORY_PAGE_SIZE = 50; +export const MAX_HISTORY_TEMPLATE_SIZE = 2000; // 2KB diff --git a/crates/web/frontend/src/lib/theme.ts b/crates/web/frontend/src/lib/theme.ts index 3a13001..a7c6638 100644 --- a/crates/web/frontend/src/lib/theme.ts +++ b/crates/web/frontend/src/lib/theme.ts @@ -25,7 +25,6 @@ export function gqThemeInit(options?: Partial) { styles: [ { tag: [ - t.keyword, t.operatorKeyword, t.modifier, t.color, @@ -39,6 +38,10 @@ export function gqThemeInit(options?: Partial) { ], color: "var(--foreground)", }, + { + tag: [t.keyword], + color: "var(--success)", + }, { tag: [t.controlKeyword, t.moduleKeyword], color: "var(--foreground)", @@ -92,7 +95,8 @@ export function gqThemeInit(options?: Partial) { { tag: t.strong, fontWeight: "bold" }, { tag: t.emphasis, fontStyle: "italic" }, { tag: t.strikethrough, textDecoration: "line-through" }, - { tag: [t.meta, t.comment], color: "var(--foreground)" }, + { tag: [t.meta], color: "var(--foreground)" }, + { tag: [t.comment], color: "var(--accent-subtle)" }, { tag: t.link, color: "var(--foreground)", textDecoration: "underline" }, { tag: t.invalid, color: "var(--foreground)" }, { tag: t.punctuation, color: "var(--code-primary)" }, diff --git a/crates/web/frontend/src/model/file-type.ts b/crates/web/frontend/src/model/file-type.ts index d0daf0a..df0b0f1 100644 --- a/crates/web/frontend/src/model/file-type.ts +++ b/crates/web/frontend/src/model/file-type.ts @@ -2,6 +2,8 @@ enum FileType { JSON = "json", GQ = "gq", YAML = "yaml", + JINJA = "jinja", + UNKNOWN = "render", } export const getFileExtensions = (fileType: FileType): string[] => { @@ -12,6 +14,10 @@ export const getFileExtensions = (fileType: FileType): string[] => { return ["gq"]; case FileType.YAML: return ["yml", "yaml"]; + case FileType.JINJA: + return ["jinja"]; + case FileType.UNKNOWN: + return []; } }; @@ -22,6 +28,9 @@ export const fromMimeType = (mime: string): FileType => { if (mime.startsWith("application/yaml")) { return FileType.YAML; } + if (mime.startsWith("application/jinja")) { + return FileType.JINJA; + } if (mime === "") { return FileType.GQ; } diff --git a/crates/web/frontend/src/model/user-query.ts b/crates/web/frontend/src/model/history-item.ts similarity index 65% rename from crates/web/frontend/src/model/user-query.ts rename to crates/web/frontend/src/model/history-item.ts index c5e24cc..9303ce3 100644 --- a/crates/web/frontend/src/model/user-query.ts +++ b/crates/web/frontend/src/model/history-item.ts @@ -1,4 +1,4 @@ -export type UserQuery = { +export type HistoryItem = { id: number; timestamp: number; content: string; diff --git a/crates/web/frontend/src/services/history/history-service.ts b/crates/web/frontend/src/services/history/history-service.ts new file mode 100644 index 0000000..b769e8e --- /dev/null +++ b/crates/web/frontend/src/services/history/history-service.ts @@ -0,0 +1,145 @@ +import { MAX_QUERY_HISTORY_SIZE, MAX_TEMPLATE_HISTORY_SIZE } from "@/lib/constants"; +import type { HistoryItem } from "@/model/history-item"; +import { type IDBPDatabase, deleteDB, openDB } from "idb"; + +const DB_NAME = "gq"; +const DB_VERSION = 2; +const QUERIES_STORE_NAME = "queries"; +const TEMPLATES_STORE_NAME = "templates"; + +let dbConnection: Promise; + +const createStore = (db: IDBPDatabase, storeName: string) => { + if (!db.objectStoreNames.contains(storeName)) { + const store = db.createObjectStore(storeName, { keyPath: "id", autoIncrement: true }); + store.createIndex("timestamp", "timestamp", { unique: false }); + store.createIndex("content", "content", { unique: false }); + } +}; + +const getDatabase = (): Promise => { + if (!dbConnection) { + dbConnection = openDB(DB_NAME, DB_VERSION, { + upgrade(db) { + createStore(db, QUERIES_STORE_NAME); + createStore(db, TEMPLATES_STORE_NAME); + }, + }); + } + return dbConnection; +}; + +export const addQuery = async ( + content: string, +): Promise<[HistoryItem | undefined, HistoryItem | undefined]> => { + return addItem(QUERIES_STORE_NAME, MAX_QUERY_HISTORY_SIZE, content); +}; + +export const addTemplate = async ( + content: string, +): Promise<[HistoryItem | undefined, HistoryItem | undefined]> => { + return addItem(TEMPLATES_STORE_NAME, MAX_TEMPLATE_HISTORY_SIZE, content); +}; + +export const getPaginatedQueries = async ( + page: number, + limit: number, + query?: string, + reverse = true, +): Promise<[HistoryItem[], hasMore: boolean]> => { + return getPaginatedItems(QUERIES_STORE_NAME, page, limit, query, reverse); +}; + +export const getPaginatedTemplates = async ( + page: number, + limit: number, + query?: string, + reverse = true, +): Promise<[HistoryItem[], hasMore: boolean]> => { + return getPaginatedItems(TEMPLATES_STORE_NAME, page, limit, query, reverse); +}; + +export const deleteQuery = async (id: number) => { + await deleteItem(QUERIES_STORE_NAME, id); +}; + +export const deleteTemplate = async (id: number) => { + await deleteItem(TEMPLATES_STORE_NAME, id); +}; + +const addItem = async ( + storeName: string, + maxSize: number, + content: string, +): Promise<[HistoryItem | undefined, HistoryItem | undefined]> => { + if (!content || content.length > maxSize) return [undefined, undefined]; + const newestItem = (await getPaginatedItems(storeName, 0, 1))[0][0]; + if (newestItem?.content === content) return [undefined, undefined]; // Avoid adding consecutive duplicated items + let oldestItem: HistoryItem | undefined; + if ((await countItems(storeName)) >= maxSize) { + oldestItem = (await getPaginatedItems(storeName, 0, 1, undefined, false))[0][0]; + oldestItem && (await deleteItem(storeName, oldestItem.id)); + } + const database = await getDatabase(); + const tx = database.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + const now = Date.now(); + const key = await store.add({ timestamp: now, content }); + await tx.done; + return [{ id: Number(key.valueOf()), timestamp: now, content }, oldestItem]; +}; + +const getPaginatedItems = async ( + storeName: string, + page: number, + limit: number, + search?: string, + reverse = true, +): Promise<[HistoryItem[], hasMore: boolean]> => { + const database = await getDatabase(); + const tx = database.transaction(storeName, "readonly"); + const store = tx.store; + const index = store.index("timestamp"); + + const items: HistoryItem[] = []; + let cursor = await index.openCursor(null, reverse ? "prev" : "next"); + let skipped = 0; + let hasMore = false; + + while (cursor && items.length <= limit) { + const matches = !search || cursor.value.content.toLowerCase().includes(search.toLowerCase()); + if (items.length === limit && matches) { + hasMore = true; + break; + } + if (matches && skipped >= page * limit) { + items.push(cursor.value); + } + matches && skipped++; + cursor = await cursor.continue(); + } + + await tx.done; + return [items, hasMore]; +}; + +const deleteItem = async (storeName: string, id: number) => { + const database = await getDatabase(); + const tx = database.transaction(storeName, "readwrite"); + const store = tx.objectStore(storeName); + await store.delete(id); + await tx.done; +}; + +const countItems = async (storeName: string): Promise => { + const database = await getDatabase(); + const tx = database.transaction(storeName, "readonly"); + const store = tx.objectStore(storeName); + const count = await store.count(); + await tx.done; + return count; +}; + +export const deleteDatabase = async () => { + await deleteDB(DB_NAME); +}; diff --git a/crates/web/frontend/src/services/queries/query-service.ts b/crates/web/frontend/src/services/queries/query-service.ts deleted file mode 100644 index eb7289b..0000000 --- a/crates/web/frontend/src/services/queries/query-service.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { MAX_HISTORY_SIZE, MAX_QUERY_SIZE } from "@/lib/constants"; -import type { UserQuery } from "@/model/user-query"; -import { type IDBPDatabase, deleteDB, openDB } from "idb"; - -const DB_NAME = "gq"; -const DB_VERSION = 1; -const STORE_NAME = "queries"; - -let dbConnection: Promise; - -export const getDatabase = (): Promise => { - if (!dbConnection) { - dbConnection = openDB(DB_NAME, DB_VERSION, { - upgrade(db) { - if (!db.objectStoreNames.contains(STORE_NAME)) { - const store = db.createObjectStore(STORE_NAME, { keyPath: "id", autoIncrement: true }); - store.createIndex("timestamp", "timestamp", { unique: false }); - store.createIndex("content", "content", { unique: false }); - } - }, - }); - } - return dbConnection; -}; - -export const addQuery = async ( - content: string, -): Promise<[UserQuery | undefined, UserQuery | undefined]> => { - if (!content || content.length > MAX_QUERY_SIZE) return [undefined, undefined]; - const newestQuery = (await getPaginatedQueries(0, 1))[0][0]; - if (newestQuery?.content === content) return [undefined, undefined]; // Avoid adding consecutive duplicated queries - let oldestQuery: UserQuery | undefined; - if ((await countQueries()) >= MAX_HISTORY_SIZE) { - oldestQuery = (await getPaginatedQueries(0, 1, undefined, false))[0][0]; - oldestQuery && (await deleteQuery(oldestQuery.id)); - } - const database = await getDatabase(); - const tx = database.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - const now = Date.now(); - const key = await store.add({ timestamp: now, content }); - await tx.done; - return [{ id: Number(key.valueOf()), timestamp: now, content }, oldestQuery]; -}; - -export const getPaginatedQueries = async ( - page: number, - limit: number, - query?: string, - reverse = true, -): Promise<[UserQuery[], hasMore: boolean]> => { - const database = await getDatabase(); - const tx = database.transaction(STORE_NAME, "readonly"); - const store = tx.store; - const index = store.index("timestamp"); - - const items: UserQuery[] = []; - let cursor = await index.openCursor(null, reverse ? "prev" : "next"); - let skipped = 0; - let hasMore = false; - - while (cursor && items.length <= limit) { - const matches = !query || cursor.value.content.toLowerCase().includes(query.toLowerCase()); - if (items.length === limit && matches) { - hasMore = true; - break; - } - if (matches && skipped >= page * limit) { - items.push(cursor.value); - } - matches && skipped++; - cursor = await cursor.continue(); - } - - await tx.done; - return [items, hasMore]; -}; - -export const deleteQuery = async (id: number) => { - const database = await getDatabase(); - const tx = database.transaction(STORE_NAME, "readwrite"); - const store = tx.objectStore(STORE_NAME); - await store.delete(id); - await tx.done; -}; - -export const deleteDatabase = async () => { - await deleteDB(DB_NAME); -}; - -const countQueries = async (): Promise => { - const database = await getDatabase(); - const tx = database.transaction(STORE_NAME, "readonly"); - const store = tx.objectStore(STORE_NAME); - const count = await store.count(); - await tx.done; - return count; -}; diff --git a/crates/web/frontend/src/services/shares/file-type-dto.ts b/crates/web/frontend/src/services/share/file-type-dto.ts similarity index 100% rename from crates/web/frontend/src/services/shares/file-type-dto.ts rename to crates/web/frontend/src/services/share/file-type-dto.ts diff --git a/crates/web/frontend/src/services/shares/share-dto.ts b/crates/web/frontend/src/services/share/share-dto.ts similarity index 100% rename from crates/web/frontend/src/services/shares/share-dto.ts rename to crates/web/frontend/src/services/share/share-dto.ts diff --git a/crates/web/frontend/src/services/shares/share-service.ts b/crates/web/frontend/src/services/share/share-service.ts similarity index 100% rename from crates/web/frontend/src/services/shares/share-service.ts rename to crates/web/frontend/src/services/share/share-service.ts