From dca627eff89a93dba0cefa7b2a0b20c58ca1c100 Mon Sep 17 00:00:00 2001 From: polubis Date: Mon, 26 Jan 2026 16:54:31 +0100 Subject: [PATCH 01/48] wip --- package-lock.json | 158 +++++++++- package.json | 2 + src/api-4markdown-contracts/contracts.ts | 11 + src/api-4markdown-contracts/dtos.ts | 76 +++++ src/components/markdown-diff-viewer.tsx | 270 ++++++++++++++++++ src/components/markdown-widget.tsx | 190 +++++++++--- src/components/social-share.tsx | 4 +- .../document-change-history.container.tsx | 24 ++ src/containers/document-layout.container.tsx | 75 +++-- .../mindmap-preview.module.tsx | 2 + .../acts/load-resource-activity.act.ts | 185 ++++++++++++ .../activities/comment-added-activity.tsx | 50 ++++ .../activities/content-changed-activity.tsx | 62 ++++ .../activities/created-activity.tsx | 26 ++ .../activities/metadata-updated-activity.tsx | 79 +++++ .../activities/rating-changed-activity.tsx | 57 ++++ .../activities/score-changed-activity.tsx | 38 +++ .../visibility-changed-activity.tsx | 40 +++ .../components/activity-author-badge.tsx | 36 +++ .../components/activity-item.tsx | 50 ++++ .../components/activity-tile.tsx | 31 ++ .../change-history-skeleton-loader.tsx | 55 ++++ .../components/change-history.tsx | 142 +++++++++ .../resource-activity.container.tsx | 41 +++ src/modules/resource-activity/index.ts | 1 + src/modules/resource-activity/store/index.ts | 8 + src/modules/resource-activity/store/models.ts | 13 + .../resource-activity/store/selectors.ts | 23 ++ 28 files changed, 1676 insertions(+), 73 deletions(-) create mode 100644 src/components/markdown-diff-viewer.tsx create mode 100644 src/containers/document-change-history.container.tsx create mode 100644 src/modules/resource-activity/acts/load-resource-activity.act.ts create mode 100644 src/modules/resource-activity/components/activities/comment-added-activity.tsx create mode 100644 src/modules/resource-activity/components/activities/content-changed-activity.tsx create mode 100644 src/modules/resource-activity/components/activities/created-activity.tsx create mode 100644 src/modules/resource-activity/components/activities/metadata-updated-activity.tsx create mode 100644 src/modules/resource-activity/components/activities/rating-changed-activity.tsx create mode 100644 src/modules/resource-activity/components/activities/score-changed-activity.tsx create mode 100644 src/modules/resource-activity/components/activities/visibility-changed-activity.tsx create mode 100644 src/modules/resource-activity/components/activity-author-badge.tsx create mode 100644 src/modules/resource-activity/components/activity-item.tsx create mode 100644 src/modules/resource-activity/components/activity-tile.tsx create mode 100644 src/modules/resource-activity/components/change-history-skeleton-loader.tsx create mode 100644 src/modules/resource-activity/components/change-history.tsx create mode 100644 src/modules/resource-activity/containers/resource-activity.container.tsx create mode 100644 src/modules/resource-activity/index.ts create mode 100644 src/modules/resource-activity/store/index.ts create mode 100644 src/modules/resource-activity/store/models.ts create mode 100644 src/modules/resource-activity/store/selectors.ts diff --git a/package-lock.json b/package-lock.json index f5ec7e4a5..644630383 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,8 @@ "version": "1.0.0", "dependencies": { "@dagrejs/dagre": "^1.1.5", + "@git-diff-view/file": "^0.0.36", + "@git-diff-view/react": "^0.0.36", "@greenonsoftware/react-kit": "^0.1.0", "@tailwindcss/typography": "^0.5.16", "@xyflow/react": "^12.9.0", @@ -3419,6 +3421,79 @@ "strip-ansi": "^6.0.0" } }, + "node_modules/@git-diff-view/core": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/core/-/core-0.0.36.tgz", + "integrity": "sha512-A3mN21C/ZdIJz0J9/PSZvLR1dLBu8BG13tVE+w4A9v8dLS5omkoQVjHM9K5GaoJideMhK/11y6V7CMbMXCPQoQ==", + "license": "MIT", + "dependencies": { + "@git-diff-view/lowlight": "^0.0.36", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/file": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/file/-/file-0.0.36.tgz", + "integrity": "sha512-3I5GMpZzdTwSurzbz8ZT9HL2xo74W5/o/SgWhUpEWjn2Z6pCZrHLAEzWeYyqewv30YM25PuK/JMODT4S5zhvzw==", + "license": "MIT", + "dependencies": { + "@git-diff-view/core": "^0.0.36", + "diff": "^8.0.2", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/lowlight": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/lowlight/-/lowlight-0.0.36.tgz", + "integrity": "sha512-8H3fUfnW+jw8EEbhEOr0vq5jXxZfbhNgvf9ruY6GljQdymj8j2R69k83gwMB/GltWEMt5o5624d8MxD21vqsZA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0" + } + }, + "node_modules/@git-diff-view/lowlight/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@git-diff-view/react": { + "version": "0.0.36", + "resolved": "https://registry.npmjs.org/@git-diff-view/react/-/react-0.0.36.tgz", + "integrity": "sha512-RqM4vkU2uoiav8di5LavR8UDkSP+fNDftlNOfAMQmcuLDFpDnLhtqcgot4+Q5rflkwK04UlYL6G72ps9XSGgDw==", + "license": "MIT", + "dependencies": { + "@git-diff-view/core": "^0.0.36", + "@types/hast": "^3.0.0", + "fast-diff": "^1.3.0", + "highlight.js": "^11.11.0", + "lowlight": "^3.3.0", + "reactivity-store": "^0.3.12", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/@git-diff-view/react/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/@graphql-codegen/add": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/@graphql-codegen/add/-/add-3.2.3.tgz", @@ -8188,6 +8263,21 @@ "resolve": "^1.10.0" } }, + "node_modules/@vue/reactivity": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.27.tgz", + "integrity": "sha512-vvorxn2KXfJ0nBEnj4GYshSgsyMNFnIQah/wczXlsNXt+ijhugmW+PpJ2cNPe4V6jpnBcs0MhCODKllWG+nvoQ==", + "license": "MIT", + "dependencies": { + "@vue/shared": "3.5.27" + } + }, + "node_modules/@vue/shared": { + "version": "3.5.27", + "resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.27.tgz", + "integrity": "sha512-dXr/3CgqXsJkZ0n9F3I4elY8wM9jMJpP3pvRG52r6m0tu/MsAFIe6JpXVGeNMd/D9F4hQynWT8Rfuj0bdm9kFQ==", + "license": "MIT" + }, "node_modules/@webassemblyjs/ast": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", @@ -12337,6 +12427,15 @@ "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", "dev": true }, + "node_modules/diff": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz", + "integrity": "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -14016,6 +14115,12 @@ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==" }, + "node_modules/fast-diff": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz", + "integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==", + "license": "Apache-2.0" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -18044,6 +18149,15 @@ "tslib": "^2.0.3" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/hosted-git-info": { "version": "3.0.8", "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-3.0.8.tgz", @@ -22607,6 +22721,30 @@ "node": ">=8" } }, + "node_modules/lowlight": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", + "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.0.0", + "highlight.js": "~11.11.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/lowlight/node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -32058,6 +32196,20 @@ "node": ">=0.4.0" } }, + "node_modules/reactivity-store": { + "version": "0.3.12", + "resolved": "https://registry.npmjs.org/reactivity-store/-/reactivity-store-0.3.12.tgz", + "integrity": "sha512-Idz9EL4dFUtQbHySZQzckWOTUfqjdYpUtNW0iOysC32mG7IjiUGB77QrsyR5eAWBkRiS9JscF6A3fuQAIy+LrQ==", + "license": "MIT", + "dependencies": { + "@vue/reactivity": "~3.5.22", + "@vue/shared": "~3.5.22", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/read": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/read/-/read-1.0.7.tgz", @@ -36790,9 +36942,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" diff --git a/package.json b/package.json index 42f62054e..5bf8d1219 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,8 @@ }, "dependencies": { "@dagrejs/dagre": "^1.1.5", + "@git-diff-view/file": "^0.0.36", + "@git-diff-view/react": "^0.0.36", "@greenonsoftware/react-kit": "^0.1.0", "@tailwindcss/typography": "^0.5.16", "@xyflow/react": "^12.9.0", diff --git a/src/api-4markdown-contracts/contracts.ts b/src/api-4markdown-contracts/contracts.ts index db560d88b..5629a4cf7 100644 --- a/src/api-4markdown-contracts/contracts.ts +++ b/src/api-4markdown-contracts/contracts.ts @@ -14,6 +14,7 @@ import { FullMindmapDto, ImageDto, MindmapDto, + ResourceActivityDto, ResourceCompletionDto, UserProfileDto, YourAccountDto, @@ -148,6 +149,15 @@ type ResourceCompletionsContracts = } >; +type ResourceActivityContracts = Contract< + "getResourceActivity", + ResourceActivityDto[], + { + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + } +>; + type UserProfilesContracts = | Contract< "rateUserProfile", @@ -399,6 +409,7 @@ type API4MarkdownContracts = | DocumentsContracts | AccountsContracts | ResourceCompletionsContracts + | ResourceActivityContracts | AccessGroupsContracts | UserProfilesContracts | UserProfileCommentsContracts; diff --git a/src/api-4markdown-contracts/dtos.ts b/src/api-4markdown-contracts/dtos.ts index 2f4fa5b54..f9a1b9f06 100644 --- a/src/api-4markdown-contracts/dtos.ts +++ b/src/api-4markdown-contracts/dtos.ts @@ -28,6 +28,7 @@ export type Atoms = { src: Atoms["Path"]; }; DocumentCommentId: Brand; + ResourceActivityId: Brand; Rating: Record; Score: { scoreAverage: number; @@ -228,3 +229,78 @@ export type DocumentCommentDto = Prettify< resourceId: Atoms["DocumentId"]; } >; + +export type ResourceActivityDto = + | { + id: Atoms["ResourceActivityId"]; + type: "created"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + } + | { + id: Atoms["ResourceActivityId"]; + type: "content-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + previousContent: string; + newContent: string; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + } + | { + id: Atoms["ResourceActivityId"]; + type: "visibility-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousVisibility: Atoms["ResourceVisibility"]; + newVisibility: Atoms["ResourceVisibility"]; + } + | { + id: Atoms["ResourceActivityId"]; + type: "metadata-updated"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousMeta: { + tags: string[]; + description: string | null; + }; + newMeta: { + tags: string[]; + description: string | null; + }; + } + | { + id: Atoms["ResourceActivityId"]; + type: "comment-added"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + comment: UserProfileCommentDto; + } + | { + id: Atoms["ResourceActivityId"]; + type: "rating-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousRating: Atoms["Rating"]; + newRating: Atoms["Rating"]; + } + | { + id: Atoms["ResourceActivityId"]; + type: "score-changed"; + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + cdate: Atoms["UTCDate"]; + authorProfile: UserProfileDto | null; + previousScore: Atoms["Score"]; + newScore: Atoms["Score"]; + }; diff --git a/src/components/markdown-diff-viewer.tsx b/src/components/markdown-diff-viewer.tsx new file mode 100644 index 000000000..b79b154ac --- /dev/null +++ b/src/components/markdown-diff-viewer.tsx @@ -0,0 +1,270 @@ +import React from "react"; +import { DiffView, DiffModeEnum } from "@git-diff-view/react"; +import { generateDiffFile, type DiffFile } from "@git-diff-view/file"; +import "@git-diff-view/react/styles/diff-view.css"; +import { Loader } from "design-system/loader"; +import { isClient } from "development-kit/ssr-csr"; +import { Modal2 } from "design-system/modal2"; +import { Button } from "design-system/button"; +import { BiExpand } from "react-icons/bi"; + +type MarkdownDiffViewerProps = { + previous: string; + current: string; + previousLabel?: string; + currentLabel?: string; + className?: string; + showFullScreenButton?: boolean; + modalTitle?: string; +}; + +const getTheme = (): "light" | "dark" => { + if (!isClient()) return "light"; + return window.__theme === "dark" ? "dark" : "light"; +}; + +const MarkdownDiffViewer = ({ + previous, + current, + previousLabel = "Previous", + currentLabel = "Current", + className = "", + showFullScreenButton = true, + modalTitle = "Markdown Diff Viewer", +}: MarkdownDiffViewerProps) => { + const [isProcessing, setIsProcessing] = React.useState(true); + const [theme, setTheme] = React.useState<"light" | "dark">(getTheme()); + const [diffFile, setDiffFile] = React.useState(null); + const [containerWidth, setContainerWidth] = React.useState(0); + const [isFullScreenOpen, setIsFullScreenOpen] = React.useState(false); + const containerRef = React.useRef(null); + const modalContainerRef = React.useRef(null); + const [modalContainerWidth, setModalContainerWidth] = React.useState(0); + + // Track container width for responsive diff mode + React.useEffect(() => { + if (!isClient() || !containerRef.current) return; + + const updateWidth = () => { + if (containerRef.current) { + setContainerWidth(containerRef.current.offsetWidth); + } + }; + + // Initial width + updateWidth(); + + // Use ResizeObserver if available, otherwise fall back to window resize + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(containerRef.current); + } else { + window.addEventListener("resize", updateWidth); + } + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } else { + window.removeEventListener("resize", updateWidth); + } + }; + }, []); + + // Track modal container width for responsive diff mode in full-screen + React.useEffect(() => { + if (!isClient() || !isFullScreenOpen || !modalContainerRef.current) return; + + const updateWidth = () => { + if (modalContainerRef.current) { + setModalContainerWidth(modalContainerRef.current.offsetWidth); + } + }; + + // Initial width + updateWidth(); + + // Use ResizeObserver if available, otherwise fall back to window resize + let resizeObserver: ResizeObserver | null = null; + if (typeof ResizeObserver !== "undefined") { + resizeObserver = new ResizeObserver(updateWidth); + resizeObserver.observe(modalContainerRef.current); + } else { + window.addEventListener("resize", updateWidth); + } + + return () => { + if (resizeObserver) { + resizeObserver.disconnect(); + } else { + window.removeEventListener("resize", updateWidth); + } + }; + }, [isFullScreenOpen]); + + // Listen for theme changes + React.useEffect(() => { + if (!isClient()) return; + + const previousHandler = window.__onThemeChange; + + const handleThemeChange = (themeArg: "light" | "dark") => { + const themeValue = themeArg || (window.__theme === "dark" ? "dark" : "light"); + setTheme(themeValue); + + if (previousHandler && typeof previousHandler === "function") { + previousHandler(themeValue); + } + }; + + window.__onThemeChange = handleThemeChange; + + return () => { + if (previousHandler && typeof previousHandler === "function") { + window.__onThemeChange = previousHandler; + } else { + window.__onThemeChange = () => {}; + } + }; + }, []); + + // Generate diff file using the library's function + // According to official docs: file.initTheme() -> file.init() -> file.buildSplitDiffLines() + React.useEffect(() => { + if (!isClient()) { + setIsProcessing(false); + return; + } + + try { + setIsProcessing(true); + + const file = generateDiffFile( + `${previousLabel}.md`, + previous, + `${currentLabel}.md`, + current, + "markdown", + "markdown" + ); + + // IMPORTANT: Order matters according to official docs! + // 1. initTheme() must be called FIRST + // 2. Then init() + // 3. Then buildSplitDiffLines() + file.initTheme(theme); + file.init(); + file.buildSplitDiffLines(); + + setDiffFile(file); + setIsProcessing(false); + } catch (error) { + console.error("Error generating diff file:", error); + setDiffFile(null); + setIsProcessing(false); + } + }, [previous, current, previousLabel, currentLabel, theme]); + + const hasContent = previous.trim().length > 0 || current.trim().length > 0; + const useUnifiedView = containerWidth > 0 && containerWidth < 1024; + const useUnifiedViewInModal = modalContainerWidth > 0 && modalContainerWidth < 1024; + + const renderDiffContent = (useUnified: boolean, isInModal: boolean = false) => { + if (!hasContent) { + return ( +
+

+ No content to compare +

+
+ ); + } + + if (isProcessing) { + return ( +
+ +
+ ); + } + + if (!diffFile) { + return ( +
+

+ Unable to generate diff +

+
+ ); + } + + return ( +
+ +
+ ); + }; + + return ( + <> +
+ {showFullScreenButton && hasContent && diffFile && ( +
+ +
+ )} + {renderDiffContent(useUnifiedView, false)} +
+ + {isFullScreenOpen && ( + setIsFullScreenOpen(false)} + > + + +
+ {renderDiffContent(useUnifiedViewInModal, true)} +
+
+
+ )} + + ); +}; + +export { MarkdownDiffViewer }; diff --git a/src/components/markdown-widget.tsx b/src/components/markdown-widget.tsx index 4b8d1bdd4..4e3b677ec 100644 --- a/src/components/markdown-widget.tsx +++ b/src/components/markdown-widget.tsx @@ -10,6 +10,7 @@ import { import { useCopy } from "development-kit/use-copy"; import { useKeyPress } from "development-kit/use-key-press"; import React, { ReactNode } from "react"; +import { ResourceActivityContainer } from "modules/resource-activity"; import { BiArrowToLeft, BiArrowToRight, @@ -17,16 +18,25 @@ import { BiBookContent, BiCheck, BiChevronDown, + BiCollapse, BiCopyAlt, BiDetail, + BiDotsHorizontal, + BiExpand, + BiHistory, BiListOl, } from "react-icons/bi"; +import { Atoms } from "api-4markdown-contracts"; +import Popover from "design-system/popover"; +import { Tabs } from "design-system/tabs"; type MarkdownWidgetProps = { chunksActive?: boolean; headerControls?: ReactNode; markdown: string; onClose(): void; + resourceId?: Atoms["ResourceId"]; + resourceType?: Atoms["ResourceType"]; }; const MAX_CHUNK_HEADING_LEVEL = 2; @@ -36,6 +46,8 @@ const MarkdownWidget = ({ headerControls, markdown, onClose, + resourceId, + resourceType, }: MarkdownWidgetProps) => { const bodyId = React.useId(); const markdownId = React.useId(); @@ -138,6 +150,9 @@ const MarkdownWidget = ({ useKeyPress([`d`, `D`, `ArrowRight`], goToNextChunk); const [copyState, copy] = useCopy(); + const historyModal = useSimpleFeature(); + const moreMenuModal = useSimpleFeature(); + const [isFullscreen, setIsFullscreen] = React.useState(false); React.useLayoutEffect(() => { const timeout = setTimeout(() => { @@ -193,7 +208,14 @@ const MarkdownWidget = ({ }, [markdownId, chunksMode.isOn]); return ( - + *]:!max-w-full [&>*]:!w-full [&>*]:!h-screen [&>*]:!max-h-full [&>*]:!rounded-none" + : "[&>*]:max-w-3xl [&>*]:h-full" + )} + onClose={onClose} + > {headerControls} - +
+ + {moreMenuModal.isOn && ( + + + {resourceId && resourceType && ( + + )} + + + + + + + + + + + )} +
)} - +
- - -
- -
- {finalHeadings.length > 1 && ( - - )} - - {chunksMode.isOn && ( + {chunksMode.isOn ? ( <> + {finalHeadings.length > 1 && ( + + )} + + ) : ( + <> + + {finalHeadings.length > 1 && ( + + )} + )}
+ {historyModal.isOn && resourceId && resourceType && ( + + + + )}
); }; diff --git a/src/components/social-share.tsx b/src/components/social-share.tsx index a698e335f..c58d1df7e 100644 --- a/src/components/social-share.tsx +++ b/src/components/social-share.tsx @@ -43,7 +43,6 @@ const SocialShare = () => { timeoutRef.current = setTimeout(() => { window.open(url(), `_blank`); - panel.off(); }, 1000); }; @@ -51,7 +50,6 @@ const SocialShare = () => { copy( `I’ve found a great article! Here’s the link: ${window.location.href}`, ); - panel.off(); }; React.useEffect(() => { @@ -72,7 +70,7 @@ const SocialShare = () => { {panel.isOn && ( - + {moreMenuModal.isOn && ( + + + + + )} - + + + {showDiff && ( +
+ +
+ )} + + ); +}; + +export { ContentChangedActivity }; diff --git a/src/modules/resource-activity/components/activities/created-activity.tsx b/src/modules/resource-activity/components/activities/created-activity.tsx new file mode 100644 index 000000000..4ec1954d5 --- /dev/null +++ b/src/modules/resource-activity/components/activities/created-activity.tsx @@ -0,0 +1,26 @@ +import React from "react"; +import { ActivityTile } from "../activity-tile"; +import { ActivityAuthorBadge } from "../activity-author-badge"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type CreatedActivityProps = { + activity: Extract; +}; + +const CreatedActivity = ({ activity }: CreatedActivityProps) => { + return ( + +
+ +

+ Resource Created +

+
+

+ The resource was initially created. This marks the beginning of the resource's lifecycle. +

+
+ ); +}; + +export { CreatedActivity }; diff --git a/src/modules/resource-activity/components/activities/metadata-updated-activity.tsx b/src/modules/resource-activity/components/activities/metadata-updated-activity.tsx new file mode 100644 index 000000000..d0d09887b --- /dev/null +++ b/src/modules/resource-activity/components/activities/metadata-updated-activity.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { ActivityTile } from "../activity-tile"; +import { ActivityAuthorBadge } from "../activity-author-badge"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type MetadataUpdatedActivityProps = { + activity: Extract; +}; + +const MetadataUpdatedActivity = ({ activity }: MetadataUpdatedActivityProps) => { + const tagsChanged = + activity.previousMeta.tags.join(",") !== activity.newMeta.tags.join(","); + const descriptionChanged = + activity.previousMeta.description !== activity.newMeta.description; + const hasPreviousTags = activity.previousMeta.tags.length > 0; + const hasNewTags = activity.newMeta.tags.length > 0; + const hasPreviousDescription = activity.previousMeta.description && activity.previousMeta.description.trim().length > 0; + const hasNewDescription = activity.newMeta.description && activity.newMeta.description.trim().length > 0; + + return ( + +
+ +

+ Metadata Updated +

+
+
+

Resource metadata was updated.

+ {(tagsChanged || descriptionChanged) ? ( + <> + {tagsChanged && ( +
+

Tags:

+
+ {hasPreviousTags ? ( + <> + + {activity.previousMeta.tags.join(", ") || "None"} + + + + ) : null} + + {hasNewTags ? activity.newMeta.tags.join(", ") : "None"} + +
+
+ )} + {descriptionChanged && ( +
+

Description:

+
+ {hasPreviousDescription ? ( + <> + + {activity.previousMeta.description} + + + + ) : null} + + {hasNewDescription ? activity.newMeta.description : "None"} + +
+
+ )} + + ) : ( +

+ No visible changes detected. +

+ )} +
+
+ ); +}; + +export { MetadataUpdatedActivity }; diff --git a/src/modules/resource-activity/components/activities/rating-changed-activity.tsx b/src/modules/resource-activity/components/activities/rating-changed-activity.tsx new file mode 100644 index 000000000..a1009f488 --- /dev/null +++ b/src/modules/resource-activity/components/activities/rating-changed-activity.tsx @@ -0,0 +1,57 @@ +import React from "react"; +import { RATING_ICONS } from "core/rating-config"; +import { ActivityTile } from "../activity-tile"; +import { ActivityAuthorBadge } from "../activity-author-badge"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type RatingChangedActivityProps = { + activity: Extract; +}; + +const RatingChangedActivity = ({ activity }: RatingChangedActivityProps) => { + return ( + +
+ +

+ Rating Changed +

+
+
+

Resource rating was updated.

+
+ {RATING_ICONS.map(([Icon, category]) => { + const previousCount = activity.previousRating?.[category] ?? 0; + const newCount = activity.newRating?.[category] ?? 0; + const hasChanged = previousCount !== newCount; + const shouldShow = previousCount > 0 || newCount > 0; + + if (!shouldShow) return null; + + return ( +
+
+ ); + })} +
+
+
+ ); +}; + +export { RatingChangedActivity }; diff --git a/src/modules/resource-activity/components/activities/score-changed-activity.tsx b/src/modules/resource-activity/components/activities/score-changed-activity.tsx new file mode 100644 index 000000000..e27a8aade --- /dev/null +++ b/src/modules/resource-activity/components/activities/score-changed-activity.tsx @@ -0,0 +1,38 @@ +import React from "react"; +import { ActivityTile } from "../activity-tile"; +import { ActivityAuthorBadge } from "../activity-author-badge"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type ScoreChangedActivityProps = { + activity: Extract; +}; + +const ScoreChangedActivity = ({ activity }: ScoreChangedActivityProps) => { + return ( + +
+ +

+ Score Changed +

+
+
+

Resource score was updated.

+
+ + {activity.previousScore.scoreAverage.toFixed(1)} + + + + {activity.newScore.scoreAverage.toFixed(1)} + +
+

+ Based on {activity.newScore.scoreCount} rating{activity.newScore.scoreCount !== 1 ? "s" : ""} +

+
+
+ ); +}; + +export { ScoreChangedActivity }; diff --git a/src/modules/resource-activity/components/activities/visibility-changed-activity.tsx b/src/modules/resource-activity/components/activities/visibility-changed-activity.tsx new file mode 100644 index 000000000..c7566e442 --- /dev/null +++ b/src/modules/resource-activity/components/activities/visibility-changed-activity.tsx @@ -0,0 +1,40 @@ +import React from "react"; +import { VisibilityIcon } from "components/visibility-icon"; +import { ActivityTile } from "../activity-tile"; +import { ActivityAuthorBadge } from "../activity-author-badge"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type VisibilityChangedActivityProps = { + activity: Extract; +}; + +const VisibilityChangedActivity = ({ + activity, +}: VisibilityChangedActivityProps) => { + return ( + +
+ +

+ Visibility Changed +

+
+
+

Resource visibility was changed.

+
+
+
+ +
+
+
+
+
+ ); +}; + +export { VisibilityChangedActivity }; diff --git a/src/modules/resource-activity/components/activity-author-badge.tsx b/src/modules/resource-activity/components/activity-author-badge.tsx new file mode 100644 index 000000000..8c67994fa --- /dev/null +++ b/src/modules/resource-activity/components/activity-author-badge.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { Avatar } from "design-system/avatar"; +import { UserProfileDto } from "api-4markdown-contracts"; + +type ActivityAuthorBadgeProps = { + authorProfile: UserProfileDto | null; + className?: string; +}; + +const ActivityAuthorBadge = ({ + authorProfile, + className, +}: ActivityAuthorBadgeProps) => { + if (!authorProfile) return null; + + const authorName = + authorProfile.displayName ?? authorProfile.id ?? "Unknown"; + + return ( +
+ + + {authorName} + +
+ ); +}; + +export { ActivityAuthorBadge }; diff --git a/src/modules/resource-activity/components/activity-item.tsx b/src/modules/resource-activity/components/activity-item.tsx new file mode 100644 index 000000000..78964fe4d --- /dev/null +++ b/src/modules/resource-activity/components/activity-item.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import { ResourceActivityDto } from "api-4markdown-contracts"; +import { CreatedActivity } from "./activities/created-activity"; +import { ContentChangedActivity } from "./activities/content-changed-activity"; +import { VisibilityChangedActivity } from "./activities/visibility-changed-activity"; +import { MetadataUpdatedActivity } from "./activities/metadata-updated-activity"; +import { CommentAddedActivity } from "./activities/comment-added-activity"; +import { RatingChangedActivity } from "./activities/rating-changed-activity"; +import { ScoreChangedActivity } from "./activities/score-changed-activity"; + +type ActivityItemProps = { + activity: ResourceActivityDto; + index: number; +}; + +const ActivityItem = ({ activity, index }: ActivityItemProps) => { + const renderActivity = () => { + switch (activity.type) { + case "created": + return ; + case "content-changed": + return ; + case "visibility-changed": + return ; + case "metadata-updated": + return ; + case "comment-added": + return ; + case "rating-changed": + return ; + case "score-changed": + return ; + default: + return null; + } + }; + + return ( +
  • + {renderActivity()} +
  • + ); +}; + +export { ActivityItem }; diff --git a/src/modules/resource-activity/components/activity-tile.tsx b/src/modules/resource-activity/components/activity-tile.tsx new file mode 100644 index 000000000..80bcf3780 --- /dev/null +++ b/src/modules/resource-activity/components/activity-tile.tsx @@ -0,0 +1,31 @@ +import React from "react"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type ActivityTileProps = { + activity: ResourceActivityDto; + children: React.ReactNode; +}; + +const formatDate = (cdate: ResourceActivityDto["cdate"]): string => { + return new Date(cdate).toLocaleDateString("en-US", { + year: "numeric", + month: "short", + day: "numeric", + }); +}; + +const ActivityTile = ({ activity, children }: ActivityTileProps) => { + return ( +
    + + {children} +
    + ); +}; + +export { ActivityTile, formatDate }; diff --git a/src/modules/resource-activity/components/change-history-skeleton-loader.tsx b/src/modules/resource-activity/components/change-history-skeleton-loader.tsx new file mode 100644 index 000000000..d1a435a5a --- /dev/null +++ b/src/modules/resource-activity/components/change-history-skeleton-loader.tsx @@ -0,0 +1,55 @@ +import React from "react"; +import { Skeleton } from "design-system/skeleton"; +import { c } from "design-system/c"; + +type ChangeHistorySkeletonLoaderProps = { + className?: string; +}; + +const ChangeHistorySkeletonLoader = ({ + className, +}: ChangeHistorySkeletonLoaderProps) => { + return ( +
    +
    +
    +
    +
    +
    + +
      + {Array.from({ length: 5 }).map((_, index) => ( +
    • +
      + +
      +
      + + +
      + +
      + + +
      +
    • + ))} +
    +
    +
    +
    + ); +}; + +export { ChangeHistorySkeletonLoader }; diff --git a/src/modules/resource-activity/components/change-history.tsx b/src/modules/resource-activity/components/change-history.tsx new file mode 100644 index 000000000..9e44445fe --- /dev/null +++ b/src/modules/resource-activity/components/change-history.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { BiError, BiHistory } from "react-icons/bi"; +import { useResourceActivityState } from "../store"; +import { Empty } from "design-system/empty"; +import { Err } from "design-system/err"; +import { ChangeHistorySkeletonLoader } from "./change-history-skeleton-loader"; +import { ActivityItem } from "./activity-item"; +import { ResourceActivityDto } from "api-4markdown-contracts"; + +type ChangeHistoryProps = { + onRetry?: () => void; +}; + +type ActivityGroup = { + monthYear: string; + activities: ResourceActivityDto[]; +}; + +const formatMonthYear = (cdate: ResourceActivityDto["cdate"]): string => { + const date = new Date(cdate); + return new Intl.DateTimeFormat("en-US", { + month: "long", + year: "numeric", + }).format(date); +}; + +const ChangeHistory = ({ onRetry }: ChangeHistoryProps) => { + const state = useResourceActivityState(); + + const groupedActivities = React.useMemo(() => { + if (state.is !== `ok` || state.data.length === 0) { + return []; + } + + const groups: ActivityGroup[] = []; + let currentGroup: ActivityGroup | null = null; + + for (const activity of state.data) { + const monthYearLabel = formatMonthYear(activity.cdate); + + if (!currentGroup || currentGroup.monthYear !== monthYearLabel) { + currentGroup = { + monthYear: monthYearLabel, + activities: [], + }; + groups.push(currentGroup); + } + + currentGroup.activities.push(activity); + } + + return groups; + }, [state]); + + if (state.is === `idle` || state.is === `busy`) { + return ; + } + + if (state.is === `fail`) { + return ( + + + + + Something went wrong! + {state.error.message} + {onRetry && ( + + Try Again + + )} + + ); + } + + if (state.data.length === 0) { + return ( + + + + + No Change History + + No change history available for this resource yet. + + + ); + } + + return ( +
    +
    +
    +
    +
    +
    + +
      + {groupedActivities.map((group, groupIndex) => { + const previousActivitiesCount = groupedActivities + .slice(0, groupIndex) + .reduce((sum, g) => sum + g.activities.length, 0); + + return ( + +
    • +
      + +
      +
    • + {group.activities.map((activity, activityIndexInGroup) => { + const index = previousActivitiesCount + activityIndexInGroup; + return ( + + ); + })} +
      + ); + })} +
    +
    +
    +
    + ); +}; + +export { ChangeHistory }; diff --git a/src/modules/resource-activity/containers/resource-activity.container.tsx b/src/modules/resource-activity/containers/resource-activity.container.tsx new file mode 100644 index 000000000..a1c3ddb6a --- /dev/null +++ b/src/modules/resource-activity/containers/resource-activity.container.tsx @@ -0,0 +1,41 @@ +import React from "react"; +import { Modal2 } from "design-system/modal2"; +import { loadResourceActivityAct } from "../acts/load-resource-activity.act"; +import { ChangeHistory } from "../components/change-history"; +import { useResourceActivityState } from "../store"; +import { Atoms } from "api-4markdown-contracts"; + +type ResourceActivityContainerProps = { + resourceId: Atoms["ResourceId"]; + resourceType: Atoms["ResourceType"]; + onClose(): void; +}; + +const ResourceActivityContainer = ({ + resourceId, + resourceType, + onClose, +}: ResourceActivityContainerProps) => { + const handleRetry = React.useCallback(() => { + loadResourceActivityAct(resourceId, resourceType); + }, [resourceId, resourceType]); + + React.useEffect(() => { + loadResourceActivityAct(resourceId, resourceType); + + return () => { + useResourceActivityState.reset(); + }; + }, [resourceId, resourceType]); + + return ( + + + + + + + ); +}; + +export { ResourceActivityContainer }; diff --git a/src/modules/resource-activity/index.ts b/src/modules/resource-activity/index.ts new file mode 100644 index 000000000..8a9b620f0 --- /dev/null +++ b/src/modules/resource-activity/index.ts @@ -0,0 +1 @@ +export { ResourceActivityContainer } from "./containers/resource-activity.container"; diff --git a/src/modules/resource-activity/store/index.ts b/src/modules/resource-activity/store/index.ts new file mode 100644 index 000000000..b948b1637 --- /dev/null +++ b/src/modules/resource-activity/store/index.ts @@ -0,0 +1,8 @@ +import { state } from "development-kit/state"; +import type { ResourceActivityState } from "./models"; + +const useResourceActivityState = state({ + is: `idle`, +}); + +export { useResourceActivityState }; diff --git a/src/modules/resource-activity/store/models.ts b/src/modules/resource-activity/store/models.ts new file mode 100644 index 000000000..e373dbfb6 --- /dev/null +++ b/src/modules/resource-activity/store/models.ts @@ -0,0 +1,13 @@ +import { ResourceActivityDto } from "api-4markdown-contracts"; +import { Transaction } from "development-kit/utility-types"; + +type ResourceActivityState = Transaction<{ + data: ResourceActivityDto[]; +}>; + +type OkResourceActivityState = Extract< + ResourceActivityState, + { is: `ok` } +>; + +export type { ResourceActivityState, OkResourceActivityState }; diff --git a/src/modules/resource-activity/store/selectors.ts b/src/modules/resource-activity/store/selectors.ts new file mode 100644 index 000000000..fb7190008 --- /dev/null +++ b/src/modules/resource-activity/store/selectors.ts @@ -0,0 +1,23 @@ +import { + OkResourceActivityState, + ResourceActivityState, +} from "./models"; + +const okResourceActivitySelector = ( + state: ResourceActivityState, +): OkResourceActivityState => { + if (state.is !== `ok`) + throw Error(`Invalid reading attempt. Cannot find resource activity`); + + return state; +}; + +const rawResourceActivitySelector = ( + state: ResourceActivityState, +): OkResourceActivityState["data"] => { + if (state.is !== `ok`) return []; + + return state.data; +}; + +export { okResourceActivitySelector, rawResourceActivitySelector }; From a7c8e69bd1b00d527830606f6e57cb186e202455 Mon Sep 17 00:00:00 2001 From: polubis Date: Thu, 29 Jan 2026 08:52:45 +0100 Subject: [PATCH 02/48] feat(app): add search option to the mindmap and documents modal --- src/components/markdown-diff-viewer.tsx | 28 +- src/components/markdown-widget.tsx | 17 +- src/containers/document-layout.container.tsx | 1 - .../containers/docs-list-modal.container.tsx | 317 ++++++++++++---- .../your-mindmaps-modal.container.tsx | 357 ++++++++++++++---- .../acts/load-resource-activity.act.ts | 55 +-- .../activities/comment-added-activity.tsx | 5 +- .../activities/content-changed-activity.tsx | 8 +- .../activities/created-activity.tsx | 8 +- .../activities/metadata-updated-activity.tsx | 41 +- .../activities/rating-changed-activity.tsx | 23 +- .../activities/score-changed-activity.tsx | 8 +- .../visibility-changed-activity.tsx | 21 +- .../components/activity-author-badge.tsx | 3 +- .../components/change-history.tsx | 3 +- src/modules/resource-activity/store/models.ts | 5 +- .../resource-activity/store/selectors.ts | 5 +- 17 files changed, 674 insertions(+), 231 deletions(-) diff --git a/src/components/markdown-diff-viewer.tsx b/src/components/markdown-diff-viewer.tsx index b79b154ac..5f2d8f629 100644 --- a/src/components/markdown-diff-viewer.tsx +++ b/src/components/markdown-diff-viewer.tsx @@ -39,7 +39,8 @@ const MarkdownDiffViewer = ({ const [isFullScreenOpen, setIsFullScreenOpen] = React.useState(false); const containerRef = React.useRef(null); const modalContainerRef = React.useRef(null); - const [modalContainerWidth, setModalContainerWidth] = React.useState(0); + const [modalContainerWidth, setModalContainerWidth] = + React.useState(0); // Track container width for responsive diff mode React.useEffect(() => { @@ -110,7 +111,8 @@ const MarkdownDiffViewer = ({ const previousHandler = window.__onThemeChange; const handleThemeChange = (themeArg: "light" | "dark") => { - const themeValue = themeArg || (window.__theme === "dark" ? "dark" : "light"); + const themeValue = + themeArg || (window.__theme === "dark" ? "dark" : "light"); setTheme(themeValue); if (previousHandler && typeof previousHandler === "function") { @@ -139,16 +141,16 @@ const MarkdownDiffViewer = ({ try { setIsProcessing(true); - + const file = generateDiffFile( `${previousLabel}.md`, previous, `${currentLabel}.md`, current, "markdown", - "markdown" + "markdown", ); - + // IMPORTANT: Order matters according to official docs! // 1. initTheme() must be called FIRST // 2. Then init() @@ -156,7 +158,7 @@ const MarkdownDiffViewer = ({ file.initTheme(theme); file.init(); file.buildSplitDiffLines(); - + setDiffFile(file); setIsProcessing(false); } catch (error) { @@ -168,9 +170,13 @@ const MarkdownDiffViewer = ({ const hasContent = previous.trim().length > 0 || current.trim().length > 0; const useUnifiedView = containerWidth > 0 && containerWidth < 1024; - const useUnifiedViewInModal = modalContainerWidth > 0 && modalContainerWidth < 1024; + const useUnifiedViewInModal = + modalContainerWidth > 0 && modalContainerWidth < 1024; - const renderDiffContent = (useUnified: boolean, isInModal: boolean = false) => { + const renderDiffContent = ( + useUnified: boolean, + isInModal: boolean = false, + ) => { if (!hasContent) { return (
    *]:!max-w-full [&>*]:!w-full [&>*]:!h-screen [&>*]:!max-h-full [&>*]:!rounded-none" - : "[&>*]:max-w-3xl [&>*]:h-full" - )} + isFullscreen + ? "!p-0 [&>*]:!max-w-full [&>*]:!w-full [&>*]:!h-screen [&>*]:!max-h-full [&>*]:!rounded-none" + : "[&>*]:max-w-3xl [&>*]:h-full", + )} onClose={onClose} > - +
    + + ) : ( + - - - + + + + )} - + {pending && ( { )} {docsStore.is === `fail` && (

    - Something went wrong... Try again with above button refresh button + Something went wrong… Try again with above button refresh button

    )} {docsStore.is === `ok` && ( @@ -87,58 +283,41 @@ const DocsListModalContainer = ({ onClose }: { onClose(): void }) => { {docs.length > 0 ? (
      {docs.map((doc) => ( -
    • selectDoc(doc)} - > -
      - - Edited{` `} - {formatDistance(new Date(), doc.mdate, { - addSuffix: true, - })} - {` `} - ago - - -
      - {doc.name} -
    • + doc={doc} + isActive={isDocActive(doc.id)} + onSelect={selectDoc} + /> ))}
    ) : (

    - No documents for selected filters + {searchQuery.trim().length > 0 + ? `No documents found matching "${searchQuery}"` + : `No documents for selected filters`}

    )} )}
    - - - {rangeFilters.map((range) => ( - setActiveRange(range)} - > - {range} - - ))} - - + {!isSearchActive && ( + + + {rangeFilters.map((range) => ( + handleRangeChange(range)} + > + {range} + + ))} + + + )} ); }; diff --git a/src/features/mindmap-creator/containers/your-mindmaps-modal.container.tsx b/src/features/mindmap-creator/containers/your-mindmaps-modal.container.tsx index 9c65ed969..6c640e6e8 100644 --- a/src/features/mindmap-creator/containers/your-mindmaps-modal.container.tsx +++ b/src/features/mindmap-creator/containers/your-mindmaps-modal.container.tsx @@ -1,7 +1,7 @@ import { Button } from "design-system/button"; import { Modal2 } from "design-system/modal2"; import React from "react"; -import { BiErrorAlt, BiRefresh } from "react-icons/bi"; +import { BiErrorAlt, BiRefresh, BiSearch, BiX } from "react-icons/bi"; import c from "classnames"; import { differenceInDays, formatDistance } from "date-fns"; import { Tabs } from "design-system/tabs"; @@ -26,97 +26,294 @@ const rangeLookup: Record = { "Really Old": [31, Number.MAX_VALUE], }; +interface MindmapListItemProps { + mindmap: MindmapDto; + isActive: boolean; + onSelect: (mindmapId: MindmapDto["id"]) => void; +} + +const MindmapListItem = React.memo( + ({ mindmap, isActive, onSelect }) => { + const handleClick = React.useCallback(() => { + onSelect(mindmap.id); + }, [mindmap.id, onSelect]); + + const formattedDate = React.useMemo( + () => + formatDistance(new Date(), mindmap.mdate, { + addSuffix: true, + }), + [mindmap.mdate], + ); + + return ( +
  • +
    + + Edited{` `} + {formattedDate} + {` `} + ago + + +
    + {mindmap.name} +
  • + ); + }, +); + +MindmapListItem.displayName = `MindmapListItem`; + const YourMindmapsContentContainer = ({ - activeRange, + searchQuery, + activeMindmapId, + filteredMindmaps, }: { - activeRange: RangeFilter; + searchQuery: string; + activeMindmapId: string | null; + filteredMindmaps: MindmapDto[]; }) => { - const { activeMindmapId } = useMindmapCreatorState(); - const mindmapsState = useMindmapCreatorState((state) => - readyMindmapsSelector(state.mindmaps), + const selectMindmap = React.useCallback( + (mindmapId: MindmapDto["id"]): void => { + selectMindmapAction(mindmapId); + }, + [], ); - const mindmaps = React.useMemo((): MindmapDto[] => { - const now = new Date(); - - return mindmapsState.data.filter((mindmap) => { - const diff = differenceInDays(now, mindmap.mdate); - const [from, to] = rangeLookup[activeRange]; - - return diff >= from && diff <= to; - }); - }, [mindmapsState, activeRange]); + const isMindmapActive = React.useCallback( + (mindmapId: string): boolean => activeMindmapId === mindmapId, + [activeMindmapId], + ); return ( <> - {mindmaps.length > 0 ? ( + {filteredMindmaps.length > 0 ? (
      - {mindmaps.map((mindmap) => ( -
    • ( + selectMindmapAction(mindmap.id)} - > -
      - - Edited{` `} - {formatDistance(new Date(), mindmap.mdate, { - addSuffix: true, - })} - {` `} - ago - - -
      - {mindmap.name} -
    • + mindmap={mindmap} + isActive={isMindmapActive(mindmap.id)} + onSelect={selectMindmap} + /> ))}
    ) : ( -
    No mindmaps for selected filters
    +
    + {searchQuery.trim().length > 0 + ? `No mindmaps found matching "${searchQuery}"` + : `No mindmaps for selected filters`} +
    )} ); }; const YourMindmapsModalContainer = () => { - const { mindmaps } = useMindmapCreatorState(); + const { mindmaps, activeMindmapId } = useMindmapCreatorState(); const [activeRange, setActiveRange] = React.useState( rangeFilters[0], ); + const [searchQuery, setSearchQuery] = React.useState(``); + const [isSearchActive, setIsSearchActive] = React.useState(false); + const searchInputRef = React.useRef(null); + + const mindmapsState = useMindmapCreatorState((state) => { + if (state.mindmaps.is === `ok`) { + return readyMindmapsSelector(state.mindmaps); + } + return null; + }); + + const handleToggleSearch = React.useCallback((): void => { + setIsSearchActive((prev) => { + const newState = !prev; + if (newState) { + // Focus input when opening search + setTimeout(() => { + searchInputRef.current?.focus(); + }, 0); + } else { + // Clear search when closing + setSearchQuery(``); + } + return newState; + }); + }, []); + + const handleSearchChange = React.useCallback( + (e: React.ChangeEvent): void => { + setSearchQuery(e.target.value); + }, + [], + ); + + const handleSearchKeyDown = React.useCallback( + (e: React.KeyboardEvent): void => { + if (e.key === `Escape`) { + setIsSearchActive(false); + setSearchQuery(``); + } + }, + [], + ); + + const handleRangeChange = React.useCallback((range: RangeFilter): void => { + setActiveRange(range); + }, []); + + const filteredMindmaps = React.useMemo((): MindmapDto[] => { + if (mindmaps.is !== `ok` || !mindmapsState) { + return []; + } + + const query = searchQuery.trim().toLowerCase(); + const hasQuery = query.length > 0; - const pending = mindmaps.is === `idle` || mindmaps.is === `busy`; + // Early exit for search mode with empty query + if (isSearchActive && !hasQuery) { + return mindmapsState.data; + } + + const now = new Date(); + + return mindmapsState.data.filter((mindmap) => { + // If search is active with query, filter by search query + if (isSearchActive && hasQuery) { + return ( + mindmap.name.toLowerCase().includes(query) || + mindmap.id.toLowerCase().includes(query) + ); + } + + // Otherwise, apply range filter + const diff = differenceInDays(now, mindmap.mdate); + const [from, to] = rangeLookup[activeRange]; + return diff >= from && diff <= to; + }); + }, [mindmaps.is, mindmapsState, activeRange, searchQuery, isSearchActive]); + + const pending = React.useMemo( + () => mindmaps.is === `idle` || mindmaps.is === `busy`, + [mindmaps.is], + ); return ( - - - +
    + + ) : ( + - - - - + + + + )} + + {pending && } {mindmaps.is === `ok` && ( - + )} {mindmaps.is === `fail` && ( <> @@ -137,21 +334,23 @@ const YourMindmapsModalContainer = () => { )} - - - {rangeFilters.map((range) => ( - setActiveRange(range)} - > - {range} - - ))} - - + {!isSearchActive && ( + + + {rangeFilters.map((range) => ( + handleRangeChange(range)} + > + {range} + + ))} + + + )} ); }; diff --git a/src/modules/resource-activity/acts/load-resource-activity.act.ts b/src/modules/resource-activity/acts/load-resource-activity.act.ts index a7d20d8ea..341a8081a 100644 --- a/src/modules/resource-activity/acts/load-resource-activity.act.ts +++ b/src/modules/resource-activity/acts/load-resource-activity.act.ts @@ -11,10 +11,12 @@ const generateMockActivity = ( const now = new Date(); const mockAuthor: ResourceActivityDto["authorProfile"] = { id: "user_mock_001" as Atoms["UserProfileId"], - cdate: new Date(now.getTime() - 365 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], - mdate: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 365 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], + mdate: new Date( + now.getTime() - 30 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], displayName: "John Doe", displayNameSlug: "john-doe" as Atoms["Slug"], bio: "Software developer passionate about clean code", @@ -33,8 +35,9 @@ const generateMockActivity = ( type: "score-changed", resourceId, resourceType, - cdate: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 5 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, previousScore: { scoreAverage: 7.5, @@ -52,8 +55,9 @@ const generateMockActivity = ( type: "rating-changed", resourceId, resourceType, - cdate: new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 7 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, previousRating: { ugly: 0, @@ -75,16 +79,19 @@ const generateMockActivity = ( type: "comment-added", resourceId, resourceType, - cdate: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 10 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, comment: { id: "comment_001" as Atoms["UserProfileCommentId"], ownerProfile: mockAuthor, - cdate: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], - mdate: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 10 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], + mdate: new Date( + now.getTime() - 10 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], content: "This is a great resource! Very helpful content.", ugly: 0, bad: 0, @@ -98,8 +105,9 @@ const generateMockActivity = ( type: "metadata-updated", resourceId, resourceType, - cdate: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 15 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, previousMeta: { tags: [], @@ -115,8 +123,9 @@ const generateMockActivity = ( type: "visibility-changed", resourceId, resourceType, - cdate: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 20 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, previousVisibility: "private", newVisibility: "public", @@ -128,8 +137,9 @@ const generateMockActivity = ( resourceType, previousContent: "Initial content", newContent: "Updated content with improvements", - cdate: new Date(now.getTime() - 25 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 25 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, }, { @@ -137,8 +147,9 @@ const generateMockActivity = ( type: "created", resourceId, resourceType, - cdate: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000) - .toISOString() as Atoms["UTCDate"], + cdate: new Date( + now.getTime() - 30 * 24 * 60 * 60 * 1000, + ).toISOString() as Atoms["UTCDate"], authorProfile: mockAuthor, }, ]; diff --git a/src/modules/resource-activity/components/activities/comment-added-activity.tsx b/src/modules/resource-activity/components/activities/comment-added-activity.tsx index ecca48615..09e5be2d2 100644 --- a/src/modules/resource-activity/components/activities/comment-added-activity.tsx +++ b/src/modules/resource-activity/components/activities/comment-added-activity.tsx @@ -17,7 +17,10 @@ const CommentAddedActivity = ({ activity }: CommentAddedActivityProps) => { return (
    - +

    Comment Added

    diff --git a/src/modules/resource-activity/components/activities/content-changed-activity.tsx b/src/modules/resource-activity/components/activities/content-changed-activity.tsx index 3b3b258f8..f54f879f3 100644 --- a/src/modules/resource-activity/components/activities/content-changed-activity.tsx +++ b/src/modules/resource-activity/components/activities/content-changed-activity.tsx @@ -16,13 +16,17 @@ const ContentChangedActivity = ({ activity }: ContentChangedActivityProps) => { return (
    - +

    Content Changed

    - The resource content was updated. Changes were made to improve clarity and structure. + The resource content was updated. Changes were made to improve clarity + and structure.

    @@ -189,7 +189,7 @@ const UserPopoverContent = ({ onClose }: { onClose(): void }) => { i={1} s={1} title="Sync your profile" - onClick={reloadYourUserProfile} + onClick={reloadYourUserProfileAct} > @@ -220,7 +220,7 @@ const UserPopoverContent = ({ onClose }: { onClose(): void }) => { s={1} auto title="Retry your profile load" - onClick={reloadYourUserProfile} + onClick={reloadYourUserProfileAct} > Try Again diff --git a/src/components/user-popover.tsx b/src/components/user-popover.tsx index 2b9a8aae0..379a7694b 100644 --- a/src/components/user-popover.tsx +++ b/src/components/user-popover.tsx @@ -4,7 +4,7 @@ import { BiLogInCircle } from "react-icons/bi"; import { useAuthStore } from "store/auth/auth.store"; import { useDocsStore } from "store/docs/docs.store"; import { YourAvatarContainer } from "../containers/your-avatar.container"; -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import { useSimpleFeature } from "@greenonsoftware/react-kit"; import { useYourUserProfileState } from "store/your-user-profile"; import { useYourAccountState } from "store/your-account"; @@ -41,7 +41,7 @@ const UserPopover = () => { return; } - logIn(); + logInAct(); }; return ( diff --git a/src/containers/document-layout.container.tsx b/src/containers/document-layout.container.tsx index 6e64019af..6e2720f5c 100644 --- a/src/containers/document-layout.container.tsx +++ b/src/containers/document-layout.container.tsx @@ -312,7 +312,12 @@ const DocumentLayoutContainer = () => { {sectionsModal.isOn && ( - + )} diff --git a/src/core/use-auth-start.ts b/src/core/use-auth-start.ts index 0a1699943..2e2d8e8fa 100644 --- a/src/core/use-auth-start.ts +++ b/src/core/use-auth-start.ts @@ -1,4 +1,4 @@ -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import React from "react"; import { useAuthStore } from "store/auth/auth.store"; @@ -18,7 +18,7 @@ const useAuthStart = () => { shouldStartAfterLogIn.current = true; - logIn(); + logInAct(); }, [auth], ); diff --git a/src/features/creator/containers/active-document-bar.container.tsx b/src/features/creator/containers/active-document-bar.container.tsx index 7e55a13b8..b0704b3d3 100644 --- a/src/features/creator/containers/active-document-bar.container.tsx +++ b/src/features/creator/containers/active-document-bar.container.tsx @@ -7,8 +7,8 @@ import { docStoreSelectors } from "store/doc/doc.store"; import { useDocsStore } from "store/docs/docs.store"; import { YourDocumentsContainer } from "./your-documents.container"; import { useForm } from "development-kit/use-form"; -import { updateDocumentCode } from "actions/update-document-code.action"; -import { updateDocumentName } from "actions/update-document-name.action"; +import { updateDocumentCodeAct } from "acts/update-document-code.act"; +import { updateDocumentNameAct } from "acts/update-document-name.act"; import { useDocumentCreatorState } from "store/document-creator"; import { useSimpleFeature } from "@greenonsoftware/react-kit"; @@ -41,7 +41,7 @@ const ActiveDocumentBarContainer = () => { > = async (e) => { e.preventDefault(); try { - await updateDocumentName(values.name); + await updateDocumentNameAct(values.name); edition.off(); } catch {} }; @@ -119,7 +119,7 @@ const ActiveDocumentBarContainer = () => { s={1} disabled={nonInteractive || !creatorStore.changed} title="Save changes" - onClick={updateDocumentCode} + onClick={updateDocumentCodeAct} > diff --git a/src/features/creator/containers/creator-error-modal.container.tsx b/src/features/creator/containers/creator-error-modal.container.tsx index d0f5644f0..367807582 100644 --- a/src/features/creator/containers/creator-error-modal.container.tsx +++ b/src/features/creator/containers/creator-error-modal.container.tsx @@ -5,7 +5,7 @@ import { docManagementStoreActions, docManagementStoreSelectors, } from "store/doc-management/doc-management.store"; -import { reloadYourDocuments } from "actions/reload-your-documents.action"; +import { reloadYourDocumentsAct } from "acts/reload-your-documents.act"; const CreatorErrorModalContainer = () => { const docManagementStore = docManagementStoreSelectors.useFail(); @@ -22,7 +22,7 @@ const CreatorErrorModalContainer = () => { s={2} auto title="Sync out of date document" - onClick={reloadYourDocuments} + onClick={reloadYourDocumentsAct} > Sync diff --git a/src/features/creator/containers/delete-document-modal.container.tsx b/src/features/creator/containers/delete-document-modal.container.tsx index 7960d04e2..e30e217c5 100644 --- a/src/features/creator/containers/delete-document-modal.container.tsx +++ b/src/features/creator/containers/delete-document-modal.container.tsx @@ -3,7 +3,7 @@ import { Field } from "design-system/field"; import { Input } from "design-system/input"; import { Modal2 } from "design-system/modal2"; import React from "react"; -import { deleteDocument } from "actions/delete-document.action"; +import { deleteDocumentAct } from "acts/delete-document.act"; import { useDocManagementStore } from "store/doc-management/doc-management.store"; import { docStoreSelectors } from "store/doc/doc.store"; @@ -15,7 +15,7 @@ const DeleteDocumentModalContainer = ({ onClose }: { onClose(): void }) => { const disabled = docManagementStore.is === `busy`; const handleConfirm = (): void => { - deleteDocument(onClose); + deleteDocumentAct(onClose); }; return ( diff --git a/src/features/creator/containers/doc-bar.container.tsx b/src/features/creator/containers/doc-bar.container.tsx index 6ebc9e2ee..33fdbe164 100644 --- a/src/features/creator/containers/doc-bar.container.tsx +++ b/src/features/creator/containers/doc-bar.container.tsx @@ -2,7 +2,7 @@ import React from "react"; import { useDocStore } from "store/doc/doc.store"; import { useAuthStore } from "store/auth/auth.store"; import { YourDocumentsContainer } from "./your-documents.container"; -import { getYourDocuments } from "actions/get-your-documents.action"; +import { getYourDocumentsAct } from "acts/get-your-documents.act"; const ActiveDocumentBarContainer = React.lazy( () => import(`./active-document-bar.container`), @@ -21,7 +21,7 @@ const DocBarContainer = () => { const authStore = useAuthStore(); React.useEffect(() => { - authStore.is === `authorized` && getYourDocuments(); + authStore.is === `authorized` && getYourDocumentsAct(); }, [authStore]); return ( diff --git a/src/features/creator/containers/docs-list-modal.container.tsx b/src/features/creator/containers/docs-list-modal.container.tsx index bf16147a6..fc05487a6 100644 --- a/src/features/creator/containers/docs-list-modal.container.tsx +++ b/src/features/creator/containers/docs-list-modal.container.tsx @@ -6,7 +6,7 @@ import { type DocsStoreOkState, useDocsStore } from "store/docs/docs.store"; import c from "classnames"; import { differenceInDays, formatDistance } from "date-fns"; import { Tabs } from "design-system/tabs"; -import { reloadYourDocuments } from "actions/reload-your-documents.action"; +import { reloadYourDocumentsAct } from "acts/reload-your-documents.act"; import type { API4MarkdownDto } from "api-4markdown-contracts"; import { Modal2 } from "design-system/modal2"; import { Loader } from "design-system/loader"; @@ -251,7 +251,7 @@ const DocsListModalContainer = ({ onClose }: { onClose(): void }) => { s={1} title="Sync documents" disabled={pending} - onClick={reloadYourDocuments} + onClick={reloadYourDocumentsAct} > diff --git a/src/features/creator/creator.view.tsx b/src/features/creator/creator.view.tsx index 0eaa953bc..096af7cdd 100644 --- a/src/features/creator/creator.view.tsx +++ b/src/features/creator/creator.view.tsx @@ -39,7 +39,7 @@ import { replaceText, } from "development-kit/textarea-utils"; import { useAuthStore } from "store/auth/auth.store"; -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import { useYourAccountState } from "store/your-account"; import { hasTokensForFeatureSelector } from "store/your-account/selectors"; import { REWRITE_ASSISTANT_TOKEN_COST } from "core/consts"; @@ -175,7 +175,7 @@ const CreatorView = () => { return; } - logIn(); + logInAct(); }; const maintainAssistantAppearance: ReactEventHandler = ( diff --git a/src/features/mindmap-creator/containers/node-form-modal.container.tsx b/src/features/mindmap-creator/containers/node-form-modal.container.tsx index d85aefcb3..dfc1cc9af 100644 --- a/src/features/mindmap-creator/containers/node-form-modal.container.tsx +++ b/src/features/mindmap-creator/containers/node-form-modal.container.tsx @@ -8,7 +8,12 @@ import { Hint } from "design-system/hint"; import { Input } from "design-system/input"; import { Textarea } from "design-system/textarea"; import { Button } from "design-system/button"; -import { BiPlusCircle, BiSave } from "react-icons/bi"; +import { + BiDotsHorizontal, + BiHistory, + BiPlusCircle, + BiSave, +} from "react-icons/bi"; import { type MindmapCreatorNode } from "store/mindmap-creator/models"; import { addNewEmbeddedNodeAction, @@ -21,8 +26,10 @@ import { validationLimits } from "../core/validation"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { openedNodeFormSelector } from "store/mindmap-creator/selectors"; import { openNodeContentInCreatorAct } from "acts/open-node-content-in-creator.act"; -import { context } from "@greenonsoftware/react-kit"; +import { context, useSimpleFeature } from "@greenonsoftware/react-kit"; import { Atoms } from "api-4markdown-contracts"; +import Popover from "design-system/popover"; +import { ResourceActivityContainer } from "modules/resource-activity"; type StepType = MindmapCreatorNode["type"] | `none`; @@ -39,6 +46,52 @@ const prepareBaseValues = (values: { }; }; +const NodeHistoryControls = ({ + nodeId, +}: { + nodeId: Atoms["MindmapNodeId"]; +}) => { + const historyModal = useSimpleFeature(); + const moreMenuModal = useSimpleFeature(); + + return ( + <> +
    + + {moreMenuModal.isOn && ( + + + + )} +
    + {historyModal.isOn && ( + + + + )} + + ); +}; + const [LocalProvider, useLocalContext] = context(() => { const { nodeForm } = useMindmapCreatorState(); @@ -57,6 +110,9 @@ const ExternalForm = () => { const nodeForm = useMindmapCreatorState((state) => openedNodeFormSelector(state.nodeForm), ); + const activeMindmapId = useMindmapCreatorState( + (state) => state.activeMindmapId, + ); const [initialValues] = React.useState(() => nodeForm.is === `active` @@ -129,7 +185,13 @@ const ExternalForm = () => { } closeButtonTitle="Cancel node edition" - /> + > + {activeMindmapId && ( + + )} + ) : ( { const nodeForm = useMindmapCreatorState((state) => openedNodeFormSelector(state.nodeForm), ); + const activeMindmapId = useMindmapCreatorState( + (state) => state.activeMindmapId, + ); const [initialValues] = React.useState(() => nodeForm.is === `active` @@ -312,7 +377,13 @@ const EmbeddedForm = () => { } closeButtonTitle="Cancel node edition" - /> + > + {activeMindmapId && ( + + )} + ) : ( const NodePreviewModalContainer = () => { const nodePreview = useMindmapCreatorState((state) => state.nodePreview); + const activeMindmapId = useMindmapCreatorState( + (state) => state.activeMindmapId, + ); + const historyEnabled = Boolean(activeMindmapId); const openNodeEdition = (): void => { if (nodePreview.is === `active`) { @@ -72,6 +77,12 @@ const NodePreviewModalContainer = () => { } onClose={closeNodePreviewAction} markdown={nodePreview.data.content} + resourceId={ + historyEnabled + ? (nodePreview.id as Atoms["MindmapNodeId"]) + : undefined + } + resourceType={historyEnabled ? "mindmap-node" : undefined} /> ); diff --git a/src/features/mindmap-creator/mindmap-creator.view.tsx b/src/features/mindmap-creator/mindmap-creator.view.tsx index 0d48545cc..530af7b81 100644 --- a/src/features/mindmap-creator/mindmap-creator.view.tsx +++ b/src/features/mindmap-creator/mindmap-creator.view.tsx @@ -6,7 +6,7 @@ import UserPopover from "components/user-popover"; import MoreNav from "components/more-nav"; import { MindmapCreatorContainer } from "./containers/mindmap-creator.container"; import { useAuthStore } from "store/auth/auth.store"; -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import { meta } from "../../../meta"; import { useMindmapCreatorState } from "store/mindmap-creator"; import { @@ -45,7 +45,7 @@ const AddNewMindmapContainer = () => { } triggerMindmapCreation(); - logIn(); + logInAct(); }; React.useEffect(() => { diff --git a/src/features/user-profile-preview/containers/add-comment-trigger.container.tsx b/src/features/user-profile-preview/containers/add-comment-trigger.container.tsx index 4ae72fe3a..062f565d3 100644 --- a/src/features/user-profile-preview/containers/add-comment-trigger.container.tsx +++ b/src/features/user-profile-preview/containers/add-comment-trigger.container.tsx @@ -1,5 +1,5 @@ import { useSimpleFeature } from "@greenonsoftware/react-kit"; -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import { Button } from "design-system/button"; import React, { useEffect } from "react"; import { BiPlusCircle } from "react-icons/bi"; @@ -23,7 +23,7 @@ const AddCommentTriggerContainer = () => { } showWidgetAfterLogIn.current = true; - logIn(); + logInAct(); }; useEffect(() => { diff --git a/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts b/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts index 10b9d85f6..62a03aae5 100644 --- a/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts +++ b/src/modules/resource-completions/hooks/use-resource-completion-toggle.ts @@ -1,7 +1,7 @@ import React from "react"; import { useAuthStore } from "store/auth/auth.store"; import { toggleResourceCompletionAct } from "../acts/toggle-resource-completion.act"; -import { logIn } from "actions/log-in.action"; +import { logInAct } from "acts/log-in.act"; import { API4MarkdownPayload } from "api-4markdown-contracts"; import { Transaction } from "development-kit/utility-types"; import { useResourceCompletion } from "./use-is-resource-completed"; @@ -22,7 +22,7 @@ const useResourceCompletionToggle = ( setState({ is: `busy` }); setState(await toggleResourceCompletionAct(payload)); } else { - logIn(); + logInAct(); } }, [payload]); diff --git a/tsconfig.json b/tsconfig.json index 94fc87921..b33db4201 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -113,7 +113,6 @@ "providers/*": ["./src/providers/*"], "core/*": ["./src/core/*"], "layouts/*": ["./src/layouts/*"], - "actions/*": ["./src/actions/*"], "acts/*": ["./src/acts/*"], "containers/*": ["./src/containers/*"], "api-4markdown": ["./src/api-4markdown"], From 3fb15ac7cb8fae58e343271d059742a0448953cd Mon Sep 17 00:00:00 2001 From: polubis Date: Fri, 6 Feb 2026 13:12:27 +0100 Subject: [PATCH 04/48] fix issues with modal, add rating to nodes --- src/acts/rate-mindmap-node.act.ts | 20 ++ src/api-4markdown-contracts/contracts.ts | 80 ++++++ src/api-4markdown-contracts/dtos.ts | 13 + src/components/markdown-widget.tsx | 193 +++++++++++--- src/development-kit/use-scroll-hide.ts | 33 ++- .../active-document-bar.container.tsx | 4 +- .../containers/sub-nav.container.tsx | 4 +- .../acts/add-mindmap-node-comment.act.ts | 10 + .../acts/delete-mindmap-node-comment.act.ts | 10 + .../acts/edit-mindmap-node-comment.act.ts | 10 + .../acts/load-mindmap-node-comments.act.ts | 10 + .../acts/rate-mindmap-node-comment.act.ts | 10 + .../components/mindmap-node-comments-list.tsx | 120 +++++++++ ...ap-node-comment-delete-modal.container.tsx | 97 +++++++ .../mindmap-node-comment-form.container.tsx | 236 ++++++++++++++++++ .../mindmap-node-comments.container.tsx | 183 ++++++++++++++ src/modules/mindmap-node-comments/index.ts | 1 + .../mindmap-node-comments.module.tsx | 23 ++ .../mindmap-node-comments/models/index.ts | 16 ++ .../mindmap-node-comments.provider.tsx | 43 ++++ .../components/mindmap-node-engagement.tsx | 196 +++++++++++++++ .../mindmap-preview.module.tsx | 24 ++ src/modules/mindmap-preview/models/index.ts | 24 +- 23 files changed, 1302 insertions(+), 58 deletions(-) create mode 100644 src/acts/rate-mindmap-node.act.ts create mode 100644 src/modules/mindmap-node-comments/acts/add-mindmap-node-comment.act.ts create mode 100644 src/modules/mindmap-node-comments/acts/delete-mindmap-node-comment.act.ts create mode 100644 src/modules/mindmap-node-comments/acts/edit-mindmap-node-comment.act.ts create mode 100644 src/modules/mindmap-node-comments/acts/load-mindmap-node-comments.act.ts create mode 100644 src/modules/mindmap-node-comments/acts/rate-mindmap-node-comment.act.ts create mode 100644 src/modules/mindmap-node-comments/components/mindmap-node-comments-list.tsx create mode 100644 src/modules/mindmap-node-comments/containers/mindmap-node-comment-delete-modal.container.tsx create mode 100644 src/modules/mindmap-node-comments/containers/mindmap-node-comment-form.container.tsx create mode 100644 src/modules/mindmap-node-comments/containers/mindmap-node-comments.container.tsx create mode 100644 src/modules/mindmap-node-comments/index.ts create mode 100644 src/modules/mindmap-node-comments/mindmap-node-comments.module.tsx create mode 100644 src/modules/mindmap-node-comments/models/index.ts create mode 100644 src/modules/mindmap-node-comments/providers/mindmap-node-comments.provider.tsx create mode 100644 src/modules/mindmap-preview/components/mindmap-node-engagement.tsx diff --git a/src/acts/rate-mindmap-node.act.ts b/src/acts/rate-mindmap-node.act.ts new file mode 100644 index 000000000..1968c0b74 --- /dev/null +++ b/src/acts/rate-mindmap-node.act.ts @@ -0,0 +1,20 @@ +import { getAPI, parseError } from "api-4markdown"; +import type { + API4MarkdownDto, + API4MarkdownPayload, +} from "api-4markdown-contracts"; +import type { AsyncResult } from "development-kit/utility-types"; + +const rateMindmapNodeAct = async ( + payload: API4MarkdownPayload<"rateMindmapNode">, +): AsyncResult> => { + try { + const data = await getAPI().call("rateMindmapNode")(payload); + return { is: "ok", data }; + } catch (rawError: unknown) { + const error = parseError(rawError); + return { is: "fail", error }; + } +}; + +export { rateMindmapNodeAct }; diff --git a/src/api-4markdown-contracts/contracts.ts b/src/api-4markdown-contracts/contracts.ts index 5629a4cf7..ffaa407b3 100644 --- a/src/api-4markdown-contracts/contracts.ts +++ b/src/api-4markdown-contracts/contracts.ts @@ -6,6 +6,7 @@ import type { PublicDocumentDto, ManualDocumentDto, DocumentCommentDto, + MindmapNodeCommentDto, } from "./dtos"; import { AccessGroupDto, @@ -134,6 +135,61 @@ type DocumentCommentsContracts = } >; +type MindmapNodeCommentsContracts = + | Contract< + `addMindmapNodeComment`, + MindmapNodeCommentDto, + { + comment: string; + resourceId: Atoms["MindmapNodeId"]; + } + > + | Contract< + `editMindmapNodeComment`, + MindmapNodeCommentDto, + { + commentId: Atoms["MindmapNodeCommentId"]; + resourceId: Atoms["MindmapNodeId"]; + content: string; + } + > + | Contract< + `getMindmapNodeComments`, + { + comments: MindmapNodeCommentDto[]; + hasMore: boolean; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["MindmapNodeCommentId"]; + } | null; + }, + { + resourceId: Atoms["MindmapNodeId"]; + nextCursor: { + cdate: Atoms["UTCDate"]; + id: Atoms["MindmapNodeCommentId"]; + } | null; + limit: number | null; + } + > + | Contract< + `deleteMindmapNodeComment`, + null, + { + resourceId: Atoms["MindmapNodeId"]; + commentId: Atoms["MindmapNodeCommentId"]; + } + > + | Contract< + `rateMindmapNodeComment`, + null, + { + resourceId: Atoms["MindmapNodeId"]; + commentId: Atoms["MindmapNodeCommentId"]; + category: Atoms["RatingCategory"]; + } + >; + type ResourceCompletionsContracts = | Contract< "getUserResourceCompletions", @@ -317,6 +373,28 @@ type DocumentsContracts = Pick >; +type MindmapNodeEngagementContracts = + | Contract< + `addMindmapNodeScore`, + { + average: number; + count: number; + values: Atoms["ScoreValue"][]; + }, + { + mindmapNodeId: Atoms["MindmapNodeId"]; + score: Atoms["ScoreValue"]; + } + > + | Contract< + `rateMindmapNode`, + Atoms["Rating"], + { + mindmapNodeId: Atoms["MindmapNodeId"]; + category: Atoms["RatingCategory"]; + } + >; + type MindmapsContracts = | Contract< `createMindmap`, @@ -402,11 +480,13 @@ type AnalyticsContracts = Contract< type API4MarkdownContracts = | DocumentCommentsContracts + | MindmapNodeCommentsContracts | AssetsContracts | AnalyticsContracts | MindmapsContracts | AIContracts | DocumentsContracts + | MindmapNodeEngagementContracts | AccountsContracts | ResourceCompletionsContracts | ResourceActivityContracts diff --git a/src/api-4markdown-contracts/dtos.ts b/src/api-4markdown-contracts/dtos.ts index f9a1b9f06..c29d4afdb 100644 --- a/src/api-4markdown-contracts/dtos.ts +++ b/src/api-4markdown-contracts/dtos.ts @@ -28,6 +28,7 @@ export type Atoms = { src: Atoms["Path"]; }; DocumentCommentId: Brand; + MindmapNodeCommentId: Brand; ResourceActivityId: Brand; Rating: Record; Score: { @@ -230,6 +231,18 @@ export type DocumentCommentDto = Prettify< } >; +export type MindmapNodeCommentDto = Prettify< + Atoms["Rating"] & { + id: Atoms["MindmapNodeCommentId"]; + ownerProfile: UserProfileDto; + cdate: Atoms["UTCDate"]; + mdate: Atoms["UTCDate"]; + content: string; + etag: Atoms["Etag"]; + resourceId: Atoms["MindmapNodeId"]; + } +>; + export type ResourceActivityDto = | { id: Atoms["ResourceActivityId"]; diff --git a/src/components/markdown-widget.tsx b/src/components/markdown-widget.tsx index 2a1518a93..43681325e 100644 --- a/src/components/markdown-widget.tsx +++ b/src/components/markdown-widget.tsx @@ -33,6 +33,7 @@ import { Tabs } from "design-system/tabs"; type MarkdownWidgetProps = { chunksActive?: boolean; headerControls?: ReactNode; + footerLeftControls?: ReactNode; markdown: string; onClose(): void; resourceId?: Atoms["ResourceId"]; @@ -44,6 +45,7 @@ const MAX_CHUNK_HEADING_LEVEL = 2; const MarkdownWidget = ({ chunksActive = true, headerControls, + footerLeftControls, markdown, onClose, resourceId, @@ -153,6 +155,25 @@ const MarkdownWidget = ({ const historyModal = useSimpleFeature(); const moreMenuModal = useSimpleFeature(); const [isFullscreen, setIsFullscreen] = React.useState(false); + const [articleWidth, setArticleWidth] = React.useState< + "narrow" | "medium" | "wide" | "full" + >("full"); + const [isLargeScreen, setIsLargeScreen] = React.useState(false); + + // Check if screen is large enough for width controls + // Using 768px (md breakpoint) as minimum - narrow option (max-w-2xl = 672px) needs space + React.useEffect(() => { + const checkScreenSize = () => { + setIsLargeScreen(window.innerWidth >= 768); + }; + + checkScreenSize(); + window.addEventListener("resize", checkScreenSize); + + return () => { + window.removeEventListener("resize", checkScreenSize); + }; + }, []); React.useLayoutEffect(() => { const timeout = setTimeout(() => { @@ -231,52 +252,119 @@ const MarkdownWidget = ({ {moreMenuModal.isOn && ( - + {resourceId && resourceType && ( + )} - - {resourceId && resourceType && ( + + + + + + + + +
    + {isFullscreen && isLargeScreen && ( +
    + +
    + {(["narrow", "medium", "wide", "full"] as const).map( + (width) => ( + + ), + )} +
    +
    )} - - - - - - - - - )}
    @@ -285,9 +373,31 @@ const MarkdownWidget = ({ id={bodyId} className={c("p-0", asideNavigation.isOn && "overflow-hidden")} > - - {chunksMode.isOn ? activeChunk : markdown} - +
    + + {chunksMode.isOn ? activeChunk : markdown} + +
    {asideNavigation.isOn && ( <>
    diff --git a/src/modules/mindmap-node-comments/acts/add-mindmap-node-comment.act.ts b/src/modules/mindmap-node-comments/acts/add-mindmap-node-comment.act.ts new file mode 100644 index 000000000..2be956221 --- /dev/null +++ b/src/modules/mindmap-node-comments/acts/add-mindmap-node-comment.act.ts @@ -0,0 +1,10 @@ +import { getAPI } from "api-4markdown"; +import { API4MarkdownDto, API4MarkdownPayload } from "api-4markdown-contracts"; + +const addMindmapNodeCommentAct = async ( + payload: API4MarkdownPayload<"addMindmapNodeComment">, +): Promise> => { + return getAPI().call("addMindmapNodeComment")(payload); +}; + +export { addMindmapNodeCommentAct }; diff --git a/src/modules/mindmap-node-comments/acts/delete-mindmap-node-comment.act.ts b/src/modules/mindmap-node-comments/acts/delete-mindmap-node-comment.act.ts new file mode 100644 index 000000000..a355e9f97 --- /dev/null +++ b/src/modules/mindmap-node-comments/acts/delete-mindmap-node-comment.act.ts @@ -0,0 +1,10 @@ +import { getAPI } from "api-4markdown"; +import { API4MarkdownDto, API4MarkdownPayload } from "api-4markdown-contracts"; + +const deleteMindmapNodeCommentAct = async ( + payload: API4MarkdownPayload<"deleteMindmapNodeComment">, +): Promise> => { + return getAPI().call("deleteMindmapNodeComment")(payload); +}; + +export { deleteMindmapNodeCommentAct }; diff --git a/src/modules/mindmap-node-comments/acts/edit-mindmap-node-comment.act.ts b/src/modules/mindmap-node-comments/acts/edit-mindmap-node-comment.act.ts new file mode 100644 index 000000000..e55edde73 --- /dev/null +++ b/src/modules/mindmap-node-comments/acts/edit-mindmap-node-comment.act.ts @@ -0,0 +1,10 @@ +import { getAPI } from "api-4markdown"; +import { API4MarkdownDto, API4MarkdownPayload } from "api-4markdown-contracts"; + +const editMindmapNodeCommentAct = async ( + payload: API4MarkdownPayload<"editMindmapNodeComment">, +): Promise> => { + return getAPI().call("editMindmapNodeComment")(payload); +}; + +export { editMindmapNodeCommentAct }; diff --git a/src/modules/mindmap-node-comments/acts/load-mindmap-node-comments.act.ts b/src/modules/mindmap-node-comments/acts/load-mindmap-node-comments.act.ts new file mode 100644 index 000000000..a749ca3da --- /dev/null +++ b/src/modules/mindmap-node-comments/acts/load-mindmap-node-comments.act.ts @@ -0,0 +1,10 @@ +import { getAPI } from "api-4markdown"; +import { API4MarkdownDto, API4MarkdownPayload } from "api-4markdown-contracts"; + +const loadMindmapNodeCommentsAct = async ( + payload: API4MarkdownPayload<"getMindmapNodeComments">, +): Promise> => { + return getAPI().call("getMindmapNodeComments")(payload); +}; + +export { loadMindmapNodeCommentsAct }; diff --git a/src/modules/mindmap-node-comments/acts/rate-mindmap-node-comment.act.ts b/src/modules/mindmap-node-comments/acts/rate-mindmap-node-comment.act.ts new file mode 100644 index 000000000..4e2b37982 --- /dev/null +++ b/src/modules/mindmap-node-comments/acts/rate-mindmap-node-comment.act.ts @@ -0,0 +1,10 @@ +import { getAPI } from "api-4markdown"; +import { API4MarkdownDto, API4MarkdownPayload } from "api-4markdown-contracts"; + +const rateMindmapNodeCommentAct = async ( + payload: API4MarkdownPayload<"rateMindmapNodeComment">, +): Promise> => { + return getAPI().call("rateMindmapNodeComment")(payload); +}; + +export { rateMindmapNodeCommentAct }; diff --git a/src/modules/mindmap-node-comments/components/mindmap-node-comments-list.tsx b/src/modules/mindmap-node-comments/components/mindmap-node-comments-list.tsx new file mode 100644 index 000000000..61d14270d --- /dev/null +++ b/src/modules/mindmap-node-comments/components/mindmap-node-comments-list.tsx @@ -0,0 +1,120 @@ +import React from "react"; +import { BiPencil, BiTrash } from "react-icons/bi"; +import { Avatar } from "design-system/avatar"; +import { formatDistance } from "date-fns"; +import { Button } from "design-system/button"; +import throttle from "lodash.throttle"; +import { Atoms, MindmapNodeCommentDto } from "api-4markdown-contracts"; +import { c } from "design-system/c"; +import { useFeature } from "@greenonsoftware/react-kit"; +import { MindmapNodeCommentDeleteModalContainer } from "../containers/mindmap-node-comment-delete-modal.container"; +import { rateMindmapNodeCommentAct } from "../acts/rate-mindmap-node-comment.act"; +import { useMindmapNodeCommentsContext } from "../providers/mindmap-node-comments.provider"; +import { RatePicker } from "components/rate-picker"; + +const rateCommentThrottled = throttle(rateMindmapNodeCommentAct, 5000); + +type MindmapNodeCommentsListProps = { + className?: string; + comments: MindmapNodeCommentDto[]; + userProfileId: Atoms["UserProfileId"] | null; + onEditStart(comment: MindmapNodeCommentDto): void; +}; + +const MindmapNodeCommentsList = ({ + className, + comments, + userProfileId, + onEditStart, +}: MindmapNodeCommentsListProps) => { + const [ratedComments, setRatedComments] = React.useState< + Record + >({}); + + const { mindmapNodeId } = useMindmapNodeCommentsContext(); + const deleteModal = useFeature(); + + const rateComment = ( + category: Atoms["RatingCategory"], + commentId: Atoms["MindmapNodeCommentId"], + ) => { + setRatedComments((prev) => ({ ...prev, [commentId]: category })); + rateCommentThrottled({ + commentId, + resourceId: mindmapNodeId, + category, + }); + }; + + return ( + <> +
      + {comments.map((comment) => ( +
    • +
      + +
      +

      + {comment.ownerProfile.displayName ?? `Anonymous`} +

      +

      + {formatDistance(new Date(), comment.mdate, { + addSuffix: true, + })} +

      +
      +
      +

      {comment.content}

      + rateComment(category, comment.id)} + /> + {comment.ownerProfile.id === userProfileId && ( +
      + + +
      + )} +
    • + ))} +
    + {deleteModal.is === "on" && ( + deleteModal.off()} + /> + )} + + ); +}; + +export { MindmapNodeCommentsList }; diff --git a/src/modules/mindmap-node-comments/containers/mindmap-node-comment-delete-modal.container.tsx b/src/modules/mindmap-node-comments/containers/mindmap-node-comment-delete-modal.container.tsx new file mode 100644 index 000000000..f7a01bf9a --- /dev/null +++ b/src/modules/mindmap-node-comments/containers/mindmap-node-comment-delete-modal.container.tsx @@ -0,0 +1,97 @@ +import React from "react"; +import { Err } from "design-system/err"; +import { BiError } from "react-icons/bi"; +import { Button } from "design-system/button"; +import { Modal2 } from "design-system/modal2"; +import { Loader } from "design-system/loader"; +import { deleteMindmapNodeCommentAct } from "../acts/delete-mindmap-node-comment.act"; +import { useMindmapNodeCommentsContext } from "../providers/mindmap-node-comments.provider"; +import { Atoms } from "api-4markdown-contracts"; +import { useMutation2 } from "core/use-mutation-2"; + +type MindmapNodeCommentDeleteModalContainerProps = { + commentId: Atoms["MindmapNodeCommentId"]; + onClose: () => void; +}; + +const MindmapNodeCommentDeleteModalContainer = ({ + commentId, + onClose, +}: MindmapNodeCommentDeleteModalContainerProps) => { + const { mindmapNodeId, commentsQuery, onCountChange } = + useMindmapNodeCommentsContext(); + + const deleteMutation = useMutation2({ + handler: () => + deleteMindmapNodeCommentAct({ + commentId, + resourceId: mindmapNodeId, + }), + onOk: () => { + commentsQuery.setData((currData) => ({ + ...currData, + comments: currData.comments.filter((comment) => comment.id !== commentId), + })); + if (commentsQuery.data) { + onCountChange(commentsQuery.data.comments.length - 1); + } + onClose(); + }, + }); + + return ( + + + + {deleteMutation.idle && ( +

    Are you sure you want to delete this comment?

    + )} + {deleteMutation.busy && } + {deleteMutation.error && ( + + + + + Something went wrong! + {deleteMutation.error.message} + deleteMutation.start()} + > + Try Again + + + )} +
    + + + + +
    + ); +}; + +export { MindmapNodeCommentDeleteModalContainer }; diff --git a/src/modules/mindmap-node-comments/containers/mindmap-node-comment-form.container.tsx b/src/modules/mindmap-node-comments/containers/mindmap-node-comment-form.container.tsx new file mode 100644 index 000000000..fd288c27a --- /dev/null +++ b/src/modules/mindmap-node-comments/containers/mindmap-node-comment-form.container.tsx @@ -0,0 +1,236 @@ +import { Button } from "design-system/button"; +import { Field } from "design-system/field"; +import { Modal2 } from "design-system/modal2"; +import { Textarea } from "design-system/textarea"; +import { ValidatorFn, ValidatorsSetup } from "development-kit/form"; +import { useForm } from "development-kit/use-form"; +import React from "react"; +import { BiErrorAlt, BiInfoCircle } from "react-icons/bi"; +import { useYourUserProfileState } from "store/your-user-profile"; +import { emit } from "core/app-events"; +import { Loader } from "design-system/loader"; +import { Atoms } from "api-4markdown-contracts"; +import { useMutation } from "core/use-mutation"; +import { editMindmapNodeCommentAct } from "../acts/edit-mindmap-node-comment.act"; +import { addMindmapNodeCommentAct } from "../acts/add-mindmap-node-comment.act"; +import { useMindmapNodeCommentsContext } from "../providers/mindmap-node-comments.provider"; + +const limits = { + content: { + min: 10, + max: 250, + }, +} as const; + +const commentContentValidator: ValidatorFn = (value) => { + const trimmed = value.trim(); + if (trimmed.length < limits.content.min) { + return `Comment must be at least ${limits.content.min} characters long`; + } + + if (trimmed.length > limits.content.max) { + return `Comment must be at most ${limits.content.max} characters long`; + } + + return null; +}; + +type FormValues = { + content: string; +}; + +const validators: ValidatorsSetup = { + content: [commentContentValidator], +}; + +type MindmapNodeCommentFormContainerProps = { + mode: "edit" | "add"; + content?: string; + commentId?: Atoms["MindmapNodeCommentId"]; +}; + +const MindmapNodeCommentFormContainer = ({ + mode, + content, + commentId, +}: MindmapNodeCommentFormContainerProps) => { + const { commentForm } = useMindmapNodeCommentsContext(); + + const yourUserProfile = useYourUserProfileState(); + + const { mindmapNodeId, commentsQuery, onCountChange } = + useMindmapNodeCommentsContext(); + + const isEditMode = mode === "edit"; + + const [{ invalid, values, result, untouched }, { inject }] = + useForm( + { + content: content ?? "", + }, + validators, + ); + + const confirmMutation = useMutation({ + handler: () => { + if (mode === "edit") { + if (!commentId) { + throw new Error("Comment ID is required"); + } + + return editMindmapNodeCommentAct({ + commentId, + resourceId: mindmapNodeId, + content: values.content, + }); + } + + return addMindmapNodeCommentAct({ + comment: values.content, + resourceId: mindmapNodeId, + }); + }, + onOk: (newData) => { + if (mode === "edit") { + commentsQuery.setData((currData) => ({ + hasMore: currData.hasMore, + nextCursor: currData.nextCursor, + comments: currData.comments.map((comment) => + comment.id === commentId ? newData : comment, + ), + })); + } else { + commentsQuery.setData((currData) => ({ + hasMore: currData.hasMore, + nextCursor: currData.nextCursor, + comments: [newData, ...currData.comments], + })); + if (commentsQuery.data) { + onCountChange(commentsQuery.data.comments.length + 1); + } + } + + commentForm.off(); + }, + }); + + const close = () => { + commentForm.off(); + }; + + const goToUserProfileForm = () => { + close(); + emit({ type: "SHOW_USER_PROFILE_FORM" }); + }; + + const busy = confirmMutation.is === "busy"; + + return ( + + + + {(yourUserProfile.is === "idle" || yourUserProfile.is === "busy") && ( + + )} + {yourUserProfile.is === "ok" && + (yourUserProfile.user ? ( + <> +

    + Please be aware that comments are public and{" "} + can be seen by anyone. You can always delete + your comment later. +

    + {result.content} + ) : ( + + {limits.content.min}-{limits.content.max} characters + + ) + } + > +