feat: first cut at menu editor
This commit is contained in:
183
package-lock.json
generated
183
package-lock.json
generated
@@ -29,12 +29,14 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
|
"fast-xml-parser": "^5.3.7",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lightbox2": "^2.11.5",
|
"lightbox2": "^2.11.5",
|
||||||
"liquidjs": "^10.24.0",
|
"liquidjs": "^10.24.0",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
@@ -381,7 +383,6 @@
|
|||||||
"version": "7.28.6",
|
"version": "7.28.6",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz",
|
||||||
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
"integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=6.9.0"
|
"node": ">=6.9.0"
|
||||||
@@ -4683,6 +4684,24 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@react-dnd/asap": {
|
||||||
|
"version": "4.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/asap/-/asap-4.0.1.tgz",
|
||||||
|
"integrity": "sha512-kLy0PJDDwvwwTXxqTFNAAllPHD73AycE9ypWeln/IguoGBEbvFcPDbCV03G52bEcC5E+YgupBE0VzHGdC8SIXg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/invariant": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/invariant/-/invariant-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-xL4RCQBCBDJ+GRwKTFhGUW8GXa4yoDfJrPbLblc3U09ciS+9ZJXJ3Qrcs/x2IODOdIE5kQxvMmE2UKyqUictUw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@react-dnd/shallowequal": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@react-dnd/shallowequal/-/shallowequal-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-Pc/AFTdwZwEKJxFJvlxrSmGe/di+aAOBn60sremrpLo6VI/6cmiUYNNwlI5KNYttg7uypzA3ILPMPgxB2GYZEg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@rolldown/pluginutils": {
|
"node_modules/@rolldown/pluginutils": {
|
||||||
"version": "1.0.0-rc.3",
|
"version": "1.0.0-rc.3",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||||
@@ -7829,6 +7848,26 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/dnd-core": {
|
||||||
|
"version": "14.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/dnd-core/-/dnd-core-14.0.1.tgz",
|
||||||
|
"integrity": "sha512-+PVS2VPTgKFPYWo3vAFEA8WPbTf7/xo43TifH9G8S1KqnrQu0o77A3unrF5yOugy4mIz7K5wAVFHUcha7wsz6A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/asap": "^4.0.0",
|
||||||
|
"@react-dnd/invariant": "^2.0.0",
|
||||||
|
"redux": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/dnd-core/node_modules/redux": {
|
||||||
|
"version": "4.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-4.2.1.tgz",
|
||||||
|
"integrity": "sha512-LAUYz4lc+Do8/g7aeRa8JkyDErK6ekstQaqWQrNRW//MY1TvCEpMtpTWvlQ+FPbWCx+Xixu/6SHt5N0HR+SB4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.9.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dom-accessibility-api": {
|
"node_modules/dom-accessibility-api": {
|
||||||
"version": "0.5.16",
|
"version": "0.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz",
|
||||||
@@ -8834,7 +8873,6 @@
|
|||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/fast-json-stable-stringify": {
|
"node_modules/fast-json-stable-stringify": {
|
||||||
@@ -8868,6 +8906,24 @@
|
|||||||
],
|
],
|
||||||
"license": "BSD-3-Clause"
|
"license": "BSD-3-Clause"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-xml-parser": {
|
||||||
|
"version": "5.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.3.7.tgz",
|
||||||
|
"integrity": "sha512-JzVLro9NQv92pOM/jTCR6mHlJh2FGwtomH8ZQjhFj/R29P2Fnj38OgPJVtcvYw6SuKClhgYuwUZf5b3rd8u2mA==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"strnum": "^2.1.2"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"fxparser": "src/cli/cli.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fd-slicer": {
|
"node_modules/fd-slicer": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz",
|
||||||
@@ -9523,6 +9579,21 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/hoist-non-react-statics": {
|
||||||
|
"version": "3.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz",
|
||||||
|
"integrity": "sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"react-is": "^16.7.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/hoist-non-react-statics/node_modules/react-is": {
|
||||||
|
"version": "16.13.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
|
||||||
|
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/hosted-git-info": {
|
"node_modules/hosted-git-info": {
|
||||||
"version": "4.1.0",
|
"version": "4.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-4.1.0.tgz",
|
||||||
@@ -10756,6 +10827,12 @@
|
|||||||
"tslib": "2"
|
"tslib": "2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/memoize-one": {
|
||||||
|
"version": "5.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
|
||||||
|
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/micromark": {
|
"node_modules/micromark": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz",
|
||||||
@@ -12187,6 +12264,7 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
"peer": true,
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -12619,6 +12697,62 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-arborist": {
|
||||||
|
"version": "3.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-arborist/-/react-arborist-3.4.3.tgz",
|
||||||
|
"integrity": "sha512-yFnq1nIQhT2uJY4TZVz2tgAiBb9lxSyvF4vC3S8POCK8xLzjGIxVv3/4dmYquQJ7AHxaZZArRGHiHKsEewKdTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"react-dnd": "^14.0.3",
|
||||||
|
"react-dnd-html5-backend": "^14.0.3",
|
||||||
|
"react-window": "^1.8.11",
|
||||||
|
"redux": "^5.0.0",
|
||||||
|
"use-sync-external-store": "^1.2.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">= 16.14",
|
||||||
|
"react-dom": ">= 16.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dnd": {
|
||||||
|
"version": "14.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd/-/react-dnd-14.0.5.tgz",
|
||||||
|
"integrity": "sha512-9i1jSgbyVw0ELlEVt/NkCUkxy1hmhJOkePoCH713u75vzHGyXhPDm28oLfc2NMSBjZRM1Y+wRjHXJT3sPrTy+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@react-dnd/invariant": "^2.0.0",
|
||||||
|
"@react-dnd/shallowequal": "^2.0.0",
|
||||||
|
"dnd-core": "14.0.1",
|
||||||
|
"fast-deep-equal": "^3.1.3",
|
||||||
|
"hoist-non-react-statics": "^3.3.2"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/hoist-non-react-statics": ">= 3.3.1",
|
||||||
|
"@types/node": ">= 12",
|
||||||
|
"@types/react": ">= 16",
|
||||||
|
"react": ">= 16.14"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/hoist-non-react-statics": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/node": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-dnd-html5-backend": {
|
||||||
|
"version": "14.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-dnd-html5-backend/-/react-dnd-html5-backend-14.1.0.tgz",
|
||||||
|
"integrity": "sha512-6ONeqEC3XKVf4eVmMTe0oPds+c5B9Foyj8p/ZKLb7kL2qh9COYxiBHv3szd6gztqi/efkmriywLUVlPotqoJyw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dnd-core": "14.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react-dom": {
|
"node_modules/react-dom": {
|
||||||
"version": "19.2.4",
|
"version": "19.2.4",
|
||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz",
|
||||||
@@ -12666,6 +12800,23 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/react-window": {
|
||||||
|
"version": "1.8.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-window/-/react-window-1.8.11.tgz",
|
||||||
|
"integrity": "sha512-+SRbUVT2scadgFSWx+R1P754xHPEqvcfSfVX10QYg6POOz+WNgkN48pS+BtZNIMGiL1HYrSEiCkwsMS15QogEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.0.0",
|
||||||
|
"memoize-one": ">=3.1.1 <6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">8.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||||
|
"react-dom": "^15.0.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-binary-file-arch": {
|
"node_modules/read-binary-file-arch": {
|
||||||
"version": "1.0.6",
|
"version": "1.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/read-binary-file-arch/-/read-binary-file-arch-1.0.6.tgz",
|
||||||
@@ -12721,6 +12872,12 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/redux": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/remark": {
|
"node_modules/remark": {
|
||||||
"version": "15.0.1",
|
"version": "15.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz",
|
||||||
@@ -13561,6 +13718,18 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strnum": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/stubborn-fs": {
|
"node_modules/stubborn-fs": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/stubborn-fs/-/stubborn-fs-2.0.0.tgz",
|
||||||
@@ -14699,6 +14868,16 @@
|
|||||||
"punycode": "^2.1.0"
|
"punycode": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/use-sync-external-store": {
|
||||||
|
"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",
|
||||||
|
"peer": true,
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/utf8-byte-length": {
|
"node_modules/utf8-byte-length": {
|
||||||
"version": "1.0.5",
|
"version": "1.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/utf8-byte-length/-/utf8-byte-length-1.0.5.tgz",
|
||||||
|
|||||||
@@ -80,12 +80,14 @@
|
|||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
"drizzle-orm": "^0.45.1",
|
"drizzle-orm": "^0.45.1",
|
||||||
"dropbox": "^10.34.0",
|
"dropbox": "^10.34.0",
|
||||||
|
"fast-xml-parser": "^5.3.7",
|
||||||
"gray-matter": "^4.0.3",
|
"gray-matter": "^4.0.3",
|
||||||
"lightbox2": "^2.11.5",
|
"lightbox2": "^2.11.5",
|
||||||
"liquidjs": "^10.24.0",
|
"liquidjs": "^10.24.0",
|
||||||
"marked-react": "^3.0.2",
|
"marked-react": "^3.0.2",
|
||||||
"monaco-editor": "^0.55.1",
|
"monaco-editor": "^0.55.1",
|
||||||
"react": "^19.2.4",
|
"react": "^19.2.4",
|
||||||
|
"react-arborist": "^3.4.3",
|
||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-hot-toast": "^2.6.0",
|
"react-hot-toast": "^2.6.0",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.34.5",
|
||||||
|
|||||||
223
src/main/engine/MenuEngine.ts
Normal file
223
src/main/engine/MenuEngine.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { EventEmitter } from 'events';
|
||||||
|
import * as fs from 'fs/promises';
|
||||||
|
import * as path from 'path';
|
||||||
|
import { randomUUID } from 'crypto';
|
||||||
|
import { app } from 'electron';
|
||||||
|
import { XMLBuilder, XMLParser } from 'fast-xml-parser';
|
||||||
|
|
||||||
|
export type MenuItemKind = 'page' | 'submenu';
|
||||||
|
|
||||||
|
export interface MenuItemData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
kind: MenuItemKind;
|
||||||
|
pageId?: string;
|
||||||
|
pageSlug?: string;
|
||||||
|
children: MenuItemData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuDocument {
|
||||||
|
items: MenuItemData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type OpmlOutlineNode = {
|
||||||
|
'@_id'?: string;
|
||||||
|
'@_text'?: string;
|
||||||
|
'@_title'?: string;
|
||||||
|
'@_type'?: string;
|
||||||
|
'@_pageId'?: string;
|
||||||
|
'@_pageSlug'?: string;
|
||||||
|
outline?: OpmlOutlineNode | OpmlOutlineNode[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateMenuItemId(): string {
|
||||||
|
try {
|
||||||
|
return randomUUID();
|
||||||
|
} catch {
|
||||||
|
return `menu-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeOutlineNodes(value: unknown): OpmlOutlineNode[] {
|
||||||
|
if (!value) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value as OpmlOutlineNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [value as OpmlOutlineNode];
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeNonEmptyString(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const trimmed = String(value).trim();
|
||||||
|
return trimmed.length > 0 ? trimmed : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMenuItem(input: unknown): MenuItemData {
|
||||||
|
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
||||||
|
const kind = candidate.kind === 'submenu' ? 'submenu' : 'page';
|
||||||
|
const childrenSource = Array.isArray(candidate.children) ? candidate.children : [];
|
||||||
|
const title = normalizeNonEmptyString(candidate.title) || 'Untitled';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeNonEmptyString(candidate.id) || generateMenuItemId(),
|
||||||
|
title,
|
||||||
|
kind,
|
||||||
|
pageId: kind === 'page' ? normalizeNonEmptyString(candidate.pageId) : undefined,
|
||||||
|
pageSlug: kind === 'page' ? normalizeNonEmptyString(candidate.pageSlug) : undefined,
|
||||||
|
children: childrenSource.map((child) => sanitizeMenuItem(child)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanitizeMenuDocument(input: unknown): MenuDocument {
|
||||||
|
const candidate = (input && typeof input === 'object') ? input as Record<string, unknown> : {};
|
||||||
|
const items = Array.isArray(candidate.items) ? candidate.items : [];
|
||||||
|
return {
|
||||||
|
items: items.map((item) => sanitizeMenuItem(item)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOutlineNode(node: OpmlOutlineNode): MenuItemData {
|
||||||
|
const kind: MenuItemKind = node['@_type'] === 'submenu' ? 'submenu' : 'page';
|
||||||
|
const title = normalizeNonEmptyString(node['@_text']) || normalizeNonEmptyString(node['@_title']) || 'Untitled';
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: normalizeNonEmptyString(node['@_id']) || generateMenuItemId(),
|
||||||
|
title,
|
||||||
|
kind,
|
||||||
|
pageId: kind === 'page' ? normalizeNonEmptyString(node['@_pageId']) : undefined,
|
||||||
|
pageSlug: kind === 'page' ? normalizeNonEmptyString(node['@_pageSlug']) : undefined,
|
||||||
|
children: normalizeOutlineNodes(node.outline).map((child) => parseOutlineNode(child)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toOpmlOutlineNode(item: MenuItemData): OpmlOutlineNode {
|
||||||
|
const outlineNode: OpmlOutlineNode = {
|
||||||
|
'@_id': item.id,
|
||||||
|
'@_text': item.title,
|
||||||
|
'@_type': item.kind,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (item.kind === 'page' && item.pageId) {
|
||||||
|
outlineNode['@_pageId'] = item.pageId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind === 'page' && item.pageSlug) {
|
||||||
|
outlineNode['@_pageSlug'] = item.pageSlug;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.children.length > 0) {
|
||||||
|
outlineNode.outline = item.children.map((child) => toOpmlOutlineNode(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
return outlineNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class MenuEngine extends EventEmitter {
|
||||||
|
private currentProjectId: string = 'default';
|
||||||
|
private dataDir: string | null = null;
|
||||||
|
|
||||||
|
private getDefaultBaseDir(): string {
|
||||||
|
const userDataPath = app.getPath('userData');
|
||||||
|
return path.join(userDataPath, 'projects', this.currentProjectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getBaseDir(): string {
|
||||||
|
return this.dataDir || this.getDefaultBaseDir();
|
||||||
|
}
|
||||||
|
|
||||||
|
getMetaDir(): string {
|
||||||
|
return path.join(this.getBaseDir(), 'meta');
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMenuFilePath(): string {
|
||||||
|
return path.join(this.getMetaDir(), 'menu.opml');
|
||||||
|
}
|
||||||
|
|
||||||
|
setProjectContext(projectId: string, dataDir?: string): void {
|
||||||
|
this.currentProjectId = projectId;
|
||||||
|
this.dataDir = dataDir || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getMenu(): Promise<MenuDocument> {
|
||||||
|
const menuPath = this.getMenuFilePath();
|
||||||
|
|
||||||
|
let xmlContent: string;
|
||||||
|
try {
|
||||||
|
xmlContent = await fs.readFile(menuPath, 'utf-8');
|
||||||
|
} catch (error) {
|
||||||
|
const asErrno = error as NodeJS.ErrnoException;
|
||||||
|
if (asErrno?.code === 'ENOENT') {
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parser = new XMLParser({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: '@_',
|
||||||
|
allowBooleanAttributes: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const parsed = parser.parse(xmlContent) as {
|
||||||
|
opml?: {
|
||||||
|
body?: {
|
||||||
|
outline?: OpmlOutlineNode | OpmlOutlineNode[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const outlineNodes = normalizeOutlineNodes(parsed?.opml?.body?.outline);
|
||||||
|
const items = outlineNodes.map((node) => parseOutlineNode(node));
|
||||||
|
return sanitizeMenuDocument({ items });
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveMenu(input: MenuDocument): Promise<MenuDocument> {
|
||||||
|
const sanitized = sanitizeMenuDocument(input);
|
||||||
|
|
||||||
|
const builder = new XMLBuilder({
|
||||||
|
ignoreAttributes: false,
|
||||||
|
attributeNamePrefix: '@_',
|
||||||
|
format: true,
|
||||||
|
suppressEmptyNode: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const opmlPayload = {
|
||||||
|
'?xml': {
|
||||||
|
'@_version': '1.0',
|
||||||
|
'@_encoding': 'UTF-8',
|
||||||
|
},
|
||||||
|
opml: {
|
||||||
|
'@_version': '2.0',
|
||||||
|
head: {
|
||||||
|
title: 'Blog Menu',
|
||||||
|
},
|
||||||
|
body: {
|
||||||
|
outline: sanitized.items.map((item) => toOpmlOutlineNode(item)),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const xml = builder.build(opmlPayload);
|
||||||
|
await fs.mkdir(this.getMetaDir(), { recursive: true });
|
||||||
|
await fs.writeFile(this.getMenuFilePath(), xml, 'utf-8');
|
||||||
|
|
||||||
|
this.emit('menuUpdated', sanitized);
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let menuEngine: MenuEngine | null = null;
|
||||||
|
|
||||||
|
export function getMenuEngine(): MenuEngine {
|
||||||
|
if (!menuEngine) {
|
||||||
|
menuEngine = new MenuEngine();
|
||||||
|
}
|
||||||
|
return menuEngine;
|
||||||
|
}
|
||||||
@@ -93,3 +93,10 @@ export {
|
|||||||
type BlogGenerationOptions,
|
type BlogGenerationOptions,
|
||||||
type BlogGenerationResult,
|
type BlogGenerationResult,
|
||||||
} from './BlogGenerationEngine';
|
} from './BlogGenerationEngine';
|
||||||
|
export {
|
||||||
|
MenuEngine,
|
||||||
|
getMenuEngine,
|
||||||
|
type MenuItemData,
|
||||||
|
type MenuDocument,
|
||||||
|
type MenuItemKind,
|
||||||
|
} from './MenuEngine';
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getPostEngine, PostData, PostFilter, PaginationOptions } from '../engin
|
|||||||
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
import { getMediaEngine, MediaData } from '../engine/MediaEngine';
|
||||||
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
import { getProjectEngine, ProjectData } from '../engine/ProjectEngine';
|
||||||
import { getMetaEngine } from '../engine/MetaEngine';
|
import { getMetaEngine } from '../engine/MetaEngine';
|
||||||
|
import { getMenuEngine, type MenuDocument } from '../engine/MenuEngine';
|
||||||
import { getTagEngine } from '../engine/TagEngine';
|
import { getTagEngine } from '../engine/TagEngine';
|
||||||
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
import { getPostMediaEngine } from '../engine/PostMediaEngine';
|
||||||
import { getGitEngine } from '../engine/GitEngine';
|
import { getGitEngine } from '../engine/GitEngine';
|
||||||
@@ -248,10 +249,12 @@ export function registerIpcHandlers(): void {
|
|||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = getMediaEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
|
const menuEngine = getMenuEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext(project.id, dataDir);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
|
menuEngine.setProjectContext(project.id, dataDir);
|
||||||
tagEngine.setProjectContext(project.id, dataDir);
|
tagEngine.setProjectContext(project.id, dataDir);
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
postMediaEngine.setProjectContext(project.id);
|
postMediaEngine.setProjectContext(project.id);
|
||||||
@@ -284,10 +287,12 @@ export function registerIpcHandlers(): void {
|
|||||||
const postEngine = getPostEngine();
|
const postEngine = getPostEngine();
|
||||||
const mediaEngine = getMediaEngine();
|
const mediaEngine = getMediaEngine();
|
||||||
const metaEngine = getMetaEngine();
|
const metaEngine = getMetaEngine();
|
||||||
|
const menuEngine = getMenuEngine();
|
||||||
const tagEngine = getTagEngine();
|
const tagEngine = getTagEngine();
|
||||||
postEngine.setProjectContext(project.id, dataDir);
|
postEngine.setProjectContext(project.id, dataDir);
|
||||||
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
mediaEngine.setProjectContext(project.id, dataDir, dataDir);
|
||||||
metaEngine.setProjectContext(project.id, dataDir);
|
metaEngine.setProjectContext(project.id, dataDir);
|
||||||
|
menuEngine.setProjectContext(project.id, dataDir);
|
||||||
tagEngine.setProjectContext(project.id, dataDir);
|
tagEngine.setProjectContext(project.id, dataDir);
|
||||||
const postMediaEngine = getPostMediaEngine();
|
const postMediaEngine = getPostMediaEngine();
|
||||||
postMediaEngine.setProjectContext(project.id);
|
postMediaEngine.setProjectContext(project.id);
|
||||||
@@ -813,6 +818,34 @@ export function registerIpcHandlers(): void {
|
|||||||
|
|
||||||
// ============ Meta Handlers ============
|
// ============ Meta Handlers ============
|
||||||
|
|
||||||
|
safeHandle('menu:get', async () => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const menuEngine = getMenuEngine();
|
||||||
|
const project = await projectEngine.getActiveProject();
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('No active project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
|
menuEngine.setProjectContext(project.id, dataDir);
|
||||||
|
return menuEngine.getMenu();
|
||||||
|
});
|
||||||
|
|
||||||
|
safeHandle('menu:save', async (_, menu: MenuDocument) => {
|
||||||
|
const projectEngine = getProjectEngine();
|
||||||
|
const menuEngine = getMenuEngine();
|
||||||
|
const project = await projectEngine.getActiveProject();
|
||||||
|
|
||||||
|
if (!project) {
|
||||||
|
throw new Error('No active project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const dataDir = projectEngine.getDataDir(project.id, project.dataPath);
|
||||||
|
menuEngine.setProjectContext(project.id, dataDir);
|
||||||
|
return menuEngine.saveMenu(menu);
|
||||||
|
});
|
||||||
|
|
||||||
safeHandle('meta:getTags', async () => {
|
safeHandle('meta:getTags', async () => {
|
||||||
const engine = getMetaEngine();
|
const engine = getMetaEngine();
|
||||||
return engine.getTags();
|
return engine.getTags();
|
||||||
|
|||||||
@@ -258,6 +258,11 @@ export const electronAPI: ElectronAPI = {
|
|||||||
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
|
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
|
||||||
},
|
},
|
||||||
|
|
||||||
|
menu: {
|
||||||
|
get: () => ipcRenderer.invoke('menu:get'),
|
||||||
|
save: (menu: import('./shared/electronApi').MenuDocument) => ipcRenderer.invoke('menu:save', menu),
|
||||||
|
},
|
||||||
|
|
||||||
// AI Chat (OpenCode Zen API integration)
|
// AI Chat (OpenCode Zen API integration)
|
||||||
chat: {
|
chat: {
|
||||||
// API Key Management
|
// API Key Management
|
||||||
|
|||||||
@@ -422,6 +422,21 @@ export interface SiteValidationApplyResult {
|
|||||||
removedEmptyDirCount: number;
|
removedEmptyDirCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type MenuItemKind = 'page' | 'submenu';
|
||||||
|
|
||||||
|
export interface MenuItemData {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
kind: MenuItemKind;
|
||||||
|
pageId?: string;
|
||||||
|
pageSlug?: string;
|
||||||
|
children: MenuItemData[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MenuDocument {
|
||||||
|
items: MenuItemData[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface ElectronAPI {
|
export interface ElectronAPI {
|
||||||
git: {
|
git: {
|
||||||
checkAvailability: () => Promise<GitAvailability>;
|
checkAvailability: () => Promise<GitAvailability>;
|
||||||
@@ -629,6 +644,10 @@ export interface ElectronAPI {
|
|||||||
validateSite: () => Promise<SiteValidationReport>;
|
validateSite: () => Promise<SiteValidationReport>;
|
||||||
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
|
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
|
||||||
};
|
};
|
||||||
|
menu: {
|
||||||
|
get: () => Promise<MenuDocument>;
|
||||||
|
save: (menu: MenuDocument) => Promise<MenuDocument>;
|
||||||
|
};
|
||||||
chat: {
|
chat: {
|
||||||
// API Key Management
|
// API Key Management
|
||||||
checkReady: () => Promise<ChatReadyStatus>;
|
checkReady: () => Promise<ChatReadyStatus>;
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
|
"menu.item.rebuildDatabase": "Datenbank aus Dateien neu aufbauen",
|
||||||
"menu.item.reindexText": "Suchtext neu indizieren",
|
"menu.item.reindexText": "Suchtext neu indizieren",
|
||||||
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
|
"menu.item.metadataDiff": "Metadaten-Diff-Werkzeug",
|
||||||
|
"menu.item.editMenu": "Blog-Menü bearbeiten",
|
||||||
"menu.item.generateSitemap": "Site rendern",
|
"menu.item.generateSitemap": "Site rendern",
|
||||||
"menu.item.validateSite": "Website validieren",
|
"menu.item.validateSite": "Website validieren",
|
||||||
"menu.item.about": "Über Blogging Desktop Server",
|
"menu.item.about": "Über Blogging Desktop Server",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.rebuildDatabase": "Rebuild Database from Files",
|
"menu.item.rebuildDatabase": "Rebuild Database from Files",
|
||||||
"menu.item.reindexText": "Reindex Search Text",
|
"menu.item.reindexText": "Reindex Search Text",
|
||||||
"menu.item.metadataDiff": "Metadata Diff Tool",
|
"menu.item.metadataDiff": "Metadata Diff Tool",
|
||||||
|
"menu.item.editMenu": "Edit Blog Menu",
|
||||||
"menu.item.generateSitemap": "Render Site",
|
"menu.item.generateSitemap": "Render Site",
|
||||||
"menu.item.validateSite": "Validate Site",
|
"menu.item.validateSite": "Validate Site",
|
||||||
"menu.item.about": "About Blogging Desktop Server",
|
"menu.item.about": "About Blogging Desktop Server",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.rebuildDatabase": "Reconstruir Database from Files",
|
"menu.item.rebuildDatabase": "Reconstruir Database from Files",
|
||||||
"menu.item.reindexText": "Reindex Buscar Text",
|
"menu.item.reindexText": "Reindex Buscar Text",
|
||||||
"menu.item.metadataDiff": "Herramienta diff de metadatos",
|
"menu.item.metadataDiff": "Herramienta diff de metadatos",
|
||||||
|
"menu.item.editMenu": "Editar menú del blog",
|
||||||
"menu.item.generateSitemap": "Renderizar sitio",
|
"menu.item.generateSitemap": "Renderizar sitio",
|
||||||
"menu.item.validateSite": "Validar sitio",
|
"menu.item.validateSite": "Validar sitio",
|
||||||
"menu.item.about": "Acerca de Blogging Desktop Server",
|
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.rebuildDatabase": "Reconstruire Database from Files",
|
"menu.item.rebuildDatabase": "Reconstruire Database from Files",
|
||||||
"menu.item.reindexText": "Reindex Recherche Text",
|
"menu.item.reindexText": "Reindex Recherche Text",
|
||||||
"menu.item.metadataDiff": "Outil de diff des métadonnées",
|
"menu.item.metadataDiff": "Outil de diff des métadonnées",
|
||||||
|
"menu.item.editMenu": "Modifier le menu du blog",
|
||||||
"menu.item.generateSitemap": "Rendre le site",
|
"menu.item.generateSitemap": "Rendre le site",
|
||||||
"menu.item.validateSite": "Valider le site",
|
"menu.item.validateSite": "Valider le site",
|
||||||
"menu.item.about": "À propos de Blogging Desktop Server",
|
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@
|
|||||||
"menu.item.rebuildDatabase": "Ricostruisci Database from Files",
|
"menu.item.rebuildDatabase": "Ricostruisci Database from Files",
|
||||||
"menu.item.reindexText": "Reindex Ricerca Text",
|
"menu.item.reindexText": "Reindex Ricerca Text",
|
||||||
"menu.item.metadataDiff": "Strumento diff metadati",
|
"menu.item.metadataDiff": "Strumento diff metadati",
|
||||||
|
"menu.item.editMenu": "Modifica menu blog",
|
||||||
"menu.item.generateSitemap": "Renderizza sito",
|
"menu.item.generateSitemap": "Renderizza sito",
|
||||||
"menu.item.validateSite": "Valida sito",
|
"menu.item.validateSite": "Valida sito",
|
||||||
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export type AppMenuAction =
|
|||||||
| 'rebuildDatabase'
|
| 'rebuildDatabase'
|
||||||
| 'reindexText'
|
| 'reindexText'
|
||||||
| 'metadataDiff'
|
| 'metadataDiff'
|
||||||
|
| 'editMenu'
|
||||||
| 'generateSitemap'
|
| 'generateSitemap'
|
||||||
| 'validateSite'
|
| 'validateSite'
|
||||||
| 'openDocumentation'
|
| 'openDocumentation'
|
||||||
@@ -123,6 +124,7 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
|||||||
{ label: 'menu.item.reindexText', action: 'reindexText' },
|
{ label: 'menu.item.reindexText', action: 'reindexText' },
|
||||||
{ label: '', action: 'blog-separator-3', separator: true },
|
{ label: '', action: 'blog-separator-3', separator: true },
|
||||||
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
||||||
|
{ label: 'menu.item.editMenu', action: 'editMenu' },
|
||||||
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||||
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
|
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
|
||||||
],
|
],
|
||||||
@@ -156,6 +158,7 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
|||||||
rebuildDatabase: 'menu:rebuildDatabase',
|
rebuildDatabase: 'menu:rebuildDatabase',
|
||||||
reindexText: 'menu:reindexText',
|
reindexText: 'menu:reindexText',
|
||||||
metadataDiff: 'menu:metadataDiff',
|
metadataDiff: 'menu:metadataDiff',
|
||||||
|
editMenu: 'menu:editMenu',
|
||||||
generateSitemap: 'menu:generateSitemap',
|
generateSitemap: 'menu:generateSitemap',
|
||||||
validateSite: 'menu:validateSite',
|
validateSite: 'menu:validateSite',
|
||||||
openDocumentation: 'menu:openDocumentation',
|
openDocumentation: 'menu:openDocumentation',
|
||||||
|
|||||||
@@ -276,6 +276,12 @@ const App: React.FC = () => {
|
|||||||
}) || (() => {})
|
}) || (() => {})
|
||||||
);
|
);
|
||||||
|
|
||||||
|
unsubscribers.push(
|
||||||
|
window.electronAPI?.on('menu:editMenu', () => {
|
||||||
|
openSingletonToolTab(openTab, 'menu-editor');
|
||||||
|
}) || (() => {})
|
||||||
|
);
|
||||||
|
|
||||||
// Rebuild events - clear store on start, reload on complete
|
// Rebuild events - clear store on start, reload on complete
|
||||||
unsubscribers.push(
|
unsubscribers.push(
|
||||||
window.electronAPI?.on('posts:rebuildStarted', () => {
|
window.electronAPI?.on('posts:rebuildStarted', () => {
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { TagsView } from '../TagsView';
|
|||||||
import { TagInput } from '../TagInput';
|
import { TagInput } from '../TagInput';
|
||||||
import { ChatPanel } from '../ChatPanel';
|
import { ChatPanel } from '../ChatPanel';
|
||||||
import { ImportAnalysisView } from '../ImportAnalysisView';
|
import { ImportAnalysisView } from '../ImportAnalysisView';
|
||||||
|
import { MenuEditorView } from '../MenuEditorView/MenuEditorView';
|
||||||
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
import { MetadataDiffPanel } from '../MetadataDiffPanel';
|
||||||
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
import { GitDiffView } from '../GitDiffView/GitDiffView';
|
||||||
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
import { DocumentationView } from '../DocumentationView/DocumentationView';
|
||||||
@@ -1784,6 +1785,7 @@ export const Editor: React.FC = () => {
|
|||||||
chat: () => (editorRoute.tabId ? <ChatPanel key={editorRoute.tabId} conversationId={editorRoute.tabId} /> : <Dashboard />),
|
chat: () => (editorRoute.tabId ? <ChatPanel key={editorRoute.tabId} conversationId={editorRoute.tabId} /> : <Dashboard />),
|
||||||
import: () =>
|
import: () =>
|
||||||
editorRoute.tabId ? <ImportAnalysisView key={editorRoute.tabId} definitionId={editorRoute.tabId} /> : <Dashboard />,
|
editorRoute.tabId ? <ImportAnalysisView key={editorRoute.tabId} definitionId={editorRoute.tabId} /> : <Dashboard />,
|
||||||
|
'menu-editor': () => <MenuEditorView />,
|
||||||
'metadata-diff': () => <MetadataDiffPanel />,
|
'metadata-diff': () => <MetadataDiffPanel />,
|
||||||
'git-diff': () =>
|
'git-diff': () =>
|
||||||
editorRoute.tabId && editorRoute.gitDiffResource
|
editorRoute.tabId && editorRoute.gitDiffResource
|
||||||
|
|||||||
196
src/renderer/components/MenuEditorView/MenuEditorView.css
Normal file
196
src/renderer/components/MenuEditorView/MenuEditorView.css
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
.menu-editor-view {
|
||||||
|
padding: 1rem;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-header p {
|
||||||
|
margin: 0.25rem 0 0;
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-loading,
|
||||||
|
.menu-editor-empty {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(480px, 1fr) minmax(280px, 340px);
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-height: 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tree-wrap,
|
||||||
|
.menu-editor-details {
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
padding: 0.5rem;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-bottom: 0.4rem;
|
||||||
|
border-bottom: 1px solid var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool {
|
||||||
|
width: 1.8rem;
|
||||||
|
height: 1.8rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--vscode-foreground);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool:hover:not(:disabled) {
|
||||||
|
background: var(--vscode-toolbar-hoverBackground);
|
||||||
|
border-color: var(--vscode-panel-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-tool:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.2rem 0.4rem;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row.is-selected {
|
||||||
|
background: var(--vscode-list-activeSelectionBackground);
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-kind {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-row-title {
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-details {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-details h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-details label {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-backdrop {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: color-mix(in srgb, var(--vscode-editor-background) 75%, black);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker {
|
||||||
|
width: min(580px, 90%);
|
||||||
|
max-height: 75%;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
background: var(--vscode-editor-background);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 0.75rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.35rem;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
border: 1px solid var(--vscode-panel-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--vscode-input-background);
|
||||||
|
color: var(--vscode-input-foreground);
|
||||||
|
padding: 0.45rem 0.55rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item:hover {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
background: var(--vscode-list-hoverBackground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item.is-active {
|
||||||
|
border-color: var(--vscode-focusBorder);
|
||||||
|
background: var(--vscode-list-activeSelectionBackground);
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item.is-active small {
|
||||||
|
color: var(--vscode-list-activeSelectionForeground);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-item small {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
|
|
||||||
|
.menu-editor-picker-state {
|
||||||
|
color: var(--vscode-descriptionForeground);
|
||||||
|
}
|
||||||
650
src/renderer/components/MenuEditorView/MenuEditorView.tsx
Normal file
650
src/renderer/components/MenuEditorView/MenuEditorView.tsx
Normal file
@@ -0,0 +1,650 @@
|
|||||||
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { Tree } from 'react-arborist';
|
||||||
|
import { useI18n } from '../../i18n';
|
||||||
|
import { showToast } from '../Toast';
|
||||||
|
import type { MenuDocument, MenuItemData, MenuItemKind, PostData } from '../../../main/shared/electronApi';
|
||||||
|
import { createAutoExpandController } from './menuAutoExpand';
|
||||||
|
import {
|
||||||
|
createMenuPageItemFromPost,
|
||||||
|
filterPagePosts,
|
||||||
|
getNextPickerIndex,
|
||||||
|
isPickerCloseKey,
|
||||||
|
isPickerFocusShortcut,
|
||||||
|
} from './menuPagePicker';
|
||||||
|
import { applyTreeMove } from './menuTreeMove';
|
||||||
|
import './MenuEditorView.css';
|
||||||
|
|
||||||
|
function createMenuItem(kind: MenuItemKind, title: string): MenuItemData {
|
||||||
|
return {
|
||||||
|
id: `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||||
|
title,
|
||||||
|
kind,
|
||||||
|
pageId: undefined,
|
||||||
|
pageSlug: undefined,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPathById(items: MenuItemData[], id: string, path: number[] = []): number[] | null {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
const nextPath = [...path, index];
|
||||||
|
if (item.id === id) {
|
||||||
|
return nextPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = findPathById(item.children, id, nextPath);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateItemsAtLevel(
|
||||||
|
items: MenuItemData[],
|
||||||
|
path: number[],
|
||||||
|
updater: (level: MenuItemData[]) => MenuItemData[],
|
||||||
|
): MenuItemData[] {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return updater(items);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, ...tail] = path;
|
||||||
|
return items.map((item, index) => {
|
||||||
|
if (index !== head) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: updateItemsAtLevel(item.children, tail, updater),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemByPath(items: MenuItemData[], path: number[]): { next: MenuItemData[]; removed: MenuItemData | null } {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length === 1) {
|
||||||
|
const [index] = path;
|
||||||
|
if (index < 0 || index >= items.length) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
const removed = items[index];
|
||||||
|
return {
|
||||||
|
next: items.filter((_, currentIndex) => currentIndex !== index),
|
||||||
|
removed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, ...tail] = path;
|
||||||
|
const current = items[head];
|
||||||
|
if (!current) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = removeItemByPath(current.children, tail);
|
||||||
|
if (!nested.removed) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item));
|
||||||
|
return { next, removed: nested.removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertItemAtPath(items: MenuItemData[], parentPath: number[], index: number, node: MenuItemData): MenuItemData[] {
|
||||||
|
if (parentPath.length === 0) {
|
||||||
|
const boundedIndex = Math.max(0, Math.min(index, items.length));
|
||||||
|
return [...items.slice(0, boundedIndex), node, ...items.slice(boundedIndex)];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, ...tail] = parentPath;
|
||||||
|
return items.map((item, currentIndex) => {
|
||||||
|
if (currentIndex !== head) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: insertItemAtPath(item.children, tail, index, node),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapItems(items: MenuItemData[], mapper: (item: MenuItemData) => MenuItemData): MenuItemData[] {
|
||||||
|
return items.map((item) => {
|
||||||
|
const mapped = mapper(item);
|
||||||
|
if (mapped.children.length === 0) {
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...mapped,
|
||||||
|
children: mapItems(mapped.children, mapper),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MenuEditorView: React.FC = () => {
|
||||||
|
const { t: tr } = useI18n();
|
||||||
|
const [items, setItems] = useState<MenuItemData[]>([]);
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
const [showPagePicker, setShowPagePicker] = useState(false);
|
||||||
|
const [pagePickerParentId, setPagePickerParentId] = useState<string | null>(null);
|
||||||
|
const [pagePickerLoading, setPagePickerLoading] = useState(false);
|
||||||
|
const [pagePickerQuery, setPagePickerQuery] = useState('');
|
||||||
|
const [pagePickerPosts, setPagePickerPosts] = useState<PostData[]>([]);
|
||||||
|
const [pagePickerActiveIndex, setPagePickerActiveIndex] = useState(-1);
|
||||||
|
const pagePickerInputRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
const autoExpandController = useMemo(() => createAutoExpandController(450), []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const load = async () => {
|
||||||
|
setIsLoading(true);
|
||||||
|
try {
|
||||||
|
const menu = await window.electronAPI.menu.get();
|
||||||
|
setItems(menu.items);
|
||||||
|
setSelectedId(menu.items[0]?.id ?? null);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load menu:', error);
|
||||||
|
showToast.error(tr('menuEditor.loadError'));
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void load();
|
||||||
|
}, [tr]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
autoExpandController.cancelAll();
|
||||||
|
};
|
||||||
|
}, [autoExpandController]);
|
||||||
|
|
||||||
|
const selectedPath = useMemo(() => {
|
||||||
|
if (!selectedId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return findPathById(items, selectedId);
|
||||||
|
}, [items, selectedId]);
|
||||||
|
|
||||||
|
const selectedItem = useMemo(() => {
|
||||||
|
if (!selectedPath || selectedPath.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentItems = items;
|
||||||
|
let current: MenuItemData | null = null;
|
||||||
|
for (const segment of selectedPath) {
|
||||||
|
current = currentItems[segment] || null;
|
||||||
|
if (!current) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
currentItems = current.children;
|
||||||
|
}
|
||||||
|
|
||||||
|
return current;
|
||||||
|
}, [items, selectedPath]);
|
||||||
|
|
||||||
|
const filteredPagePosts = useMemo(() => {
|
||||||
|
return filterPagePosts(pagePickerPosts, pagePickerQuery);
|
||||||
|
}, [pagePickerPosts, pagePickerQuery]);
|
||||||
|
|
||||||
|
const replaceSelected = (updater: (item: MenuItemData) => MenuItemData): void => {
|
||||||
|
if (!selectedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((previous) => mapItems(previous, (item) => (item.id === selectedId ? updater(item) : item)));
|
||||||
|
};
|
||||||
|
|
||||||
|
const insertItem = (previous: MenuItemData[], node: MenuItemData, parentId: string | null): MenuItemData[] => {
|
||||||
|
if (!parentId) {
|
||||||
|
return [...previous, node];
|
||||||
|
}
|
||||||
|
|
||||||
|
return mapItems(previous, (item) => {
|
||||||
|
if (item.id !== parentId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: [...item.children, node],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closePagePicker = (): void => {
|
||||||
|
setShowPagePicker(false);
|
||||||
|
setPagePickerParentId(null);
|
||||||
|
setPagePickerQuery('');
|
||||||
|
setPagePickerActiveIndex(-1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openPagePicker = async (parentId: string | null): Promise<void> => {
|
||||||
|
setShowPagePicker(true);
|
||||||
|
setPagePickerParentId(parentId);
|
||||||
|
setPagePickerQuery('');
|
||||||
|
setPagePickerActiveIndex(-1);
|
||||||
|
setPagePickerLoading(true);
|
||||||
|
try {
|
||||||
|
const posts = await window.electronAPI.posts.filter({ categories: ['page'] });
|
||||||
|
setPagePickerPosts(posts);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load page posts:', error);
|
||||||
|
showToast.error(tr('menuEditor.pagePicker.loadError'));
|
||||||
|
setPagePickerPosts([]);
|
||||||
|
} finally {
|
||||||
|
setPagePickerLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectPageForMenu = (post: PostData): void => {
|
||||||
|
const node = createMenuPageItemFromPost(post);
|
||||||
|
setItems((previous) => insertItem(previous, node, pagePickerParentId));
|
||||||
|
setSelectedId(node.id);
|
||||||
|
closePagePicker();
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPagePicker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filteredPagePosts.length === 0) {
|
||||||
|
setPagePickerActiveIndex(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPagePickerActiveIndex((previous) => {
|
||||||
|
if (previous < 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Math.min(previous, filteredPagePosts.length - 1);
|
||||||
|
});
|
||||||
|
}, [filteredPagePosts, showPagePicker]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!showPagePicker) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const onWindowKeyDown = (event: KeyboardEvent): void => {
|
||||||
|
if (isPickerFocusShortcut({ key: event.key, metaKey: event.metaKey, ctrlKey: event.ctrlKey })) {
|
||||||
|
event.preventDefault();
|
||||||
|
pagePickerInputRef.current?.focus();
|
||||||
|
pagePickerInputRef.current?.select();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('keydown', onWindowKeyDown);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('keydown', onWindowKeyDown);
|
||||||
|
};
|
||||||
|
}, [showPagePicker]);
|
||||||
|
|
||||||
|
const addRootItem = (kind: MenuItemKind): void => {
|
||||||
|
if (kind === 'page') {
|
||||||
|
void openPagePicker(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu');
|
||||||
|
const node = createMenuItem(kind, title);
|
||||||
|
setItems((previous) => [...previous, node]);
|
||||||
|
setSelectedId(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addChildItem = (kind: MenuItemKind): void => {
|
||||||
|
if (!selectedId) {
|
||||||
|
addRootItem(kind);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kind === 'page') {
|
||||||
|
void openPagePicker(selectedId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = kind === 'page' ? tr('menuEditor.newPage') : tr('menuEditor.newSubmenu');
|
||||||
|
const node = createMenuItem(kind, title);
|
||||||
|
|
||||||
|
setItems((previous) => mapItems(previous, (item) => {
|
||||||
|
if (item.id !== selectedId) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: [...item.children, node],
|
||||||
|
};
|
||||||
|
}));
|
||||||
|
setSelectedId(node.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveSelected = (direction: 'up' | 'down'): void => {
|
||||||
|
if (!selectedPath || selectedPath.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = selectedPath.slice(0, -1);
|
||||||
|
const index = selectedPath[selectedPath.length - 1];
|
||||||
|
const delta = direction === 'up' ? -1 : 1;
|
||||||
|
|
||||||
|
setItems((previous) => updateItemsAtLevel(previous, parentPath, (level) => {
|
||||||
|
const targetIndex = index + delta;
|
||||||
|
if (targetIndex < 0 || targetIndex >= level.length) {
|
||||||
|
return level;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = [...level];
|
||||||
|
const [moved] = next.splice(index, 1);
|
||||||
|
next.splice(targetIndex, 0, moved);
|
||||||
|
return next;
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const indentSelected = (): void => {
|
||||||
|
if (!selectedPath || selectedPath.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const index = selectedPath[selectedPath.length - 1];
|
||||||
|
if (index <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = selectedPath.slice(0, -1);
|
||||||
|
setItems((previous) => {
|
||||||
|
const removed = removeItemByPath(previous, selectedPath);
|
||||||
|
if (!removed.removed) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
const previousSiblingPath = [...parentPath, index - 1];
|
||||||
|
return updateItemsAtLevel(removed.next, previousSiblingPath, (level) => [...level, removed.removed as MenuItemData]);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const unindentSelected = (): void => {
|
||||||
|
if (!selectedPath || selectedPath.length < 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = selectedPath.slice(0, -1);
|
||||||
|
const parentIndex = parentPath[parentPath.length - 1];
|
||||||
|
const grandParentPath = parentPath.slice(0, -1);
|
||||||
|
|
||||||
|
setItems((previous) => {
|
||||||
|
const removed = removeItemByPath(previous, selectedPath);
|
||||||
|
if (!removed.removed) {
|
||||||
|
return previous;
|
||||||
|
}
|
||||||
|
|
||||||
|
return insertItemAtPath(removed.next, grandParentPath, parentIndex + 1, removed.removed);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const deleteSelected = (): void => {
|
||||||
|
if (!selectedPath || selectedPath.length === 0 || !selectedId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setItems((previous) => {
|
||||||
|
const removed = removeItemByPath(previous, selectedPath);
|
||||||
|
return removed.next;
|
||||||
|
});
|
||||||
|
setSelectedId(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async (): Promise<void> => {
|
||||||
|
setIsSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: MenuDocument = { items };
|
||||||
|
const saved = await window.electronAPI.menu.save(payload);
|
||||||
|
setItems(saved.items);
|
||||||
|
showToast.success(tr('menuEditor.saved'));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save menu:', error);
|
||||||
|
showToast.error(tr('menuEditor.saveFailed'));
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="menu-editor-view">
|
||||||
|
<div className="menu-editor-header">
|
||||||
|
<div>
|
||||||
|
<h2>{tr('menuEditor.title')}</h2>
|
||||||
|
<p>{tr('menuEditor.description')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="menu-editor-loading">{tr('menuEditor.loading')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="menu-editor-main">
|
||||||
|
<div className="menu-editor-tree-wrap">
|
||||||
|
<div className="menu-editor-toolbar" role="toolbar" aria-label={tr('menuEditor.title')}>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addPage')} aria-label={tr('menuEditor.addPage')} onClick={() => addRootItem('page')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M3 2h6l4 4v8H3V2zm6 1.5V6h2.5L9 3.5zM7 8V6h2v2h2v2H9v2H7v-2H5V8h2z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addSubmenu')} aria-label={tr('menuEditor.addSubmenu')} onClick={() => addRootItem('submenu')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h8v2H2V3zm0 4h8v2H2V7zm0 4h8v2H2v-2zm9-8h3v3h-1V4h-2V3zm2 4h1v6h-6v-1h5V7z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildPage')} aria-label={tr('menuEditor.addChildPage')} onClick={() => addChildItem('page')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm6 5V6h2v2h2v2h-2v2H8v-2H6V8h2z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.addChildSubmenu')} aria-label={tr('menuEditor.addChildSubmenu')} onClick={() => addChildItem('submenu')}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 3h5v2H4v6h3v2H2V3zm5 2h7v2H7V5zm3 3h4v2h-4V8zm0 3h4v2h-4v-2z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveUp')} aria-label={tr('menuEditor.moveUp')} onClick={() => moveSelected('up')} disabled={!selectedPath}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M8 3l4 4H9v6H7V7H4l4-4z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.moveDown')} aria-label={tr('menuEditor.moveDown')} onClick={() => moveSelected('down')} disabled={!selectedPath}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M7 3h2v6h3l-4 4-4-4h3V3z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.indent')} aria-label={tr('menuEditor.indent')} onClick={indentSelected} disabled={!selectedPath || selectedPath[selectedPath.length - 1] === 0}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm6-1 3 2-3 2V9z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.unindent')} aria-label={tr('menuEditor.unindent')} onClick={unindentSelected} disabled={!selectedPath || selectedPath.length < 2}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 4h8v2H2V4zm0 3h4v2H2V7zm0 3h8v2H2v-2zm3-1-3 2 3 2V9z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.delete')} aria-label={tr('menuEditor.delete')} onClick={deleteSelected} disabled={!selectedPath}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M6 2h4l1 1h3v2H2V3h3l1-1zm-1 4h2v6H5V6zm4 0h2v6H9V6z" /></svg>
|
||||||
|
</button>
|
||||||
|
<button type="button" className="menu-editor-tool" title={tr('menuEditor.save')} aria-label={tr('menuEditor.save')} onClick={() => void save()} disabled={isSaving}>
|
||||||
|
<svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M2 2h9l3 3v9H2V2zm2 1v3h6V3H4zm0 9h8V7H4v5z" /></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{items.length === 0 ? (
|
||||||
|
<div className="menu-editor-empty">{tr('menuEditor.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<Tree<MenuItemData>
|
||||||
|
data={items}
|
||||||
|
width={720}
|
||||||
|
height={420}
|
||||||
|
rowHeight={30}
|
||||||
|
indent={20}
|
||||||
|
openByDefault
|
||||||
|
disableEdit
|
||||||
|
disableMultiSelection
|
||||||
|
onMove={({ dragIds, parentId, index }) => {
|
||||||
|
setItems((previous) => applyTreeMove(previous, {
|
||||||
|
dragIds,
|
||||||
|
parentId,
|
||||||
|
index,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onSelect={(nodes) => {
|
||||||
|
setSelectedId(nodes[0]?.data.id || null);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{({ node, style, tree }) => (
|
||||||
|
<div
|
||||||
|
style={style}
|
||||||
|
className={`menu-editor-row ${selectedId === node.data.id ? 'is-selected' : ''}`}
|
||||||
|
onClick={() => setSelectedId(node.data.id)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
if (!tree.dragNode || !node.isInternal || node.isOpen) {
|
||||||
|
autoExpandController.cancel(node.id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
autoExpandController.schedule(node.id, () => {
|
||||||
|
node.open();
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
autoExpandController.cancel(node.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="menu-editor-row-kind">
|
||||||
|
{node.data.kind === 'page' ? tr('menuEditor.type.page') : tr('menuEditor.type.submenu')}
|
||||||
|
</span>
|
||||||
|
<span className="menu-editor-row-title">{node.data.title}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Tree>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="menu-editor-details">
|
||||||
|
<h3>{tr('menuEditor.details')}</h3>
|
||||||
|
{!selectedItem ? (
|
||||||
|
<p>{tr('menuEditor.selectItem')}</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>{tr('menuEditor.field.title')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedItem.title}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
replaceSelected((item) => ({ ...item, title: value }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>{tr('menuEditor.field.type')}</span>
|
||||||
|
<select
|
||||||
|
value={selectedItem.kind}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value as MenuItemKind;
|
||||||
|
replaceSelected((item) => ({ ...item, kind: value }));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="page">{tr('menuEditor.type.page')}</option>
|
||||||
|
<option value="submenu">{tr('menuEditor.type.submenu')}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{selectedItem.kind === 'page' && (
|
||||||
|
<>
|
||||||
|
<label>
|
||||||
|
<span>{tr('menuEditor.field.pageSlug')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedItem.pageSlug || ''}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
replaceSelected((item) => ({ ...item, pageSlug: value || undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label>
|
||||||
|
<span>{tr('menuEditor.field.pageId')}</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={selectedItem.pageId || ''}
|
||||||
|
onChange={(event) => {
|
||||||
|
const value = event.target.value;
|
||||||
|
replaceSelected((item) => ({ ...item, pageId: value || undefined }));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showPagePicker && (
|
||||||
|
<div className="menu-editor-picker-backdrop" onClick={closePagePicker}>
|
||||||
|
<div className="menu-editor-picker" onClick={(event) => event.stopPropagation()}>
|
||||||
|
<div className="menu-editor-picker-header">
|
||||||
|
<h3>{tr('menuEditor.pagePicker.title')}</h3>
|
||||||
|
<button type="button" onClick={closePagePicker}>{tr('common.cancel')}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<input
|
||||||
|
ref={pagePickerInputRef}
|
||||||
|
type="text"
|
||||||
|
value={pagePickerQuery}
|
||||||
|
onChange={(event) => setPagePickerQuery(event.target.value)}
|
||||||
|
onKeyDown={(event) => {
|
||||||
|
if (isPickerCloseKey(event.key)) {
|
||||||
|
event.preventDefault();
|
||||||
|
closePagePicker();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
|
||||||
|
event.preventDefault();
|
||||||
|
setPagePickerActiveIndex((previous) => getNextPickerIndex(previous, event.key, filteredPagePosts.length));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (event.key === 'Enter' && pagePickerActiveIndex >= 0 && pagePickerActiveIndex < filteredPagePosts.length) {
|
||||||
|
event.preventDefault();
|
||||||
|
selectPageForMenu(filteredPagePosts[pagePickerActiveIndex]);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={tr('menuEditor.pagePicker.searchPlaceholder')}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
|
||||||
|
{pagePickerLoading ? (
|
||||||
|
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.loading')}</div>
|
||||||
|
) : filteredPagePosts.length === 0 ? (
|
||||||
|
<div className="menu-editor-picker-state">{tr('menuEditor.pagePicker.empty')}</div>
|
||||||
|
) : (
|
||||||
|
<div className="menu-editor-picker-list">
|
||||||
|
{filteredPagePosts.map((post) => (
|
||||||
|
<button
|
||||||
|
key={post.id}
|
||||||
|
type="button"
|
||||||
|
className={`menu-editor-picker-item ${filteredPagePosts[pagePickerActiveIndex]?.id === post.id ? 'is-active' : ''}`}
|
||||||
|
onClick={() => selectPageForMenu(post)}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
const nextIndex = filteredPagePosts.findIndex((candidate) => candidate.id === post.id);
|
||||||
|
setPagePickerActiveIndex(nextIndex);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{post.title}</span>
|
||||||
|
<small>/{post.slug}</small>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
43
src/renderer/components/MenuEditorView/menuAutoExpand.ts
Normal file
43
src/renderer/components/MenuEditorView/menuAutoExpand.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
interface AutoExpandController {
|
||||||
|
schedule: (id: string, callback: () => void) => void;
|
||||||
|
cancel: (id: string) => void;
|
||||||
|
cancelAll: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createAutoExpandController(delayMs: number): AutoExpandController {
|
||||||
|
const timers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
|
|
||||||
|
const cancel = (id: string): void => {
|
||||||
|
const timer = timers.get(id);
|
||||||
|
if (!timer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearTimeout(timer);
|
||||||
|
timers.delete(id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const cancelAll = (): void => {
|
||||||
|
for (const timer of timers.values()) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
timers.clear();
|
||||||
|
};
|
||||||
|
|
||||||
|
const schedule = (id: string, callback: () => void): void => {
|
||||||
|
cancel(id);
|
||||||
|
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
timers.delete(id);
|
||||||
|
callback();
|
||||||
|
}, delayMs);
|
||||||
|
|
||||||
|
timers.set(id, timer);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
schedule,
|
||||||
|
cancel,
|
||||||
|
cancelAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
55
src/renderer/components/MenuEditorView/menuPagePicker.ts
Normal file
55
src/renderer/components/MenuEditorView/menuPagePicker.ts
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
import type { MenuItemData, PostData } from '../../../main/shared/electronApi';
|
||||||
|
|
||||||
|
function createMenuItemId(): string {
|
||||||
|
return `menu-item-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterPagePosts(posts: PostData[], query: string): PostData[] {
|
||||||
|
const normalized = query.trim().toLowerCase();
|
||||||
|
|
||||||
|
return posts.filter((post) => {
|
||||||
|
if (!(post.categories || []).includes('page')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!normalized) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return post.title.toLowerCase().includes(normalized) || post.slug.toLowerCase().includes(normalized);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createMenuPageItemFromPost(post: PostData): MenuItemData {
|
||||||
|
return {
|
||||||
|
id: createMenuItemId(),
|
||||||
|
title: post.title,
|
||||||
|
kind: 'page',
|
||||||
|
pageId: post.id,
|
||||||
|
pageSlug: post.slug,
|
||||||
|
children: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getNextPickerIndex(currentIndex: number, key: 'ArrowDown' | 'ArrowUp', total: number): number {
|
||||||
|
if (total <= 0) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (key === 'ArrowDown') {
|
||||||
|
const next = currentIndex + 1;
|
||||||
|
return next >= total ? 0 : Math.max(0, next);
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = currentIndex < 0 ? total - 1 : currentIndex - 1;
|
||||||
|
return next < 0 ? total - 1 : next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPickerCloseKey(key: string): boolean {
|
||||||
|
return key === 'Escape';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPickerFocusShortcut(event: { key: string; metaKey: boolean; ctrlKey: boolean }): boolean {
|
||||||
|
const normalizedKey = event.key.toLowerCase();
|
||||||
|
return normalizedKey === 'k' && (event.metaKey || event.ctrlKey);
|
||||||
|
}
|
||||||
115
src/renderer/components/MenuEditorView/menuTreeMove.ts
Normal file
115
src/renderer/components/MenuEditorView/menuTreeMove.ts
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import type { MenuItemData } from '../../../main/shared/electronApi';
|
||||||
|
|
||||||
|
export type MenuTreeItem = MenuItemData;
|
||||||
|
|
||||||
|
interface TreeMoveInput {
|
||||||
|
dragIds: string[];
|
||||||
|
parentId: string | null;
|
||||||
|
index: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findPathById(items: MenuTreeItem[], id: string, path: number[] = []): number[] | null {
|
||||||
|
for (let index = 0; index < items.length; index += 1) {
|
||||||
|
const item = items[index];
|
||||||
|
const nextPath = [...path, index];
|
||||||
|
if (item.id === id) {
|
||||||
|
return nextPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = findPathById(item.children, id, nextPath);
|
||||||
|
if (nested) {
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeItemByPath(items: MenuTreeItem[], path: number[]): { next: MenuTreeItem[]; removed: MenuTreeItem | null } {
|
||||||
|
if (path.length === 0) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.length === 1) {
|
||||||
|
const [index] = path;
|
||||||
|
if (index < 0 || index >= items.length) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = items[index];
|
||||||
|
return {
|
||||||
|
next: items.filter((_, currentIndex) => currentIndex !== index),
|
||||||
|
removed,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, ...tail] = path;
|
||||||
|
const current = items[head];
|
||||||
|
if (!current) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const nested = removeItemByPath(current.children, tail);
|
||||||
|
if (!nested.removed) {
|
||||||
|
return { next: items, removed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = items.map((item, index) => (index === head ? { ...item, children: nested.next } : item));
|
||||||
|
return { next, removed: nested.removed };
|
||||||
|
}
|
||||||
|
|
||||||
|
function insertItemsAtPath(items: MenuTreeItem[], parentPath: number[], index: number, nodes: MenuTreeItem[]): MenuTreeItem[] {
|
||||||
|
if (parentPath.length === 0) {
|
||||||
|
const boundedIndex = Math.max(0, Math.min(index, items.length));
|
||||||
|
return [
|
||||||
|
...items.slice(0, boundedIndex),
|
||||||
|
...nodes,
|
||||||
|
...items.slice(boundedIndex),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const [head, ...tail] = parentPath;
|
||||||
|
return items.map((item, currentIndex) => {
|
||||||
|
if (currentIndex !== head) {
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
children: insertItemsAtPath(item.children, tail, index, nodes),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyTreeMove(items: MenuTreeItem[], move: TreeMoveInput): MenuTreeItem[] {
|
||||||
|
if (!move.dragIds.length) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
let working = items;
|
||||||
|
const draggedNodes: MenuTreeItem[] = [];
|
||||||
|
|
||||||
|
for (const dragId of move.dragIds) {
|
||||||
|
const path = findPathById(working, dragId);
|
||||||
|
if (!path) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const removed = removeItemByPath(working, path);
|
||||||
|
if (removed.removed) {
|
||||||
|
draggedNodes.push(removed.removed);
|
||||||
|
working = removed.next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!draggedNodes.length) {
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parentPath = move.parentId ? findPathById(working, move.parentId) : [];
|
||||||
|
if (move.parentId && !parentPath) {
|
||||||
|
return working;
|
||||||
|
}
|
||||||
|
|
||||||
|
return insertItemsAtPath(working, parentPath || [], move.index, draggedNodes);
|
||||||
|
}
|
||||||
@@ -64,6 +64,10 @@ const getTabTitle = (
|
|||||||
return importDefTitles.get(tab.id) || tr('activity.import');
|
return importDefTitles.get(tab.id) || tr('activity.import');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tab.type === 'menu-editor') {
|
||||||
|
return tr('menuEditor.tabTitle');
|
||||||
|
}
|
||||||
|
|
||||||
if (tab.type === 'metadata-diff') {
|
if (tab.type === 'metadata-diff') {
|
||||||
return tr('app.metadataDiff');
|
return tr('app.metadataDiff');
|
||||||
}
|
}
|
||||||
@@ -129,6 +133,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
|||||||
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
<path d="M19 9h-4V3H9v6H5l7 7 7-7zM5 18v2h14v-2H5z"/>
|
||||||
</svg>
|
</svg>
|
||||||
);
|
);
|
||||||
|
case 'menu-editor':
|
||||||
|
return (
|
||||||
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
<path d="M2 3h12v1H2V3zm0 3h12v1H2V6zm0 3h8v1H2V9zm0 3h8v1H2v-1zm10-2 2 2-2 2v-1H9v-2h3V10z"/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
case 'metadata-diff':
|
case 'metadata-diff':
|
||||||
return (
|
return (
|
||||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||||
|
|||||||
@@ -41,6 +41,40 @@
|
|||||||
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
|
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
|
||||||
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
|
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
|
||||||
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
|
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
|
||||||
|
"menuEditor.tabTitle": "Blog-Menü",
|
||||||
|
"menuEditor.title": "Blog-Menü-Editor",
|
||||||
|
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
|
||||||
|
"menuEditor.loading": "Menü wird geladen...",
|
||||||
|
"menuEditor.loadError": "Blog-Menü konnte nicht geladen werden",
|
||||||
|
"menuEditor.save": "Menü speichern",
|
||||||
|
"menuEditor.saving": "Speichern...",
|
||||||
|
"menuEditor.saved": "Blog-Menü gespeichert",
|
||||||
|
"menuEditor.saveFailed": "Blog-Menü konnte nicht gespeichert werden",
|
||||||
|
"menuEditor.pagePicker.title": "Seite auswählen",
|
||||||
|
"menuEditor.pagePicker.searchPlaceholder": "Seiten nach Titel oder Slug durchsuchen...",
|
||||||
|
"menuEditor.pagePicker.loading": "Seiten werden geladen...",
|
||||||
|
"menuEditor.pagePicker.empty": "Keine passenden Seiten gefunden.",
|
||||||
|
"menuEditor.pagePicker.loadError": "Seiten konnten nicht geladen werden",
|
||||||
|
"menuEditor.addPage": "Seite hinzufügen",
|
||||||
|
"menuEditor.addSubmenu": "Untermenü hinzufügen",
|
||||||
|
"menuEditor.addChildPage": "Unterseite hinzufügen",
|
||||||
|
"menuEditor.addChildSubmenu": "Unter-Untermenü hinzufügen",
|
||||||
|
"menuEditor.moveUp": "Nach oben",
|
||||||
|
"menuEditor.moveDown": "Nach unten",
|
||||||
|
"menuEditor.indent": "Einrücken",
|
||||||
|
"menuEditor.unindent": "Ausrücken",
|
||||||
|
"menuEditor.delete": "Löschen",
|
||||||
|
"menuEditor.details": "Eintragsdetails",
|
||||||
|
"menuEditor.selectItem": "Wähle einen Eintrag, um Details zu bearbeiten.",
|
||||||
|
"menuEditor.field.title": "Titel",
|
||||||
|
"menuEditor.field.type": "Typ",
|
||||||
|
"menuEditor.field.pageSlug": "Seiten-Slug",
|
||||||
|
"menuEditor.field.pageId": "Seiten-ID",
|
||||||
|
"menuEditor.type.page": "Seite",
|
||||||
|
"menuEditor.type.submenu": "Untermenü",
|
||||||
|
"menuEditor.empty": "Noch keine Menüeinträge. Füge eine Seite oder ein Untermenü hinzu.",
|
||||||
|
"menuEditor.newPage": "Neue Seite",
|
||||||
|
"menuEditor.newSubmenu": "Neues Untermenü",
|
||||||
"settings.language.english": "Englisch",
|
"settings.language.english": "Englisch",
|
||||||
"settings.language.german": "Deutsch",
|
"settings.language.german": "Deutsch",
|
||||||
"settings.language.french": "Französisch",
|
"settings.language.french": "Französisch",
|
||||||
|
|||||||
@@ -41,6 +41,40 @@
|
|||||||
"siteValidation.error.validate": "Site validation failed",
|
"siteValidation.error.validate": "Site validation failed",
|
||||||
"siteValidation.error.apply": "Applying validation failed",
|
"siteValidation.error.apply": "Applying validation failed",
|
||||||
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
|
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
|
||||||
|
"menuEditor.tabTitle": "Blog Menu",
|
||||||
|
"menuEditor.title": "Blog Menu Editor",
|
||||||
|
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
|
||||||
|
"menuEditor.loading": "Loading menu...",
|
||||||
|
"menuEditor.loadError": "Failed to load blog menu",
|
||||||
|
"menuEditor.save": "Save Menu",
|
||||||
|
"menuEditor.saving": "Saving...",
|
||||||
|
"menuEditor.saved": "Blog menu saved",
|
||||||
|
"menuEditor.saveFailed": "Failed to save blog menu",
|
||||||
|
"menuEditor.pagePicker.title": "Select Page",
|
||||||
|
"menuEditor.pagePicker.searchPlaceholder": "Search pages by title or slug...",
|
||||||
|
"menuEditor.pagePicker.loading": "Loading pages...",
|
||||||
|
"menuEditor.pagePicker.empty": "No matching pages found.",
|
||||||
|
"menuEditor.pagePicker.loadError": "Failed to load pages",
|
||||||
|
"menuEditor.addPage": "Add Page",
|
||||||
|
"menuEditor.addSubmenu": "Add Submenu",
|
||||||
|
"menuEditor.addChildPage": "Add Child Page",
|
||||||
|
"menuEditor.addChildSubmenu": "Add Child Submenu",
|
||||||
|
"menuEditor.moveUp": "Move Up",
|
||||||
|
"menuEditor.moveDown": "Move Down",
|
||||||
|
"menuEditor.indent": "Indent",
|
||||||
|
"menuEditor.unindent": "Unindent",
|
||||||
|
"menuEditor.delete": "Delete",
|
||||||
|
"menuEditor.details": "Entry Details",
|
||||||
|
"menuEditor.selectItem": "Select an entry to edit details.",
|
||||||
|
"menuEditor.field.title": "Title",
|
||||||
|
"menuEditor.field.type": "Type",
|
||||||
|
"menuEditor.field.pageSlug": "Page Slug",
|
||||||
|
"menuEditor.field.pageId": "Page ID",
|
||||||
|
"menuEditor.type.page": "Page",
|
||||||
|
"menuEditor.type.submenu": "Submenu",
|
||||||
|
"menuEditor.empty": "No menu entries yet. Add a page or submenu to start.",
|
||||||
|
"menuEditor.newPage": "New Page",
|
||||||
|
"menuEditor.newSubmenu": "New Submenu",
|
||||||
"settings.language.english": "English",
|
"settings.language.english": "English",
|
||||||
"settings.language.german": "German",
|
"settings.language.german": "German",
|
||||||
"settings.language.french": "French",
|
"settings.language.french": "French",
|
||||||
|
|||||||
@@ -41,6 +41,40 @@
|
|||||||
"siteValidation.error.validate": "La validación del sitio falló",
|
"siteValidation.error.validate": "La validación del sitio falló",
|
||||||
"siteValidation.error.apply": "La aplicación de la validación falló",
|
"siteValidation.error.apply": "La aplicación de la validación falló",
|
||||||
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
|
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
|
||||||
|
"menuEditor.tabTitle": "Menú del blog",
|
||||||
|
"menuEditor.title": "Editor del menú del blog",
|
||||||
|
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
|
||||||
|
"menuEditor.loading": "Cargando menú...",
|
||||||
|
"menuEditor.loadError": "No se pudo cargar el menú del blog",
|
||||||
|
"menuEditor.save": "Guardar menú",
|
||||||
|
"menuEditor.saving": "Guardando...",
|
||||||
|
"menuEditor.saved": "Menú del blog guardado",
|
||||||
|
"menuEditor.saveFailed": "No se pudo guardar el menú del blog",
|
||||||
|
"menuEditor.pagePicker.title": "Seleccionar página",
|
||||||
|
"menuEditor.pagePicker.searchPlaceholder": "Buscar páginas por título o slug...",
|
||||||
|
"menuEditor.pagePicker.loading": "Cargando páginas...",
|
||||||
|
"menuEditor.pagePicker.empty": "No se encontraron páginas coincidentes.",
|
||||||
|
"menuEditor.pagePicker.loadError": "No se pudieron cargar las páginas",
|
||||||
|
"menuEditor.addPage": "Añadir página",
|
||||||
|
"menuEditor.addSubmenu": "Añadir submenú",
|
||||||
|
"menuEditor.addChildPage": "Añadir página hija",
|
||||||
|
"menuEditor.addChildSubmenu": "Añadir submenú hijo",
|
||||||
|
"menuEditor.moveUp": "Mover arriba",
|
||||||
|
"menuEditor.moveDown": "Mover abajo",
|
||||||
|
"menuEditor.indent": "Sangrar",
|
||||||
|
"menuEditor.unindent": "Quitar sangría",
|
||||||
|
"menuEditor.delete": "Eliminar",
|
||||||
|
"menuEditor.details": "Detalles de la entrada",
|
||||||
|
"menuEditor.selectItem": "Selecciona una entrada para editar sus detalles.",
|
||||||
|
"menuEditor.field.title": "Título",
|
||||||
|
"menuEditor.field.type": "Tipo",
|
||||||
|
"menuEditor.field.pageSlug": "Slug de página",
|
||||||
|
"menuEditor.field.pageId": "ID de página",
|
||||||
|
"menuEditor.type.page": "Página",
|
||||||
|
"menuEditor.type.submenu": "Submenú",
|
||||||
|
"menuEditor.empty": "Aún no hay entradas de menú. Añade una página o un submenú para empezar.",
|
||||||
|
"menuEditor.newPage": "Nueva página",
|
||||||
|
"menuEditor.newSubmenu": "Nuevo submenú",
|
||||||
"settings.language.english": "Inglés",
|
"settings.language.english": "Inglés",
|
||||||
"settings.language.german": "Alemán",
|
"settings.language.german": "Alemán",
|
||||||
"settings.language.french": "Francés",
|
"settings.language.french": "Francés",
|
||||||
|
|||||||
@@ -41,6 +41,40 @@
|
|||||||
"siteValidation.error.validate": "Échec de la validation du site",
|
"siteValidation.error.validate": "Échec de la validation du site",
|
||||||
"siteValidation.error.apply": "Échec de l’application de la validation",
|
"siteValidation.error.apply": "Échec de l’application de la validation",
|
||||||
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
|
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
|
||||||
|
"menuEditor.tabTitle": "Menu du blog",
|
||||||
|
"menuEditor.title": "Éditeur du menu du blog",
|
||||||
|
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
|
||||||
|
"menuEditor.loading": "Chargement du menu...",
|
||||||
|
"menuEditor.loadError": "Impossible de charger le menu du blog",
|
||||||
|
"menuEditor.save": "Enregistrer le menu",
|
||||||
|
"menuEditor.saving": "Enregistrement...",
|
||||||
|
"menuEditor.saved": "Menu du blog enregistré",
|
||||||
|
"menuEditor.saveFailed": "Impossible d’enregistrer le menu du blog",
|
||||||
|
"menuEditor.pagePicker.title": "Sélectionner une page",
|
||||||
|
"menuEditor.pagePicker.searchPlaceholder": "Rechercher des pages par titre ou slug...",
|
||||||
|
"menuEditor.pagePicker.loading": "Chargement des pages...",
|
||||||
|
"menuEditor.pagePicker.empty": "Aucune page correspondante trouvée.",
|
||||||
|
"menuEditor.pagePicker.loadError": "Impossible de charger les pages",
|
||||||
|
"menuEditor.addPage": "Ajouter une page",
|
||||||
|
"menuEditor.addSubmenu": "Ajouter un sous-menu",
|
||||||
|
"menuEditor.addChildPage": "Ajouter une page enfant",
|
||||||
|
"menuEditor.addChildSubmenu": "Ajouter un sous-menu enfant",
|
||||||
|
"menuEditor.moveUp": "Monter",
|
||||||
|
"menuEditor.moveDown": "Descendre",
|
||||||
|
"menuEditor.indent": "Indenter",
|
||||||
|
"menuEditor.unindent": "Désindenter",
|
||||||
|
"menuEditor.delete": "Supprimer",
|
||||||
|
"menuEditor.details": "Détails de l’entrée",
|
||||||
|
"menuEditor.selectItem": "Sélectionnez une entrée pour modifier ses détails.",
|
||||||
|
"menuEditor.field.title": "Titre",
|
||||||
|
"menuEditor.field.type": "Type",
|
||||||
|
"menuEditor.field.pageSlug": "Slug de page",
|
||||||
|
"menuEditor.field.pageId": "ID de page",
|
||||||
|
"menuEditor.type.page": "Page",
|
||||||
|
"menuEditor.type.submenu": "Sous-menu",
|
||||||
|
"menuEditor.empty": "Aucune entrée de menu. Ajoutez une page ou un sous-menu pour commencer.",
|
||||||
|
"menuEditor.newPage": "Nouvelle page",
|
||||||
|
"menuEditor.newSubmenu": "Nouveau sous-menu",
|
||||||
"settings.language.english": "Anglais",
|
"settings.language.english": "Anglais",
|
||||||
"settings.language.german": "Allemand",
|
"settings.language.german": "Allemand",
|
||||||
"settings.language.french": "Français",
|
"settings.language.french": "Français",
|
||||||
|
|||||||
@@ -41,6 +41,40 @@
|
|||||||
"siteValidation.error.validate": "Validazione del sito non riuscita",
|
"siteValidation.error.validate": "Validazione del sito non riuscita",
|
||||||
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
|
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
|
||||||
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
|
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
|
||||||
|
"menuEditor.tabTitle": "Menu blog",
|
||||||
|
"menuEditor.title": "Editor del menu blog",
|
||||||
|
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
|
||||||
|
"menuEditor.loading": "Caricamento menu...",
|
||||||
|
"menuEditor.loadError": "Impossibile caricare il menu blog",
|
||||||
|
"menuEditor.save": "Salva menu",
|
||||||
|
"menuEditor.saving": "Salvataggio...",
|
||||||
|
"menuEditor.saved": "Menu blog salvato",
|
||||||
|
"menuEditor.saveFailed": "Impossibile salvare il menu blog",
|
||||||
|
"menuEditor.pagePicker.title": "Seleziona pagina",
|
||||||
|
"menuEditor.pagePicker.searchPlaceholder": "Cerca pagine per titolo o slug...",
|
||||||
|
"menuEditor.pagePicker.loading": "Caricamento pagine...",
|
||||||
|
"menuEditor.pagePicker.empty": "Nessuna pagina corrispondente trovata.",
|
||||||
|
"menuEditor.pagePicker.loadError": "Impossibile caricare le pagine",
|
||||||
|
"menuEditor.addPage": "Aggiungi pagina",
|
||||||
|
"menuEditor.addSubmenu": "Aggiungi sottomenu",
|
||||||
|
"menuEditor.addChildPage": "Aggiungi pagina figlia",
|
||||||
|
"menuEditor.addChildSubmenu": "Aggiungi sottomenu figlio",
|
||||||
|
"menuEditor.moveUp": "Sposta su",
|
||||||
|
"menuEditor.moveDown": "Sposta giù",
|
||||||
|
"menuEditor.indent": "Indenta",
|
||||||
|
"menuEditor.unindent": "Riduci rientro",
|
||||||
|
"menuEditor.delete": "Elimina",
|
||||||
|
"menuEditor.details": "Dettagli voce",
|
||||||
|
"menuEditor.selectItem": "Seleziona una voce per modificarne i dettagli.",
|
||||||
|
"menuEditor.field.title": "Titolo",
|
||||||
|
"menuEditor.field.type": "Tipo",
|
||||||
|
"menuEditor.field.pageSlug": "Slug pagina",
|
||||||
|
"menuEditor.field.pageId": "ID pagina",
|
||||||
|
"menuEditor.type.page": "Pagina",
|
||||||
|
"menuEditor.type.submenu": "Sottomenu",
|
||||||
|
"menuEditor.empty": "Nessuna voce menu. Aggiungi una pagina o un sottomenu per iniziare.",
|
||||||
|
"menuEditor.newPage": "Nuova pagina",
|
||||||
|
"menuEditor.newSubmenu": "Nuovo sottomenu",
|
||||||
"settings.language.english": "Inglese",
|
"settings.language.english": "Inglese",
|
||||||
"settings.language.german": "Tedesco",
|
"settings.language.german": "Tedesco",
|
||||||
"settings.language.french": "Francese",
|
"settings.language.french": "Francese",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type EditorRoute =
|
|||||||
| 'tags'
|
| 'tags'
|
||||||
| 'chat'
|
| 'chat'
|
||||||
| 'import'
|
| 'import'
|
||||||
|
| 'menu-editor'
|
||||||
| 'metadata-diff'
|
| 'metadata-diff'
|
||||||
| 'git-diff'
|
| 'git-diff'
|
||||||
| 'documentation'
|
| 'documentation'
|
||||||
@@ -23,6 +24,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
|
|||||||
tags: 'tags',
|
tags: 'tags',
|
||||||
chat: 'chat',
|
chat: 'chat',
|
||||||
import: 'import',
|
import: 'import',
|
||||||
|
'menu-editor': 'menu-editor',
|
||||||
'metadata-diff': 'metadata-diff',
|
'metadata-diff': 'metadata-diff',
|
||||||
'git-diff': 'git-diff',
|
'git-diff': 'git-diff',
|
||||||
documentation: 'documentation',
|
documentation: 'documentation',
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export type SingletonToolTabKey =
|
|||||||
| 'settings'
|
| 'settings'
|
||||||
| 'tags'
|
| 'tags'
|
||||||
| 'style'
|
| 'style'
|
||||||
|
| 'menu-editor'
|
||||||
| 'documentation'
|
| 'documentation'
|
||||||
| 'metadata-diff'
|
| 'metadata-diff'
|
||||||
| 'site-validation';
|
| 'site-validation';
|
||||||
@@ -22,6 +23,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
|
|||||||
settings: { type: 'settings', id: 'settings', isTransient: false },
|
settings: { type: 'settings', id: 'settings', isTransient: false },
|
||||||
tags: { type: 'tags', id: 'tags', isTransient: false },
|
tags: { type: 'tags', id: 'tags', isTransient: false },
|
||||||
style: { type: 'style', id: 'style', isTransient: false },
|
style: { type: 'style', id: 'style', isTransient: false },
|
||||||
|
'menu-editor': { type: 'menu-editor', id: 'menu-editor', isTransient: false },
|
||||||
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
|
documentation: { type: 'documentation', id: 'documentation', isTransient: false },
|
||||||
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
|
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
|
||||||
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
|
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import type {
|
|||||||
const STORAGE_KEY = 'bds-app-state';
|
const STORAGE_KEY = 'bds-app-state';
|
||||||
|
|
||||||
// Tab types
|
// Tab types
|
||||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
|
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'site-validation';
|
||||||
|
|
||||||
export interface Tab {
|
export interface Tab {
|
||||||
type: TabType;
|
type: TabType;
|
||||||
|
|||||||
110
tests/engine/MenuEngine.test.ts
Normal file
110
tests/engine/MenuEngine.test.ts
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
const mockFiles = new Map<string, string>();
|
||||||
|
const mockDirs = new Set<string>();
|
||||||
|
|
||||||
|
const normalizePath = (value: string): string => value.replace(/\\/g, '/');
|
||||||
|
|
||||||
|
vi.mock('fs/promises', () => ({
|
||||||
|
readFile: vi.fn(async (filePath: string) => {
|
||||||
|
const normalizedPath = normalizePath(filePath);
|
||||||
|
if (mockFiles.has(normalizedPath)) {
|
||||||
|
return mockFiles.get(normalizedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const err = new Error(`ENOENT: no such file or directory, open '${filePath}'`) as NodeJS.ErrnoException;
|
||||||
|
err.code = 'ENOENT';
|
||||||
|
throw err;
|
||||||
|
}),
|
||||||
|
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||||
|
mockFiles.set(normalizePath(filePath), content);
|
||||||
|
}),
|
||||||
|
mkdir: vi.fn(async (dirPath: string) => {
|
||||||
|
mockDirs.add(normalizePath(dirPath));
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('electron', () => ({
|
||||||
|
app: {
|
||||||
|
getPath: vi.fn(() => '/mock/userData'),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { MenuEngine } from '../../src/main/engine/MenuEngine';
|
||||||
|
|
||||||
|
describe('MenuEngine', () => {
|
||||||
|
let menuEngine: MenuEngine;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockFiles.clear();
|
||||||
|
mockDirs.clear();
|
||||||
|
menuEngine = new MenuEngine();
|
||||||
|
menuEngine.setProjectContext('project-1');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty menu when no OPML file exists', async () => {
|
||||||
|
const result = await menuEngine.getMenu();
|
||||||
|
|
||||||
|
expect(result.items).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parses nested OPML outlines into menu items', async () => {
|
||||||
|
const menuPath = normalizePath(`${menuEngine.getMetaDir()}/menu.opml`);
|
||||||
|
mockFiles.set(
|
||||||
|
menuPath,
|
||||||
|
`<?xml version="1.0" encoding="UTF-8"?>\n<opml version="2.0">\n <head><title>Blog Menu</title></head>\n <body>\n <outline id="home" text="Home" type="page" pageSlug="home"/>\n <outline id="docs" text="Docs" type="submenu">\n <outline id="about" text="About" type="page" pageSlug="about"/>\n </outline>\n </body>\n</opml>`,
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await menuEngine.getMenu();
|
||||||
|
|
||||||
|
expect(result.items).toHaveLength(2);
|
||||||
|
expect(result.items[0]).toMatchObject({
|
||||||
|
id: 'home',
|
||||||
|
title: 'Home',
|
||||||
|
kind: 'page',
|
||||||
|
pageSlug: 'home',
|
||||||
|
});
|
||||||
|
expect(result.items[1]).toMatchObject({
|
||||||
|
id: 'docs',
|
||||||
|
title: 'Docs',
|
||||||
|
kind: 'submenu',
|
||||||
|
});
|
||||||
|
expect(result.items[1].children[0]).toMatchObject({
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
kind: 'page',
|
||||||
|
pageSlug: 'about',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('writes menu state as OPML and can read it back', async () => {
|
||||||
|
const saved = await menuEngine.saveMenu({
|
||||||
|
items: [
|
||||||
|
{
|
||||||
|
id: 'top',
|
||||||
|
title: 'Top',
|
||||||
|
kind: 'submenu',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'page-1',
|
||||||
|
title: 'First Page',
|
||||||
|
kind: 'page',
|
||||||
|
pageSlug: 'first-page',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(saved.items[0].title).toBe('Top');
|
||||||
|
|
||||||
|
const roundTrip = await menuEngine.getMenu();
|
||||||
|
expect(roundTrip).toEqual(saved);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -136,6 +136,12 @@ const mockTagEngine = {
|
|||||||
searchTags: vi.fn(),
|
searchTags: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const mockMenuEngine = {
|
||||||
|
setProjectContext: vi.fn(),
|
||||||
|
getMenu: vi.fn(),
|
||||||
|
saveMenu: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
const mockPostMediaEngine = {
|
const mockPostMediaEngine = {
|
||||||
on: vi.fn(),
|
on: vi.fn(),
|
||||||
setProjectContext: vi.fn(),
|
setProjectContext: vi.fn(),
|
||||||
@@ -245,6 +251,10 @@ vi.mock('../../src/main/engine/TagEngine', () => ({
|
|||||||
getTagEngine: vi.fn(() => mockTagEngine),
|
getTagEngine: vi.fn(() => mockTagEngine),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../src/main/engine/MenuEngine', () => ({
|
||||||
|
getMenuEngine: vi.fn(() => mockMenuEngine),
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
vi.mock('../../src/main/engine/PostMediaEngine', () => ({
|
||||||
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
getPostMediaEngine: vi.fn(() => mockPostMediaEngine),
|
||||||
}));
|
}));
|
||||||
@@ -1252,6 +1262,51 @@ describe('IPC Handlers', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ============ Menu Handlers ============
|
||||||
|
describe('Menu Handlers', () => {
|
||||||
|
describe('menu:get', () => {
|
||||||
|
it('loads menu for active project context', async () => {
|
||||||
|
const activeProject = createMockProject({ id: 'project-42', dataPath: '/custom/data' });
|
||||||
|
const menuDocument = {
|
||||||
|
items: [
|
||||||
|
{ id: 'home', title: 'Home', kind: 'page', pageSlug: 'home', children: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
||||||
|
mockMenuEngine.getMenu.mockResolvedValue(menuDocument);
|
||||||
|
|
||||||
|
const result = await invokeHandler('menu:get');
|
||||||
|
|
||||||
|
expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-42', '/resolved/project-data');
|
||||||
|
expect(mockMenuEngine.getMenu).toHaveBeenCalled();
|
||||||
|
expect(result).toEqual(menuDocument);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('menu:save', () => {
|
||||||
|
it('saves menu for active project context', async () => {
|
||||||
|
const activeProject = createMockProject({ id: 'project-24', dataPath: '/custom/data' });
|
||||||
|
const menuDocument = {
|
||||||
|
items: [
|
||||||
|
{ id: 'docs', title: 'Docs', kind: 'submenu', children: [] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
mockProjectEngine.getActiveProject.mockResolvedValue(activeProject);
|
||||||
|
mockProjectEngine.getDataDir.mockReturnValue('/resolved/project-data');
|
||||||
|
mockMenuEngine.saveMenu.mockResolvedValue(menuDocument);
|
||||||
|
|
||||||
|
const result = await invokeHandler('menu:save', menuDocument);
|
||||||
|
|
||||||
|
expect(mockMenuEngine.setProjectContext).toHaveBeenCalledWith('project-24', '/resolved/project-data');
|
||||||
|
expect(mockMenuEngine.saveMenu).toHaveBeenCalledWith(menuDocument);
|
||||||
|
expect(result).toEqual(menuDocument);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// ============ Task Handlers ============
|
// ============ Task Handlers ============
|
||||||
describe('Task Handlers', () => {
|
describe('Task Handlers', () => {
|
||||||
describe('tasks:getAll', () => {
|
describe('tasks:getAll', () => {
|
||||||
|
|||||||
63
tests/renderer/components/menuAutoExpand.test.ts
Normal file
63
tests/renderer/components/menuAutoExpand.test.ts
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { createAutoExpandController } from '../../../src/renderer/components/MenuEditorView/menuAutoExpand';
|
||||||
|
|
||||||
|
describe('createAutoExpandController', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('runs callback after configured delay', () => {
|
||||||
|
const controller = createAutoExpandController(300);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.schedule('node-a', callback);
|
||||||
|
vi.advanceTimersByTime(299);
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1);
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels scheduled callback for a node', () => {
|
||||||
|
const controller = createAutoExpandController(300);
|
||||||
|
const callback = vi.fn();
|
||||||
|
|
||||||
|
controller.schedule('node-a', callback);
|
||||||
|
controller.cancel('node-a');
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(callback).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('replaces existing schedule for same node id', () => {
|
||||||
|
const controller = createAutoExpandController(300);
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
|
||||||
|
controller.schedule('node-a', first);
|
||||||
|
controller.schedule('node-a', second);
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(first).not.toHaveBeenCalled();
|
||||||
|
expect(second).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cancels all pending callbacks', () => {
|
||||||
|
const controller = createAutoExpandController(300);
|
||||||
|
const first = vi.fn();
|
||||||
|
const second = vi.fn();
|
||||||
|
|
||||||
|
controller.schedule('node-a', first);
|
||||||
|
controller.schedule('node-b', second);
|
||||||
|
controller.cancelAll();
|
||||||
|
vi.runAllTimers();
|
||||||
|
|
||||||
|
expect(first).not.toHaveBeenCalled();
|
||||||
|
expect(second).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
94
tests/renderer/components/menuPagePicker.test.ts
Normal file
94
tests/renderer/components/menuPagePicker.test.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
|
import type { PostData } from '../../../src/main/shared/electronApi';
|
||||||
|
import {
|
||||||
|
createMenuPageItemFromPost,
|
||||||
|
filterPagePosts,
|
||||||
|
getNextPickerIndex,
|
||||||
|
isPickerCloseKey,
|
||||||
|
isPickerFocusShortcut,
|
||||||
|
} from '../../../src/renderer/components/MenuEditorView/menuPagePicker';
|
||||||
|
|
||||||
|
function createPost(overrides: Partial<PostData>): PostData {
|
||||||
|
return {
|
||||||
|
id: 'post-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
title: 'Sample Page',
|
||||||
|
slug: 'sample-page',
|
||||||
|
content: '',
|
||||||
|
status: 'draft',
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
tags: [],
|
||||||
|
categories: ['page'],
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('menuPagePicker', () => {
|
||||||
|
it('filters to page-category posts only', () => {
|
||||||
|
const posts = [
|
||||||
|
createPost({ id: 'page-1', categories: ['page'] }),
|
||||||
|
createPost({ id: 'article-1', categories: ['article'] }),
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = filterPagePosts(posts, '');
|
||||||
|
|
||||||
|
expect(result.map((post) => post.id)).toEqual(['page-1']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by title and slug using case-insensitive query', () => {
|
||||||
|
const posts = [
|
||||||
|
createPost({ id: 'alpha', title: 'About Us', slug: 'about-us' }),
|
||||||
|
createPost({ id: 'beta', title: 'Imprint', slug: 'impressum' }),
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(filterPagePosts(posts, 'about').map((post) => post.id)).toEqual(['alpha']);
|
||||||
|
expect(filterPagePosts(posts, 'IMPRESS').map((post) => post.id)).toEqual(['beta']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a menu page node with linked page metadata', () => {
|
||||||
|
const post = createPost({
|
||||||
|
id: 'page-3',
|
||||||
|
title: 'Contact',
|
||||||
|
slug: 'contact',
|
||||||
|
categories: ['page'],
|
||||||
|
});
|
||||||
|
|
||||||
|
const item = createMenuPageItemFromPost(post);
|
||||||
|
|
||||||
|
expect(item.kind).toBe('page');
|
||||||
|
expect(item.title).toBe('Contact');
|
||||||
|
expect(item.pageId).toBe('page-3');
|
||||||
|
expect(item.pageSlug).toBe('contact');
|
||||||
|
expect(item.children).toEqual([]);
|
||||||
|
expect(item.id.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves active index with arrow navigation and wraps around', () => {
|
||||||
|
expect(getNextPickerIndex(-1, 'ArrowDown', 3)).toBe(0);
|
||||||
|
expect(getNextPickerIndex(0, 'ArrowDown', 3)).toBe(1);
|
||||||
|
expect(getNextPickerIndex(2, 'ArrowDown', 3)).toBe(0);
|
||||||
|
|
||||||
|
expect(getNextPickerIndex(-1, 'ArrowUp', 3)).toBe(2);
|
||||||
|
expect(getNextPickerIndex(2, 'ArrowUp', 3)).toBe(1);
|
||||||
|
expect(getNextPickerIndex(0, 'ArrowUp', 3)).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns -1 when there are no picker items', () => {
|
||||||
|
expect(getNextPickerIndex(-1, 'ArrowDown', 0)).toBe(-1);
|
||||||
|
expect(getNextPickerIndex(1, 'ArrowUp', 0)).toBe(-1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects escape as picker close key', () => {
|
||||||
|
expect(isPickerCloseKey('Escape')).toBe(true);
|
||||||
|
expect(isPickerCloseKey('Enter')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detects cmd/ctrl+k as picker focus shortcut', () => {
|
||||||
|
expect(isPickerFocusShortcut({ key: 'k', metaKey: true, ctrlKey: false })).toBe(true);
|
||||||
|
expect(isPickerFocusShortcut({ key: 'K', metaKey: false, ctrlKey: true })).toBe(true);
|
||||||
|
expect(isPickerFocusShortcut({ key: 'k', metaKey: false, ctrlKey: false })).toBe(false);
|
||||||
|
expect(isPickerFocusShortcut({ key: 'p', metaKey: true, ctrlKey: false })).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
81
tests/renderer/components/menuTreeMove.test.ts
Normal file
81
tests/renderer/components/menuTreeMove.test.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { applyTreeMove, type MenuTreeItem } from '../../../src/renderer/components/MenuEditorView/menuTreeMove';
|
||||||
|
|
||||||
|
function createTree(): MenuTreeItem[] {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'home',
|
||||||
|
title: 'Home',
|
||||||
|
kind: 'page',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'docs',
|
||||||
|
title: 'Docs',
|
||||||
|
kind: 'submenu',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'about',
|
||||||
|
title: 'About',
|
||||||
|
kind: 'page',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blog',
|
||||||
|
title: 'Blog',
|
||||||
|
kind: 'submenu',
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
id: 'post-1',
|
||||||
|
title: 'Post 1',
|
||||||
|
kind: 'page',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'post-2',
|
||||||
|
title: 'Post 2',
|
||||||
|
kind: 'page',
|
||||||
|
children: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('applyTreeMove', () => {
|
||||||
|
it('moves a page into a submenu', () => {
|
||||||
|
const moved = applyTreeMove(createTree(), {
|
||||||
|
dragIds: ['home'],
|
||||||
|
parentId: 'docs',
|
||||||
|
index: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const docs = moved.find((item) => item.id === 'docs');
|
||||||
|
expect(docs?.children.map((child) => child.id)).toEqual(['about', 'home']);
|
||||||
|
expect(moved.map((item) => item.id)).toEqual(['docs', 'blog']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves a whole subtree without losing children', () => {
|
||||||
|
const moved = applyTreeMove(createTree(), {
|
||||||
|
dragIds: ['blog'],
|
||||||
|
parentId: null,
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(moved[0].id).toBe('blog');
|
||||||
|
expect(moved[0].children.map((child) => child.id)).toEqual(['post-1', 'post-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reorders siblings within same parent', () => {
|
||||||
|
const moved = applyTreeMove(createTree(), {
|
||||||
|
dragIds: ['post-2'],
|
||||||
|
parentId: 'blog',
|
||||||
|
index: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const blog = moved.find((item) => item.id === 'blog');
|
||||||
|
expect(blog?.children.map((child) => child.id)).toEqual(['post-2', 'post-1']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -63,4 +63,15 @@ describe('Help menu documentation entry', () => {
|
|||||||
it('maps Edit Preferences to a renderer menu event', () => {
|
it('maps Edit Preferences to a renderer menu event', () => {
|
||||||
expect(APP_MENU_ACTION_EVENT_MAP.editPreferences).toBe('menu:editPreferences');
|
expect(APP_MENU_ACTION_EVENT_MAP.editPreferences).toBe('menu:editPreferences');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('includes Edit Menu action in Blog menu', () => {
|
||||||
|
const blogGroup = APP_MENU_GROUPS.find((group) => group.label === 'Blog');
|
||||||
|
|
||||||
|
expect(blogGroup).toBeDefined();
|
||||||
|
expect(blogGroup?.items.some((item) => item.action === 'editMenu')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps Edit Menu to a renderer menu event', () => {
|
||||||
|
expect(APP_MENU_ACTION_EVENT_MAP.editMenu).toBe('menu:editMenu');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ describe('editorRouting', () => {
|
|||||||
tags: 'tags',
|
tags: 'tags',
|
||||||
chat: 'chat',
|
chat: 'chat',
|
||||||
import: 'import',
|
import: 'import',
|
||||||
|
'menu-editor': 'menu-editor',
|
||||||
'metadata-diff': 'metadata-diff',
|
'metadata-diff': 'metadata-diff',
|
||||||
'git-diff': 'git-diff',
|
'git-diff': 'git-diff',
|
||||||
documentation: 'documentation',
|
documentation: 'documentation',
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ describe('tabPolicy', () => {
|
|||||||
expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false });
|
expect(getSingletonToolTabSpec('settings')).toEqual({ type: 'settings', id: 'settings', isTransient: false });
|
||||||
expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false });
|
expect(getSingletonToolTabSpec('tags')).toEqual({ type: 'tags', id: 'tags', isTransient: false });
|
||||||
expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false });
|
expect(getSingletonToolTabSpec('style')).toEqual({ type: 'style', id: 'style', isTransient: false });
|
||||||
|
expect(getSingletonToolTabSpec('menu-editor')).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
|
||||||
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
|
expect(getSingletonToolTabSpec('documentation')).toEqual({ type: 'documentation', id: 'documentation', isTransient: false });
|
||||||
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
|
expect(getSingletonToolTabSpec('metadata-diff')).toEqual({ type: 'metadata-diff', id: 'metadata-diff', isTransient: false });
|
||||||
expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
expect(getSingletonToolTabSpec('site-validation')).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
||||||
@@ -33,8 +34,9 @@ describe('tabPolicy', () => {
|
|||||||
let captured: { type: string; id: string; isTransient: boolean } | null = null;
|
let captured: { type: string; id: string; isTransient: boolean } | null = null;
|
||||||
|
|
||||||
openSingletonToolTab(openTab, 'site-validation');
|
openSingletonToolTab(openTab, 'site-validation');
|
||||||
|
openSingletonToolTab(openTab, 'menu-editor');
|
||||||
|
|
||||||
expect(captured).toEqual({ type: 'site-validation', id: 'site-validation', isTransient: false });
|
expect(captured).toEqual({ type: 'menu-editor', id: 'menu-editor', isTransient: false });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('provides canonical entity tab spec for preview and pin intents', () => {
|
it('provides canonical entity tab spec for preview and pin intents', () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user