Repository: prostgles/ui Branch: master Commit: 90ad3340a3a3 Files: 1271 Total size: 5.9 MB Directory structure: gitextract_slssybq4/ ├── .dockerignore ├── .eslintrc.common.js ├── .gitattributes ├── .github/ │ ├── ISSUE_TEMPLATE/ │ │ ├── BUG.yml │ │ ├── CUSTOM.yml │ │ ├── FEATURE.yml │ │ ├── bug_report.md │ │ ├── config.yml │ │ ├── custom.md │ │ └── feature_request.md │ └── workflows/ │ ├── docker_test.yml │ ├── electron_build_linux.yml │ ├── electron_build_macos.yml │ ├── electron_build_windows.yml │ ├── electron_linux_test.yml │ ├── electron_macos_test.yml │ ├── linux_test.yml │ ├── macos_test.yml │ ├── on_release.yml │ ├── package_version_increased.yml │ └── windows_test.yml ├── .gitignore ├── .prettierignore ├── .prettierrc ├── .vscode/ │ └── settings.json ├── DB-Dockerfile ├── Dockerfile ├── LICENSE ├── PRIVACY ├── README.md ├── SECURITY.md ├── changelog/ │ ├── v1.0.0.md │ ├── v2.0.0.md │ ├── v2.2.0.md │ ├── v2.2.1.md │ ├── v2.2.2.md │ ├── v2.2.3.md │ └── v2.2.4.md ├── client/ │ ├── .babelrc │ ├── .gitignore │ ├── build.sh │ ├── eslint.config.mjs │ ├── package.json │ ├── public/ │ │ ├── manifest.json │ │ └── robots.txt │ ├── setup-icons.js │ ├── src/ │ │ ├── App.css │ │ ├── App.tsx │ │ ├── Testing.ts │ │ ├── WithPrgl.tsx │ │ ├── app/ │ │ │ ├── CommandPalette/ │ │ │ │ ├── CommandPalette.css │ │ │ │ ├── CommandPalette.tsx │ │ │ │ ├── Documentation.css │ │ │ │ ├── Documentation.tsx │ │ │ │ ├── getDocumentation.ts │ │ │ │ ├── getUIDocShortestPath.ts │ │ │ │ ├── useGoToUI.tsx │ │ │ │ ├── useHighlightDocItem.ts │ │ │ │ └── utils.ts │ │ │ ├── UIDocs/ │ │ │ │ ├── UIInstallationUIDoc.ts │ │ │ │ ├── accountUIDoc.ts │ │ │ │ ├── commandPaletteUIDoc.ts │ │ │ │ ├── connection/ │ │ │ │ │ ├── AIAssistantUIDoc.ts │ │ │ │ │ ├── config/ │ │ │ │ │ │ ├── accessControlUIDoc.ts │ │ │ │ │ │ ├── apiUIDoc.ts │ │ │ │ │ │ ├── backupAndRestoreUIDoc.ts │ │ │ │ │ │ └── fileStorageUIDoc.ts │ │ │ │ │ ├── connectionConfigUIDoc.ts │ │ │ │ │ ├── dashboard/ │ │ │ │ │ │ ├── dashboardContentUIDoc.ts │ │ │ │ │ │ ├── dashboardMenuUIDoc.ts │ │ │ │ │ │ ├── mapUIDoc.ts │ │ │ │ │ │ ├── serverSideFunctionUIDoc.ts │ │ │ │ │ │ ├── sqlEditorUIDoc.ts │ │ │ │ │ │ ├── table/ │ │ │ │ │ │ │ ├── addColumnMenuUIDoc.ts │ │ │ │ │ │ │ ├── columnMenuUIDoc.ts │ │ │ │ │ │ │ ├── paginationUIDoc.ts │ │ │ │ │ │ │ ├── smartFilterBarUIDoc.ts │ │ │ │ │ │ │ ├── smartFormUIDoc.ts │ │ │ │ │ │ │ ├── tableMenuUIDoc.ts │ │ │ │ │ │ │ └── tableUIDoc.ts │ │ │ │ │ │ └── timechartUIDoc.ts │ │ │ │ │ ├── dashboardUIDoc.ts │ │ │ │ │ └── getCommonViewHeaderUIDoc.ts │ │ │ │ ├── connectionsUIDoc.ts │ │ │ │ ├── desktopInstallation.ts │ │ │ │ ├── editConnectionUIDoc.ts │ │ │ │ ├── navbarUIDoc.ts │ │ │ │ ├── overviewUIDoc.ts │ │ │ │ └── serverSettingsUIDoc.ts │ │ │ ├── UIDocs.ts │ │ │ ├── XRealIpSpoofableAlert.tsx │ │ │ └── domToSVG/ │ │ │ ├── SVGif/ │ │ │ │ ├── addSVGifCaption.ts │ │ │ │ ├── addSVGifPointer.ts │ │ │ │ ├── addSVGifTimelineControls.ts │ │ │ │ ├── animations/ │ │ │ │ │ ├── getSVGifCursorAnimationHandler.ts │ │ │ │ │ ├── getSVGifTypeAnimation.ts │ │ │ │ │ └── getSVGifZoomToAnimation.ts │ │ │ │ ├── compressSVGif.ts │ │ │ │ ├── getSVGif.ts │ │ │ │ ├── getSVGifAnimations.ts │ │ │ │ ├── getSVGifParsedScenes.ts │ │ │ │ ├── getSVGifRevealKeyframes.ts │ │ │ │ └── getSVGifTargetBBox.ts │ │ │ ├── addFragmentViewBoxes.ts │ │ │ ├── containers/ │ │ │ │ ├── addOverflowClipPath.ts │ │ │ │ ├── bgAndBorderToSVG.ts │ │ │ │ ├── deduplicateSVGPaths.ts │ │ │ │ ├── elementToSVG.ts │ │ │ │ ├── rectangleToSVG.ts │ │ │ │ └── shadowToSVG.ts │ │ │ ├── domToSVG.ts │ │ │ ├── domToThemeAwareSVG.ts │ │ │ ├── getCorrespondingDarkNode.ts │ │ │ ├── graphics/ │ │ │ │ ├── fontIconToSVG.ts │ │ │ │ ├── getForeignObject.ts │ │ │ │ └── imgToSVG.ts │ │ │ ├── recordDomChanges.ts │ │ │ ├── setThemeForSVGScreenshot.ts │ │ │ ├── text/ │ │ │ │ ├── getTextForSVG.ts │ │ │ │ └── textToSVG.ts │ │ │ └── utils/ │ │ │ ├── addNewChildren.ts │ │ │ ├── canvasToDataURL.ts │ │ │ ├── copyAnimationStylesToSvg.ts │ │ │ ├── getWhatToRenderOnSVG.ts │ │ │ ├── isElementVisible.ts │ │ │ └── toFixed.ts │ │ ├── appUtils.ts │ │ ├── components/ │ │ │ ├── AlertProvider.tsx │ │ │ ├── Animations.css │ │ │ ├── Animations.tsx │ │ │ ├── Btn.css │ │ │ ├── Btn.tsx │ │ │ ├── ButtonBar.tsx │ │ │ ├── ButtonGroup.tsx │ │ │ ├── Chat/ │ │ │ │ ├── Chat.css │ │ │ │ ├── Chat.tsx │ │ │ │ ├── ChatFileAttachments/ │ │ │ │ │ └── ChatFileAttachments.tsx │ │ │ │ ├── ChatMessage.tsx │ │ │ │ ├── ChatSendControls.tsx │ │ │ │ ├── ChatSpeech/ │ │ │ │ │ ├── ChatSpeech.tsx │ │ │ │ │ ├── ChatSpeechSetup.tsx │ │ │ │ │ ├── hooks/ │ │ │ │ │ │ ├── SpeechRecorder.ts │ │ │ │ │ │ ├── renderSpeechAudioLevelsIcon.ts │ │ │ │ │ │ ├── useSpeechRecorder.ts │ │ │ │ │ │ └── useSpeechToTextWeb.ts │ │ │ │ │ └── useChatSpeechSetup.ts │ │ │ │ ├── Marked.css │ │ │ │ ├── Marked.tsx │ │ │ │ ├── MonacoCodeInMarkdown/ │ │ │ │ │ ├── MarkdownMonacoCodeHeader.tsx │ │ │ │ │ ├── MonacoCodeInMarkdown.tsx │ │ │ │ │ └── useOnRunSQL.ts │ │ │ │ ├── useChatOnPaste.ts │ │ │ │ └── useChatState.ts │ │ │ ├── Checkbox.css │ │ │ ├── Checkbox.tsx │ │ │ ├── Chip.css │ │ │ ├── Chip.tsx │ │ │ ├── ClickCatch.tsx │ │ │ ├── ClickCatchOverlay.css │ │ │ ├── ClickCatchOverlay.tsx │ │ │ ├── ConfirmationDialog.tsx │ │ │ ├── CopyToClipboardBtn.tsx │ │ │ ├── DragOverUpload/ │ │ │ │ ├── DragOverUpload.css │ │ │ │ └── DragOverUpload.tsx │ │ │ ├── DraggableLI.tsx │ │ │ ├── ErrorComponent.tsx │ │ │ ├── ExpandSection.tsx │ │ │ ├── Expander.tsx │ │ │ ├── FileBrowser/ │ │ │ │ ├── FileBrowser.tsx │ │ │ │ └── FileBrowserCurrentDirectory.tsx │ │ │ ├── FileInput/ │ │ │ │ ├── DropZone.tsx │ │ │ │ ├── FileInput.tsx │ │ │ │ ├── FileInputMedia.tsx │ │ │ │ └── useFileDropZone.ts │ │ │ ├── FlashMessage.tsx │ │ │ ├── Flex.tsx │ │ │ ├── FormField/ │ │ │ │ ├── FormField.css │ │ │ │ ├── FormField.tsx │ │ │ │ ├── FormFieldCodeEditor.tsx │ │ │ │ ├── FormFieldDebounced.tsx │ │ │ │ ├── FormFieldSkeleton.tsx │ │ │ │ └── onFormFieldKeyDown.ts │ │ │ ├── Hotkey.css │ │ │ ├── Hotkey.tsx │ │ │ ├── Icon/ │ │ │ │ └── Icon.tsx │ │ │ ├── IconPalette/ │ │ │ │ └── IconPalette.tsx │ │ │ ├── InfoRow.tsx │ │ │ ├── Input.tsx │ │ │ ├── JSONBSchema/ │ │ │ │ ├── JSONBSchema.tsx │ │ │ │ ├── JSONBSchemaAllowedOptions.tsx │ │ │ │ ├── JSONBSchemaArray.tsx │ │ │ │ ├── JSONBSchemaLookup.tsx │ │ │ │ ├── JSONBSchemaObject.tsx │ │ │ │ ├── JSONBSchemaOneOfType.tsx │ │ │ │ ├── JSONBSchemaPrimitive.tsx │ │ │ │ ├── JSONBSchemaRecord.tsx │ │ │ │ └── isCompleteJSONB.ts │ │ │ ├── JsonRenderer.tsx │ │ │ ├── Label.css │ │ │ ├── Label.tsx │ │ │ ├── LabeledRow.tsx │ │ │ ├── List.css │ │ │ ├── List.tsx │ │ │ ├── Loader/ │ │ │ │ ├── Loading.css │ │ │ │ ├── Loading.tsx │ │ │ │ ├── SpinnerV2.tsx │ │ │ │ ├── SpinnerV3.tsx │ │ │ │ └── SpinnerV4.tsx │ │ │ ├── MediaViewer/ │ │ │ │ ├── MediaViewer.tsx │ │ │ │ └── RenderMedia.tsx │ │ │ ├── MenuList.css │ │ │ ├── MenuList.tsx │ │ │ ├── MenuListItem.tsx │ │ │ ├── MonacoEditor/ │ │ │ │ ├── MonacoEditor.tsx │ │ │ │ ├── useMonacoEditorAddActions.ts │ │ │ │ └── useWhyDidYouUpdate.ts │ │ │ ├── MonacoLogRenderer/ │ │ │ │ └── MonacoLogRenderer.tsx │ │ │ ├── NavBar/ │ │ │ │ ├── NavBar.css │ │ │ │ ├── NavBar.tsx │ │ │ │ ├── NavBarWrapper.tsx │ │ │ │ └── useNavBarItems.ts │ │ │ ├── Pan.tsx │ │ │ ├── Popup/ │ │ │ │ ├── Footer.tsx │ │ │ │ ├── FooterButtons.tsx │ │ │ │ ├── Popup.css │ │ │ │ ├── Popup.tsx │ │ │ │ ├── PopupHeader.tsx │ │ │ │ ├── getPopupPosition.ts │ │ │ │ ├── getPopupStyle.ts │ │ │ │ └── popupCheckPosition.ts │ │ │ ├── PopupMenu.tsx │ │ │ ├── PopupMenuList.tsx │ │ │ ├── PostgresInstallationInstructions.tsx │ │ │ ├── ProgressBar.css │ │ │ ├── ProgressBar.tsx │ │ │ ├── QRCodeImage.tsx │ │ │ ├── Scheduler.css │ │ │ ├── Scheduler.tsx │ │ │ ├── ScrollFade/ │ │ │ │ ├── ScrollFade.tsx │ │ │ │ └── useResizeObserver.ts │ │ │ ├── SearchList/ │ │ │ │ ├── SearchInput.tsx │ │ │ │ ├── SearchList.css │ │ │ │ ├── SearchList.tsx │ │ │ │ ├── SearchListContent.tsx │ │ │ │ ├── SearchListItems.tsx │ │ │ │ ├── SearchListRowContent.tsx │ │ │ │ ├── getSearchListMatchAndHighlight.tsx │ │ │ │ ├── hooks/ │ │ │ │ │ ├── useSearchListItems.tsx │ │ │ │ │ ├── useSearchListItemsSorting.ts │ │ │ │ │ ├── useSearchListOnClick.ts │ │ │ │ │ ├── useSearchListOnKeyUpDown.ts │ │ │ │ │ └── useSearchListSearch.ts │ │ │ │ └── searchMatchUtils/ │ │ │ │ ├── getItemSearchRank.ts │ │ │ │ └── getSearchRanking.ts │ │ │ ├── Section.tsx │ │ │ ├── Select/ │ │ │ │ ├── Select.css │ │ │ │ ├── Select.tsx │ │ │ │ └── SelectTriggerButton.tsx │ │ │ ├── ShorterText.tsx │ │ │ ├── SidePanel.tsx │ │ │ ├── Slider.css │ │ │ ├── Slider.tsx │ │ │ ├── StatusChip.tsx │ │ │ ├── Stepper.tsx │ │ │ ├── SvgIcon.tsx │ │ │ ├── SwitchToggle.css │ │ │ ├── SwitchToggle.tsx │ │ │ ├── Table/ │ │ │ │ ├── Pagination.tsx │ │ │ │ ├── Table.css │ │ │ │ ├── Table.tsx │ │ │ │ ├── TableBody.tsx │ │ │ │ ├── TableHeader.tsx │ │ │ │ ├── TableRow.tsx │ │ │ │ └── useVirtualisedRows.ts │ │ │ ├── Tabs.tsx │ │ │ └── Tooltip.tsx │ │ ├── dashboard/ │ │ │ ├── API/ │ │ │ │ └── zip.ts │ │ │ ├── AccessControl/ │ │ │ │ ├── AccessControl.tsx │ │ │ │ ├── AccessControlRuleEditor.tsx │ │ │ │ ├── AccessRuleEditorFooter.tsx │ │ │ │ ├── AccessRuleSummary.tsx │ │ │ │ ├── ContextDataSelector.tsx │ │ │ │ ├── ContextFilter.tsx │ │ │ │ ├── ExistingAccessRules.tsx │ │ │ │ ├── Methods/ │ │ │ │ │ ├── ArgumentDefinition.tsx │ │ │ │ │ ├── MethodDefinition.tsx │ │ │ │ │ ├── MethodDefinitionEditAsJson.tsx │ │ │ │ │ ├── MethodFunctionDefinition.tsx │ │ │ │ │ ├── ReferencesDefinition.tsx │ │ │ │ │ └── useCodeEditorTsTypes.ts │ │ │ │ ├── OptionControllers/ │ │ │ │ │ ├── DynamicFields.tsx │ │ │ │ │ ├── FieldFilterControl.tsx │ │ │ │ │ └── FilterControl.tsx │ │ │ │ ├── PasswordlessSetup.tsx │ │ │ │ ├── PermissionTypes/ │ │ │ │ │ ├── PAllTables.tsx │ │ │ │ │ ├── PCustomTables.tsx │ │ │ │ │ └── PRunSQL.tsx │ │ │ │ ├── PublishedWorkspaceSelector.tsx │ │ │ │ ├── RuleTypeControls/ │ │ │ │ │ ├── ComparablePGPolicies.tsx │ │ │ │ │ ├── DeleteRuleControl.tsx │ │ │ │ │ ├── ExampleComparablePolicy.tsx │ │ │ │ │ ├── InsertRuleControl.tsx │ │ │ │ │ ├── RuleToggle.tsx │ │ │ │ │ ├── SelectRuleControl.tsx │ │ │ │ │ ├── SyncRuleControl.tsx │ │ │ │ │ ├── UpdateRuleControl.tsx │ │ │ │ │ └── getComparablePGPolicy.ts │ │ │ │ ├── TableRules/ │ │ │ │ │ ├── FileTableAccessControlInfo.tsx │ │ │ │ │ ├── TablePermissionControls.tsx │ │ │ │ │ ├── TableRulesPopup.tsx │ │ │ │ │ └── useLocalTableRulesErrors.ts │ │ │ │ ├── UserStats.tsx │ │ │ │ ├── UserSyncConfig.tsx │ │ │ │ ├── UserTypeSelect.tsx │ │ │ │ ├── useAccessControlSearchParams.ts │ │ │ │ └── useEditedAccessRule.ts │ │ │ ├── AskLLM/ │ │ │ │ ├── AskLLM.tsx │ │ │ │ ├── Chat/ │ │ │ │ │ ├── AskLLMChat.tsx │ │ │ │ │ ├── AskLLMChatHeader.tsx │ │ │ │ │ ├── AskLLMChatMessages/ │ │ │ │ │ │ ├── LLMChatMessage/ │ │ │ │ │ │ │ ├── LLMChatMessage.tsx │ │ │ │ │ │ │ ├── LLMChatMessageContent.tsx │ │ │ │ │ │ │ ├── LLMChatMessageContentText.tsx │ │ │ │ │ │ │ ├── LLMChatMessageHeader.tsx │ │ │ │ │ │ │ ├── LLMGroupedToolCallsMessage.tsx │ │ │ │ │ │ │ └── LLMSingleChatMessage.tsx │ │ │ │ │ │ ├── ProstglesToolUseMessage/ │ │ │ │ │ │ │ ├── ProstglesMCPTools/ │ │ │ │ │ │ │ │ ├── DockerSandboxCreateContainer.tsx │ │ │ │ │ │ │ │ ├── ExecuteSQL.tsx │ │ │ │ │ │ │ │ ├── LoadSuggestedDashboards.tsx │ │ │ │ │ │ │ │ ├── LoadSuggestedToolsAndPrompt/ │ │ │ │ │ │ │ │ │ ├── LoadSuggestedToolsAndPrompt.tsx │ │ │ │ │ │ │ │ │ └── LoadSuggestedToolsAndPromptLoadBtn.tsx │ │ │ │ │ │ │ │ ├── LoadSuggestedWorkflow.tsx │ │ │ │ │ │ │ │ ├── WebSearch/ │ │ │ │ │ │ │ │ │ ├── Favicon.tsx │ │ │ │ │ │ │ │ │ └── WebSearch.tsx │ │ │ │ │ │ │ │ └── common/ │ │ │ │ │ │ │ │ ├── DatabaseAccessPermissions.tsx │ │ │ │ │ │ │ │ ├── HeaderList.tsx │ │ │ │ │ │ │ │ └── useTypedToolUseResultData.ts │ │ │ │ │ │ │ └── ProstglesToolUseMessage.tsx │ │ │ │ │ │ ├── ToolUseChatMessage/ │ │ │ │ │ │ │ ├── PopupSection.tsx │ │ │ │ │ │ │ ├── ToolUseChatMessage.tsx │ │ │ │ │ │ │ ├── ToolUseChatMessageBtn.tsx │ │ │ │ │ │ │ ├── ToolUseChatMessageBtnTextSummary.tsx │ │ │ │ │ │ │ ├── ToolUseChatMessageJSONData.tsx │ │ │ │ │ │ │ ├── ToolUseChatMessageResult.tsx │ │ │ │ │ │ │ ├── useToolUseChatMessage.ts │ │ │ │ │ │ │ └── utils/ │ │ │ │ │ │ │ └── getToolUseResult.ts │ │ │ │ │ │ └── hooks/ │ │ │ │ │ │ ├── useLLMChatMessageGrouper.tsx │ │ │ │ │ │ └── useLLMChatMessages.tsx │ │ │ │ │ ├── AskLLMChatOptions.tsx │ │ │ │ │ ├── useAskLLMChatSend.ts │ │ │ │ │ ├── useLLMChat.tsx │ │ │ │ │ └── useLLMSchemaStr.ts │ │ │ │ ├── ChatActionBar/ │ │ │ │ │ ├── AskLLMChatActionBar.tsx │ │ │ │ │ ├── AskLLMChatActionBarDatabaseAccess.tsx │ │ │ │ │ ├── AskLLMChatActionBarMCPTools.tsx │ │ │ │ │ ├── AskLLMChatActionBarMCPToolsBtn.tsx │ │ │ │ │ ├── AskLLMChatActionBarModelSelector.tsx │ │ │ │ │ └── AskLLMChatActionBarPromptSelector.tsx │ │ │ │ ├── Setup/ │ │ │ │ │ ├── AddLLMPromptForm.tsx │ │ │ │ │ ├── AskLLMAccessControl.tsx │ │ │ │ │ ├── LLMProviderSetup.tsx │ │ │ │ │ ├── ProstglesSignup.tsx │ │ │ │ │ ├── SetupLLMCredentials.tsx │ │ │ │ │ └── useLLMSetupState.ts │ │ │ │ ├── Tools/ │ │ │ │ │ ├── AskLLMToolApprover.tsx │ │ │ │ │ ├── loadGeneratedWorkspaces/ │ │ │ │ │ │ ├── loadGeneratedBarchart.ts │ │ │ │ │ │ ├── loadGeneratedMap.ts │ │ │ │ │ │ ├── loadGeneratedTimechart.ts │ │ │ │ │ │ └── loadGeneratedWorkspaces.ts │ │ │ │ │ └── useLLMToolsApprover.tsx │ │ │ │ └── useLocalLLM.ts │ │ │ ├── BackupAndRestore/ │ │ │ │ ├── AutomaticBackups.tsx │ │ │ │ ├── BackupsControls.tsx │ │ │ │ ├── BackupsInProgress.tsx │ │ │ │ ├── CloudStorageCredentialSelector.tsx │ │ │ │ ├── CodeConfirmation.tsx │ │ │ │ ├── CompletedBackups.tsx │ │ │ │ ├── DumpLocationOptions.tsx │ │ │ │ ├── DumpRestoreAlerts.tsx │ │ │ │ ├── PGDumpOptions.tsx │ │ │ │ ├── RenderBackupLogs.tsx │ │ │ │ ├── RenderBackupStatus.tsx │ │ │ │ ├── Restore/ │ │ │ │ │ ├── Restore.tsx │ │ │ │ │ └── RestoreOptions.tsx │ │ │ │ └── useBackupsControlsState.ts │ │ │ ├── Charts/ │ │ │ │ ├── CanvasChart.ts │ │ │ │ ├── TimeChart/ │ │ │ │ │ ├── TimeChart.tsx │ │ │ │ │ ├── getBinValueLabels.ts │ │ │ │ │ ├── getTimeAxisTicks.ts │ │ │ │ │ ├── getTimechartBinSize.ts │ │ │ │ │ ├── getTimechartTooltipIntersections.ts │ │ │ │ │ ├── getTimechartTooltipShapes.ts │ │ │ │ │ ├── measureText.ts │ │ │ │ │ ├── onDeltaTimechart.ts │ │ │ │ │ ├── onRenderTimechart.ts │ │ │ │ │ └── prepareTimechartData.ts │ │ │ │ ├── createHiPPICanvas.ts │ │ │ │ ├── drawMonotoneXCurve.ts │ │ │ │ ├── drawShapes/ │ │ │ │ │ ├── drawLinkLine.ts │ │ │ │ │ ├── drawShapes.ts │ │ │ │ │ ├── drawShapesOnSVG.ts │ │ │ │ │ ├── findShortestPathAroundRectangles.ts │ │ │ │ │ ├── getTimechartGradientPeakSections.ts │ │ │ │ │ └── shortestLinkLineV2.ts │ │ │ │ └── roundRect.ts │ │ │ ├── Charts.tsx │ │ │ ├── CodeEditor/ │ │ │ │ ├── CodeEditor.tsx │ │ │ │ ├── CodeEditorWithSaveButton.tsx │ │ │ │ ├── monacoTsLibs.ts │ │ │ │ ├── registerLogLang.ts │ │ │ │ └── utils/ │ │ │ │ ├── getMonacoJsonSchemas.ts │ │ │ │ ├── setMonacoErrorMarkers.ts │ │ │ │ ├── useSetMonacoJsonSchemas.ts │ │ │ │ └── useSetMonacoTsLibraries.ts │ │ │ ├── CodeExample.tsx │ │ │ ├── ConnectionConfig/ │ │ │ │ ├── APIDetails/ │ │ │ │ │ ├── APICodeExamples.tsx │ │ │ │ │ ├── APIDetails.tsx │ │ │ │ │ ├── APIDetailsHttp.tsx │ │ │ │ │ ├── APIDetailsTokens.tsx │ │ │ │ │ ├── APIDetailsWs.tsx │ │ │ │ │ └── AllowedOriginCheck.tsx │ │ │ │ ├── ConnectionConfig.tsx │ │ │ │ ├── ServerSideFunctions.tsx │ │ │ │ └── useConnectionConfigSearchParams.ts │ │ │ ├── ConnectionSelector.tsx │ │ │ ├── Dashboard/ │ │ │ │ ├── CloseSaveSQLPopup.tsx │ │ │ │ ├── DBS.ts │ │ │ │ ├── Dashboard.tsx │ │ │ │ ├── DashboardCenteredLayoutResizer.tsx │ │ │ │ ├── PALETTE.ts │ │ │ │ ├── ViewRenderer.tsx │ │ │ │ ├── cloneWorkspace.ts │ │ │ │ ├── dashboardUtils.ts │ │ │ │ ├── debuggingUtils.ts │ │ │ │ ├── getTables.ts │ │ │ │ ├── getViewRendererUtils.ts │ │ │ │ └── loadTable.ts │ │ │ ├── DashboardMenu/ │ │ │ │ ├── CreateTable.tsx │ │ │ │ ├── DashboardHotkeys.tsx │ │ │ │ ├── DashboardMenu.tsx │ │ │ │ ├── DashboardMenuContent.tsx │ │ │ │ ├── DashboardMenuHeader.tsx │ │ │ │ ├── DashboardMenuHotkeys.tsx │ │ │ │ ├── DashboardMenuResizer.tsx │ │ │ │ ├── DashboardMenuSettings.tsx │ │ │ │ ├── NewTableMenu.tsx │ │ │ │ ├── SettingsSection.tsx │ │ │ │ └── useTableSizeInfo.ts │ │ │ ├── DetailedFilterControl/ │ │ │ │ ├── DetailedFilterBaseControl.tsx │ │ │ │ ├── DetailedFilterBaseControlRouter.tsx │ │ │ │ ├── DetailedFilterBaseTypes/ │ │ │ │ │ ├── AgeFilter.tsx │ │ │ │ │ ├── GeoFilter.tsx │ │ │ │ │ ├── ListFilter/ │ │ │ │ │ │ ├── ListFilter.tsx │ │ │ │ │ │ └── fetchListFilterOptions.ts │ │ │ │ │ └── NumberOrDateFilter.tsx │ │ │ │ ├── DetailedFilterControl.tsx │ │ │ │ ├── FTS_LANGUAGES.ts │ │ │ │ ├── FilterWrapper.css │ │ │ │ ├── FilterWrapper.tsx │ │ │ │ └── validateFilter.ts │ │ │ ├── Feedback.tsx │ │ │ ├── FileImporter/ │ │ │ │ ├── FileImporter.tsx │ │ │ │ ├── FileImporterFooter.tsx │ │ │ │ ├── checkCSVColumnDataTypes.tsx │ │ │ │ ├── importFile.ts │ │ │ │ ├── parseCSVFile.ts │ │ │ │ └── setFile.ts │ │ │ ├── FileTableControls/ │ │ │ │ ├── CreateFileColumn.tsx │ │ │ │ ├── FileColumnConfigControls.tsx │ │ │ │ ├── FileColumnConfigEditor.tsx │ │ │ │ ├── FileStorageControls.tsx │ │ │ │ ├── FileStorageDelete.tsx │ │ │ │ ├── FileStorageReferencedTablesConfig.tsx │ │ │ │ ├── FileTableConfigControls.tsx │ │ │ │ └── useFileTableConfigControls.ts │ │ │ ├── JSONBColumnEditor.tsx │ │ │ ├── LinkMenu.tsx │ │ │ ├── Map/ │ │ │ │ ├── DeckGLFeatureEditor.tsx │ │ │ │ ├── DeckGLMap.css │ │ │ │ ├── DeckGLMap.tsx │ │ │ │ ├── DeckGLWrapped.ts │ │ │ │ ├── InMapControls.tsx │ │ │ │ ├── fitBounds.ts │ │ │ │ ├── mapDrawUtils.ts │ │ │ │ └── mapUtils.ts │ │ │ ├── RTComp.tsx │ │ │ ├── RenderFilter.tsx │ │ │ ├── SQLEditor/ │ │ │ │ ├── SQLCompletion/ │ │ │ │ │ ├── CommonMatchImports.ts │ │ │ │ │ ├── KEYWORDS.ts │ │ │ │ │ ├── MacthCreate/ │ │ │ │ │ │ ├── MatchCreate.ts │ │ │ │ │ │ ├── MatchCreateView.ts │ │ │ │ │ │ ├── matchCreateIndex.ts │ │ │ │ │ │ ├── matchCreatePolicy.ts │ │ │ │ │ │ ├── matchCreateRule.tsx │ │ │ │ │ │ ├── matchCreateTable.ts │ │ │ │ │ │ └── matchCreateTrigger.ts │ │ │ │ │ ├── MatchAlter/ │ │ │ │ │ │ ├── MatchAlter.tsx │ │ │ │ │ │ ├── matchAlterPolicy.ts │ │ │ │ │ │ ├── matchAlterTable.ts │ │ │ │ │ │ └── matchCreateOrAlterUser.tsx │ │ │ │ │ ├── MatchComment.ts │ │ │ │ │ ├── MatchCopy.ts │ │ │ │ │ ├── MatchDrop.ts │ │ │ │ │ ├── MatchFirst.ts │ │ │ │ │ ├── MatchGrant.ts │ │ │ │ │ ├── MatchInsert.tsx │ │ │ │ │ ├── MatchLast.ts │ │ │ │ │ ├── MatchPublication.ts │ │ │ │ │ ├── MatchReassign.ts │ │ │ │ │ ├── MatchReindex.ts │ │ │ │ │ ├── MatchSelect.ts │ │ │ │ │ ├── MatchSet.ts │ │ │ │ │ ├── MatchSubscription.ts │ │ │ │ │ ├── MatchUpdate.ts │ │ │ │ │ ├── MatchVacuum.ts │ │ │ │ │ ├── MatchWith.ts │ │ │ │ │ ├── MathDelete.ts │ │ │ │ │ ├── PSQL.ts │ │ │ │ │ ├── STARTING_KEYWORDS.ts │ │ │ │ │ ├── TableKWDs.ts │ │ │ │ │ ├── completionUtils/ │ │ │ │ │ │ ├── checkIfInsideDollarFunctionDefinition.ts │ │ │ │ │ │ ├── checkIfUnfinishedParenthesis.ts │ │ │ │ │ │ ├── getCodeBlock.ts │ │ │ │ │ │ ├── getQueryReturnType.ts │ │ │ │ │ │ ├── getTableExpressionReturnTypes.ts │ │ │ │ │ │ ├── getTabularExpressions.ts │ │ │ │ │ │ └── getTokens.ts │ │ │ │ │ ├── getExpected.ts │ │ │ │ │ ├── getJoinSuggestions.ts │ │ │ │ │ ├── getMatch.ts │ │ │ │ │ ├── getPGObjects.ts │ │ │ │ │ ├── getPrevTokensNoParantheses.ts │ │ │ │ │ ├── jsonbPathSuggest.ts │ │ │ │ │ ├── monacoSQLSetup/ │ │ │ │ │ │ ├── registerHover.ts │ │ │ │ │ │ └── registerSuggestions.ts │ │ │ │ │ ├── suggestColumnLike.ts │ │ │ │ │ ├── suggestCondition.ts │ │ │ │ │ ├── suggestFuncArgs.ts │ │ │ │ │ ├── suggestTableLike.ts │ │ │ │ │ ├── suggestValue.ts │ │ │ │ │ └── withKWDs.ts │ │ │ │ ├── SQLEditorSuggestions.ts │ │ │ │ ├── SQLSmartEditor.tsx │ │ │ │ ├── SQL_SNIPPETS.ts │ │ │ │ ├── W_SQLEditor.css │ │ │ │ ├── W_SQLEditor.tsx │ │ │ │ ├── addSqlEditorFunctions.ts │ │ │ │ ├── defineCustomMonacoSQLTheme.ts │ │ │ │ ├── getFormattedSql.ts │ │ │ │ ├── registerFunctionSuggestions.ts │ │ │ │ └── utils/ │ │ │ │ ├── scrollToLineIfNeeded.ts │ │ │ │ └── setMonacEditorError.ts │ │ │ ├── SampleSchemas.tsx │ │ │ ├── SchemaGraph/ │ │ │ │ ├── ERDSchema/ │ │ │ │ │ ├── ERDSchema.tsx │ │ │ │ │ ├── getInitialPlacement.ts │ │ │ │ │ ├── useCanvasPanZoom.ts │ │ │ │ │ ├── useDrawSchemaShapes.ts │ │ │ │ │ ├── useFetchSchemaForDiagram.ts │ │ │ │ │ ├── usePanShapes.ts │ │ │ │ │ └── useSchemaShapes.ts │ │ │ │ ├── SchemaGraph.tsx │ │ │ │ ├── SchemaGraphControls.tsx │ │ │ │ └── types.ts │ │ │ ├── SearchAll/ │ │ │ │ ├── SearchAll.tsx │ │ │ │ ├── SearchAllContent.tsx │ │ │ │ ├── SearchAllHeader.tsx │ │ │ │ ├── SearchMatchRow.tsx │ │ │ │ └── hooks/ │ │ │ │ ├── useSearchAllState.ts │ │ │ │ ├── useSearchListProps.tsx │ │ │ │ ├── useSearchTables.tsx │ │ │ │ └── useTablesAndViewsSearchItems.ts │ │ │ ├── SilverGrid/ │ │ │ │ ├── SilverGrid.tsx │ │ │ │ ├── SilverGridChild.tsx │ │ │ │ ├── SilverGridChildHeader.tsx │ │ │ │ ├── SilverGridResizer.tsx │ │ │ │ └── TreeBuilder.ts │ │ │ ├── SmartCard/ │ │ │ │ ├── SmartCard.tsx │ │ │ │ ├── SmartCardActions.tsx │ │ │ │ ├── SmartCardColumn.tsx │ │ │ │ ├── getSelectForFieldConfigs.ts │ │ │ │ ├── getSmartCardColumns.ts │ │ │ │ ├── parseFieldConfigs.tsx │ │ │ │ └── useFieldConfigParser.ts │ │ │ ├── SmartCardList/ │ │ │ │ ├── SmartCardList.tsx │ │ │ │ ├── SmartCardListHeaderControls.tsx │ │ │ │ ├── SmartCardListJoinedNewRecords.tsx │ │ │ │ └── useSmartCardListState.ts │ │ │ ├── SmartFilter/ │ │ │ │ ├── AddJoinFilter.tsx │ │ │ │ ├── MinimisedFilter.css │ │ │ │ ├── MinimisedFilter.tsx │ │ │ │ ├── SmartAddFilter.tsx │ │ │ │ ├── SmartFilter.tsx │ │ │ │ ├── SmartSearch/ │ │ │ │ │ ├── SmartSearch.css │ │ │ │ │ ├── SmartSearch.tsx │ │ │ │ │ ├── getSmartSearchRows.ts │ │ │ │ │ └── onSearchItems.tsx │ │ │ │ ├── SortByControl.tsx │ │ │ │ └── smartFilterUtils.ts │ │ │ ├── SmartFilterBar/ │ │ │ │ ├── SmartFilterBar.tsx │ │ │ │ ├── SmartFilterBarFilters.tsx │ │ │ │ ├── SmartFilterBarRightActions.tsx │ │ │ │ ├── SmartFilterBarSearch.tsx │ │ │ │ ├── SmartFilterBarSort.tsx │ │ │ │ └── useSmartFilterBarState.ts │ │ │ ├── SmartForm/ │ │ │ │ ├── ChipArrayEditor.tsx │ │ │ │ ├── InsertButton.tsx │ │ │ │ ├── JoinedRecords/ │ │ │ │ │ ├── JoinedRecords.tsx │ │ │ │ │ ├── JoinedRecordsAddRow.tsx │ │ │ │ │ ├── JoinedRecordsSection.tsx │ │ │ │ │ ├── getJoinFilter.ts │ │ │ │ │ ├── useJoinedRecordsSections.ts │ │ │ │ │ └── useJoinedSectionFieldConfigs.tsx │ │ │ │ ├── SmartForm.tsx │ │ │ │ ├── SmartFormField/ │ │ │ │ │ ├── RenderValue.tsx │ │ │ │ │ ├── SmartFormField.tsx │ │ │ │ │ ├── SmartFormFieldFileSection.tsx │ │ │ │ │ ├── SmartFormFieldForeignKey.tsx │ │ │ │ │ ├── SmartFormFieldLinkedData.tsx │ │ │ │ │ ├── SmartFormFieldLinkedDataInsert/ │ │ │ │ │ │ ├── SmartFormFieldLinkedDataInsert.tsx │ │ │ │ │ │ └── useSmartFormFieldLinkedDataInsert.tsx │ │ │ │ │ ├── SmartFormFieldLinkedDataSearch.tsx │ │ │ │ │ ├── SmartFormFieldRightButtons.tsx │ │ │ │ │ ├── ViewMoreSmartCardList.tsx │ │ │ │ │ ├── fetchColumnValueSuggestions.ts │ │ │ │ │ ├── fetchForeignKeyOptions.tsx │ │ │ │ │ ├── fieldUtils.ts │ │ │ │ │ ├── useFormData.ts │ │ │ │ │ ├── useNestedInsertDefaultData.ts │ │ │ │ │ ├── useSmartFormFieldAsJSON.tsx │ │ │ │ │ └── useSmartFormFieldOnChange.ts │ │ │ │ ├── SmartFormFieldList.tsx │ │ │ │ ├── SmartFormFileSection.tsx │ │ │ │ ├── SmartFormFooter/ │ │ │ │ │ ├── SmartFormFooterButtons.tsx │ │ │ │ │ └── useSmartFormActions.ts │ │ │ │ ├── SmartFormNewRowDataHandler.ts │ │ │ │ ├── SmartFormPopup/ │ │ │ │ │ └── SmartFormPopupWrapper.tsx │ │ │ │ ├── SmartFormUpperFooter/ │ │ │ │ │ ├── SmartFormUpperFooter.tsx │ │ │ │ │ └── useActiveJoinedRecordsTab.ts │ │ │ │ ├── useNewRowDataHandler.ts │ │ │ │ ├── useSmartForm.ts │ │ │ │ ├── useSmartFormColumns.ts │ │ │ │ └── useSmartFormMode.ts │ │ │ ├── SmartSelect.tsx │ │ │ ├── SmartTable.tsx │ │ │ ├── StatusMonitor/ │ │ │ │ ├── StatusMonitor.tsx │ │ │ │ ├── StatusMonitorConnections.tsx │ │ │ │ ├── StatusMonitorInfoHeader/ │ │ │ │ │ ├── StatusMonitorInfoHeader.tsx │ │ │ │ │ ├── StatusMonitorInfoHeaderCpu.tsx │ │ │ │ │ └── StatusMonitorInfoHeaderMemory.tsx │ │ │ │ ├── StatusMonitorProcList.tsx │ │ │ │ └── StatusMonitorProcListControlsHeader.tsx │ │ │ ├── TableConfig/ │ │ │ │ ├── ProcessLogs.tsx │ │ │ │ └── TableConfig.tsx │ │ │ ├── TimeSeries.tsx │ │ │ ├── UserManager.tsx │ │ │ ├── W_Barchart/ │ │ │ │ ├── W_Barchart.tsx │ │ │ │ ├── fetchSQLBarchartData.ts │ │ │ │ └── useBarchartData.ts │ │ │ ├── W_Map/ │ │ │ │ ├── OSM/ │ │ │ │ │ ├── OverpassQuery.tsx │ │ │ │ │ ├── fetchOSMCountryBoundary.ts │ │ │ │ │ ├── getOSMData.ts │ │ │ │ │ ├── osmToGeoJSON.ts │ │ │ │ │ └── osmTypes.ts │ │ │ │ ├── W_Map.tsx │ │ │ │ ├── W_MapMenu.tsx │ │ │ │ ├── controls/ │ │ │ │ │ ├── MapBasemapOptions.tsx │ │ │ │ │ ├── MapInfoSection.tsx │ │ │ │ │ ├── MapOSMQuery.tsx │ │ │ │ │ └── MapOpacityMenu.tsx │ │ │ │ ├── fetchData/ │ │ │ │ │ ├── fetchMapLayerData.ts │ │ │ │ │ ├── getMapData.ts │ │ │ │ │ ├── getMapDataExtent.ts │ │ │ │ │ └── getMapLayerQueries.ts │ │ │ │ ├── getMapFeatureStyle.ts │ │ │ │ └── onMapHover.ts │ │ │ ├── W_Method/ │ │ │ │ ├── FunctionLabel.tsx │ │ │ │ ├── NewMethod.tsx │ │ │ │ ├── PublishedMethods.tsx │ │ │ │ ├── W_Method.tsx │ │ │ │ ├── W_MethodControls.tsx │ │ │ │ └── W_MethodMenu.tsx │ │ │ ├── W_QuickMenu.tsx │ │ │ ├── W_SQL/ │ │ │ │ ├── CSVRender.tsx │ │ │ │ ├── CopyResultBtn.tsx │ │ │ │ ├── MonacoLanguageRegister.ts │ │ │ │ ├── SQLHotkeys.tsx │ │ │ │ ├── TestSQL.ts │ │ │ │ ├── W_SQL.tsx │ │ │ │ ├── W_SQLBottomBar/ │ │ │ │ │ ├── W_SQLBottomBar.tsx │ │ │ │ │ └── W_SQLBottomBarProcStats.tsx │ │ │ │ ├── W_SQLMenu.tsx │ │ │ │ ├── W_SQLResults.tsx │ │ │ │ ├── customRenderers.tsx │ │ │ │ ├── demoScripts/ │ │ │ │ │ ├── createTables.ts │ │ │ │ │ ├── mainTestScripts.ts │ │ │ │ │ ├── testBugs.ts │ │ │ │ │ ├── testMiscAndBugs.ts │ │ │ │ │ └── testSqlCharts.ts │ │ │ │ ├── getChartableSQL.ts │ │ │ │ ├── getDemoUtils.ts │ │ │ │ ├── getSQLResultTableColumns.ts │ │ │ │ ├── monacoEditorTypes.ts │ │ │ │ ├── parseExplainResult.ts │ │ │ │ ├── parseSQLError.ts │ │ │ │ ├── parseSqlResultCols.ts │ │ │ │ ├── runSQL/ │ │ │ │ │ ├── getQueryTotalRowCount.ts │ │ │ │ │ └── runSQL.ts │ │ │ │ └── runSQLErrorHints.ts │ │ │ ├── W_Table/ │ │ │ │ ├── CardView/ │ │ │ │ │ ├── CardView.tsx │ │ │ │ │ ├── CardViewColumn.tsx │ │ │ │ │ ├── CardViewKanban.tsx │ │ │ │ │ ├── CardViewRow.tsx │ │ │ │ │ ├── DragHeader.tsx │ │ │ │ │ ├── useCardViewState.ts │ │ │ │ │ └── useDragHeader.ts │ │ │ │ ├── ColumnMenu/ │ │ │ │ │ ├── AddColumnMenu.tsx │ │ │ │ │ ├── AddComputedColumn/ │ │ │ │ │ │ ├── AddComputedColMenu.tsx │ │ │ │ │ │ ├── FunctionColumnList.tsx │ │ │ │ │ │ ├── QuickAddComputedColumn.tsx │ │ │ │ │ │ └── useAddComputedColumn.ts │ │ │ │ │ ├── AlterColumn/ │ │ │ │ │ │ ├── AlterColumn.tsx │ │ │ │ │ │ ├── AlterColumnFileOptions.tsx │ │ │ │ │ │ ├── ColumnEditor.tsx │ │ │ │ │ │ ├── CreateColumn.tsx │ │ │ │ │ │ ├── ReferenceEditor.tsx │ │ │ │ │ │ └── alterColumnUtilts.ts │ │ │ │ │ ├── ColorPicker.tsx │ │ │ │ │ ├── ColumnDisplayFormat/ │ │ │ │ │ │ ├── ChipStylePalette.tsx │ │ │ │ │ │ ├── ColumnDisplayFormat.tsx │ │ │ │ │ │ ├── ConditionalCellIconStyleControls.tsx │ │ │ │ │ │ ├── ConditionalCellStyleControls.tsx │ │ │ │ │ │ ├── NestedColumnRender.tsx │ │ │ │ │ │ └── columnFormatUtils.tsx │ │ │ │ │ ├── ColumnList.tsx │ │ │ │ │ ├── ColumnMenu.tsx │ │ │ │ │ ├── ColumnQuickStats/ │ │ │ │ │ │ ├── ColumnQuickStats.tsx │ │ │ │ │ │ └── useColumnQuickStats.ts │ │ │ │ │ ├── ColumnSelect/ │ │ │ │ │ │ ├── ColumnSelect.tsx │ │ │ │ │ │ └── getColumnListItem.tsx │ │ │ │ │ ├── ColumnSortMenu.tsx │ │ │ │ │ ├── ColumnStyleControls/ │ │ │ │ │ │ ├── ColumnStyleControls.tsx │ │ │ │ │ │ └── getValueColors.ts │ │ │ │ │ ├── ColumnsMenu.tsx │ │ │ │ │ ├── FunctionSelector/ │ │ │ │ │ │ ├── FunctionExtraArguments.tsx │ │ │ │ │ │ ├── FunctionSelector.tsx │ │ │ │ │ │ └── functions.ts │ │ │ │ │ ├── JoinPathSelectorV2.tsx │ │ │ │ │ ├── LinkedColumn/ │ │ │ │ │ │ ├── LinkedColumn.tsx │ │ │ │ │ │ ├── LinkedColumnFooter.tsx │ │ │ │ │ │ └── LinkedColumnSelect.tsx │ │ │ │ │ ├── NestedTimechartControls.tsx │ │ │ │ │ ├── SummariseColumns.tsx │ │ │ │ │ ├── getNestedColumnTable.ts │ │ │ │ │ └── rgba2hex.ts │ │ │ │ ├── JoinPathSelector/ │ │ │ │ │ ├── JoinPathItem.tsx │ │ │ │ │ ├── JoinPathSelector.tsx │ │ │ │ │ └── getJoinTree.ts │ │ │ │ ├── NodeCountChecker.tsx │ │ │ │ ├── ProstglesTable.css │ │ │ │ ├── QuickFilterGroupsControl.tsx │ │ │ │ ├── RowCard.tsx │ │ │ │ ├── TableMenu/ │ │ │ │ │ ├── AddChartMenu.tsx │ │ │ │ │ ├── AutoRefreshMenu.tsx │ │ │ │ │ ├── W_TableMenu.tsx │ │ │ │ │ ├── W_TableMenu_AccessRules.tsx │ │ │ │ │ ├── W_TableMenu_Constraints.tsx │ │ │ │ │ ├── W_TableMenu_CurrentQuery.tsx │ │ │ │ │ ├── W_TableMenu_DisplayOptions.tsx │ │ │ │ │ ├── W_TableMenu_Indexes.tsx │ │ │ │ │ ├── W_TableMenu_Policies.tsx │ │ │ │ │ ├── W_TableMenu_TableInfo.tsx │ │ │ │ │ ├── W_TableMenu_Triggers.tsx │ │ │ │ │ ├── getAndFixWColumnsConfig.tsx │ │ │ │ │ ├── getChartCols.ts │ │ │ │ │ └── getTableMeta.ts │ │ │ │ ├── TooManyColumnsWarning.tsx │ │ │ │ ├── W_Table.tsx │ │ │ │ ├── W_Table_Content.tsx │ │ │ │ ├── colorBlend.ts │ │ │ │ ├── getTableData/ │ │ │ │ │ ├── getTableData.ts │ │ │ │ │ └── getTableFilter.ts │ │ │ │ └── tableUtils/ │ │ │ │ ├── StyledTableColumn.tsx │ │ │ │ ├── getColWInfo.ts │ │ │ │ ├── getColWidth.ts │ │ │ │ ├── getEditColumn.tsx │ │ │ │ ├── getFullColumnConfig.ts │ │ │ │ ├── getJoinPaths.ts │ │ │ │ ├── getRowFilter.ts │ │ │ │ ├── getTableCols.tsx │ │ │ │ ├── getTableSelect.ts │ │ │ │ ├── onRenderColumn.tsx │ │ │ │ ├── prepareColsForRender.ts │ │ │ │ └── tableUtils.ts │ │ │ ├── W_TimeChart/ │ │ │ │ ├── AddTimeChartFilter.tsx │ │ │ │ ├── W_TimeChart.tsx │ │ │ │ ├── W_TimeChartHeaderControls.tsx │ │ │ │ ├── W_TimeChartLayerLegend.tsx │ │ │ │ ├── W_TimeChartMenu.tsx │ │ │ │ └── fetchData/ │ │ │ │ ├── constants.ts │ │ │ │ ├── fetchAndSetTimechartLayerData.ts │ │ │ │ ├── fetchTimechartLayer.ts │ │ │ │ ├── getTimeChartData.ts │ │ │ │ ├── getTimeChartLayers.ts │ │ │ │ ├── getTimeChartLayersWithBins.ts │ │ │ │ ├── getTimeChartSelectParams.ts │ │ │ │ ├── getTimeLayerDataSignature.ts │ │ │ │ └── getTimechartExtentFilter.ts │ │ │ ├── Window.tsx │ │ │ ├── WindowControls/ │ │ │ │ ├── AddChartLayer.tsx │ │ │ │ ├── ColorByLegend/ │ │ │ │ │ ├── ColorByLegend.tsx │ │ │ │ │ └── getGroupByValueColor.ts │ │ │ │ ├── DataLayerManager/ │ │ │ │ │ ├── DataLayer.tsx │ │ │ │ │ ├── DataLayerManager.tsx │ │ │ │ │ └── useSortedLayerQueries.ts │ │ │ │ ├── LayerColorPicker.tsx │ │ │ │ ├── MapLayerStyling.tsx │ │ │ │ ├── OSMLayerOptions.tsx │ │ │ │ ├── SQLChartLayerEditor.tsx │ │ │ │ └── TimeChartLayerOptions.tsx │ │ │ ├── WorkspaceMenu/ │ │ │ │ ├── WorkspaceAddBtn.tsx │ │ │ │ ├── WorkspaceDeleteBtn.tsx │ │ │ │ ├── WorkspaceMenu.css │ │ │ │ ├── WorkspaceMenu.tsx │ │ │ │ ├── WorkspaceMenuDropDown.tsx │ │ │ │ ├── WorkspaceSettings.tsx │ │ │ │ └── useWorkspaces.ts │ │ │ ├── joinUtils.ts │ │ │ ├── localSettings.ts │ │ │ ├── setPan.ts │ │ │ └── shortestPath.ts │ │ ├── demo/ │ │ │ ├── AppVideoDemo.tsx │ │ │ ├── MousePointer.tsx │ │ │ ├── demoUtils.ts │ │ │ ├── recordDemoUtils.ts │ │ │ └── scripts/ │ │ │ ├── AIAssistantDemo.ts │ │ │ ├── accessControlDemo.ts │ │ │ ├── backupDemo.ts │ │ │ ├── dashboardDemo.ts │ │ │ ├── fileDemo.ts │ │ │ ├── schemaDiagramDemo.ts │ │ │ ├── sqlVideoDemo.ts │ │ │ └── videoDemoAccessControlScripts.ts │ │ ├── exports.ts │ │ ├── hooks/ │ │ │ ├── useDebouncedCallback.ts │ │ │ ├── useThrottledCallback.ts │ │ │ └── useTypedSearchParams.ts │ │ ├── i18n/ │ │ │ ├── LanguageSelector.tsx │ │ │ ├── i18nUtils.ts │ │ │ └── translations/ │ │ │ ├── de.json │ │ │ ├── es.json │ │ │ ├── fr.json │ │ │ ├── hi.json │ │ │ ├── ru.json │ │ │ ├── translations.ts │ │ │ └── zh.json │ │ ├── index.css │ │ ├── index.html.ejs │ │ ├── index.tsx │ │ ├── pages/ │ │ │ ├── Account/ │ │ │ │ ├── Account.tsx │ │ │ │ ├── ChangePassword.tsx │ │ │ │ ├── Sessions.tsx │ │ │ │ └── Setup2FA.tsx │ │ │ ├── AccountMenu.tsx │ │ │ ├── Alerts.tsx │ │ │ ├── ComponentList.tsx │ │ │ ├── Connections/ │ │ │ │ ├── Connection.tsx │ │ │ │ ├── ConnectionActionBar.tsx │ │ │ │ ├── Connections.tsx │ │ │ │ ├── ConnectionsOptions.tsx │ │ │ │ ├── CreateConnection/ │ │ │ │ │ ├── CreateConnection.tsx │ │ │ │ │ └── useCreateConnection.ts │ │ │ │ ├── CreatePostgresUser.tsx │ │ │ │ ├── useConnectionServersList.ts │ │ │ │ └── useConnections.ts │ │ │ ├── ElectronSetup/ │ │ │ │ ├── ElectronSetup.tsx │ │ │ │ ├── ElectronSetupStateDB.tsx │ │ │ │ └── useElectronSetup.ts │ │ │ ├── Login/ │ │ │ │ ├── AuthNotifPopup.tsx │ │ │ │ ├── Login.tsx │ │ │ │ ├── LoginTotpForm.tsx │ │ │ │ ├── LoginWithProviders.tsx │ │ │ │ ├── SocialIcons.css │ │ │ │ ├── SocialIcons.tsx │ │ │ │ └── useLoginState.ts │ │ │ ├── NewConnection/ │ │ │ │ ├── NewConnectionFormFields.tsx │ │ │ │ ├── NewConnnectionForm.tsx │ │ │ │ ├── SchemaFilter.tsx │ │ │ │ └── newConnectionUtils.ts │ │ │ ├── NonHTTPSWarning.tsx │ │ │ ├── NotFound.tsx │ │ │ ├── ProjectConnection/ │ │ │ │ ├── PrglContextProvider.tsx │ │ │ │ ├── ProjectConnection.tsx │ │ │ │ ├── ProjectConnectionError.tsx │ │ │ │ └── useProjectDb.ts │ │ │ ├── ProjectLogs.tsx │ │ │ ├── ServerSettings/ │ │ │ │ ├── AuthProvidersSetup.tsx │ │ │ │ ├── EmailAuthSetup.tsx │ │ │ │ ├── EmailAuthSetupIngredients/ │ │ │ │ │ ├── EmailSMTPAndTemplateSetup.tsx │ │ │ │ │ ├── EmailSMTPSetup.tsx │ │ │ │ │ └── EmailTemplateSetup.tsx │ │ │ │ ├── MCPServers/ │ │ │ │ │ ├── MCPServerConfig/ │ │ │ │ │ │ ├── MCPServerConfig.tsx │ │ │ │ │ │ ├── MCPServerConfigButton.tsx │ │ │ │ │ │ ├── useMCPServerConfigState.tsx │ │ │ │ │ │ └── useMCPServerEnable.ts │ │ │ │ │ ├── MCPServerFooterActions/ │ │ │ │ │ │ ├── MCPServerFooterActions.tsx │ │ │ │ │ │ └── MCPServersInstall.tsx │ │ │ │ │ ├── MCPServerHeaderCheckbox.tsx │ │ │ │ │ ├── MCPServerTools/ │ │ │ │ │ │ ├── MCPServerTools.tsx │ │ │ │ │ │ └── MCPServerToolsGroupToggle.tsx │ │ │ │ │ ├── MCPServers.tsx │ │ │ │ │ ├── MCPServersHeader.tsx │ │ │ │ │ ├── MCPServersToolbar/ │ │ │ │ │ │ ├── AddMCPServer.tsx │ │ │ │ │ │ ├── MCPServersToolbar.tsx │ │ │ │ │ │ └── useAddMCPServer.ts │ │ │ │ │ ├── useMCPChatAllowedTools.ts │ │ │ │ │ └── useMCPServersListProps.tsx │ │ │ │ ├── OAuthProviderSetup.tsx │ │ │ │ ├── ServerSettings.tsx │ │ │ │ ├── Services.tsx │ │ │ │ └── useEditableData.ts │ │ │ ├── TopControls.tsx │ │ │ └── projectUtils.ts │ │ ├── theme/ │ │ │ ├── ThemeSelector.tsx │ │ │ ├── useAppTheme.ts │ │ │ └── useSystemTheme.ts │ │ ├── useAppState/ │ │ │ ├── dbsConnectionOptions.ts │ │ │ ├── useAppState.ts │ │ │ ├── useDBSClient.ts │ │ │ └── useServerState.ts │ │ └── utils/ │ │ ├── colorUtils.ts │ │ ├── hashCode.ts │ │ └── utils.ts │ ├── static/ │ │ └── util.css │ ├── tsconfig.eslint.json │ ├── tsconfig.json │ ├── tsconfig_lib.json │ ├── tsconfig_rollup.json │ └── tslint.json ├── common/ │ ├── DBGeneratedSchema.d.ts │ ├── DashboardTypes.d.ts │ ├── DashboardTypes.js │ ├── DashboardTypes.ts │ ├── OAuthUtils.d.ts │ ├── OAuthUtils.js │ ├── OAuthUtils.ts │ ├── dashboardTypesContent.d.ts │ ├── dashboardTypesContent.js │ ├── dashboardTypesContent.ts │ ├── electronInitTypes.d.ts │ ├── electronInitTypes.js │ ├── electronInitTypes.ts │ ├── filterUtils.d.ts │ ├── filterUtils.js │ ├── filterUtils.ts │ ├── llmUtils.d.ts │ ├── llmUtils.js │ ├── llmUtils.ts │ ├── mcp.d.ts │ ├── mcp.js │ ├── mcp.ts │ ├── prostglesMcp.d.ts │ ├── prostglesMcp.js │ ├── prostglesMcp.ts │ ├── psql_queries.json │ ├── publishUtils.d.ts │ ├── publishUtils.js │ ├── publishUtils.ts │ ├── tsconfig.json │ ├── utils.d.ts │ ├── utils.js │ └── utils.ts ├── docker-compose.yml ├── docs/ │ ├── 01_Overview.md │ ├── 02_Installation.md │ ├── 03_Installation_(Desktop_Version).md │ ├── 04_Navigation_bar.md │ ├── 05_Connections.md │ ├── 06_Connection_dashboard.md │ ├── 07_Import_file.md │ ├── 08_Schema_diagram.md │ ├── 09_SQL_editor.md │ ├── 10_Table_view.md │ ├── 11_Map_view.md │ ├── 12_Timechart_view.md │ ├── 13_AI_Assistant.md │ ├── 14_Connection_configuration.md │ ├── 15_Access_control.md │ ├── 16_File_storage.md │ ├── 17_Backup_and_Restore.md │ ├── 18_API.md │ ├── 19_Server_Settings.md │ ├── 20_Account.md │ └── 21_Command_Palette.md ├── e2e/ │ ├── .gitignore │ ├── package.json │ ├── playwright.config.js │ ├── playwright.config.ts │ ├── test-local.sh │ ├── tests/ │ │ ├── Testing.ts │ │ ├── command_palette.spec.ts │ │ ├── createReceipt.ts │ │ ├── create_docs.spec.ts │ │ ├── create_load_test_users.spec.ts │ │ ├── demo_video.spec.ts │ │ ├── demo_video_setup.spec.ts │ │ ├── load_test.spec.ts │ │ ├── main.spec.ts │ │ ├── mockSMTPServer.ts │ │ ├── sampleToolUseData.ts │ │ ├── speechToTextTest.ts │ │ ├── svgScreenshots/ │ │ │ ├── SVG_SCREENSHOT_DETAILS.ts │ │ │ ├── account.svgif.ts │ │ │ ├── aiAssistant.svgif.ts │ │ │ ├── backupAndRestore.svgif.ts │ │ │ ├── commandPalette.svgif.ts │ │ │ ├── dashboard.svgif.ts │ │ │ ├── electronSetup.svgif.ts │ │ │ ├── fileImporter.svgif.ts │ │ │ ├── getOverviewSvgifSpecs.svgif.ts │ │ │ ├── map.svgif.ts │ │ │ ├── navbar.svgif.ts │ │ │ ├── newConnection.svgif.ts │ │ │ ├── schemaDiagram.svgif.ts │ │ │ ├── smartForm.svgif.ts │ │ │ ├── sqlEditor.svgif.ts │ │ │ ├── table.svgif.ts │ │ │ ├── timechart.svgif.ts │ │ │ └── utils/ │ │ │ ├── constants.ts │ │ │ ├── getFilesFromDir.ts │ │ │ ├── getSceneUtils.ts │ │ │ ├── saveSVGScreenshot.ts │ │ │ ├── saveSVGifs.ts │ │ │ ├── saveSVGs.ts │ │ │ ├── svgScreenshotsCompleteReferenced.ts │ │ │ └── typeSendAddScenes.ts │ │ ├── testAskLLM.ts │ │ ├── tsconfig.json │ │ └── utils/ │ │ ├── constants.ts │ │ ├── goTo.ts │ │ ├── isPortFree.ts │ │ └── utils.ts │ └── tsconfig.json ├── electron/ │ ├── .gitignore │ ├── README.md │ ├── build.sh │ ├── e2e-electron/ │ │ ├── electron.spec.tsx │ │ └── tsconfig.json │ ├── forge.config.js │ ├── getProtocolHandler.ts │ ├── images/ │ │ ├── generate_from_svg.sh │ │ └── loading-effect.css │ ├── loadingHTML.ts │ ├── main.ts │ ├── mainWindow.ts │ ├── package.json │ ├── playwright.config.ts │ ├── screenCapture.bat │ ├── setContextMenu.ts │ ├── start.sh │ ├── test-linux-macos.sh │ ├── test-linux.sh │ ├── test-setup.js │ ├── test-w-debug.sh │ ├── test.sh │ ├── tsconfig.json │ ├── win-inno-setup.ts │ └── win.js ├── package.json ├── releases/ │ ├── v1.0.0.md │ ├── v2.0.0.md │ ├── v2.2.2.md │ ├── v2.2.3.md │ └── v2.2.4.md ├── scripts/ │ ├── demo/ │ │ ├── SCREEN RECORD.txt │ │ ├── record-local.sh │ │ └── split-video.sh │ ├── get_version.sh │ ├── release.sh │ ├── start-dev-electron.sh │ ├── start-dev.sh │ ├── start-prod.sh │ └── test.sh └── server/ ├── .gitignore ├── .vscode/ │ └── settings.json ├── eslint.config.mjs ├── licenses.json ├── package.json ├── proj-prgl.js ├── sample_schemas/ │ ├── _crypto.sql │ ├── cleaning.sql │ ├── cloud_computing.sql │ ├── countries.sql │ ├── crypto/ │ │ ├── onMount.ts │ │ ├── tableConfig.ts │ │ └── workspaceConfig.ts │ ├── financial.sql │ ├── food_delivery/ │ │ ├── connection.ts │ │ ├── databaseConfig.ts │ │ ├── onInit.sql │ │ ├── onMount.ts │ │ └── workspaceConfig.ts │ ├── lodging.sql │ ├── maps.sql │ ├── property_management/ │ │ ├── onMount.ts │ │ └── workspaceConfig.ts │ ├── sales.sql │ ├── sample.sql │ ├── testschema.ts │ └── weather/ │ └── onMount.ts ├── src/ │ ├── BackupManager/ │ │ ├── BackupManager.ts │ │ ├── checkAutomaticBackup.ts │ │ ├── getInstalledPrograms.ts │ │ ├── pgDump.ts │ │ ├── pgRestore.ts │ │ ├── pipeFromCommand.ts │ │ ├── pipeToCommand.ts │ │ └── utils.ts │ ├── ConnectionManager/ │ │ ├── ConnectionManager.ts │ │ ├── ForkedPrglProcRunner/ │ │ │ ├── ForkedPrglProcRunner.ts │ │ │ ├── createProc.ts │ │ │ └── forkedProcess.ts │ │ ├── connectionManagerUtils.ts │ │ ├── getConnectionPublish.ts │ │ ├── getConnectionPublishMethods.ts │ │ ├── getInitiatedPostgresqlPIDs.ts │ │ ├── initConnectionManager.ts │ │ ├── saveCertificates.ts │ │ └── startConnection.ts │ ├── Logger.ts │ ├── McpHub/ │ │ ├── AnthropicMcpHub/ │ │ │ ├── McpHub.ts │ │ │ ├── McpTypes.ts │ │ │ ├── connectToMCPServer.ts │ │ │ ├── fetchMCPResourceTemplatesList.ts │ │ │ ├── fetchMCPResourcesList.ts │ │ │ ├── fetchMCPToolsList.ts │ │ │ ├── installMCPServer.ts │ │ │ ├── runShellCommand.ts │ │ │ └── startMcpHub.ts │ │ ├── DefaultMCPServers/ │ │ │ ├── DefaultMCPServers.ts │ │ │ └── mcpGithub.ts │ │ ├── ProstglesMcpHub/ │ │ │ ├── ProstglesMCPServerTypes.ts │ │ │ ├── ProstglesMCPServers/ │ │ │ │ ├── DockerSandbox/ │ │ │ │ │ ├── createContainer.spec.ts │ │ │ │ │ ├── createContainer.ts │ │ │ │ │ ├── dockerMCPServerProxy/ │ │ │ │ │ │ ├── dockerContainerAuthRegistry.ts │ │ │ │ │ │ ├── dockerMCPServerProxy.ts │ │ │ │ │ │ └── isPortFree.ts │ │ │ │ │ ├── executeDockerCommand.ts │ │ │ │ │ ├── fetchTools.ts │ │ │ │ │ ├── getContainerLogs.ts │ │ │ │ │ ├── getDockerGatewayIP.ts │ │ │ │ │ └── getDockerRunArgs.ts │ │ │ │ ├── DockerSandbox.mcp.ts │ │ │ │ └── WebSearch.mcp.ts │ │ │ ├── ProstglesMCPServers.ts │ │ │ └── ProstglesMcpHub.ts │ │ ├── callMCPServerTool.ts │ │ ├── fetchMCPServerConfigs.ts │ │ ├── insertServerList.ts │ │ ├── reloadMcpServerTools.ts │ │ └── testMCPServerConfig.ts │ ├── SecurityManager/ │ │ └── initUsers.ts │ ├── ServiceManager/ │ │ ├── ServiceManager.spec.ts │ │ ├── ServiceManager.ts │ │ ├── ServiceManagerTypes.ts │ │ ├── buildService.ts │ │ ├── dockerInspect.ts │ │ ├── enableService.ts │ │ ├── getDockerBuildHash.ts │ │ ├── getSelectedConfigEnvs.ts │ │ ├── getServiceEndoints.ts │ │ ├── initialiseServices.ts │ │ ├── services/ │ │ │ ├── speechToText/ │ │ │ │ ├── speechToText.service.ts │ │ │ │ └── src/ │ │ │ │ ├── Dockerfile │ │ │ │ ├── app.py │ │ │ │ └── requirements.txt │ │ │ └── webSearchSearxng/ │ │ │ ├── src/ │ │ │ │ ├── Dockerfile │ │ │ │ ├── limiter.toml │ │ │ │ └── settings.yml │ │ │ └── webSearchSearxng.service.ts │ │ ├── startService.ts │ │ └── stopService.ts │ ├── authConfig/ │ │ ├── OAuthProviders/ │ │ │ ├── getOAuthLoginProviders.ts │ │ │ └── loginWithProvider.ts │ │ ├── authUtils.ts │ │ ├── createPasswordlessAdminSessionIfNeeded.ts │ │ ├── createPublicUserSessionIfAllowed.ts │ │ ├── emailProvider/ │ │ │ ├── getEmailAuthProvider.ts │ │ │ ├── getEmailSenderWithMockTest.ts │ │ │ └── onEmailRegistration.ts │ │ ├── getActiveSession.ts │ │ ├── getAuth.ts │ │ ├── getLogin.ts │ │ ├── getUser.ts │ │ ├── onMagicLinkOrOTP.ts │ │ ├── onUseOrSocketConnected.ts │ │ ├── sessionUtils.ts │ │ ├── startRateLimitedLoginAttempt.ts │ │ ├── subscribeToAuthSetupChanges.ts │ │ └── upsertSession.ts │ ├── cloudClients/ │ │ └── cloudClients.ts │ ├── connectionUtils/ │ │ ├── getConnectionDetails.ts │ │ ├── testDBConnection.ts │ │ └── validateConnection.ts │ ├── electronConfig.ts │ ├── envVars.ts │ ├── getPSQLQueries.ts │ ├── index.ts │ ├── init/ │ │ ├── cleanupTestDatabases.ts │ │ ├── initExpressAndIOServers.ts │ │ ├── insertStateDatabase.ts │ │ ├── isRetryableError.ts │ │ ├── logOutgoingHttpRequests.ts │ │ ├── onProstglesReady.ts │ │ ├── setDBSRoutesForElectron.ts │ │ ├── startDevHotReloadNotifier.ts │ │ ├── startProstgles.ts │ │ ├── testDashboardTypesContent.ts │ │ └── tryStartProstgles.ts │ ├── init.sql │ ├── methods/ │ │ ├── getPidStats.ts │ │ ├── getPidStatsFromProc.ts │ │ └── statusMonitorUtils.ts │ ├── publish/ │ │ ├── getPublishLLM.ts │ │ └── publish.ts │ ├── publishMethods/ │ │ ├── applySampleSchema.ts │ │ ├── askLLM/ │ │ │ ├── LLMResponseTypes.ts │ │ │ ├── askLLM.ts │ │ │ ├── checkLLMLimit.ts │ │ │ ├── checkMaxCostLimitForChat.ts │ │ │ ├── fetchLLMResponse.ts │ │ │ ├── getFullPrompt.ts │ │ │ ├── getLLMRequestBody.ts │ │ │ ├── getLLMToolsAllowedInThisChat.ts │ │ │ ├── getLLMUsageCost.ts │ │ │ ├── getUserMessageCost.ts │ │ │ ├── parseLLMResponseObject.ts │ │ │ ├── prostglesLLMTools/ │ │ │ │ ├── getMCPServerTools.ts │ │ │ │ ├── getProstglesDBTools.ts │ │ │ │ ├── getProstglesLLMTools.ts │ │ │ │ ├── getPublishedMethodsTools.ts │ │ │ │ ├── prostglesMcpTools.ts │ │ │ │ └── runProstglesDBTool.ts │ │ │ ├── readFetchStream.ts │ │ │ ├── refreshModels.ts │ │ │ ├── runApprovedTools/ │ │ │ │ ├── runApprovedTools.ts │ │ │ │ └── validateLastMessageToolUseRequests.ts │ │ │ └── setupLLM.ts │ │ ├── deleteConnection.ts │ │ ├── getConnectionAndDatabaseConfig.ts │ │ ├── getNodeTypes.ts │ │ ├── prostglesSignup.ts │ │ ├── publishMethods.ts │ │ └── setFileStorage.ts │ ├── tableConfig/ │ │ ├── tableConfig.ts │ │ ├── tableConfigAccessControl.ts │ │ ├── tableConfigBackups.ts │ │ ├── tableConfigConnections.ts │ │ ├── tableConfigGlobalSettings.ts │ │ ├── tableConfigLinks.ts │ │ ├── tableConfigLlm/ │ │ │ ├── tableConfigLlm.ts │ │ │ ├── tableConfigLlmChats.ts │ │ │ └── tableConfigLlmExtraRequestData.ts │ │ ├── tableConfigMCPServers.ts │ │ ├── tableConfigMigrations.ts │ │ ├── tableConfigPublishedMethods.ts │ │ ├── tableConfigUsers.ts │ │ ├── tableConfigWindows.ts │ │ └── tableConfigWorkspaces.ts │ ├── testElectron.ts │ └── upsertConnection.ts ├── tsconfig.json └── tslint.json ================================================ FILE CONTENTS ================================================ ================================================ FILE: .dockerignore ================================================ .git .env .github .vscode scripts/demo e2e electron releases **/node_modules **/dist **/prostgles_backups **/prostgles_media **/prostgles_storage **/prostgles_mcp ================================================ FILE: .eslintrc.common.js ================================================ /** * @type {import('eslint').Linter.Config} */ module.exports = { // "root": true, parserOptions: { ecmaVersion: "latest", sourceType: "module", allowImportExportEverywhere: true, project: ["./tsconfig.json"], // "tsconfigRootDir": __dirname, }, // "ingorePatterns": "**/*.d.ts, **/*.js", ignores: [ "node_modules", "dist", "examples", "**/*.d.ts", "**/*.js", "tests", ".eslintrc.js", ".eslint.config.js", "*.json", "**/*.json", ], parser: "@typescript-eslint/parser", plugins: ["@typescript-eslint"], extends: ["eslint:recommended", "plugin:@typescript-eslint/recommended"], rules: { "@typescript-eslint/no-misused-spread": [ "error", { allow: [ "CSSStyleDeclaration", "Partial", "DOMStringMap", ], }, ], "no-cond-assign": "error", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/ban-types": "off", "@typescript-eslint/ban-ts-comment": "off", "no-async-promise-executor": "off", "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-unused-vars": "off", "@typescript-eslint/no-empty-function": "off", "@typescript-eslint/prefer-promise-reject-errors": "off", "no-unused-vars": "off", "no-empty": "off", "no-constant-condition": "error", "@typescript-eslint/no-unnecessary-condition": "error", "@typescript-eslint/consistent-type-imports": [ "error", { disallowTypeAnnotations: false, }, ], quotes: [ "error", "double", { avoidEscape: true, allowTemplateLiterals: true, }, ], }, }; ================================================ FILE: .gitattributes ================================================ # Never modify line endings of bash scripts *.sh -crlf ================================================ FILE: .github/ISSUE_TEMPLATE/BUG.yml ================================================ name: Bug Report description: File a bug report title: "[Bug]: " labels: ["bug", "triage"] assignees: - octocat body: - type: markdown attributes: value: | Thanks for taking the time to fill out this bug report! - type: textarea id: what-happened attributes: label: What happened? description: Also tell us, what did you expect to happen? placeholder: Tell us what you see! value: "A bug happened!" validations: required: true - type: dropdown id: product attributes: label: Which product are you seeing the problem on? multiple: true options: - UI - Prostgles-Desktop - type: dropdown id: os attributes: label: What OS are you seeing the problem on? multiple: true options: - Android - iOS - Windows - Linux - type: dropdown id: browsers attributes: label: What browsers are you seeing the problem on? multiple: true options: - Firefox - Chrome - Safari - Microsoft Edge - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/CUSTOM.yml ================================================ name: Custom issue description: File a feature request title: "[Title]" assignees: - prostgles body: - type: textarea id: details attributes: label: Issue details value: "Description ..." validations: required: true ================================================ FILE: .github/ISSUE_TEMPLATE/FEATURE.yml ================================================ name: Feature request description: File a feature request title: "[Feature]: " labels: ["feature"] assignees: - prostgles body: - type: markdown attributes: value: | Thanks for taking the time to fill out this feature request! - type: textarea id: details attributes: label: Requested Behavior description: What do you want to be able to do value: "I need to " validations: required: true - type: textarea id: current-approach attributes: label: How to achieve this now description: Current work arounds to accomplish requested behavior value: "You have to " validations: required: false ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ ### Actual Behavior ### Expected Behavior ### Steps to reproduce the issue #### Data used #### Config ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ blank_issues_enabled: false ================================================ FILE: .github/ISSUE_TEMPLATE/custom.md ================================================ ## Issue title ## Details ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ ## Requested Behavior ## Use Cases ## Current work arounds to accomplish requested behavior ================================================ FILE: .github/workflows/docker_test.yml ================================================ name: Test docker scripts on: pull_request: branches: [main, master] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test_docker: name: Test docker scripts runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Docker run run: | docker run -d -p 127.0.0.1:5432:5432 -e POSTGRES_PASSWORD=postgres postgres TEST_SCRIPT=$(awk '/```docker-run.sh/{f=1;next} /```/{f=0} f' README.md) echo $TEST_SCRIPT eval "$TEST_SCRIPT" - name: Test Docker run run: | sleep 15 docker ps pg_container_name=$(docker ps --format '{{.Names}}\t{{.Image}}' | grep 'prostgles' | head -1 | awk '{print $1}') echo $pg_container_name eval "docker logs $pg_container_name" result=$(curl -b -i -L -v localhost:3004) echo $result echo $result | grep -q "Prostgles UI"; echo $? docker stop $(docker ps -a -q) - name: Docker compose run: | TEST_SCRIPT=$(awk '/```docker-compose.sh/{f=1;next} /```/{f=0} f' README.md) echo $TEST_SCRIPT eval "$TEST_SCRIPT" - name: Test Docker compose run: | sleep 10 docker ps result=$(curl -b -i -L -v localhost:3004) echo $result | grep -q "Prostgles UI"; echo $? ================================================ FILE: .github/workflows/electron_build_linux.yml ================================================ name: Electron build & test linux on: workflow_dispatch jobs: electron_build: timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Install dependencies, build and test run: | sudo apt-get update && \ cd electron && npm run build-linux - name: Install dependencies for testing run: | sudo apt-get update cd electron && sudo apt-get install -y x11-xserver-utils scrot libnotify4 libnss3-dev libatk1.0-0 libatk-bridge2.0-0 libgdk-pixbuf2.0-0 libgtk-3-0 gconf-service gconf2 DEBIAN_FRONTEND=noninteractive sudo apt install -y x11-apps xvfb gdebi-core - name: Install the app run: | cd electron sha256sum ./dist/*.deb > ./dist/checksum.txt cat ./dist/checksum.txt sudo dpkg -i ./dist/*.deb - name: Test installation file run: | cd electron xvfb-run --server-num=98 --server-args="-screen 0 1920x1080x24" -- bash -c "QTWEBENGINE_CHROMIUM_FLAGS=--disable-gpu prostgles-desktop & sleep 5 && scrot ./dist/screenshot.png && exit" && exit - uses: actions/upload-artifact@v4 if: always() with: name: installers path: electron/dist compression-level: 0 retention-days: 10 ================================================ FILE: .github/workflows/electron_build_macos.yml ================================================ name: Electron build & test macOs on: workflow_dispatch concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: electron_build_macos: timeout-minutes: 25 runs-on: macos-12-large steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 22 # node 20 is too slow - name: Install dependencies run: | cd electron npm run build-macos rm -rf ./dist/mac-universal - name: Test DMG file installations and run run: | shasum -a 256 electron/dist/*.dmg > electron/dist/checksum.txt cat electron/dist/checksum.txt hdiutil attach electron/dist/*.dmg ls -la /Volumes sudo cp -r /Volumes/Prostgles*/*.app /Applications open /Applications/Prostgles*.app sleep 10 screencapture electron/dist/picture.png - uses: actions/upload-artifact@v4 if: always() with: name: installers path: electron/dist compression-level: 0 retention-days: 10 ================================================ FILE: .github/workflows/electron_build_windows.yml ================================================ name: Electron build & test windows on: workflow_dispatch jobs: electron_build_windows: timeout-minutes: 12 runs-on: windows-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Build run: | cd electron && npm run build-win - name: Test build shell: cmd run: | cd electron\dist for /r %%f in (*.exe) DO certutil -hashfile "%%f" SHA256 > windows.checksums type windows.checksums for /r %%f in (*.exe) DO "%%f" /S --force-run cd .. waitfor SomethingThatIsNeverHappening /t 3 2>NUL call screenCapture.bat .\dist\screen1.png - uses: actions/upload-artifact@v3 if: always() with: name: installers path: electron/dist compression-level: 0 retention-days: 10 ================================================ FILE: .github/workflows/electron_linux_test.yml ================================================ name: Electron test linux (deprecated) on: workflow_dispatch jobs: electron_build: timeout-minutes: 20 runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Install dependencies, build and test run: | sudo apt-get install xvfb -y && cd electron && npm run test-linux - uses: actions/upload-artifact@v3 if: always() with: name: installers path: electron/dist retention-days: 10 ================================================ FILE: .github/workflows/electron_macos_test.yml ================================================ name: Electron test macos (deprecated) on: workflow_dispatch jobs: electron_build: timeout-minutes: 20 runs-on: macos-12-large steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Install dependencies, build and test run: | sleep 60 && \ screencapture -v electron/dist/vid.mov & \ cd electron && npm run test-macos - uses: actions/upload-artifact@v3 if: always() with: name: test-results path: electron/dist retention-days: 10 ================================================ FILE: .github/workflows/linux_test.yml ================================================ name: Linux & Electron test on: pull_request: branches: [main, master] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test_linux: runs-on: ubuntu-latest timeout-minutes: 45 services: postgres: image: postgis/postgis:15-3.3 env: POSTGRES_DB: db POSTGRES_PASSWORD: psw POSTGRES_USER: usr ports: - 5432:5432 options: >- --health-cmd pg_isready --health-interval 10s --health-timeout 5s --health-retries 10 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 fetch-depth: 2 # Fetch at least 2 commits to ensure doc checks work - name: Install curl inside postgres container for video needed for demo run: | pg_container_name=$(docker ps --format '{{.Names}}\t{{.Image}}' | grep 'postgis' | head -1 | awk '{print $1}') && \ echo $pg_container_name && \ docker ps -a && \ docker exec $pg_container_name /bin/sh -c "apt-get update && apt install curl -y" - name: Wait for PostgreSQL to be ready and create prostgles_desktop_db run: | until pg_isready -h localhost -U usr -d db; do sleep 1 done PGPASSWORD=psw psql -h localhost -U usr -d db -c "CREATE DATABASE prostgles_desktop_db;" - name: Install dependencies (psql with pg_dump) pg_dump version must be >= pg servers version to ensure backup/restore works run: | cd client && npm ci && \ cd ../server && npm ci && \ cd ../e2e && npm ci && npx playwright install && \ sudo sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \ sudo apt-get -y install wget ca-certificates && \ wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | sudo apt-key add - && \ sudo apt-get update && \ sudo apt-get -y install postgresql-client-16 # Install uv tool for MCP tools curl -LsSf https://astral.sh/uv/install.sh | sh npm cache clean --force - name: Run test run: | npm test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: | e2e/electron-report/ e2e/playwright-report/ retention-days: 10 test_desktop_on_macos: runs-on: macos-latest timeout-minutes: 15 if: success() steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Install curl inside postgres container for video needed for demo run: | ls -la /Applications/ - name: Install dependencies (psql with pg_dump) pg_dump version must be >= pg servers version to ensure backup/restore works run: | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" && \ brew install postgresql@14 brew services restart postgresql@14 export PATH="/opt/homebrew/opt/postgresql@14/bin:$PATH" /opt/homebrew/opt/postgresql@14/bin/psql --version /opt/homebrew/opt/postgresql@14/bin/pg_dump --version until pg_isready do echo "Waiting for postgres to start..." sleep 1 done /opt/homebrew/opt/postgresql@14/bin/createuser -s postgres sudo psql -U postgres -c "CREATE USER usr WITH LOGIN SUPERUSER ENCRYPTED PASSWORD 'psw'" sudo psql -U postgres -c "CREATE DATABASE prostgles_desktop_db OWNER usr" brew install postgis brew services restart postgresql psql --version cd electron npm run test-macos - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report-electron path: e2e/electron-report/ retention-days: 10 ================================================ FILE: .github/workflows/macos_test.yml ================================================ name: MacOS test on: workflow_dispatch concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test_macos: runs-on: macos-latest timeout-minutes: 30 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Install curl inside postgres container for video needed for demo run: | ls -la /Applications/ - name: Install dependencies (psql with pg_dump) pg_dump version must be >= pg servers version to ensure backup/restore works run: | /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" && \ brew install postgresql@14 brew services restart postgresql@14 export PATH="/opt/homebrew/opt/postgresql@14/bin:$PATH" /opt/homebrew/opt/postgresql@14/bin/psql --version /opt/homebrew/opt/postgresql@14/bin/pg_dump --version until pg_isready do echo "Waiting for postgres to start..." sleep 1 done /opt/homebrew/opt/postgresql@14/bin/createuser -s postgres sudo psql -U postgres -c "CREATE USER usr WITH LOGIN SUPERUSER ENCRYPTED PASSWORD 'psw'" sudo psql -U postgres -c "CREATE DATABASE db OWNER usr" brew install postgis brew services restart postgresql psql --version cd client && npm ci && \ cd ../server && npm ci && \ cd ../e2e && npm ci && npx playwright install - name: Run test run: | npm test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: e2e/playwright-report/ retention-days: 10 # brew install pcre # sudo ln -s /opt/homebrew/Cellar/postgresql@16/16.0/bin/postgres /usr/local/bin/postgres # wget https://download.osgeo.org/postgis/source/postgis-3.4.0.tar.gz # tar -xvzf postgis-3.4.0.tar.gz # rm postgis-3.4.0.tar.gz # cd postgis-3.4.0 # ./configure --with-projdir=/opt/homebrew/opt/proj --with-protobufdir=/opt/homebrew/opt/protobuf-c --with-pgconfig=/opt/homebrew/opt/postgresql@16/bin/pg_config --with-jsondir=/opt/homebrew/opt/json-c --with-sfcgal=/opt/homebrew/opt/sfcgal/bin/sfcgal-config --with-pcredir=/opt/homebrew/opt/pcre "LDFLAGS=$LDFLAGS -L/opt/homebrew/Cellar/gettext/0.22.2/lib" "CFLAGS=-I/opt/homebrew/Cellar/gettext/0.22.2/include" # make # make install # export PATH="/opt/homebrew/opt/postgresql@16/bin:$PATH" && \ ================================================ FILE: .github/workflows/on_release.yml ================================================ name: On Release on: push: tags: - "v*" concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: build_docker_images: timeout-minutes: 20 runs-on: ubuntu-latest strategy: fail-fast: false # Optional: Prevent other matrix jobs from being cancelled if one fails matrix: include: # Define the specific combinations you need - image_name: ui dockerfile: Dockerfile - image_name: ui-db dockerfile: DB-Dockerfile # Add more entries here if you have more images to build steps: - name: Checkout uses: actions/checkout@v4 - name: Extract version id: extract_version run: | VERSION=$(./scripts/get_version.sh) echo "version=v$VERSION" >> $GITHUB_OUTPUT echo "Extracted version: v$VERSION" - name: Ensure release file exists run: | pr_version=$(./scripts/get_version.sh) base_ref="${{ github.base_ref }}" release_file_path="./releases/v${pr_version}.md" echo "Checking for release file: ${release_file_path}" if [ -f "$release_file_path" ]; then echo "Release file found: ${release_file_path}" else echo "Release file not found for v${pr_version}" echo "Ensure this file exists: ${release_file_path}" exit 1 fi - name: Ensure docker-compose.yml image tags are updated run: | pr_version=$(./scripts/get_version.sh) expected_tag="v${pr_version}" docker_compose_file="./docker-compose.yml" echo "Checking docker-compose.yml for correct image tags..." if grep -q "image: prostgles/ui:${expected_tag}" "$docker_compose_file" && \ grep -q "image: prostgles/ui-db:${expected_tag}" "$docker_compose_file"; then echo "docker-compose.yml uses the correct image tags: ${expected_tag}" else echo "docker-compose.yml does not use the correct image tags." echo "Expected tags: prostgles/ui:${expected_tag} and prostgles/ui-db:${expected_tag}" exit 1 fi - name: Create Release with Notes uses: softprops/action-gh-release@v2 with: body_path: ./releases/${{ steps.extract_version.outputs.version }}.md draft: false - name: Login to Docker Hub uses: docker/login-action@v3 with: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 - name: Build and push ui uses: docker/build-push-action@v6 with: context: . file: ${{ matrix.dockerfile }} platforms: linux/amd64 push: true tags: | prostgles/${{ matrix.image_name }}:latest prostgles/${{ matrix.image_name }}:${{ steps.extract_version.outputs.version }} build_linux: timeout-minutes: 20 runs-on: ubuntu-latest needs: build_docker_images if: success() steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Build Linux run: | sudo apt-get install rpm -y && \ cd electron && npm run build-linux cd dist sha256sum *.* > linux.checksums echo linux.checksums - name: Release Linux uses: softprops/action-gh-release@v2 with: files: | electron/dist/**/*.deb electron/dist/**/*.rpm electron/dist/**/*.AppImage electron/dist/**/*.checksums build_windows: timeout-minutes: 20 needs: build_docker_images runs-on: windows-latest if: success() steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - name: Build Windows shell: cmd run: | cd electron npm run build-win cd dist for /r %%f in (*.exe) DO certutil -hashfile "%%f" SHA256 > windows.checksums type windows.checksums - name: Release Windows uses: softprops/action-gh-release@v2 with: files: | electron/dist/**/*.exe electron/dist/**/*.checksums build_macos: timeout-minutes: 20 needs: build_docker_images runs-on: macos-latest if: success() steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 18 - name: Build MacOS run: | cd electron npm run build-macos rm -rf ./dist/mac-universal cd dist shasum -a 256 *.* > macos.checksums cat macos.checksums - name: Release MacOS uses: softprops/action-gh-release@v2 with: files: | electron/dist/**/*.dmg electron/dist/**/*.checksums ================================================ FILE: .github/workflows/package_version_increased.yml ================================================ name: Ensure package version is higher on: pull_request: branches: [main, master] merge_group: concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test_docker: name: Run test suite runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v3 - name: Get versions id: pr-version run: | sudo apt install jq ./scripts/get_version.sh > pr_version.txt git fetch origin ${{ github.base_ref }} --depth=1 git checkout origin/${{ github.base_ref }} -- package.json ./electron/package.json ./scripts/get_version.sh > current_version.txt - name: Compare versions run: | current_version=$(cat current_version.txt) pr_version=$(cat pr_version.txt) if [ "$(printf '%s\n' "$current_version" "$pr_version" | sort -V | head -n1)" = "$pr_version" ]; then echo "Version in package.json is not greater than the version in the base branch: $current_version <= $pr_version" exit 1 else echo "Version check passed: $current_version <= $pr_version" fi - name: Ensure Changelog exists if: success() # Ensure version comparison passed run: | pr_version=$(cat pr_version.txt) base_ref="${{ github.base_ref }}" release_file_path="./changelog/v${pr_version}.md" echo "Checking for changelog file: ${release_file_path}" if [ -f "$release_file_path" ]; then echo "Changelog file found: ${release_file_path}" else echo "Changelog file not found for v${pr_version}." echo "Ensure this file exists: ${release_file_path}" exit 1 fi ================================================ FILE: .github/workflows/windows_test.yml ================================================ name: Windows test on: workflow_dispatch concurrency: group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} cancel-in-progress: true jobs: test_windows: runs-on: windows-latest timeout-minutes: 30 steps: - uses: actions/checkout@v3 - uses: actions/setup-node@v3 with: node-version: 20 - uses: nyurik/action-setup-postgis@v2 with: username: postgres password: postgres database: postgres port: 5432 id: postgres - name: Install postgres run: | cd "C:\Program Files\PostgreSQL\14\bin" .\psql.exe -c "CREATE EXTENSION postgis;" "postgresql://postgres:postgres@127.0.0.1:5432/postgres" .\psql.exe -c "CREATE USER usr WITH LOGIN SUPERUSER ENCRYPTED PASSWORD 'psw'" "postgresql://postgres:postgres@127.0.0.1:5432/postgres" .\psql.exe -c "CREATE DATABASE db OWNER usr" "postgresql://postgres:postgres@127.0.0.1:5432/postgres" - name: Install test dependencies run: | npm config set script-shell "C:\\Program Files\\Git\\bin\\bash.exe" cd client npm ci cd ../server npm ci cd ../e2e npm ci npx playwright install - name: Run test run: | npm test - uses: actions/upload-artifact@v4 if: always() with: name: playwright-report path: e2e/playwright-report/ retention-days: 10 ================================================ FILE: .gitignore ================================================ node_modules prostgles_media prostgles_storage prostgles_backups prostgles_certificates prostgles_mcp .electron-auth.json **/.electron-auth.json .prostgles-desktop-config.json **/.prostgles-desktop-config.json scripts/demo/dist client/build client/static/icons server/dist server/media BACKUP .Trash-1000/ .npmrc .npmignore media log.txt ui .aider* .env configs debug docs/screenshots/svgif-scenes ================================================ FILE: .prettierignore ================================================ e2e/playwright-report/trace/ server/*.json client/*.json .vscode/ *.d.ts ================================================ FILE: .prettierrc ================================================ { "experimentalTernaries": true } ================================================ FILE: .vscode/settings.json ================================================ { "git.terminalAuthentication": false, "git.autofetch": false, "git.ignoreLimitWarning": true, "typescript.tsdk": "./node_modules/typescript/lib", "editor.formatOnSave": true, "prettier.experimentalTernaries": true, "[javascript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescript]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "[typescriptreact]": { "editor.defaultFormatter": "esbenp.prettier-vscode" }, "prettier.printWidth": 100 } ================================================ FILE: DB-Dockerfile ================================================ FROM postgis/postgis:17-3.4 # Switch to root user to install packages USER root # procps needed for stat monitoring RUN apt-get update && apt-get install -y procps && \ rm -rf /var/lib/apt/lists/* # Switch back to the default postgres user USER postgres ================================================ FILE: Dockerfile ================================================ FROM node:20-slim AS base WORKDIR /usr/src/app COPY . . # Install latest pg_dump (psql v17) to ensure backup/restore works RUN apt-get update && \ apt-get install -y --no-install-recommends gnupg wget ca-certificates lsb-release && \ apt-get upgrade -y && \ sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \ wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \ apt-get update && \ apt-get install -y --no-install-recommends postgresql-client-17 && \ pg_dump --version && \ psql --version && \ # Clean up apt-get clean && \ apt-get purge -y --auto-remove gnupg wget lsb-release && \ rm -rf /var/lib/apt/lists/* FROM base AS deps WORKDIR /usr/src/app/client RUN npm run build && cd ../server && npm run build ENV NODE_ENV=production ENV IS_DOCKER=yes CMD ["node", "/usr/src/app/server/dist/server/src/index.js"] ================================================ FILE: LICENSE ================================================ GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 Copyright (C) 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. Preamble The GNU Affero General Public License is a free, copyleft license for software and other kinds of works, specifically designed to ensure cooperation with the community in the case of network server software. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, our General Public Licenses are intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. Developers that use our General Public Licenses protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License which gives you legal permission to copy, distribute and/or modify the software. A secondary benefit of defending all users' freedom is that improvements made in alternate versions of the program, if they receive widespread use, become available for other developers to incorporate. Many developers of free software are heartened and encouraged by the resulting cooperation. However, in the case of software used on network servers, this result may fail to come about. The GNU General Public License permits making a modified version and letting the public access it on a server without ever releasing its source code to the public. The GNU Affero General Public License is designed specifically to ensure that, in such cases, the modified source code becomes available to the community. It requires the operator of a network server to provide the source code of the modified version running there to the users of that server. Therefore, public use of a modified version, on a publicly accessible server, gives the public access to the source code of the modified version. An older license, called the Affero General Public License and published by Affero, was designed to accomplish similar goals. This is a different license, not a version of the Affero GPL, but Affero has released a new version of the Affero GPL which permits relicensing under this license. The precise terms and conditions for copying, distribution and modification follow. TERMS AND CONDITIONS 0. Definitions. "This License" refers to version 3 of the GNU Affero General Public License. "Copyright" also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. "The Program" refers to any copyrightable work licensed under this License. Each licensee is addressed as "you". "Licensees" and "recipients" may be individuals or organizations. To "modify" a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a "modified version" of the earlier work or a work "based on" the earlier work. A "covered work" means either the unmodified Program or a work based on the Program. To "propagate" a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well. To "convey" a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays "Appropriate Legal Notices" to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. 1. Source Code. The "source code" for a work means the preferred form of the work for making modifications to it. "Object code" means any non-source form of a work. A "Standard Interface" means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The "System Libraries" of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A "Major Component", in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. The "Corresponding Source" for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work. The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. 2. Basic Permissions. All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 3. Protecting Users' Legal Rights From Anti-Circumvention Law. No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. 4. Conveying Verbatim Copies. You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. 5. Conveying Modified Source Versions. You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: a) The work must carry prominent notices stating that you modified it, and giving a relevant date. b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to "keep intact all notices". c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it. d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an "aggregate" if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. 6. Conveying Non-Source Forms. You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b. d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements. e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d. A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work. A "User Product" is either (1) a "consumer product", which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, "normally used" refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product. "Installation Information" for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made. If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM). The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. 7. Additional Terms. "Additional permissions" are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or d) Limiting the use for publicity purposes of names of licensors or authors of the material; or e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors. All other non-permissive additional terms are considered "further restrictions" within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying. If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. 8. Termination. You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. 9. Acceptance Not Required for Having Copies. You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. 10. Automatic Licensing of Downstream Recipients. Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An "entity transaction" is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. 11. Patents. A "contributor" is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's "contributor version". A contributor's "essential patent claims" are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, "control" includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. In the following three paragraphs, a "patent license" is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To "grant" such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party. If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. "Knowingly relying" means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid. If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it. A patent license is "discriminatory" if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. 12. No Surrender of Others' Freedom. If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. 13. Remote Network Interaction; Use with the GNU General Public License. Notwithstanding any other provision of this License, if you modify the Program, your modified version must prominently offer all users interacting with it remotely through a computer network (if your version supports such interaction) an opportunity to receive the Corresponding Source of your version by providing access to the Corresponding Source from a network server at no charge, through some standard or customary means of facilitating copying of software. This Corresponding Source shall include the Corresponding Source for any work covered by version 3 of the GNU General Public License that is incorporated pursuant to the following paragraph. Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the work with which it is combined will remain governed by version 3 of the GNU General Public License. 14. Revised Versions of this License. The Free Software Foundation may publish revised and/or new versions of the GNU Affero General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU Affero General Public License "or any later version" applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU Affero General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU Affero General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. 15. Disclaimer of Warranty. THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 16. Limitation of Liability. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. 17. Interpretation of Sections 15 and 16. If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS How to Apply These Terms to Your New Programs If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the "copyright" line and a pointer to where the full notice is found. Prostgles Desktop Copyright (C) 2024 Stefan L This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero General Public License along with this program. If not, see . Also add information on how to contact you by electronic and paper mail. If your software can interact with users remotely through a computer network, you should also make sure that it provides a way for users to get its source. For example, if your program is a web application, its interface could display a "Source" link that leads users to an archive of the code. There are many ways you could offer source, and different solutions will be better for different programs; see section 13 for the specific requirements. You should also get your employer (if you work as a programmer) or school, if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . ================================================ FILE: PRIVACY ================================================

With the exception of information you voluntarily submit via the "Send feedback" feature, Prostgles does not collect, transmit, or store any of your data on our servers.

All your data is stored only in the following locations, depending on how you use the software:

  1. Your connected database: Metadata such as database connections, dashboard settings, SQL queries, etc., are stored within the database you provide and connect to. Depending on the variant:
    • Electron/Desktop app: Data is in the connection titled Prostgles Desktop state.
    • Source/Docker/Node.js app: Data is in the connection titled Prostgles UI state.
  2. Your local machine:
    • Electron/Desktop app: Stores files, backups and encrypted state database connection details in your user data directory (Windows: %APPDATA%/prostgles-desktop, macOS: ~/Library/Application Support/prostgles-desktop, Linux: ~/.config/prostgles-desktop).
    • Source/Docker/Node.js app: Stores files, backups and state state database configuration in the directory where the software is run (current working directory) unless otherwise configured.
  3. Optional cloud storage providers: If you configure file storage or backups, these providers store your data according to your setup.

To the best of our knowledge, all third-party libraries used in Prostgles do not collect, transmit, or store your data on their servers.

================================================ FILE: README.md ================================================ # Prostgles UI SQL Editor and internal tool builder for Postgres [Live demo](https://playground.prostgles.com/) ### Screenshots [More](https://prostgles.com/ui)

### Features - SQL Editor with context-aware schema auto-completion and documentation extracts and hints - Realtime data exploration dashboard with versatile layout system (tab and side-by-side view) - Table view with controls to view related data, sort, filter and cross-filter - Map and Time charts with aggregations - Data insert/update forms with autocomplete - Role based access control rules - Isomorphic TypeScript API with schema types, end to end type safety and React hooks - File upload (locally or to cloud) - Search all tables from public schema - Media file display (audio/video/image/html/svg) - Data import (CSV, JSON and GeoJSON) - Backup/Restore (locally or to cloud) - TypeScript server-side functions (experimental) - Mobile friendly - LISTEN NOTIFY support ### Installation - Docker compose (recommended) Download the source code: ```bash git clone https://github.com/prostgles/ui.git cd ui ``` Docker setup. By default the app will be accessible at [localhost:3004](http://localhost:3004). Omit "--build" to use our published images. ```docker-compose.sh docker compose up -d --build ``` To use a custom port (3099 for example) and/or a custom binding address (0.0.0.0 for example): ```bash PRGL_DOCKER_IP=0.0.0.0 PRGL_DOCKER_PORT=3099 docker compose up --build ``` To use with docker mcp experimental feature: ```bash docker compose --profile=docker-mcp up --build ``` ### Installation - use existing PostgreSQL instance Use this method if you want to use your existing database to store Prostgles metadata Download the source code: ```bash git clone https://github.com/prostgles/ui.git prostgles cd prostgles ``` Build and run our docker image: ```docker-run.sh docker build -t prostgles . docker run --network=host -d -p 127.0.0.1:3004:3004 \ -e POSTGRES_HOST=127.0.0.1 \ -e POSTGRES_PORT=5432 \ -e POSTGRES_DB=postgres \ -e POSTGRES_USER=postgres \ -e POSTGRES_PASSWORD=postgres \ -e PROSTGLES_UI_HOST=0.0.0.0 \ -e IS_DOCKER=yes \ -e NODE_ENV=production \ prostgles ``` Your server will be running on [localhost:3004](http://localhost:3004). ### Development #### 1. Install dependencies: - [NodeJS](https://nodejs.org/en/download) - [Postgres](https://www.postgresql.org/download/): For full features **postgresql-17-postgis-3.4** is recommended #### 2. Create a database and user update `.env`. All prostgles state and metadata will be stored in this database sudo su - postgres createuser --superuser usr psql -c "alter user usr with encrypted password 'psw'" createdb db -O usr #### 3. Start app in dev mode (will install npm packages) npm run dev ### Testing Ensure the app is running in development mode and: cd e2e && npm test-local ================================================ FILE: SECURITY.md ================================================ # Security Policy ### Reporting a Vulnerability Please report (suspected) security vulnerabilities to security@prostgles.com - You will receive a response from us within 7 working days. - If the issue is confirmed, we will release a patch as soon as possible depending on complexity but historically within a few days. ================================================ FILE: changelog/v1.0.0.md ================================================ Prostgles UI v1.0.0 Workspaces Each database connection now allows multiple workspaces. You can create and switch between different dashboards ================================================ FILE: changelog/v2.0.0.md ================================================ Prostgles UI v2 Improved UI - Referenced tables tab - SQL code blocks - SQL snippets Access control - Role-based access rules - API File storage - Saved locally or to S3 - File type and size rules Backup and Restore - Automatic or manual - Saved locally or to S3 Security - App based 2 factor-authentication - IP subnet rules - CORS ================================================ FILE: changelog/v2.2.0.md ================================================ - Schema Diagram - Link colouring modes to better understand related tables and foreign key properties - Filtering by relationship type, schema - Command and settings quick search (Ctrl+K). Access any command, setting, or action through our new command palette - AI Assistant: - Data access controls. Allow the assistant to execute sql with rollback, commit or specify allowed tables and commands for each chat - Dashboard Generation: Create dashboards from your database schema and requirements - Create task mode: Get suggested tools and data access permissions for the chat - MCP Server Tools support: Install and allow the assistant to access Model Context Protocol server tools. - File upload support - Execute sql snippets directly in chat - Real-time cost tracking per chat and configurable spending limits - Improved setup for Prostgles Desktop: "Quick setup" mode handles the creation of a state database and user. ================================================ FILE: changelog/v2.2.1.md ================================================ - Maintenance and fixes: - Fixed docker release pipeline - Updated electron packages - Remove dead code ================================================ FILE: changelog/v2.2.2.md ================================================ - Improve documentation - Maintenance and fixes: - Tidy release workflows ================================================ FILE: changelog/v2.2.3.md ================================================ - Improve documentation - Improve Docker MCP server ================================================ FILE: changelog/v2.2.4.md ================================================ - Improve documentation - Improve Tool use message UI - Fix parallel MCP requests bug ================================================ FILE: client/.babelrc ================================================ { "presets": [ [ "@babel/env", { "targets": { "esmodules": true }, "modules": false, "bugfixes": true } ], "@babel/preset-react", "@babel/preset-typescript" ], "plugins": ["dynamic-import-node"], "env": { "production": { "presets": [ [ "minify", { "builtIns": false, "evaluate": false, "mangle": false } ] ] } } } ================================================ FILE: client/.gitignore ================================================ # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. # dependencies /node_modules /.pnp .pnp.js # testing /coverage # production /build # misc .DS_Store .env.local .env.development.local .env.test.local .env.production.local npm-debug.log* yarn-debug.log* yarn-error.log* BACKUP dist /configs/last_compiled.txt ================================================ FILE: client/build.sh ================================================ npm i && export NODE_ENV=production && export BABEL_ENV=production && export NODE_OPTIONS=--max-old-space-size=2048 && npm run build-start ================================================ FILE: client/eslint.config.mjs ================================================ // @ts-check import commonConfig from "../.eslintrc.common.js"; import eslint from "@eslint/js"; import tseslint from "typescript-eslint"; import { defineConfig } from "eslint/config"; import eslintPluginReact from "eslint-plugin-react"; import eslintPluginReactHooks from "eslint-plugin-react-hooks"; // import typescriptEslint from "@typescript-eslint/eslint-plugin"; export default defineConfig( eslint.configs.recommended, tseslint.configs.recommended, tseslint.configs.recommendedTypeChecked, { ignores: [ "node_modules", "dist", "examples", "**/*.d.ts", "tests", "docs", "*.mjs", "sample_schemas", "**/*.d.ts", "**/*.js", ], }, { files: ["**/*.{ts,tsx}"], languageOptions: { parser: tseslint.parser, parserOptions: { ecmaVersion: "latest", sourceType: "module", // projectService: { // allowDefaultProject: ["*.js", "*.mjs"], // }, project: ["./tsconfig.eslint.json"], tsconfigRootDir: import.meta.dirname, }, }, plugins: { "@typescript-eslint": tseslint.plugin, react: eslintPluginReact, "react-hooks": eslintPluginReactHooks, }, settings: { react: { version: "detect", }, }, rules: { ...commonConfig.rules, "react/no-unescaped-entities": "off", "react/display-name": "off", "react/no-children-prop": "off", "react/prop-types": "off", "react/no-unused-prop-types": "off", "react-hooks/exhaustive-deps": [ "warn", { additionalHooks: "(usePromise|useEffectAsync|useProstglesClient|useAsyncEffectQueue|useEffectDeep)", }, ], "no-cond-assign": "error", "@typescript-eslint/no-namespace": "off", "@typescript-eslint/no-explicit-any": "off", "@typescript-eslint/no-non-null-assertion": "off", "@typescript-eslint/ban-types": "off", "@typescript-eslint/ban-ts-comment": "off", "@typescript-eslint/no-unused-expressions": "off", "@typescript-eslint/no-require-imports": "off", "@typescript-eslint/no-empty-object-type": "off", "no-async-promise-executor": "off", "@typescript-eslint/no-var-requires": "off", "@typescript-eslint/no-unnecessary-condition": "error", "@typescript-eslint/no-floating-promises": "warn", "no-unused-vars": "off", "no-empty": "off", "@typescript-eslint/only-throw-error": "off", "@typescript-eslint/prefer-promise-reject-errors": "off", "@typescript-eslint/restrict-template-expressions": [ "warn", { allowNumber: true, allowBoolean: true, allowNullish: true, allowArray: true, }, ], "@typescript-eslint/no-misused-promises": [ "warn", { checksVoidReturn: false }, ], "@typescript-eslint/no-unsafe-assignment": "warn", "@typescript-eslint/no-unsafe-argument": "warn", "@typescript-eslint/no-unsafe-return": "warn", "@typescript-eslint/await-thenable": "warn", "@typescript-eslint/no-unsafe-member-access": "warn", "@typescript-eslint/no-unsafe-call": "warn", "@typescript-eslint/no-unused-vars": [ "warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_", }, ], }, }, ); ================================================ FILE: client/package.json ================================================ { "name": "prostgles-ui-client", "dependencies": { "@mdi/js": "^7.4.47", "@types/dom-speech-recognition": "^0.0.6", "d3": "^7.9.0", "deck.gl": "^9.1.14", "loaders.gl": "^0.3.5", "monaco-editor": "^0.53.0", "papaparse": "^5.4.1", "prostgles-client": "^4.0.270", "qrcode": "^1.5.1", "react": "^18.3.1", "react-dom": "^18.3.1", "react-flip-move": "^3.0.5", "react-markdown": "^10.1.0", "react-router-dom": "^7.5.2", "rehype-raw": "^7.0.0", "remark-gfm": "^4.0.1", "sanitize-html": "^2.13.0", "socket.io-client": "^4.8.1", "sql-formatter": "^15.3.2", "strip-ansi": "^7.1.2", "tsconfig-paths-webpack-plugin": "^4.2.0" }, "scripts": { "prod": "NODE_ENV=production BABEL_ENV=production webpack", "start": "node scripts/start.js --no-cache", "build-start": "rm -rf ./build && webpack --config=configs/prod.js && rm -rf ./node_modules", "build": "bash ./build.sh", "dev": "npm i --no-audit --quiet && NODE_OPTIONS='--max-old-space-size=4048' && webpack --watch --config=configs/dev.js", "lib": "rm -rf ./dist && tsc --project tsconfig_lib.json && npm run copyf", "copyf": "cd ./src && find . -name '*.css' -type f -exec cp --parents {} ../dist/ \\; ", "copyf2": "find ./src/. -name '*.css' | cpio -pdm ./dist/", "lint": "eslint . --quiet --fix", "tsc": "tsc --noEmit" }, "browserslist": { "production": [ ">0.2%", "not dead", "not op_mini all" ], "development": [ "last 1 chrome version", "last 1 firefox version", "last 1 safari version" ] }, "devDependencies": { "@babel/core": "^7.28.5", "@babel/plugin-transform-runtime": "^7.28.5", "@babel/plugin-transform-typescript": "^7.28.5", "@babel/preset-env": "^7.28.5", "@babel/preset-react": "^7.28.5", "@babel/preset-typescript": "^7.28.5", "@babel/runtime": "^7.28.4", "@eslint/js": "^9.39.1", "@types/d3": "^7.4.3", "@types/dom-mediacapture-record": "^1.0.10", "@types/papaparse": "^5.3.1", "@types/qrcode": "^1.4.2", "@types/react": "^18.3.7", "@types/react-dom": "^18.3.0", "@types/react-router-dom": "^5.1.5", "@types/resize-observer-browser": "^0.1.6", "@types/sanitize-html": "^2.6.2", "@types/webpack": "^4.41.25", "@typescript-eslint/eslint-plugin": "^8.48.0", "@typescript-eslint/parser": "^8.48.0", "babel-loader": "^9.1.3", "babel-plugin-dynamic-import-node": "^2.3.3", "babel-plugin-named-asset-import": "^0.3.8", "babel-preset-minify": "^0.5.1", "circular-dependency-plugin": "^5.2.2", "copy-webpack-plugin": "^12.0.2", "css-loader": "^6.8.1", "eslint": "^9.39.1", "eslint-plugin-react": "^7.37.5", "eslint-plugin-react-hooks": "^7.0.1", "file-loader": "^6.2.0", "html-webpack-plugin": "^5.6.3", "interpolate-html-plugin": "^4.0.0", "mini-css-extract-plugin": "^2.7.1", "monaco-editor-webpack-plugin": "^7.1.0", "postcss": "^8.5.3", "postcss-flexbugs-fixes": "^5.0.2", "postcss-loader": "^7.3.4", "postcss-normalize": "^10.0.1", "postcss-preset-env": "^7.8.3", "postcss-safe-parser": "^6.0.0", "prettier": "^3.4.2", "sass-loader": "^16.0.4", "style-loader": "^4.0.0", "ts-loader": "^9.5.1", "typescript": "^5.9.3", "typescript-eslint": "^8.48.0", "url-loader": "^4.1.1", "webpack": "^5.99.7", "webpack-bundle-analyzer": "^4.10.2", "webpack-cli": "^6.0.1", "webpack-manifest-plugin": "^5.0.0", "webpack-merge": "^6.0.1", "webpack-shell-plugin-next": "^2.3.2" } } ================================================ FILE: client/public/manifest.json ================================================ { "name": "Prostgles UI", "short_name": "Prostgles UI", "description": "Dashboard and SQL editor for PostgreSQL.", "display": "standalone", "icons": [ { "src": "/prostgles-logo.svg", "sizes": "any" } ] } ================================================ FILE: client/public/robots.txt ================================================ # https://www.robotstxt.org/robotstxt.html User-agent: * Disallow: ================================================ FILE: client/setup-icons.js ================================================ /** Save all mdi icons as xml */ const fs = require("fs"); const path = require("path"); const icons = require("@mdi/js"); const saveMdiIcons = () => { const iconEntries = Object.entries(icons); const iconsDestinationFolder = path.join(__dirname, "/static/icons"); if (fs.existsSync(iconsDestinationFolder)) { const contents = fs.readdirSync(iconsDestinationFolder); if (contents.length >= iconEntries.length) { return; } fs.rm(iconsDestinationFolder, { recursive: true }, console.log); } fs.mkdirSync(iconsDestinationFolder, { recursive: true }); if (!iconEntries.length) { console.error("No icons found. Did you run npm i ?!"); process.exit(1); } const iconNames = []; iconEntries.forEach(([name, iconPathD]) => { const nameWithoutMdi = name.slice(3); iconNames.push(nameWithoutMdi); const iconSvg = ` `; fs.writeFileSync( path.join(iconsDestinationFolder, `${name.slice(3)}.svg`), iconSvg, { encoding: "utf8" }, ); }); console.log(` Saved ${iconEntries.length} icons`); fs.writeFileSync( path.join(iconsDestinationFolder, "_meta.json"), JSON.stringify(iconNames, null, 2), { encoding: "utf8" }, ); }; setTimeout(saveMdiIcons, 1000); class SaveMdiIcons { apply(compiler) { compiler.hooks.afterEmit.tap( "Save Mdi icons plugin afterEmit", (_stats) => { setTimeout(saveMdiIcons, 1000); }, ); compiler.hooks.done.tap("Save Mdi icons plugin done", (_stats) => { setTimeout(saveMdiIcons, 1000); }); } } module.exports = { SaveMdiIcons }; ================================================ FILE: client/src/App.css ================================================ *, body, html { box-sizing: border-box; } body { overflow: hidden; } .page-content { padding-top: 1em; } .active-code-block-decoration { background: lightblue; width: 5px !important; margin-left: 3px; } .active-code-block-play { margin-left: 4px; } .dark-theme .active-code-block-decoration { background: #14708f; } .dark-theme * { color-scheme: dark; } ================================================ FILE: client/src/App.tsx ================================================ import type { ReactChild } from "react"; import React, { useMemo, useState } from "react"; import { Navigate, Route, Routes as Switch } from "react-router-dom"; import "./App.css"; import Loading from "./components/Loader/Loading"; import type { CommonWindowProps } from "./dashboard/Dashboard/Dashboard"; import { t } from "./i18n/i18nUtils"; import { Connections } from "./pages/Connections/Connections"; import NewConnnection from "./pages/NewConnection/NewConnnectionForm"; import { NotFound } from "./pages/NotFound"; import { ProjectConnection } from "./pages/ProjectConnection/ProjectConnection"; import ErrorComponent from "./components/ErrorComponent"; import UserManager from "./dashboard/UserManager"; import { Account } from "./pages/Account/Account"; import { ServerSettings } from "./pages/ServerSettings/ServerSettings"; import type { ProstglesState } from "@common/electronInitTypes"; import type { DBSSchema } from "@common/publishUtils"; import { fixIndent, ROUTES } from "@common/utils"; import type { AuthHandler } from "prostgles-client/dist/getAuthHandler"; import { type DBHandlerClient, type MethodHandler, } from "prostgles-client/dist/prostgles"; import { type Socket } from "socket.io-client"; import { CommandPalette } from "./app/CommandPalette/CommandPalette"; import { Documentation } from "./app/CommandPalette/Documentation"; import { XRealIpSpoofableAlert } from "./app/XRealIpSpoofableAlert"; import { createReactiveState, useReactiveState } from "./appUtils"; import { AlertProvider } from "./components/AlertProvider"; import { FlexCol, FlexRow } from "./components/Flex"; import { InfoRow } from "./components/InfoRow"; import { NavBarWrapper } from "./components/NavBar/NavBarWrapper"; import { PostgresInstallationInstructions } from "./components/PostgresInstallationInstructions"; import type { DBS, DBSMethods } from "./dashboard/Dashboard/DBS"; import { MousePointer } from "./demo/MousePointer"; import { ComponentList } from "./pages/ComponentList"; import { ElectronSetup } from "./pages/ElectronSetup/ElectronSetup"; import { Login } from "./pages/Login/Login"; import { NonHTTPSWarning } from "./pages/NonHTTPSWarning"; import { useAppTheme } from "./theme/useAppTheme"; import { useAppState } from "./useAppState/useAppState"; export type ClientUser = { sid: string; uid: string; type: string; has_2fa: boolean; } & DBSSchema["users"]; export type ClientAuth = { user?: ClientUser; }; export type Theme = "dark" | "light"; export type PrglReadyState = { /** * Used to re-render dashboard on dbs reconnect */ dbsKey: string; dbs: DBS; dbsTables: CommonWindowProps["tables"]; dbsMethods: DBSMethods; dbsSocket: Socket; auth: AuthHandler; isAdminOrSupport: boolean; sid: string | undefined; }; export type ExtraProps = PrglReadyState & { setTitle: (content: string | ReactChild) => void; user: DBSSchema["users"] | undefined; dbsSocket: Socket; theme: Theme; } & Pick, "serverState">; export type PrglStateCore = Pick< ExtraProps, "dbs" | "dbsMethods" | "dbsTables" >; export type PrglState = ExtraProps; export type PrglCore = { db: DBHandlerClient; methods: MethodHandler; tables: CommonWindowProps["tables"]; }; export type PrglProject = PrglCore & { dbKey: string; connectionId: string; databaseId: number; projectPath: string; connection: DBSSchema["connections"]; }; export type Prgl = PrglState & PrglProject; export type AppState = { prglState?: PrglReadyState; user: DBSSchema["users"] | undefined; serverState?: ProstglesState; title: React.ReactNode; isConnected: boolean; }; export const r_useAppVideoDemo = createReactiveState({ demoStarted: false }); export const App = () => { const [isDisconnected, setIsDisconnected] = useState(false); const state = useAppState(setIsDisconnected); const [title, setTitle] = useState(""); const { state: { demoStarted }, } = useReactiveState(r_useAppVideoDemo); const { theme, userThemeOption } = useAppTheme(state); const extraProps: PrglState | undefined = useMemo( () => state.prglState && state.serverState && { ...state.prglState, setTitle: (content: ReactChild) => { if (title !== content) setTitle(content); }, user: state.user, theme, serverState: state.serverState, }, [state, theme, title], ); const { initState } = state.serverState ?? {}; const initStateError = initState?.state === "error" ? initState : undefined; if ( state.serverState?.isElectron && ((state.state !== "loading" && state.state !== "ok") || !state.serverState.electronCredsProvided || initStateError) ) { return ; } const unknownErrorMessage = "Something went wrong with initialising the server. Check console for more details"; const error = state.dbsClientError || (initState?.state === "error" ? initState.error || unknownErrorMessage : undefined); const { prglState, serverState, state: _state } = state; if (!error && (!prglState || !serverState || _state === "loading")) { return (
); } if (error || !prglState || !serverState || !extraProps) { const hint = state.dbsClientError ? errorHints.dbsClientError : initStateError?.errorType && errorHints[initStateError.errorType]; return ( {initStateError?.errorType === "connection" && ( )} {hint && ( {hint} )} ); } const isElectron = !!serverState.isElectron; return ( {demoStarted && } {isDisconnected && ( )} } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> } /> ); }; const errorHints = { connection: fixIndent(` Could not connect to state database. Ensure /server/.env file (or environment variables) point to a running and accessible postgres server database`), init: "Failed to start Prostgles", dbsClientError: "Failed to connect to state database. Try refreshing the page or restarting the app.", }; export * from "./appUtils"; ================================================ FILE: client/src/Testing.ts ================================================ export const COMMANDS = { "NewConnectionForm.connectionName": "Connection name input field", "NewConnectionForm.connectionType": "Connection type select field", "NewConnectionForm.db_conn": "Database connection input field", "NewConnectionForm.db_host": "Database host input field", "NewConnectionForm.db_port": "Database port input field", "NewConnectionForm.db_user": "Database user input field", "NewConnectionForm.db_pass": "Database password input field", "NewConnectionForm.db_name": "Database name input field", "NewConnectionForm.MoreOptionsToggle": "", "NewConnectionForm.schemaFilter": "", "NewConnectionForm.connectionTimeout": "Connection timeout input field", "NewConnectionForm.sslMode": "SSL mode select field", "NewConnectionForm.watchSchema": "Watch schema toggle", "NewConnectionForm.realtime": "Realtime toggle", "NewConnectionForm.testConnection": "Test connection button", "config.goToConnDashboard": "Go to connection workspace ", "config.details": "", "config.bkp": "", "config.tableConfig": "", "config.bkp.create": "", "config.bkp.create.name": "Backup name input field", "config.bkp.create.start": "", "config.bkp.AutomaticBackups": "", "config.bkp.AutomaticBackups.toggle": "", "config.ac": { desc: "", uiOnly: true }, "config.status": "", "config.ac.create": "", "config.ac.save": "", "config.ac.removeRule": "", "config.ac.cancel": "", "config.ac.createDefault": "", "config.ac.edit.user": "Opens select/edit access rule user types", "config.ac.edit.user.select": "User type select search box", "config.ac.edit.user.select.create": "Creates a user type when the search did not yield any results", "config.ac.edit.user.select.done": "Closes create user popup", "config.ac.edit.type": "Rule type button group. Each button.value will contain the type", "config.ac.edit.dataAccess": "Data access section with tables/runsql", "config.ac.edit.createWorkspaces": "", "config.ac.edit.publishedWorkspaces": "", "config.ac.edit.typeCustom.tables": "", "config.ac.edit.typeAll": "", "config.ac.edit.typeSQL": "", "config.files": "", "config.files.toggle": "", "config.files.toggle.confirm": "", "config.api": { desc: "", uiOnly: true }, "config.methods": "", "dashboard.window.rowInsert": "Open row insert panel", "dashboard.window.rowInsertTop": "Open row insert panel from top filter bar", "W_SQLMenu.name": "", "W_SQLMenu.renderDisplayMode": "", "W_SQLMenu.saveQuery": "", "W_SQLMenu.openSQLFile": "", "W_SQLMenu.deleteQuery": "", "W_SQLEditor.executeStatement": "Executes the current SQL statement", W_SQLEditor: "", W_SQLBottomBar: "", "W_SQLBottomBar.runQuery": "Executes the current query (selected, block or full)", "W_SQLBottomBar.limit": "", "W_SQLBottomBar.queryDuration": "", "W_SQLBottomBar.cancelQuery": "Cancels the current query", "W_SQLBottomBar.terminateQuery": "Terminates the current query", "W_SQLBottomBar.stopListen": "Terminates the current LISTEN query", "W_SQLBottomBar.toggleTable": "", "W_SQLBottomBar.toggleCodeEditor": "", "W_SQLBottomBar.toggleNotices": "", "W_SQLBottomBar.stopLoopQuery": "Stop loop query", "W_SQLBottomBar.copyResults": "Copy results", "W_SQLBottomBar.rowCount": "Row count", "W_SQLBottomBar.sqlError": "SQL error", W_SQLResults: "", "Window.W_QuickMenu": "Quick menu for the current window", "Window.ChildChart": "", "Window.ChildChart.toolbar": "", "dashboard.window.fullscreen": "fullscreen", "dashboard.window.close": "close", "dashboard.window.collapse": "collapse", "dashboard.window.viewEditRow": "", "dashboard.window.toggleFilterBar": "", "dashboard.window.menu": "", "dashboard.window.detachChart": "", "dashboard.window.collapseChart": "", "dashboard.window.closeChart": "", "dashboard.window.chartMenu": "", "dashboard.window.restoreMinimisedCharts": "", "dashboard.goToConnConfig": "Go to connection config", "dashboard.menu.settingsToggle": "", "dashboard.menu.settings.defaultLayoutType": "", "dashboard.menu.settings": "", "dashboard.menu": "", "dashboard.menu.sqlEditor": "", "dashboard.menu.quickSearch": "", "dashboard.menu.resize": "", "dashboard.centered-layout.resize": "", "dashboard.menu.fileTable": "", "dashboard.menu.savedQueriesList": "", "dashboard.menu.tablesSearchList": "", "dashboard.menu.tablesSearchListInput": "", "dashboard.menu.serverSideFunctionsList": "", "dashboard.menu.create": "", "dashboard.menu.createTable": "", "dashboard.menu.createTable.tableName": "", "dashboard.menu.createTable.addColumn": "", "dashboard.menu.createTable.addColumn.confirm": "", "dashboard.menu.createTable.confirm": "", "W_TableMenu_TableInfo.name": "", "W_TableMenu_TableInfo.comment": "", "W_TableMenu_TableInfo.oid": "", "W_TableMenu_TableInfo.type": "", "W_TableMenu_TableInfo.owner": "", "W_TableMenu_TableInfo.sizeInfo": "", "W_TableMenu_TableInfo.viewDefinition": "", "W_TableMenu_TableInfo.vacuum": "", "W_TableMenu_TableInfo.vacuumFull": "", "W_TableMenu_TableInfo.drop": "", "W_TableMenu_ColumnList.alter": "", "W_TableMenu_ColumnList.linkedColumnOptions": "", "W_TableMenu_ColumnList.removeComputedColumn": "", TableHeader: "", "TableHeader.resizeHandle": "", "FormField.clear": "Clear a FormField", SmartForm: "", "SmartForm.header.tableIconAndName": "", "SmartForm.header.previousRow": "", "SmartForm.header.nextRow": "", "SmartForm.close": "Close dialog", "SmartForm.delete": "Deletes row", "SmartForm.delete.confirm": "Confirms Deleting a row", "SmartForm.update": "update row", "SmartForm.update.confirm": "Confirms update a row", "SmartForm.insert": "Confirms Deleting a row", "SmartForm.clone": "Confirms Deleting a row", "SQLSmartEditor.Run": "Run the sql statement", "SearchList.toggleAll": "", "SearchList.List": "", "SearchList.Input": "", ViewMoreSmartCardList: "", "Section.toggleFullscreen": "", FieldFilterControl: "", "FieldFilterControl.type": "", "FieldFilterControl.type.custom": "", "FieldFilterControl.type.except": "", "FieldFilterControl.select": "", "RenderFilter.edit": "", "RenderFilter.done": "", ForcedFilterControl: "", "ForcedFilterControl.type": "", "ForcedFilterControl.type.disabled": "", "ForcedFilterControl.type.enabled": "", CheckFilterControl: "", "CheckFilterControl.type": "", "CheckFilterControl.type.disabled": "", "CheckFilterControl.type.enabled": "", selectRule: "", selectRuleAdvanced: "", updateRule: "", updateRuleAdvanced: "", deleteRule: "", deleteRuleAdvanced: "", insertRule: "", insertRuleAdvanced: "", syncRule: "", syncRuleAdvanced: "", SearchAll: "", SmartAddFilter: "", FilterWrapper: "", "FilterWrapper.typeSelect": "", FileBtn: "", "ForcedDataControl.toggle": "", "ForcedDataControl.addColumn": "", "TablePermissionControls.close": "", "TablePermissionControls.done": "", Connections: "", "Connections.add": "add", "Connections.new": " ", "Connection.openConnection": "Open connection", "Connection.workspaceList": "Connection workspace list", "Connection.closeAllWindows": "", "Connection.statusMonitor": "", "Connection.configure": "", "Connection.edit": "", "Connection.edit.updateOrCreateConfirm": "", "Connection.edit.delete": "", "Connection.edit.delete.dropDatabase": "", "Connection.edit.delete.confirm": "", "Connection.disconnect": "", "ConnectionServer.add": "", "ConnectionServer.add.newDatabase": "", "ConnectionServer.add.existingDatabase": "", "ConnectionServer.NewDbName": "", "ConnectionServer.add.confirm": "", "SmartFilterBar.toggle": "", "SmartFilterBar.rightOptions.show": "", "SmartFilterBar.rightOptions.update": "", "SmartFilterBar.rightOptions.delete": "", "ColumnEditor.name": "", "ColumnEditor.dataType": "", AddColumnMenu: "", WorkspaceAddBtn: "", WorkspaceMenuDropDown: "", "WorkspaceMenuDropDown.WorkspaceAddBtn": "", "WorkspaceMenu.SearchList": "", "WorkspaceMenu.CloneWorkspace": "", "WorkspaceMenu.toggleWorkspaceLayoutMode": "", WorkspaceDeleteBtn: "", "WorkspaceDeleteBtn.Confirm": "", JoinPathSelectorV2: "", "LinkedColumn.Add": "", "LinkedColumn.ColumnList.toggle": "", "SummariseColumn.toggle": "", FunctionSelector: "", "SummariseColumn.apply": "", "Popup.header": "", "Popup.close": "", "Popup.content": "", "Popup.footer": "", "Popup.toggleFullscreen": "", "PopupSection.fullscreen": "", "PopupSection.content": "", "LinkedColumn.ColumnListMenu": "", "AddChartMenu.Map": "", "AddChartMenu.Timechart": "", W_TimeChart: "", "W_TimeChart.ActiveRow": "", "W_TimeChart.AddTimeChartFilter": "", SmartFormField: "", "CloseSaveSQLPopup.delete": "", SchemaGraph: "", "SchemaGraph.TopControls": "", "SchemaGraph.TopControls.tableRelationsFilter": "", "SchemaGraph.TopControls.columnRelationsFilter": "", "SchemaGraph.TopControls.linkColorMode": "", "SchemaGraph.TopControls.resetLayout": "", AddColumnReference: "", "SmartFormFieldOptions.AttachFile": "", RuleToggle: "", "table.options.displayMode": "", "table.options.cardView.groupBy": "", "table.options.cardView.orderBy": "", "CardView.row": "", "CardView.group": "", "CardView.DragHeader": "", "CreateFileColumn.confirm": "", "AppDemo.start": "", MenuList: "", ComparablePGPolicies: "", "ConnectionServer.SampleSchemas": "", "dashboard.goToConnections": "", QuickAddComputedColumn: "", "SmartSelect.Done": "", "SmartAddFilter.JoinTo": "", "SmartAddFilter.toggleIncludeLinkedColumns": "", ContextDataSelector: "", ClickCatchOverlay: "", "BackupControls.Restore": "", ChartLayerManager: "", "App.colorScheme": "", "ElectronSetup.Next": "", "ElectronSetup.Back": "", PostgresInstallationInstructions: "", "PostgresInstallationInstructions.Close": "", "ElectronSetup.Done": "", SmartCardList: "", "AutomaticBackups.destination": "", "AutomaticBackups.frequency": "", "AutomaticBackups.hourOfDay": "", "WorkspaceAddBtn.Create": "", MapOpacityMenu: "", MapBasemapOptions: "", "InMapControls.goToDataBounds": "", "InMapControls.showCursorCoords": "", LayerColorPicker: "", "W_TimeChart.resetExtent": "", TimeChartFilter: "", "ChartLayerManager.toggleLayer": "", "ChartLayerManager.removeLayer": "", "ChartLayerManager.AddChartLayer.addLayer": "", "ChartLayerManager.AddChartLayer.addOSMLayer": "", ConnectionSelector: "", "Setup2FA.Enable": "", "Setup2FA.Enable.GenerateQR": "", "Setup2FA.Enable.CantScanQR": "", "Setup2FA.Enable.Base64Secret": "", "Setup2FA.Enable.ConfirmCode": "", "Setup2FA.Enable.Confirm": "", "Setup2FA.Disable": "", "Setup2FA.error": "", "DashboardMenuHeader.togglePinned": "", "BackupControls.DeleteAll": "", "BackupControls.DeleteAll.Confirm": "", "ProjectConnection.error": "", NotFound: "", "NotFound.goHome": "", "ConnectionServer.NewUserName": "", "ConnectionServer.NewUserPassword": "", "ConnectionServer.NewUserPermissionType": "", "ConnectionServer.withNewOwnerToggle": "", "W_Table.TableNotFound": "", JoinedRecords: "", "JoinedRecords.AddRow": "", "JoinedRecords.SectionToggle": "", "JoinedRecords.Section": "", "SmartCard.viewEditRow": "", "TimeChartLayerOptions.yAxis": "", "TimeChartLayerOptions.aggFunc": "", "TimeChartLayerOptions.aggFunc.select": "", "TimeChartLayerOptions.groupBy": "", "TimeChartLayerOptions.numericColumn": "", "AgeFilter.comparator": "", "AgeFilter.argsLeftToRight": "", "Login.error": "", AskLLMAccessControl: "", "AskLLMAccessControl.AllowAll": "", "Chat.messageList": "", "Chat.sendWrapper": "", "Chat.send": "", "Chat.sendStop": "", "Chat.addFiles": "", "Chat.textarea": "", "Chat.speech": "", AskLLM: "", "AskLLM.popup": "", SetupLLMCredentials: "", "SetupLLMCredentials.free": "", "SetupLLMCredentials.api": "", "AskLLMAccessControl.llm_daily_limit": "", DeckGLFeatureEditor: "", "MapBasemapOptions.Projection": "", SmartFilterBar: "", SearchList: "", "SearchList.MatchCase": "", AddJoinFilter: "", Pagination: "", "Pagination.page": "", "Pagination.lastPage": "", "Pagination.nextPage": "", "Pagination.prevPage": "", "Pagination.firstPage": "", "Pagination.pageCountInfo": "", "Pagination.pageSize": "", MapExtentBehavior: "", AddLLMCredentialForm: "", "AddLLMCredentialForm.Save": "", "AddLLMCredentialForm.Provider": "", "App.LanguageSelector": "", "EmailAuthSetup.SignupType": "", EmailAuthSetup: "", "EmailAuthSetup.error": "", "EmailAuthSetup.toggle": "", EmailSMTPAndTemplateSetup: "", "EmailSMTPAndTemplateSetup.save": "", "Login.toggle": "", AuthNotifPopup: "", "ProstglesSignup.continue": "", "PublishedMethods.deleteFunction": "", "SmartFormFieldOptions.NestedInsert": "", "Btn.ClickConfirmation": "", "Btn.ClickConfirmation.Confirm": "", AddMCPServer: "", "AddMCPServer.Open": "", "AddMCPServer.Add": "", "LLMChatOptions.MCPTools": "", "LLMChatOptions.DatabaseAccess": "", "MCPServersToolbar.stopAllToggle": "", "MCPServersToolbar.searchTools": "", ConnectionsOptions: "", "ConnectionsOptions.showStateDatabase": "", "ConnectionsOptions.showDatabaseNames": "", "AuthProviderSetup.websiteURL": "", "AuthProviderSetup.defaultUserType": "", "AuthProviders.list": "", EmailSMTPSetup: "", EmailTemplateSetup: "", "WorkspaceMenu.list": "", WorkspaceSettings: "", "LLMChatOptions.toggle": "", "LLMChat.select": "", "LLMChatOptions.Prompt": "", "LLMChatOptions.Model": "", "AskLLMChat.NewChat": "", "AskLLMChat.LoadSuggestedToolsAndPrompt": "", "AskLLMChat.LoadSuggestedDashboards": "", "AskLLMChat.UnloadSuggestedDashboards": "", "AskLLMToolApprover.AllowAlways": "", "AskLLMToolApprover.AllowOnce": "", "AskLLMToolApprover.Deny": "", MonacoEditor: "", MCPServerTools: "", "MCPServerFooterActions.logs": "", "MCPServerFooterActions.config": "", "MCPServerFooterActions.enableToggle": "", "MCPServerFooterActions.refreshTools": "", MCPServerConfigButton: "", MCPServerConfig: "", "MCPServerConfig.save": "", "MCPServers.toggleAutoApprove": "", Feedback: "", "FileImporterFooter.import": "", Sessions: "Active sessions list", "Account.ChangePassword": "Change password", NavBar: "", "NavBar.mobileMenuToggle": "", "NavBar.logout": "", CommandPalette: "", "Chat.attachedFiles": "", "Window.W_QuickMenu.addCrossFilteredTable": "Add cross-filtered table", Alert: "Alert popup", ErrorComponent: "", ToolUseMessage: "", "ToolUseMessage.toggle": "", "ToolUseMessage.Popup": "", MarkdownMonacoCode: "", "MCPServersInstall.install": "", NewConnectionForm: "", "BackupsControls.Completed": "Completed backups list", "AllowedOriginCheck.FormField": "", "APIDetailsWs.Examples": "", "APIDetailsHttp.Examples": "", AllowedOriginCheck: "", "APIDetailsTokens.CreateToken": "", "APIDetailsTokens.CreateToken.daysUntilExpiration": "", "APIDetailsTokens.CreateToken.generate": "", APIDetailsTokens: "", "AskLLM.DeleteMessage": "", "DockerSandboxCreateContainer.Logs": "", TableBody: "", "ServerSideFunctions.onMountEnabled": "", DashboardMenu: "", "SearchAll.Popup": "", AddComputedColMenu: "", "AddComputedColMenu.countOfAllRows": "", "AddComputedColMenu.addBtn": "", "AddComputedColMenu.name": "", "AddComputedColMenu.addTo": "", "LinkedColumn.joinType": "", "LinkedColumn.layoutType": "", "CreateColumn.next": "", FileColumnConfigEditor: "", "FileColumnConfigEditor.maxFileSizeMB": "", "FileColumnConfigEditor.contentMode": "", CreateFileColumn: "", ColumnQuickStats: "", "ColumnQuickStats.addFilter": "", "QuickAddComputedColumn.Add": "", "QuickAddComputedColumn.name": "", "LLMChatOptions.Prompt.Preview": "", "FunctionColumnList.SearchInput": "", "LLMChatOptions.Model.AddCredentials": "", QuickFilterGroupsControl: "", LinkedColumn: "", CreateColumn: "", "ToolUseMessage.toggleGroup": "", "PGDumpOptions.format": "", "PGDumpOptions.destination": "", "PGDumpOptions.numberOfJobs": "", "PGDumpOptions.compressionLevel": "", "PGDumpOptions.excludeSchema": "", "PGDumpOptions.noOwner": "", "PGDumpOptions.create": "", "PGDumpOptions.globalsOnly": "", "PGDumpOptions.rolesOnly": "", "PGDumpOptions.schemaOnly": "", "PGDumpOptions.encoding": "", "PGDumpOptions.clean": "", "PGDumpOptions.dataOnly": "", "PGDumpOptions.ifExists": "", "PGDumpOptions.keepLogs": "", "BackupControls.backupsInProgress": "", "BackupsControls.Completed.delete": "", "BackupsControls.Completed.download": "", "BackupsControls.Completed.restore": "", "BackupsControls.Completed.deleteAll": "", "BackupsControls.restoreFromFile": "", BackupLogs: "", FilterWrapper_FieldName: "", FilterWrapper_Field: "", "CloudStorageCredentialSelector.selectCredential": "", DashboardMenuContent: "", } as const satisfies Record< string, | string | { desc: string; uiOnly?: true; } >; export type Command = keyof typeof COMMANDS; export type TestSelectors = { "data-command"?: Command; "data-key"?: string; id?: string; }; export const dataCommand = (cmd: Command): { "data-command": Command } => ({ "data-command": cmd, }); export const getCommandElemSelector = (cmd: Command) => { return `[data-command=${JSON.stringify(cmd)}]`; }; export const getDataKeyElemSelector = (key: string) => { return `[data-key=${JSON.stringify(key)}]`; }; export const getDataLabelElemSelector = (key: string) => { return `[data-label=${JSON.stringify(key)}]`; }; export const COMMAND_SEARCH_ATTRIBUTE_NAME = "data-command-search-ended"; export const MOCK_ELECTRON_WINDOW_ATTR = "MOCK_ELECTRON_WINDOW_ATTR" as const; declare module "react" { interface HTMLAttributes { "data-command"?: Command; } } export declare namespace SVGif { export type CursorAnimation = | { elementSelector: string; offset?: { x: number; y: number }; duration: number; type: "click" | "clickAppearOnHover"; /** * Time to wait before clicking after reaching the final position * */ waitBeforeClick?: number; /** * Time to stay on the final position after clicking */ lingerMs?: number; } | { type: "moveTo"; xy: [number, number]; duration: number; }; export type Animation = | CursorAnimation | { elementSelector: string; duration: number; type: "type"; extraAnimation?: | { type: "zoomToElement" } | { type: "bringToFront"; elementSelector: string }; /** * Maximum scale to zoom in while typing */ maxScale?: number; } | { elementSelector: string; duration: number; type: "zoomToElement" | "bringToFront"; bringToFrontSelector?: string; /** * Maximum scale to zoom in */ maxScale?: number; } | { elementSelector: string; duration: number; type: "fadeIn" | "growIn"; } | { type: "wait"; duration: number; }; export type Scene = { svgFileName: string; caption?: string; animations: Animation[]; }; } /** * TODO: Forbid imports to ensure this file is portable */ ================================================ FILE: client/src/WithPrgl.tsx ================================================ import React from "react"; import type { Prgl } from "./App"; import { createReactiveState, useReactiveState } from "./appUtils"; export const prgl_R = createReactiveState(undefined); export const WithPrgl = ({ onRender, }: { onRender: (prgl: Prgl) => React.ReactNode; }): JSX.Element => { const { state: prgl } = useReactiveState(prgl_R); if (!prgl) return <>; const res = onRender(prgl); return <>{res}; }; ================================================ FILE: client/src/app/CommandPalette/CommandPalette.css ================================================ .flicker { animation: flicker .5s infinite ; } .flicker-slow { animation: flicker 1.5s infinite ; } @keyframes flicker { 0% { opacity: .75; } 50% { opacity: 0.1; } 100% { opacity: .75; } } ================================================ FILE: client/src/app/CommandPalette/CommandPalette.tsx ================================================ import { FlashMessage } from "@components/FlashMessage"; import { Icon } from "@components/Icon/Icon"; import Popup from "@components/Popup/Popup"; import { SearchList } from "@components/SearchList/SearchList"; import { mdiArrowSplitVertical, mdiButtonPointer, mdiCardTextOutline, mdiChartLine, mdiCheckboxOutline, mdiFileUploadOutline, mdiFormatListBulleted, mdiFormSelect, mdiFormTextbox, mdiKeyboard, mdiLink, mdiListBoxOutline, mdiMenu, mdiNumeric, mdiTextLong, } from "@mdi/js"; import React, { useEffect, useState } from "react"; import { NavLink } from "react-router-dom"; import { flatUIDocs, type UIDoc, type UIDocInputElement } from "../UIDocs"; import "./CommandPalette.css"; import { Documentation } from "./Documentation"; import { useGoToUI } from "./useGoToUI"; import { getItemSearchRank } from "@components/SearchList/searchMatchUtils/getItemSearchRank"; import { isPlaywrightTest } from "src/i18n/i18nUtils"; import { getProperty } from "@common/utils"; /** * By pressing Ctrl+K, the user to search and go to functionality in the UI. */ export const CommandPalette = ({ isElectron }: { isElectron: boolean }) => { const { showSection, setShowSection } = useOnKeyDown(); const [highlights, setHighlights] = useState([]); const { message, setMessage, goToUIDocItem } = useGoToUI(setHighlights); return ( <> {highlights.map((h, i) => (
))} {message ? setMessage(undefined)} /> : showSection && ( Documentation ) } data-command="CommandPalette" clickCatchStyle={{ opacity: 1 }} positioning={showSection === "commands" ? "top-center" : "center"} onClose={() => setShowSection(undefined)} contentClassName={ "flex-col gap-2 " + (showSection === "docs" ? " p-2" : "p-1") } contentStyle={ showSection === "docs" ? { textAlign: "left", } : { width: "min(100vw, 700px)", maxHeight: "min(100vh, 500px)", } } > {showSection === "commands" ? { const iconKey = data.type === "input" ? `${data.type}-${data.inputType}` : data.type; const iconPath = data.iconPath ?? getProperty(UIDocTypeToIcon, iconKey); if (!iconPath) { console.warn("No icon for UIDoc type", iconKey, data); } return { key: data.title, parentLabels: data.parentTitles, label: data.title, subLabel: data.description, contentLeft: iconPath ? : undefined, onPress: async () => { setShowSection(undefined); await goToUIDocItem(data); }, ranking: (searchTerm) => getItemSearchRank( { title: data.title, subTitle: data.description, level: data.parentTitles.length, }, searchTerm, ), data, }; })} /> : } ) } ); }; export type CommandSearchHighlight = { left: number; top: number; width: number; height: number; borderRadius: string; flickerSlow?: boolean; }; const useOnKeyDown = () => { const [showSection, setShowSection] = useState<"commands" | "docs">(); useEffect(() => { const handleKeyDown = (event: KeyboardEvent) => { if (event.key === "F1") { event.preventDefault(); setShowSection("docs"); } if (event.ctrlKey && event.key === "k") { event.preventDefault(); setShowSection("commands"); } if (event.key === "Escape") { setShowSection(undefined); } }; window.addEventListener("keydown", handleKeyDown); return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [showSection]); return { showSection, setShowSection }; }; const UIDocTypeToIcon: Partial< Record< `${UIDocInputElement["type"]}-${UIDocInputElement["inputType"]}`, string > & Record, string> > = { link: mdiLink, button: mdiButtonPointer, popup: mdiButtonPointer, select: mdiButtonPointer, "input-text": mdiFormTextbox, "input-checkbox": mdiCheckboxOutline, "input-file": mdiFileUploadOutline, "input-number": mdiNumeric, "input-select": mdiFormSelect, smartform: mdiListBoxOutline, "smartform-popup": mdiListBoxOutline, list: mdiFormatListBulleted, section: mdiCardTextOutline, tab: mdiCardTextOutline, "accordion-item": mdiCardTextOutline, "drag-handle": mdiArrowSplitVertical, "hotkey-popup": mdiKeyboard, navbar: mdiMenu, text: mdiTextLong, canvas: mdiChartLine, // page: mdiGrid, }; if (isPlaywrightTest) { flatUIDocs.forEach(({ title: searchTerm }) => { let lowestRank = { value: Infinity, title: "" }; flatUIDocs.forEach(({ title, description, parentTitles }) => { const rank = getItemSearchRank( { title, subTitle: description, level: parentTitles.length, }, searchTerm, ); if (rank < lowestRank.value) { lowestRank = { value: rank, title }; } }); if (searchTerm !== lowestRank.title) { throw new Error( `Search rank test failed for term "${searchTerm}". Expected "${searchTerm}" to rank highest, but got "${lowestRank.title}"`, ); } }); } ================================================ FILE: client/src/app/CommandPalette/Documentation.css ================================================ .Documentation img { max-width: 100%; } ================================================ FILE: client/src/app/CommandPalette/Documentation.tsx ================================================ import React, { useMemo } from "react"; import Markdown from "react-markdown"; import rehypeRaw from "rehype-raw"; import "./Documentation.css"; import Btn from "@components/Btn"; import { FlexCol, FlexRow } from "@components/Flex"; import { ScrollFade } from "@components/ScrollFade/ScrollFade"; import { useTypedSearchParams } from "src/hooks/useTypedSearchParams"; import { getDocumentationFiles } from "./getDocumentation"; import remarkGfm from "remark-gfm"; type P = { isElectron: boolean; }; export const Documentation = ({ isElectron }: P) => { const { docFiles, docFilesMap } = useMemo(() => { const docFiles = getDocumentationFiles(isElectron).map((d) => ({ ...d, text: d.text.replaceAll(`="./screenshots/`, `="/screenshots/`), })); const docFilesMap = new Map(docFiles.map((d) => [d.id, d])); return { docFiles, docFilesMap, }; }, [isElectron]); const [{ documentation_section: section = docFiles[0]?.id }, setParams] = useTypedSearchParams({ documentation_section: { type: "string", optional: true }, }); const currentDocFile = section ? docFilesMap.get(section) : undefined; const WrappingElement = window.isLowWidthScreen ? FlexCol : FlexRow; return ( {docFiles.map((docFile) => ( setParams({ documentation_section: docFile.id })} > {docFile.title} ))} {!currentDocFile ? null : ( {currentDocFile.text} )} ); }; ================================================ FILE: client/src/app/CommandPalette/getDocumentation.ts ================================================ import { isObject } from "@common/publishUtils"; import { fixIndent } from "../../demo/scripts/sqlVideoDemo"; import { COMMANDS } from "../../Testing"; import { UIDocs, type UIDoc, type UIDocElement } from "../UIDocs"; type SeparatePage = { doc: UIDoc; parentDocs: UIDoc[]; depth: number }; const asList = ( children: UIDocElement[], parentDocs: UIDoc[], separatePageDepth: number | undefined, isElectron: boolean, ) => { const depth = parentDocs.length; const listItemDepth = depth - (separatePageDepth ?? 1); const separatePages: SeparatePage[] = []; const listContent: string = children .map((child) => { const moveToNewPage = Boolean(child.docs); if (moveToNewPage) { separatePages.push({ doc: child, depth, parentDocs }); } const listTitle = moveToNewPage ? `${child.title}` : `**${child.title}**`; const listItem = `${" ".repeat(listItemDepth)}- ${listTitle}: ${child.description} `; if (moveToNewPage) { return listItem; } const items = getChildren(child, isElectron); if (items.length) { const nestedList = asList( items, [...parentDocs, child], separatePageDepth, isElectron, ); nestedList.separatePages.forEach((sp) => separatePages.push(sp)); return listItem + "\n" + nestedList.listContent; } return listItem; }) .join("\n"); return { listContent, separatePages, }; }; const getUIDocAsMarkdown = ( doc: UIDoc, parentDocs: UIDoc[], separatePageDepth: number | undefined, isElectron: boolean, ): { title: string; content: string; doc: UIDoc; }[] => { const { docOptions } = doc; const depth = docOptions ? 0 : Math.min(3, parentDocs.length); if (isElectron && doc.uiVersionOnly) { return []; } const { listContent: childrenContent, separatePages } = asList( getChildren(doc, isElectron), [...parentDocs, doc], separatePageDepth, isElectron, ); const separatePagesWithContent = separatePages.flatMap((sp) => getUIDocAsMarkdown(sp.doc, sp.parentDocs, sp.depth, isElectron), ); if (doc.uiVersionOnly) { const sel = "selectorCommand" in doc ? doc.selectorCommand : undefined; const cmdInfo = sel && COMMANDS[sel]; // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!cmdInfo || !isObject(cmdInfo) || !cmdInfo.uiOnly) { throw new Error( `UI Version Only documentation for "${doc.title}" does not have a valid selectorCommand.`, ); } } const hDepth = depth + 1; const childrenTitle = doc.childrenTitle ?? (doc.type === "navbar" ? "Navbar items" : undefined); const displayTitle = isObject(docOptions) ? docOptions.title : doc.title; const content = [ ` ${displayTitle} \n`, doc.uiVersionOnly ? `> Not available on Desktop version\n ` : "", `${doc.docs ? fixIndent(doc.docs) : doc.description}\n`, childrenTitle ? `### ${childrenTitle}` : "", childrenContent, ] .filter(Boolean) .join("\n"); return [ { title: doc.title, doc, content, }, ].concat(separatePagesWithContent); }; export type DocumentationFile = { id: string; fileName: string; title: string; text: string; }; export const getDocumentationFiles = (isElectron: boolean) => { const documentationPages: DocumentationFile[] = []; UIDocs.forEach((doc) => { const docItems = getUIDocAsMarkdown(doc, [], undefined, isElectron); const pushFile = (title: string, text: string) => { const index = documentationPages.length + 1; documentationPages.push({ fileName: `${index.toString().padStart(2, "0")}_${title.replaceAll(" ", "_")}.md`, title, id: toSnakeCase(title), text, }); }; if (documentationPages.length) { pushFile(doc.title, ""); } do { const lastFile = documentationPages.at(-1); const currDocItem = docItems.shift(); if (!currDocItem) { continue; } const asSeparateFile = currDocItem.doc.docOptions === "asSeparateFile"; if (!lastFile || asSeparateFile) { const title = asSeparateFile ? currDocItem.doc.title : doc.title; pushFile(title, currDocItem.content + "\n\n"); } else { lastFile.text += currDocItem.content + "\n\n"; } } while (docItems.length); }); return documentationPages; }; const toSnakeCase = (str: string) => str.toLowerCase().trim().replaceAll(/ /g, "_"); const getChildren = (doc: UIDoc, isElectron: boolean) => { if (doc.docOptions === "hideChildren") return []; const items = "children" in doc ? doc.children : "itemContent" in doc ? doc.itemContent : "pageContent" in doc ? (doc.pageContent ?? []) : []; if (isElectron) { return items.filter((item) => !item.uiVersionOnly); } return items; }; window.documentation = getDocumentationFiles(false); ================================================ FILE: client/src/app/CommandPalette/getUIDocShortestPath.ts ================================================ import { filterArr } from "@common/llmUtils"; import type { UIDoc, UIDocPage } from "../UIDocs"; import { getCommandElemSelector } from "src/Testing"; import { isDefined } from "prostgles-types"; export const getUIDocShortestPath = ( currentPage: UIDocPage, prevParents: UIDoc[], ): undefined | UIDoc[] => { const currentPageLinks = filterArr(currentPage.children, { type: "link", } as const); const shortcut = prevParents.slice().map((doc, index) => { if (doc.type === "popup") { if ( doc.contentSelectorCommand && document.querySelectorAll( getCommandElemSelector(doc.contentSelectorCommand), ).length === 1 ) { return { index }; } const selector = doc.selector ?? getCommandElemSelector(doc.selectorCommand); if (index > 0 && document.querySelectorAll(selector).length === 1) { return { index: index - 1 }; } } else if (doc.type === "page" || doc.type === "link") { const matchingLink = currentPageLinks.find((link) => { return ( link.path === doc.path && link.pathItem?.tableName === doc.pathItem?.tableName ); }); if (!matchingLink) { const isAlreadyOnPage = currentPage.path === doc.path && currentPage.pathItem?.tableName === doc.pathItem?.tableName; if (!isAlreadyOnPage) { return undefined; } return { index }; } return { matchingLink, index }; } else if (doc.type !== "info") { if ( (doc.selector && document.querySelectorAll(doc.selector).length === 1) || (doc.selectorCommand && document.querySelectorAll(getCommandElemSelector(doc.selectorCommand)) .length === 1) ) { return { index }; } } }); const bestShortcut = shortcut.findLast(isDefined); if (bestShortcut) { const { matchingLink, index } = bestShortcut; return [matchingLink, ...prevParents.slice(index + 1)].filter(isDefined); } }; ================================================ FILE: client/src/app/CommandPalette/useGoToUI.tsx ================================================ import { useCallback, useMemo } from "react"; import { useLocation, useNavigate } from "react-router-dom"; import { COMMAND_SEARCH_ATTRIBUTE_NAME } from "../../Testing"; import { useAlert } from "@components/AlertProvider"; import { click } from "../../demo/demoUtils"; import { isPlaywrightTest } from "../../i18n/i18nUtils"; import { tout } from "../../utils/utils"; import { flatUIDocs, type UIDoc, type UIDocFlat, type UIDocPage, } from "../UIDocs"; import type { CommandSearchHighlight } from "./CommandPalette"; import { useHighlightDocItem } from "./useHighlightDocItem"; import { getDocPagePath, getUIDocElements, getUIDocElementsAndAlertIfEmpty, } from "./utils"; import { includes } from "../../dashboard/W_SQL/W_SQLBottomBar/W_SQLBottomBar"; import { getUIDocShortestPath } from "./getUIDocShortestPath"; export type DocItemHighlightItemPosition = "mid" | "last"; export const useGoToUI = ( setHighlights: (h: CommandSearchHighlight[]) => void, ) => { const navigate = useNavigate(); const { addAlert } = useAlert(); const { highlight, message, setMessage, showMultiHighlight } = useHighlightDocItem(setHighlights); const location = useLocation(); const currentPage = useMemo(() => { return flatUIDocs.find((doc) => { if (doc.type === "page") { const matchInfo = getDocPagePath(doc, location.pathname); return matchInfo.isExactMatch; } }) as UIDocPage | undefined; }, [location.pathname]); const clickOneOrHighlight = useCallback( async (doc: UIDoc, duration: DocItemHighlightItemPosition) => { if (doc.type === "info") return; const { items, fullSelector } = getUIDocElements(doc); if (items.length === 1) { await highlight(doc, duration); await click("", fullSelector); } else if (items.length > 1) { await showMultiHighlight(doc, duration); } }, [highlight, showMultiHighlight], ); const goToUI = useCallback( async (doc: UIDoc): Promise => { const nonIteractableContainers: UIDoc["type"][] = [ "info", "list", "page", "navbar", "section", ]; if (doc.type === "info") return; if (doc.type === "hotkey-popup") { const [maybeCtrl, charKey] = doc.hotkey; const ctrlKEvent = new KeyboardEvent("keydown", { key: charKey.toLowerCase(), code: "Key" + charKey, ctrlKey: maybeCtrl === "Ctrl", altKey: maybeCtrl === "Alt", shiftKey: maybeCtrl === "Shift", bubbles: true, }); // Dispatch it on the document document.dispatchEvent(ctrlKEvent); } else if (doc.type === "page" || doc.type === "navbar") { const { isExactMatch, paths } = getDocPagePath(doc, location.pathname); if (!isExactMatch) { navigate(paths[0]!); await tout(400); if (doc.type === "page" && doc.pathItem) { await highlight(doc, "mid"); } await tout(400); } } else if (nonIteractableContainers.includes(doc.type)) { // Do not highlight non-interactable container types const { items } = getUIDocElementsAndAlertIfEmpty(doc, addAlert); return !items.length; } else if ( doc.type === "popup" || doc.type === "tab" || doc.type === "accordion-item" || doc.type === "link" || doc.type === "smartform-popup" ) { await clickOneOrHighlight(doc, "mid"); } else { await highlight(doc, "mid"); } }, [location.pathname, navigate, clickOneOrHighlight, highlight, addAlert], ); const goToUIDocItem = useCallback( async (data: UIDocFlat) => { const prevParents = data.parentDocs; const shortcut = currentPage ? getUIDocShortestPath(currentPage, prevParents) : undefined; const pathItems = shortcut ?? prevParents; const shouldBeOpened = includes(data.type, [ "link", "page", "tab", "popup", "smartform-popup", ]); const finalPathItems = data.type === "hotkey-popup" ? [data] : shouldBeOpened ? [...pathItems, data] : pathItems; for (const parent of finalPathItems) { const shouldStop = await goToUI(parent); if (!isPlaywrightTest && shouldStop) { return; } await tout(200); } if (!shouldBeOpened) { await highlight(data, "last"); } window.document.body.setAttribute( COMMAND_SEARCH_ATTRIBUTE_NAME, data.title, ); }, [currentPage, highlight, goToUI], ); return { message, setMessage, goToUIDocItem, }; }; ================================================ FILE: client/src/app/CommandPalette/useHighlightDocItem.ts ================================================ import { useCallback, useState } from "react"; import { useAlert } from "@components/AlertProvider"; import { isDefined } from "prostgles-types"; import { getUIDocElementsAndAlertIfEmpty } from "./utils"; import type { UIDocNonInfo } from "../UIDocs"; import { scrollIntoViewIfNeeded, tout } from "../../utils/utils"; import type { CommandSearchHighlight } from "./CommandPalette"; import { isInParentViewport } from "../domToSVG/utils/isElementVisible"; import { isPlaywrightTest } from "../../i18n/i18nUtils"; import type { DocItemHighlightItemPosition } from "./useGoToUI"; export const useHighlightDocItem = ( setHighlights: (h: CommandSearchHighlight[]) => void, ) => { const { addAlert } = useAlert(); const [message, setMessage] = useState<{ text: string; left: number; top: number; }>(); const highlight = useCallback( async (doc: UIDocNonInfo, itemPosition: DocItemHighlightItemPosition) => { const { items } = getUIDocElementsAndAlertIfEmpty(doc, addAlert); const firstElem = items[0]; firstElem && scrollIntoViewIfNeeded(firstElem); await tout(500); const mustChooseOne = items.length > 1 && itemPosition !== "last"; const highlights = Array.from(items) .map((el) => { const rect = el.getBoundingClientRect(); const isVisible = isInParentViewport(el, rect); if (!isVisible) { return; } return { left: rect.left, top: rect.top, width: rect.width, height: rect.height, borderRadius: getComputedStyle(el).borderRadius, flickerSlow: itemPosition === "last" || mustChooseOne, }; }) .filter(isDefined); const firstItem = highlights[0]; setHighlights(highlights); let waitedForClick = false; if (firstItem && mustChooseOne) { const { left, top } = firstItem; setMessage({ text: "Chose one", left, top: top - 70 }); if (isPlaywrightTest) { items[0]?.scrollIntoView(); items[0]?.click(); } else { await new Promise((resolve) => { window.addEventListener("click", resolve, { once: true }); window.addEventListener("keydown", resolve, { once: true }); }); } waitedForClick = true; } if (!waitedForClick) { await tout( isPlaywrightTest ? 0 : itemPosition === "mid" ? 500 : 2000, ); } setHighlights([]); setMessage(undefined); return Array.from(items); }, [addAlert, setHighlights], ); const showMultiHighlight = useCallback( async (doc: UIDocNonInfo, duration: DocItemHighlightItemPosition) => { const [firstItem] = await highlight(doc, duration); if (!firstItem) { addAlert({ children: `No items found in the ${JSON.stringify(doc.title)} list.`, }); return; } }, [highlight, addAlert], ); return { highlight, message, setMessage, showMultiHighlight }; }; ================================================ FILE: client/src/app/CommandPalette/utils.ts ================================================ import { isObject } from "@common/publishUtils"; import type { AlertContext } from "@components/AlertProvider"; import { includes } from "../../dashboard/W_SQL/W_SQLBottomBar/W_SQLBottomBar"; import { waitForElement } from "../../demo/demoUtils"; import { isPlaywrightTest } from "../../i18n/i18nUtils"; import { getCommandElemSelector, type Command } from "../../Testing"; import { isDefined } from "../../utils/utils"; import type { UIDoc, UIDocNonInfo } from "../UIDocs"; export const focusElement = async ( testId: Command | "", endSelector?: string, ) => { const elem = await waitForElement(testId, endSelector); elem.scrollIntoView({ behavior: "smooth", block: "center" }); elem.focus(); return elem; }; export const getUIDocElements = (doc: Exclude) => { const selectors = doc.type === "page" ? doc.pathItem : doc; const { selectorCommand, selector = "" } = selectors ?? {}; const childSelector = doc.type === "list" ? doc.itemSelector : ""; let fullSelector = (selectorCommand ? getCommandElemSelector(selectorCommand) : "") + " " + selector + " " + childSelector; if (!fullSelector.trim() && doc.type === "page") { fullSelector = "body"; } const items = document.querySelectorAll(fullSelector); return { items, fullSelector, }; }; export const highlightItems = (doc: Exclude) => { const listItems = getUIDocElements(doc).items; listItems.forEach((el) => { el.style.border = "2px solid var(--text-warning)"; }); return Array.from(listItems); }; const PATH_JOIN_CHARS = ["/", "#", "?"] as const; export const getDocPagePath = ( doc: Extract, pathname: string, ) => { const paths = doc.type === "page" ? [doc.pathItem?.selectorPath, doc.path].filter(isDefined) : doc.paths; const matchedPage = paths.find((pathWopts) => { const path = isObject(pathWopts) ? pathWopts.route : pathWopts; const exact = isObject(pathWopts) && pathWopts.exact; if (exact) { return pathname === path; } const endChar = pathname[path.length]; return ( pathname.startsWith(path) && (!endChar || includes(endChar, PATH_JOIN_CHARS)) ); }); let isOnPage = !!matchedPage; let currentPathItem: string | undefined; if (matchedPage && doc.type === "page" && doc.pathItem) { const matchedPagePath = isObject(matchedPage) ? matchedPage.route : matchedPage; currentPathItem = pathname .slice(matchedPagePath.length + 1) .split(`/[${PATH_JOIN_CHARS.join("")}]/`) .pop(); isOnPage = isOnPage && !!currentPathItem; } const isExactMatch = !isOnPage ? false : doc.type === "navbar" ? true : doc.pathItem ? Boolean(currentPathItem) : doc.path === pathname; return { isOnPage, isExactMatch, matchedPage, paths: paths.map((pathWopts) => isObject(pathWopts) ? pathWopts.route : pathWopts, ), }; }; export const getUIDocElementsAndAlertIfEmpty = ( doc: UIDocNonInfo, addAlert: AlertContext["addAlert"], ) => { const result = getUIDocElements(doc); if (!result.items.length && !isPlaywrightTest) { addAlert({ children: [ `Could not find a ${JSON.stringify(doc.title)} item.`, `Selector used: ${JSON.stringify(result.fullSelector)}`, ].join("\n"), }); } return result; }; ================================================ FILE: client/src/app/UIDocs/UIInstallationUIDoc.ts ================================================ import type { UIDoc } from "../UIDocs"; export const UIInstallation = { title: "Installation", type: "info", description: "A guide to help you get started with the application.", docs: ` The quickest way to start is with Docker. If you don't have Docker installed, please follow the official Docker installation guide Download the source code: \`\`\`bash git clone https://github.com/prostgles/ui.git cd ui \`\`\` Start the application: \`\`\`docker-compose.sh docker compose up -d --build \`\`\` Once running, Prostgles UI will be available at [localhost:3004](http://localhost:3004) ### Initial Setup & Authentication When first launching Prostgles UI, an admin user will be created automatically: - If \`PRGL_USERNAME\` and \`PRGL_PASSWORD\` environment variables are provided, an admin user is created with these credentials. - Otherwise, a passwordless admin user is created. It gets assigned to the first client that accesses the app. Subsequent clients accessing the app will be rejected with an appropriate error message detailing that the passwordless admin user has already been assigned. To setup multiple users, the passwordless admin user must be converted to a normal admin account by setting up a password. This will allow accessing /users page where you can manage users. Users login using their username and password. Two-factor authentication is provided through TOTP (Time-based One-Time Password) and can be enabled in the account section. Email and third-party (OAuth) authentication can be configured in Server Settings section. It allows users to register and log in using their email address or third-party accounts like Google, GitHub, etc. `, } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/accountUIDoc.ts ================================================ import { mdiAccountOutline, mdiApplicationBracesOutline } from "@mdi/js"; import { ROUTES } from "@common/utils"; import { getDataKeyElemSelector } from "../../Testing"; import type { UIDocContainers } from "../UIDocs"; export const accountUIDoc = { type: "page", path: ROUTES.ACCOUNT, title: "Account", iconPath: mdiAccountOutline, description: "Manage your account settings, security preferences, and API access.", docs: ` Manage your account settings, security preferences, and API access. Account Page `, children: [ { type: "tab", title: "Account details", selector: getDataKeyElemSelector("details"), description: "View and update your account information.", children: [ { type: "smartform", title: "Account information", description: "View all account details and associated data (workspaces, dashboards, views, etc.)", tableName: "users", selectorCommand: "SmartForm", }, ], }, { type: "tab", title: "Security", selector: getDataKeyElemSelector("security"), description: "Manage your account security settings.", children: [ { type: "popup", title: "Two-factor authentication", description: "Set up and manage two-factor authentication for enhanced security.", selectorCommand: "Setup2FA.Enable", children: [ { type: "button", title: "Generate QR Code", description: "Generate a QR code to set up 2FA with your authenticator app.", selectorCommand: "Setup2FA.Enable.GenerateQR", }, { type: "button", title: "Can't scan QR code", description: "View manual setup instructions if you can't scan the QR code.", selectorCommand: "Setup2FA.Enable.CantScanQR", }, { type: "input", inputType: "text", title: "Confirm code", description: "Enter the code from your authenticator app to enable 2FA.", selectorCommand: "Setup2FA.Enable.ConfirmCode", }, { type: "button", title: "Enable 2FA", description: "Complete the 2FA setup process by confirming the code.", selectorCommand: "Setup2FA.Enable.Confirm", }, { type: "text", title: "Base64 secret", description: "Manual setup secret key for your authenticator app.", selectorCommand: "Setup2FA.Enable.Base64Secret", }, ], }, { type: "button", title: "Disable 2FA", description: "Turn off two-factor authentication for your account.", selectorCommand: "Setup2FA.Disable", }, { type: "popup", title: "Change password", description: "Change your account password.", selectorCommand: "Account.ChangePassword", children: [], }, { type: "list", title: "Active sessions", description: "View and manage your active web sessions.", selectorCommand: "Sessions", itemContent: [], itemSelector: ``, }, ], }, { type: "tab", title: "API", selector: getDataKeyElemSelector("api"), iconPath: mdiApplicationBracesOutline, description: "View and manage your API access settings.", children: [ { type: "section", title: "API Details", description: "View your API credentials and configuration.", selectorCommand: "config.api", uiVersionOnly: true, children: [], }, ], }, ], } satisfies UIDocContainers; ================================================ FILE: client/src/app/UIDocs/commandPaletteUIDoc.ts ================================================ import { getCommandElemSelector } from "src/Testing"; import type { UIDocContainers } from "../UIDocs"; export const commandPaletteUIDoc = { type: "hotkey-popup", hotkey: ["Ctrl", "K"], title: "Command Palette", description: "Go to command/action quickly", docs: ` Keyboard-driven navigation to different parts of the application without having to browse through menus or panels. Press Ctrl+K to open the command palette popup. Type to search through the documentation for functionality, settings, and other sections. Command Palette `, selectorCommand: "CommandPalette", children: [ { type: "input", inputType: "text", title: "Search commands input", description: "Type to search for commands and actions", selector: getCommandElemSelector("CommandPalette") + " " + getCommandElemSelector("SearchList.Input"), }, { type: "list", title: "Search results", description: "List of matching commands and actions. Press Enter to execute/go to the selected command.", selector: getCommandElemSelector("CommandPalette") + " " + getCommandElemSelector("SearchList.List"), itemSelector: "[data-key]", itemContent: [], }, ], } satisfies UIDocContainers; ================================================ FILE: client/src/app/UIDocs/connection/AIAssistantUIDoc.ts ================================================ import { mdiMagnify, mdiPlus, mdiStop, mdiTools } from "@mdi/js"; import { fixIndent } from "../../../demo/scripts/sqlVideoDemo"; import { getCommandElemSelector } from "../../../Testing"; import type { UIDocElement } from "../../UIDocs"; import { DEFAULT_MCP_SERVER_NAMES } from "@common/mcp"; export const AIAssistantUIDoc = { type: "popup", selectorCommand: "AskLLM", title: "AI Assistant", description: "Opens an AI assistant to help generate SQL queries, understand database schema, or perform other tasks.", docs: fixIndent(` The AI assistant is an intelligent companion that helps you work more efficiently with your PostgreSQL databases. It can generate SQL queries, explain database schemas, analyze data patterns, and assist with various database-related tasks through a conversational interface. MCP Servers can be used to extend the AI capabilities with custom tools and integrations. AI assistant popup screenshot Supported AI Providers: OpenAI, Anthropic, Google Gemini, OpenRouter, and Local Models. *Note: AI providers are configured by administrators in Server Settings > LLM Providers* `), docOptions: "asSeparateFile", children: [ { type: "section", title: "Header actions", description: "Actions available in the header of the AI assistant popup.", selector: getCommandElemSelector("Popup.header"), children: [ { type: "smartform-popup", selector: getCommandElemSelector("LLMChatOptions.toggle"), title: "Chat settings", tableName: "llm_chats", description: "Allows editing all chat settings and data as well deleting or cloning the current chat.", }, { type: "input", inputType: "select", selector: getCommandElemSelector("LLMChat.select"), title: "Select chat", description: "Selects a chat from the list of available chats. Each chat represents a separate conversation with the AI assistant.", }, { type: "button", selector: getCommandElemSelector("AskLLMChat.NewChat"), title: "New chat", description: "Creates a new chat with the AI assistant.", }, { type: "button", selector: getCommandElemSelector("Popup.toggleFullscreen"), title: "Toggle fullscreen", description: "Toggles the fullscreen mode for the AI assistant popup.", }, { type: "button", selector: getCommandElemSelector("Popup.close"), title: "Close popup", description: "Closes the AI assistant popup.", }, ], }, { type: "list", title: "Chat messages", description: "List of messages in the current chat.", selector: getCommandElemSelector("Chat.messageList"), itemContent: [], itemSelector: getCommandElemSelector("Chat.messageList") + " > .message", }, { type: "section", title: "Message input", description: "Input field for entering messages to the AI assistant and quick actions.", docs: fixIndent(` The message input area allows you to write text, attach files and control other aspects of the AI assistant (change model, add/remove tools, speech to text). `), selector: getCommandElemSelector("Chat.sendWrapper"), children: [ { type: "input", inputType: "text", title: "Message input", description: "Input field for entering messages to the AI assistant. Pressing Shift+Enter creates a new line.", selector: getCommandElemSelector("Chat.textarea"), }, { type: "popup", title: "MCP tools allowed", iconPath: mdiTools, description: `Opens the MCP tools menu for the current chat. Default tools: ${DEFAULT_MCP_SERVER_NAMES.join(", ")}`, docs: ` MCP Servers extend the capabilities of the AI assistant by providing custom tools and integrations. `, selector: getCommandElemSelector("LLMChatOptions.MCPTools"), children: [ { type: "popup", selector: getCommandElemSelector("AddMCPServer.Open"), title: "Add MCP server", iconPath: mdiPlus, description: "Opens the form to add a new MCP server for the current chat.", children: [ { type: "input", inputType: "text", title: "MCP tool json config", description: "JSON configuration for the MCP tool to be added.", selector: getCommandElemSelector("AddMCPServer") + " " + getCommandElemSelector("MonacoEditor"), }, { type: "popup", title: "Add MCP server", description: "Adds the specified MCP server to the current chat.", selector: getCommandElemSelector("AddMCPServer.Add"), children: [], }, ], }, { type: "button", title: "Stop/Start all MCP Servers", description: "Quick way to stop/restart all MCP servers.", selector: getCommandElemSelector( "MCPServersToolbar.stopAllToggle", ), iconPath: mdiStop, }, { type: "button", title: "Search tools", description: "Searches for specific MCP tools in the list of available tools.", selector: getCommandElemSelector("MCPServersToolbar.searchTools"), iconPath: mdiMagnify, }, { type: "list", title: "MCP tools", iconPath: mdiTools, description: "List of available MCP tools. To allow a tool to be used in the current chat it must be ticked. Each tool represents a specific functionality or integration.", selector: getCommandElemSelector("LLMChatOptions.MCPTools") + " " + getCommandElemSelector("SmartCardList"), itemSelector: ".SmartCard", itemContent: [ { type: "text", title: "MCP server name", description: "Name of the parent MCP server associated with the tool.", selector: "div", }, { type: "list", title: "MCP server tools", description: "List of available tools for the selected MCP server. Click to enable or disable a specific tool for the current chat.", selector: getCommandElemSelector("MCPServerTools"), itemSelector: ".SmartCard", itemContent: [], }, { type: "popup", title: "MCP Server Logs", description: "Opens the logs for the selected MCP server, allowing you to view its activity and status.", selector: getCommandElemSelector( "MCPServerFooterActions.logs", ), children: [], }, { type: "popup", title: "MCP Server Config", description: "Opens the configuration for the selected MCP server, allowing you to manage its settings.", selector: getCommandElemSelector("MCPServerConfigButton"), children: [ { type: "button", title: "Save config", description: "Saves the configuration for the selected MCP server.", selector: getCommandElemSelector("MCPServerConfig.save"), }, ], }, { type: "button", title: "Reload MCP tools", description: "Reloads the MCP tools for the selected MCP server, updating the list of available tools.", selectorCommand: "MCPServerFooterActions.refreshTools", }, { type: "button", title: "Enable/Disable MCP server", description: "Enables or disables the selected MCP server for all chats. If configuration is required a popup will be shown.", selector: getCommandElemSelector( "MCPServerFooterActions.enableToggle", ), }, ], }, ], }, { type: "smartform-popup", selector: getCommandElemSelector("LLMChatOptions.DatabaseAccess"), title: "Database access", description: "Opens the database access settings for the current chat. This controls how the AI assistant can interact with the current database.", tableName: "llm_chats", fieldNames: ["db_data_permissions", "db_schema_permissions"], }, { type: "popup", title: "Prompt Selector", description: "Opens the prompt details for the current chat, allowing you to manage the prompt template and other related settings.", selector: getCommandElemSelector("LLMChatOptions.Prompt"), children: [ { type: "popup", selectorCommand: "LLMChatOptions.Prompt.Preview", title: "Prompt preview", description: "Preview of the prompt with context variables filled in.", children: [], }, ], }, { type: "popup", title: "LLM Model", description: "Selects the LLM model to be used for the current chat. Different models may have different capabilities and performance. ", selector: getCommandElemSelector("LLMChatOptions.Model"), children: [ { type: "button", selector: `${getCommandElemSelector("LLMChatOptions.Model")} .LABELWRAPPER`, title: "Select model", description: "Selects this LLM model for the current chat.", }, { type: "smartform-popup", title: "Add model credentials", description: "Opens the form to add llm provider credentials for the selected LLM model.", selectorCommand: "LLMChatOptions.Model.AddCredentials", tableName: "llm_credentials", }, ], }, { type: "input", inputType: "file", selectorCommand: "Chat.addFiles", title: "Attach files", description: "Attaches files to be sent to the AI assistant along with the message. Supported file types may vary depending on the AI model and configuration.", }, { type: "popup", title: "Speech to Text", selectorCommand: "Chat.speech", description: "Opens the speech-to-text input options, allowing you to send audio recordings or transcribe audio messages to send to the AI assistant. Right click to open speech-to-text settings.", children: [], }, { type: "button", title: "Send message", description: "Sends the entered message to the AI assistant.", selector: getCommandElemSelector("Chat.send"), }, ], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/config/accessControlUIDoc.ts ================================================ import { mdiAccountMultiple } from "@mdi/js"; import { fixIndent } from "../../../../demo/scripts/sqlVideoDemo"; import type { UIDoc } from "../../../UIDocs"; export const accessControlUIDoc = { type: "tab", selectorCommand: "config.ac", uiVersionOnly: true, title: "Access control", iconPath: mdiAccountMultiple, description: "Manage user permissions and access rules for this database connection.", docs: fixIndent(` Manage user permissions and access rules for this database connection. Access control `), docOptions: "asSeparateFile", children: [ { type: "button", selectorCommand: "config.ac.create", title: "Create Access Rule", description: "Add a new access control rule to define user permissions.", }, { type: "button", selectorCommand: "config.ac.save", title: "Save Access Rule", description: "Save changes to the current access control rule.", }, { type: "button", selectorCommand: "config.ac.cancel", title: "Cancel Changes", description: "Cancel editing the current access control rule.", }, { type: "button", selectorCommand: "config.ac.removeRule", title: "Remove Rule", description: "Delete the selected access control rule.", }, ], } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/connection/config/apiUIDoc.ts ================================================ import { mdiApplicationBracesOutline } from "@mdi/js"; import type { UIDocElement } from "../../../UIDocs"; export const apiUIDoc = { type: "tab", selectorCommand: "config.api", iconPath: mdiApplicationBracesOutline, title: "API", description: "Configure API access settings and view API documentation.", docs: ` The API section allows you to configure the API access settings for your application. This enables programmatic access to your application's data and functionality via HTTP or WebSocket protocols. Generated database types can be used to interact with the API in a type-safe manner through our TypeScript client library. Code snippets are provided to help you get started with using the API in your applications. You can also manage API tokens for authentication and access control. You can set the URL path for the API, manage allowed origins for CORS requests, and create or view API tokens for accessing the API. The API supports both WebSocket and HTTP protocols.`, docOptions: "asSeparateFile", children: [ { type: "input", inputType: "text", selector: "input#url_path", title: "API URL Path", description: "Set the URL path for the API. This is the base path for all API endpoints.", }, { type: "popup", title: "Allowed Origin Alert", selectorCommand: "AllowedOriginCheck", description: "Will only appear if the allowed origin is not set.", children: [ { type: "input", inputType: "text", selectorCommand: "AllowedOriginCheck.FormField", title: "Allowed Origin", description: "Set the allowed origin for CORS requests. This controls which domains can make cross-origin requests to this app by setting the Access-Control-Allow-Origin header.", }, ], }, { type: "popup", selectorCommand: "APIDetailsWs.Examples", title: "Websocket API usage examples", description: "View examples of how to use the API using typescript and javascript", children: [], }, { type: "popup", selectorCommand: "APIDetailsHttp.Examples", title: "HTTP API usage examples", description: "View examples of how to use the API using typescript and javascript", children: [], }, { type: "section", selectorCommand: "APIDetailsTokens", title: "API Tokens", description: "Shows existing API tokens and allows you to create new ones.", children: [ { type: "popup", selectorCommand: "APIDetailsTokens.CreateToken", title: "Create API Token", description: "Create a new API token for accessing the API. Tokens can be used for both WebSocket and HTTP API access.", children: [ { type: "input", inputType: "number", selectorCommand: "APIDetailsTokens.CreateToken.daysUntilExpiration", title: "Expires in (days)", description: "Set the number of days until the token expires. After expiration, the token will no longer be valid.", }, { type: "button", selectorCommand: "APIDetailsTokens.CreateToken.generate", title: "Generate Token", description: "Click to generate a new API token. The token will be displayed once generated. After generation, the code examples will be updated to include the new token.", }, ], }, ], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/config/backupAndRestoreUIDoc.ts ================================================ import { mdiDatabaseSync } from "@mdi/js"; import type { UIDoc } from "../../../UIDocs"; export const backupAndRestoreUIDoc = { type: "tab", selectorCommand: "config.bkp", title: "Backup and Restore", description: "Manage database backups and restore operations.", docOptions: "asSeparateFile", iconPath: mdiDatabaseSync, docs: ` Manage database backups and restore operations for this PostgreSQL connection. Create reliable backups using PostgreSQL's native tools and restore your data when needed. Backups can be saved to a local file system or to cloud storage to AWS S3. Similarly, you can restore backups from local files or from AWS S3. Backup and Restore `, children: [ { type: "popup", selectorCommand: "config.bkp.create", title: "Create Backup", description: "Start a new database backup operation.", children: [ { type: "input", inputType: "text", selectorCommand: "config.bkp.create.name", title: "Backup Name", description: "Optional name for the backup to help identify it later.", }, { type: "select", selectorCommand: "PGDumpOptions.format", title: "Backup Format", description: "Choose the backup format: Custom, Plain SQL, Tar, or Directory.", }, { type: "input", inputType: "checkbox", selectorCommand: "PGDumpOptions.schemaOnly", title: "Schema Only", description: "Backup only the database schema without data.", }, { type: "input", inputType: "checkbox", selectorCommand: "PGDumpOptions.dataOnly", title: "Data Only", description: "Backup only the data without schema.", }, { type: "select", selectorCommand: "PGDumpOptions.destination", title: "Backup Destination", description: "Choose where to save the backup: Local filesystem or AWS S3.", }, { selectorCommand: "PGDumpOptions.numberOfJobs", type: "input", inputType: "number", title: "Number of Jobs", description: "Specify the number of parallel jobs to use for the backup process.", }, { selectorCommand: "PGDumpOptions.compressionLevel", type: "input", inputType: "number", title: "Compression Level", description: "Set the compression level for the backup (0-9). Higher values mean better compression but slower performance.", }, { selectorCommand: "PGDumpOptions.excludeSchema", type: "input", inputType: "text", title: "Exclude Schema", description: "Specify a schema to exclude from the backup.", }, { selectorCommand: "PGDumpOptions.noOwner", type: "input", inputType: "checkbox", title: "No Owner", description: "Do not output commands to set ownership of objects to match the original database. Useful when restoring to a different database user.", }, { selectorCommand: "PGDumpOptions.create", type: "input", inputType: "checkbox", title: "Create", description: "Include commands to create the database in the backup.", }, { selectorCommand: "PGDumpOptions.globalsOnly", type: "input", inputType: "checkbox", title: "Globals Only", description: "Backup only global objects such as roles and tablespaces.", }, { selectorCommand: "PGDumpOptions.rolesOnly", type: "input", inputType: "checkbox", title: "Roles Only", description: "Backup only roles (users) from the database.", }, { selectorCommand: "PGDumpOptions.schemaOnly", type: "input", inputType: "checkbox", title: "Schema Only", description: "Backup only the database schema without data.", }, { selectorCommand: "PGDumpOptions.encoding", type: "input", inputType: "text", title: "Encoding", description: "Specify the character encoding to use in the backup.", }, { selectorCommand: "PGDumpOptions.clean", type: "input", inputType: "checkbox", title: "Clean", description: "Include commands to drop database objects before recreating them.", }, { selectorCommand: "PGDumpOptions.dataOnly", type: "input", inputType: "checkbox", title: "Data Only", description: "Backup only the data without schema.", }, { selectorCommand: "PGDumpOptions.ifExists", type: "input", inputType: "checkbox", title: "If Exists", description: "Use IF EXISTS clauses in the backup to avoid errors when dropping objects that do not exist.", }, { selectorCommand: "PGDumpOptions.keepLogs", type: "input", inputType: "checkbox", title: "Keep Logs", description: "Retain log files generated during the backup process.", }, { type: "button", selectorCommand: "config.bkp.create.start", title: "Start Backup", description: "Begin the backup process with the selected options.", }, ], }, { type: "popup", selectorCommand: "config.bkp.AutomaticBackups", title: "Automatic Backups", description: "Configure scheduled automatic backups for this database.", children: [ { type: "input", inputType: "checkbox", selectorCommand: "config.bkp.AutomaticBackups.toggle", title: "Enable Automatic Backups", description: "Enable or disable automatic backup scheduling.", }, ], }, { type: "section", selectorCommand: "BackupControls.backupsInProgress", title: "Backups in Progress", description: "Monitor and manage ongoing backup operations.", children: [ { selectorCommand: "BackupLogs", type: "button", title: "View Logs", description: "View real-time logs of the ongoing backup operation.", }, ], }, { selectorCommand: "BackupsControls.restoreFromFile", type: "button", title: "Restore from File", description: "Initiate a database restore operation from a local backup file.", }, { type: "list", selectorCommand: "BackupsControls.Completed", title: "Completed Backups", description: "View and manage completed backup operations.", itemSelector: `[data-key]`, itemContent: [ { type: "button", selectorCommand: "BackupsControls.Completed.delete", title: "Delete Backup", description: "Delete the selected backup file from storage. Will ask for confirmation.", }, { type: "button", selectorCommand: "BackupsControls.Completed.download", title: "Download Backup", description: "Download the selected backup file to your local system.", }, { type: "button", selectorCommand: "BackupsControls.Completed.restore", title: "Restore Backup", description: "Restore a database from a selected backup file. Will ask for confirmation.", }, ], }, ], } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/connection/config/fileStorageUIDoc.ts ================================================ import { mdiImage } from "@mdi/js"; import type { UIDoc } from "../../../UIDocs"; export const fileStorageUIDoc = { type: "tab", selectorCommand: "config.files", title: "File storage", description: "Configure file upload and storage settings for this connection.", docOptions: "asSeparateFile", iconPath: mdiImage, docs: ` Configure file upload and storage settings for this connection. File Storage Configuration `, children: [ { type: "input", inputType: "checkbox", selectorCommand: "config.files.toggle", title: "Enable File Storage", description: "Enable file upload and storage capabilities for this connection.", }, ], } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/connection/connectionConfigUIDoc.ts ================================================ import { mdiChartLine, mdiDatabaseCogOutline } from "@mdi/js"; import { fixIndent, ROUTES } from "@common/utils"; import type { UIDocContainers } from "../../UIDocs"; import { editConnectionUIDoc } from "../editConnectionUIDoc"; import { accessControlUIDoc } from "./config/accessControlUIDoc"; import { apiUIDoc } from "./config/apiUIDoc"; import { backupAndRestoreUIDoc } from "./config/backupAndRestoreUIDoc"; import { fileStorageUIDoc } from "./config/fileStorageUIDoc"; export const connectionConfigUIDoc = { type: "page", path: ROUTES.CONFIG, iconPath: mdiDatabaseCogOutline, pathItem: { tableName: "connections", selectorCommand: "Connection.configure", selectorPath: ROUTES.CONNECTIONS, }, title: "Connection configuration", description: "Configure the selected database connection. Set connection details, manage users, and customize settings.", docs: fixIndent(` Configure the selected database connection. Set connection details, manage users, and customize settings. Connection configuration `), children: [ { type: "tab", selectorCommand: "config.details", title: "Connection details", description: "Edit connection parameters such as host, port, database name, and other connection settings.", docs: editConnectionUIDoc.docs, iconPath: mdiDatabaseCogOutline, children: [], }, { type: "tab", selectorCommand: "config.status", title: "Status monitor", iconPath: mdiChartLine, description: "View real-time connection status, running queries, and system resource usage.", children: [], }, accessControlUIDoc, fileStorageUIDoc, backupAndRestoreUIDoc, apiUIDoc, { type: "tab", selectorCommand: "config.tableConfig", title: "Table config", description: "Advanced table configuration using TypeScript (experimental feature).", children: [], }, { type: "tab", selectorCommand: "config.methods", title: "Server-side functions", description: "Configure and manage server-side functions (experimental feature).", children: [], }, ], } satisfies UIDocContainers; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/dashboardContentUIDoc.ts ================================================ import type { UIDocElement } from "../../../UIDocs"; import { mapUIDoc } from "./mapUIDoc"; import { sqlEditorUIDoc } from "./sqlEditorUIDoc"; import { tableUIDoc } from "./table/tableUIDoc"; import { timechartUIDoc } from "./timechartUIDoc"; export const dashboardContentUIDoc = { type: "section", selector: ".Dashboard_MainContentWrapper", title: "Workspace area", description: "Main content area of the dashboard, where the tables, views, SQL editors and other visualisations are displayed.", docs: ` The workspace area is the main place for interacting with your data. It includes the SQL editor, data tables, maps, and timecharts, allowing you to execute queries, visualize data, and manage database objects.`, children: [sqlEditorUIDoc, tableUIDoc, mapUIDoc, timechartUIDoc], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/dashboardMenuUIDoc.ts ================================================ import { getDataKeyElemSelector, getDataLabelElemSelector, } from "../../../../Testing"; import type { UIDocElement } from "../../../UIDocs"; export const dashboardMenuUIDoc = { type: "popup", selectorCommand: "dashboard.menu", title: "Dashboard menu", description: "Allows opening tables and views, schema diagram, importing files, managing saved queries, and accessing dashboard settings.", docs: ` The dashboard menu is the entry point in exploring your database. The layout adapts to the screen size by pinning the menu to keep it open when there is enough space. For wider screens the centered layout mode can be enabled through the settings. `, contentSelectorCommand: "DashboardMenuContent", children: [ { type: "button", selectorCommand: "dashboard.menu.sqlEditor", title: "Open an SQL editor", description: "Opens an SQL editor view in the workspace area.", }, { type: "popup", selectorCommand: "dashboard.menu.quickSearch", title: "Quick search", description: "Opens the quick search menu for searching across all available tables and views from the current database.", children: [], }, { type: "smartform-popup", tableName: "workspaces", fieldNames: ["options"], selectorCommand: "dashboard.menu.settings", title: "Settings", description: "Opens the settings menu for configuring dashboard layout preferences.", }, { type: "button", selectorCommand: "DashboardMenuHeader.togglePinned", title: "Pin/Unpin menu", description: "Toggles the pinning of the dashboard menu. Pinned menus remain open until unpinned or accessing from a low width screen.", }, { type: "drag-handle", direction: "x", title: "Resize menu", description: "Allows resizing the dashboard menu. Drag to adjust the width of the menu.", selectorCommand: "dashboard.menu.resize", }, { type: "drag-handle", direction: "x", selectorCommand: "dashboard.centered-layout.resize", title: "Resize centered layout", description: "Allows resizing the workspace area when centered layout is enabled. Drag to adjust the width of the centered layout.", }, { type: "list", selectorCommand: "dashboard.menu.savedQueriesList", title: "Saved queries", description: "List of saved queries of the current user from the current workspace. Click to open a saved query.", itemContent: [], itemSelector: "li", }, { type: "list", selectorCommand: "dashboard.menu.tablesSearchList", title: "Tables and views", description: "List of tables and views from the current database. Click to open a table or view. By default only the tables from the public schema are shown. Schema list from the connection settings controls which schemas are shown.", itemSelector: "li", itemContent: [], }, { type: "list", selectorCommand: "dashboard.menu.serverSideFunctionsList", title: "Server-side functions", description: "List of server-side functions for the current database. Click to open a function.", itemSelector: "li", itemContent: [], }, { type: "popup", selectorCommand: "dashboard.menu.create", title: "Create/Import", description: "Opens the menu for creating new tables, server-side functions or importing csv/json files.", docs: ` Create new tables, server-side functions or import files into the current database.`, children: [ { type: "popup", selector: getDataKeyElemSelector("new table"), title: "Create new table", description: "Opens the form to create a new table in the current database.", children: [], }, { type: "popup", selector: getDataKeyElemSelector("import file"), title: "Import file", description: "Opens the form to import a file into the current database.", docs: ` Import files into the current database. Supported file types include CSV, GeoJSON, and JSON. The import process allows you to specify the table name, infer column data types, and choose how to insert JSON/GeoJSON data into the table. File Importer screenshot `, docOptions: "asSeparateFile", childrenTitle: "Import file options", children: [ { type: "input", inputType: "file", selectorCommand: "FileBtn", title: "Import file", description: "Input field for selecting a file to import. Supported types: csv/geojson/json.", }, { type: "input", inputType: "text", selector: getDataLabelElemSelector("Table name"), title: "Table name", description: "New/existing table name into which data is to be imported.", }, { type: "input", inputType: "checkbox", selector: getDataLabelElemSelector( "Try to infer and apply column data types", ), title: "Try to infer and apply column data types", description: "Checkbox for inferring and applying column data types during import. If checked, the system will attempt to determine the appropriate data types for each column based on the imported file. If unchecked, TEXT data type will be used for all columns.", }, { type: "input", inputType: "checkbox", selector: getDataLabelElemSelector("Drop table if exists"), title: "Drop table if exists", description: "Checkbox for dropping the table if it already exists in the database. If checked, the existing table will be deleted before importing the new file.", }, { type: "select", selector: getDataLabelElemSelector("Insert as"), title: "Insert as", description: "Select list for choosing the method of inserting JSON/GeoJSON data into the table. Options include: Single text value, JSONB rows, and Properties with geometry.", }, { type: "button", selectorCommand: "FileImporterFooter.import", title: "Import", description: "Button to initiate the import process. Click to start importing the selected file into the specified table.", }, ], }, { type: "popup", selector: getDataKeyElemSelector("new function"), title: "Create TS Function", description: "Opens the form to create a new server-side TypeScript function for the current database.", children: [], }, ], }, { type: "popup", selectorCommand: "SchemaGraph", title: "Schema diagram", description: "Opens the schema diagram for visualizing the relationships between tables in the current database.", docs: ` Explore your database structure visually through the schema diagram. This tool lets you: - **Select schemas** — Choose one or multiple schemas to display - **Navigate freely** — Pan and zoom to focus on specific areas - **View table relationships** — See how tables connect through foreign keys - **Filter your view** — Show or hide tables and columns by relationship type - **Color links by root table** — Trace relationships back to their source at a glance. Links inherit the color of the table that defines the relationship (e.g., all user_id foreign keys match the users table color) - **Reset the layout** — Return to the default view which auto-arranges tables ensuring the most linked tables are central It allows you to explore the schema structure, view table relationships, and manage the layout of the schema diagram. You can pan and zoom the diagram, select schemas, filter tables and columns based on their relationship types, reset the layout. Link color modes allow you to better understand related tables and foreign key properties. Schema diagram screenshot ## Controls `, docOptions: "asSeparateFile", childrenTitle: "Top controls", children: [ { type: "select", selectorCommand: "SchemaGraph.TopControls.tableRelationsFilter", title: "Table relationship filter", description: "Display tables based on their relationship type. Options include: all, linked (with relationships), orphaned (without relationships).", }, { type: "select", selectorCommand: "SchemaGraph.TopControls.columnRelationsFilter", title: "Column relationship filter", description: "Display columns based on their relationship type. Options include: all, references (with relationships), none (no columns/only table names will be shown).", }, { type: "select", title: "Link colour mode", selectorCommand: "SchemaGraph.TopControls.linkColorMode", description: "Colour links by: default (fixed colour), root table (the colour of the table the relationship tree originates from), on-delete/on-update (colour based on constraint referential action).", }, { type: "button", selectorCommand: "SchemaGraph.TopControls.resetLayout", title: "Reset layout", description: "Moving tables is persisted the state database. Clicking this resets the schema diagram layout to its initial state.", }, { type: "button", selectorCommand: "Popup.close", title: "Close schema diagram", description: "Closes the schema diagram and returns to the dashboard menu.", }, ], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/mapUIDoc.ts ================================================ import { fixIndent } from "../../../../demo/scripts/sqlVideoDemo"; import { getCommandElemSelector, getDataKeyElemSelector, } from "../../../../Testing"; import type { UIDocElement } from "../../../UIDocs"; import { getCommonViewHeaderUIDoc } from "../getCommonViewHeaderUIDoc"; export const mapUIDoc = { type: "section", selector: `.SilverGridChild[data-view-type="map"]`, title: "Map view", description: "Displays a map visualization based on the Table/SQL query results.", docOptions: "asSeparateFile", docs: fixIndent(` The map view allows you to visualize geographical data from your database. It requires the [PostGIS](https://postgis.net/) extension to be installed on your PostgreSQL database. It can display points, lines, and polygons based on geometry or geography columns in your tables or views. It supports multiple layers, custom basemaps, and various map controls for interaction. Map view screenshot `), children: [ getCommonViewHeaderUIDoc( "Shows the table/view name together with the geometry/geography column name used for the map visualization.", { title: "Map view menu", docs: fixIndent(` The map view menu provides options for configuring the map visualization, including data refresh, basemap settings, and layer management. `), description: "Data refresh and display options.", children: [ { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("Data refresh"), title: "Data refresh", description: "Allows setting subscriptions or data refresh rates. By default every table subscribes to data changes.", children: [], }, { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("Basemap"), title: "Basemap", description: "Allows setting the map tiles and projection.", children: [ { type: "select", selectorCommand: "MapBasemapOptions.Projection", title: "Projection", description: "Allows setting the map projection: Mercator (default) or Orthographic (allows a setting custom tile image for plan drawings).", }, ], }, { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("Layers"), title: "Layers", description: "Allows setting the map layers data source and style. The map supports multiple layers.", children: [], }, { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("Settings"), title: "Settings", description: "Allows setting the map layout options: aggregation limit, click behavior, etc.", children: [], }, ], }, "chart", ), { type: "section", selector: ".Window", title: "Map window with controls", description: "Map visualization and controls for interacting with the map.", docs: fixIndent(` The map window contains the map visualization and controls for interacting with the map. It allows you to add layers, set the map extent behavior, and toggle cursor coordinates display. `), children: [ { type: "popup", selectorCommand: "ChartLayerManager", title: "Map layer manager", description: "Allows adding/removing layers to the map. Each layer can be configured with its own data source and style.", children: [ { type: "select", selectorCommand: "ChartLayerManager.AddChartLayer.addLayer", title: "Add layer", description: "Allows adding a new layer to the map. The available options are all the tables that have geometry or geography columns.", }, { type: "popup", selectorCommand: "ChartLayerManager.AddChartLayer.addOSMLayer", title: "Add OSM layer", description: "Allows adding a new layer based on OpenStreetMap data. This is useful for displaying additional map data like roads, restaurants, etc.", children: [], }, { type: "popup", selectorCommand: "MapBasemapOptions", title: "Map basemap options", description: "Allows setting the map tiles and projection.", children: [], }, { selectorCommand: "MapOpacityMenu", type: "section", title: "Map opacity options", description: "Allows setting the opacity of the map layers.", children: [], }, ], }, { type: "button", selectorCommand: "InMapControls.showCursorCoords", title: "Show cursor coordinates", description: "Toggle displays the current cursor coordinates on the map. ", }, { type: "select", selectorCommand: "MapExtentBehavior", title: "Map extent behavior", description: "Allows setting the map extent and auto-zoom behavior: Follow data, Follow map or Free roam.", }, { type: "button", selectorCommand: "InMapControls.goToDataBounds", title: "Zoom to data", description: "Zooms the map to fit the bounds of the data currently displayed.", }, { type: "select", selectorCommand: "DeckGLFeatureEditor", title: "Add new feature", description: "Allows drawing and inserting a new feature: Point, Line or Polygon.", }, ], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/serverSideFunctionUIDoc.ts ================================================ import { fixIndent } from "../../../../demo/scripts/sqlVideoDemo"; import type { UIDocElement } from "../../../UIDocs"; import { getCommonViewHeaderUIDoc } from "../getCommonViewHeaderUIDoc"; export const serverSideFunctionUIDoc = { type: "section", selector: `.SilverGridChild[data-view-type="method"]`, title: "Server-side function view", description: "Allows executing server-side functions and viewing results.", docs: fixIndent(` The server-side functions is an experimental feature that allows you to specify and execute server-side Typescript functions directly from the dashboard.`), children: [ getCommonViewHeaderUIDoc( "Function name.", { children: [], title: "Server-side function view menu", description: "Server-side function menu", docs: fixIndent(` The server-side function view menu provides options for executing the function, viewing results, and managing function parameters. `), }, "method", ), ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/sqlEditorUIDoc.ts ================================================ import { fixIndent } from "../../../../demo/scripts/sqlVideoDemo"; import { getCommandElemSelector, getDataKeyElemSelector, } from "../../../../Testing"; import type { UIDocElement } from "../../../UIDocs"; import { getCommonViewHeaderUIDoc } from "../getCommonViewHeaderUIDoc"; export const sqlEditorUIDoc = { type: "section", selector: `.SilverGridChild[data-view-type="sql"]`, title: "SQL editor", description: "The SQL editor allows users to write and execute SQL queries against the selected database. It provides a user-friendly interface for interacting with the database.", docs: ` The SQL editor is a powerful tool for executing SQL queries against your PostgreSQL database. ### Core Features - **Intelligent auto-completion** with context-aware suggestions based on your schema and data with JSONB property access support - **Rich suggestion details** with related objects, usage examples and documentation extracts, reducing the need to switch context - **Execute current statement** functionality to run only the SQL statement where the cursor is located - **Charting options** to visualize query results as timecharts or maps directly from the editor - **Multiple result display modes** including table, JSON, and CSV formats To make it easier working with multiple queries, the default query execution behaviour is to execute the current statement. It is highlighted by the blue vertical line to the left of the code. Press Alt+E or Ctrl+Enter or F5 to execute it. The editor is based on [Monaco Editor](https://microsoft.github.io/monaco-editor/), which powers VS Code. It supports multi-cursor editing, find and replace, and many other advanced editing features. Realtime query resource usage can be enabled in the settings to monitor CPU and memory consumption. SQL editor screenshot ## Components: `, docOptions: "asSeparateFile", children: [ getCommonViewHeaderUIDoc( "SQL editor query name, editable in the menu.", { title: "SQL editor menu", description: "The SQL editor menu provides access to various options and settings for the SQL editor.", docs: fixIndent(` The SQL editor menu provides access to various options and settings for the SQL editor.`), children: [ { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("General"), title: "General", description: "General settings for the SQL editor.", children: [ { type: "input", inputType: "text", selectorCommand: "W_SQLMenu.name", title: "Query name", description: "The name of the current SQL query. This is used for saving and managing queries.", }, { type: "select", selectorCommand: "W_SQLMenu.renderDisplayMode", title: "Result display mode", description: "The mode in which the results of the SQL query will be displayed. Options include table, JSON, and CSV.", }, { type: "button", selectorCommand: "W_SQLMenu.saveQuery", title: "Save query as file", description: "Saves the current SQL query to a file. This can also be accomplished by pressing Ctrl+S.", }, { type: "button", selectorCommand: "W_SQLMenu.openSQLFile", title: "Open SQL file", description: "This allows loading the contents of an SQL file into the current query.", }, { selectorCommand: "W_SQLMenu.deleteQuery", type: "button", title: "Delete query", description: "Deletes the current SQL query. If it has contents a confirmation dialog will appear.", }, ], }, { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("Editor options"), title: "Editor options", description: "Settings for the SQL editor's appearance and behavior.", children: [ { type: "smartform", tableName: "windows", fieldNames: ["sql_options"], title: "Editor settings", selector: getCommandElemSelector("Popup.content") + " .MonacoEditor", description: "Settings for the SQL editor's appearance and behavior. This includes font size, theme, and other preferences.", }, ], }, { type: "tab", selector: getCommandElemSelector("MenuList") + " " + getDataKeyElemSelector("Hotkeys"), title: "Hotkeys", description: "Keyboard shortcuts for common actions in the SQL editor. This includes executing queries, saving files, and more.", children: [], }, ], }, "sql", ), { type: "section", selectorCommand: "W_SQLEditor", title: "SQL editor component", description: "The main component for the SQL editor. It contains the SQL editor and statement action buttons. Being bsed on Monaco editor, it supports syntax highlighting, auto-completion and other editing functionality like multi-cursor editing.", children: [ { type: "input", inputType: "text", selectorCommand: "MonacoEditor", title: "SQL editor input", description: "The input field for writing SQL queries. It supports syntax highlighting and auto-completion.", }, { type: "button", selectorCommand: "W_SQLEditor.executeStatement", title: "Execute current statement", description: "Executes the current SQL statement highlighted by the blue vertical line to the left of the code. This button is only visible when the cursor is within a statement.", }, { type: "button", selectorCommand: "AddChartMenu.Timechart", title: "Add timechart", description: "Adds a timechart visualization based on the current SQL statement. This button is only visible when the cursor is within a statement that returns at least one timestamp column.", }, { type: "button", selectorCommand: "AddChartMenu.Map", title: "Add map", description: "Adds a map visualization based on the current SQL statement. This button is only visible when the cursor is within a statement that returns at least one geometry/geography column (postgis extension must be enabled).", }, ], }, { type: "section", title: "SQL editor toolbar", description: "The toolbar provides various options for executing and managing SQL queries.", selectorCommand: "W_SQLBottomBar", children: [ { type: "button", selectorCommand: "W_SQLBottomBar.runQuery", title: "Run query", description: "Executes the current SQL query. The result will be displayed in the query results section.", }, { selectorCommand: "W_SQLBottomBar.limit", title: "Limit", type: "input", inputType: "number", description: "Sets the maximum number of rows to return in the query results. This is useful for limiting the amount of data returned, especially for large datasets.", }, { type: "button", selectorCommand: "W_SQLBottomBar.cancelQuery", title: "Cancel query", description: "Cancels the currently running query. This button is only visible when a query is running.", }, { type: "button", selectorCommand: "W_SQLBottomBar.terminateQuery", title: "Terminate query", description: "Forcefully terminates the currently running query. This is more aggressive than cancel and is only visible when a query is running.", }, { type: "button", selectorCommand: "W_SQLBottomBar.stopListen", title: "Stop LISTEN", description: "Stops the active LISTEN operation. This button is only visible when a LISTEN query is active.", }, { type: "text", selectorCommand: "W_SQLBottomBar.rowCount", title: "Row count", description: "Displays the number of rows fetched by the query and the total number of rows if available.", }, { type: "button", selectorCommand: "W_SQLBottomBar.toggleTable", title: "Toggle table visibility", description: "Shows or hides the results table for the executed query.", }, { type: "button", selectorCommand: "W_SQLBottomBar.toggleCodeEditor", title: "Toggle code editor", description: "Shows or hides the SQL code editor, allowing users to focus on query results when needed.", }, { type: "button", selectorCommand: "W_SQLBottomBar.toggleNotices", title: "Toggle notices", description: "Shows or hides database notices. When enabled, it displays notifications from the database system.", }, { type: "section", selectorCommand: "W_SQLBottomBar.queryDuration", title: "Query duration", children: [], description: "Displays the execution time of the last completed query or the current running time for an active query.", }, { type: "select", selectorCommand: "W_SQLBottomBar.copyResults", title: "Copy results", description: "Copy/download query results as: CSV, TSV, JSON, Typescript definition, SQL SELECT INTO", }, { type: "text", selectorCommand: "W_SQLBottomBar.sqlError", title: "SQL error", description: "Displays any errors that occurred during the execution of the SQL query. This is useful for debugging and correcting SQL syntax.", }, ], }, { type: "section", selectorCommand: "W_SQLResults", title: "Query results", description: "Displays the results of the executed SQL query. Results can be displayed as a table (default), JSON or CSV. Users can interact with the results, such as sorting and filtering.", children: [ { type: "section", selector: ".table-component", title: "Results table", description: "The results table displays the data returned by the executed SQL query. It supports sorting, filtering, and pagination.", children: [], }, { type: "section", selectorCommand: "Window.ChildChart", title: "Chart", description: "Timechart/Map visualization of the SQL query results.", children: [], }, ], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/addColumnMenuUIDoc.ts ================================================ import type { UIDoc } from "src/app/UIDocs"; import { getCommandElemSelector } from "src/Testing"; export const addColumnMenuUIDoc = { type: "popup", selectorCommand: "AddColumnMenu", title: "Add column menu", description: "Opens the add column menu, allowing users to add computed columns, create new columns, add linked fields.", children: [ { type: "popup", selector: `${getCommandElemSelector("AddColumnMenu")} [data-key="Computed"]`, contentSelectorCommand: "QuickAddComputedColumn", title: "Add Computed Field", description: "Opens a popup to create a computed column - calculations or transformations based on existing data using functions like aggregations, date formatting, string operations, etc.", children: [ { type: "list", selectorCommand: "FunctionSelector", title: "Function selector", description: "Choose a function to apply to the selected column (e.g., aggregates (min/max/avg/count), date formatting, string operations).", itemContent: [], itemSelector: `li[role="option"]`, }, { type: "list", selectorCommand: "SearchList.List", title: "Column selection", description: "List of applicable columns to apply functions to. Columns from foreign tables are also shown with the join path in the header.", itemSelector: "li", itemContent: [], }, { type: "input", inputType: "text", selectorCommand: "AddComputedColMenu.name", title: "Column name", description: "Name for the new computed column. Auto-generated based on the function and column.", }, { type: "select", selectorCommand: "AddComputedColMenu.addTo", title: "Add to position", description: "Choose whether to add the computed column at the start or end of the column list.", }, { type: "button", selectorCommand: "AddComputedColMenu.addBtn", title: "Add computed column", description: "Confirms and adds the new computed column to the table based on the selected function and parameters.", }, ], }, { type: "popup", selector: `${getCommandElemSelector("AddColumnMenu")} [data-key="Referenced"]`, contentSelectorCommand: "LinkedColumn", title: "Add Linked Data", description: "Opens a popup to display data from related tables via foreign key relationships. Disabled if no foreign keys exist or when using aggregates with nested columns.", children: [ { type: "select", selectorCommand: "JoinPathSelectorV2", title: "Join path selector", description: "Select which related table to join to via foreign key relationships. Shows available join paths from the current table.", }, { type: "input", inputType: "text", selector: "#nested-col-name", title: "Column label", description: "Custom name/label for this linked column field in the table. Defaults to the related table name.", }, { type: "select", selectorCommand: "LinkedColumn.ColumnList.toggle", title: "Linked column selection", description: "Choose which columns from the related table to display in this linked field.", }, { type: "select", selectorCommand: "QuickAddComputedColumn", title: "Quick add computed column", description: "Add a single computed column from the linked table. E.g., count, sum, avg.", }, { type: "section", selector: ".ExpandSection", title: "More options", description: "Additional configuration for linked columns including layout, join type, filters, and limits.", children: [ { type: "select", selector: "LinkedColumn.layoutType", title: "Layout mode", description: "Choose how to display the linked data: as rows, columns, or without headers.", }, { type: "select", selectorCommand: "LinkedColumn.joinType", title: "Join type", description: "Select between inner join (discards parent rows without matches) or left join (keeps all parent rows).", }, { type: "input", inputType: "number", selector: "#nested-col-limit", title: "Limit", description: "Maximum number of linked records to display (0-30). Optional.", }, { type: "section", selectorCommand: "SmartFilterBar", title: "Filters and sorting", description: "Apply filters and sorting to the linked table data.", children: [], }, ], }, ], }, { type: "popup", selector: `${getCommandElemSelector("AddColumnMenu")} [data-key="Create"]`, title: "Create New Column", description: "Opens a popup to create a new physical column in the database table. Disabled for views or users without SQL privileges.", children: [ { type: "section", selectorCommand: "ColumnEditor.name", title: "Column editor", description: "Configure column properties including name, data type, constraints, default values, and foreign key references.", children: [], }, { type: "select", selectorCommand: "AddColumnReference", title: "Foreign key reference", description: "Optionally set up a foreign key relationship to another table/column in the database.", }, { type: "select", selectorCommand: "ColumnEditor.dataType", title: "Data type selection", description: "Appears after typing the column name. Choose the data type for the new column (e.g., integer, text, date, boolean, etc.).", }, { type: "tab", selectorCommand: "CreateColumn.next", title: "Show create column SQL", description: "Generates and displays the SQL query that will be executed to create the new column in the database.", children: [ { type: "input", inputType: "text", selectorCommand: "MonacoEditor", title: "Generated SQL query", description: "The generated ALTER TABLE SQL query to create the new column based on the specified properties.", }, { type: "button", selectorCommand: "SQLSmartEditor.Run", title: "Execute create column", description: "Runs the generated ALTER TABLE query to create the new column in the database.", }, ], }, ], }, { type: "popup", selector: `${getCommandElemSelector("AddColumnMenu")} [data-key="CreateFileColumn"]`, title: "Create New File Column", description: "Opens a popup to create a new column for handling file uploads and attachments. Requires file storage to be enabled.", children: [ { type: "input", inputType: "text", selector: `[data-label="New column name"] input`, title: "New column name", description: "Name for the new file column.", }, { type: "input", inputType: "checkbox", selector: ".SwitchToggle", title: "Optional", description: "Whether the file column should allow NULL values (optional) or require a file (NOT NULL).", }, { type: "section", selectorCommand: "FileColumnConfigEditor", title: "File column configuration", description: "Configure accepted file types and other file handling options. Appears after entering the column name.", children: [ { type: "input", title: "Maximum file size in megabytes", inputType: "number", selectorCommand: "FileColumnConfigEditor.maxFileSizeMB", description: "Set the maximum allowed file size for uploads in megabytes. Default is 1 MB. 0 means no limit.", }, { type: "select", selectorCommand: "FileColumnConfigEditor.contentMode", title: "Content filter mode", description: "Choose how to filter accepted files: by file extension, basic content type (e.g., image, audio, vide), by specific content type (e.g., image/png), by specific extension (jpg, png, pdf).", }, ], }, { type: "button", selectorCommand: "CreateFileColumn.confirm", title: "Create file column", description: "Confirms and creates the new file column.", }, ], }, ], } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/columnMenuUIDoc.ts ================================================ import type { UIDoc } from "src/app/UIDocs"; import { getDataKeyElemSelector } from "src/Testing"; export const columnMenuUIDoc = { type: "popup", title: "Column menu", triggerMode: "contextmenu", selector: `[role="columnheader"]`, description: "Opens the column menu, allowing users to change styling, render mode, view quick stats and other column related options.", children: [ { type: "tab", title: "Sort", description: "Sort the table data based on the values in this column, either in ascending or descending order. Table can also be sorted by multiple columns by holding shift while clicking column headers.", selector: getDataKeyElemSelector("Sort"), children: [], }, { type: "tab", title: "Style", description: "Customize the appearance of this column, including text color and cell background. You can also set conditional formatting rules to highlight specific data patterns.", selector: getDataKeyElemSelector("Style"), children: [], }, { type: "tab", title: "Display format", description: "Choose how the data in this column is displayed, such as date formats, number formats, or custom render modes.", selector: getDataKeyElemSelector("Display format"), children: [], }, { type: "button", title: "Filter", description: "Open the filter panel to set up filters based on this column's values, helping you to quickly narrow down the data displayed in the table.", selector: getDataKeyElemSelector("Filter"), }, { type: "tab", title: "Quick stats", description: "View quick statistics about the data in this column, such as count, unique values, and distribution.", selector: getDataKeyElemSelector("Quick stats"), children: [ { type: "section", title: "Column Quick Stats", description: "The Column Quick Stats panel provides a summary of key statistics for the selected column, including distinct count, min/max values, and value distribution. You can also sort the distribution and add filters directly from this panel.", selectorCommand: "ColumnQuickStats", children: [ { type: "button", title: "Add filter", description: "Add a filter based on the selected value from top values list.", selectorCommand: "ColumnQuickStats.addFilter", }, ], }, ], }, { type: "tab", title: "Columns", description: "Shortcut to the column management panel to add, remove, or rearrange columns in the table.", selector: getDataKeyElemSelector("Columns"), children: [], }, { type: "tab", title: "Add Computed Column", description: "Add a computed field based on calculations or transformations of existing data in this column.", selector: getDataKeyElemSelector("Add Computed Column"), children: [], }, { type: "tab", title: "Apply function", description: "Apply a function to the data in this column, such as aggregations, string manipulations, or date transformations.", selector: getDataKeyElemSelector("Apply function"), children: [], }, { type: "tab", title: "Add Linked Columns", description: "Add linked data from related tables based on foreign key relationships.", selector: getDataKeyElemSelector("Add Linked Columns"), children: [], }, { type: "tab", title: "Alter", description: "Alter the column's properties, such as data type, default value, or constraints.", selector: getDataKeyElemSelector("Alter"), children: [], }, { type: "button", title: "Hide", description: "Hide this column from the table view without deleting it, allowing you to focus on the most relevant data.", selector: getDataKeyElemSelector("Hide"), }, { type: "button", title: "Hide Others", description: "Hide all other columns except this one, providing a focused view of the data in this column.", selector: getDataKeyElemSelector("Hide Others"), }, ], } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/paginationUIDoc.ts ================================================ import type { UIDocElement } from "../../../../UIDocs"; export const paginationUIDoc: UIDocElement = { selectorCommand: "Pagination", type: "section", title: "Pagination Controls", description: "Navigation controls for paginated data.", children: [ { selectorCommand: "Pagination.firstPage", type: "button", title: "First Page", description: "Navigate to the first page of results.", }, { selectorCommand: "Pagination.prevPage", type: "button", title: "Previous Page", description: "Navigate to the previous page of results.", }, { selectorCommand: "Pagination.page", type: "input", inputType: "number", title: "Page Number", description: "Current page number. You can type a specific page number to jump directly to that page.", }, { selectorCommand: "Pagination.nextPage", type: "button", title: "Next Page", description: "Navigate to the next page of results.", }, { selectorCommand: "Pagination.lastPage", type: "button", title: "Last Page", description: "Navigate to the last page of results.", }, { selectorCommand: "Pagination.pageSize", type: "select", title: "Page Size", description: "Select how many rows to display per page. Changing this may adjust the current page if it would exceed the total number of pages.", }, { selectorCommand: "Pagination.pageCountInfo", type: "text", title: "Page Count Information", description: "Displays the total number of pages and rows in the current dataset.", }, ], }; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/smartFilterBarUIDoc.ts ================================================ import { fixIndent } from "../../../../../demo/scripts/sqlVideoDemo"; import { getDataKeyElemSelector } from "../../../../../Testing"; import type { UIDocElement } from "../../../../UIDocs"; export const smartFilterBarUIDoc = { type: "section", selectorCommand: "SmartFilterBar", title: "Table Toolbar", description: "Filtering and data add/edit interface for database tables and views.", docs: fixIndent(` Table toolbar can be toggled through the show/hide filtering button (top left corner). It provides a user-friendly interface to add filters, search for data, and perform various actions on the table data. Smart Filter Bar screenshot `), children: [ { type: "popup", selectorCommand: "SmartAddFilter", title: "Add Filter", description: "Allows adding a filter by chosing a column from the current table or from linked tables.", children: [ { type: "button", selectorCommand: "SmartAddFilter.toggleIncludeLinkedColumns", title: "Include Linked Columns", description: "Toggle to include columns from linked tables in the column list.", }, { type: "button", selectorCommand: "SmartAddFilter.JoinTo", title: "Join To", description: "Toggles join view to specify which tables a join path to a specific table from which to select a column to filter by.", }, { type: "list", selector: ".SearchList", title: "Filterable Columns", description: "List of columns available for filtering. Click to add a filter.", itemSelector: "li", itemContent: [], }, ], }, { type: "section", selectorCommand: "SearchList", title: "Search Bar", description: "Quick search functionality across the table data.", children: [ { type: "input", inputType: "text", selector: ".SmartSearch input", title: "Search", description: "Type to search across all searchable fields in the table. The search is a simple contains search. Selecting a result will add a filter to the table.", }, { type: "button", selectorCommand: "SearchList.MatchCase", title: "Match Case", description: "Toggle to match the case of the search term with the data.", }, ], }, { type: "section", selector: ".SmartFilterBarRightActions", title: "Table actions", description: "Additional table data actions.", children: [ { type: "button", selectorCommand: "SmartFilterBar.rightOptions.show", title: "Show additional actions", description: "Opens a menu with additional actions for the table data.", }, { selectorCommand: "SmartFilterBar.rightOptions.delete", type: "popup", title: "Delete action", description: "Opens a menu to delete the selected rows from the table.", children: [], }, { selectorCommand: "SmartFilterBar.rightOptions.update", type: "popup", title: "Update action", description: "Opens a menu to update the selected rows in the table.", children: [], }, { selectorCommand: "dashboard.window.rowInsertTop", type: "popup", title: "Insert row", description: "Opens the row insert menu, allowing users to add new rows to the table.", children: [], }, ], }, { type: "section", title: "Filters", description: "Filters applied to the data", selector: getDataKeyElemSelector("where"), children: [], }, { type: "section", title: "Having Filters", description: "Filters applied after aggregation (HAVING clause in SQL).", selector: getDataKeyElemSelector("having"), children: [], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/smartFormUIDoc.ts ================================================ import type { UIDocElement } from "src/app/UIDocs"; import { getCommandElemSelector } from "src/Testing"; export const smartFormUIDoc: UIDocElement = { type: "popup", selectorCommand: "dashboard.window.viewEditRow", title: "View/edit data", description: "Opens the row card, allowing users to view/edit/delete the selected row.", docOptions: { title: "Row card" }, docs: ` Smart form is an intelligent, auto-generated form system that adapts to your database schema. It provides a user-friendly interface for inserting and updating data with automatic validation, foreign key handling, and support for complex data types. SmartForm screenshot ## Features - **Auto-generated fields** based on table schema - **Data type validation** (text, numbers, dates, JSON, etc.) - **Foreign key support** with searchable dropdowns - **File upload** for media and document columns - **Linked data management** - insert related records inline - **JSON/JSONB editor** with syntax highlighting - **Geometry/Geography** support for spatial data - **Array types** with dynamic add/remove - **Default values** and constraints enforcement - **Required field** indicators - **Custom field rendering** based on column configuration ## Field Types - Text inputs (single/multi-line) - Number inputs (integer, decimal) - Date/Time/Timestamp pickers - Boolean checkboxes - Select dropdowns (enums, foreign keys) - File upload fields - JSON/JSONB editors - Geometry/Geography mappers - Array editors `, children: [ { type: "section", selector: ".W_SmartForm", title: "Smart Form", description: "The smart form displays the details of a single row from the table, allowing users to view and edit the data in a structured format.", children: [ { selectorCommand: "SmartForm.header.tableIconAndName", type: "section", title: "Table icon in header", children: [], description: "Table icon and table name. Table icon is configurable through the table menu settings.", }, { selectorCommand: "SmartForm.header.previousRow", type: "button", title: "Previous row button", description: "Navigate to the previous row in the dataset. Only shown for tables with primary key. Disabled when there is no previous row available.", }, { selectorCommand: "SmartForm.header.nextRow", type: "button", title: "Next row button", description: "Navigate to the next row in the dataset. Only shown for tables with primary key. Disabled when there is no next row available.", }, { selectorCommand: "Popup.toggleFullscreen", type: "button", title: "Fullscreen toggle button", description: "Toggles the fullscreen mode of the SmartForm popup for an expanded view.", }, { selectorCommand: "Popup.close", type: "button", title: "Close button", description: "Closes the SmartForm popup without saving any data.", }, { type: "section", selector: ".form-field", title: "Form field", description: "Each form field represents a column from the table, displaying the column name and the corresponding value for the selected row. Users can edit the value if the field is editable.", children: [ { type: "section", selector: "label", children: [], title: "Field label", description: "Displays the name of the column.", }, { type: "section", selector: ".form-field__right-content-wrapper", title: "Field input area", description: "The input area where users can view and edit the value of the column. The input type varies based on the column's data type.", children: [], }, { type: "section", selector: ".FormFieldHint", title: "Field hint", description: "Additional information or guidance about the field, displayed below the input area.", children: [], }, { selectorCommand: "ViewMoreSmartCardList", type: "popup", title: "View more linked records", description: "For foreign key fields a 'View more' button appears to open a detailed list of all linked records in a separate popup. This is useful to browse and search all columns from the referenced table.", children: [], }, { type: "popup", selectorCommand: "SmartFormFieldOptions.NestedInsert", title: "Insert linked record", description: "If the column is a foreign key a 'Insert new record' button will appear on hover which allows inserting data into the referenced table. Useful when the desired value does not exist yet (foreign key columns only show existing values).", children: [], }, { selectorCommand: "FormField.clear", type: "button", title: "Clear field value button", description: "Clear the current value of the field, resetting it to null. Only shown if the field is nullable and the user is allowed to update it.", }, ], }, { selectorCommand: "JoinedRecords", type: "list", title: "Joined records section", description: "If the current table has other tables referencing it via foreign keys, a section will appear at the bottom of the form showing lists of those related records. This allows users to view and manage data that is linked to the current row.", itemSelector: getCommandElemSelector("JoinedRecords.Section"), itemContent: [ { selectorCommand: "JoinedRecords.SectionToggle", type: "button", title: "Toggle joined records section", description: "Expand or collapse the joined records section to show or hide the list of related records.", }, { selectorCommand: "ViewMoreSmartCardList", type: "popup", title: "View more joined records", description: "Open a detailed list of all joined records in a separate popup. Useful to browse and search all columns from the joined table.", children: [], }, { selectorCommand: "JoinedRecords.AddRow", type: "popup", title: "Add joined record", description: "Open the SmartForm to insert a new joined record into the related table, automatically linking it to the current row.", children: [], }, { selectorCommand: "Section.toggleFullscreen", type: "button", title: "Toggle fullscreen mode", description: "Expand the section to fullscreen for better visibility and interaction with the joined records.", }, ], }, ], }, ], }; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/tableMenuUIDoc.ts ================================================ import { fixIndent } from "../../../../../demo/scripts/sqlVideoDemo"; import { getCommandElemSelector, getDataKeyElemSelector, } from "../../../../../Testing"; import type { UIDocElement } from "../../../../UIDocs"; export const tableMenuUIDoc = { selectorCommand: "dashboard.window.menu", type: "popup", title: "Table menu", description: "Opens the table menu, allowing users to manage the table view.", docs: fixIndent(` The table menu provides options for managing the table view, including viewing table info, editing columns, and managing data refresh rates. `), children: [ { type: "tab", selector: getDataKeyElemSelector("Table info"), title: "Table info", description: "Postgres specific table/view details.", children: [ { type: "section", selectorCommand: "W_TableMenu_TableInfo.name", title: "Table name", description: "Displays the name of the table with option to rename it.", children: [ { type: "button", selector: `${getCommandElemSelector("W_TableMenu_TableInfo.name")} button`, title: "Edit name", description: "Opens SQL editor to rename the table.", }, ], }, { type: "section", selectorCommand: "W_TableMenu_TableInfo.comment", title: "Comment", description: "Displays and allows editing of the table comment.", children: [ { type: "button", selector: `${getCommandElemSelector("W_TableMenu_TableInfo.comment")} button`, title: "Edit comment", description: "Opens SQL editor to modify the table comment.", }, ], }, { type: "text", selectorCommand: "W_TableMenu_TableInfo.oid", title: "OID", description: "Displays the object identifier of the table in the database.", }, { type: "text", selectorCommand: "W_TableMenu_TableInfo.type", title: "Type", description: "Shows whether this is a table or view.", }, { type: "text", selectorCommand: "W_TableMenu_TableInfo.owner", title: "Owner", description: "Displays the database user who owns this table/view.", }, { type: "section", selectorCommand: "W_TableMenu_TableInfo.sizeInfo", title: "Size information", description: "Provides details about the table's size and row count.", children: [ { type: "text", selector: `${getCommandElemSelector("W_TableMenu_TableInfo.sizeInfo")} [label="Actual Size"]`, title: "Actual Size", description: "The physical size of the table data on disk.", }, { type: "text", selector: `${getCommandElemSelector("W_TableMenu_TableInfo.sizeInfo")} [label="Index Size"]`, title: "Index Size", description: "The size of all indexes associated with this table.", }, { type: "text", selector: `${getCommandElemSelector("W_TableMenu_TableInfo.sizeInfo")} [label="Total Size"]`, title: "Total Size", description: "The combined size of table data and indexes.", }, { type: "text", selector: `${getCommandElemSelector("W_TableMenu_TableInfo.sizeInfo")} [label="Row count"]`, title: "Row count", description: "The total number of rows in the table.", }, ], }, { type: "text", selectorCommand: "W_TableMenu_TableInfo.viewDefinition", title: "View definition", description: "Shows the SQL definition for views. Only visible for views, not tables.", }, { type: "button", selectorCommand: "W_TableMenu_TableInfo.vacuum", title: "Vacuum", description: "Performs garbage collection and optionally analyzes the database. Only available for tables.", }, { type: "button", selectorCommand: "W_TableMenu_TableInfo.vacuumFull", title: "Vacuum Full", description: "Performs a more thorough vacuum that can reclaim more space but takes longer and locks the table. Only available for tables.", }, { type: "button", selectorCommand: "W_TableMenu_TableInfo.drop", title: "Drop", description: "Deletes the table or view from the database after confirmation.", }, ], }, { type: "tab", selector: getDataKeyElemSelector("Columns"), title: "Columns", description: "Allows editing, reordering and toggling table columns.", children: [ { type: "list", selector: getCommandElemSelector("SearchList.List"), title: "Columns list", description: "Allows editing, reordering and toggling table columns.", itemSelector: "li", itemContent: [ { type: "popup", selectorCommand: "W_TableMenu_ColumnList.alter", title: "Alter column", description: "Opens a popup to edit the column properties.", children: [], }, { type: "popup", selectorCommand: "W_TableMenu_ColumnList.linkedColumnOptions", title: "Edit linked field", description: "Opens a popup to edit the linked field properties.", children: [], }, { type: "button", selectorCommand: "W_TableMenu_ColumnList.removeComputedColumn", title: "Remove computed column", description: "Removes a computed column from the table. Only visible for computed columns.", }, ], }, ], }, { type: "tab", selector: getDataKeyElemSelector("Data refresh"), title: "Data refresh", description: "Allows setting subscriptions or data refresh rates. By default every table subscribes to data changes.", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Triggers"), title: "Triggers", description: "Allows managing triggers. ", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Constraints"), title: "Constraints", description: "Allows managing constraints.", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Indexes"), title: "Indexes", description: "Allows managing indexes.", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Policies"), title: "Policies", description: "Allows managing policies.", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Access rules"), title: "Access rules", description: "Allows managing prostgles access rules.", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Current query"), title: "Current query", description: "Allows viewing the SQL and data/layout info for the current table view.", children: [], }, { type: "tab", selector: getDataKeyElemSelector("Display options"), title: "Display options", description: "Layout preferences.", children: [], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/table/tableUIDoc.ts ================================================ import { fixIndent } from "src/demo/scripts/sqlVideoDemo"; import type { UIDocElement } from "../../../../UIDocs"; import { getCommonViewHeaderUIDoc } from "../../getCommonViewHeaderUIDoc"; import { paginationUIDoc } from "./paginationUIDoc"; import { smartFilterBarUIDoc } from "./smartFilterBarUIDoc"; import { tableMenuUIDoc } from "./tableMenuUIDoc"; import { addColumnMenuUIDoc } from "./addColumnMenuUIDoc"; import { columnMenuUIDoc } from "./columnMenuUIDoc"; import { smartFormUIDoc } from "./smartFormUIDoc"; export const tableUIDoc = { type: "section", selector: `.SilverGridChild[data-view-type="table"]`, title: "Table view", description: "Allows interacting with a table/view from the database.", docs: fixIndent(` The table view allows you to explore, filter, and edit your Postgres data with ease. Instantly sort and search, build computed columns, pull in linked data through automatic joins, and create charts, maps, and cross-filtered views in a couple of clicks. With smart forms for row editing, rich column controls, and deep schema-aware features, the table view turns your database into an interactive workspace for analysis, tooling, and rapid iteration. ## Features - **Smart filtering**: Use the smart filter bar to quickly filter your data based on column types and values. - **Computed columns**: Add calculations or transformations of existing data. - **Automatic joins**: Show related data from linked tables with automatic joins and summarise if needed. - **Charts and maps**: Timechart and map visualisations with multi-layer support. - **Cross-filtered views**: Create additional table or chart views that are cross-filtered by the current table. - **Smart forms**: Edit rows using smart forms that adapt to your schema and data types. - **Conditional styling**: Style your columns based on row data. Table view screenshot ## Components `), docOptions: "asSeparateFile", children: [ getCommonViewHeaderUIDoc( "The name of the table/view together with the number of records matching the current filters. ", tableMenuUIDoc, "table", ), smartFilterBarUIDoc, { type: "section", selector: ".W_Table", title: "Table", description: "The main table view displaying the data from the database. It allows users to interact with the data, including sorting, filtering, and editing.", children: [ { type: "section", selectorCommand: "TableHeader", title: "Table header", description: "The header of the table, which contains the column names and allows users to sort the data by clicking on the column headers.", children: [ addColumnMenuUIDoc, columnMenuUIDoc, { type: "button", selector: `[role="columnheader"]`, title: "Column header", description: "Pressing the header will toggle sorting state (if the column is sortable). Right clicking (or long press on mobile) will open column menu. Dragging the header will allow reordering the columns.", }, { type: "drag-handle", direction: "x", selectorCommand: "TableHeader.resizeHandle", title: "Resize column", description: "Allows users to resize the column width by dragging the handle.", }, smartFormUIDoc, { selectorCommand: "dashboard.window.rowInsert", type: "popup", title: "Insert row", description: "Opens the row insert menu, allowing users to add new rows to the table.", children: [], }, ], }, ], }, paginationUIDoc, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboard/timechartUIDoc.ts ================================================ import { fixIndent } from "../../../../demo/scripts/sqlVideoDemo"; import { getCommandElemSelector } from "../../../../Testing"; import type { UIDocElement } from "../../../UIDocs"; import { getCommonViewHeaderUIDoc } from "../getCommonViewHeaderUIDoc"; export const timechartUIDoc = { type: "section", selector: `.SilverGridChild[data-view-type="timechart"]`, title: "Timechart view", description: "Displays a timechart based on the Table/SQL query results.", docs: ` The timechart view allows you to visualize time-series data from your database. It supports multiple layers, each with its own data source and style. You can add filters to the timechart to narrow down the data displayed. Timechart view screenshot ## Components `, docOptions: "asSeparateFile", children: [ getCommonViewHeaderUIDoc( "Shows the table/view name together with the number of records matching the current filters.", { description: "Timechart view menu", children: [], title: "Timechart view menu", docs: fixIndent(` The timechart view menu provides options for configuring the timechart. `), }, "chart", ), { type: "section", selector: ".Window", title: "Chart area with controls", description: "Timechart visualization.", children: [ { type: "popup", selectorCommand: "ChartLayerManager", title: "Layer manager", description: "Allows adding/removing layers from the chart. Each layer can be configured with its own data source and style.", children: [ { type: "list", selector: ".ChartLayerManager_LayerList", title: "Layer list", itemSelector: ".ChartLayerManager_LayerList > .LayerQuery", itemContent: [ { type: "popup", title: "Layer color picker", selectorCommand: "LayerColorPicker", description: "Allows setting the color for the layer. The color can be set for each column in the layer.", children: [], }, { selector: `[title="Table name"]`, type: "text", title: "Table name", description: "The name of the table used for the layer. This is the table that contains the data for the layer.", }, { type: "popup", selectorCommand: "TimeChartLayerOptions.aggFunc", title: "Aggregation options", description: "Allows setting the y-axis options for the layer.", children: [ { type: "select", selectorCommand: "TimeChartLayerOptions.aggFunc.select", title: "Aggregation function", description: "Selects the aggregation function to be used for the layer. The available options are: Sum, Average, Min, Max, Count.", }, { type: "select", selectorCommand: "TimeChartLayerOptions.numericColumn", title: "Aggregation column", description: "Selects the numeric column to be used for the aggregation function. ", }, { type: "select", selectorCommand: "TimeChartLayerOptions.groupBy", title: "Group by", description: "Selects the column to group the data by. This is will create a line for each group by value.", }, { selectorCommand: "Popup.close", type: "button", title: "Close", description: "Closes the aggregation function popup.", }, ], }, { selectorCommand: "ChartLayerManager.toggleLayer", type: "button", title: "Toggle layer on/off", description: "Toggles the visibility of the layer on the chart. This allows you to hide or show the layer without removing it.", }, { selectorCommand: "ChartLayerManager.removeLayer", type: "button", title: "Remove layer", description: "Removes the layer from the chart. This will delete the layer and its configuration.", }, ], description: "Displays the list of layers currently added to the chart. ", }, { type: "select", selectorCommand: "ChartLayerManager.AddChartLayer.addLayer", title: "Add layer", description: "Allows adding a new layer to the chart. The available options are all the tables that have date or timestamp columns.", }, ], }, { type: "button", selectorCommand: "W_TimeChart.resetExtent", title: "Reset extent", description: "Resets the chart to the default extent, showing all data points. Visible when the chart was paned or zoomed.", }, { type: "list", selector: ".W_TimeChartLayerLegend", title: "Layer legend", itemSelector: ".W_TimeChartLayerLegend_Item", itemContent: [], description: "Displays the layers currently added to the chart. Quick access to changing the layer color, aggregation type and group by.", }, { type: "button", selectorCommand: "W_TimeChart.AddTimeChartFilter", title: "Add/Edit time filter", description: "Allows adding a time filter to the timechart. This will filter the data points based on the selected time range.", }, { type: "canvas", selector: getCommandElemSelector("W_TimeChart") + " > .Canvas", title: "Timechart canvas", description: "Zoomable and pannable canvas that displays the timechart. It shows the data points based on the selected layers and filters. Clicking on a point will add a filter with that time bucket.", }, ], }, ], } satisfies UIDocElement; ================================================ FILE: client/src/app/UIDocs/connection/dashboardUIDoc.ts ================================================ import { mdiMonitorDashboard } from "@mdi/js"; import { ROUTES } from "@common/utils"; import { getCommandElemSelector } from "../../../Testing"; import type { UIDocContainers } from "../../UIDocs"; import { AIAssistantUIDoc } from "./AIAssistantUIDoc"; import { dashboardContentUIDoc } from "./dashboard/dashboardContentUIDoc"; import { dashboardMenuUIDoc } from "./dashboard/dashboardMenuUIDoc"; export const dashboardUIDoc = { type: "page", path: ROUTES.CONNECTIONS, pathItem: { tableName: "connections", selectorCommand: "Connection.openConnection", }, title: "Connection dashboard", description: "Database exploration and management interface", iconPath: mdiMonitorDashboard, docs: ` The connection dashboard is your command center for exploring and managing your Postgres database. Open tables, run SQL, visualize schema relationships, switch workspaces, and launch tools—all in one flexible, customizable workspace. With quick search, saved queries, AI-powered assistance, and instant access to every database object, the dashboard gives you a fast, intuitive way to navigate your data and build the tools you need. ## Features - **Unified workspace**: View tables, SQL editors, charts, and tools together in a flexible layout. Save and switch between different layouts and sets of opened views for different tasks or projects. - **AI Assistant**: Generate SQL, explore data, and get help directly within the dashboard. - **Flexible layout**: Drag, resize, and arrange views in a tiled layout to create a workspace that fits your needs. - **Global search**: Search across all tables, views, and functions in a single, fast search bar. - **Schema diagram**: Visualize relationships between tables and schemas to better understand your database structure. - **Import data**: Easily import data from CSV/JSON files into your database tables. Connection dashboard ## Components `, childrenTitle: "Dashboard elements", children: [ dashboardMenuUIDoc, { type: "button", title: "Dashboard menu toggle", description: "Opens or closes the dashboard menu unless the menu is pinned.", selectorCommand: "dashboard.menu", }, { type: "link", title: "Go to configuration", description: "Opens the configuration page for the selected connection.", selectorCommand: "dashboard.goToConnConfig", path: ROUTES.CONFIG, pathItem: { tableName: "connections", }, }, { type: "input", inputType: "select", title: "Change connection", description: "Switch to a different database connection.", selectorCommand: "ConnectionSelector", }, { type: "select", title: "Workspaces", description: "List of available workspaces for the selected connection. Each workspace stores opened views and their layout.", selectorCommand: "WorkspaceMenu.list", }, { type: "popup", selectorCommand: "WorkspaceMenuDropDown", title: "Workspaces menu", description: "Opens the workspaces menu, allowing you to create, manage, and switch between workspaces.", docs: ` Workspaces are a powerful feature that allows you to organize your work within a connection. The opened views and their layout is saved to the workspace, so you can switch between different sets of data and configurations without losing your progress. The workspaces menu provides access to all available workspaces for the selected connection. You can create new workspaces, switch between existing ones, and manage workspace settings. Each workspace allows you to work with a separate set of data and configurations, making it easier to organize your work and collaborate with others. The menu also includes options to clone existing workspaces and delete them if they are no longer needed. `, children: [ { type: "list", title: "Workspaces", description: "List of available workspaces. Click to switch to a different workspace.", selector: getCommandElemSelector("WorkspaceMenu.SearchList") + " ul", itemSelector: "li", itemContent: [ { type: "popup", selectorCommand: "WorkspaceDeleteBtn", title: "Delete workspace", description: "Opens the delete workspace confirmation dialog", children: [ { type: "button", title: "Delete workspace", description: "Confirms the deletion of the selected workspace.", selectorCommand: "WorkspaceDeleteBtn.Confirm", }, ], }, { type: "button", selectorCommand: "WorkspaceMenu.CloneWorkspace", title: "Clone workspace", description: "Creates a copy of the selected workspace with a new name.", }, { selectorCommand: "WorkspaceSettings", type: "smartform-popup", tableName: "workspaces", title: "Workspace settings", description: "Opens the settings for the selected workspace, allowing you to manage its properties.", }, ], }, { type: "popup", title: "Create new workspace", description: "Opens the form to create a new workspace for the selected connection.", selectorCommand: "WorkspaceMenuDropDown.WorkspaceAddBtn", children: [ { type: "input", title: "Workspace name", description: "Name of the new workspace.", selector: getCommandElemSelector("WorkspaceAddBtn") + " input", inputType: "text", }, { type: "button", title: "Create workspace", description: "Create and switch to the new workspace with the specified name.", selectorCommand: "WorkspaceAddBtn.Create", }, ], }, { type: "button", selectorCommand: "WorkspaceMenu.toggleWorkspaceLayoutMode", title: "Toggle layout mode", description: "Switches between fixed and editable layout modes for the current workspace. Fixed mode locks the layout, preventing it from being changed by the user.", }, ], }, dashboardContentUIDoc, AIAssistantUIDoc, { type: "popup", selectorCommand: "Feedback", title: "Feedback", description: "Opens the feedback form, allowing you to provide feedback about the application.", children: [], }, { type: "link", selectorCommand: "dashboard.goToConnections", title: "Go to Connections", description: "Opens the connections list page.", path: ROUTES.CONNECTIONS, }, ], } satisfies UIDocContainers; ================================================ FILE: client/src/app/UIDocs/connection/getCommonViewHeaderUIDoc.ts ================================================ import { isDefined } from "../../../utils/utils"; import type { UIDocElement } from "../../UIDocs"; export const getCommonViewHeaderUIDoc = ( titleContentDescription: string, menu: { description: string; children: UIDocElement[]; docs: string; title: string; }, viewType: "table" | "sql" | "chart" | "method", ): UIDocElement => ({ type: "section", selector: ".silver-grid-item-header", title: "Header section", description: "Contains menu button, title and window minimise/fullscreen controls.", children: ( [ viewType === "chart" ? undefined : ( { type: "section", selectorCommand: "Window.W_QuickMenu", title: "Quick actions", description: "Quick actions for the view, providing easy access to charting and joins.", children: ( [ viewType === "sql" ? undefined : ( { selectorCommand: "dashboard.window.toggleFilterBar", type: "button", title: "Toggle filter bar", description: "Shows or hides the filter bar, allowing users to filter the data displayed in the table.", } ), viewType === "sql" ? undefined : ( { selectorCommand: "Window.W_QuickMenu.addCrossFilteredTable", type: "button", title: "Add cross-filtered table", description: "Adds a new table view that is cross-filtered by the current table. This allows you to explore related data in a new table view.", } ), { type: "button", selectorCommand: "AddChartMenu.Timechart", title: "Add timechart", description: viewType === "sql" ? "Adds a timechart visualization based on the current SQL statement. Visible only when the last executed statement returned at least one timestamp column." : "Adds a timechart visualization based on the current table. Visible only when the current table (or any linked table) has a timestamp/date column.", }, { type: "button", selectorCommand: "AddChartMenu.Map", title: "Add map", description: viewType === "sql" ? "Adds a map visualization based on the current SQL statement. Visible only when the last executed statement returned at least one geometry/geography column (postgis extension must be enabled)." : "Adds a map visualization based on the current data. Visible only when the current table (or any linked table) has a geometry/geography column (postgis extension must be enabled).", }, ] satisfies (UIDocElement | undefined)[] ).filter(isDefined), } ), { type: "drag-handle", direction: "x", selector: ".silver-grid-item-header--title", title: "View title. Drag to re-arrange layout", description: titleContentDescription, }, { selectorCommand: "dashboard.window.collapse", type: "button", title: "Collapse the view", description: "Collapses the view, minimizing it to temporarily save space on the dashboard. ", }, { selectorCommand: "dashboard.window.fullscreen", type: "button", title: "Fullscreen", description: "Expands the view to fill the entire screen.", }, { selectorCommand: "dashboard.window.close", type: "button", title: "Remove view", description: viewType === "sql" ? "Removes the SQL editor from the dashboard. If there are unsaved changes, a confirmation dialog will appear." : "Removes the view from the dashboard.", }, viewType !== "chart" ? undefined : ( { type: "section", selectorCommand: "Window.ChildChart.toolbar", title: "Chart toolbar", description: "Toolbar for the chart view if not detached. By default, newly charts added will appear over the originating table/sql editor view. They can be detached to a separate window.", children: [ { type: "popup", selectorCommand: "dashboard.window.chartMenu", title: "Chart menu", description: "Menu for the chart view.", children: [], }, { selectorCommand: "dashboard.window.collapseChart", title: "Collapse chart", type: "button", description: "Collapses the chart window, minimizing it to save space on the dashboard. It can then be restored by clicking the chart icon in the SQL editor top left quick actions section.", }, { selectorCommand: "dashboard.window.detachChart", title: "Detach chart", type: "button", description: "Detaches the chart from the parent view, allowing it to be moved and resized independently. It keeps the connection the originating table view to cross filter it.", }, { selectorCommand: "dashboard.window.closeChart", title: "Close chart", type: "button", description: "Closes the chart view, returning to the originatine table/sql editor view.", }, ], } ), { selectorCommand: "dashboard.window.menu", type: "popup", ...menu, }, ] satisfies (UIDocElement | undefined)[] ).filter(isDefined), }); ================================================ FILE: client/src/app/UIDocs/connectionsUIDoc.ts ================================================ import { ROUTES } from "@common/utils"; import { mdiDatabasePlusOutline, mdiFilter, mdiServerNetwork } from "@mdi/js"; import { getCommandElemSelector, getDataKeyElemSelector } from "../../Testing"; import type { UIDocContainers, UIDocElement } from "../UIDocs"; import { editConnectionUIDoc } from "./editConnectionUIDoc"; const newOwnerOrUserOptions = [ { type: "input", inputType: "checkbox", title: "Create database owner", description: "If checked, a new owner will be created for the database. Useful for ensuring the database is owned by a non-superuser account.", selectorCommand: "ConnectionServer.withNewOwnerToggle", }, { title: "New Owner Name", description: "The name of the new owner.", selectorCommand: "ConnectionServer.NewUserName", type: "input", inputType: "text", }, { selectorCommand: "ConnectionServer.NewUserPassword", title: "New Owner Password", description: "The password of the new owner.", inputType: "text", type: "input", }, { type: "section", selectorCommand: "ConnectionServer.NewUserPermissionType", title: "New Owner Permission Type", description: "Apart from Owner it is possible to create a user with reduced permission types (SELECT/UPDATE/DELETE/INSERT).", children: [], }, ] satisfies UIDocElement[]; export const connectionsUIDoc = { type: "page", path: ROUTES.CONNECTIONS, title: "Connections", iconPath: mdiServerNetwork, description: "Manage your database connections. View, add, or edit connections to your databases.", docs: ` The Connections page serves as the central hub within Prostgles UI for managing all your PostgreSQL database connections. From here, you can add and open connections, modify existing ones, and gain an immediate overview of their status and associated workspaces. Connections page screenshot `, childrenTitle: "Connection controls", children: [ { type: "link", title: "New connection", iconPath: mdiDatabasePlusOutline, description: "Opens the form to add a new database connection.", selectorCommand: "Connections.new", path: ROUTES.NEW_CONNECTION, docs: ` Use the **New Connection** button to add a new database connection. This will open a form where you can enter the connection details such as host, port, database name, user, and password. New connection form screenshot `, childrenTitle: "New connection form fields", docOptions: { title: "Adding a connection" }, pageContent: editConnectionUIDoc.children, }, { type: "popup", title: "Display options", description: "Customize how the list of connections is displayed (e.g., show/hide state database, show database names).", selectorCommand: "ConnectionsOptions", iconPath: mdiFilter, docOptions: "hideChildren", children: [ { type: "input", title: "Show state connection", description: "If checked, displays the internal 'Prostgles UI state' connection which stores application metadata and dashboard data.", selectorCommand: "ConnectionsOptions.showStateDatabase", inputType: "checkbox", }, { type: "input", title: "Show database names", description: "If checked, displays the specific database name along with the connection name.", selectorCommand: "ConnectionsOptions.showDatabaseNames", inputType: "checkbox", }, ], }, { type: "list", title: "Connection list", description: "Controls to open and manage your database connections.", docs: ` The connection list displays all your database connections grouped by database host, port and user. Connections list screenshot `, selector: getCommandElemSelector("Connections") + " .Connections_list", itemSelector: ".Connection", childrenTitle: "Connection actions", itemContent: [ { type: "link", selectorCommand: "Connection.openConnection", path: ROUTES.CONNECTIONS, title: "Open connection", description: "Opens the selected connection dashboard on the default workspace.", pathItem: { tableName: "connections", }, }, { type: "popup", selectorCommand: "ConnectionServer.add", title: "Add new database", description: "Adds a new connection to the selected server. ", children: [ { type: "popup", selectorCommand: "ConnectionServer.add.newDatabase", title: "Create new database", description: "Create a new database within the server.", docs: ` Allows you to create a new database in the selected server. It will use the first connection details from the group connection. If no adequate account is found (no superuser or rolcreatedb), it will be greyed out with with an appropriate explanation tooltip text. `, childrenTitle: "New database options", children: [ { type: "input", title: "Database Name", description: "The name of the new database.", inputType: "text", selectorCommand: "ConnectionServer.NewDbName", }, { type: "select", title: "Sample Schemas", description: "Select a sample schema to create the database with.", selectorCommand: "ConnectionServer.SampleSchemas", }, ...newOwnerOrUserOptions, { selectorCommand: "ConnectionServer.add.confirm", type: "button", title: "Create and connect", description: "Creates and connects to the new database.", }, ], }, { type: "popup", selector: getDataKeyElemSelector("Select existing database"), title: "Connect to an existing database", description: "Selects a database from the server to connect to. ", docs: ` Allows you to connect to an existing database in the selected server. It will use the first connection details from the group connection. Connect existing database popup screenshot After selecting the database, you can choose to create a new owner or user for the connection should you need to. `, children: [ { type: "select", title: "Select Database", description: "Choose a database from the server.", selectorCommand: "ConnectionServer.add.existingDatabase", }, ...newOwnerOrUserOptions, { selectorCommand: "ConnectionServer.add.confirm", type: "button", title: "Save and connect", description: "Connects to the selected database with the new owner.", }, ], }, ], }, { type: "button", title: "Debug: Close All Windows", selectorCommand: "Connection.closeAllWindows", description: "Force-closes all windows/tabs for this connection. Use if the workspace becomes unresponsive or encounters a bug.", }, { type: "popup", title: "Status monitor", selectorCommand: "Connection.statusMonitor", description: "View real-time statistics, running queries, and system resource usage (CPU, RAM, Disk) for this connection.", children: [], }, { type: "link", title: "Connection configuration", selectorCommand: "Connection.configure", description: "Access and modify settings for this connection, such as access control, file storage, backup/restore options, and server-side functions.", path: ROUTES.CONFIG, pathItem: { tableName: "connections", }, // TODO: we need to move shared pages to the end // docs: connectionConfigUIDoc.docs, // pageContent: connectionConfigUIDoc.children, }, { type: "link", title: "Edit connection details", selectorCommand: "Connection.edit", description: "Modify the connection parameters (e.g., display name, database details like host and port). Also allows deleting or cloning the connection.", docs: editConnectionUIDoc.docs, path: ROUTES.EDIT_CONNECTION, pathItem: { tableName: "connections", }, pageContent: editConnectionUIDoc.children, }, { type: "button", title: "Status indicator/Disconnect", selectorCommand: "Connection.disconnect", description: "Shows the current connection status (green indicates connected). Click to disconnect from the database.", }, { type: "list", selectorCommand: "Connection.workspaceList", title: "Workspaces", description: "List of workspaces associated with this connection. Click to switch to a specific workspace.", itemSelector: " [data-key]", itemContent: [], }, ], }, ], } satisfies UIDocContainers; ================================================ FILE: client/src/app/UIDocs/desktopInstallation.ts ================================================ import { fixIndent } from "../../demo/scripts/sqlVideoDemo"; import type { UIDoc } from "../UIDocs"; export const desktopInstallationUIDoc = { type: "info", title: "Installation (Desktop Version)", description: "Instructions for installing Prostgles UI on your desktop.", docs: fixIndent(` To get started with Prostgles Desktop, download and install the binary file that's appropriate for your operating system (Windows, macOS, or Linux) from [our website](/download). - **Linux**: We provide **.deb**, **.rpm** or **.AppImage** files to suit your distribution, - **macOS**: Open the downloaded **.dmg** file, drag the Prostgles Desktop icon into your Applications folder, and launch the application. - **Windows** - Run the downloaded **.exe** file and follow the on-screen instructions to complete the installation. Alternatively, you can visit the [releases page](https://github.com/prostgles/ui/releases) for checksums, release notes or older versions. ## Setting up When you open Prostgles Desktop, you see the Welcome screen while it loads. You'll need to complete two initial setup steps: 1. Accept the privacy policy 2. Connect to a state database #### State database Prostgles Desktop stores its state and configuration data in a PostgreSQL database. To maintain a secure and responsive environment, we highly recommend [installing Postgres](https://www.postgresql.org/download/) on your local machine. You will need to create a dedicated database and superuser account with a strong password for Prostgles Desktop. Electron Setup `), } satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/editConnectionUIDoc.ts ================================================ import { ROUTES } from "@common/utils"; import type { UIDocContainers } from "../UIDocs"; export const editConnectionUIDoc = { type: "page", // path: ROUTES.EDIT_CONNECTION, title: "Add or Edit Connection", description: "Create a new connection or modify the details of an existing database connection. Update connection settings, credentials, and more.", docs: ` Connection settings, credentials, and other parameters to ensure your connection is configured correctly. `, children: [ { type: "popup", selectorCommand: "PostgresInstallationInstructions", title: "PostgreSQL Installation Instructions", description: "Instructions for installing PostgreSQL on your system.", children: [], }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.connectionName", title: "Connection Name", description: "The name of the connection.", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.connectionType", title: "Connection Type", description: "Allows you change the connection details format: standard or connection string.", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.db_conn", title: "Connection String", description: "The connection string for the database. ", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.db_host", title: "Database Host", description: "The hostname or IP address of the database server.", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.db_port", title: "Database Port", description: "The port number on which the database server is listening.", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.db_user", title: "Database User", description: "The username used to connect to the database.", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.db_pass", title: "Database Password", description: "The password for the database user.", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.db_name", title: "Database Name", description: "The name of the database to connect to.", }, { type: "section", selectorCommand: "NewConnectionForm.MoreOptionsToggle", title: "More Options", description: "Additional connection options.", children: [ { type: "select", selectorCommand: "NewConnectionForm.schemaFilter", title: "Schema Filter", description: "Controls which schemas are visible in the dashboard (public by default).", }, { type: "input", inputType: "text", selectorCommand: "NewConnectionForm.connectionTimeout", title: "Connection Timeout", description: "The maximum time to wait for a connection to the database before timing out.", }, { type: "section", selectorCommand: "NewConnectionForm.sslMode", title: "SSL Mode", description: "Configure SSL settings for the connection.", children: [], }, { type: "input", inputType: "checkbox", selectorCommand: "NewConnectionForm.watchSchema", title: "Watch Schema", description: "Enabled by default. Enables schema change tracking. Any changes made to the database schema are reflected in the API and UI.", }, { type: "input", inputType: "checkbox", selectorCommand: "NewConnectionForm.realtime", title: "Enable Realtime", description: "Enabled by default. Enables realtime data change tracking for tables and views. Requires trigger permissions to the underlying tables.", }, ], }, { type: "button", selectorCommand: "Connection.edit.updateOrCreateConfirm", title: "Update Connection", description: "Save the changes made to the connection.", }, ], } satisfies Omit; ================================================ FILE: client/src/app/UIDocs/navbarUIDoc.ts ================================================ import { mdiThemeLightDark, mdiTranslate } from "@mdi/js"; import { ROUTES } from "@common/utils"; import type { UIDocNavbar } from "../UIDocs"; export const navbarUIDoc = { type: "navbar", selectorCommand: "NavBar", title: "Navigation bar", description: "The top navigation bar provides quick access to all major sections of Prostgles UI.", docs: ` The top navigation bar provides quick access to all major sections of Prostgles UI. Located at the top of the interface, it allows you to switch between database connections, manage users and server settings, and access your account preferences. The navigation adapts to your user role, showing admin-only sections like Users and Server Settings only to authorized users. ## Key sections of the app ### Connections A connection represents a unique postgres database instance (unique host, port, user and database name). The connection list page shows all available connections you can access based on your user permissions. ### Connection dashboard Clicking a connection from the connection list will take you to the dashboard page where you can explore and interact with the database. Available views and tools include SQL Editor, Table, Map, Schema Diagram, AI Assistant, and more. ### Dashboard workspaces The views you open in the dashboard are saved automatically to the current workspace. This allows you to return to the same views later, even after closing the application. You can create multiple workspaces to organize your views by project, team, task, or any other criteria. Navigation `, children: [ // { // type: "link", // path: ROUTES.CONNECTIONS, // selector: ".prgl-brand-icon", // title: "Go to Homepage", // description: "Navigate to the home page (connection list). ", // }, { type: "link", path: ROUTES.CONNECTIONS, selector: '[href="/connections"]', title: "Connections", description: "Manage database connections", }, { type: "link", path: ROUTES.USERS, selector: '[href="/users"]', title: "Users", description: "Manage user accounts (admin only)", docOptions: "hideChildren", pageContent: [ { type: "input", inputType: "text", selectorCommand: "SearchList", title: "Search Users", description: "Search for users by name or email.", }, { type: "smartform-popup", selectorCommand: "dashboard.window.rowInsertTop", title: "Create User", description: "Create a new user account.", tableName: "users", }, { type: "list", selector: ".table-component", title: "User List", description: "List of all users in the system.", itemSelector: ".TableBody div[role='row']", itemContent: [ { type: "smartform-popup", selectorCommand: "dashboard.window.viewEditRow", title: "Edit User", description: "Edit user details.", tableName: "users", }, ], }, { type: "button", selectorCommand: "Pagination.lastPage", title: "Go to Last Page", description: "Navigate to the last page of the user list.", }, ], }, { type: "link", path: ROUTES.SERVER_SETTINGS, selector: '[href="/server-settings"]', title: "Server Settings", description: "Configure server settings (admin only)", }, { type: "link", path: ROUTES.ACCOUNT, title: "Account", selector: '[href="/account"]', description: "Manage your account", }, { type: "button", title: "Logout", selectorCommand: "NavBar.logout", description: "Logout of your account", }, { type: "select", selectorCommand: "App.colorScheme", title: "Theme Selector", description: "Switch between light and dark themes", iconPath: mdiThemeLightDark, }, { type: "select", selectorCommand: "App.LanguageSelector", title: "Language Selector", description: "Change the interface language", iconPath: mdiTranslate, }, { type: "button", selector: ".hamburger", title: "Toggle Menu (visible on small screens)", description: "Toggle the mobile navigation menu on smaller screens", }, ], paths: [ { route: ROUTES.CONNECTIONS, exact: true }, ROUTES.USERS, ROUTES.SERVER_SETTINGS, ROUTES.ACCOUNT, ], } satisfies UIDocNavbar; ================================================ FILE: client/src/app/UIDocs/overviewUIDoc.ts ================================================ import type { UIDoc } from "../UIDocs"; export const overviewUIDoc = { type: "info", title: "Overview", description: "Overview", docs: ` Prostgles UI is a user-friendly way for interacting with PostgreSQL, creating dashboards and internal tools. Prostgles UI Overview ## Features - SQL Editor with syntax highlighting and auto-completion - Real-time dashboards with charts - AI assistant with MCP support - User authentication (email, third-party OAuth and two-factor authentication) - Role-based access control - Database management - File storage and backups (locally or to AWS S3 compatible storage) - TypeScript API with database schema types and end to end type safety - LISTEN NOTIFY support - Mobile friendly It comes in two versions: - **Prostgles UI** - a web-based application with the complete feature set accessible through any modern browser. - **Prostgles Desktop** - a native desktop application based on Electron available for Linux, MacOS and Windows. It has a subset of the core features from Prostgles UI for data exploration and database management. User Management and other multi-user focused features are not available in the desktop version. `, docOptions: "asSeparateFile", } as const satisfies UIDoc; ================================================ FILE: client/src/app/UIDocs/serverSettingsUIDoc.ts ================================================ import { mdiServerSecurity, mdiTools } from "@mdi/js"; import { ROUTES } from "@common/utils"; import { getCommandElemSelector, getDataKeyElemSelector } from "../../Testing"; import type { UIDocContainers } from "../UIDocs"; export const serverSettingsUIDoc = { type: "page", path: ROUTES.SERVER_SETTINGS, iconPath: mdiServerSecurity, title: "Server Settings", description: "Server Settings. Configure security, authentication, and LLM settings.", docs: ` Manage server settings to enhance security, configure authentication methods, and set up LLM providers. Server Settings `, children: [ { type: "tab", selector: getDataKeyElemSelector("security"), title: "Security", description: "Security. Configure domain access, IP restrictions, session duration, and login rate limits to enhance security.", children: [ { type: "smartform", title: "Settings form", description: "Configure server settings.", selectorCommand: "SmartForm", tableName: "server_settings", }, ], }, { type: "tab", title: "Authentication", selector: getDataKeyElemSelector("auth"), description: "Manage user authentication methods, default user roles, and third-party login providers to control access.", children: [ { type: "input", title: "Website URL", inputType: "text", selector: getCommandElemSelector("AuthProviderSetup.websiteURL"), description: "Website URL. Used for email and third-party login redirect URL. When first visiting the app as an admin user, it is automatically set to the current URL which will trigger a page refresh.", }, { type: "input", title: "Default user type", inputType: "select", selector: getCommandElemSelector("AuthProviderSetup.defaultUserType"), description: "The default user type assigned to new users. Defaults to 'default'.", }, { type: "accordion-item", title: "Email signup", description: "Email signup/magic-link authentication setup.", selector: getCommandElemSelector("EmailAuthSetup"), docs: ` Provide SMTP or AWS SES credentials to enable email signup and magic-link authentication. By default users authenticate using a password.`, children: [ { type: "input", title: "Enable/Disable email signup toggle", inputType: "checkbox", selector: getCommandElemSelector("EmailAuthSetup.toggle"), description: "Enable email signup. This will allow users to sign up and log in using their email address.", }, { type: "input", title: "Signup type", inputType: "select", selector: getCommandElemSelector("EmailAuthSetup.SignupType"), description: "Signup type. Choose between 'withPassword' or 'withMagicLink'.", }, { type: "popup", title: "Email verification", selector: getCommandElemSelector("EmailSMTPAndTemplateSetup"), description: "SMTP and email template setup.", children: [ { type: "accordion-item", title: "Email provider setup", selector: getCommandElemSelector("EmailSMTPSetup"), description: "SMTP settings for sending registration/magic-link emails. Allowed providers: SMTP (host, port, username, password) or AWS SES (region, accessKeyId, secretAccessKey).", children: [], }, { type: "accordion-item", title: "Email Template setup", selector: getCommandElemSelector("EmailTemplateSetup"), description: "Email template for registration/magic-link emails", children: [], }, { type: "button", title: "Test and save", selector: getCommandElemSelector( "EmailSMTPAndTemplateSetup.save", ), description: "Test and Save SMTP and email template settings.", }, ], }, ], }, { type: "list", title: "Third-party login providers", description: "Third-party login providers (OAuth2)", selectorCommand: "AuthProviders.list", itemSelector: getCommandElemSelector("AuthProviders.list") + " > .Section", itemContent: [], }, ], }, { type: "tab", iconPath: mdiTools, title: "MCP Servers", selector: getDataKeyElemSelector("mcpServers"), description: "Manage MCP servers and tools that can then be used in the Ask AI chat", children: [], }, { type: "tab", title: "LLM Providers", selector: getDataKeyElemSelector("llmProviders"), description: "Manage LLM providers, credentials and models to be used in the Ask AI chat", children: [], }, { type: "tab", title: "Services", selector: getDataKeyElemSelector("services"), description: "Manage services", children: [], }, ], } satisfies UIDocContainers; ================================================ FILE: client/src/app/UIDocs.ts ================================================ import { filterArrInverse } from "@common/llmUtils"; import type { DBSSchema } from "@common/publishUtils"; import type { ROUTES } from "@common/utils"; import type { Route } from "react-router-dom"; import { isPlaywrightTest } from "../i18n/i18nUtils"; import type { Command } from "../Testing"; import { isDefined } from "../utils/utils"; import { domToThemeAwareSVG } from "./domToSVG/domToThemeAwareSVG"; import { accountUIDoc } from "./UIDocs/accountUIDoc"; import { commandPaletteUIDoc } from "./UIDocs/commandPaletteUIDoc"; import { connectionConfigUIDoc } from "./UIDocs/connection/connectionConfigUIDoc"; import { dashboardUIDoc } from "./UIDocs/connection/dashboardUIDoc"; import { connectionsUIDoc } from "./UIDocs/connectionsUIDoc"; import { desktopInstallationUIDoc } from "./UIDocs/desktopInstallation"; import { navbarUIDoc } from "./UIDocs/navbarUIDoc"; import { overviewUIDoc } from "./UIDocs/overviewUIDoc"; import { serverSettingsUIDoc } from "./UIDocs/serverSettingsUIDoc"; import { UIInstallation } from "./UIDocs/UIInstallationUIDoc"; import { getSVGif } from "./domToSVG/SVGif/getSVGif"; /** * The purpose of UIDocs is to provide structured metadata about the UI elements. * This metadata is used for: * 1. Command Palette. It requires a list of all UI elements and their relationships to enable quick navigation. * 2. Documentation. Generating user guides and documentation with screenshots and descriptions. * 3. Automated testing to ensure UI elements are present and functional. * * Each UIDoc describes a UI element or a group of elements, including their type, selector, description, and hierarchical relationships. * This structured approach allows for easy maintenance and scalability of the documentation system. */ type UIDocCommon = { title: string; /** * Optional mdi icon path representing the element, enhancing visual identification in the Command Palette. */ iconPath?: string; /** * Short description of the element's purpose or functionality used in Command Palette. */ description: string; /** * If defined, this will be used to generate a separate section in the documentation. */ docs?: string; /** * If defined, this will be used as the title for the children list in the documentation. */ childrenTitle?: string; docOptions?: /** * If docs is defined, then it will be rendered as a separate header in the documentation with this title. */ | { title: string } /** * If "asSeparateFile" AND docs is defined, this will be saved as a separate file in the documentation. * By default, a single file is generated for each root UIDoc with child items with docs appended to the bottom. */ | "asSeparateFile" /** * Hides children from documentation. Meant to be used when the children content is obvious/documented on the parent. */ | "hideChildren"; /** If true then this is not available for Prostgles Desktop */ uiVersionOnly?: true; }; /** * UI Documentation system for generating interactive element guides and documentation. * Defines structured metadata for UI elements including selectors, types, and hierarchical relationships. * Used for automated testing, user guidance, and generating SVG documentation from DOM elements. */ type UIDocBase = ( | { selector: string; selectorCommand?: undefined; } | { selector?: undefined; /** * data-command attribute of the element to be selected. */ selectorCommand: Command; } ) & UIDocCommon & T; type Route = (typeof ROUTES)[keyof typeof ROUTES]; export type UIDocInputElement = UIDocBase<{ type: "input"; inputType: "text" | "number" | "checkbox" | "select" | "file"; }>; export type UIDocElement = | UIDocBase<{ type: "button"; }> | UIDocBase<{ type: "drag-handle"; direction: "x" | "y"; }> | UIDocBase<{ type: "canvas"; }> | UIDocInputElement | UIDocBase<{ type: "text"; }> | UIDocBase<{ type: "list"; itemSelector: string; itemContent: UIDocElement[]; }> | UIDocBase<{ type: "select"; }> | UIDocBase<{ type: "link"; path: Route; /** * If defined this means that the final url is `${pagePath}/pathItemRow.id` */ pathItem?: { tableName: keyof DBSSchema; }; pageContent?: UIDocElement[]; }> | UIDocBase<{ type: "popup"; triggerMode?: "click" | "contextmenu"; /** * Used in detecting if popup is shown */ contentSelectorCommand?: Command; children: UIDocElement[]; }> | UIDocBase<{ type: "tab" | "accordion-item"; children: UIDocElement[]; }> | UIDocBase<{ type: "section"; children: UIDocElement[]; }> | UIDocBase<{ type: "smartform" | "smartform-popup"; tableName: string; fieldNames?: string[]; }> | (UIDocCommon & { /** * Documentation-only. Does not appear in Command Palette. */ type: "info"; }); export type UIDocPage = UIDocCommon & { type: "page"; path: Route; pathItem?: { tableName: keyof DBSSchema; selectorCommand?: Command; selector?: string; selectorPath?: Route; }; children: UIDocElement[]; }; export type UIDocContainers = | UIDocPage | UIDocBase<{ type: "hotkey-popup"; hotkey: ["Ctrl" | "Alt" | "Shift", "A" | "K"]; children: UIDocElement[]; }>; export type UIDocNavbar = UIDocBase<{ type: "navbar"; docs?: string; children: UIDocElement[]; /** * List of paths the navbar appears on. */ paths: (Route | { route: Route; exact: true })[]; }>; export type UIDoc = UIDocContainers | UIDocElement | UIDocNavbar; export const UIDocs = [ overviewUIDoc, UIInstallation, desktopInstallationUIDoc, navbarUIDoc, connectionsUIDoc, dashboardUIDoc, connectionConfigUIDoc, serverSettingsUIDoc, accountUIDoc, commandPaletteUIDoc, ] satisfies UIDoc[]; const getFlatDocs = ( doc: UIDoc | undefined, parentDocs: UIDoc[] = [], ): | ({ parentTitles: string[]; parentDocs: UIDoc[]; } & UIDoc)[] | undefined => { if (!doc) return []; const parentTitles = parentDocs.map((d) => d.title); const children = "children" in doc ? doc.children : "itemContent" in doc ? doc.itemContent : "pageContent" in doc ? doc.pageContent : undefined; if (!children?.length) { return [ { parentTitles, ...doc, parentDocs, }, ]; } const nextParentDocs = [...parentDocs, doc]; const flatChildren = children.flatMap( (childDoc: UIDocContainers | UIDocElement) => { const flatChildren = getFlatDocs(childDoc, nextParentDocs) ?? []; return flatChildren; }, ); return [ { parentTitles, ...doc, parentDocs, }, ...flatChildren, ]; }; export type UIDocNonInfo = Exclude; export type UIDocFlat = UIDocNonInfo & { parentTitles: string[]; parentDocs: UIDoc[]; }; export const flatUIDocs = filterArrInverse(UIDocs, { type: "info" } as const) .map((doc) => getFlatDocs(doc)) .filter(isDefined) .flat() as UIDocFlat[]; window.flatUIDocs = flatUIDocs; if (isPlaywrightTest) { window.toSVG = domToThemeAwareSVG; window.getSVGif = getSVGif; } ================================================ FILE: client/src/app/XRealIpSpoofableAlert.tsx ================================================ import { mdiAlertOutline } from "@mdi/js"; import React from "react"; import { NavLink } from "react-router-dom"; import { ROUTES } from "@common/utils"; import Btn from "@components/Btn"; import { InfoRow } from "@components/InfoRow"; import PopupMenu from "@components/PopupMenu"; import { t } from "../i18n/i18nUtils"; import type { useAppState } from "../useAppState/useAppState"; type P = Pick, "serverState" | "user">; export const XRealIpSpoofableAlert = ({ serverState, user }: P) => { return ( <> {serverState?.xRealIpSpoofable && user?.type === "admin" && ( {t["App"]["Security issue"]} } style={{ position: "fixed", right: 0, top: 0, zIndex: 999999 }} positioning="beneath-left-minfill" clickCatchStyle={{ opacity: 0.5 }} content={ Failed login rate limiting is based on x-real-ip header which can be spoofed based on your current connection.{" "} {t["App"]["Settings"]} } /> )} ); }; ================================================ FILE: client/src/app/domToSVG/SVGif/addSVGifCaption.ts ================================================ import { SVG_NAMESPACE } from "../domToSVG"; export const addSVGifCaption = ({ svgDom, appendStyle, height, caption, fromPerc, toPerc, sceneId, totalDuration, }: { svgDom: SVGElement; appendStyle: (style: string) => void; width: number; height: number; caption: string; fromPerc: number; toPerc: number; sceneId: string; totalDuration: number; }) => { appendStyle(` :root { color-scheme: light dark; } .caption-background { fill: light-dark(#ffffff, #1a1a1a); stroke: light-dark(#e0e0e0, #404040); stroke-width: 2; } .caption-text { fill: light-dark(#333333, #e0e0e0); font-family: system-ui, -apple-system, 'Segoe UI', Arial, sans-serif; font-size: 28px; user-select: none; } .caption-progress-bar { fill: light-dark(#00000066, #ffffff66); } `); // Create caption group const captionGroup = document.createElementNS(SVG_NAMESPACE, "g"); svgDom.appendChild(captionGroup); captionGroup.setAttribute("class", "caption"); // Create text element const text = document.createElementNS(SVG_NAMESPACE, "text"); text.setAttribute("text-anchor", "start"); text.setAttribute("class", "caption-text"); text.textContent = caption; captionGroup.appendChild(text); const bbox = text.getBBox(); const textWidth = bbox.width; const textHeight = bbox.height; if (!textWidth || !textHeight) { throw new Error("Failed to measure caption text dimensions"); } // Background dimensions with padding const padding = { x: 22, y: 22 }; const bgWidth = textWidth + padding.x * 2; const bgHeight = textHeight + padding.y * 2; const bgX = 40; const bgY = height - bgHeight - 10; const borderRadius = 4; text.setAttribute("x", bgX + padding.x); text.setAttribute("y", bgY + bgHeight / 2 + textHeight / 3); const addProgressBar = () => { const progressBar = document.createElementNS(SVG_NAMESPACE, "rect"); const progressBarHeight = 4; const barId = "caption-progress-bar-" + sceneId; progressBar.setAttribute("id", barId); progressBar.setAttribute("class", "caption-progress-bar"); progressBar.setAttribute("x", bgX); progressBar.setAttribute("y", bgY + bgHeight - progressBarHeight); progressBar.setAttribute("width", bgWidth); progressBar.setAttribute("height", progressBarHeight); progressBar.setAttribute("rx", "2"); progressBar.setAttribute("ry", "2"); captionGroup.prepend(progressBar); appendStyle(` @keyframes ${barId}-anim { 0% { width: 0; } ${fromPerc + 0.1}% { width: 0; } ${toPerc - 0.1}% { width: ${bgWidth}px; } ${toPerc}% { width: 0; } 100% { width: 0; } } #${barId} { animation: ${barId}-anim ${totalDuration}ms ease-in-out infinite; } `); }; // addProgressBar(); const bgRect = document.createElementNS(SVG_NAMESPACE, "rect"); bgRect.setAttribute("x", bgX); bgRect.setAttribute("y", bgY); bgRect.setAttribute("width", bgWidth); bgRect.setAttribute("height", bgHeight); bgRect.setAttribute("rx", borderRadius); bgRect.setAttribute("ry", borderRadius); bgRect.setAttribute("class", "caption-background"); captionGroup.prepend(bgRect); }; ================================================ FILE: client/src/app/domToSVG/SVGif/addSVGifPointer.ts ================================================ import { SVG_NAMESPACE } from "../domToSVG"; import { getAnimationProperty } from "./getSVGif"; export const addSVGifPointer = ({ g, appendStyle, cursorKeyframes, totalDuration, }: { g: SVGGElement; appendStyle: (style: string) => void; totalDuration: number; cursorKeyframes: string[]; }) => { const pointerId = "pointer"; const pointerCircle = document.createElementNS(SVG_NAMESPACE, "circle"); pointerCircle.setAttribute("r", "10"); pointerCircle.setAttribute("opacity", "0"); pointerCircle.setAttribute("id", pointerId); const cursorAnimationName = `cursor-move`; appendStyle(` #${pointerId} { transform-origin: center; fill: #00000036; filter: drop-shadow(0 0 2px #000000aa); } @media (prefers-color-scheme: dark) { #${pointerId} { fill: #ffffff36; filter: drop-shadow(0 0 2px #ffffffaa); } } @keyframes ${cursorAnimationName} { ${cursorKeyframes.map((v) => ` ${v}`).join("\n")} } ${getAnimationProperty({ totalDuration, elemSelector: `#${pointerId}`, animName: cursorAnimationName, easeFunction: "ease-out", })} `); g.appendChild(pointerCircle); }; ================================================ FILE: client/src/app/domToSVG/SVGif/addSVGifTimelineControls.ts ================================================ import { SVG_NAMESPACE } from "../domToSVG"; import { getAnimationProperty } from "./getSVGif"; import type { getSVGifAnimations } from "./getSVGifAnimations"; export const addSVGifTimelineControls = ({ g, appendStyle, width, height, totalDuration, sceneAnimations, }: { g: SVGGElement; appendStyle: (style: string) => void; width: number; height: number; totalDuration: number; sceneAnimations: ReturnType["sceneAnimations"]; }) => { const progressBarId = "progress-bar"; const animationProgressBar = document.createElementNS(SVG_NAMESPACE, "rect"); const animationProgressBarHeight = 2; animationProgressBar.setAttribute("id", progressBarId); animationProgressBar.setAttribute("x", "0"); animationProgressBar.setAttribute("y", height - animationProgressBarHeight); animationProgressBar.setAttribute("height", animationProgressBarHeight); animationProgressBar.setAttribute("width", `${width}px`); animationProgressBar.setAttribute("rx", "2.5"); animationProgressBar.setAttribute("ry", "2.5"); const progressBarAnimationName = "animation-progress-bar"; appendStyle(` @keyframes ${progressBarAnimationName} { 0% { transform: translateX(-100%); } 100% { transform: translateX(0%); } } #${progressBarId} { fill: red; opacity: 0.3; ${getAnimationProperty({ totalDuration, animName: progressBarAnimationName, elemSelector: `#${progressBarId}` }, true)} } @media (prefers-color-scheme: dark) { #${progressBarId} { opacity: 0.5; } } /* Pause only while SVG is pressed */ g.paused * { animation-play-state: paused; } `); const setPause = document.createElementNS(SVG_NAMESPACE, "set"); setPause.setAttribute("attributeName", "class"); setPause.setAttribute("to", "paused"); setPause.setAttribute("begin", "mousedown"); setPause.setAttribute("end", "mouseup"); const sceneSkipWrapper = document.createElementNS(SVG_NAMESPACE, "g"); sceneAnimations.forEach((scene, index) => { const sceneSkipRect = document.createElementNS(SVG_NAMESPACE, "rect"); const sceneStartTime = scene.startMs; sceneSkipRect.setAttribute( "x", `${(sceneStartTime / totalDuration) * width}`, ); sceneSkipRect.setAttribute("y", "0"); sceneSkipRect.setAttribute( "width", `${(scene.duration / totalDuration) * width}`, ); sceneSkipRect.setAttribute("height", `${height}`); sceneSkipRect.setAttribute("fill", "none"); sceneSkipRect.setAttribute("cursor", "pointer"); const seekTime = sceneStartTime / 1000; // in seconds sceneSkipRect.addEventListener("click", () => { const svgElem = g.ownerSVGElement; if (!svgElem) return; const currentTime = svgElem.getCurrentTime(); const timeDiff = seekTime - currentTime; svgElem.setCurrentTime(currentTime + timeDiff + 0.01); // add small offset to trigger time update }); sceneSkipWrapper.appendChild(sceneSkipRect); }); g.appendChild(animationProgressBar); // g.appendChild(sceneSkipWrapper); g.appendChild(setPause); }; ================================================ FILE: client/src/app/domToSVG/SVGif/animations/getSVGifCursorAnimationHandler.ts ================================================ import type { SVGif } from "src/Testing"; import type { SVGifParsedScene } from "../getSVGifParsedScenes"; import { getSVGifRevealKeyframes } from "../getSVGifRevealKeyframes"; import { toFixed } from "../../utils/toFixed"; import type { SceneNodeAnimation } from "../getSVGifAnimations"; import { fixIndent } from "@common/utils"; export const getSVGifCursorAnimationHandler = ({ parsedScenes, getPercent, totalSvgifDuration, width, height, }: { parsedScenes: SVGifParsedScene[]; getPercent: (ms: number, offset?: 0 | 0.1 | -0.1) => number; totalSvgifDuration: number; width: number; height: number; }) => { const cursorMovements: { fromPerc: number; toPerc: number; lingerPerc: number | undefined; target: [number, number]; }[] = []; const [x0, y0] = [width / 2, height]; const addAnimation = ({ currentPrevDuration, animationIndex, animations, sceneIndex, animation, bbox, svgFileName, sceneNodeAnimations, sceneId, }: { currentPrevDuration: number; animations: SVGif.Animation[]; animationIndex: number; sceneIndex: number; animation: SVGif.CursorAnimation; bbox: DOMRect | undefined; svgFileName: string; sceneNodeAnimations: SceneNodeAnimation[]; sceneId: string; }) => { if (animation.type === "moveTo") { cursorMovements.push({ fromPerc: Number(getPercent(currentPrevDuration)), toPerc: Number(getPercent(currentPrevDuration + animation.duration)), lingerPerc: undefined, target: animation.xy, }); return; } if (!bbox) { throw new Error(`Unexpected. BBox missing`); } const { type, lingerMs = 500, waitBeforeClick = 500, duration, elementSelector, offset, } = animation; const xOffset = offset?.x ?? Math.min(60, bbox.width / 2); const yOffset = offset?.y ?? Math.min(30, bbox.height / 2); const cx = bbox.x + xOffset; const cy = bbox.y + yOffset; if (duration < waitBeforeClick) { throw new Error( fixIndent(` Duration ${duration}ms for "${type}" animation on element ${elementSelector} in SVG file ${svgFileName} is too short. It must be greater than the waitBeforeClick time of ${waitBeforeClick}ms. `), ); } const clickEndTime = currentPrevDuration + duration - waitBeforeClick; const nextAnimation = animations[animationIndex + 1] || parsedScenes[sceneIndex + 1]?.animations[0]; const anotherClickFollowing = nextAnimation?.type === "click"; cursorMovements.push({ fromPerc: getPercent(currentPrevDuration), toPerc: getPercent(clickEndTime), lingerPerc: !lingerMs || anotherClickFollowing ? undefined : ( Number( getPercent(Math.min(totalSvgifDuration, clickEndTime + lingerMs)), ) ), target: [cx, cy], }); if (type === "clickAppearOnHover") { const toPerc = getPercent(clickEndTime); /** Ensure it appears as the cursor enters the bbox */ const xDistance = Math.abs( cx - (cursorMovements.at(-2)?.target[0] ?? x0), ); const yDistance = Math.abs( cy - (cursorMovements.at(-2)?.target[1] ?? y0), ); const distance = Math.sqrt(xDistance ** 2 + yDistance ** 2); const approxMsToEnter = Math.max(300, distance * 4); const appearTime = Math.max( currentPrevDuration, clickEndTime - approxMsToEnter, ); const appearPerc = getPercent(appearTime); sceneNodeAnimations.push({ sceneId, elemSelector: elementSelector, keyframes: getSVGifRevealKeyframes({ fromPerc: appearPerc, toPerc, mode: "opacity", }), }); } }; const getCursorKeyframes = () => { const firstTranslate = `transform: translate(${toFixed(x0)}px, ${toFixed(y0)}px)`; const cursorKeyframes = [`0% { opacity: 0; ${firstTranslate}; }`]; const cursorMovementsFixed = cursorMovements.map((e) => ({ ...e, target: e.target.map((v) => toFixed(v)), })); cursorMovementsFixed.forEach( ({ fromPerc, toPerc, lingerPerc, target: [x, y] }, i, arr) => { const translate = `transform: translate(${x}px, ${y}px)`; const prevTarget = arr[i - 1]?.target ?? [x0, y0].map((v) => toFixed(v)); const prevTranslate = `transform: translate(${prevTarget[0]}px, ${prevTarget[1]}px)`; cursorKeyframes.push( ...[ `${toFixed(fromPerc, 4)}% { opacity: 0; ${prevTranslate}; }`, `${toFixed(fromPerc + 0.0001, 4)}% { opacity: 1; ${prevTranslate}; }`, `${toFixed(toPerc - 0.0001, 4)}% { opacity: 1; ${translate}; }`, `${toFixed(lingerPerc ?? toPerc, 4)}% { opacity: 0; ${translate}; }`, ].filter(Boolean), ); }, ); return { cursorKeyframes }; }; return { addAnimation, cursorMovements, getCursorKeyframes }; }; ================================================ FILE: client/src/app/domToSVG/SVGif/animations/getSVGifTypeAnimation.ts ================================================ import type { SVGif } from "src/Testing"; import type { SceneNodeAnimation } from "../getSVGifAnimations"; import type { SVGifParsedScene } from "../getSVGifParsedScenes"; import { getSVGifRevealKeyframes } from "../getSVGifRevealKeyframes"; import type { getSVGifTargetBBox } from "../getSVGifTargetBBox"; import { getSVGifZoomToAnimation } from "./getSVGifZoomToAnimation"; /** * Given an SVGifScenes, return the animations */ export const getSVGifTypeAnimation = ( viewport: { width: number; height: number }, { element, bbox: rawBBox }: ReturnType, { svgDom, svgFileName }: SVGifParsedScene, animation: Extract, { sceneId, sceneIndex, totalDuration, getPercent, fromTime, }: { sceneIndex: number; sceneId: string; totalDuration: number; getPercent: (time: number, increment?: 0.1 | -0.1) => number; fromTime: number; }, ) => { const { elementSelector, duration } = animation; const sceneNodeAnimations: SceneNodeAnimation[] = []; const tSpansOrText = Array.from( element.querySelectorAll("tspan, text"), ); if (!tSpansOrText.length) { throw `No tspan elements found in element: ${elementSelector} in SVG file ${svgFileName}. "type" animations require the target element to contain one or more elements.`; } const rootGId = svgDom.querySelector(":scope > g")?.id; if (!rootGId) { throw `No root element with id found in SVG file ${svgFileName}. "type" animations require the SVG to have a root element with an id.`; } const totalWidth = tSpansOrText.reduce( (acc, tspan) => acc + tspan.getComputedTextLength(), 0, ); const { extraAnimation } = animation; const zoomInDuration = extraAnimation ? 500 : 0; const zoomOutDuration = extraAnimation ? 500 : 0; const waitBeforeZoomOut = extraAnimation ? 300 : 0; const typingDuration = duration - zoomInDuration - zoomOutDuration - waitBeforeZoomOut; if (typingDuration < 500) { throw [ `Duration ${duration}ms for "type" animation on element ${elementSelector} in SVG file ${svgFileName} is too short.`, `Must be at least ${zoomInDuration + zoomOutDuration + waitBeforeZoomOut + 500}ms`, ].join("\n"); } const zoomInEndTime = fromTime + zoomInDuration; const typingStartTime = zoomInEndTime; const msPerPx = typingDuration / totalWidth; let fromTimeLocal = typingStartTime; tSpansOrText.forEach((tspanOrText, i) => { const tspanWidth = tspanOrText.getComputedTextLength(); const tspanDuration = tspanWidth * msPerPx; sceneNodeAnimations.push({ sceneId, elemSelector: `${elementSelector} ${tspanOrText.nodeName}:nth-of-type(${i + 1})`, keyframes: getSVGifRevealKeyframes({ fromPerc: getPercent(fromTimeLocal), toPerc: getPercent(fromTimeLocal + tspanDuration), mode: "left to right", }), }); fromTimeLocal += tspanDuration; }); const zoomToStyle = !animation.extraAnimation ? "" : ( getSVGifZoomToAnimation( viewport, { bbox: rawBBox }, { svgDom, svgFileName }, animation.extraAnimation.type === "zoomToElement" ? { ...animation, type: "zoomToElement" } : { ...animation, type: "bringToFront", bringToFrontSelector: animation.extraAnimation.elementSelector, }, { sceneId, sceneIndex, totalDuration, getPercent, fromTime, }, false, ).style ); return { sceneNodeAnimations, style: zoomToStyle }; }; ================================================ FILE: client/src/app/domToSVG/SVGif/animations/getSVGifZoomToAnimation.ts ================================================ import { fixIndent } from "@common/utils"; import type { SVGif } from "src/Testing"; import { toFixed } from "../../utils/toFixed"; import { getAnimationProperty } from "../getSVGif"; import type { SVGifParsedScene } from "../getSVGifParsedScenes"; import type { getSVGifTargetBBox } from "../getSVGifTargetBBox"; export const getSVGifZoomToAnimation = ( viewport: { width: number; height: number }, { bbox: rawBBox }: Pick, "bbox">, { svgDom, svgFileName }: Pick, animation: Extract< SVGif.Animation, { type: "zoomToElement" | "bringToFront" } >, { sceneId, sceneIndex, totalDuration, getPercent, fromTime, }: { sceneIndex: number; sceneId: string; totalDuration: number; getPercent: (time: number, increment?: 0.1 | -0.1) => number; fromTime: number; }, addToRootSvg: boolean, ) => { const { elementSelector, duration, maxScale = 3, type, bringToFrontSelector, } = animation; const rootGId = svgDom.querySelector(":scope > g")?.id; if (!rootGId) { throw `No root element with id found in SVG file ${svgFileName}. "type" animations require the SVG to have a root element with an id.`; } const zoomInDuration = 500; const zoomOutDuration = 500; const waitBeforeZoomOut = 300; const dwellDuration = duration - zoomInDuration - zoomOutDuration - waitBeforeZoomOut; if (dwellDuration < 500) { throw [ `Duration ${duration}ms for "type" animation on element ${elementSelector} in SVG file ${svgFileName} is too short.`, `Must be at least ${zoomInDuration + zoomOutDuration + waitBeforeZoomOut + 500}ms`, ].join("\n"); } const zoomInStartTime = fromTime; const zoomInEndTime = fromTime + zoomInDuration; const dwellEndTime = zoomInEndTime + dwellDuration; const zoomOutStartTime = dwellEndTime + waitBeforeZoomOut; const zoomOutEndTime = zoomOutStartTime + zoomOutDuration; const adjustedBBox = { x: rawBBox.x, y: rawBBox.y, height: rawBBox.height, width: rawBBox.width, }; const xPadding = 50; const requiredScale = (svgDom.clientWidth - xPadding) / adjustedBBox.width; const effectiveScale = toFixed(Math.min(requiredScale, maxScale)); const toPerc = getPercent(fromTime + duration); // Calculate center of the element const elementCenterX = toFixed(adjustedBBox.x + adjustedBBox.width / 2); const elementCenterY = toFixed(adjustedBBox.y + adjustedBBox.height / 2); // Calculate viewport center const viewportCenterX = toFixed(viewport.width / 2); const viewportCenterY = toFixed(viewport.height / 2); // Calculate translation needed to center the element const translateX = toFixed(viewportCenterX - elementCenterX); const translateY = toFixed(viewportCenterY - elementCenterY); const transformOrigin = `${elementCenterX}px ${elementCenterY}px`; const rootGSelector = `svg#${sceneId} g#${rootGId}`; const animatedElementSelector = type === "bringToFront" ? `${rootGSelector} ${bringToFrontSelector}` : addToRootSvg ? ":scope" : rootGSelector; /** Add root svg zoom in-out animation to the typed */ const animProp = getAnimationProperty({ animName: `scene-${sceneIndex}-type-zoom`, elemSelector: animatedElementSelector, totalDuration, otherProps: `transform-origin: ${transformOrigin};`, }); const style = fixIndent(` @keyframes scene-${sceneIndex}-type-zoom { ${getPercent(zoomInStartTime)}% { transform: translate(0px, 0px) scale(1);} ${getPercent(zoomInEndTime)}% { transform: translate(${translateX}px, ${translateY}px) scale(${effectiveScale}); } ${getPercent(zoomOutStartTime)}% { transform: translate(${translateX}px, ${translateY}px) scale(${effectiveScale}); } ${getPercent(zoomOutEndTime)}% { transform: translate(0px, 0px) scale(1); } ${toFixed( Math.min(100, toPerc + 0.1), 4, )}% { transform: translate(0px, 0px) scale(1); } } ${animProp} `); return { style }; }; ================================================ FILE: client/src/app/domToSVG/SVGif/compressSVGif.ts ================================================ /** * Given an SVG with multiple SVG scenes inside , * identify which g elements have the same data-selector attribute and outerHTML across scenes and * compress them by only keeping one instance and replacing the others with elements referencing the first. */ import { isDefined } from "src/utils/utils"; import { SVG_NAMESPACE } from "../domToSVG"; import type { SVGifParsedScene } from "./getSVGifParsedScenes"; export const compressSVGif = ( svg: SVGSVGElement, parsedScenes: SVGifParsedScene[], ) => { const defs = document.createElementNS(SVG_NAMESPACE, "defs"); svg.appendChild(defs); // to ensure the SVG namespace is defined const allSelectorNodes = Array.from( svg.querySelectorAll(`[data-selector]`), ); const nodesMap = new Map(); allSelectorNodes.forEach((n) => { const { selector } = n.dataset; if (selector && n.outerHTML.length > 150) { nodesMap.set(selector, n); } }); const nodesToReuse = new Map(); const pushNodeToReuse = (selector: string, node: SVGGElement) => { const id = `c-${selector.replaceAll(" ", "_").replaceAll(".", "_")}`; if (!nodesToReuse.has(selector)) { nodesToReuse.set(selector, node); const clonedNode = node.cloneNode(true) as SVGGElement; clonedNode.setAttribute("id", id); clonedNode.removeAttribute("data-selector"); defs.appendChild(clonedNode); } const useElem = document.createElementNS(SVG_NAMESPACE, "use"); useElem.setAttribute("href", `#${id}`); node.replaceWith(useElem); }; const scenes = Array.from(svg.querySelectorAll(`#all-scenes > svg`)); nodesMap.forEach((originalNode, selector) => { const matchingScenes = scenes .map((scene) => { const [matchingNode, ...others] = scene.querySelectorAll( `[data-selector=${JSON.stringify(selector)}]`, ); /** Do not compress nodes that are selected in animations to ensure css selectors still work */ const parsedScene = parsedScenes.find((ps) => ps.svgDom === scene); if (!parsedScene) { throw "Could not find parsedScene"; } if ( matchingNode && !others.length && matchingNode.outerHTML === originalNode.outerHTML && !parsedScene.animations.some((anim) => { const node = anim.type !== "wait" && anim.type !== "moveTo" ? matchingNode.querySelector(anim.elementSelector) : null; return node; }) ) { return { scene, matchingNode }; } }) .filter(isDefined); if (matchingScenes.length > 1) { matchingScenes.forEach(({ matchingNode }) => { pushNodeToReuse(selector, matchingNode); }); } }); return svg; }; ================================================ FILE: client/src/app/domToSVG/SVGif/getSVGif.ts ================================================ import { fixIndent } from "@common/utils"; import type { SVGif } from "src/Testing"; import { SVG_NAMESPACE } from "../domToSVG"; import { addSVGifPointer } from "./addSVGifPointer"; import { addSVGifTimelineControls } from "./addSVGifTimelineControls"; import { getSVGifAnimations } from "./getSVGifAnimations"; import { getSVGifParsedScenes } from "./getSVGifParsedScenes"; import { compressSVGif } from "./compressSVGif"; export const getSVGif = ( scenes: SVGif.Scene[], svgFiles: Map, ) => { const { parsedScenes, firstScene } = getSVGifParsedScenes(scenes, svgFiles); const { viewBox, width, height } = firstScene; const svg = document.createElementNS(SVG_NAMESPACE, "svg"); svg.setAttribute("xmlns", SVG_NAMESPACE); svg.setAttribute("viewBox", viewBox); const style = document.createElementNS(SVG_NAMESPACE, "style"); svg.appendChild(style); const appendStyle = (s: string) => { style.textContent += "\n\n" + fixIndent(s); }; const g = document.createElementNS(SVG_NAMESPACE, "g"); g.setAttribute("id", "all-scenes"); svg.appendChild(g); const { cursorKeyframes, sceneAnimations, totalDuration } = getSVGifAnimations({ width, height }, g, parsedScenes, appendStyle); const getThisAnimationProperty = ( args: Omit< Parameters[0], "totalDuration" | "loop" >, onlyValue?: boolean, ) => getAnimationProperty({ ...args, totalDuration }, onlyValue); sceneAnimations.forEach(({ sceneId, keyframes }) => { const animationName = `scene-${sceneId}-anim`; appendStyle(` @keyframes ${animationName} { ${keyframes.map((v) => ` ${v}`).join("\n")} } ${getThisAnimationProperty({ elemSelector: `#${sceneId}`, animName: animationName })} `); }); addSVGifTimelineControls({ width, height, appendStyle, g, totalDuration, sceneAnimations, }); addSVGifPointer({ cursorKeyframes, g, appendStyle, totalDuration }); /** This appears to break/appear narrower on ios ?!! */ compressSVGif(svg, parsedScenes); svg .querySelectorAll("[data-selector]") .forEach((el) => el.removeAttribute("data-selector")); // document.body.appendChild(svg); // debugging /** Remove defs with empty styles */ svg.querySelectorAll("defs").forEach((defs) => { if (!defs.innerHTML || !defs.innerHTML.trim()) { defs.remove(); } }); svg.setAttribute("data-duration", totalDuration); const xmlSerializer = new XMLSerializer(); const svgString = xmlSerializer.serializeToString(svg); return svgString; }; export const getAnimationProperty = ( { elemSelector, animName, totalDuration, otherProps = "", easeFunction = "ease-in-out", }: { elemSelector: string; animName: string; totalDuration: number; otherProps?: string; easeFunction?: "ease-in-out" | "ease-out"; }, onlyValue = false, ) => { const loop = true as boolean; const value = `animation: ${animName} ${totalDuration}ms ${easeFunction} ${ loop ? "infinite" : "forwards" };`; if (onlyValue) return value; return fixIndent(` ${elemSelector} { ${value} ${otherProps} } `); }; ================================================ FILE: client/src/app/domToSVG/SVGif/getSVGifAnimations.ts ================================================ import { fixIndent } from "@common/utils"; import { SVG_NAMESPACE } from "../domToSVG"; import { renderSvg } from "../text/textToSVG"; import { toFixed } from "../utils/toFixed"; import { addSVGifCaption } from "./addSVGifCaption"; import { getSVGifCursorAnimationHandler } from "./animations/getSVGifCursorAnimationHandler"; import { getSVGifTypeAnimation } from "./animations/getSVGifTypeAnimation"; import { getSVGifZoomToAnimation } from "./animations/getSVGifZoomToAnimation"; import { getAnimationProperty } from "./getSVGif"; import type { SVGifParsedScene } from "./getSVGifParsedScenes"; import { getSVGifRevealKeyframes } from "./getSVGifRevealKeyframes"; import { getSVGifTargetBBox } from "./getSVGifTargetBBox"; /** * Given an SVGifScenes, return the animations */ export const getSVGifAnimations = ( { height, width }: { width: number; height: number }, g: SVGGElement, parsedScenes: SVGifParsedScene[], appendRootStyle: (s: string) => void, ) => { const totalSvgifDuration = parsedScenes.reduce( (acc, { animations }) => acc + animations.reduce((a, { duration }) => a + duration, 0), 0, ); const getPercent = (ms: number, offset: 0 | 0.1 | -0.1 = 0) => { const perc = (ms / totalSvgifDuration) * 100; const result = Math.max(0, Math.min(100, perc)); const resultWithOffset = result + offset; return toFixed(resultWithOffset, 4); }; const cursorHandler = getSVGifCursorAnimationHandler({ getPercent, parsedScenes, totalSvgifDuration, width, height, }); const sceneAnimations: { sceneId: string; svgName: string; startMs: number; duration: number; keyframes: string[]; }[] = []; let currentPrevDuration = 0; const prevSceneAnim = sceneAnimations.at(-1); for (const [sceneIndex, parsedScene] of parsedScenes.entries()) { const { svgFileName, animations, svgDom, caption } = parsedScene; if (!animations.length) { throw new Error( `No animations provided for scene ${sceneIndex} (${svgFileName}). Each scene must have at least one animation.`, ); } const sceneId = `scene-${sceneIndex}`; const sceneStartMs = currentPrevDuration; const sceneFromPercent = getPercent(currentPrevDuration); const renderedSceneSVG = renderSvg(svgDom); const sceneKeyframes: string[] = []; if (prevSceneAnim) { if (prevSceneAnim.svgName === svgFileName) { throw new Error("SVG file must change between scenes"); } } if (currentPrevDuration) { sceneKeyframes.push(`0% ${hidden}`); sceneKeyframes.push( `${getPercent(currentPrevDuration, -0.1)}% ${hidden}`, ); } sceneKeyframes.push(`${getPercent(currentPrevDuration)}% ${visible}`); const sceneNodeAnimations: SceneNodeAnimation[] = []; let sceneNodeAnimationsStyle = ""; const getDefs = () => { let defs = svgDom.querySelector("defs"); if (!defs) { defs = document.createElementNS(SVG_NAMESPACE, "defs"); svgDom.insertBefore(defs, svgDom.firstChild); } return defs; }; let styleElem = svgDom.querySelector("style"); const appendStyle = (style: string) => { if (!styleElem) { styleElem = document.createElementNS(SVG_NAMESPACE, "style"); const defs = getDefs(); defs.appendChild(styleElem); } console.log("Appending style:", style); styleElem.textContent += style; }; for (const [animationIndex, animation] of animations.entries()) { const bboxInfo = animation.type !== "moveTo" && animation.type !== "wait" ? getSVGifTargetBBox({ elementSelector: animation.elementSelector, svgDom, svgFileName, width, height, }) : undefined; if (animation.type === "wait") { } else if ( animation.type === "click" || animation.type === "clickAppearOnHover" || animation.type === "moveTo" ) { cursorHandler.addAnimation({ currentPrevDuration, animation, animationIndex, animations, bbox: bboxInfo?.bbox, sceneIndex, svgFileName, sceneNodeAnimations, sceneId, }); } else { const { duration, elementSelector } = animation; if (!bboxInfo) throw new Error("Missing bboxInfo"); const { bbox, element } = bboxInfo; const fromTime = currentPrevDuration; const fromPerc = getPercent(fromTime); if (animation.type === "fadeIn" || animation.type === "growIn") { const toTime = fromTime + duration; const toPerc = getPercent(toTime); sceneNodeAnimations.push({ sceneId, elemSelector: elementSelector, keyframes: getSVGifRevealKeyframes({ fromPerc, toPerc, mode: animation.type === "fadeIn" ? "opacity" : "growIn", // mode: "top to bottom", }), }); } else if (animation.type === "type") { const parsedAnimations = getSVGifTypeAnimation( { height, width }, { element, bbox }, parsedScene, animation, { sceneId, sceneIndex, totalDuration: totalSvgifDuration, getPercent, fromTime, }, ); sceneNodeAnimations.push(...parsedAnimations.sceneNodeAnimations); appendStyle(parsedAnimations.style); // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition } else if (animation.type === "zoomToElement") { const parsedAnimations = getSVGifZoomToAnimation( { height, width }, { bbox }, parsedScene, animation, { sceneId, sceneIndex, totalDuration: totalSvgifDuration, getPercent, fromTime, }, true, ); appendRootStyle(parsedAnimations.style); } } const isParallelAnimation = animation.type === "zoomToElement"; currentPrevDuration += isParallelAnimation ? 0 : animation.duration; } if (sceneNodeAnimations.length) { sceneNodeAnimationsStyle += "\n"; let nodeAnimIndex = 0; sceneNodeAnimations.forEach(({ sceneId, elemSelector, keyframes }) => { nodeAnimIndex++; const animationName = `node-${sceneId}-anim-${nodeAnimIndex}`; sceneNodeAnimationsStyle += "\n"; sceneNodeAnimationsStyle += fixIndent(` @keyframes ${animationName} { ${keyframes.map((v) => ` ${v}`).join("\n")} } ${getAnimationProperty({ elemSelector: `#${sceneId} ${elemSelector}`, animName: animationName, totalDuration: totalSvgifDuration })} `); }); appendStyle(sceneNodeAnimationsStyle); } if (caption) { addSVGifCaption({ svgDom, appendStyle, width, height, caption, fromPerc: sceneFromPercent, toPerc: getPercent(currentPrevDuration), totalDuration: totalSvgifDuration, sceneId, }); } const serializer = new XMLSerializer(); const svgString = serializer.serializeToString(svgDom); appendSvgToSvg({ id: sceneId, svgFile: svgString, svgDom }, g); const isLastScene = sceneIndex === parsedScenes.length - 1; sceneKeyframes.push(`${getPercent(currentPrevDuration)}% ${visible}`); if (!isLastScene) { const toPerc = getPercent(currentPrevDuration, 0.1); sceneKeyframes.push(`${getPercent(currentPrevDuration, 0.1)}% ${hidden}`); if (toPerc < 100) { sceneKeyframes.push(`100% ${hidden}`); } } sceneAnimations.push({ sceneId, startMs: sceneStartMs, duration: currentPrevDuration - sceneStartMs, svgName: svgFileName, keyframes: sceneKeyframes, }); renderedSceneSVG.remove(); } const { cursorKeyframes } = cursorHandler.getCursorKeyframes(); return { cursorKeyframes, sceneAnimations, totalDuration: totalSvgifDuration, }; }; export type SceneNodeAnimation = { sceneId: string; elemSelector: string; keyframes: string[]; }; const appendSvgToSvg = ( { svgFile, id, svgDom }: { svgFile: string; id: string; svgDom: SVGElement }, g: SVGGElement, ) => { svgDom.setAttribute("id", id); g.appendChild(svgDom); return { remove: () => { svgDom.remove(); }, }; }; const visible = "{ visibility: visible; }"; const hidden = "{ visibility: hidden; }"; ================================================ FILE: client/src/app/domToSVG/SVGif/getSVGifParsedScenes.ts ================================================ import type { SVGif } from "src/Testing"; const parseSVGWithViewBox = ( svgFileName: string, svgFiles: Map, ) => { if (!svgFileName) { throw "SVG file name is empty"; } const svgFile = svgFiles.get(svgFileName + ".svg"); if (!svgFile) { throw `SVG file not found: ${svgFileName} \nExpecting one of: ${svgFiles.keys().toArray()}`; } const parsedSVG = new DOMParser().parseFromString(svgFile, "image/svg+xml"); const viewBox = parsedSVG.documentElement.getAttribute("viewBox"); if (!viewBox) { throw `SVG file ${svgFileName} does not have a viewBox attribute`; } const width = Number(viewBox.split(" ")[2]); const height = Number(viewBox.split(" ")[3]); if (!width || !height) { throw `Invalid viewBox dimensions in SVG file ${svgFileName}`; } return { svgDom: parsedSVG.documentElement as unknown as SVGElement, width, height, viewBox, svgFile, }; }; export type SVGifParsedScene = ReturnType & SVGif.Scene; export const getSVGifParsedScenes = ( scenes: SVGif.Scene[], svgFiles: Map, ) => { const parsedScenes = scenes.map((scene) => ({ ...scene, ...parseSVGWithViewBox(scene.svgFileName, svgFiles), })); const firstScene = parsedScenes[0]; if (!firstScene) { throw "No scenes provided"; } return { parsedScenes, firstScene }; }; ================================================ FILE: client/src/app/domToSVG/SVGif/getSVGifRevealKeyframes.ts ================================================ import { toFixed } from "../utils/toFixed"; export const getSVGifRevealKeyframes = ({ fromPerc, toPerc, mode, }: { fromPerc: number; toPerc: number; mode: "top to bottom" | "left to right" | "opacity" | "growIn"; }) => { if (mode === "growIn") { return [ !fromPerc ? "" : ( `0% { opacity: 0; transform: scale(0.2); transform-origin: center; }` ), `${toFixed(fromPerc, 4)}% { opacity: 0; transform: scale(0.2); transform-origin: center; }`, `${toFixed(fromPerc + 0.1, 4)}% { opacity: 0; transform: scale(0.2); transform-origin: center; }`, `${toFixed(toPerc, 4)}% { opacity: 1; transform: scale(1); transform-origin: center; }`, toPerc === 100 ? "" : ( `100% { opacity: 1; transform: scale(1); transform-origin: center; }` ), ].filter(Boolean); } if (mode === "opacity") { return [ !fromPerc ? "" : `0% { opacity: 0; }`, `${toFixed(fromPerc, 4)}% { opacity: 0; }`, `${toFixed(fromPerc + 0.1, 4)}% { opacity: 0; }`, `${toFixed(toPerc, 4)}% { opacity: 1; }`, toPerc === 100 ? "" : `100% { opacity: 1; }`, ].filter(Boolean); } const clippedInset = mode === "top to bottom" ? `inset(0 0 100% 0)` : `inset(0 100% 0 0)`; return [ !fromPerc ? "" : `0% { opacity: 0; clip-path: ${clippedInset} }`, `${toFixed(fromPerc, 4)}% { opacity: 0; clip-path: ${clippedInset} }`, `${toFixed(fromPerc + 0.1, 4)}% { opacity: 1; clip-path: ${clippedInset} }`, `${toFixed(toPerc, 4)}% { opacity: 1; clip-path: inset(0 0 0 0); }`, toPerc === 100 ? "" : `100% { opacity: 1; clip-path: inset(0 0 0 0); }`, ].filter(Boolean); }; ================================================ FILE: client/src/app/domToSVG/SVGif/getSVGifTargetBBox.ts ================================================ export const getSVGifTargetBBox = ({ elementSelector, svgDom, svgFileName, width, height, }: { elementSelector: string; svgDom: SVGElement; svgFileName: string; width: number; height: number; }) => { const element = svgDom.querySelector(elementSelector); if (!element) { throw `Element not found: ${elementSelector} in SVG file ${svgFileName}`; } const bbox = element.getBBox(); /* Clamp width and height to be within visible bounds */ bbox.x = Math.max(0, Math.min(bbox.x, width)); bbox.y = Math.max(0, Math.min(bbox.y, height)); bbox.width = Math.max(0, Math.min(bbox.width, width - bbox.x)); bbox.height = Math.max(0, Math.min(bbox.height, height - bbox.y)); return { bbox, element }; }; ================================================ FILE: client/src/app/domToSVG/addFragmentViewBoxes.ts ================================================ import { SVG_NAMESPACE } from "./domToSVG"; export const addFragmentViewBoxes = (svg: SVGSVGElement, padding = 10) => { // Ensure the SVG has a viewBox defined (needed for calculations) if (!svg.hasAttribute("viewBox")) { throw new Error("SVG must have a viewBox attribute"); } if (!svg.isConnected) { throw new Error("SVG must be in the DOM for bbox calculations"); } // Iterate over elements with a data-command attribute const groups = svg.querySelectorAll("g[data-command]"); groups.forEach((g) => { const cmd = g.getAttribute("data-command"); if (!cmd) return; const bbox = g.getBBox(); const x = bbox.x - padding; const y = bbox.y - padding; const w = bbox.width + 2 * padding; const h = bbox.height + 2 * padding; const view = document.createElementNS(SVG_NAMESPACE, "view"); const id = cmd.replace(/\./g, "_"); if (svg.querySelector(`view#${id}`)) { console.warn(`View ID collision for ${id}`); return; } view.setAttribute("id", id); view.setAttribute("viewBox", [x, y, w, h].map(Math.round).join(" ")); svg.appendChild(view); }); /** * Ensures the img tag size matches the viewBox size when using fragment identifiers */ svg.setAttribute("preserveAspectRatio", "xMidYMid meet"); svg.setAttribute("width", "100%"); svg.setAttribute("height", "100%"); }; ================================================ FILE: client/src/app/domToSVG/containers/addOverflowClipPath.ts ================================================ import { includes } from "../../../dashboard/W_SQL/W_SQLBottomBar/W_SQLBottomBar"; import type { WhatToRenderOnSVG } from "../utils/getWhatToRenderOnSVG"; import { isElementNode } from "../utils/isElementVisible"; import type { SVGContext } from "./elementToSVG"; export const addOverflowClipPath = ( element: HTMLElement, style: CSSStyleDeclaration, g: SVGGElement, { x, height, width, y, }: { x: number; y: number; width: number; height: number; }, context: SVGContext, whatToRender: Pick, ) => { /** * If overflow is set to hidden, we need to add a clip path to the group */ if (!mustAddClipPath(element, style)) return; const borderWidth = whatToRender.border?.type === "border" ? whatToRender.border.borderWidth : 0; /** * This is to ensure we don't cut out the rounded parent corners * */ // const parentWithRoundedCorners = element const inputBorderRadius = element instanceof HTMLInputElement ? "8px" : style.borderRadius || "0"; const borderRadiusValue = parseFloat(inputBorderRadius); const roundProperty = borderRadiusValue ? ` round ${borderRadiusValue}px` : ""; const translate = g .getAttribute("transform") ?.split("translate(")[1] ?.split(")")[0]; const [transformX = 0, transformY = 0] = translate ? translate.split(",").map((v) => parseFloat(v.trim())) : [0, 0]; const top = y - borderWidth - transformY; const right = context.width - x - width - borderWidth - transformX; const bottom = context.height - y - height - borderWidth - transformY; const left = x - borderWidth - transformX; const insetValues = [top, right, bottom, left] .map((v) => (v < 0 ? 0 : v)) .map((v) => `${v}px`) .join(" "); g._overflowClipPath = { x, y, width, height, }; g.style.clipPath = `inset(${insetValues} ${roundProperty}) view-box`; }; const mustAddClipPath = (element: HTMLElement, style: CSSStyleDeclaration) => { if ( element instanceof HTMLSelectElement || element instanceof HTMLButtonElement || element instanceof HTMLLabelElement ) { return false; } if ( element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ) { return true; } if (element instanceof HTMLCanvasElement) { return true; } if (!includes(style.overflow, ["hidden", "auto", "scroll", "clip"])) { return false; } if (!element.children.length && element.childNodes.length) { return true; } /** * Ensures the sql tooltip is not overflowing out of the sql editor if (element.className.includes("monaco-scrollable-element")) { debugger; } */ const isRelativeOrAbsolute = includes(style.position, [ "relative", "absolute", ]); const isBoundingChildren = Array.from(element.children).some((child) => { if (!isElementNode(child)) { return false; } const childStyle = getComputedStyle(child); if (childStyle.position === "absolute") return isRelativeOrAbsolute; if (childStyle.position !== "fixed") return true; }); return isBoundingChildren; /* To expensive and not accurate enough */ // return Array.from(element.children).some( // (child) => isElementVisible(child).isVisible, // ); }; ================================================ FILE: client/src/app/domToSVG/containers/bgAndBorderToSVG.ts ================================================ import { SVG_NAMESPACE } from "../domToSVG"; import type { SVGScreenshotNodeType } from "../domToThemeAwareSVG"; import { toFixed } from "../utils/toFixed"; export function hasBorder(style: CSSStyleDeclaration) { return ( style.borderTopWidth !== "0px" || style.borderRightWidth !== "0px" || style.borderBottomWidth !== "0px" || style.borderLeftWidth !== "0px" ); } export const addSpecificBorders = ( g: SVGGElement, x: number, y: number, width: number, height: number, style: CSSStyleDeclaration, ) => { const drawBorder = ( x1: number, y1: number, x2: number, y2: number, color: string, width: number, ) => { const border = document.createElementNS(SVG_NAMESPACE, "line") as Extract< SVGScreenshotNodeType, SVGLineElement >; border._purpose = { border: true }; border.setAttribute("x1", x1); border.setAttribute("y1", y1); border.setAttribute("x2", x2); border.setAttribute("y2", y2); border.setAttribute("stroke", color); border.setAttribute("stroke-width", width); g.appendChild(border); }; const { borderTopWidth, borderRightWidth, borderBottomWidth, borderLeftWidth, borderTopColor, borderRightColor, borderBottomColor, borderLeftColor, } = style; // Top border if (borderTopWidth !== "0px") { const borderWidth = parseFloat(borderTopWidth); drawBorder( x, /** Given that the x,y,w,h refer to the rectangle edges, we need to subtract half width of the overflowing border to match the dimensions */ y + borderWidth / 2, x + width, y + borderWidth / 2, borderTopColor, borderWidth, ); } // Right border if (borderRightWidth !== "0px") { const borderWidth = parseFloat(borderRightWidth); drawBorder( x - borderWidth / 2 + width, y, x - borderWidth / 2 + width, y + height, borderRightColor, borderWidth, ); } // Bottom border if (borderBottomWidth !== "0px") { const borderWidth = parseFloat(borderBottomWidth); drawBorder( x, y - borderWidth / 2 + height, x + width, y - borderWidth / 2 + height, borderBottomColor, borderWidth, ); } // Left border if (borderLeftWidth !== "0px") { const borderWidth = parseFloat(borderLeftWidth); drawBorder( x + borderWidth / 2, y, x + borderWidth / 2, y + height, borderLeftColor, borderWidth, ); } }; export function getBackgroundColor(style: CSSStyleDeclaration) { const { backgroundColor, backgroundImage } = style; if (backgroundImage.includes("gradient")) { console.warn("TODO: handle gradients"); // TODO: handle gradients // return backgroundImage // .split("var(--") // .map((cssVar, index) => { // if (!index) return cssVar; // const [varName, ...endParts] = cssVar.split(")"); // const value = getComputedStyle(document.documentElement) // .getPropertyValue("--" + varName) // .trim(); // return value + endParts.join(")"); // }) // .join(""); } if (backgroundColor.startsWith("rgba") && backgroundColor.endsWith("0)")) { return undefined; } if (backgroundColor === "transparent") { return undefined; } return backgroundColor; } export const getBackdropFilter = (style: CSSStyleDeclaration) => { return style.backdropFilter && style.backdropFilter !== "none" ? style.backdropFilter : undefined; }; /** * Build a "d" attribute for a rectangle with arbitrary corner radii. * Accounts for border width and ensures crisp edges by using whole numbers * and half-pixel offsets when needed. */ export const roundedRectPath = ( x: number, y: number, w: number, h: number, [rtl, rtr, rbr, rbl]: [number, number, number, number], ) => { /* Clamp radii so that they never overlap (same thing the browser does) */ const sumH = rtl + rtr; const sumH2 = rbl + rbr; const sumV = rtl + rbl; const sumV2 = rtr + rbr; if (sumH > w) { const scale = w / sumH; rtl *= scale; rtr *= scale; } if (sumH2 > w) { const scale = w / sumH2; rbl *= scale; rbr *= scale; } if (sumV > h) { const scale = h / sumV; rtl *= scale; rbl *= scale; } if (sumV2 > h) { const scale = h / sumV2; rtr *= scale; rbr *= scale; } /* Path – clockwise, starting in the top-left corner */ return [ `M${toFixed(x + rtl)},${toFixed(y)}`, // start `H${toFixed(x + w - rtr)}`, // top rtr ? `A${toFixed(rtr)},${toFixed(rtr)} 0 0 1 ${toFixed(x + w)},${toFixed(y + rtr)}` : "", `V${toFixed(y + h - rbr)}`, // right rbr ? `A${toFixed(rbr)},${toFixed(rbr)} 0 0 1 ${toFixed(x + w - rbr)},${toFixed(y + h)}` : "", `H${toFixed(x + rbl)}`, // bottom rbl ? `A${toFixed(rbl)},${toFixed(rbl)} 0 0 1 ${toFixed(x)},${toFixed(y + h - rbl)}` : "", `V${toFixed(y + rtl)}`, // left rtl ? `A${toFixed(rtl)},${toFixed(rtl)} 0 0 1 ${toFixed(x + rtl)},${toFixed(y)}` : "", "Z", ] .filter(Boolean) .join(" "); }; ================================================ FILE: client/src/app/domToSVG/containers/deduplicateSVGPaths.ts ================================================ export const deduplicateSVGPaths = (svgElement: SVGElement) => { // Get or create defs element let defs = svgElement.querySelector("defs"); if (!defs) { defs = document.createElementNS("http://www.w3.org/2000/svg", "defs"); svgElement.insertBefore(defs, svgElement.firstChild); } // Map to track identical paths: key = path signature, value = { id, count } const pathMap = new Map(); // Find all path elements (excluding those already in defs) const paths = Array.from( svgElement.querySelectorAll("path:not(defs path)"), ); paths.forEach((path) => { // Create a signature from the path's key attributes const signature = createPathSignature(path); if (pathMap.has(signature)) { // Duplicate found - replace with const { id } = pathMap.get(signature); replaceWithUse(path, id); } else { // First occurrence - move to defs if beneficial const id = `path-${pathMap.size}`; pathMap.set(signature, { id, element: path }); } }); // Move paths that appear multiple times to defs let deduplicatedCount = 0; pathMap.forEach(({ id, element }, signature) => { // Count how many times this signature appears const occurrences = paths.filter( (p) => createPathSignature(p) === signature, ).length; if (occurrences > 1) { moveToDefsAndReplaceAll(element, id, signature, paths, defs); deduplicatedCount++; } }); return { deduplicatedCount, totalPaths: paths.length, }; }; const createPathSignature = (path: SVGPathElement) => { // Create a unique signature based on path data and styling attributes // Exclude transform and position attributes const d = path.getAttribute("d") || ""; // const fill = path.getAttribute("fill") || ""; // const stroke = path.getAttribute("stroke") || ""; // const strokeWidth = path.getAttribute("stroke-width") || ""; // const fillRule = path.getAttribute("fill-rule") || ""; // const strokeLinecap = path.getAttribute("stroke-linecap") || ""; // const strokeLinejoin = path.getAttribute("stroke-linejoin") || ""; // const opacity = path.getAttribute("opacity") || ""; // const fillOpacity = path.getAttribute("fill-opacity") || ""; // const strokeOpacity = path.getAttribute("stroke-opacity") || ""; return JSON.stringify({ d, // fill, // stroke, // strokeWidth, // fillRule, // strokeLinecap, // strokeLinejoin, // opacity, // fillOpacity, // strokeOpacity, }); }; const moveToDefsAndReplaceAll = ( originalPath: SVGPathElement, id, signature: string, allPaths: SVGPathElement[], defs: SVGDefsElement, ) => { // Clone the path for defs (without transform) const defPath = originalPath.cloneNode(true) as SVGPathElement; defPath.setAttribute("id", id); defPath.removeAttribute("transform"); defs.appendChild(defPath); // Replace all matching paths with elements allPaths.forEach((path) => { if (createPathSignature(path) === signature) { replaceWithUse(path, id); } }); }; const replaceWithUse = (path: SVGPathElement, refId: string) => { const use = document.createElementNS("http://www.w3.org/2000/svg", "use"); use.setAttributeNS("http://www.w3.org/1999/xlink", "xlink:href", `#${refId}`); use.setAttribute("href", `#${refId}`); // Preserve transform and position attributes const preserveAttrs = ["transform", "x", "y", "class", "id", "data-*"]; preserveAttrs.forEach((attr) => { if (path.hasAttribute(attr)) { use.setAttribute(attr, path.getAttribute(attr)); } }); // Copy data attributes Array.from(path.attributes).forEach((attr) => { if (attr.name.startsWith("data-")) { use.setAttribute(attr.name, attr.value); } }); const parent = path.parentNode; if (!parent) { return; // throw new Error("Path element has no parent node."); } parent.replaceChild(use, path); }; ================================================ FILE: client/src/app/domToSVG/containers/elementToSVG.ts ================================================ import { getEntries } from "@common/utils"; import { drawShapesOnSVG } from "../../../dashboard/Charts/drawShapes/drawShapesOnSVG"; import { SVG_NAMESPACE } from "../domToSVG"; import { getBBoxCode, type SVGScreenshotNodeType } from "../domToThemeAwareSVG"; import { fontIconToSVG } from "../graphics/fontIconToSVG"; import { addImageFromDataURL, imgToSVG } from "../graphics/imgToSVG"; import { textToSVG } from "../text/textToSVG"; import { canvasToDataURL } from "../utils/canvasToDataURL"; import { getAnimationsHandler } from "../utils/copyAnimationStylesToSvg"; import { getWhatToRenderOnSVG } from "../utils/getWhatToRenderOnSVG"; import { isElementNode } from "../utils/isElementVisible"; import { toFixed } from "../utils/toFixed"; import { addOverflowClipPath } from "./addOverflowClipPath"; import { rectangleToSVG } from "./rectangleToSVG"; export type SVGContext = { docId: string; offsetX: number; offsetY: number; defs: SVGDefsElement; idCounter: number; fontFamilies: string[]; cssDeclarations: Map; width: number; height: number; }; export type SVGNodeLayout = { x: number; y: number; width: number; height: number; style: CSSStyleDeclaration; }; export const elementToSVG = async ( element: HTMLElement, parentSvg: SVGElement | SVGGElement, context: SVGContext, ) => { /** Ensures bbox calculations are stable */ const copyAnimations = getAnimationsHandler(element); const _whatToRender = await getWhatToRenderOnSVG(element, context, parentSvg); const { elemInfo, ...whatToRender } = _whatToRender; const { x, y, width, height, style, isVisible } = elemInfo; if (!isVisible && !_whatToRender.mightBeHovered) { return whatToRender; } const g = document.createElementNS(SVG_NAMESPACE, "g"); g._domElement = element; g._whatToRender = _whatToRender; g.setAttribute( "data-selector", [element.nodeName.toLowerCase(), element.className].join("."), ); const roundedPosition = { x: Math.round(x), y: Math.round(y), width: Math.round(width), height: Math.round(height), }; const bboxCode = getBBoxCode(element, roundedPosition); (g as SVGScreenshotNodeType)._bboxCode = bboxCode; getEntries({ ...whatToRender.attributeData, }).forEach(([key, value]) => { if (value) { g.setAttribute(key, value); } }); getEntries({ ...whatToRender.childAffectingStyles, }).forEach(([key, value]) => { if (value && key === "opacity") { g.style[key] = value; } }); const rectElem = rectangleToSVG( g, element, style, elemInfo, whatToRender, bboxCode, ); if (whatToRender.text?.length) { whatToRender.text.forEach((textForSVG) => { textToSVG(element, g, textForSVG, style, bboxCode); }); } if (element instanceof HTMLCanvasElement) { if (element._drawn?.shapes.length) { const { shapes, scale, translate } = element._drawn; const transformedG = document.createElementNS(SVG_NAMESPACE, "g"); g.setAttribute("transform", `translate(${x}, ${y})`); g.appendChild(transformedG); drawShapesOnSVG( shapes, context, transformedG, { scale, translate, }, { width, height, }, ); } else { element._deckgl?.redraw("screenshot"); const canvas = element._deckgl?.getCanvas() || element; const dataURL = canvasToDataURL(canvas); addImageFromDataURL(g, dataURL, context, elemInfo); } } if (whatToRender.image?.type === "foreignObject") { parentSvg.appendChild(whatToRender.image.foreignObject); (whatToRender.image.foreignObject as SVGScreenshotNodeType)._bboxCode = bboxCode; return; } const { image } = whatToRender; if (image?.type === "svgElement") { const width = image.element instanceof HTMLImageElement ? image.element.width : element.clientWidth; const height = image.element instanceof HTMLImageElement ? image.element.height : element.clientHeight; const gWrapper = document.createElementNS(SVG_NAMESPACE, "g"); parentSvg.appendChild(gWrapper); const svgClone = image.element.cloneNode(true) as SVGElement; svgClone.setAttribute("width", `${toFixed(width)}`); svgClone.setAttribute("height", `${toFixed(height)}`); gWrapper.style.transform = `translate(${toFixed(x)}px, ${toFixed(y)}px)`; gWrapper.style.color = style.color; gWrapper._gWrapperFor = element; gWrapper.appendChild(svgClone); } else if (image?.type === "fontIcon") { await fontIconToSVG(g, image, context, elemInfo); } else if (image?.type === "img") { await imgToSVG(g, image.element, elemInfo, context); } else if (image?.type === "maskedElement") { const { width, height, x, y } = element.getBoundingClientRect(); const dataUrl = decodeURIComponent( style.maskImage.split(",")[1]!.slice(0, -2), ); const parser = new DOMParser(); const svgDoc = parser.parseFromString(dataUrl, "image/svg+xml"); const svgElement = svgDoc.documentElement; svgElement.setAttribute("width", `${toFixed(width)}`); svgElement.setAttribute("height", `${toFixed(height)}`); svgElement.setAttribute("x", toFixed(x)); svgElement.setAttribute("y", toFixed(y)); svgElement.setAttribute("fill", style.color); const wrapperG = document.createElementNS(SVG_NAMESPACE, "g"); wrapperG.appendChild(svgElement); /** wrapperG is required to ensure animations work on safari */ if (style.animation) { wrapperG.setAttribute( "style", `animation: ${style.animation}; transform-origin: ${toFixed(x + width / 2)}px ${toFixed(y + height / 2)}px;`, ); } copyAnimations?.(style, wrapperG, context.cssDeclarations, false); parentSvg.appendChild(wrapperG); } if (image?.type !== "maskedElement") { copyAnimations?.(style, rectElem?.path ?? g, context.cssDeclarations, true); } for (const child of getChildrenSortedByZIndex(element)) { if (isElementNode(child)) { await elementToSVG(child, g, context); } } /** Must ensure we have a bbox for clicking interaction placement */ if (!g.childNodes.length && whatToRender.attributeData) { const bboxRect = document.createElementNS(SVG_NAMESPACE, "rect"); bboxRect.setAttribute("x", toFixed(x)); bboxRect.setAttribute("y", toFixed(y)); bboxRect.setAttribute("width", toFixed(width)); bboxRect.setAttribute("height", toFixed(height)); bboxRect.setAttribute("fill", "transparent"); g.appendChild(bboxRect); } if (g.childNodes.length) { addOverflowClipPath( element, style, g, { x, y, width, height }, context, whatToRender, ); parentSvg.appendChild(g); } return whatToRender; }; const getChildrenSortedByZIndex = (element: HTMLElement): HTMLElement[] => { const children = Array.from(element.children) as HTMLElement[]; return children.slice(0).sort((a, b) => { const aZIndex = parseInt(getComputedStyle(a).zIndex) || 0; const bZIndex = parseInt(getComputedStyle(b).zIndex) || 0; return aZIndex - bZIndex; }); }; ================================================ FILE: client/src/app/domToSVG/containers/rectangleToSVG.ts ================================================ import { fromEntries, getEntries } from "@common/utils"; import { SVG_NAMESPACE } from "../domToSVG"; import type { SVGScreenshotNodeType } from "../domToThemeAwareSVG"; import type { getWhatToRenderOnSVG } from "../utils/getWhatToRenderOnSVG"; import { addSpecificBorders, roundedRectPath } from "./bgAndBorderToSVG"; import type { SVGNodeLayout } from "./elementToSVG"; import { getBoxShadowAsDropShadow } from "./shadowToSVG"; export const BORDER_ELEMENT_TYPES = ["rect", "path", "line"] as const; export const rectangleToSVG = ( g: SVGGElement, element: HTMLElement, style: CSSStyleDeclaration, { x, y, width, height }: Pick, { border, background, backdropFilter, }: Pick< Awaited>, "background" | "border" | "backdropFilter" >, bboxCode: string, ) => { const shadow = getBoxShadowAsDropShadow(style); const scrollMask = style.backdropFilter && style.mask && style.mask.includes("linear-gradient"); if (!border && !background && !shadow && !scrollMask && !backdropFilter) { return; } let _path: ReturnType | undefined; const getPath = () => { if (_path) return _path; const entries = getEntries({ border, background, shadow, scrollMask, backdropFilter, } as const); const _purpose = fromEntries(entries.map(([k, v]) => [k, v])); const { path, showBorder, rtl, rtr, rbr, rbl, borderWidth } = getRectanglePath(style, { x, y, width, height }, { border }); path._domElement = element; path._bboxCode = bboxCode; path._purpose = _purpose; /** This is required to make backgroundSameAsRenderedParent work as expected */ path.setAttribute("fill", "none"); g.appendChild(path); _path = { path, showBorder, rtl, rtr, rbr, rbl, borderWidth }; return _path; }; const maskLinearGradients = style.maskImage.split("linear-gradient("); const blendModes = style.maskComposite.split(", "); if ( maskLinearGradients.length > 1 && blendModes.every((b) => b === "source-in") ) { const masks = maskLinearGradients.slice(1); masks.forEach((grad, index) => { if (index) { // Mask combining does not work return; } g.style.maskImage = style.maskImage; g.style.maskSize = `${width}px ${height}px`; g.style.maskPosition = `${x}px ${0}px`; g.style.maskPosition = `0px 0px`; }); } if (background) { getPath().path.setAttribute("fill", background); } if (style.animation) { getPath().path.style.animation = style.animation; } // TODO: shadow and border must be drawn outside the overflow clip path if (shadow) { getPath().path.style.filter = shadow.filter; } if (border) { const { outline } = border; if (outline) { // TODO: outline must be drawn outside the overflow clip path const outlineNode = getPath().path.cloneNode( true, ) as SVGScreenshotNodeType; outlineNode.setAttribute("fill", "none"); outlineNode.setAttribute("stroke-width", outline.borderWidth + "px"); outlineNode.setAttribute("stroke", outline.borderColor); outlineNode.setAttribute("stroke-linejoin", "round"); outlineNode.setAttribute("stroke-linecap", "round"); g.appendChild(outlineNode); // const { path, rtl, rtr, rbr, rbl, showBorder, borderWidth } = getPath(); // if (path instanceof SVGRectElement) { // throw new Error("Outline not supported for rect element"); // } // path.setAttribute( // "d", // roundedRectPath( // /** This is to ensure the new-connection connection type radio buttons are aligned */ // x - outline.borderWidth / 2 + (!showBorder ? borderWidth : 0), // y - outline.borderWidth / 2 + (!showBorder ? borderWidth : 0), // width + outline.borderWidth, // height + outline.borderWidth, // [rtl, rtr, rbr, rbl], // ), // ); } if (border.type === "border") { getPath().path.setAttribute("stroke-width", border.borderWidth + "px"); getPath().path.setAttribute("stroke", border.borderColor); } else if (border.type === "borders") { addSpecificBorders(g, x, y, width, height, style); } } return _path; }; const getRectanglePath = ( style: CSSStyleDeclaration, { x, y, width, height }: Pick, { border }: Pick>, "border">, ) => { const minDimension = Math.min(width, height); const [rtl = 0, rtr = 0, rbr = 0, rbl = 0] = [ style.borderTopLeftRadius, style.borderTopRightRadius, style.borderBottomRightRadius, style.borderBottomLeftRadius, ].map((r) => { const radiusNumber = parseFloat(r); if (r.includes("%")) { const clampedPercentage = Math.min(50, radiusNumber); return (clampedPercentage / 100) * minDimension; } return radiusNumber; }); const showBorder = border?.type == "border" && border.borderColor !== "rgba(0, 0, 0, 0)"; const borderWidth = parseFloat(style.borderWidth); const visibleBorderWidth = showBorder ? borderWidth : 0; const adjusted = { x: x + visibleBorderWidth / 2, y: y + visibleBorderWidth / 2, width: width - visibleBorderWidth, height: height - visibleBorderWidth, }; /** Use recangle if possible */ const hasSingleRadius = new Set([rtl, rtr, rbr, rbl]).size === 1; const hasConstantBorder = !border || border.type === "border"; if (hasSingleRadius && hasConstantBorder) { const rect = document.createElementNS(SVG_NAMESPACE, "rect") as Extract< SVGScreenshotNodeType, SVGRectElement >; rect.setAttribute("x", adjusted.x); rect.setAttribute("y", adjusted.y); rect.setAttribute("width", adjusted.width); rect.setAttribute("height", adjusted.height); rect.setAttribute("rx", rtl); rect.setAttribute("ry", rtl); return { path: rect, showBorder, rtl, rtr, rbr, rbl, borderWidth }; } const path = document.createElementNS(SVG_NAMESPACE, "path") as Extract< SVGScreenshotNodeType, SVGPathElement >; path.setAttribute( "d", roundedRectPath( /** This is to ensure the new-connection connection type radio buttons are aligned */ adjusted.x, adjusted.y, adjusted.width, adjusted.height, [rtl, rtr, rbr, rbl], ), ); path satisfies SVGElementTagNameMap[(typeof BORDER_ELEMENT_TYPES)[number]]; return { path, showBorder, rtl, rtr, rbr, rbl, borderWidth }; }; ================================================ FILE: client/src/app/domToSVG/containers/shadowToSVG.ts ================================================ import { isDefined } from "src/utils/utils"; /** * 'rgba(255, 0, 0, 0.21) 0px 1px 2px 0px' * or * 'rgba(255, 0, 0) 0px 1px 2px 0px' */ export const getBoxShadowAsDropShadow = (style: CSSStyleDeclaration) => { if (!style.boxShadow || style.boxShadow === "none") return; const boxShadows = style.boxShadow .split("rgb") .filter((v) => v) .map((v) => "rgb" + v.trim()) .map((v) => (v.endsWith(",") ? v.slice(0, -1) : v)); const parsedBoxShadows = boxShadows .map((boxShadow) => { const [colorPart, offsetParts = ""] = boxShadow.split(")"); const color = colorPart + ")"; const offsets = offsetParts .split(" ") .map((v) => v.trim()) .filter((v) => v); const offsetValues = offsets.map((v) => parseFloat(v)); if (offsetValues.slice(0, 3).filter((v) => v === 0).length >= 3) { return; } return { color, offsets, offsetValues, }; }) .filter(isDefined); const filterParts = parsedBoxShadows.slice().map( ({ color, offsets }) => `drop-shadow(${offsets .slice(0, 3) .map((part, index) => { if (index === 2 && part.endsWith("px")) { // reduce blur radius due to SVG rendering differences return `${(parseFloat(part) * 0.5).toFixed(1)}px`; } return part; }) .join(" ")} ${color})`, ); const filter = filterParts.join(" "); return { filter, filterParts }; }; ================================================ FILE: client/src/app/domToSVG/domToSVG.ts ================================================ import { includes } from "prostgles-types"; import { tout } from "src/utils/utils"; import { elementToSVG, type SVGContext } from "./containers/elementToSVG"; import type { SVGScreenshotNodeType } from "./domToThemeAwareSVG"; import { renderSvg, wrapAllSVGText } from "./text/textToSVG"; import { BORDER_ELEMENT_TYPES } from "./containers/rectangleToSVG"; export const SVG_NAMESPACE = "http://www.w3.org/2000/svg"; export const domToSVG = async (node: HTMLElement) => { const svg = document.createElementNS(SVG_NAMESPACE, "svg"); const cssDeclarations = new Map(); // Get dimensions and position const nodeBBox = node.getBoundingClientRect(); svg.setAttribute("width", nodeBBox.width.toString()); svg.setAttribute("height", nodeBBox.height.toString()); svg.setAttribute("viewBox", `0 0 ${nodeBBox.width} ${nodeBBox.height}`); // Create defs section for gradients, patterns, etc. const defs = document.createElementNS(SVG_NAMESPACE, "defs"); svg.appendChild(defs); const rootId = "id-" + crypto.randomUUID().split("-")[0]; const context: SVGContext = { docId: rootId, offsetX: -nodeBBox.left, offsetY: -nodeBBox.top, width: nodeBBox.width, height: nodeBBox.height, defs: defs, idCounter: 0, cssDeclarations, fontFamilies: [], }; await elementToSVG(node, svg, context); const style = document.createElementNS(SVG_NAMESPACE, "style"); style.setAttribute("type", "text/css"); defs.appendChild(style); style.textContent = Array.from(cssDeclarations.entries()) .map(([_selector, declaration]) => declaration) .join("\n"); setBackdropFilters(svg); const { remove } = renderSvg(svg); wrapAllSVGText(svg); /** Add textLength to prevent bugs in ios (It uses a different font which is wider and overflows the existing rects and clip paths) */ svg.querySelectorAll("text,tspan").forEach((_text) => { const text = _text as SVGTextElement | SVGTSpanElement; const isMultiLine = text.textContent.includes("\n"); if ( isMultiLine || /** Has tspans that we'll handle separately */ (text instanceof SVGTextElement && text.children.length) ) { return; } const bbox = text.getBoundingClientRect(); const ctm = text.getCTM(); const scaleX = !ctm ? 1 : Math.hypot(ctm.a, ctm.c); text.setAttribute("textLength", bbox.width / scaleX); text.setAttribute("lengthAdjust", "spacingAndGlyphs"); }); /** Does not really seem effective */ // deduplicateSVGPaths(svg); // await addFragmentViewBoxes(svg, 10); repositionAbsoluteFixedAndSticky(svg); moveBordersToTop(svg); removeOverflowedElements(svg); repositionMasks(svg); remove(); await tout(100); const xmlSerializer = new XMLSerializer(); const svgString = xmlSerializer.serializeToString(svg); const [firstG, otherChild] = Array.from(svg.children).filter( (c) => c instanceof SVGGElement, ); if (!firstG) { throw new Error("No SVG content generated"); } if (otherChild) { throw new Error("Unexpected SVG structure - multiple root elements"); } firstG.setAttribute("id", rootId); return { svgString, svg, rootId }; }; /** * In divs the mask positioning is relative to the div, but in SVG it's relative to the SVG canvas */ const repositionMasks = (svg: SVGGElement) => { const gElements = svg.querySelectorAll("g"); gElements.forEach((g) => { const { elemInfo } = g._whatToRender ?? {}; const maskImage = g.style.maskImage; if (maskImage && elemInfo) { const { x, y, width, height } = elemInfo; const gBBox = g.getBoundingClientRect(); const offsetX = x - gBBox.left; const offsetY = y - gBBox.top; g.style.maskSize = `${width}px ${height}px`; g.style.maskPosition = `${offsetX}px ${offsetY}px`; } }); }; const removeOverflowedElements = (svg: SVGGElement) => { svg.querySelectorAll("g").forEach((g) => { if (g._overflowClipPath) { const { x, y, width, height } = g._overflowClipPath; const clipXMin = x; const clipYMin = y; const clipXMax = x + width; const clipYMax = y + height; if (g.querySelector(`[style*=animation]`)) { return; } g.childNodes.forEach((child) => { /** Ignore animated elements */ if (child instanceof SVGGElement || child instanceof SVGTextElement) { const elBBox = child.getBoundingClientRect(); const cXMin = elBBox.x; const cYMin = elBBox.y; const cXMax = elBBox.x + elBBox.width; const cYMax = elBBox.y + elBBox.height; const bboxesOverlap = clipXMin < cXMax && clipXMax > cXMin && clipYMin < cYMax && clipYMax > cYMin; if (!bboxesOverlap) { child.remove(); } } }); } }); }; /** * Hacky (because bg+border case is not handled) approach to ensure row card foreign key select fields rounded border corners are visible */ const moveBordersToTop = (svg: SVGGElement) => { svg .querySelectorAll(BORDER_ELEMENT_TYPES.join(",")) .forEach((path) => { if ( path._purpose?.border && !path._purpose.background && path.parentElement instanceof SVGGElement ) { path.parentElement.appendChild(path); } }); }; const repositionAbsoluteFixedAndSticky = (svg: SVGGElement) => { const [gBody, ...other] = Array.from( svg.querySelectorAll(":scope > g"), ); if (!gBody || other.length || gBody._domElement !== document.body) { console.error("Unexpected SVG structure", { svg, gBody, other }); throw new Error("Unexpected SVG structure"); } const gElements = Array.from(svg.querySelectorAll("g")); gElements.forEach((g) => { const style = g._domElement && getComputedStyle(g._domElement); if (style?.position === "fixed") { gBody.appendChild(g); } if (style?.position === "absolute") { const closestParent = getClosestRelativeOrAbsoluteParent(g) || gBody; const closestParentOrGBody = gBody.contains(closestParent) ? closestParent : gBody; closestParentOrGBody.appendChild(g); } /** Move sticky to end */ if ( style?.position === "sticky" && g.parentElement instanceof SVGGElement ) { g.parentElement.appendChild(g); } }); }; const getClosestRelativeOrAbsoluteParent = (g: SVGGElement) => { let parentG = g.parentElement; while (parentG && parentG instanceof SVGGElement && parentG._domElement) { const position = getComputedStyle(parentG._domElement).position; if ( includes(["relative", "absolute"] as const, position) && parentG._domElement !== g._domElement ) { return parentG; } parentG = parentG.parentElement; } }; declare global { interface Element { setAttribute(name: string, value: any): void; } } const setBackdropFilters = (svg: SVGGElement) => { const gElements = svg.querySelectorAll("g"); gElements.forEach((g) => { const prevContent = g.parentElement?.parentElement?.previousSibling; if ( g._whatToRender?.backdropFilter && prevContent && prevContent instanceof SVGGElement && prevContent._whatToRender?.elemInfo ) { const { width, height } = g._whatToRender.elemInfo; const pBBox = prevContent._whatToRender.elemInfo; // If not fully covering it then ignore it if (width > pBBox.width * 0.8 && height > 0.8 * pBBox.height) { prevContent.style.filter = g._whatToRender.backdropFilter; } } }); }; ================================================ FILE: client/src/app/domToSVG/domToThemeAwareSVG.ts ================================================ import { hashCode } from "src/utils/hashCode"; import { isDefined } from "src/utils/utils"; import { domToSVG } from "./domToSVG"; import { getCorrespondingDarkNode } from "./getCorrespondingDarkNode"; import { setThemeForSVGScreenshot } from "./setThemeForSVGScreenshot"; import type { TextForSVG } from "./text/getTextForSVG"; import { renderSvg } from "./text/textToSVG"; import type { getWhatToRenderOnSVG } from "./utils/getWhatToRenderOnSVG"; export const displayNoneIfDark = "--dark-theme-hide"; export const displayNoneIfLight = "--light-theme-hide"; export const domToThemeAwareSVG = async ( node: HTMLElement, themes?: "current" | "both", ) => { const { svg: svgLight, rootId: svgLightRootId } = await domToSVG(node); if (themes === "current") { renderSvg(svgLight); return; } svgLight.parentElement?.removeChild(svgLight); await setThemeForSVGScreenshot("dark"); const { svg: svgDark } = await domToSVG(node); svgDark.parentElement?.removeChild(svgDark); document.body.appendChild(svgDark); document.body.appendChild(svgLight); let varId = 0; type CSSProperty = "color" | "shadow" | "opacity" | "fontFamily" | "href"; const getUniqueColorVarName = ( property: CSSProperty, value: string, darkValue: string, ): string => { /** This allows us to deduplicate g elements that have the same outerHTML */ let varName = `${(value + "-" + darkValue).replace(/[^a-zA-Z0-9]/g, "")}`; // If bigger than a hash then use hash if (varName.length > 40) { varName = `${property}-${hashCode(varName)}`; } while (lightToDarkMap.get(value)?.some((c) => c.varName === varName)) { varName = `${property}-${varId++}`; } return varName; }; const lightToDarkMap = new Map< string, { darkValue: string; varName: string }[] >(); const upsertCssVar = ( property: CSSProperty, value: string, darkValue: string, ): string => { const existingGroup = lightToDarkMap.get(value) ?? []; const existing = existingGroup.find((c) => c.darkValue === darkValue); if (existing) { return existing.varName; } const varName = getUniqueColorVarName(property, value, darkValue); lightToDarkMap.set(value, [ ...existingGroup, { darkValue: darkValue, varName }, ]); return varName; }; const isVisibleColor = (color: string | null) => { const isVisible = color && color !== "none" && color !== "currentColor" && color !== "rgba(0, 0, 0, 0)" && color !== "transparent"; return isVisible; }; const selector = "line, path, text, foreignObject, g, use, rect"; const lightNodes = svgLight.querySelectorAll(selector); const darkNodes = svgDark.querySelectorAll(selector); if (!svgDark.isConnected || !svgLight.isConnected) { throw new Error( "SVGs must be connected to the DOM to ensure bbox calculations work.", ); } const matchesMap: Map< SVGScreenshotNodeType, SVGScreenshotNodeType | undefined > = new Map(); const matches = Array.from(lightNodes) .map((lightNode, index) => { // Ignore nested svgs if (lightNode.ownerSVGElement !== svgLight) { return; } const darkNode = getCorrespondingDarkNode(darkNodes, lightNode, index); matchesMap.set(lightNode, darkNode); return { lightNode, darkNode }; }) .filter(isDefined); matches.forEach(({ lightNode, darkNode }) => { if (lightNode instanceof SVGUseElement) { const lightHref = lightNode.getAttribute("href"); const darkHref = darkNode?.getAttribute("href"); if (lightHref && darkHref) { const darkRefImgSymbol = svgDark.querySelector(darkHref); const lightRefImgSymbol = svgLight.querySelector(lightHref); const darkRefImgData = darkRefImgSymbol?.querySelector("image")?.href.baseVal; const lightRefImgData = lightRefImgSymbol?.querySelector("image")?.href.baseVal; if ( darkRefImgData && lightRefImgData && darkRefImgData !== lightRefImgData ) { /** Add dark image into light svg */ const darkImageSymbolClone = darkRefImgSymbol.cloneNode( true, ) as SVGImageElement; darkImageSymbolClone.id = `${darkImageSymbolClone.id}-dark`; lightNode.ownerSVGElement ?.querySelector("defs") ?.appendChild(darkImageSymbolClone); const darkThemeUse = lightNode.cloneNode(true) as SVGUseElement; darkThemeUse.setAttribute("href", `#${darkImageSymbolClone.id}`); darkThemeUse.style.opacity = `var(${displayNoneIfLight})`; lightNode.parentElement?.appendChild(darkThemeUse); lightNode.style.opacity = `var(${displayNoneIfDark})`; } } return; } if (!darkNode) { console.warn( "No corresponding dark node found for light node " + lightNode.nodeName, lightNode._bboxCode, ); // if ( // lightNode instanceof SVGTextElement // // && // // lightNode.textContent?.includes("11:4") // ) { // console.log(lightNodes, darkNodes); // debugger; // } return; } /** Add extra elements from dark node (sometimes the background changes from transparent to color on match case button) */ // if (lightNode instanceof SVGGElement && darkNode instanceof SVGGElement) { // addNewChildren(lightNode, darkNode, matchesMap); // } const fill = lightNode.getAttribute("fill"); const darkFill = darkNode.getAttribute("fill"); if (fill !== darkFill) { const varName = upsertCssVar("color", fill || "", darkFill || fill || ""); lightNode.setAttribute("fill", `var(--${varName})`); } const stroke = lightNode.getAttribute("stroke"); const darkStroke = darkNode.getAttribute("stroke"); if (stroke !== darkStroke) { const varName = upsertCssVar( "color", stroke || "", darkStroke || stroke || "", ); lightNode.setAttribute("stroke", `var(--${varName})`); } const color = lightNode.style.color; if (color) { const darkColor = darkNode.style.color; const varName = upsertCssVar("color", color, darkColor || color); lightNode.style.color = `var(--${varName})`; } const opacity = lightNode.style.opacity; if (!opacity || opacity !== "1") { const darkOpacity = darkNode.style.opacity; if (darkOpacity !== opacity) { const varName = upsertCssVar( "opacity", opacity, darkOpacity || opacity, ); lightNode.style.opacity = `var(--${varName})`; } } /** Just to save space */ if (lightNode instanceof SVGTextElement) { const fontFamily = lightNode.getAttribute("font-family"); if (fontFamily && fontFamily.length > 12) { const darkFontFamily = darkNode.getAttribute("font-family"); const varName = upsertCssVar( "fontFamily", fontFamily, darkFontFamily || fontFamily, ); lightNode.setAttribute("font-family", `var(--${varName})`); } } const filter = lightNode.style.filter; const darkFilter = darkNode.style.filter; if (darkFilter !== filter) { const varName = upsertCssVar("shadow", filter, darkFilter || filter); lightNode.style.filter = `var(--${varName})`; } if (lightNode instanceof SVGForeignObjectElement) { const color = lightNode.style.color; if (color && isVisibleColor(color)) { const darkColor = darkNode.style.color; const varName = upsertCssVar("color", color, darkColor || color); lightNode.style.color = `var(--${varName})`; } } }); const colorArr = Array.from(lightToDarkMap.entries()).flatMap( ([lightColor, darkItems]) => darkItems.map(({ darkValue: darkColor, varName }) => ({ lightColor, darkColor, sameForBoth: lightColor === darkColor, varName, })), ); const cssSheet = document.createElement("style"); cssSheet.setAttribute("type", "text/css"); svgLight.appendChild(cssSheet); cssSheet.textContent = [ `:root #${svgLightRootId} { `, ` ${displayNoneIfDark}: 1;`, ` ${displayNoneIfLight}: 0;`, ...colorArr.map( ({ varName, lightColor }) => ` --${varName}: ${lightColor}; `, ), `}\n`, ].join("\n"); cssSheet.textContent += [ `@media (prefers-color-scheme: dark) { `, ` :root #${svgLightRootId} { `, ` ${displayNoneIfDark}: 0;`, ` ${displayNoneIfLight}: 1;`, ...colorArr .filter((c) => !c.sameForBoth) .map(({ varName, darkColor }) => ` --${varName}: ${darkColor}; `), ` }`, `} \n`, ].join("\n"); const xmlSerializer = new XMLSerializer(); const svgString = xmlSerializer.serializeToString(svgLight); document.body.removeChild(svgDark); await setThemeForSVGScreenshot(undefined); document.body.removeChild(svgLight); if (themes === "both") { renderSvg(svgLight); return; } return { light: svgString, dark: xmlSerializer.serializeToString(svgDark), }; }; document.body.addEventListener("keydown", (e) => { if (e.key === "F2") { void domToThemeAwareSVG(document.body, "current"); } else if (e.key === "F4") { void domToThemeAwareSVG(document.body, "both"); } else if (e.key === "F6") { // eslint-disable-next-line no-debugger debugger; } }); /** Interleave data */ export const getBBoxCode = ( element: HTMLElement, { x, y, width, height, }: { x: number; y: number; width: number; height: number; }, ) => { return `${x}-${y}-${width}-${height}__${element.nodeName}${getElementPath(element).join("-")}`; }; declare global { interface SVGGElement { _gWrapperFor?: HTMLElement; _overflowClipPath?: { x: number; y: number; width: number; height: number; }; _domElement?: HTMLElement; _whatToRender?: Awaited>; } } export type SVGScreenshotNodeType = ( | SVGPathElement | SVGTextElement | SVGRectElement | SVGLineElement | SVGForeignObjectElement ) & { _bboxCode?: string; _purpose?: Partial< Record< "border" | "background" | "shadow" | "scrollMask" | "backdropFilter", any > >; _bbox?: DOMRect; _gWrapperFor?: HTMLElement; _domElement?: HTMLElement; _domElementId?: string; _domElementPath?: number[]; _domElementPathString?: string; _textInfo?: TextForSVG; }; const getElementPath = (element: HTMLElement) => { const path: number[] = []; let current: HTMLElement | ParentNode | null = element; while (current && current !== document.body) { const index = Array.from(current.parentNode?.children ?? []).indexOf( // @ts-ignore current, ); path.unshift(index); current = current.parentNode; } return path; }; ================================================ FILE: client/src/app/domToSVG/getCorrespondingDarkNode.ts ================================================ import type { SVGScreenshotNodeType } from "./domToThemeAwareSVG"; export const getCorrespondingDarkNode = ( darkNodes: NodeListOf, lightNode: SVGScreenshotNodeType, index: number, ): SVGScreenshotNodeType | undefined => { let darkNode = darkNodes[index]; const darkNodesArr = Array.from(darkNodes); const matchesTypes = darkNodesArr.filter( (n) => n.nodeName === lightNode.nodeName, ); darkNode = matchesTypes.find( (n) => lightNode._bboxCode && n._bboxCode === lightNode._bboxCode, ); if (darkNode) return darkNode; const lightBBox = lightNode.getBBox(); let matchedTypeAndOverlap = matchesTypes.filter((n) => { if (lightNode._gWrapperFor) { return n._gWrapperFor === lightNode._gWrapperFor; } const nBBox = n.getBBox(); if (!lightBBox.width || !lightBBox.height) { return ( lightBBox.width === nBBox.width && lightBBox.height === nBBox.height && lightBBox.x === nBBox.x && lightBBox.y === nBBox.y ); } const bboxesOverlap = lightBBox.x < nBBox.x + nBBox.width && lightBBox.x + lightBBox.width > nBBox.x && lightBBox.y < nBBox.y + nBBox.height && lightBBox.y + lightBBox.height > nBBox.y; return bboxesOverlap; }); if (matchedTypeAndOverlap.length > 1) { matchedTypeAndOverlap = matchedTypeAndOverlap.filter((n) => n.nodeName === "use" ? n.getAttribute("href") === lightNode.getAttribute("href") || n.parentElement?.dataset.selector === lightNode.parentElement?.dataset.selector : n.nodeName === "path" ? n.getAttribute("d") === lightNode.getAttribute("d") : n.nodeName === "rect" ? n.getAttribute("x") === lightNode.getAttribute("x") && n.getAttribute("y") === lightNode.getAttribute("y") && n.getAttribute("width") === lightNode.getAttribute("width") && n.getAttribute("height") === lightNode.getAttribute("height") : n.nodeName === "line" ? n.getAttribute("x1") === lightNode.getAttribute("x1") && n.getAttribute("y1") === lightNode.getAttribute("y1") && n.getAttribute("x2") === lightNode.getAttribute("x2") && n.getAttribute("y2") === lightNode.getAttribute("y2") : n.textContent === lightNode.textContent || /** Some text content changes between renders */ (n.getAttribute("x") === lightNode.getAttribute("x") && n.getAttribute("y") === lightNode.getAttribute("y")), ); } // if ( // lightNode instanceof SVGTextElement && // lightNode.textContent?.includes("11:5") // // && // // lightNode.getAttribute("fill") === "rgb(108, 6, 171)" // ) { // debugger; // } if (matchedTypeAndOverlap.length > 1 && lightNode.nodeName === "path") { matchedTypeAndOverlap = matchedTypeAndOverlap.filter( (n) => n._bboxCode?.length === lightNode._bboxCode?.length, ); } if (lightNode._gWrapperFor && matchedTypeAndOverlap.length > 1) { throw new Error("Multiple matching gWrappers found"); } if (matchedTypeAndOverlap.length === 1) { darkNode = matchedTypeAndOverlap[0]; } return darkNode; }; ================================================ FILE: client/src/app/domToSVG/graphics/fontIconToSVG.ts ================================================ import { includes } from "../../../dashboard/W_SQL/W_SQLBottomBar/W_SQLBottomBar"; import type { SVGContext, SVGNodeLayout } from "../containers/elementToSVG"; import { isElementNode } from "../utils/isElementVisible"; import { SVG_NAMESPACE } from "../domToSVG"; export const fontIconToSVG = async ( g: SVGGElement, iconInfo: NonNullable>, context: SVGContext, layout: SVGNodeLayout, ) => { // const { x, y, height, width } = layout; const style = getComputedStyle(iconInfo.element); const fontFamily = style.getPropertyValue("font-family"); const fontSize = parseInt(style.getPropertyValue("font-size")); const iconColor = style.getPropertyValue("color"); await addFontFamily(fontFamily, context).catch((err) => { /** Might be system font. Ignore error */ if (fontFamily.includes(", ")) return; console.error( `Failed to add font ${fontFamily} to SVG:`, iconInfo.element, err, ); }); const rect = iconInfo.element.getBoundingClientRect(); const width = rect.width; const height = rect.height; /** TODO: Must calculate the actual position of :after based on main content width + bbox - :after width */ const x = iconInfo.iconStyle.type === "after" ? layout.width + layout.x - rect.width / 2 : layout.x; const y = rect.y; // Create a text element with the icon const textEl = document.createElementNS(SVG_NAMESPACE, "text"); textEl.setAttribute("x", x + width / 2); textEl.setAttribute("y", y + height / 2); textEl.setAttribute("font-family", fontFamily); textEl.setAttribute("font-size", `${fontSize}px`); textEl.setAttribute("fill", iconColor); textEl.setAttribute("text-anchor", "middle"); textEl.setAttribute("dominant-baseline", "middle"); textEl.textContent = iconInfo.content; g.appendChild(textEl); }; /** * TODO: extract used icons only using opentype.js */ const addFontFamily = async (familyName: string, context: SVGContext) => { if (context.fontFamilies.includes(familyName)) { return; } const fontURL = findFontURL(familyName); if (!fontURL) return; return fetch(fontURL) .then((response) => response.blob()) .then((blob) => { // Convert blob to data URL return new Promise((resolve) => { const reader = new FileReader(); reader.onloadend = () => resolve(reader.result as string); reader.readAsDataURL(blob); }); }) .then((dataURL) => { // Create a style element for the font const styleEl = document.createElementNS(SVG_NAMESPACE, "style"); styleEl.textContent = ` @font-face { font-family: "${familyName}"; src: url("${dataURL}"); font-weight: normal; font-style: normal; } `; context.defs.appendChild(styleEl); context.fontFamilies.push(familyName); }); }; export const getFontIconElement = (node: Node) => { if (!isElementNode(node)) return; const beforeStyle = getComputedStyle(node, ":before"); const afterStyle = getComputedStyle(node, ":after"); const iconStyle = beforeStyle.content && !includes(beforeStyle.content, ["", "none"]) ? ({ type: "before", style: beforeStyle } as const) : afterStyle.content && !includes(afterStyle.content, ["", "none"]) ? ({ type: "after", style: afterStyle } as const) : ""; if (!iconStyle) { return; } return { element: node, iconStyle, content: iconStyle.style.content.replace(/['"]/g, ""), }; }; function findFontURL(fontFamily: string) { // This is a simplified approach to find the font URL for (const sheet of document.styleSheets) { try { const rules = sheet.cssRules; for (let j = 0; j < rules.length; j++) { const rule = rules[j]; if (rule instanceof CSSFontFaceRule) { const fontFamilyValue = rule.style .getPropertyValue("font-family") .replace(/['"]/g, ""); if (fontFamilyValue === fontFamily) { // Extract the URL from the src property const src = rule.style.getPropertyValue("src"); const urlMatch = src.match(/url\(['"]?([^'"]+)['"]?\)/); if (urlMatch && urlMatch[1]) { return urlMatch[1]; } } } } } catch (e) { // Security error, likely due to cross-origin stylesheet console.warn("Could not access stylesheet:", e); } } throw new Error(`Could not find URL for font family: ${fontFamily}`); } ================================================ FILE: client/src/app/domToSVG/graphics/getForeignObject.ts ================================================ import { isImgNode } from "../utils/isElementVisible"; import { toFixed } from "../utils/toFixed"; export const isSVGElement = (element: Element): element is SVGElement => { return element instanceof SVGElement; }; export const getForeignObject = async ( element: Element, style: CSSStyleDeclaration, x: number, y: number, ) => { if (isImgNode(element) && element.src.endsWith(".svg")) { return new Promise((resolve) => { fetch(element.src) .then((response) => { if (!response.ok) { throw new Error(`Failed to fetch SVG: ${response.statusText}`); } return response.text(); }) .then((svgContent) => { const parser = new DOMParser(); const svgDoc = parser.parseFromString(svgContent, "image/svg+xml"); const svgElement = svgDoc.documentElement; const { width, height } = element; const paddingLeft = parseFloat(style.paddingLeft) || 0; const paddingTop = parseFloat(style.paddingTop) || 0; svgElement.setAttribute("x", `${toFixed(x + paddingLeft)}`); svgElement.setAttribute("y", `${toFixed(y + paddingTop)}`); svgElement.setAttribute("width", `${toFixed(width)}`); svgElement.setAttribute("height", `${toFixed(height)}`); resolve(svgElement as unknown as SVGElement); }) .catch((error) => { console.error("Error fetching SVG:", error); resolve(undefined); }); }); } }; ================================================ FILE: client/src/app/domToSVG/graphics/imgToSVG.ts ================================================ import { hashCode } from "src/utils/hashCode"; import type { SVGContext, SVGNodeLayout } from "../containers/elementToSVG"; import { SVG_NAMESPACE } from "../domToSVG"; import { canvasToDataURL } from "../utils/canvasToDataURL"; export const addImageFromDataURL = ( g: SVGGElement, dataUrl: string, context: SVGContext, { style, height, width, x, y }: SVGNodeLayout, ) => { const sameDataUrlSymbol = Array.from( context.defs.querySelectorAll("symbol"), ).find((symbol) => { const imgInSymbol = symbol.querySelector("image"); if (!imgInSymbol) { return false; } const href = imgInSymbol.getAttribute("href"); return href === dataUrl; })?.id; let imageSymbolId = sameDataUrlSymbol; if (!imageSymbolId) { imageSymbolId = "id" + hashCode(dataUrl.slice(100)); while (context.defs.querySelector(`#${imageSymbolId}`)) { imageSymbolId += Math.floor(Math.random() * 10).toString(); } const imageElem = document.createElementNS(SVG_NAMESPACE, "image"); imageElem.setAttribute("href", dataUrl); imageElem.setAttribute("width", "100%"); imageElem.setAttribute("height", "100%"); imageElem.setAttribute("preserveAspectRatio", "xMidYMid meet"); const symbolElem = document.createElementNS(SVG_NAMESPACE, "symbol"); symbolElem.setAttribute("id", imageSymbolId); symbolElem.appendChild(imageElem); symbolElem.setAttribute("viewBox", `0 0 ${width} ${height}`); context.defs.appendChild(symbolElem); } const useElem = document.createElementNS(SVG_NAMESPACE, "use"); useElem.setAttribute("href", `#${imageSymbolId}`); useElem.setAttribute("x", x); useElem.setAttribute("y", y); useElem.setAttribute("width", width); useElem.setAttribute("height", height); if (style.opacity && style.opacity !== "1") { useElem.style.opacity = style.opacity; } g.appendChild(useElem); }; export const imgToSVG = async ( g: SVGGElement, imgElement: HTMLImageElement, layout: SVGNodeLayout, context, ) => { const loadedImage = await loadImage(imgElement); const dataUrl = convertImageToDataURL(loadedImage); addImageFromDataURL(g, dataUrl, context, layout); }; const loadImage = async ( imgSource: HTMLImageElement, ): Promise => { if (!imgSource.complete) { return new Promise((resolve, reject) => { imgSource.onload = () => resolve(imgSource); imgSource.onerror = () => reject(new Error("Failed to load the provided image element")); }); } return imgSource; }; const convertImageToDataURL = (img: HTMLImageElement): string => { try { if (img.src.startsWith("data:")) { return img.src; } const canvas = document.createElement("canvas"); canvas.width = img.naturalWidth; canvas.height = img.naturalHeight; const ctx = canvas.getContext("2d"); if (!ctx) { throw new Error("Failed to get canvas context"); } ctx.drawImage(img, 0, 0); return canvasToDataURL(canvas); } catch (error) { console.error("Error converting image to data URL:", error); // Fallback to original source if conversion fails return img.src; } }; ================================================ FILE: client/src/app/domToSVG/recordDomChanges.ts ================================================ import { isElementNode, isElementVisible } from "./utils/isElementVisible"; export const recordDomChanges = (targetNode: HTMLElement) => { const config = { attributes: true, childList: true, subtree: true, attributeOldValue: true, }; const observer = new MutationObserver((mutationList, observer) => { for (const mutation of mutationList) { const { target, oldValue } = mutation; if (!isElementNode(target) || !isElementVisible(target).isVisible) continue; if (mutation.type === "childList") { console.log( "A child node has been added or removed.", mutation.addedNodes, mutation.removedNodes, ); changes.push({ type: "childList", target: target, addedNodes: mutation.addedNodes, removedNodes: mutation.removedNodes, timestamp: Date.now(), }); } else if ( mutation.type === "attributes" && mutation.attributeName === "style" ) { const currentStyle = target.getAttribute("style"); const styleDiff = getStyleDiff(oldValue, currentStyle, target); changes.push({ type: "style", target: target, oldValue, currentStyle, changes: styleDiff, timestamp: Date.now(), }); } } }); observer.observe(targetNode, config); }; setInterval(() => { if (changes.length > 0) { console.log("DOM changes detected:", changes); } }, 3000); type DOMChange = | { type: "childList"; target: HTMLElement; addedNodes: NodeList; removedNodes: NodeList; timestamp: number; } | { type: "style"; target: HTMLElement; oldValue: string | null; currentStyle: string | null; changes: Record; timestamp: number; }; const changes: DOMChange[] = []; const getStyleDiff = ( oldStyle: string | null, currentStyle: string | null, target: HTMLElement, ) => { const oldStyleObj = parseStyle(oldStyle); const currentStyleObj = parseStyle(currentStyle); // Determine added/changed properties const added: Record = {}; const changed: Record = {}; const removed: Record = {}; // Find added or changed properties Object.entries(currentStyleObj).forEach(([prop, value]) => { if (!(prop in oldStyleObj)) { added[prop] = value; } else if (oldStyleObj[prop] !== value) { changed[prop] = { old: oldStyleObj[prop]!, new: value }; } }); // Find removed properties Object.entries(oldStyleObj).forEach(([prop, value]) => { if (!(prop in currentStyleObj)) { removed[prop] = value; } }); console.log(`Style attribute was modified on`, target); if (Object.keys(added).length > 0) { console.log("Added styles:", added); } if (Object.keys(changed).length > 0) { console.log("Changed styles:", changed); } if (Object.keys(removed).length > 0) { console.log("Removed styles:", removed); } return { added, changed, removed }; }; // Parse the styles into key-value objects for easier comparison const parseStyle = (styleString: string | null): Record => { const result: Record = {}; if (!styleString) return result; // Split the style string and extract property-value pairs styleString.split(";").forEach((pair) => { const trimmed = pair.trim(); if (!trimmed) return; const [property, value] = trimmed.split(":").map((s) => s.trim()); if (property && value) { result[property] = value; } }); return result; }; ================================================ FILE: client/src/app/domToSVG/setThemeForSVGScreenshot.ts ================================================ import { localSettings } from "../../dashboard/localSettings"; import { tout } from "../../utils/utils"; export const setThemeForSVGScreenshot = async (theme: undefined | "dark") => { const resetUICallbacks: (() => void)[] = []; /** Ensure that any sql suggestion popups are opened back */ const sqlEditor = document.querySelector(`div.ProstglesSQL`); if (sqlEditor?.sqlRef?.editor) { const suggestionsAreShown = //@ts-ignore sqlEditor.sqlRef.editor._contextKeyService?.getContextKeyValue( "suggestWidgetVisible", ); const position = sqlEditor.sqlRef.editor.getPosition(); if (suggestionsAreShown && position) { resetUICallbacks.push(async () => { await tout(500); const editor = document.querySelector(`div.ProstglesSQL`); editor?.sqlRef?.editor.setPosition(position); await tout(100); editor?.sqlRef?.editor.trigger( "demo", "editor.action.triggerSuggest", {}, ); }); } } /** Re-open any closed popup menus */ const openPopupSelectors = Array.from( document.querySelectorAll( `div.PopupMenu_triggerWrapper.is-open, .select-button.is-open`, ), ).map((el) => getUniqueSelector(el)); openPopupSelectors.forEach((selector) => { resetUICallbacks.push(async () => { await tout(500); const triggerBtn = document.querySelector(selector); if (triggerBtn && !triggerBtn.classList.contains("is-open")) { triggerBtn.click(); } }); }); localSettings.get().$set({ themeOverride: theme }); await tout(500); for (const cb of resetUICallbacks) { await cb(); } await tout(500); }; // function getUniqueSelector(element: HTMLElement) { // // If element has an ID, use it // if (element.id) { // return `#${element.id}`; // } // // Build path from element to root // const path: string[] = []; // let current: HTMLElement | null = element; // while (current && current !== document.body) { // let selector = current.tagName.toLowerCase(); // // Add class if available // if (current.className && typeof current.className === "string") { // const classes = current.className.trim().split(/\s+/).join("."); // if (classes) { // selector += `.${classes}`; // } // } // // Add nth-child if needed to make it unique // const parent: HTMLElement | null = current.parentElement; // if (parent) { // const siblings = Array.from(parent.children); // const sameTagSiblings = siblings.filter( // (s) => s.tagName === current?.tagName, // ); // if (sameTagSiblings.length > 1) { // const index = siblings.indexOf(current) + 1; // selector += `:nth-child(${index})`; // } // } // path.unshift(selector); // current = parent; // } // return path.join(" > "); // } function getUniqueSelector(element: HTMLElement): string { // If element has an ID, use it if (element.id) { return `#${element.id}`; } // Build path from element to root const path: string[] = []; let current: HTMLElement | null = element; while (current && current !== document.documentElement) { const parent = current.parentElement; if (!parent) break; // Get index among all siblings const siblings = Array.from(parent.children); const index = siblings.indexOf(current) + 1; // Build selector: tagname:nth-child(index) const selector = `${current.tagName.toLowerCase()}:nth-child(${index})`; path.unshift(selector); current = parent; } return path.join(" > "); } ================================================ FILE: client/src/app/domToSVG/text/getTextForSVG.ts ================================================ import { isDefined } from "../../../utils/utils"; import { isElementNode, isInputOrTextAreaNode, isTextNode, } from "../utils/isElementVisible"; export type TextForSVG = { style: CSSStyleDeclaration; textContent: string; textIndent: number | undefined; x: number; y: number; width: number; height: number; isSingleLine: boolean | undefined; numberOfLines?: number; element: HTMLElement; }; export const getTextForSVG = ( element: HTMLElement, style: CSSStyleDeclaration, { x, y, width, height, }: { x: number; y: number; width: number; height: number }, ): TextForSVG[] | undefined => { if (isTextNode(element)) { throw new Error("Not expecting this to be honest"); } if (isInputOrTextAreaNode(element)) { const inputRect = element.getBoundingClientRect(); let textContent = element.value || element.placeholder; if ( (element.type === "date" || element.type === "datetime-local") && element.value ) { try { textContent = new Date(element.value).toLocaleString(); } catch {} } const isPlaceholder = !element.value; if (!textContent.trim()) return; const paddingLeft = parseFloat(style.paddingLeft) || 0; const paddingTop = parseFloat(style.paddingTop) || 0; const paddingBottom = parseFloat(style.paddingBottom) || 0; const borderTop = parseFloat(style.borderTop) || 0; const borderBottom = parseFloat(style.borderBottom) || 0; const contentHeight = inputRect.height - paddingTop - paddingBottom - borderTop - borderBottom; const actualStyle = isPlaceholder ? getComputedStyle(element, "::placeholder") : style; const fontSize = parseFloat(actualStyle.fontSize); const fontYPadding = Math.max(0, contentHeight - fontSize); const borderLeft = parseFloat(style.borderLeftWidth) || 0; const yTextOffsetForInput = -paddingBottom - borderBottom - fontYPadding / 2 - 0.5; const isTextArea = element instanceof HTMLTextAreaElement; const y = isTextArea ? inputRect.y + paddingTop + fontSize + borderTop + 3 : inputRect.y + inputRect.height + yTextOffsetForInput; const result = [ { style: actualStyle, textContent, x: inputRect.x + paddingLeft + borderLeft, y, width: inputRect.width - paddingLeft, height: inputRect.height - paddingTop, textIndent: 0, isSingleLine: undefined, element, }, ]; return result; } return Array.from(element.childNodes) .map((childTextNode, index) => { if (!isTextNode(childTextNode)) return; const textContent = childTextNode.textContent; if (!textContent || !textContent.trim()) return; const range = document.createRange(); range.selectNodeContents(childTextNode); const textRect = range.getBoundingClientRect(); const maxX = x + width; const maxY = y + height; const textMaxX = textRect.x + textRect.width; const textMaxY = textRect.y + textRect.height; const textxMaxWidth = Math.min(textMaxX, maxX) - textRect.x; const textyMaxHeight = Math.min(textMaxY, maxY) - textRect.y; const visibleTextWidth = Math.min(textRect.width, textxMaxWidth); const visibleTextHeight = Math.min(textRect.height, textyMaxHeight); const spanHeight = element instanceof HTMLSpanElement ? element.clientHeight : undefined; const numberOfLines = range.getClientRects().length; if (visibleTextWidth && visibleTextHeight) { const edgeRects = getTextEdgeRects(childTextNode, textContent.length); const textIndent = edgeRects.startCharRect.left - textRect.x; const res: TextForSVG = { style: { // eslint-disable-next-line @typescript-eslint/no-misused-spread ...style, /** This is done to preserve leading spaces between spans of the same text block */ whiteSpace: textContent.startsWith(" ") ? "pre" : style.whiteSpace, }, textContent, x: textRect.x, y: textRect.y + 1, /** This ensures the actual visible/non overflown size of text is used */ width: visibleTextWidth, height: spanHeight ?? visibleTextHeight, textIndent: Math.max(0, textIndent), isSingleLine: edgeRects.startCharRect.top === edgeRects.endCharRect.top, numberOfLines, element, }; return res; } }) .filter(isDefined); }; const getTextEdgeRects = (textNode: Text, contentLength: number) => { const range = document.createRange(); range.setStart(textNode, 0); range.setEnd(textNode, 1); const startCharRect = range.getBoundingClientRect(); range.setStart(textNode, contentLength - 1); range.setEnd(textNode, contentLength); const endCharRect = range.getBoundingClientRect(); return { startCharRect, endCharRect }; }; // Function to recursively process text nodes const getTextNodes = ( element: HTMLElement, parentStyles: Partial, ) => { const computedStyles = window.getComputedStyle(element); // Merge parent styles with current element styles const currentStyles = { fontSize: computedStyles.fontSize, fontFamily: computedStyles.fontFamily, fontWeight: computedStyles.fontWeight, fontStyle: computedStyles.fontStyle, color: computedStyles.color, // eslint-disable-next-line @typescript-eslint/no-misused-spread ...parentStyles, }; return Array.from(element.childNodes) .map((node) => { if (isTextNode(node) && node.textContent) { const text = node.textContent.trim(); if (text) { // Create a range for this text node const range = document.createRange(); range.selectNodeContents(node); const rect = range.getBoundingClientRect(); return { text: text, x: rect.left, y: rect.top, width: rect.width, height: rect.height, styles: currentStyles, element: element.tagName || "TEXT", }; } } else if (isElementNode(node)) { // Recursively process child elements getTextNodes(node, currentStyles); } }) .filter(isDefined); }; const calculateVerticalPosition = (inputElement: HTMLInputElement) => { const computedStyle = window.getComputedStyle(inputElement); // Get dimensions const inputHeight = inputElement.offsetHeight; const fontSize = parseFloat(computedStyle.fontSize); const lineHeight = computedStyle.lineHeight === "normal" ? fontSize * 1.2 : parseFloat(computedStyle.lineHeight); // Get padding const paddingTop = parseFloat(computedStyle.paddingTop); const paddingBottom = parseFloat(computedStyle.paddingBottom); const borderTop = parseFloat(computedStyle.borderTopWidth); const borderBottom = parseFloat(computedStyle.borderBottomWidth); // Calculate available content height const contentHeight = inputHeight - paddingTop - paddingBottom - borderTop - borderBottom; // Calculate vertical center position const textVerticalCenter = paddingTop + borderTop + (contentHeight - lineHeight) / 2; return { textTop: textVerticalCenter, textCenter: textVerticalCenter + lineHeight / 2, textBottom: textVerticalCenter + lineHeight, contentHeight: contentHeight, lineHeight: lineHeight, }; }; ================================================ FILE: client/src/app/domToSVG/text/textToSVG.ts ================================================ import { includes } from "prostgles-types"; import { SVG_NAMESPACE } from "../domToSVG"; import type { SVGScreenshotNodeType } from "../domToThemeAwareSVG"; import { isInputOrTextAreaNode } from "../utils/isElementVisible"; import { toFixed } from "../utils/toFixed"; import type { TextForSVG } from "./getTextForSVG"; const _singleLineEllipsis = "_singleLineEllipsis" as const; const TEXT_WIDTH_ATTR = "data-text-width"; const TEXT_HEIGHT_ATTR = "data-text-height"; const getLineBreakPartsWithDelimiters = (content: string) => content.split(/([\s\-–—:]+)/); export const textToSVG = ( element: HTMLElement, g: SVGGElement, textInfo: TextForSVG, elementStyle: CSSStyleDeclaration, bboxCode: string, ) => { const { height, style: placeholderOrElementStyle, textContent: content, width, x, y, isSingleLine, numberOfLines, } = textInfo; const style = placeholderOrElementStyle; if (!content.trim()) return; const textNode = document.createElementNS(SVG_NAMESPACE, "text"); (textNode as SVGScreenshotNodeType)._bboxCode = bboxCode; (textNode as SVGScreenshotNodeType)._textInfo = textInfo; textNode.setAttribute(TEXT_WIDTH_ATTR, toFixed(width)); textNode.setAttribute(TEXT_HEIGHT_ATTR, toFixed(height)); textNode.setAttribute("x", toFixed(x)); /** In firefox it seems the text nodes don't have font size */ const textNodeStyle = { color: style.color || elementStyle.color, fontFamily: style.fontFamily || elementStyle.fontFamily, fontSize: style.fontSize || elementStyle.fontSize, fontWeight: style.fontWeight || elementStyle.fontWeight, letterSpacing: style.letterSpacing || elementStyle.letterSpacing, textDecoration: style.textDecoration || elementStyle.textDecoration, lineHeight: style.lineHeight || elementStyle.lineHeight, whiteSpace: style.whiteSpace || elementStyle.whiteSpace, textOverflow: style.textOverflow || elementStyle.textOverflow, textTransform: style.textTransform || elementStyle.textTransform, fontStyle: style.fontStyle || elementStyle.fontStyle, }; const fontSize = parseFloat(textNodeStyle.fontSize); const isInputElement = isInputOrTextAreaNode(element); textNode.setAttribute("y", toFixed((isInputElement ? y : y + fontSize) - 2)); textNode.setAttribute("fill", textNodeStyle.color); textNode.setAttribute("font-family", textNodeStyle.fontFamily); textNode.setAttribute("font-size", textNodeStyle.fontSize); textNode.setAttribute("font-weight", textNodeStyle.fontWeight); textNode.setAttribute("font-style", textNodeStyle.fontStyle); textNode.setAttribute("letter-spacing", textNodeStyle.letterSpacing); textNode.setAttribute("text-decoration", textNodeStyle.textDecoration); textNode.style.lineHeight = textNodeStyle.lineHeight; textNode.style.whiteSpace = textNodeStyle.whiteSpace; textNode.style.textTransform = textNodeStyle.textTransform; const nonWrappingWhiteSpaces = ["nowrap", "pre", "reverse", "reverse-wrap"]; if ( textNodeStyle.textOverflow === "ellipsis" && (includes(nonWrappingWhiteSpaces, textNodeStyle.whiteSpace) || isSingleLine) ) { textNode[_singleLineEllipsis] = true; } textNode.setAttribute("text-anchor", "start"); textNode.textContent = content.trimEnd(); g.appendChild(textNode); }; const tolerance = 2; const wrapTextIfOverflowing = ( textNode: Extract, width: number, height: number, content: string, ) => { const currTextLength = textNode.getComputedTextLength(); const { textIndent = 0, isSingleLine, style, numberOfLines, } = textNode._textInfo ?? {}; if (currTextLength <= width + tolerance && !textIndent) { return; } else if (textNode[_singleLineEllipsis]) { while ( textNode.getComputedTextLength() > width + tolerance && content.length ) { content = content.slice(0, -1); textNode.textContent = content + "..."; } return; } textNode.textContent = ""; let line: string[] = []; const wordsWithDelimiters = getLineBreakPartsWithDelimiters(content).filter( (w) => w !== "", ); const fontSize = textNode.getAttribute("font-size") || "16"; const lineHeightPx = parseFloat(textNode.style.lineHeight) || 1.1 * parseFloat(fontSize); const x = parseFloat(textNode.getAttribute("x") || "0"); const maxLines = numberOfLines ?? Math.floor(height / lineHeightPx); let tspan = document.createElementNS(SVG_NAMESPACE, "tspan"); tspan.setAttribute("x", toFixed(x + textIndent)); tspan.setAttribute("dy", 0); tspan.textContent = style?.whiteSpace === "pre" ? content : content.trimStart(); textNode.appendChild(tspan); const willNotWrap = isSingleLine || textNode._textInfo?.element instanceof HTMLInputElement; if (willNotWrap) { return; } let lineNumber = 1; const hasTextOverflowEllipsis = style?.textOverflow === "ellipsis" && style.overflow === "hidden"; for (let wordIndex = 0; wordIndex < wordsWithDelimiters.length; wordIndex++) { const isFirstLine = lineNumber === 1; const currentLineWidth = isFirstLine ? width - textIndent : width; const word = wordsWithDelimiters[wordIndex]!; line.push(word); const setTextContent = () => { tspan.textContent = isFirstLine ? line.join("") : line.join("").trimStart(); }; setTextContent(); const textLen = tspan.getComputedTextLength(); const textIsOverflowing = textLen > currentLineWidth + tolerance; const cannotWrapMoreBecauseItsASingleWord = line.length === 1; if (textIsOverflowing && !cannotWrapMoreBecauseItsASingleWord) { if ( numberOfLines && lineNumber === numberOfLines && !hasTextOverflowEllipsis ) { return; } line.pop(); // Remove the word that caused overflow setTextContent(); // Move to next line if possible lineNumber++; if (lineNumber >= maxLines && hasTextOverflowEllipsis) { // Add ellipsis to indicate truncation if there's room if ( tspan.textContent && tspan.getComputedTextLength() < currentLineWidth - 10 ) { tspan.textContent += "..."; } return; } // Create new tspan for next line line = [word]; tspan = document.createElementNS(SVG_NAMESPACE, "tspan"); tspan.setAttribute("x", toFixed(x)); tspan.setAttribute("dy", toFixed(lineHeightPx) + "px"); textNode.appendChild(tspan); tspan.textContent = word; } } }; const unnestRedundantGElements = (svg: SVGElement) => { const gElements = svg.querySelectorAll("g"); gElements.forEach((gElement) => { if ( gElement.childElementCount === 1 && gElement.firstElementChild?.tagName.toLowerCase() === "g" && gElement.attributes.length === 0 ) { const childG = gElement.firstElementChild as SVGElement; gElement.parentNode?.replaceChild(childG, gElement); unnestRedundantGElements(svg); } }); return svg; }; export const wrapAllSVGText = (svg: SVGElement) => { if (!svg.isConnected) { throw new Error("SVG must be in the DOM for bbox calculations"); } unnestRedundantGElements(svg); svg .querySelectorAll(`text[${TEXT_WIDTH_ATTR}]`) .forEach((text) => { const textWidth = text.getAttribute(TEXT_WIDTH_ATTR); const textHeight = text.getAttribute(TEXT_HEIGHT_ATTR); if (!text.textContent || !textWidth || !textHeight) return; wrapTextIfOverflowing( text, +textWidth, +textHeight, text.textContent || "", ); }); svg .querySelectorAll(`text[${TEXT_WIDTH_ATTR}]`) .forEach((text) => { text.removeAttribute(TEXT_WIDTH_ATTR); text.removeAttribute(TEXT_HEIGHT_ATTR); }); }; /** * Appends svg to document to ensure the bbox/text length calcs work * */ export const renderSvg = (svg: SVGElement) => { const topStyle = { position: "absolute", top: "0", left: "0", zIndex: "9999", } as const; const getIsAppended = () => document.body.contains(svg); if (!getIsAppended()) { Object.entries(topStyle).forEach(([key, value]) => { svg.style[key] = value; }); document.body.appendChild(svg); } return { remove: () => { if (getIsAppended()) { svg.removeAttribute("style"); svg.remove(); } }, }; }; ================================================ FILE: client/src/app/domToSVG/utils/addNewChildren.ts ================================================ import { isDefined } from "src/utils/utils"; import { displayNoneIfLight, type SVGScreenshotNodeType, } from "../domToThemeAwareSVG"; export const addNewChildren = ( lightNode: SVGScreenshotNodeType, darkNode: SVGScreenshotNodeType, matchesMap: Map, ) => { const matchingChildren = Array.from(lightNode.children) .map((lightChild, index) => { const darkChild = matchesMap.get(lightChild as SVGScreenshotNodeType); if (darkChild) { return { lightChild: lightChild as SVGScreenshotNodeType, darkChild, index, }; } }) .filter(isDefined); const newChildren = Array.from(darkNode.children) .map((darkChild, index) => { if ( !( darkChild instanceof SVGGElement || darkChild instanceof SVGPathElement || darkChild instanceof SVGRectElement || darkChild instanceof SVGCircleElement || darkChild instanceof SVGUseElement ) ) return; const isMatched = matchingChildren.find( (mc) => mc.darkChild === darkChild, ); if (!isMatched) { return { darkChild: darkChild as SVGScreenshotNodeType, index, }; } }) .filter(isDefined); /** Add new nodes to lightNode */ newChildren.forEach(({ darkChild, index }) => { const clonedChild = darkChild.cloneNode(true) as SVGScreenshotNodeType; clonedChild.style.opacity = `var(${displayNoneIfLight})`; lightNode.insertBefore(clonedChild, lightNode.children[index] || null); }); }; ================================================ FILE: client/src/app/domToSVG/utils/canvasToDataURL.ts ================================================ export const canvasToDataURL = ( canvas: HTMLCanvasElement, quality = 0.8, ): string => { return canvas.toDataURL("image/webp", quality); }; ================================================ FILE: client/src/app/domToSVG/utils/copyAnimationStylesToSvg.ts ================================================ import type { SVGContext } from "../containers/elementToSVG"; /** * Extracts and clones CSS animations and keyframes from an element */ const getAnimationKeyframeRules = (element: Element) => { const computedStyle = window.getComputedStyle(element); const animationName = computedStyle.animationName; if (!animationName || animationName === "none") { return; } // const styleElement = targetDocument.createElement("style"); const keyframeRules: CSSKeyframesRule[] = []; const animationNames = animationName.split(",").map((name) => name.trim()); // Search through all stylesheets for matching @keyframes rules for (const sheet of Array.from(document.styleSheets)) { try { const rules = sheet.cssRules; for (const rule of Array.from(rules)) { if ( rule instanceof CSSKeyframesRule && animationNames.includes(rule.name) ) { keyframeRules.push(rule); } } } catch (e) { // Skip stylesheets that can't be accessed (CORS) console.warn("Cannot access stylesheet:", e); } } return keyframeRules; }; /** * Copies inline animation styles to the cloned element */ const copyAnimationStylesToSvg = ( source: CSSStyleDeclaration, target: HTMLElement | SVGElement, ) => { const computedStyle = source; const animationProperties = [ "animationName", "animationDuration", "animationTimingFunction", "animationDelay", "animationIterationCount", "animationDirection", "animationFillMode", "animationPlayState", ] as const; if (!source.animationName || source.animationName === "none") { return false; } animationProperties.forEach((prop) => { const value = computedStyle[prop]; if (value && value !== "none") { target.style[prop] = value; } }); return true; }; const copyAnimationsToSvg = ( sourceElement: Element, sourceCss: CSSStyleDeclaration, wrapperG: SVGElement, cssDeclarations: SVGContext["cssDeclarations"], ): void => { // Extract keyframes from the original element's styles const copiedAnimations = copyAnimationStylesToSvg(sourceCss, wrapperG); if (!copiedAnimations) { console.error("Unexpected: could not copy animations"); return; } const animationKeyframeRules = getAnimationKeyframeRules(sourceElement); if (animationKeyframeRules) { // defs.appendChild(animationStyles); // cssDeclarations.push(...animationKeyframeRules); } animationKeyframeRules?.forEach((rule) => { const ruleText = rule.cssText; if (!cssDeclarations.has(ruleText)) { cssDeclarations.set(ruleText, ruleText); } }); }; export const getAnimationsHandler = (sourceElement: Element) => { let hasAnimations = false as boolean; /** Ensures bbox calculations are stable */ sourceElement.getAnimations().forEach((animation) => { animation.cancel(); // animation.pause(); // animation.currentTime = 0; hasAnimations = true; }); if (!hasAnimations) return; return ( sourceCss: CSSStyleDeclaration, wrapperG: SVGElement, cssDeclarations: SVGContext["cssDeclarations"], /** * Important for correct animation scaling in SVG. Otherwise percentages are * calculated based on the viewport, not the element's bounding box. */ addFillBox: boolean, ) => { if (addFillBox) { wrapperG.style.transformBox = "fill-box"; } return copyAnimationsToSvg( sourceElement, sourceCss, wrapperG, cssDeclarations, ); }; }; ================================================ FILE: client/src/app/domToSVG/utils/getWhatToRenderOnSVG.ts ================================================ import { isDefined } from "../../../utils/utils"; import { getBackdropFilter, getBackgroundColor, } from "../containers/bgAndBorderToSVG"; import type { SVGContext } from "../containers/elementToSVG"; import { getFontIconElement } from "../graphics/fontIconToSVG"; import { getTextForSVG } from "../text/getTextForSVG"; import { isElementVisible, isImgNode, isSVGNode } from "./isElementVisible"; import { getForeignObject } from "../graphics/getForeignObject"; import { includes } from "src/dashboard/W_SQL/W_SQLBottomBar/W_SQLBottomBar"; const attributesToKeep = [ "data-command", "data-key", "data-label", "role", ] as const; export type WhatToRenderOnSVG = Awaited< ReturnType >; export const getWhatToRenderOnSVG = async ( element: HTMLElement, context: SVGContext, parentSvg: SVGElement | SVGGElement, ) => { const { isVisible, style, bbox } = isElementVisible(element); // Calculate absolute position const x = bbox.left + context.offsetX; const y = bbox.top + context.offsetY; const width = bbox.width; const height = bbox.height; const elemInfo = { x, y, width, height, style, bbox, isVisible, }; /** Used to highlight so will render as a rectangle */ const attributeData = attributesToKeep.reduce( (acc, attr) => { const attrValue = element.getAttribute(attr); if (attrValue) { return { ...acc, [attr]: attrValue }; } return acc; }, undefined as | Partial> | undefined, ); let mightBeHovered = false; if (!isVisible) { const hoverClasses = [ "show-on-row-hover", "show-on-hover", "show-on-parent-hover", "show-on-trigger-hover", ]; if ( hoverClasses.some( (cls) => element.classList.contains(cls) || element.closest(`.${cls}`), ) && (attributeData || element.querySelector(`[data-command], [data-key], [data-label]`)) ) { mightBeHovered = true; } else return { elemInfo }; } const background = getBackgroundColor(style); const parentBackground = background && element.parentElement && getBackgroundColor(getComputedStyle(element.parentElement)); /** * Used to prevent drawing over rounded input border corners */ const backgroundSameAsRenderedParent = background && background === parentBackground && (parentSvg as SVGGElement)._domElement === element.parentElement; const backdropFilter = getBackdropFilter(style); const childAffectingStyles: Partial< Pick > = {}; if (style.opacity && style.opacity !== "1") { childAffectingStyles.opacity = style.opacity; } if (includes(style.position, ["fixed", "absolute", "relative"])) { childAffectingStyles.position = style.position; } const foreignObject = await getForeignObject(element, style, x, y); const fontIcon = getFontIconElement(element); const image = isSVGNode(element) ? { type: "svgElement" as const, element, } : foreignObject ? { type: "foreignObject" as const, foreignObject, } : fontIcon ? { type: "fontIcon" as const, ...fontIcon, } : isImgNode(element) ? { type: "img" as const, element, } : style.maskImage.startsWith("url(") ? { type: "maskedElement" as const, element, } : undefined; const text = getTextForSVG(element, style, { x, y, width, height, }); return { elemInfo, attributeData, mightBeHovered, background: /** TODO: addNewChildren should be fixed. This is a workaround when non transparent bg appears after dark theme switch */ element instanceof HTMLBodyElement ? style.backgroundColor : backgroundSameAsRenderedParent || image?.type === "maskedElement" ? undefined : background, backdropFilter, border: getBorderForSVG(style), childAffectingStyles, image, text, }; }; const getBorderDetails = (value: string) => { const [width, display, ...colorParts] = value.split(" ").map((v) => v.trim()); const color = colorParts.join(" "); if (display !== "none" && width) { const widthNum = parseFloat(width); if (widthNum && color) { return { borderWidth: widthNum, borderColor: color, }; } } }; const getBorderForSVG = (style: CSSStyleDeclaration) => { const border = getBorderDetails(style.border); const outline = getBorderDetails( [style.outlineWidth, style.outlineStyle, style.outlineColor].join(" "), ); if (border) { return { type: "border" as const, outline, ...border, }; } const borders = [ style.borderTop, style.borderRight, style.borderBottom, style.borderLeft, ] .map(getBorderDetails) .filter(isDefined); if (borders.length) { return { type: "borders" as const, outline, borders, }; } if (!outline) return; return { type: "noBorder" as const, outline, }; }; ================================================ FILE: client/src/app/domToSVG/utils/isElementVisible.ts ================================================ import { includes } from "../../../dashboard/W_SQL/W_SQLBottomBar/W_SQLBottomBar"; export const isElementVisible = (element: Element) => { const style = window.getComputedStyle(element); const bbox = element.getBoundingClientRect(); if (!isElementNode(element) && !isTextNode(element)) return { isVisible: false, style, bbox }; if (isTextNode(element)) { return { isVisible: !!(element.textContent as string | null)?.trim().length, style, bbox, }; } const mightBeVisible = element.checkVisibility({ checkOpacity: true, checkVisibilityCSS: true, }); if (!mightBeVisible) return { isVisible: false, style, bbox }; const parent = element.parentElement; if (!parent) return { isVisible: true, style, bbox }; const isOnParentScreen = isInParentViewport(element, bbox); return { isVisible: isOnParentScreen, style, bbox }; }; const isInViewport = ( bbox: DOMRect, vport: Pick, ) => { return ( bbox.left <= vport.right && vport.x <= bbox.right && bbox.top <= vport.bottom && vport.y <= bbox.bottom ); }; export const isInParentViewport = ( element: Element, bbox: DOMRect, ): boolean => { const parent = element.parentElement; if (!parent) return true; const parentHidesOverflow = includes(getComputedStyle(parent).overflow, ["hidden", "scroll", "auto"]) && !includes(getComputedStyle(element).position, ["absolute", "fixed"]); if (!parentHidesOverflow) { const isVisible = isInViewport(bbox, { x: 0, y: 0, right: window.innerWidth || document.documentElement.clientWidth, bottom: window.innerHeight || document.documentElement.clientHeight, }); // if (!isVisible) { // return Array.from(element.children).some((child) => // isInParentViewport(child, child.getBoundingClientRect()), // ); // } return isVisible; } const parentBbox = parent.getBoundingClientRect(); const isVisible = isInViewport(bbox, parentBbox); if (!isVisible) { return Array.from(element.children).some((child) => isInParentViewport(child, child.getBoundingClientRect()), ); } return true; }; const checkIfObscured = ( element: Element, bbox = element.getBoundingClientRect(), ) => { const topElement = document.elementFromPoint( bbox.x + bbox.width / 2, bbox.y + bbox.height / 2, ); if (topElement && isElementNode(topElement)) { const isObscured = !element.contains(topElement); return isObscured ? topElement : undefined; } }; export const isElementNode = (node: Node): node is HTMLElement => node.nodeType === Node.ELEMENT_NODE; export const isTextNode = (node: Node): node is Text => node.nodeType === Node.TEXT_NODE; export const isImgNode = (node: Node): node is HTMLImageElement => isElementNode(node) && node.nodeName.toLowerCase() === "img"; export const isInputOrTextAreaNode = (node: Node): node is HTMLInputElement => isElementNode(node) && (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement); export const isSVGNode = (node: Node): node is SVGElement => isElementNode(node) && node.nodeName.toLowerCase() === "svg"; ================================================ FILE: client/src/app/domToSVG/utils/toFixed.ts ================================================ export const toFixed = (num: number, precision = 2) => { return parseFloat(num.toFixed(precision)); }; ================================================ FILE: client/src/appUtils.ts ================================================ import { useEffect, useState } from "react"; import type { Theme } from "./App"; import { type DocumentationFile } from "./app/CommandPalette/getDocumentation"; import type { UIDocFlat } from "./app/UIDocs"; import type { SQLEditorRef } from "./dashboard/SQLEditor/W_SQLEditor"; import type { getSVGif } from "./app/domToSVG/SVGif/getSVGif"; import type { domToThemeAwareSVG } from "./app/domToSVG/domToThemeAwareSVG"; type Unsubscribe = { unsubscribe: () => void; }; type OnStateChange = (newState: S) => void; type Subscribe = (sc: OnStateChange) => Unsubscribe; export type ReactiveState = { initialState: S; set: (newState: S) => void; get: () => S; subscribe: Subscribe; }; export const createReactiveState = ( initialState: S, onChange?: (state: S) => void, ) => { const handler: { listeners: OnStateChange[]; currentState: S; } = { listeners: [], currentState: initialState, }; const rootReference: ReactiveState = { subscribe: (listener) => { handler.listeners.push(listener); return { unsubscribe: () => { handler.listeners = handler.listeners.filter((l) => l !== listener); }, }; }, set: (newState: S) => { handler.currentState = newState; handler.listeners.forEach((l) => l(handler.currentState)); onChange?.(newState); }, initialState: initialState, get: () => handler.currentState, }; return rootReference; }; export const useReactiveState = (store: ReactiveState) => { const [state, setState] = useState(store.get()); useEffect(() => { return store.subscribe((newState) => { setState(newState); }).unsubscribe; }, [store]); return { state, setState: (data) => { store.set(data); }, }; }; export const iOS = () => { return ( [ "iPad Simulator", "iPhone Simulator", "iPod Simulator", "iPad", "iPhone", "iPod", ].includes(navigator.platform) || // iPad on iOS 13 detection (navigator.userAgent.includes("Mac") && "ontouchend" in document) ); }; declare global { interface Window { __prglIsImporting: any; /** * /Mobi/i.test(window.navigator.userAgent); */ isMobileDevice: boolean; /** * window.matchMedia("(any-hover: none)").matches */ isTouchOnlyDevice: boolean; /** * window.innerWidth < 700 */ isLowWidthScreen: boolean; /** * window.innerWidth < 1200 */ isMediumWidthScreen: boolean; isIOSDevice: boolean; isMobile: boolean; toSVG: typeof domToThemeAwareSVG; getSVGif: typeof getSVGif; documentation: DocumentationFile[]; flatUIDocs: UIDocFlat[]; } interface HTMLDivElement { sqlRef?: SQLEditorRef; } } export const appTheme = createReactiveState("light"); const checkSize = () => { window.isLowWidthScreen = window.innerWidth < 700; window.isMediumWidthScreen = window.innerWidth < 1200; }; window.isIOSDevice = iOS(); window.isMobileDevice = /Mobi/i.test(window.navigator.userAgent); window.isTouchOnlyDevice = window.matchMedia("(any-hover: none)").matches; window.isMobile = window.isLowWidthScreen || window.isIOSDevice; checkSize(); window.addEventListener("resize", checkSize); ================================================ FILE: client/src/components/AlertProvider.tsx ================================================ import React, { createContext, useCallback, useContext, useMemo, useState, } from "react"; import Popup, { type PopupProps } from "./Popup/Popup"; import ErrorComponent from "./ErrorComponent"; import { useIsMounted } from "prostgles-client"; type AlertDialogProps = Pick< PopupProps, "children" | "footerButtons" | "title" | "subTitle" | "contentClassName" >; export type AlertContext = { addAlert: (props: AlertDialogProps | string) => void; }; const AlertContext = createContext(undefined); export const AlertProvider = ({ children }: { children: React.ReactNode }) => { const [alerts, setAlerts] = useState([]); const removeFirstAlert = useCallback(() => { setAlerts((prevAlerts) => prevAlerts.slice(1)); }, []); const addAlert: AlertContext["addAlert"] = useCallback((newAlert) => { setAlerts((prevAlerts) => [ ...prevAlerts, typeof newAlert === "string" ? { children: newAlert } : newAlert, ]); }, []); const contextValue = useMemo( () => ({ addAlert, }), [addAlert], ); const alertProps = useMemo(() => alerts.at(0), [alerts]); return ( {children} {alertProps && ( )} ); }; export const useAlert = () => { const context = useContext(AlertContext); if (!context) { throw new Error("useAlert must be used within an AlertProvider"); } return context; }; export const useOnErrorAlert = () => { const alert = useAlert(); const getIsMounted = useIsMounted(); const onErrorAlert = useCallback( async (promiseFunc: () => Promise) => { await promiseFunc().catch((error) => { if (!getIsMounted()) return; // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment alert.addAlert({ children: }); }); }, [alert, getIsMounted], ); return { onErrorAlert }; }; ================================================ FILE: client/src/components/Animations.css ================================================ .custom-animations.success-checkmark { width: 80px; height: 115px; margin: 0 auto; } .custom-animations.success-checkmark .check-icon { width: 80px; height: 80px; position: relative; border-radius: 50%; box-sizing: content-box; border: 4px solid #4caf50; } .custom-animations.success-checkmark .check-icon::before { top: 3px; left: -2px; width: 30px; transform-origin: 100% 50%; border-radius: 100px 0 0 100px; } .custom-animations.success-checkmark .check-icon::after { top: 0; left: 30px; width: 60px; transform-origin: 0 50%; border-radius: 0 100px 100px 0; animation: rotate-circle 4.25s ease-in; } .custom-animations.success-checkmark .check-icon::before, .custom-animations.success-checkmark .check-icon::after { content: ""; height: 100px; position: absolute; transform: rotate(-45deg); } .custom-animations.success-checkmark .check-icon .icon-line { height: 5px; background-color: #4caf50; display: block; border-radius: 2px; position: absolute; z-index: 10; } .custom-animations.success-checkmark .check-icon .icon-line.line-tip { top: 46px; left: 14px; width: 25px; transform: rotate(45deg); animation: icon-line-tip 0.75s; } .custom-animations.success-checkmark .check-icon .icon-line.line-long { top: 38px; right: 8px; width: 47px; transform: rotate(-45deg); animation: icon-line-long 0.75s; } .custom-animations.success-checkmark .icon-circle { top: -4px; left: -4px; z-index: 10; width: 80px; height: 80px; border-radius: 50%; position: absolute; box-sizing: content-box; border: 4px solid rgba(76, 175, 80, 0.5); } .custom-animations.success-checkmark .icon-fix { top: 8px; width: 5px; left: 26px; z-index: 1; height: 85px; position: absolute; transform: rotate(-45deg); } @keyframes rotate-circle { 0% { transform: rotate(-45deg); } 5% { transform: rotate(-45deg); } 12% { transform: rotate(-405deg); } 100% { transform: rotate(-405deg); } } @keyframes icon-line-tip { 0% { width: 0; left: 1px; top: 19px; } 54% { width: 0; left: 1px; top: 19px; } 70% { width: 50px; left: -8px; top: 37px; } 84% { width: 17px; left: 21px; top: 48px; } 100% { width: 25px; left: 14px; top: 45px; } } @keyframes icon-line-long { 0% { width: 0; right: 46px; top: 54px; } 65% { width: 0; right: 46px; top: 54px; } 84% { width: 55px; right: 0px; top: 35px; } 100% { width: 47px; right: 8px; top: 38px; } } .custom-animations.checkmark { width: 1em; height: 1em; font-size: 36px; border-radius: 50%; display: block; stroke-width: 2; stroke: #fff; stroke-miterlimit: 10; margin: 10% auto; box-shadow: inset 0px 0px 0px #7ac142; animation: fill 0.4s ease-in-out 0.4s forwards, scale 0.3s ease-in-out 0.9s both; stroke: #7ac142; } .custom-animations .checkmark__circle { stroke-dasharray: 166; stroke-dashoffset: 166; stroke-width: 2; stroke-miterlimit: 10; stroke: #7ac142; fill: white; /* animation: stroke 0.6s cubic-bezier(0.65, 0, 0.45, 1) forwards; */ animation: stroke 0.5s ease-out forwards; } .custom-animations .checkmark__check { transform-origin: 50% 50%; stroke-dasharray: 48; stroke-dashoffset: 48; animation: stroke 0.3s cubic-bezier(0.65, 0, 0.45, 1) 0.5s forwards; } @keyframes stroke { 100% { stroke-dashoffset: 0; } } @keyframes scale { 0%, 100% { transform: none; } 50% { transform: scale3d(1.1, 1.1, 1); } } @keyframes fill { 100% { box-shadow: inset 0px 0px 0px 30px #7ac142; } } ================================================ FILE: client/src/components/Animations.tsx ================================================ import React, { useEffect } from "react"; import "./Animations.css"; import type { TestSelectors } from "../Testing"; import { classOverride } from "./Flex"; import { useIsMounted } from "prostgles-client"; export type DivProps = React.DetailedHTMLProps< React.HTMLAttributes, HTMLDivElement >; export const Success = (props: DivProps) => { const { className = "", ...otherProps } = props; return (
); }; export class SuccessSVG extends React.Component> { render() { const { className = "" } = this.props; return ( ); } } const smallStyle = { transform: `scale(0.5) translate(-50%, -50%)`, width: "40px", height: "40px", }; const textSizedStyle = { transform: `scale(0.25) translate(-75%, -175%)`, width: "20px", height: "20px", }; export const SuccessMini = ({ children }: { children: React.ReactNode }) => { return (
{children}
); }; type FlashMessageProps = { message: string; duration?: { millis: number; onEnd: VoidFunction; }; variant?: "small" | "text-sized"; } & TestSelectors & Pick; export const SuccessMessage = ({ message, className = "", duration, variant, ...divProps }: FlashMessageProps) => { const getIsMounted = useIsMounted(); useEffect(() => { if (duration) { const timeout = setInterval(() => { if (!getIsMounted()) return; duration.onEnd(); }, duration.millis); return () => clearInterval(timeout); } }, [duration, getIsMounted]); return (
{message}
); }; ================================================ FILE: client/src/components/Btn.css ================================================ button.disabled { cursor: not-allowed !important; } button.disabled:not(.no-fade):not(.hidden) { opacity: 0.5 !important; } button { background: transparent; } button.fade-in, .fade-in-2 { -webkit-animation: fadein 2s; /* Safari, Chrome and Opera > 12.1 */ -moz-animation: fadein 2s; /* Firefox < 16 */ -ms-animation: fadein 2s; /* Internet Explorer */ -o-animation: fadein 2s; /* Opera < 12.1 */ animation: fadein 2s; } .fade-in { -webkit-animation: fadein 0.5s; /* Safari, Chrome and Opera > 12.1 */ -moz-animation: fadein 0.5s; /* Firefox < 16 */ -ms-animation: fadein 0.5s; /* Internet Explorer */ -o-animation: fadein 0.5s; /* Opera < 12.1 */ animation: fadein 0.5s; } @keyframes fadein { from { opacity: 0; } to { opacity: 1; } } /* Firefox < 16 */ @-moz-keyframes fadein { from { opacity: 0; } to { opacity: 1; } } /* Safari, Chrome and Opera > 12.1 */ @-webkit-keyframes fadein { from { opacity: 0; } to { opacity: 1; } } /* Internet Explorer */ @-ms-keyframes fadein { from { opacity: 0; } to { opacity: 1; } } /* Opera < 12.1 */ @-o-keyframes fadein { from { opacity: 0; } to { opacity: 1; } } .btn-color-default { --color: var(--gray-600); --filled-bg: var(--gray-400); --border-color: var(--gray-400); --faded-bg: #7d7d7d38; --faded-bg-hover: #5252525e; } .dark-theme .btn-color-default { --filled-bg: #5d5d5d; --border-color: var(--gray-400); --color: var(--gray-200); --faded-bg: #7d7d7d61; --faded-bg-hover: #7d7d7dc4; } .btn-color-action { --filled-bg: var(--blue-500); --border-color: var(--blue-500); --color: var(--blue-500); --faded-bg: var(--blue-100); --faded-bg-hover: var(--blue-200); } .dark-theme .btn-color-action { --filled-bg: #3079c5; --border-color: #5292d7; --color: var(--blue-200); --faded-bg: #004fa3cc; --faded-bg-hover: #6195e4ee; } .dark-theme .btn-color-action.btn-faded { --color: white; } .btn-outline.btn-color-action { --color: #479dff; } .dark-theme .btn-default.btn-color-action { --color: #5ba4ff; } .btn-color-green { --filled-bg: var(--green-500); --border-color: var(--green-500); --color: var(--green-800); --faded-bg: var(--green-100); --faded-bg-hover: var(--green-200); } .btn-color-danger { --color: var(--text-danger); --filled-bg: #b31010; --border-color: var(--text-danger); --faded-bg: var(--red-100); --faded-bg-hover: #be181833; } .btn-color-warn { --filled-bg: var(--yellow-500); --border-color: var(--yellow-700); --color: var(--yellow-800); --faded-bg: #e4ce0559; --faded-bg-hover: #e6d96c; } .dark-theme .btn-color-warn { --color: #dabb8c; } .dark-theme .btn-color-danger { --filled-bg: #890808; --faded-bg: #5900003b; --faded-bg-hover: #7e020288; /* --color: #9c2a2a; */ } .btn-color-inherit { --color: inherit; --filled-bg: inherit; --border-color: inherit; --faded-bg: inherit; --faded-bg-hover: inherit; } .btn-color-transparent { --color: transparent; --filled-bg: transparent; --border-color: transparent; --faded-bg: transparent; --faded-bg-hover: transparent; } .btn-color-white { --color: white; --filled-bg: white; --border-color: white; --faded-bg: white; --faded-bg-hover: var(--gray-100); } .btn-color-indigo { --filled-bg: var(--indigo-600); --border-color: var(--indigo-400); --color: var(--indigo-800); --faded-bg: var(--indigo-200); --faded-bg-hover: var(--indigo-300); } .btn-filled { background: var(--filled-bg); color: white; } .btn.btn-filled:hover { filter: brightness(1.2); } .btn-outline { border: 1px solid var(--border-color); color: var(--color); background: var(--bg-color-0); } .btn-outline:hover { background: var(--filled-bg); border-color: var(--filled-bg) !important; color: white; } :root { --btn-hover-bg: #dbdbdb; } :root.dark-theme { --btn-hover-bg: var(--li-hover-bg); } .btn-default, .btn-icon, .btn-text { color: var(--color); } .btn-default:hover { filter: brightness(1.5); } .btn-icon:hover { background: var(--btn-hover-bg); } .btn-faded { background: var(--faded-bg); color: var(--color); } .btn.btn-faded:hover { background-color: var(--faded-bg-hover); } .underline-on-hover:hover { text-decoration: underline; } ================================================ FILE: client/src/components/Btn.tsx ================================================ import { mdiAlert, mdiCheck } from "@mdi/js"; import { omitKeys, pickKeys } from "prostgles-types"; import React from "react"; import { NavLink } from "react-router-dom"; import RTComp from "../dashboard/RTComp"; import type { TestSelectors } from "../Testing"; import { tout } from "../utils/utils"; import "./Btn.css"; import { parsedError } from "./ErrorComponent"; import { classOverride } from "./Flex"; import type { IconProps } from "./Icon/Icon"; import { Icon } from "./Icon/Icon"; import { Label, type LabelProps } from "./Label"; import Loading from "./Loader/Loading"; import Popup from "./Popup/Popup"; type ClickMessage = ( | { err: any } | { ok: React.ReactNode; replace?: boolean } | { loading: 1 | 0; delay?: number } ) & { duration?: number }; type ClickMessageArgs = (msg: ClickMessage, onEnd?: () => void) => void; type BtnCustomProps = ( | { iconPath?: never; iconStyle?: never; iconProps?: never; iconClassname?: never; iconNode?: never; } | { iconPath?: string; iconStyle?: React.CSSProperties; iconProps?: IconProps; iconClassname?: string; iconNode?: React.ReactNode; } ) & { iconPosition?: "left" | "right"; label?: LabelProps; /** * If provided then the button is disabled and will display a tooltip with this message */ disabledInfo?: string; /** * no-fade - will not fade out the button when disabled/loading */ disabledVariant?: "no-fade"; loading?: boolean; fadeIn?: boolean; _ref?: React.RefObject; size?: "large" | "default" | "small" | "micro" | "nano"; variant?: "outline" | "filled" | "faded" | "icon" | "text" | "default"; color?: | "danger" | "warn" | "action" | "inherit" | "transparent" | "white" | "green" | "indigo" | "default"; "data-id"?: string; /** * If true then title will be used as children */ titleAsLabel?: boolean; /** * If provided then will display a confirmation dialog before running any onClick function */ clickConfirmation?: { color: "danger" | "action" | "warn"; message: React.ReactNode; buttonText: string; }; } & ( | { onClickMessage?: ( e: React.MouseEvent, showMessage: ClickMessageArgs, ) => void; onClickPromise?: undefined; onClickPromiseMode?: undefined; } | { onClickMessage?: undefined; onClickPromise?: ( e: React.MouseEvent, ) => Promise | void; onClickPromiseMode?: "noTickIcon"; /** * Will display it instead of the error message */ onClickPromiseMessage?: React.ReactNode; } ); type KeysOfUnion = T extends T ? keyof T : never; type OmmitedKeys = | KeysOfUnion | "children" | "ref" | "onClick" | "style" | "title"; const CUSTOM_ATTRS: OmmitedKeys[] = [ "iconPath", "children", "disabledInfo", "title", "disabledVariant", "onClick", "loading", "color", "fadeIn", "_ref", "ref", "style", "size", "iconProps", "iconNode", "iconPosition", "iconClassname", "onClickMessage", "onClickPromise", "onClickPromiseMessage", "onClickPromiseMode", //@ts-ignore "asNavLink", "iconStyle", "titleAsLabel", "clickConfirmation", "label", ]; export type BtnProps = TestSelectors & BtnCustomProps & { /** * If provided then will render as an anchor */ href?: HREF; target?: string; asNavLink?: boolean; download?: boolean; } & React.HTMLAttributes & { value?: string; type?: "button" | "submit"; }; type BtnState = { show: boolean; clickMessage?: { type: "err" | "ok" | "loading"; msg: React.ReactNode; replace?: boolean; }; showClickConfirmation?: boolean; }; export default class Btn extends RTComp< BtnProps, BtnState > { state: BtnState = { show: true, }; timeOut?: NodeJS.Timeout; loadingTimeOut?: NodeJS.Timeout; latestMsg?: ClickMessage; clickMessage: ClickMessageArgs = (msg, onEnd) => { if (!this.mounted) return; this.latestMsg = msg; const hasErr = "err" in msg; if (this.loadingTimeOut) clearTimeout(this.loadingTimeOut); if (this.timeOut) clearTimeout(this.timeOut); if (hasErr) { this.setState({ clickMessage: { type: "err", msg: parsedError(msg.err, true), }, }); } else if ("ok" in msg) { this.setState({ clickMessage: { type: "ok", msg: msg.ok, replace: msg.replace, }, }); } else if ("loading" in msg) { if (!msg.loading) { this.setState({ clickMessage: undefined }, onEnd); } else { this.loadingTimeOut = setTimeout(() => { /** Check if msg is stale */ if ( this.mounted && JSON.stringify(this.latestMsg) === JSON.stringify(msg) ) { this.setState({ clickMessage: { type: "loading", msg: "" }, }); } }, msg.delay ?? 750); } return; } this.timeOut = setTimeout( () => { if (this.mounted) { this.setState({ clickMessage: undefined }, onEnd); } }, msg.duration ?? (hasErr ? 5000 : 2000), ); }; setPromise = async ( promise: ReturnType["onClickPromise"]>, ) => { this.clickMessage({ loading: 1, delay: 0 }); const minDuration = 500; const startTime = Date.now(); try { const res = await promise; const endTime = Date.now(); const duration = endTime - startTime; if (!this.mounted) return; if (duration < minDuration) { await tout(Math.max(0, minDuration - duration)); } this.clickMessage({ ok: "" }); } catch (err) { this.clickMessage({ err: ("onClickPromiseMessage" in this.props ? this.props.onClickPromiseMessage : undefined) ?? err, }); } }; render() { const { iconPath, iconPosition = "left", className = "", style = {}, iconStyle = {}, disabledInfo, disabledVariant, title, fadeIn, variant = "default", iconProps, iconNode, iconClassname = "", titleAsLabel, label, clickConfirmation, onClickPromiseMode, ...otherProps } = this.props; const { clickMessage, showClickConfirmation } = this.state; let extraStyle: React.CSSProperties = {}; const color = (clickMessage?.type === "err" ? "danger" : clickMessage?.type === "ok" ? "action" : this.props.color) ?? "default"; const loading = clickMessage?.type === "loading" ? true : (this.props.loading ?? false); const children = clickMessage?.msg || (titleAsLabel ? title : this.props.children); if (clickMessage?.replace) return clickMessage.msg; const isDisabled = disabledInfo || loading; let _className = ""; const { size = window.isLowWidthScreen ? "small" : "default" } = this.props; const hasBgClassname = (className + "").includes("bg-"); _className = " f-0 flex-row gap-p5 ai-center " + ("href" in this.props ? " button-css " : " ") + (variant === "outline" ? " b " : ""); if (children && !iconNode && !iconPath && !loading) { if (variant === "outline") { if (!hasBgClassname) _className = _className.replace("bg-transparent", "") + " bg-color-0 "; extraStyle = { borderColor: "currentcolor", }; } if (size === "micro") { extraStyle.padding = "5px"; } if (size === "small") { extraStyle.padding = "6px 8px"; } if (size === "default") { extraStyle.padding = "12px 14px"; } if (size === "large") { extraStyle.padding = "12px"; } if (variant === "text") { extraStyle.paddingLeft = 0; } } else { const padding = size === "nano" ? "0px" : size === "micro" ? "4px" : size === "small" ? "6px" : size === "default" ? "8px" : "10px"; const sidePadding = children ? `calc(${padding} * 1.5)` : padding; /** Must add right padding to icon and text button */ extraStyle = { padding: `${padding} ${sidePadding}`, }; } _className += (fadeIn ? " fade-in " : "") + (iconPath && !children ? " " : "rounded") + // round (isDisabled ? ` disabled ${disabledVariant ? "no-fade" : ""} ` : " "); const loadingSize = { large: 22, default: 20, small: 14, micro: 12, nano: 10, }[size]; const loadingMargin = { large: 1, default: 1, small: 0, micro: 2, nano: 0, }[size]; const childrenContent = children === undefined || children === null || children === "" ? null : loading ?
{children}
: children; const content = ( <> {iconPosition === "right" && childrenContent} {!(iconPath || iconProps?.path || iconNode) || loading ? null : ( (iconNode ?? ( )) )} {loading && ( )} {iconPosition === "left" && childrenContent} ); type PropsOf = React.HTMLAttributes & { ref?: React.Ref }; const needsConfirmation = () => { if (clickConfirmation && !showClickConfirmation) { this.setState({ showClickConfirmation: true }); return true; } return false; }; let onClick = this.props.onClick; if (this.props.onClickPromise) { const { onClickPromise } = this.props; onClick = (e) => { !needsConfirmation() && this.setPromise(onClickPromise(e)); }; } else if (this.props.onClickMessage) { const { onClickMessage } = this.props; onClick = (e) => { !needsConfirmation() && onClickMessage(e, this.clickMessage); }; } else if (this.props.onClick) { onClick = (e) => { if (needsConfirmation()) return; this.props.onClick?.(e); }; } if (size === "small") { extraStyle.minHeight = "32px"; extraStyle.minWidth = "32px"; } const FontSizeMap: Record["size"], string> = { large: "16px", default: "16px", small: "14px", micro: "12px", nano: "10px", }; const finalProps: PropsOf | PropsOf = { ...omitKeys(this.props, CUSTOM_ATTRS as any), onClick: isDisabled ? !window.isMobileDevice ? undefined : () => alert(disabledInfo) : onClick, title: disabledInfo || title, style: { ...extraStyle, display: "flex", lineHeight: "1em", width: "fit-content", fontSize: FontSizeMap[size], ...style, }, onMouseDown: (e) => e.preventDefault(), className: classOverride( `${_className} btn btn-${variant} btn-size-${size} btn-color-${color} ws-nowrap w-fit `, className, ), ref: this.props._ref as any, ...pickKeys(otherProps, ["data-id"]), }; const withLabel = (content: React.ReactNode) => { if (label) { return (
); } return content; }; if ("href" in this.props && this.props.href) { if (this.props.asNavLink) { return withLabel( )} onClick={ !disabledInfo ? undefined : ( (e) => { e.preventDefault(); } ) } to={this.props.href} tabIndex={-1} > {content} , ); } return withLabel( )} target={this.props.target} onClick={disabledInfo ? undefined : () => false} {...(this.props.download && { download: true })} > {content} , ); } return withLabel( <> {clickConfirmation && showClickConfirmation && ( this.setState({ showClickConfirmation: false })} clickCatchStyle={{ opacity: 1 }} footerButtons={[ { label: "Cancel", onClickClose: true, }, { label: clickConfirmation.buttonText, color: clickConfirmation.color, "data-command": "Btn.ClickConfirmation.Confirm", variant: "filled", className: "ml-auto", onClick: (e) => { this.setState({ showClickConfirmation: false }); onClick?.(e); }, }, ]} > {clickConfirmation.message} )} , ); } } ================================================ FILE: client/src/components/ButtonBar.tsx ================================================ import React from "react"; import type { BtnProps } from "./Btn"; import Btn from "./Btn"; import ErrorComponent from "./ErrorComponent"; type P = { error?: any; buttons: BtnProps[]; style?: React.CSSProperties; className?: string; }; export const ButtonBar = ({ buttons, className = "", error, style }: P) => { if (!buttons.length && !error) return null; return (
{buttons.map((btnProps, i) => ( ))}
); }; ================================================ FILE: client/src/components/ButtonGroup.tsx ================================================ import React from "react"; import { isObject } from "@common/publishUtils"; import Btn from "./Btn"; import { FlexCol } from "./Flex"; import type { LabelProps } from "./Label"; import { Label } from "./Label"; import type { FullOption } from "./Select/Select"; import { Select } from "./Select/Select"; import { pickKeys } from "prostgles-types"; import type { TestSelectors } from "../Testing"; type P