From 0a6710b68437be60feb9173ae02853572913559a Mon Sep 17 00:00:00 2001 From: hugo Date: Tue, 10 Feb 2026 15:24:36 +0100 Subject: [PATCH] feat: more cleanup work in UI --- VISION.md | 33 + package-lock.json | 999 +++++++++++++++++- package.json | 12 +- src/main/database/connection.ts | 19 +- src/main/database/schema.ts | 6 + src/main/engine/PostEngine.ts | 102 +- src/main/ipc/handlers.ts | 20 + src/main/preload.ts | 4 + src/renderer/components/Editor/Editor.css | 6 + src/renderer/components/Editor/Editor.tsx | 501 +++------ src/renderer/components/Sidebar/Sidebar.tsx | 54 +- .../WysiwygEditor/WysiwygEditor.tsx | 35 +- src/renderer/index.html | 2 +- src/renderer/main.tsx | 6 + src/renderer/store/appStore.ts | 63 +- src/renderer/store/index.ts | 1 - src/renderer/styles/global.css | 25 + src/renderer/types/electron.d.ts | 7 + tests/renderer/components/Editor.test.ts | 252 +++++ tests/renderer/store/appStore.test.ts | 163 +++ tests/setup.ts | 82 ++ vitest.config.ts | 14 +- 22 files changed, 1945 insertions(+), 461 deletions(-) create mode 100644 tests/renderer/components/Editor.test.ts create mode 100644 tests/renderer/store/appStore.test.ts diff --git a/VISION.md b/VISION.md index 9fe4092..a606251 100644 --- a/VISION.md +++ b/VISION.md @@ -8,6 +8,10 @@ sync to a cloud system for syncing data and also rendering the full blog. create a electron app in this folder that uses typescript for all the logic code and sqlite and a proper database framework around it for local storage of data and turso/libsql to sync against a cloud location for having offline work capabilities with syncing. The UI should be aligned with the UI patterns used by vscode. The name of the application is "blogging Desktop Server" and the shortname is bDS. Start with default layout for edit and view menues and things like that. I don't want the app to use raw SQL, I want some proper layer between those and proper wiring where all actual functional code is kept in engine classes and the UI realy just does presentation and reacts to state changes properly, so that long-running processes can properly integrate as async tasks. +We need a good way to handle the syncing of the non-metadata components (posts and media files), because that +is not part of the database sync. One way could be using something like dropbox in the background, so that +the posts/ and media/ folders are automatically synced to some area in dropbox and transported that way. + Blog post metadata should be managed in the SQLite database in the user local folder, so it persists application runs properly. for blog posts, create a subfolder /posts/ there where each post is stored as a markdown file with a properties segment in the top of the file with YAML like property definitions, so all metadata can always be reconstructed from posts. Do the same with images, keeping them in /media/ under the user local path, in that case storing the image file sand for each image file a properties sidecar file that uses the same header structure as for posts. The application must be able to support multiple projects (ie web sites), so there must be a way to create @@ -23,6 +27,35 @@ Integrate toasts as notification mechanism that will be used whenever anything h Integrated images in posts should be shown with a lightbox effect als galleries when there are multiple photos, or just as single images with lightbox when there is only one. The wysiwyg editor should support this at least on a basic level. +## Posting life-cycle + +New posts start in draft state. Drafts are automatically saved in the background, but the draft content is +not directly part of the publishing pipeline. A user can discard a draft, which either deltes a new post +or reverts a post that is edited to the last published state. + +A user can publish a post, that moves the content from the draft state to the published state. Also a user +can start editing in a published post, that moves it to draft state with a copy of the original post as +the starting point. + +So essentially posts have two content fields, one for draft and one for published. Editing only happens on +the draft state. Undo/Redo is also only available on the draft state editing session in the text field +with standard mechanisms. + +published posts have a delete button that allows deleting a post that exists. This will internally switch +the post to deleted, and filter it out, but will not remove it fully from the database, because the publishing +pipeline later might need the fact of the deletion as information source to do its thing (update relevant +files). + +So a post starts in "draft" and can go to "published" and from there to "deleted". From "draft" it can only +go back to "published" (via discard) or vanish fully from the database (because drafts for new posts are +not relevant for the pipeline, as they never were published before). + +Posts in draft are automatically saved during edit every 20 seconds and a dot in the tab title gives +information about its state as unsaved. The user can force the save with the standard hotkey for that +purpose or just wait. Switching to another post will also save a draft automatically. + +Published content is only ever updated when the publish action is done by the user. + ## UI and UX specifics The UI and UX should be aligned with modern applications like vscode. I want iconbar and left sidebar and the diff --git a/package-lock.json b/package-lock.json index d0bb781..cf560b0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -35,6 +35,9 @@ "zustand": "^4.4.7" }, "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20.10.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -43,16 +46,33 @@ "@vitest/coverage-v8": "^1.0.0", "@vitest/ui": "^1.0.0", "concurrently": "^8.2.2", + "cross-env": "^10.1.0", "drizzle-kit": "^0.20.0", "electron": "^28.0.0", "electron-builder": "^24.9.1", + "jsdom": "^28.0.0", "memfs": "^4.6.0", "tsx": "^4.6.0", "typescript": "^5.3.0", "vite": "^5.0.0", - "vitest": "^1.0.0" + "vitest": "^1.0.0", + "wait-on": "^9.0.3" } }, + "node_modules/@acemir/cssom": { + "version": "0.9.31", + "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", + "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@adobe/css-tools": { + "version": "4.4.4", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", + "integrity": "sha512-Elp+iwUx5rN5+Y8xLt5/GRoG20WGoDCQ/1Fb+1LiGtvwbDavuSk0jhD/eZdckHAuzcDzccnkv+rEjyWfRx18gg==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -67,6 +87,61 @@ "node": ">=6.0.0" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-4.1.2.tgz", + "integrity": "sha512-NfBUvBaYgKIuq6E/RBLY1m0IohzNHAYyaJGuTK79Z23uNwmz2jl1mPsC5ZxCCxylinKhT1Amn5oNTlx1wN8cQg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.0.0", + "@csstools/css-color-parser": "^4.0.1", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "6.7.8", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.7.8.tgz", + "integrity": "sha512-stisC1nULNc9oH5lakAj8MH88ZxeGxzyWNDfbdCxvJSJIvDsHNZqYvscGTgy/ysgXWLJPt6K/4t0/GjvtKcFJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.1.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.2.5" + } + }, + "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -367,6 +442,140 @@ "dev": true, "license": "MIT" }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.1.tgz", + "integrity": "sha512-NmXRccUJMk2AWA5A7e5a//3bCIMyOu2hAtdRYrhPPHjDxINuCwX1w6rnIZ4xjLcp0ayv6h8Pc3X0eJUGiAAXHQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.0.0.tgz", + "integrity": "sha512-q4d82GTl8BIlh/dTnVsWmxnbWJeb3kiU8eUH71UxlxnS+WIaALmtzTL8gR15PkYOexMQYVk0CO4qIG93C1IvPA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.1.tgz", + "integrity": "sha512-vYwO15eRBEkeF6xjAno/KQ61HacNhfQuuU/eGwH67DplL0zD5ZixUa563phQvUelA07yDczIXdtmYojCphKJcw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.1", + "@csstools/css-calc": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.0.27", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.0.27.tgz", + "integrity": "sha512-sxP33Jwg1bviSUXAV43cVYdmjt2TLnLXNqCWl9xmxHawWVjGz/kEbdkr7F9pxJNBN2Mh+dq0crgItbW6tQvyow==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0" + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "peer": true, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@develar/schema-utils": { "version": "2.6.5", "resolved": "https://registry.npmjs.org/@develar/schema-utils/-/schema-utils-2.6.5.tgz", @@ -710,6 +919,13 @@ "tslib": "^2.4.0" } }, + "node_modules/@epic-web/invariant": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz", + "integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==", + "dev": true, + "license": "MIT" + }, "node_modules/@esbuild-kit/core-utils": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz", @@ -1588,6 +1804,24 @@ "node": ">=12" } }, + "node_modules/@exodus/bytes": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.12.0.tgz", + "integrity": "sha512-BuCOHA/EJdPN0qQ5MdgAiJSt9fYDHbghlgrj33gRdy/Yp1/FMCDhU6vJfcKrLC0TPWGSrfH3vYXBQWmFHxlddw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@floating-ui/core": { "version": "1.7.4", "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz", @@ -1614,6 +1848,60 @@ "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==", "license": "MIT" }, + "node_modules/@hapi/address": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/@hapi/address/-/address-5.1.1.tgz", + "integrity": "sha512-A+po2d/dVoY7cYajycYI43ZbYMXukuopIsqCjh5QzsBCipDtdofHntljDlpccMjIfTy6UOkg+5KPriwYch2bXA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/formula": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/formula/-/formula-3.0.2.tgz", + "integrity": "sha512-hY5YPNXzw1He7s0iqkRQi+uMGh383CGdyyIGYtB+W5N3KHPXoqychklvHhKCC9M3Xtv0OCs/IHw+r4dcHtBYWw==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/hoek": { + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/pinpoint": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@hapi/pinpoint/-/pinpoint-2.0.1.tgz", + "integrity": "sha512-EKQmr16tM8s16vTT3cA5L0kZZcTMU5DUOZTuvpnY738m+jyP3JIUj+Mm1xc1rsLkGBQ/gVnfKYPwOmPg1tUR4Q==", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@hapi/tlds": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz", + "integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@hapi/topo": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@hapi/topo/-/topo-6.0.2.tgz", + "integrity": "sha512-KR3rD5inZbGMrHmgPxsJ9dbi6zEK+C3ZwUwTa+eMwWLz7oijWUTWD2pMSNNYJAU6Qq+65NkxXjqHr/7LM2Xkqg==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/hoek": "^11.0.2" + } + }, "node_modules/@hono/node-server": { "version": "1.19.9", "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.9.tgz", @@ -3429,6 +3717,13 @@ "url": "https://github.com/sindresorhus/is?sponsor=1" } }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, "node_modules/@szmarczak/http-timer": { "version": "4.0.6", "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-4.0.6.tgz", @@ -3442,6 +3737,131 @@ "node": ">=10" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/dom/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/@testing-library/dom/node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/@testing-library/dom/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tiptap/core": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", @@ -3916,6 +4336,13 @@ "node": ">= 10" } }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -4720,6 +5147,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -4794,6 +5231,18 @@ "node": ">=10.12.0" } }, + "node_modules/axios": { + "version": "1.13.5", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.5.tgz", + "integrity": "sha512-cz4ur7Vb0xS4/KUN0tPWe44eqxrIu31me+fbang3ijiNscE129POzipJJA6zniq2C/Z6sJCjMimjS8Lc/GAs8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^1.1.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -4832,6 +5281,16 @@ "baseline-browser-mapping": "dist/cli.js" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -5631,6 +6090,24 @@ "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", "license": "MIT" }, + "node_modules/cross-env": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz", + "integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@epic-web/invariant": "^1.0.0", + "cross-spawn": "^7.0.6" + }, + "bin": { + "cross-env": "dist/bin/cross-env.js", + "cross-env-shell": "dist/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5646,6 +6123,53 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", + "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.12.2", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cssstyle": { + "version": "5.3.7", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-5.3.7.tgz", + "integrity": "sha512-7D2EPVltRrsTkhpQmksIu+LxeWAIEk6wRDMJ1qljlv+CKHJM+cJLlfhWIzNA44eAsHXSNe3+vO6DW1yCYx8SuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^4.1.1", + "@csstools/css-syntax-patches-for-csstree": "^1.0.21", + "css-tree": "^3.1.0", + "lru-cache": "^11.2.4" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/cssstyle/node_modules/lru-cache": { + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -5676,6 +6200,58 @@ "node": ">= 12" } }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/date-fns": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", @@ -5719,6 +6295,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/decompress-response": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", @@ -5818,6 +6401,16 @@ "node": ">=0.4.0" } }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.2.tgz", @@ -5977,6 +6570,13 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/dompurify": { "version": "3.2.7", "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", @@ -7009,6 +7609,27 @@ "dev": true, "license": "ISC" }, + "node_modules/follow-redirects": { + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/foreground-child": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.1.tgz", @@ -7573,6 +8194,19 @@ "dev": true, "license": "ISC" }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -7702,6 +8336,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -7795,6 +8439,13 @@ "node": ">=8" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/is-promise": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", @@ -7956,6 +8607,25 @@ "node": ">=10" } }, + "node_modules/joi": { + "version": "18.0.2", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", + "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@hapi/address": "^5.1.1", + "@hapi/formula": "^3.0.2", + "@hapi/hoek": "^11.0.7", + "@hapi/pinpoint": "^2.0.1", + "@hapi/tlds": "^1.1.1", + "@hapi/topo": "^6.0.2", + "@standard-schema/spec": "^1.0.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/js-base64": { "version": "3.7.8", "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-3.7.8.tgz", @@ -7981,6 +8651,123 @@ "js-yaml": "bin/js-yaml.js" } }, + "node_modules/jsdom": { + "version": "28.0.0", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.0.0.tgz", + "integrity": "sha512-KDYJgZ6T2TKdU8yBfYueq5EPG/EylMsBvCaenWMJb2OXmjgczzwveRCoJ+Hgj1lXPDyasvrgneSn4GBuR1hYyA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@acemir/cssom": "^0.9.31", + "@asamuzakjp/dom-selector": "^6.7.6", + "@exodus/bytes": "^1.11.0", + "cssstyle": "^5.3.7", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "http-proxy-agent": "^7.0.2", + "https-proxy-agent": "^7.0.6", + "is-potential-custom-element-name": "^1.0.1", + "parse5": "^8.0.0", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.0", + "undici": "^7.20.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.0.tgz", + "integrity": "sha512-9CcxtEKsf53UFwkSUZjG+9vydAsFO4lFHBpJUtjBcoJOCJpKnSJNwCw813zrYJHpCJ7sgfbtOe0V5Ku7Pa1XMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -8310,6 +9097,16 @@ "es5-ext": "~0.10.2" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -8437,6 +9234,13 @@ "node": ">= 0.4" } }, + "node_modules/mdn-data": { + "version": "2.12.2", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", + "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", @@ -8577,6 +9381,16 @@ "node": ">=4" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "7.4.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-7.4.6.tgz", @@ -8943,6 +9757,32 @@ "dev": true, "license": "BlueOak-1.0.0" }, + "node_modules/parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", + "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -9372,6 +10212,13 @@ "prosemirror-transform": "^1.1.0" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "dev": true, + "license": "MIT" + }, "node_modules/pump": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", @@ -9553,6 +10400,20 @@ "node": ">=10" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -9775,6 +10636,19 @@ "node": ">=11.0.0" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", @@ -10208,6 +11082,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-literal": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/strip-literal/-/strip-literal-2.1.1.tgz", @@ -10270,6 +11157,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tar": { "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", @@ -10481,6 +11375,26 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.23.tgz", + "integrity": "sha512-ASdhgQIBSay0R/eXggAkQ53G4nTJqTXqC2kbaBbdDwM7SkjyZyO0OaaN1/FH7U/yCeqOHDwFO5j8+Os/IS1dXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.23" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.0.23", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.23.tgz", + "integrity": "sha512-0g9vrtDQLrNIiCj22HSe9d4mLVG3g5ph5DZ8zCKBr4OtrspmNB6ss7hVyzArAeE88ceZocIEGkyW1Ime7fxPtQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tmp": { "version": "0.2.5", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.5.tgz", @@ -10524,6 +11438,19 @@ "node": ">=6" } }, + "node_modules/tough-cookie": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.0.tgz", + "integrity": "sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -11093,6 +12020,16 @@ "dev": true, "license": "MIT" }, + "node_modules/undici": { + "version": "7.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.21.0.tgz", + "integrity": "sha512-Hn2tCQpoDt1wv23a68Ctc8Cr/BHpUSfaPYrkajTXOS9IKpxVRx/X5m1K2YkbK2ipgZgxXSgsUinl3x+2YdSSfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, "node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", @@ -11789,6 +12726,39 @@ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", "license": "MIT" }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wait-on": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.3.tgz", + "integrity": "sha512-13zBnyYvFDW1rBvWiJ6Av3ymAaq8EDQuvxZnPIw3g04UqGi4TyoIJABmfJ6zrvKo9yeFQExNkOk7idQbDJcuKA==", + "dev": true, + "license": "MIT", + "dependencies": { + "axios": "^1.13.2", + "joi": "^18.0.1", + "lodash": "^4.17.21", + "minimist": "^1.2.8", + "rxjs": "^7.8.2" + }, + "bin": { + "wait-on": "bin/wait-on" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -11804,6 +12774,16 @@ "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", "license": "BSD-2-Clause" }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -11919,6 +12899,16 @@ } } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, "node_modules/xmlbuilder": { "version": "15.1.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz", @@ -11929,6 +12919,13 @@ "node": ">=8.0" } }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 9c797dc..e6701d0 100644 --- a/package.json +++ b/package.json @@ -5,13 +5,15 @@ "description": "A desktop blogging application with offline-first capabilities and cloud sync", "main": "dist/main/main.js", "scripts": { - "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", + "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\" \"npm run dev:electron\"", "dev:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json --watch", "dev:renderer": "node ./node_modules/vite/bin/vite.js", + "dev:electron": "wait-on http://localhost:5173 && cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", "build": "npm run build:main && npm run build:renderer", "build:main": "node ./node_modules/typescript/bin/tsc -p tsconfig.main.json", "build:renderer": "node ./node_modules/vite/bin/vite.js build", "start": "node ./node_modules/electron/cli.js .", + "start:dev": "cross-env NODE_ENV=development node ./node_modules/electron/cli.js .", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", @@ -23,6 +25,9 @@ "author": "", "license": "MIT", "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", "@types/node": "^20.10.0", "@types/react": "^18.2.0", "@types/react-dom": "^18.2.0", @@ -31,14 +36,17 @@ "@vitest/coverage-v8": "^1.0.0", "@vitest/ui": "^1.0.0", "concurrently": "^8.2.2", + "cross-env": "^10.1.0", "drizzle-kit": "^0.20.0", "electron": "^28.0.0", "electron-builder": "^24.9.1", + "jsdom": "^28.0.0", "memfs": "^4.6.0", "tsx": "^4.6.0", "typescript": "^5.3.0", "vite": "^5.0.0", - "vitest": "^1.0.0" + "vitest": "^1.0.0", + "wait-on": "^9.0.3" }, "dependencies": { "@floating-ui/dom": "^1.7.5", diff --git a/src/main/database/connection.ts b/src/main/database/connection.ts index d44e7b2..77b05c6 100644 --- a/src/main/database/connection.ts +++ b/src/main/database/connection.ts @@ -149,7 +149,12 @@ export class DatabaseConnection { synced_at INTEGER, checksum TEXT, tags TEXT, - categories TEXT + categories TEXT, + published_title TEXT, + published_content TEXT, + published_tags TEXT, + published_categories TEXT, + published_excerpt TEXT ); CREATE TABLE IF NOT EXISTS media ( @@ -242,6 +247,18 @@ export class DatabaseConnection { ); } + // Migration: Add published snapshot columns for discard functionality + const publishedContentCol = await this.localClient.execute( + "SELECT name FROM pragma_table_info('posts') WHERE name = 'published_content'" + ); + if (publishedContentCol.rows.length === 0) { + await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_title TEXT"); + await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_content TEXT"); + await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_tags TEXT"); + await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_categories TEXT"); + await this.localClient.execute("ALTER TABLE posts ADD COLUMN published_excerpt TEXT"); + } + // Create FTS5 virtual table for full-text search await this.localClient.execute(` CREATE VIRTUAL TABLE IF NOT EXISTS posts_fts USING fts5( diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 91bcceb..14878f6 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -29,6 +29,12 @@ export const posts = sqliteTable('posts', { checksum: text('checksum'), tags: text('tags'), // JSON array stored as text categories: text('categories'), // JSON array stored as text + // Published snapshot - stores the last published version for discard functionality + publishedTitle: text('published_title'), + publishedContent: text('published_content'), + publishedTags: text('published_tags'), // JSON array stored as text + publishedCategories: text('published_categories'), // JSON array stored as text + publishedExcerpt: text('published_excerpt'), }); // Media table - stores metadata for images and other media diff --git a/src/main/engine/PostEngine.ts b/src/main/engine/PostEngine.ts index b1146f3..718c863 100644 --- a/src/main/engine/PostEngine.ts +++ b/src/main/engine/PostEngine.ts @@ -112,6 +112,52 @@ export class PostEngine extends EventEmitter { .replace(/^-|-$/g, ''); } + /** + * Check if a slug is available (not used by any existing post) + * @param slug The slug to check + * @param excludePostId Optional post ID to exclude (for updates) + */ + async isSlugAvailable(slug: string, excludePostId?: string): Promise { + const db = getDatabase().getLocal(); + const existing = await db + .select({ id: posts.id }) + .from(posts) + .where(and( + eq(posts.slug, slug), + eq(posts.projectId, this.currentProjectId) + )) + .get(); + + if (!existing) return true; + if (excludePostId && existing.id === excludePostId) return true; + return false; + } + + /** + * Generate a unique slug based on a title + * If the slug already exists, appends -2, -3, etc. + */ + async generateUniqueSlug(title: string, excludePostId?: string): Promise { + const baseSlug = this.generateSlug(title || 'untitled'); + + if (await this.isSlugAvailable(baseSlug, excludePostId)) { + return baseSlug; + } + + // Find next available number + let counter = 2; + while (counter < 1000) { + const candidateSlug = `${baseSlug}-${counter}`; + if (await this.isSlugAvailable(candidateSlug, excludePostId)) { + return candidateSlug; + } + counter++; + } + + // Fallback: add timestamp + return `${baseSlug}-${Date.now()}`; + } + private calculateChecksum(content: string): string { return crypto.createHash('md5').update(content).digest('hex'); } @@ -177,7 +223,11 @@ export class PostEngine extends EventEmitter { const client = getDatabase().getLocalClient(); const now = new Date(); const id = uuidv4(); - const slug = data.slug || this.generateSlug(data.title || 'untitled'); + + // Use provided slug or generate a unique one from title + const slug = data.slug + ? (await this.isSlugAvailable(data.slug) ? data.slug : await this.generateUniqueSlug(data.title || 'untitled')) + : await this.generateUniqueSlug(data.title || 'untitled'); const post: PostData = { id, @@ -539,10 +589,58 @@ export class PostEngine extends EventEmitter { } async publishPost(id: string): Promise { - return this.updatePost(id, { + const db = getDatabase().getLocal(); + const existing = await this.getPost(id); + + if (!existing) { + return null; + } + + // First update the post with published status + const result = await this.updatePost(id, { status: 'published', publishedAt: new Date(), }); + + if (result) { + // Save the published snapshot for discard functionality + await db.update(posts) + .set({ + publishedTitle: result.title, + publishedContent: result.content, + publishedExcerpt: result.excerpt, + publishedTags: JSON.stringify(result.tags), + publishedCategories: JSON.stringify(result.categories), + }) + .where(eq(posts.id, id)); + } + + return result; + } + + async discardChanges(id: string): Promise { + const db = getDatabase().getLocal(); + const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); + + if (!dbPost || !dbPost.publishedContent) { + // No published version to revert to + return null; + } + + // Revert to the published snapshot + return this.updatePost(id, { + title: dbPost.publishedTitle || dbPost.title, + content: dbPost.publishedContent, + excerpt: dbPost.publishedExcerpt || undefined, + tags: JSON.parse(dbPost.publishedTags || '[]'), + categories: JSON.parse(dbPost.publishedCategories || '[]'), + }); + } + + async hasPublishedVersion(id: string): Promise { + const db = getDatabase().getLocal(); + const dbPost = await db.select().from(posts).where(eq(posts.id, id)).get(); + return !!(dbPost && dbPost.publishedContent); } async unpublishPost(id: string): Promise { diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index 77a2700..ff6ec59 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -63,6 +63,16 @@ export function registerIpcHandlers(): void { return engine.createPost(data); }); + ipcMain.handle('posts:isSlugAvailable', async (_, slug: string, excludePostId?: string) => { + const engine = getPostEngine(); + return engine.isSlugAvailable(slug, excludePostId); + }); + + ipcMain.handle('posts:generateUniqueSlug', async (_, title: string, excludePostId?: string) => { + const engine = getPostEngine(); + return engine.generateUniqueSlug(title, excludePostId); + }); + ipcMain.handle('posts:update', async (_, id: string, data: Partial) => { const engine = getPostEngine(); return engine.updatePost(id, data); @@ -98,6 +108,16 @@ export function registerIpcHandlers(): void { return engine.unpublishPost(id); }); + ipcMain.handle('posts:discard', async (_, id: string) => { + const engine = getPostEngine(); + return engine.discardChanges(id); + }); + + ipcMain.handle('posts:hasPublishedVersion', async (_, id: string) => { + const engine = getPostEngine(); + return engine.hasPublishedVersion(id); + }); + ipcMain.handle('posts:rebuildFromFiles', async () => { const engine = getPostEngine(); return engine.rebuildDatabaseFromFiles(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 739f61c..39e72a1 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -24,6 +24,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status), publish: (id: string) => ipcRenderer.invoke('posts:publish', id), unpublish: (id: string) => ipcRenderer.invoke('posts:unpublish', id), + discard: (id: string) => ipcRenderer.invoke('posts:discard', id), + hasPublishedVersion: (id: string) => ipcRenderer.invoke('posts:hasPublishedVersion', id), rebuildFromFiles: () => ipcRenderer.invoke('posts:rebuildFromFiles'), search: (query: string) => ipcRenderer.invoke('posts:search', query), filter: (filter: unknown) => ipcRenderer.invoke('posts:filter', filter), @@ -33,6 +35,8 @@ contextBridge.exposeInMainWorld('electronAPI', { getLinksTo: (id: string) => ipcRenderer.invoke('posts:getLinksTo', id), getLinkedBy: (id: string) => ipcRenderer.invoke('posts:getLinkedBy', id), rebuildLinks: () => ipcRenderer.invoke('posts:rebuildLinks'), + isSlugAvailable: (slug: string, excludePostId?: string) => ipcRenderer.invoke('posts:isSlugAvailable', slug, excludePostId), + generateUniqueSlug: (title: string, excludePostId?: string) => ipcRenderer.invoke('posts:generateUniqueSlug', title, excludePostId), }, // Media diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index 5b1d4fb..09e7d34 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -100,6 +100,12 @@ background-color: var(--vscode-notificationsErrorIcon-foreground); } +.auto-save-indicator { + font-size: 11px; + color: var(--vscode-descriptionForeground); + font-style: italic; +} + .editor-content { flex: 1; display: flex; diff --git a/src/renderer/components/Editor/Editor.tsx b/src/renderer/components/Editor/Editor.tsx index e3595c6..ea80d71 100644 --- a/src/renderer/components/Editor/Editor.tsx +++ b/src/renderer/components/Editor/Editor.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useCallback, useRef } from 'react'; import MonacoEditor from '@monaco-editor/react'; -import { useAppStore, PostData, UnsavedDraft, EditorMode } from '../../store'; +import { useAppStore, PostData, EditorMode } from '../../store'; import { showToast } from '../Toast'; import { WysiwygEditor } from '../WysiwygEditor'; import { Lightbox, useMarkdownImages } from '../Lightbox'; @@ -38,14 +38,11 @@ const markdownToHtml = (markdown: string): string => { .replace(/\n/g, '
'); }; -// Check if an ID is for an unsaved draft -const isUnsavedDraftId = (id: string): boolean => id.startsWith('draft-'); - -interface SavedPostEditorProps { +interface PostEditorProps { post: PostData; } -const SavedPostEditor: React.FC = ({ post }) => { +const PostEditor: React.FC = ({ post }) => { const { updatePost, markDirty, @@ -61,6 +58,7 @@ const SavedPostEditor: React.FC = ({ post }) => { const [tags, setTags] = useState(post.tags.join(', ')); const [categories, setCategories] = useState(post.categories.join(', ')); const [isSaving, setIsSaving] = useState(false); + const [hasPublishedVersion, setHasPublishedVersion] = useState(false); const [editorMode, setEditorMode] = useState(preferredEditorMode); const [lightboxOpen, setLightboxOpen] = useState(false); const [lightboxIndex, setLightboxIndex] = useState(0); @@ -68,10 +66,62 @@ const SavedPostEditor: React.FC = ({ post }) => { const isDirty = checkIsDirty(post.id); + // Check if post has a published version for discard functionality + useEffect(() => { + window.electronAPI?.posts.hasPublishedVersion(post.id).then(setHasPublishedVersion); + }, [post.id]); + // Extract images from content for lightbox const images = useMarkdownImages(content); - // Reset when post changes + // Track latest values for auto-save on unmount/switch + const pendingChangesRef = useRef<{ + title: string; + content: string; + tags: string; + categories: string; + postId: string; + isDirty: boolean; + } | null>(null); + + // Update ref when values change + useEffect(() => { + pendingChangesRef.current = { + title, + content, + tags, + categories, + postId: post.id, + isDirty, + }; + }, [title, content, tags, categories, post.id, isDirty]); + + // Auto-save when switching away from a post or unmounting + useEffect(() => { + const prevPostId = post.id; + + return () => { + const pending = pendingChangesRef.current; + if (pending && pending.postId === prevPostId && pending.isDirty) { + // Fire and forget auto-save + window.electronAPI?.posts.update(pending.postId, { + title: pending.title, + content: pending.content, + tags: pending.tags.split(',').map(t => t.trim()).filter(t => t.length > 0), + categories: pending.categories.split(',').map(c => c.trim()).filter(c => c.length > 0), + }).then((updated) => { + if (updated) { + useAppStore.getState().updatePost(pending.postId, updated as Partial); + useAppStore.getState().markClean(pending.postId); + } + }).catch((error) => { + console.error('Auto-save failed:', error); + }); + } + }; + }, [post.id]); + + // Reset when post changes (after auto-save cleanup runs) useEffect(() => { setTitle(post.title); setContent(post.content); @@ -168,6 +218,48 @@ const SavedPostEditor: React.FC = ({ post }) => { } }; + const handleDiscard = async () => { + // If this post has a published version, revert to it + // If never published, delete the post entirely + const confirmMessage = hasPublishedVersion + ? 'Discard all changes since last publish? This cannot be undone.' + : 'Delete this draft? This cannot be undone.'; + + if (!confirm(confirmMessage)) { + return; + } + + try { + if (hasPublishedVersion) { + // Revert to published version + const reverted = await window.electronAPI?.posts.discard(post.id); + if (reverted) { + setTitle(reverted.title); + setContent(reverted.content); + setTags(reverted.tags.join(', ')); + setCategories(reverted.categories.join(', ')); + updatePost(post.id, reverted as Partial); + markClean(post.id); + showToast.success('Reverted to last published version'); + } + } else { + // Never published - delete the post entirely + await window.electronAPI?.posts.delete(post.id); + useAppStore.getState().removePost(post.id); + useAppStore.getState().setSelectedPost(null); + showToast.success('Draft deleted'); + } + } catch (error) { + console.error('Failed to discard/delete:', error); + const err = error as Error; + showErrorModal({ + title: hasPublishedVersion ? 'Discard Failed' : 'Delete Failed', + message: err.message || 'Operation failed', + stack: err.stack, + }); + } + }; + const handleDelete = async () => { if (confirm('Are you sure you want to delete this post?')) { try { @@ -224,24 +316,41 @@ const SavedPostEditor: React.FC = ({ post }) => {
{title || 'Untitled'} - {isDirty && } + {isDirty && }
{post.status} + {isSaving && Saving...} {post.status === 'draft' ? ( - + ) : ( - )} - - + )} +
@@ -401,314 +510,6 @@ const SavedPostEditor: React.FC = ({ post }) => { ); }; -interface UnsavedDraftEditorProps { - draft: UnsavedDraft; -} - -const UnsavedDraftEditor: React.FC = ({ draft }) => { - const { - updateUnsavedDraft, - removeUnsavedDraft, - addPost, - setSelectedPost, - preferredEditorMode, - setPreferredEditorMode, - showErrorModal, - markClean, - } = useAppStore(); - - const [title, setTitle] = useState(draft.title); - const [content, setContent] = useState(draft.content); - const [tags, setTags] = useState(draft.tags.join(', ')); - const [categories, setCategories] = useState(draft.categories.join(', ')); - const [isSaving, setIsSaving] = useState(false); - const [editorMode, setEditorMode] = useState(preferredEditorMode); - const [lightboxOpen, setLightboxOpen] = useState(false); - const [lightboxIndex, setLightboxIndex] = useState(0); - const editorRef = useRef(null); - - // Extract images from content for lightbox - const images = useMarkdownImages(content); - - // Update draft in store when local state changes (for recovery purposes) - useEffect(() => { - const timeout = setTimeout(() => { - updateUnsavedDraft(draft.id, { - title, - content, - tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), - categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0), - }); - }, 500); // Debounce updates - - return () => clearTimeout(timeout); - }, [title, content, tags, categories, draft.id, updateUnsavedDraft]); - - // Handle editor mode change and persist preference - const handleEditorModeChange = (mode: EditorMode) => { - setEditorMode(mode); - setPreferredEditorMode(mode); - }; - - const handleSave = useCallback(async () => { - if (isSaving) return; - - // Validate - need at least a title - if (!title.trim()) { - showErrorModal({ - title: 'Validation Error', - message: 'Please enter a title for your post before saving.', - }); - return; - } - - setIsSaving(true); - try { - // Create the post in the database - const newPost = await window.electronAPI?.posts.create({ - title: title.trim(), - content, - tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0), - categories: categories.split(',').map(c => c.trim()).filter(c => c.length > 0), - }); - - if (newPost) { - const postData = newPost as PostData; - // Add to posts list - addPost(postData); - // Remove the unsaved draft - removeUnsavedDraft(draft.id); - // Select the new post - setSelectedPost(postData.id); - markClean(postData.id); - showToast.success('Post saved'); - } - } catch (error) { - console.error('Failed to save post:', error); - const err = error as Error; - showErrorModal({ - title: 'Save Failed', - message: err.message || 'Failed to save post', - stack: err.stack, - }); - } finally { - setIsSaving(false); - } - }, [title, content, tags, categories, isSaving, draft.id, addPost, removeUnsavedDraft, setSelectedPost, markClean, showErrorModal]); - - const handleDiscard = () => { - if (title.trim() || content.trim()) { - if (!confirm('Are you sure you want to discard this unsaved post?')) { - return; - } - } - removeUnsavedDraft(draft.id); - setSelectedPost(null); - }; - - // Handle Monaco editor mount - const handleEditorDidMount = (editor: unknown) => { - editorRef.current = editor; - }; - - // Save on Ctrl+S - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if ((e.ctrlKey || e.metaKey) && e.key === 's') { - e.preventDefault(); - handleSave(); - } - }; - - window.addEventListener('keydown', handleKeyDown); - return () => window.removeEventListener('keydown', handleKeyDown); - }, [handleSave]); - - // Listen for menu events - useEffect(() => { - const unsubscribeSave = window.electronAPI?.on('menu:save', handleSave); - return () => { - unsubscribeSave?.(); - }; - }, [handleSave]); - - const hasContent = title.trim() || content.trim(); - - return ( -
-
-
-
- {title || 'New Post'} - - NEW -
-
-
- unsaved - - -
-
- -
-
-
- - setTitle(e.target.value)} - placeholder="Enter post title..." - autoFocus - /> -
-
- - -
-
-
- - setTags(e.target.value)} - placeholder="tag1, tag2, tag3" - /> -
-
- - setCategories(e.target.value)} - placeholder="category1, category2" - /> -
-
-
- -
-
- -
- - - -
- {images.length > 0 && ( - - )} -
- - {editorMode === 'wysiwyg' && ( - - )} - - {editorMode === 'markdown' && ( - setContent(value || '')} - onMount={handleEditorDidMount} - theme="vs-dark" - options={{ - minimap: { enabled: false }, - wordWrap: 'on', - lineNumbers: 'on', - fontSize: 14, - fontFamily: "'Cascadia Code', 'Consolas', 'Courier New', monospace", - padding: { top: 12, bottom: 12 }, - automaticLayout: true, - scrollBeyondLastLine: false, - renderLineHighlight: 'line', - quickSuggestions: false, - formatOnPaste: true, - cursorStyle: 'line', - cursorBlinking: 'smooth', - }} - /> - )} - - {editorMode === 'preview' && ( -
- {!content.trim() ? ( -
-

No content to preview

-
- ) : ( -
- )} -
- )} -
- - {/* Lightbox for viewing images in content */} - setLightboxOpen(false)} - /> -
- -
- - New post - not yet saved - - {hasContent && ( - - Press Ctrl+S to save - - )} -
-
- ); -}; - const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { const { media, updateMedia, showErrorModal } = useAppStore(); const item = media.find(m => m.id === mediaId); @@ -857,11 +658,23 @@ const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => { }; const WelcomeScreen: React.FC = () => { - const { createUnsavedDraft, setSelectedPost } = useAppStore(); + const { addPost, setSelectedPost } = useAppStore(); - const handleNewPost = () => { - const draftId = createUnsavedDraft(); - setSelectedPost(draftId); + const handleNewPost = async () => { + try { + const newPost = await window.electronAPI?.posts.create({ + title: 'Untitled', + content: '', + tags: [], + categories: [], + }); + if (newPost) { + addPost(newPost as PostData); + setSelectedPost(newPost.id); + } + } catch (error) { + console.error('Failed to create post:', error); + } }; return ( @@ -926,9 +739,10 @@ export const Editor: React.FC = () => { selectedPostId, selectedMediaId, posts, - unsavedDrafts, errorModal, hideErrorModal, + isLoading, + setSelectedPost, } = useAppStore(); // Show error modal if present @@ -937,29 +751,32 @@ export const Editor: React.FC = () => { ); if (activeView === 'posts' && selectedPostId) { - // Check if it's an unsaved draft - if (isUnsavedDraftId(selectedPostId)) { - const draft = unsavedDrafts.find(d => d.id === selectedPostId); - if (draft) { - return ( - <> - - {renderErrorModal()} - - ); - } - } - - // Otherwise, it's a saved post const post = posts.find(p => p.id === selectedPostId); if (post) { return ( <> - + {renderErrorModal()} ); } + + // Post not found - show loading if still loading, otherwise clear selection + if (isLoading) { + return ( + <> +
+
+

Loading post...

+
+
+ {renderErrorModal()} + + ); + } + + // Post truly not found - clear selection and fall through to welcome screen + setSelectedPost(null); } if (activeView === 'media' && selectedMediaId) { diff --git a/src/renderer/components/Sidebar/Sidebar.tsx b/src/renderer/components/Sidebar/Sidebar.tsx index c01f1ad..d5f0022 100644 --- a/src/renderer/components/Sidebar/Sidebar.tsx +++ b/src/renderer/components/Sidebar/Sidebar.tsx @@ -1,5 +1,5 @@ import React, { useState, useEffect } from 'react'; -import { useAppStore, PostData, UnsavedDraft } from '../../store'; +import { useAppStore, PostData } from '../../store'; import { showToast } from '../Toast'; import './Sidebar.css'; @@ -222,7 +222,7 @@ const SearchBox: React.FC = ({ onSearch }) => { }; const PostsList: React.FC = () => { - const { posts, selectedPostId, setSelectedPost, unsavedDrafts } = useAppStore(); + const { posts, selectedPostId, setSelectedPost } = useAppStore(); // Filter state const [searchQuery, setSearchQuery] = useState(''); @@ -321,11 +321,23 @@ const PostsList: React.FC = () => { applyFilters(); }, [selectedTags, selectedCategories]); - const handleCreatePost = () => { - // Create an unsaved draft instead of immediately saving to database - const { createUnsavedDraft, setSelectedPost: selectPost } = useAppStore.getState(); - const draftId = createUnsavedDraft(); - selectPost(draftId); + const handleCreatePost = async () => { + // Create a real post immediately in the database with default empty content + try { + const { addPost, setSelectedPost: selectPost } = useAppStore.getState(); + const newPost = await window.electronAPI?.posts.create({ + title: 'Untitled', + content: '', + tags: [], + categories: [], + }); + if (newPost) { + addPost(newPost as PostData); + selectPost(newPost.id); + } + } catch (error) { + console.error('Failed to create post:', error); + } }; // Determine which posts to display @@ -405,34 +417,6 @@ const PostsList: React.FC = () => {
)} - {/* Unsaved Drafts Section - always show at top if there are any */} - {unsavedDrafts.length > 0 && ( -
-
- - Unsaved ({unsavedDrafts.length}) -
-
- {unsavedDrafts.map((draft: UnsavedDraft) => ( -
setSelectedPost(draft.id)} - > - -
-
- {draft.title || 'New Post'} - -
-
Not yet saved
-
-
- ))} -
-
- )} - {groupedPosts.draft.length > 0 && (
diff --git a/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx b/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx index 4353731..dd623c4 100644 --- a/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx +++ b/src/renderer/components/WysiwygEditor/WysiwygEditor.tsx @@ -1,12 +1,11 @@ -import React, { useEffect, useCallback } from 'react'; +import React, { useEffect, useCallback, useRef } from 'react'; import { useEditor, EditorContent } from '@tiptap/react'; -import { BubbleMenu } from '@tiptap/extension-bubble-menu'; -import { FloatingMenu } from '@tiptap/extension-floating-menu'; +import { BubbleMenu, FloatingMenu } from '@tiptap/react/menus'; import StarterKit from '@tiptap/starter-kit'; -import Link from '@tiptap/extension-link'; import Image from '@tiptap/extension-image'; -import Underline from '@tiptap/extension-underline'; import Placeholder from '@tiptap/extension-placeholder'; +import Link from '@tiptap/extension-link'; +import Underline from '@tiptap/extension-underline'; import TurndownService from 'turndown'; import './WysiwygEditor.css'; @@ -88,6 +87,10 @@ export const WysiwygEditor: React.FC = ({ onChange, placeholder = 'Start writing your content...', }) => { + // Track if we're updating from internal changes vs external prop changes + const isInternalChange = useRef(false); + const lastExternalContent = useRef(content); + const editor = useEditor({ extensions: [ StarterKit.configure({ @@ -101,34 +104,38 @@ export const WysiwygEditor: React.FC = ({ class: 'editor-link', }, }), + Underline, Image.configure({ HTMLAttributes: { class: 'editor-image', }, }), - Underline, Placeholder.configure({ placeholder, }), ], content: markdownToHtml(content), onUpdate: ({ editor }) => { + isInternalChange.current = true; const html = editor.getHTML(); const markdown = turndownService.turndown(html); onChange(markdown); }, + editable: true, }); + // Sync content from external changes only (e.g., post switch) useEffect(() => { - if (editor && content) { - const currentHtml = editor.getHTML(); - const newHtml = markdownToHtml(content); - // Only update if content is significantly different - if (turndownService.turndown(currentHtml) !== content) { + if (editor && content !== lastExternalContent.current) { + // This is an external change (e.g., switching posts) + if (!isInternalChange.current) { + const newHtml = markdownToHtml(content); editor.commands.setContent(newHtml); } + lastExternalContent.current = content; + isInternalChange.current = false; } - }, [content]); + }, [content, editor]); const addImage = useCallback(() => { const url = window.prompt('Enter image URL:'); @@ -161,7 +168,7 @@ export const WysiwygEditor: React.FC = ({
{/* Bubble menu appears when text is selected */} {editor && ( - +