diff --git a/package-lock.json b/package-lock.json index 51710d3..085b728 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "nextcloud-explorer", "version": "0.1.0", "dependencies": { + "@codemirror/lang-markdown": "^6.3.4", "@elastic/elasticsearch": "^9.1.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -17,6 +18,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^10.11.0", "@tanstack/react-query": "^5.87.4", + "@uiw/react-codemirror": "^4.25.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", @@ -257,6 +259,15 @@ "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": { "version": "7.27.2", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", @@ -330,6 +341,159 @@ "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": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/@elastic/elasticsearch/-/elasticsearch-9.1.1.tgz", @@ -1556,6 +1720,79 @@ "@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": { "version": "0.2.12", "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" } }, + "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": { "version": "1.11.1", "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_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": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz", @@ -5941,6 +6246,12 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "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": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -10333,6 +10644,12 @@ ], "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": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -10944,6 +11261,12 @@ "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": { "version": "2.4.4", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz", diff --git a/package.json b/package.json index eaea7f4..bf31ed7 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "create:index": "tsx -r dotenv/config -r tsconfig-paths/register scripts/create-index.ts" }, "dependencies": { + "@codemirror/lang-markdown": "^6.3.4", "@elastic/elasticsearch": "^9.1.1", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -19,6 +20,7 @@ "@radix-ui/react-tooltip": "^1.2.8", "@sentry/nextjs": "^10.11.0", "@tanstack/react-query": "^5.87.4", + "@uiw/react-codemirror": "^4.25.1", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "date-fns": "^4.1.0", diff --git a/src/app/api/files/content/route.ts b/src/app/api/files/content/route.ts new file mode 100644 index 0000000..4912c48 --- /dev/null +++ b/src/app/api/files/content/route.ts @@ -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(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 }); + } +} diff --git a/src/app/api/files/copy/route.ts b/src/app/api/files/copy/route.ts new file mode 100644 index 0000000..84e07bf --- /dev/null +++ b/src/app/api/files/copy/route.ts @@ -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(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 }); + } +} diff --git a/src/app/api/files/delete/route.ts b/src/app/api/files/delete/route.ts new file mode 100644 index 0000000..b7b9612 --- /dev/null +++ b/src/app/api/files/delete/route.ts @@ -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(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 }); + } +} diff --git a/src/app/api/files/rename/route.ts b/src/app/api/files/rename/route.ts new file mode 100644 index 0000000..063d4e1 --- /dev/null +++ b/src/app/api/files/rename/route.ts @@ -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(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 }); + } +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f993b1e..e018301 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,6 +9,9 @@ import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { useQuery } from "@tanstack/react-query"; 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 = { total: number; @@ -76,6 +79,9 @@ export default function Home() { const [semantic, setSemantic] = React.useState(false); const searching = q.trim().length > 0; + // Editor state + const [editPath, setEditPath] = React.useState(null); + const filesQuery = useQuery({ queryKey: ["files", path, page, perPage], queryFn: () => fetchFiles(path, page, perPage), @@ -125,6 +131,67 @@ export default function Home() { 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() { if (!searching) { filesQuery.refetch(); @@ -181,9 +248,29 @@ export default function Home() { : `Found ${searchQuery.data?.total ?? 0} in ${searchQuery.data?.tookMs ?? 0}ms`} ) : null} - + + + {/* Editor dialog */} + !v && setEditPath(null)}> + + + Markdown Editor + +
+ {editPath ? : null} +
+
+
); } diff --git a/src/components/editor/markdown-editor.tsx b/src/components/editor/markdown-editor.tsx new file mode 100644 index 0000000..599a139 --- /dev/null +++ b/src/components/editor/markdown-editor.tsx @@ -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 ( +
+
+
Editing: {path}
+
+ + +
+
+
+ { + setValue(v); + setDirty(true); + }} + theme="dark" + /> +
+
+ ); +} diff --git a/src/components/files/file-row-actions.tsx b/src/components/files/file-row-actions.tsx new file mode 100644 index 0000000..8cef402 --- /dev/null +++ b/src/components/files/file-row-actions.tsx @@ -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 ( + + + + + + + + Edit + + + + Download + + + + + Rename / Move + + + + Copy + + + + Delete + + + + ); +} diff --git a/src/components/files/file-table.tsx b/src/components/files/file-table.tsx index 3deb013..4126186 100644 --- a/src/components/files/file-table.tsx +++ b/src/components/files/file-table.tsx @@ -4,6 +4,7 @@ import * as React from "react"; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { Button } from "@/components/ui/button"; import { Download } from "lucide-react"; +import { FileRowActions } from "@/components/files/file-row-actions"; export interface FileRow { id: string; @@ -28,10 +29,18 @@ export function FileTable({ items, onOpen, onDownload, + onEdit, + onRename, + onCopy, + onDelete, }: { items: FileRow[]; onOpen?: (item: FileRow) => void; onDownload?: (item: FileRow) => void; + onEdit?: (item: FileRow) => void; + onRename?: (item: FileRow) => void; + onCopy?: (item: FileRow) => void; + onDelete?: (item: FileRow) => void; }) { return (
@@ -59,14 +68,23 @@ export function FileTable({ {it.mimeType} {formatBytes(it.sizeBytes)} - +
+ + onEdit?.(it)} + onDownload={() => onDownload?.(it)} + onRename={() => onRename?.(it)} + onCopy={() => onCopy?.(it)} + onDelete={() => onDelete?.(it)} + /> +
))} diff --git a/src/lib/env.ts b/src/lib/env.ts index 7256dfe..a8b87de 100644 --- a/src/lib/env.ts +++ b/src/lib/env.ts @@ -67,6 +67,10 @@ const EnvSchema = z.object({ OPENAI_EMBEDDING_MODEL: optionalString, EMBEDDING_DIM: numberFromEnv, // e.g., 1536 + // Qdrant (optional) + QDRANT_URL: optionalUrl, + QDRANT_API_KEY: optionalString, + // Sentry (optional) SENTRY_DSN: optionalUrl, }); diff --git a/src/lib/webdav.ts b/src/lib/webdav.ts index bdf65a2..a4cfa6e 100644 --- a/src/lib/webdav.ts +++ b/src/lib/webdav.ts @@ -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(); diff --git a/src/types/files.ts b/src/types/files.ts index ed99feb..8568f81 100644 --- a/src/types/files.ts +++ b/src/types/files.ts @@ -47,3 +47,41 @@ export interface WebDavEntry { contentType?: string; props?: Record; } + +/** + * 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 +}