feat: implement plan step 1 — WebDAV CRUD (rename/copy/delete), content read/write; CodeMirror editor; UI actions; env(Qdrant); types & WebDAV client enhancements
This commit is contained in:
parent
479e461430
commit
362a97cada
323
package-lock.json
generated
323
package-lock.json
generated
@ -8,6 +8,7 @@
|
|||||||
"name": "nextcloud-explorer",
|
"name": "nextcloud-explorer",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-markdown": "^6.3.4",
|
||||||
"@elastic/elasticsearch": "^9.1.1",
|
"@elastic/elasticsearch": "^9.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@ -17,6 +18,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@sentry/nextjs": "^10.11.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
|
"@uiw/react-codemirror": "^4.25.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
@ -257,6 +259,15 @@
|
|||||||
"node": ">=6.0.0"
|
"node": ">=6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@babel/runtime": {
|
||||||
|
"version": "7.28.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz",
|
||||||
|
"integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6.9.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@babel/template": {
|
"node_modules/@babel/template": {
|
||||||
"version": "7.27.2",
|
"version": "7.27.2",
|
||||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz",
|
||||||
@ -330,6 +341,159 @@
|
|||||||
"url": "https://opencollective.com/node-fetch"
|
"url": "https://opencollective.com/node-fetch"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@codemirror/autocomplete": {
|
||||||
|
"version": "6.18.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.18.7.tgz",
|
||||||
|
"integrity": "sha512-8EzdeIoWPJDsMBwz3zdzwXnUpCzMiCyz5/A3FIPpriaclFCGDkAzK13sMcnsu5rowqiyeQN2Vs2TsOcoDPZirQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/commands": {
|
||||||
|
"version": "6.8.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.8.1.tgz",
|
||||||
|
"integrity": "sha512-KlGVYufHMQzxbdQONiLyGQDUW0itrLZwq3CcY7xpv9ZLRHqzkBSoteocBHtMCoY7/Ci4xhzSrToIeLg7FxHuaw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.4.0",
|
||||||
|
"@codemirror/view": "^6.27.0",
|
||||||
|
"@lezer/common": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-css": {
|
||||||
|
"version": "6.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||||
|
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.0.2",
|
||||||
|
"@lezer/css": "^1.1.7"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-html": {
|
||||||
|
"version": "6.4.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.10.tgz",
|
||||||
|
"integrity": "sha512-h/SceTVsN5r+WE+TVP2g3KDvNoSzbSrtZXCKo4vkKdbfT5t4otuVgngGdFukOO/rwRD2++pCxoh6xD4TEVMkQA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/lang-css": "^6.0.0",
|
||||||
|
"@codemirror/lang-javascript": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.4.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/css": "^1.1.0",
|
||||||
|
"@lezer/html": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-javascript": {
|
||||||
|
"version": "6.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz",
|
||||||
|
"integrity": "sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.6.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.17.0",
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/javascript": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lang-markdown": {
|
||||||
|
"version": "6.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lang-markdown/-/lang-markdown-6.3.4.tgz",
|
||||||
|
"integrity": "sha512-fBm0BO03azXnTAsxhONDYHi/qWSI+uSEIpzKM7h/bkIc9fHnFp9y7KTMXKON0teNT97pFhc1a9DQTtWBYEZ7ug==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.7.1",
|
||||||
|
"@codemirror/lang-html": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.3.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/common": "^1.2.1",
|
||||||
|
"@lezer/markdown": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/language": {
|
||||||
|
"version": "6.11.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.11.3.tgz",
|
||||||
|
"integrity": "sha512-9HBM2XnwDj7fnu0551HkGdrUrrqmYq/WC5iv6nbY2WdicXdGbhR/gfbZOH73Aqj4351alY1+aoG9rCNfiwS1RA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.23.0",
|
||||||
|
"@lezer/common": "^1.1.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0",
|
||||||
|
"style-mod": "^4.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/lint": {
|
||||||
|
"version": "6.8.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.8.5.tgz",
|
||||||
|
"integrity": "sha512-s3n3KisH7dx3vsoeGMxsbRAgKe4O1vbrnKBClm99PU0fWxmxsx5rR2PfqQgIt+2MMJBHbiJ5rfIdLYfB9NNvsA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.35.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/search": {
|
||||||
|
"version": "6.5.11",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.5.11.tgz",
|
||||||
|
"integrity": "sha512-KmWepDE6jUdL6n8cAAqIpRmLPBZ5ZKnicE8oGU/s3QrAVID+0VhLFrzUucVKHG5035/BSykhExDL/Xm7dHthiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"crelt": "^1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/state": {
|
||||||
|
"version": "6.5.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.5.2.tgz",
|
||||||
|
"integrity": "sha512-FVqsPqtPWKVVL3dPSxy8wEF/ymIEuVzF1PK3VbUgrxXpJUSHQWWZz4JMToquRxnkw+36LTamCZG2iua2Ptq0fA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@marijn/find-cluster-break": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/theme-one-dark": {
|
||||||
|
"version": "6.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz",
|
||||||
|
"integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@codemirror/view": {
|
||||||
|
"version": "6.38.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.38.2.tgz",
|
||||||
|
"integrity": "sha512-bTWAJxL6EOFLPzTx+O5P5xAO3gTqpatQ2b/ARQ8itfU/v2LlpS3pH2fkL0A3E/Fx8Y2St2KES7ZEV0sHTsSW/A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/state": "^6.5.0",
|
||||||
|
"crelt": "^1.0.6",
|
||||||
|
"style-mod": "^4.1.0",
|
||||||
|
"w3c-keyname": "^2.2.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@elastic/elasticsearch": {
|
"node_modules/@elastic/elasticsearch": {
|
||||||
"version": "9.1.1",
|
"version": "9.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.1.1.tgz",
|
||||||
@ -1556,6 +1720,79 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@lezer/common": {
|
||||||
|
"version": "1.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.2.3.tgz",
|
||||||
|
"integrity": "sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/css": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-pBL7hup88KbI7hXnZV3PQsn43DHy6TWyzuyk2AO9UyoXcDltvIdqWKE1dLL/45JVZ+YZkHe1WVHqO6wugZZWcw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/highlight": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/html": {
|
||||||
|
"version": "1.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz",
|
||||||
|
"integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.0.0",
|
||||||
|
"@lezer/lr": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/javascript": {
|
||||||
|
"version": "1.5.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.3.tgz",
|
||||||
|
"integrity": "sha512-jexmlKq5NpGiB7t+0QkyhSXRgaiab5YisHIQW9C7EcU19KSUsDguZe9WY+rmRDg34nXoNH2LQ4SxpC+aJUchSQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.2.0",
|
||||||
|
"@lezer/highlight": "^1.1.3",
|
||||||
|
"@lezer/lr": "^1.3.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/lr": {
|
||||||
|
"version": "1.4.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz",
|
||||||
|
"integrity": "sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@lezer/markdown": {
|
||||||
|
"version": "1.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@lezer/markdown/-/markdown-1.4.3.tgz",
|
||||||
|
"integrity": "sha512-kfw+2uMrQ/wy/+ONfrH83OkdFNM0ye5Xq96cLlaCy7h5UT9FO54DU4oRoIc0CSBh5NWmWuiIJA7NGLMJbQ+Oxg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@lezer/common": "^1.0.0",
|
||||||
|
"@lezer/highlight": "^1.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@marijn/find-cluster-break": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/wasm-runtime": {
|
"node_modules/@napi-rs/wasm-runtime": {
|
||||||
"version": "0.2.12",
|
"version": "0.2.12",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz",
|
||||||
@ -4665,6 +4902,59 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@uiw/codemirror-extensions-basic-setup": {
|
||||||
|
"version": "4.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.1.tgz",
|
||||||
|
"integrity": "sha512-zxgA2QkvP3ZDKxTBc9UltNFTrSeFezGXcZtZj6qcsBxiMzowoEMP5mVwXcKjpzldpZVRuY+JCC+RsekEgid4vg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@codemirror/autocomplete": ">=6.0.0",
|
||||||
|
"@codemirror/commands": ">=6.0.0",
|
||||||
|
"@codemirror/language": ">=6.0.0",
|
||||||
|
"@codemirror/lint": ">=6.0.0",
|
||||||
|
"@codemirror/search": ">=6.0.0",
|
||||||
|
"@codemirror/state": ">=6.0.0",
|
||||||
|
"@codemirror/view": ">=6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uiw/react-codemirror": {
|
||||||
|
"version": "4.25.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.1.tgz",
|
||||||
|
"integrity": "sha512-eESBKHndoYkaEGlKCwRO4KrnTw1HkWBxVpEeqntoWTpoFEUYxdLWUYmkPBVk4/u8YzVy9g91nFfIRpqe5LjApg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.18.6",
|
||||||
|
"@codemirror/commands": "^6.1.0",
|
||||||
|
"@codemirror/state": "^6.1.1",
|
||||||
|
"@codemirror/theme-one-dark": "^6.0.0",
|
||||||
|
"@uiw/codemirror-extensions-basic-setup": "4.25.1",
|
||||||
|
"codemirror": "^6.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://jaywcjlove.github.io/#/sponsor"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@babel/runtime": ">=7.11.0",
|
||||||
|
"@codemirror/state": ">=6.0.0",
|
||||||
|
"@codemirror/theme-one-dark": ">=6.0.0",
|
||||||
|
"@codemirror/view": ">=6.0.0",
|
||||||
|
"codemirror": ">=6.0.0",
|
||||||
|
"react": ">=17.0.0",
|
||||||
|
"react-dom": ">=17.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
"node_modules/@unrs/resolver-binding-android-arm-eabi": {
|
||||||
"version": "1.11.1",
|
"version": "1.11.1",
|
||||||
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
"resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz",
|
||||||
@ -5834,6 +6124,21 @@
|
|||||||
"node": ">=6"
|
"node": ">=6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/codemirror": {
|
||||||
|
"version": "6.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz",
|
||||||
|
"integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@codemirror/autocomplete": "^6.0.0",
|
||||||
|
"@codemirror/commands": "^6.0.0",
|
||||||
|
"@codemirror/language": "^6.0.0",
|
||||||
|
"@codemirror/lint": "^6.0.0",
|
||||||
|
"@codemirror/search": "^6.0.0",
|
||||||
|
"@codemirror/state": "^6.0.0",
|
||||||
|
"@codemirror/view": "^6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color": {
|
"node_modules/color": {
|
||||||
"version": "4.2.3",
|
"version": "4.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
@ -5941,6 +6246,12 @@
|
|||||||
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
"integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/crelt": {
|
||||||
|
"version": "1.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz",
|
||||||
|
"integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -10333,6 +10644,12 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/style-mod": {
|
||||||
|
"version": "4.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.2.tgz",
|
||||||
|
"integrity": "sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/styled-jsx": {
|
"node_modules/styled-jsx": {
|
||||||
"version": "5.1.6",
|
"version": "5.1.6",
|
||||||
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
|
||||||
@ -10944,6 +11261,12 @@
|
|||||||
"uuid": "dist/bin/uuid"
|
"uuid": "dist/bin/uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/w3c-keyname": {
|
||||||
|
"version": "2.2.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||||
|
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/watchpack": {
|
"node_modules/watchpack": {
|
||||||
"version": "2.4.4",
|
"version": "2.4.4",
|
||||||
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
|
||||||
|
|||||||
@ -10,6 +10,7 @@
|
|||||||
"create:index": "tsx -r dotenv/config -r tsconfig-paths/register scripts/create-index.ts"
|
"create:index": "tsx -r dotenv/config -r tsconfig-paths/register scripts/create-index.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@codemirror/lang-markdown": "^6.3.4",
|
||||||
"@elastic/elasticsearch": "^9.1.1",
|
"@elastic/elasticsearch": "^9.1.1",
|
||||||
"@radix-ui/react-dialog": "^1.1.15",
|
"@radix-ui/react-dialog": "^1.1.15",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||||
@ -19,6 +20,7 @@
|
|||||||
"@radix-ui/react-tooltip": "^1.2.8",
|
"@radix-ui/react-tooltip": "^1.2.8",
|
||||||
"@sentry/nextjs": "^10.11.0",
|
"@sentry/nextjs": "^10.11.0",
|
||||||
"@tanstack/react-query": "^5.87.4",
|
"@tanstack/react-query": "^5.87.4",
|
||||||
|
"@uiw/react-codemirror": "^4.25.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
|||||||
77
src/app/api/files/content/route.ts
Normal file
77
src/app/api/files/content/route.ts
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { nextcloud } from "@/lib/webdav";
|
||||||
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET ?path=/abs/path -> { content, mimeType }
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const rawPath = searchParams.get("path");
|
||||||
|
if (!rawPath) {
|
||||||
|
return json({ error: "path is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const path = normalizePath(rawPath);
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.files.content.get" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("path", path);
|
||||||
|
const res = await nextcloud.readText(path);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const status = /404/.test(message) ? 404 : 500;
|
||||||
|
return json({ error: "Failed to read file content", message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT { path, content, mimeType? } -> { ok, etag? }
|
||||||
|
export async function PUT(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
path?: string;
|
||||||
|
content?: string;
|
||||||
|
mimeType?: string;
|
||||||
|
};
|
||||||
|
if (!body?.path || typeof body.content !== "string") {
|
||||||
|
return json({ error: "path and content are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
const path = normalizePath(body.path);
|
||||||
|
const mime = body.mimeType || "text/markdown";
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.files.content.put" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("path", path);
|
||||||
|
span.setAttribute("content.length", body.content!.length);
|
||||||
|
span.setAttribute("mimeType", mime);
|
||||||
|
const res = await nextcloud.writeText(path, body.content!, mime);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const status = /409|conflict/i.test(message)
|
||||||
|
? 409
|
||||||
|
: /423|locked/i.test(message)
|
||||||
|
? 423
|
||||||
|
: 500;
|
||||||
|
return json({ error: "Failed to write file content", message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/app/api/files/copy/route.ts
Normal file
48
src/app/api/files/copy/route.ts
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { nextcloud } from "@/lib/webdav";
|
||||||
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body?.from || !body?.to) {
|
||||||
|
return json({ error: "from and to are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = normalizePath(body.from);
|
||||||
|
const to = normalizePath(body.to);
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.files.copy" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("path_from", from);
|
||||||
|
span.setAttribute("path_to", to);
|
||||||
|
const res = await nextcloud.copyFile(from, to);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const status = /409|conflict/i.test(message)
|
||||||
|
? 409
|
||||||
|
: /423|locked/i.test(message)
|
||||||
|
? 423
|
||||||
|
: 500;
|
||||||
|
return json({ error: "Failed to copy file", message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
45
src/app/api/files/delete/route.ts
Normal file
45
src/app/api/files/delete/route.ts
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { nextcloud } from "@/lib/webdav";
|
||||||
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
path?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body?.path) {
|
||||||
|
return json({ error: "path is required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const path = normalizePath(body.path);
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.files.delete" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("path", path);
|
||||||
|
const res = await nextcloud.deletePath(path);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
const status = /409|conflict/i.test(message)
|
||||||
|
? 409
|
||||||
|
: /423|locked/i.test(message)
|
||||||
|
? 423
|
||||||
|
: 500;
|
||||||
|
return json({ error: "Failed to delete path", message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
49
src/app/api/files/rename/route.ts
Normal file
49
src/app/api/files/rename/route.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
import * as Sentry from "@sentry/nextjs";
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { nextcloud } from "@/lib/webdav";
|
||||||
|
import { normalizePath } from "@/lib/paths";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
function json<T>(data: T, init?: { status?: number } & ResponseInit) {
|
||||||
|
return NextResponse.json(data, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
try {
|
||||||
|
const body = (await req.json().catch(() => ({}))) as {
|
||||||
|
from?: string;
|
||||||
|
to?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!body?.from || !body?.to) {
|
||||||
|
return json({ error: "from and to are required" }, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = normalizePath(body.from);
|
||||||
|
const to = normalizePath(body.to);
|
||||||
|
|
||||||
|
const result = await Sentry.startSpan(
|
||||||
|
{ op: "function", name: "api.files.rename" },
|
||||||
|
async (span) => {
|
||||||
|
span.setAttribute("path_from", from);
|
||||||
|
span.setAttribute("path_to", to);
|
||||||
|
const res = await nextcloud.moveFile(from, to);
|
||||||
|
return res;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return json(result);
|
||||||
|
} catch (error: unknown) {
|
||||||
|
Sentry.captureException(error);
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
// Map some common WebDAV conflict/locked responses if available
|
||||||
|
const status = /409|conflict/i.test(message)
|
||||||
|
? 409
|
||||||
|
: /423|locked/i.test(message)
|
||||||
|
? 423
|
||||||
|
: 500;
|
||||||
|
return json({ error: "Failed to rename file", message }, { status });
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,6 +9,9 @@ import { Input } from "@/components/ui/input";
|
|||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
|
import { MarkdownEditor } from "@/components/editor/markdown-editor";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
type FilesListResponse = {
|
type FilesListResponse = {
|
||||||
total: number;
|
total: number;
|
||||||
@ -76,6 +79,9 @@ export default function Home() {
|
|||||||
const [semantic, setSemantic] = React.useState(false);
|
const [semantic, setSemantic] = React.useState(false);
|
||||||
const searching = q.trim().length > 0;
|
const searching = q.trim().length > 0;
|
||||||
|
|
||||||
|
// Editor state
|
||||||
|
const [editPath, setEditPath] = React.useState<string | null>(null);
|
||||||
|
|
||||||
const filesQuery = useQuery({
|
const filesQuery = useQuery({
|
||||||
queryKey: ["files", path, page, perPage],
|
queryKey: ["files", path, page, perPage],
|
||||||
queryFn: () => fetchFiles(path, page, perPage),
|
queryFn: () => fetchFiles(path, page, perPage),
|
||||||
@ -125,6 +131,67 @@ export default function Home() {
|
|||||||
handleDownload(item);
|
handleDownload(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleEdit(item: FileRow) {
|
||||||
|
setEditPath(item.path);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url: string, body: unknown) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Request failed (${res.status})`);
|
||||||
|
}
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleRename(item: FileRow) {
|
||||||
|
const newName = window.prompt("New name (or full destination path):", item.name);
|
||||||
|
if (!newName) return;
|
||||||
|
const to =
|
||||||
|
newName.startsWith("/")
|
||||||
|
? newName
|
||||||
|
: `${item.parentPath ?? ""}/${newName}`.replace(/\/+/g, "/");
|
||||||
|
try {
|
||||||
|
await postJSON("/api/files/rename", { from: item.path, to });
|
||||||
|
toast.success("Renamed");
|
||||||
|
if (!searching) filesQuery.refetch();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCopy(item: FileRow) {
|
||||||
|
const newPath = window.prompt("Copy to path (absolute or name in same folder):", `${item.parentPath ?? ""}/${item.name}`);
|
||||||
|
if (!newPath) return;
|
||||||
|
const to =
|
||||||
|
newPath.startsWith("/")
|
||||||
|
? newPath
|
||||||
|
: `${item.parentPath ?? ""}/${newPath}`.replace(/\/+/g, "/");
|
||||||
|
try {
|
||||||
|
await postJSON("/api/files/copy", { from: item.path, to });
|
||||||
|
toast.success("Copied");
|
||||||
|
if (!searching) filesQuery.refetch();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(item: FileRow) {
|
||||||
|
const yes = window.confirm(`Delete "${item.name}"? This cannot be undone.`);
|
||||||
|
if (!yes) return;
|
||||||
|
try {
|
||||||
|
await postJSON("/api/files/delete", { path: item.path });
|
||||||
|
toast.success("Deleted");
|
||||||
|
if (!searching) filesQuery.refetch();
|
||||||
|
} catch (err) {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function handleUploaded() {
|
function handleUploaded() {
|
||||||
if (!searching) {
|
if (!searching) {
|
||||||
filesQuery.refetch();
|
filesQuery.refetch();
|
||||||
@ -181,9 +248,29 @@ export default function Home() {
|
|||||||
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
|
: `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
<FileTable items={files} onOpen={handleOpen} onDownload={handleDownload} />
|
<FileTable
|
||||||
|
items={files}
|
||||||
|
onOpen={handleOpen}
|
||||||
|
onDownload={handleDownload}
|
||||||
|
onEdit={handleEdit}
|
||||||
|
onRename={handleRename}
|
||||||
|
onCopy={handleCopy}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
{/* Editor dialog */}
|
||||||
|
<Dialog open={!!editPath} onOpenChange={(v) => !v && setEditPath(null)}>
|
||||||
|
<DialogContent className="max-w-4xl h-[80vh]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>Markdown Editor</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="h-[calc(80vh-60px)]">
|
||||||
|
{editPath ? <MarkdownEditor path={editPath} /> : null}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
103
src/components/editor/markdown-editor.tsx
Normal file
103
src/components/editor/markdown-editor.tsx
Normal file
@ -0,0 +1,103 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { useQuery, useMutation } from "@tanstack/react-query";
|
||||||
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
|
import { markdown } from "@codemirror/lang-markdown";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
path: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchContent(path: string) {
|
||||||
|
const url = new URL("/api/files/content", window.location.origin);
|
||||||
|
url.searchParams.set("path", path);
|
||||||
|
const res = await fetch(url.toString(), { cache: "no-store" });
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Failed to load content (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { ok: boolean; content?: string; mimeType?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveContent(path: string, content: string, mimeType?: string) {
|
||||||
|
const res = await fetch("/api/files/content", {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ path, content, mimeType: mimeType || "text/markdown" }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const payload = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(payload?.message || `Failed to save content (${res.status})`);
|
||||||
|
}
|
||||||
|
return (await res.json()) as { ok: boolean; etag?: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MarkdownEditor({ path }: Props) {
|
||||||
|
const [value, setValue] = React.useState("");
|
||||||
|
const [dirty, setDirty] = React.useState(false);
|
||||||
|
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: ["file-content", path],
|
||||||
|
queryFn: () => fetchContent(path),
|
||||||
|
refetchOnWindowFocus: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (query.data?.content != null) {
|
||||||
|
setValue(query.data.content);
|
||||||
|
setDirty(false);
|
||||||
|
}
|
||||||
|
}, [query.data?.content]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (payload: { content: string; mimeType?: string }) =>
|
||||||
|
saveContent(path, payload.content, payload.mimeType),
|
||||||
|
onSuccess: () => {
|
||||||
|
toast.success("Saved");
|
||||||
|
setDirty(false);
|
||||||
|
},
|
||||||
|
onError: (err: unknown) => {
|
||||||
|
toast.error(err instanceof Error ? err.message : String(err));
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between gap-2 border-b px-3 py-2">
|
||||||
|
<div className="text-sm text-muted-foreground truncate">Editing: <code>{path}</code></div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => query.refetch()}
|
||||||
|
disabled={query.isFetching}
|
||||||
|
>
|
||||||
|
{query.isFetching ? "Refreshing..." : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => mutation.mutate({ content: value, mimeType: query.data?.mimeType || "text/markdown" })}
|
||||||
|
disabled={mutation.isPending || !dirty}
|
||||||
|
>
|
||||||
|
{mutation.isPending ? "Saving..." : "Save"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-auto">
|
||||||
|
<CodeMirror
|
||||||
|
value={value}
|
||||||
|
height="calc(100vh - 140px)"
|
||||||
|
extensions={[markdown()]}
|
||||||
|
onChange={(v) => {
|
||||||
|
setValue(v);
|
||||||
|
setDirty(true);
|
||||||
|
}}
|
||||||
|
theme="dark"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
src/components/files/file-row-actions.tsx
Normal file
61
src/components/files/file-row-actions.tsx
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuTrigger,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { MoreHorizontal, Pencil, Copy, Trash2, Download, ScanText, MoveRight } from "lucide-react";
|
||||||
|
|
||||||
|
export type FileRowActionHandlers = {
|
||||||
|
onEdit?: () => void;
|
||||||
|
onDownload?: () => void;
|
||||||
|
onRename?: () => void;
|
||||||
|
onCopy?: () => void;
|
||||||
|
onDelete?: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function FileRowActions({
|
||||||
|
onEdit,
|
||||||
|
onDownload,
|
||||||
|
onRename,
|
||||||
|
onCopy,
|
||||||
|
onDelete,
|
||||||
|
}: FileRowActionHandlers) {
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="sm" aria-label="Actions">
|
||||||
|
<MoreHorizontal className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end" className="w-40">
|
||||||
|
<DropdownMenuItem onClick={onEdit}>
|
||||||
|
<Pencil className="mr-2 h-4 w-4" />
|
||||||
|
Edit
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onDownload}>
|
||||||
|
<Download className="mr-2 h-4 w-4" />
|
||||||
|
Download
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem onClick={onRename}>
|
||||||
|
<MoveRight className="mr-2 h-4 w-4" />
|
||||||
|
Rename / Move
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onCopy}>
|
||||||
|
<Copy className="mr-2 h-4 w-4" />
|
||||||
|
Copy
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={onDelete} className="text-destructive">
|
||||||
|
<Trash2 className="mr-2 h-4 w-4" />
|
||||||
|
Delete
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -4,6 +4,7 @@ import * as React from "react";
|
|||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
|
import { FileRowActions } from "@/components/files/file-row-actions";
|
||||||
|
|
||||||
export interface FileRow {
|
export interface FileRow {
|
||||||
id: string;
|
id: string;
|
||||||
@ -28,10 +29,18 @@ export function FileTable({
|
|||||||
items,
|
items,
|
||||||
onOpen,
|
onOpen,
|
||||||
onDownload,
|
onDownload,
|
||||||
|
onEdit,
|
||||||
|
onRename,
|
||||||
|
onCopy,
|
||||||
|
onDelete,
|
||||||
}: {
|
}: {
|
||||||
items: FileRow[];
|
items: FileRow[];
|
||||||
onOpen?: (item: FileRow) => void;
|
onOpen?: (item: FileRow) => void;
|
||||||
onDownload?: (item: FileRow) => void;
|
onDownload?: (item: FileRow) => void;
|
||||||
|
onEdit?: (item: FileRow) => void;
|
||||||
|
onRename?: (item: FileRow) => void;
|
||||||
|
onCopy?: (item: FileRow) => void;
|
||||||
|
onDelete?: (item: FileRow) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-md border">
|
<div className="rounded-md border">
|
||||||
@ -59,6 +68,7 @@ export function FileTable({
|
|||||||
<TableCell className="hidden sm:table-cell">{it.mimeType}</TableCell>
|
<TableCell className="hidden sm:table-cell">{it.mimeType}</TableCell>
|
||||||
<TableCell className="hidden sm:table-cell text-right">{formatBytes(it.sizeBytes)}</TableCell>
|
<TableCell className="hidden sm:table-cell text-right">{formatBytes(it.sizeBytes)}</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@ -67,6 +77,14 @@ export function FileTable({
|
|||||||
>
|
>
|
||||||
<Download className="h-4 w-4" />
|
<Download className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
<FileRowActions
|
||||||
|
onEdit={() => onEdit?.(it)}
|
||||||
|
onDownload={() => onDownload?.(it)}
|
||||||
|
onRename={() => onRename?.(it)}
|
||||||
|
onCopy={() => onCopy?.(it)}
|
||||||
|
onDelete={() => onDelete?.(it)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -67,6 +67,10 @@ const EnvSchema = z.object({
|
|||||||
OPENAI_EMBEDDING_MODEL: optionalString,
|
OPENAI_EMBEDDING_MODEL: optionalString,
|
||||||
EMBEDDING_DIM: numberFromEnv, // e.g., 1536
|
EMBEDDING_DIM: numberFromEnv, // e.g., 1536
|
||||||
|
|
||||||
|
// Qdrant (optional)
|
||||||
|
QDRANT_URL: optionalUrl,
|
||||||
|
QDRANT_API_KEY: optionalString,
|
||||||
|
|
||||||
// Sentry (optional)
|
// Sentry (optional)
|
||||||
SENTRY_DSN: optionalUrl,
|
SENTRY_DSN: optionalUrl,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -178,6 +178,88 @@ export class NextcloudClient {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async moveFile(from: string, to: string): Promise<{ ok: boolean; from: string; to: string }> {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "http.client", name: "WebDAV MOVE moveFile" },
|
||||||
|
async (span) => {
|
||||||
|
const src = this.resolve(from);
|
||||||
|
const dst = this.resolve(to);
|
||||||
|
span.setAttribute("path_from", src);
|
||||||
|
span.setAttribute("path_to", dst);
|
||||||
|
const client = getClient();
|
||||||
|
await client.moveFile(src, dst, { overwrite: true });
|
||||||
|
return { ok: true, from: src, to: dst };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async copyFile(from: string, to: string): Promise<{ ok: boolean; from: string; to: string }> {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "http.client", name: "WebDAV COPY copyFile" },
|
||||||
|
async (span) => {
|
||||||
|
const src = this.resolve(from);
|
||||||
|
const dst = this.resolve(to);
|
||||||
|
span.setAttribute("path_from", src);
|
||||||
|
span.setAttribute("path_to", dst);
|
||||||
|
const client = getClient();
|
||||||
|
await client.copyFile(src, dst, { overwrite: true });
|
||||||
|
return { ok: true, from: src, to: dst };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deletePath(path: string): Promise<{ ok: boolean }> {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "http.client", name: "WebDAV DELETE deletePath" },
|
||||||
|
async (span) => {
|
||||||
|
const target = this.resolve(path);
|
||||||
|
span.setAttribute("path", target);
|
||||||
|
const client = getClient();
|
||||||
|
try {
|
||||||
|
// Determine if directory or file for the correct delete call
|
||||||
|
const s = (await client.stat(target)) as FileStat;
|
||||||
|
if (s.type === "directory") {
|
||||||
|
await client.deleteFile(target); // webdav client uses deleteFile for both in many versions
|
||||||
|
} else {
|
||||||
|
await client.deleteFile(target);
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
} catch (err) {
|
||||||
|
Sentry.captureException(err);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async readText(path: string): Promise<{ ok: boolean; content?: string; mimeType?: string }> {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "http.client", name: "WebDAV GET readText" },
|
||||||
|
async (span) => {
|
||||||
|
const target = this.resolve(path);
|
||||||
|
span.setAttribute("path", target);
|
||||||
|
const client = getClient();
|
||||||
|
const text = (await client.getFileContents(target, { format: "text" })) as unknown as string;
|
||||||
|
const s = await this.stat(target);
|
||||||
|
return { ok: true, content: text, mimeType: s?.contentType || "text/plain" };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async writeText(path: string, content: string, mimeType?: string): Promise<{ ok: boolean; etag?: string }> {
|
||||||
|
return Sentry.startSpan(
|
||||||
|
{ op: "http.client", name: "WebDAV PUT writeText" },
|
||||||
|
async (span) => {
|
||||||
|
const target = this.resolve(path);
|
||||||
|
span.setAttribute("path", target);
|
||||||
|
if (mimeType) span.setAttribute("contentType", mimeType);
|
||||||
|
const client = getClient();
|
||||||
|
await client.putFileContents(target, content, { overwrite: true });
|
||||||
|
return { ok: true };
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const nextcloud = new NextcloudClient();
|
export const nextcloud = new NextcloudClient();
|
||||||
|
|||||||
@ -47,3 +47,41 @@ export interface WebDavEntry {
|
|||||||
contentType?: string;
|
contentType?: string;
|
||||||
props?: Record<string, unknown>;
|
props?: Record<string, unknown>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Additional payloads for CRUD and tagging flows
|
||||||
|
*/
|
||||||
|
export interface RenamePayload {
|
||||||
|
from: string; // PathId
|
||||||
|
to: string; // PathId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CopyPayload {
|
||||||
|
from: string; // PathId
|
||||||
|
to: string; // PathId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeletePayload {
|
||||||
|
path: string; // PathId
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileContent {
|
||||||
|
path: string; // PathId
|
||||||
|
content: string; // UTF-8 text
|
||||||
|
mimeType?: string; // default text/markdown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagUpdatePayload {
|
||||||
|
path: string; // PathId
|
||||||
|
tags: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TagHistoryEvent {
|
||||||
|
id: string; // uuid
|
||||||
|
path: string; // PathId at time of change
|
||||||
|
actor?: string; // principal (NEXTCLOUD_USERNAME)
|
||||||
|
action: "tags.update";
|
||||||
|
from?: string[]; // previous tags
|
||||||
|
to: string[]; // new tags
|
||||||
|
at: string; // ISO timestamp
|
||||||
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user