diff options
31 files changed, 3539 insertions, 16 deletions
diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 230ed07..4c4bcbc 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,8 +8,17 @@ "name": "pc98-vn", "version": "0.0.1", "dependencies": { + "@lexical/html": "^0.44.0", + "@lexical/link": "^0.44.0", + "@lexical/list": "^0.44.0", + "@lexical/markdown": "^0.44.0", + "@lexical/react": "^0.44.0", + "@lexical/rich-text": "^0.44.0", + "@lexical/selection": "^0.44.0", + "@lexical/utils": "^0.44.0", "@nanostores/react": "^1.0.0", "@types/three": "^0.180.0", + "lexical": "^0.44.0", "nanostores": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", @@ -66,7 +75,6 @@ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.3.tgz", "integrity": "sha512-yDBHV9kQNcr2/sUr9jghVyz9C3Y5G2zUM2H2lo+9mKv4sFgbA8s8Z9t8D1jiTkGoO/NoIfKMyKWr4s6CN23ZwQ==", "dev": true, - "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", @@ -675,6 +683,54 @@ "node": ">=12" } }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react": { + "version": "0.27.19", + "resolved": "https://registry.npmjs.org/@floating-ui/react/-/react-0.27.19.tgz", + "integrity": "sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog==", + "dependencies": { + "@floating-ui/react-dom": "^2.1.8", + "@floating-ui/utils": "^0.2.11", + "tabbable": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==" + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -710,6 +766,255 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lexical/clipboard": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/clipboard/-/clipboard-0.44.0.tgz", + "integrity": "sha512-nfmNIs7uENqlDI7cm2E4I1Yp8mDJGMhEQIrIV2rNWnL1oeHVXQ7yuYdyoPdcY1zuj/9nvkYBQYUEh0QiGwpETA==", + "dependencies": { + "@lexical/extension": "0.44.0", + "@lexical/html": "0.44.0", + "@lexical/list": "0.44.0", + "@lexical/selection": "0.44.0", + "@lexical/utils": "0.44.0", + "@types/trusted-types": "^2.0.7", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/code-core": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/code-core/-/code-core-0.44.0.tgz", + "integrity": "sha512-m57JyXTIvW1tsqw/Vuogk8jqWCZZIeFQbWybRc46ytR8ReDgzPRODpN8+dacIIeRH5yC5UC3lAa743mtdNkxqg==", + "dependencies": { + "@lexical/extension": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/devtools-core": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/devtools-core/-/devtools-core-0.44.0.tgz", + "integrity": "sha512-X3uNG3P1vOsdzmEcy+7m9DxAcIVtVUZnvskmLqqLs6VluVVwH9xy7h1bPsvlDKvj1Nj73tWJ3TW0qXQWDTo5tw==", + "dependencies": { + "@lexical/html": "0.44.0", + "@lexical/link": "0.44.0", + "@lexical/mark": "0.44.0", + "@lexical/table": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x" + } + }, + "node_modules/@lexical/dragon": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/dragon/-/dragon-0.44.0.tgz", + "integrity": "sha512-RhlsjVDket9k1+YFEkDE0/7Qyrh2BI0vxBMzrWwPJTXX/4YFanYN9su8RSabkIukBBJ3QiNOOoC8FKK4Lkr4qg==", + "dependencies": { + "@lexical/extension": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/extension": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/extension/-/extension-0.44.0.tgz", + "integrity": "sha512-BsYtoc+0EU0pqcOpf/lIUDU6LQVO6zX2AawZoUWJzT3Wzfov23qsqZWvl2WGM9dnRTN5iISJL3Fl53bQVxiXxw==", + "dependencies": { + "@lexical/utils": "0.44.0", + "@preact/signals-core": "^1.14.1", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/hashtag": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/hashtag/-/hashtag-0.44.0.tgz", + "integrity": "sha512-0WATahDSqYKVTudQv3KpFbLeCpmrCpRptPFbjxOMckAX2MRpYlrExlqKfgfpri5BSQPtG49EPSGeNfSx/Faavw==", + "dependencies": { + "@lexical/text": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/history": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/history/-/history-0.44.0.tgz", + "integrity": "sha512-RGXcbFTgYL1GIWaReBI26mNSsJTfiA9EAtDY4LBeZ14NrIQhYNokKgNiOxq5Bn8xXrl2+mawQEqoMfgpWp/5YA==", + "dependencies": { + "@lexical/extension": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/html": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/html/-/html-0.44.0.tgz", + "integrity": "sha512-5X6eGsgwtqPxABsuShUxF7ZfyB/U4GwSEyeonvwH1Vc/5Q2uQVjlB+FAYd+MNwWMHMh4d4+yZ3l70AtIuhr5eg==", + "dependencies": { + "@lexical/extension": "0.44.0", + "@lexical/selection": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/link": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/link/-/link-0.44.0.tgz", + "integrity": "sha512-uvEqEol/mLEzGVQd8Rok9I48RgYPKokM/nsclI9nYcEdccVOM2Nri4ntoRwodhbccFLtjMPl8OBldwXbfc77tQ==", + "dependencies": { + "@lexical/extension": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/list": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/list/-/list-0.44.0.tgz", + "integrity": "sha512-ZTCWxDz1okPrC9FBXi1yV3W5fbQQeMUlFIcSVF9HibcVPmCsPa900IxthuiQbGiTycUyXDTOB3IUYRtlJNtpjw==", + "dependencies": { + "@lexical/extension": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/mark": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/mark/-/mark-0.44.0.tgz", + "integrity": "sha512-bWMowllwe6BcgYMAkrsZx6Z+CX/72qCQpFKhlkR4ael92yOWSBkz68xp1wxxkSnQX9zoI1gYTeWBofVsSDKcsQ==", + "dependencies": { + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/markdown": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/markdown/-/markdown-0.44.0.tgz", + "integrity": "sha512-DwlXdp85pYMo3exDF6W3iz8plpuP+RQ4Me4Iljm7O5aPDp0SSrIoZxyX4zS668mVAoz5HHj1Ka0kQkft8mq26Q==", + "dependencies": { + "@lexical/code-core": "0.44.0", + "@lexical/link": "0.44.0", + "@lexical/list": "0.44.0", + "@lexical/rich-text": "0.44.0", + "@lexical/text": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/overflow": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/overflow/-/overflow-0.44.0.tgz", + "integrity": "sha512-5GYaYjSxn27pqHRfU+tQ2STF10wgJvI+MUnwTnUFSzy3dko1b+oV94K/Yx0TuEewPbwDibfoFA8CwqUvOLHAyw==", + "dependencies": { + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/plain-text": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/plain-text/-/plain-text-0.44.0.tgz", + "integrity": "sha512-bIV4Lljk0x70zFhkZIwzSPK5q3m9FpDisjGm2/3Q/chb+5BW3Tv8QJmqnpCiSO6S2KXO7gfSy81ZfkQ1dcd4EQ==", + "dependencies": { + "@lexical/clipboard": "0.44.0", + "@lexical/dragon": "0.44.0", + "@lexical/selection": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/react": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/react/-/react-0.44.0.tgz", + "integrity": "sha512-p/NQd/fMh3pXb1XqegE2ruvWDcUmfB12OidQ9nwtMtj5VfcUjQu2I+trUhgGRIADxSYxMWmw+8PPj5YSf4m5oA==", + "dependencies": { + "@floating-ui/react": "^0.27.16", + "@lexical/devtools-core": "0.44.0", + "@lexical/dragon": "0.44.0", + "@lexical/extension": "0.44.0", + "@lexical/hashtag": "0.44.0", + "@lexical/history": "0.44.0", + "@lexical/link": "0.44.0", + "@lexical/list": "0.44.0", + "@lexical/mark": "0.44.0", + "@lexical/markdown": "0.44.0", + "@lexical/overflow": "0.44.0", + "@lexical/plain-text": "0.44.0", + "@lexical/rich-text": "0.44.0", + "@lexical/table": "0.44.0", + "@lexical/text": "0.44.0", + "@lexical/utils": "0.44.0", + "@lexical/yjs": "0.44.0", + "lexical": "0.44.0", + "react-error-boundary": "^6.0.0" + }, + "peerDependencies": { + "react": ">=17.x", + "react-dom": ">=17.x", + "yjs": ">=13.5.22" + }, + "peerDependenciesMeta": { + "yjs": { + "optional": true + } + } + }, + "node_modules/@lexical/rich-text": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/rich-text/-/rich-text-0.44.0.tgz", + "integrity": "sha512-IIdrutK5GY47ITjPlZB7KzUi9dBDwygsyFOwolnrYSL7m6TtGhAqrYiFg/YNOTT/nBzK3KQeCJRbnxpjJAVZtQ==", + "dependencies": { + "@lexical/clipboard": "0.44.0", + "@lexical/dragon": "0.44.0", + "@lexical/selection": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/selection": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/selection/-/selection-0.44.0.tgz", + "integrity": "sha512-AEyeZJFFr5YRLeqVR+X0QAW19c4Fk4MFAQu52z2gxAyDGTj9xwVJxjfepVpfUp4P9K+sPtJ/yaqfMXH506ksSQ==", + "dependencies": { + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/table": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/table/-/table-0.44.0.tgz", + "integrity": "sha512-5Uq0O/fBCxcZp9y17fXUONY7dU9lVo/mB5JHy23laIiKzBKP5IzzTLMU9ikZTppIXbMNxYXd+R2pmy7PYTLyvw==", + "dependencies": { + "@lexical/clipboard": "0.44.0", + "@lexical/extension": "0.44.0", + "@lexical/utils": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/text": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/text/-/text-0.44.0.tgz", + "integrity": "sha512-1XJD8ZbwaXljTl8k4+jjiopdhnYZm26IJw9Gv8+cIThVC0b6B3JZ/WxH97BMDcSloKvWHFkGiPztxRwNwA29Rw==", + "dependencies": { + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/utils": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/utils/-/utils-0.44.0.tgz", + "integrity": "sha512-/D2ptztNevfBJgtkj4uaiYBeRcvSy+1mQj6pNYaCFZIoPJIwl6H5fXwWAvpvr11vcQKP9DEEoXR+V4qkMOA+EA==", + "dependencies": { + "@lexical/selection": "0.44.0", + "lexical": "0.44.0" + } + }, + "node_modules/@lexical/yjs": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/@lexical/yjs/-/yjs-0.44.0.tgz", + "integrity": "sha512-b3QTub9J/3LuwSSdooynb6GbMHBRyBT4xUbXzXqNPbDHgYe6CDrqf/uJIHRihIjAhOnPaHYqo9XUzitl++N1DQ==", + "dependencies": { + "@lexical/selection": "0.44.0", + "lexical": "0.44.0" + }, + "peerDependencies": { + "yjs": ">=13.5.22" + } + }, "node_modules/@nanostores/react": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/@nanostores/react/-/react-1.0.0.tgz", @@ -728,6 +1033,15 @@ "react": ">=18.0.0" } }, + "node_modules/@preact/signals-core": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@preact/signals-core/-/signals-core-1.14.1.tgz", + "integrity": "sha512-vxPpfXqrwUe9lpjqfYNjAF/0RF/eFGeLgdJzdmIIZjpOnTmGmAB4BjWone562mJGMRP4frU6iZ6ei3PDsu52Ng==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/@remix-run/router": { "version": "1.23.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", @@ -1060,7 +1374,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.1.tgz", "integrity": "sha512-3vXmQDXy+woz+gnrTvuvNrPzekOi+Ds0ReMxw0LzBiK3a+1k0kQn9f2NWk+lgD4rJehFUmYy2gMhJ2ZI+7YP9g==", "dev": true, - "peer": true, "dependencies": { "undici-types": "~7.10.0" } @@ -1076,7 +1389,6 @@ "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.24.tgz", "integrity": "sha512-0dLEBsA1kI3OezMBF8nSsb7Nk19ZnsyE1LLhB8r27KbgU5H4pvuqZLdtE+aUkJVoXgTVuA+iLIwmZ0TuK4tx6A==", "dev": true, - "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -1110,6 +1422,11 @@ "meshoptimizer": "~0.22.0" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==" + }, "node_modules/@types/webxr": { "version": "0.5.23", "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.23.tgz", @@ -1159,7 +1476,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001737", "electron-to-chromium": "^1.5.211", @@ -1303,6 +1619,16 @@ "node": ">=6.9.0" } }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "peer": true, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/js-tokens": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", @@ -1332,6 +1658,32 @@ "node": ">=6" } }, + "node_modules/lexical": { + "version": "0.44.0", + "resolved": "https://registry.npmjs.org/lexical/-/lexical-0.44.0.tgz", + "integrity": "sha512-ReDUjRlFgkGoPWzvdjr7s16PUVpHATN+2NH2NiZs+PLlISTaIFFgKil2P467oP3Vg+XgmpDsUgmWZsFJTztYjg==" + }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "peer": true, + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/loose-envify": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", @@ -1391,7 +1743,6 @@ "url": "https://github.com/sponsors/ai" } ], - "peer": true, "engines": { "node": "^20.0.0 || >=22.0.0" } @@ -1440,7 +1791,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -1452,7 +1802,6 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -1461,6 +1810,14 @@ "react": "^18.3.1" } }, + "node_modules/react-error-boundary": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.1.tgz", + "integrity": "sha512-BrYwPOdXi5mqkk5lw+Uvt0ThHx32rCt3BkukS4X23A2AIWDPSGX6iaWTc0y9TU/mHDA/6qOSGel+B2ERkOvD1w==", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -1567,6 +1924,11 @@ "node": ">=0.10.0" } }, + "node_modules/tabbable": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/tabbable/-/tabbable-6.4.0.tgz", + "integrity": "sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg==" + }, "node_modules/three": { "version": "0.180.0", "resolved": "https://registry.npmjs.org/three/-/three-0.180.0.tgz", @@ -1626,7 +1988,6 @@ "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, - "peer": true, "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", @@ -1686,6 +2047,23 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true + }, + "node_modules/yjs": { + "version": "13.6.30", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.30.tgz", + "integrity": "sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==", + "peer": true, + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 197c3d8..230a982 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -9,8 +9,17 @@ "preview": "vite preview" }, "dependencies": { + "@lexical/html": "^0.44.0", + "@lexical/link": "^0.44.0", + "@lexical/list": "^0.44.0", + "@lexical/markdown": "^0.44.0", + "@lexical/react": "^0.44.0", + "@lexical/rich-text": "^0.44.0", + "@lexical/selection": "^0.44.0", + "@lexical/utils": "^0.44.0", "@nanostores/react": "^1.0.0", "@types/three": "^0.180.0", + "lexical": "^0.44.0", "nanostores": "^1.0.1", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/frontend/src/components/ConfigModal.tsx b/frontend/src/components/ConfigModal.tsx index e7b1f9f..9746e4e 100644 --- a/frontend/src/components/ConfigModal.tsx +++ b/frontend/src/components/ConfigModal.tsx @@ -1,4 +1,7 @@ import React from 'react' +import { useStore } from '@nanostores/react' +import { Link } from 'react-router-dom' +import { documentUiEnabledStore, setDocumentUiEnabled } from '../stores' type Props = { isOpen: boolean @@ -8,6 +11,8 @@ type Props = { } export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSkipIntroChange }) => { + const documentUiEnabled = useStore(documentUiEnabledStore) + if (!isOpen) return null return ( @@ -15,9 +20,9 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki <div className="config-modal" onClick={e => e.stopPropagation()}> <div className="modal-header"> <h2>Configuration</h2> - <button className="close-btn" onClick={onClose}>×</button> + <button className="close-btn" onClick={onClose}>{'\u00D7'}</button> </div> - + <div className="modal-content"> <div className="config-option"> <label className="config-label"> @@ -33,8 +38,32 @@ export const ConfigModal: React.FC<Props> = ({ isOpen, onClose, skipIntro, onSki Skip the loading screen animation on startup </div> </div> + + <div className="config-option" style={{ marginTop: '16px' }}> + <label className="config-label"> + <input + type="checkbox" + checked={documentUiEnabled} + onChange={e => setDocumentUiEnabled(e.target.checked)} + className="config-checkbox" + /> + <span className="config-text">Enable Document UI (Experimental)</span> + </label> + <div className="config-description"> + Replace the directive management interface with an interactive document editor. This is a proof of concept. + </div> + {documentUiEnabled && ( + <Link + to="/directives" + style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }} + onClick={onClose} + > + Open Directives Editor {'\u2192'} + </Link> + )} + </div> </div> - + <div className="modal-footer"> <button className="modal-btn" onClick={onClose}>Close</button> </div> diff --git a/frontend/src/components/VNInterface.tsx b/frontend/src/components/VNInterface.tsx index 318a9b9..051c210 100644 --- a/frontend/src/components/VNInterface.tsx +++ b/frontend/src/components/VNInterface.tsx @@ -9,9 +9,11 @@ import { showSettingsModalStore, isVisibleStore, yenBalanceStore, + documentUiEnabledStore, toggleStandby, toggleShowChoices, - updateTime + updateTime, + setDocumentUiEnabled, } from '../stores' interface VNInterfaceProps { @@ -26,6 +28,7 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { const showSettingsModal = useStore(showSettingsModalStore) const isVisible = useStore(isVisibleStore) const yenBalance = useStore(yenBalanceStore) + const documentUiEnabled = useStore(documentUiEnabledStore) // Fade in effect on mount useEffect(() => { @@ -113,6 +116,14 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { <span className="info-value">Contracts</span> </Link> </div> + {documentUiEnabled && ( + <div className="status-item"> + <Link to="/directives" style={{ color: '#ff66cc', textDecoration: 'none', display: 'flex', alignItems: 'center', gap: '4px' }}> + <span className="info-label">Docs:</span> + <span className="info-value">Directives</span> + </Link> + </div> + )} </div> </div> </div> @@ -198,6 +209,31 @@ export function VNInterface({ onLogout }: VNInterfaceProps) { </div> </div> <div className="settings-section"> + <h3>Experimental</h3> + <div className="setting-item"> + <label style={{ display: 'flex', alignItems: 'center', gap: '8px', cursor: 'pointer' }}> + <input + type="checkbox" + checked={documentUiEnabled} + onChange={(e) => setDocumentUiEnabled(e.target.checked)} + /> + Document UI (Experimental) + </label> + <p style={{ fontSize: '0.8em', color: '#9ca3af', marginTop: '4px' }}> + Replace the directive management interface with an interactive document editor. This is a proof of concept. + </p> + {documentUiEnabled && ( + <Link + to="/directives" + style={{ display: 'inline-block', marginTop: '8px', color: '#ff66cc', fontSize: '0.9em' }} + onClick={() => showSettingsModalStore.set(false)} + > + Open Directives Editor {'\u2192'} + </Link> + )} + </div> + </div> + <div className="settings-section"> <h3>Audio</h3> <div className="setting-item"> <label>Master Volume</label> diff --git a/frontend/src/components/document/AutoSavePlugin.tsx b/frontend/src/components/document/AutoSavePlugin.tsx new file mode 100644 index 0000000..d3d0eb5 --- /dev/null +++ b/frontend/src/components/document/AutoSavePlugin.tsx @@ -0,0 +1,140 @@ +import { useEffect, useRef, useState, useCallback } from 'react'; +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { UNDO_COMMAND } from 'lexical'; + +const COUNTDOWN_DURATION_MS = 3000; +const TICK_INTERVAL_MS = 50; + +interface AutoSavePluginProps { + onAutoSave: (content: string) => void; + getContent: () => string; + enabled?: boolean; +} + +export default function AutoSavePlugin({ + onAutoSave, + getContent, + enabled = true, +}: AutoSavePluginProps) { + const [editor] = useLexicalComposerContext(); + const [countdown, setCountdown] = useState<number | null>(null); + const timerRef = useRef<ReturnType<typeof setInterval> | null>(null); + const startTimeRef = useRef<number>(0); + const pendingContentRef = useRef<string>(''); + const lastSavedContentRef = useRef<string>(''); + + const clearTimer = useCallback(() => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + timerRef.current = null; + } + setCountdown(null); + }, []); + + const cancelCountdown = useCallback(() => { + clearTimer(); + }, [clearTimer]); + + const startCountdown = useCallback( + (content: string) => { + pendingContentRef.current = content; + clearTimer(); + + startTimeRef.current = Date.now(); + setCountdown(COUNTDOWN_DURATION_MS); + + timerRef.current = setInterval(() => { + const elapsed = Date.now() - startTimeRef.current; + const remaining = COUNTDOWN_DURATION_MS - elapsed; + + if (remaining <= 0) { + clearTimer(); + lastSavedContentRef.current = pendingContentRef.current; + onAutoSave(pendingContentRef.current); + } else { + setCountdown(remaining); + } + }, TICK_INTERVAL_MS); + }, + [clearTimer, onAutoSave] + ); + + // Listen for editor updates (content changes) + useEffect(() => { + if (!enabled) return; + + const unregister = editor.registerUpdateListener(({ editorState, dirtyElements, dirtyLeaves }) => { + // Only trigger on actual content changes + if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return; + + const content = getContent(); + if (content !== lastSavedContentRef.current) { + startCountdown(content); + } + }); + + return unregister; + }, [editor, enabled, getContent, startCountdown]); + + // Listen for undo command to cancel countdown + useEffect(() => { + const unregister = editor.registerCommand( + UNDO_COMMAND, + () => { + cancelCountdown(); + return false; // Don't prevent the undo from executing + }, + 1 // COMMAND_PRIORITY_LOW + ); + + return unregister; + }, [editor, cancelCountdown]); + + // Listen for Escape key to cancel countdown + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape' && countdown !== null) { + e.preventDefault(); + cancelCountdown(); + } + }; + + document.addEventListener('keydown', handleKeyDown); + return () => document.removeEventListener('keydown', handleKeyDown); + }, [countdown, cancelCountdown]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (timerRef.current !== null) { + clearInterval(timerRef.current); + } + }; + }, []); + + if (countdown === null) return null; + + const progressPercent = (countdown / COUNTDOWN_DURATION_MS) * 100; + const secondsLeft = Math.ceil(countdown / 1000); + + return ( + <div className="autosave-bar"> + <span className="autosave-bar-text"> + Saving in {secondsLeft}s... <kbd>Esc</kbd> to cancel + </span> + <div className="autosave-bar-progress-track"> + <div + className="autosave-bar-progress-fill" + style={{ width: `${progressPercent}%` }} + /> + </div> + <button + className="autosave-bar-cancel" + onClick={cancelCountdown} + type="button" + > + Cancel + </button> + </div> + ); +} diff --git a/frontend/src/components/document/ContextMenu.css b/frontend/src/components/document/ContextMenu.css new file mode 100644 index 0000000..4eed119 --- /dev/null +++ b/frontend/src/components/document/ContextMenu.css @@ -0,0 +1,79 @@ +/* ============================================ + Custom Context Menu + ============================================ */ + +.ctx-menu { + position: fixed; + z-index: 10000; + min-width: 200px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 10px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12), 0 2px 6px rgba(0, 0, 0, 0.06); + padding: 4px 0; + animation: ctxFadeIn 0.12s ease-out; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; +} + +/* Menu item */ +.ctx-menu-item { + display: flex; + align-items: center; + gap: 0.6rem; + width: 100%; + padding: 0.5rem 0.85rem; + border: none; + background: none; + cursor: pointer; + font-size: 0.875rem; + color: #1f2937; + text-align: left; + border-radius: 0; + transition: background 0.1s ease; +} + +.ctx-menu-item:hover:not(:disabled) { + background: #f3f4f6; +} + +.ctx-menu-item:active:not(:disabled) { + background: #e5e7eb; +} + +/* Disabled state */ +.ctx-menu-item-disabled, +.ctx-menu-item:disabled { + color: #9ca3af; + cursor: not-allowed; +} + +/* Icon */ +.ctx-menu-icon { + font-size: 1rem; + width: 1.25rem; + text-align: center; + flex-shrink: 0; +} + +.ctx-menu-label { + flex: 1; +} + +/* Divider */ +.ctx-menu-divider { + height: 1px; + background: #e5e7eb; + margin: 4px 0; +} + +/* Animation */ +@keyframes ctxFadeIn { + from { + opacity: 0; + transform: scale(0.96); + } + to { + opacity: 1; + transform: scale(1); + } +} diff --git a/frontend/src/components/document/ContextMenu.tsx b/frontend/src/components/document/ContextMenu.tsx new file mode 100644 index 0000000..5aed940 --- /dev/null +++ b/frontend/src/components/document/ContextMenu.tsx @@ -0,0 +1,98 @@ +import { useCallback, useEffect, useRef } from 'react'; +import './ContextMenu.css'; + +export interface ContextMenuAction { + label: string; + icon: string; + disabled?: boolean; + onClick: () => void; +} + +export interface ContextMenuProps { + x: number; + y: number; + actions: ContextMenuAction[]; + dividerAfter?: number[]; + onClose: () => void; +} + +export default function ContextMenu({ + x, + y, + actions, + dividerAfter = [], + onClose, +}: ContextMenuProps) { + const menuRef = useRef<HTMLDivElement>(null); + + // Adjust position so menu stays within viewport + const adjustedPosition = useCallback(() => { + const el = menuRef.current; + if (!el) return { left: x, top: y }; + const rect = el.getBoundingClientRect(); + const left = x + rect.width > window.innerWidth ? x - rect.width : x; + const top = y + rect.height > window.innerHeight ? y - rect.height : y; + return { left: Math.max(0, left), top: Math.max(0, top) }; + }, [x, y]); + + // Close on click outside + useEffect(() => { + const handler = (e: MouseEvent) => { + if (menuRef.current && !menuRef.current.contains(e.target as Node)) { + onClose(); + } + }; + // Use capture so we catch clicks before any other handler + document.addEventListener('mousedown', handler, true); + return () => document.removeEventListener('mousedown', handler, true); + }, [onClose]); + + // Close on Escape + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); + }, [onClose]); + + // After mount, adjust position + useEffect(() => { + const el = menuRef.current; + if (!el) return; + const pos = adjustedPosition(); + el.style.left = `${pos.left}px`; + el.style.top = `${pos.top}px`; + }, [adjustedPosition]); + + const dividerSet = new Set(dividerAfter); + + return ( + <div + ref={menuRef} + className="ctx-menu" + style={{ left: x, top: y }} + role="menu" + > + {actions.map((action, i) => ( + <div key={i}> + <button + className={`ctx-menu-item ${action.disabled ? 'ctx-menu-item-disabled' : ''}`} + role="menuitem" + disabled={action.disabled} + onClick={() => { + if (!action.disabled) { + action.onClick(); + onClose(); + } + }} + > + <span className="ctx-menu-icon">{action.icon}</span> + <span className="ctx-menu-label">{action.label}</span> + </button> + {dividerSet.has(i) && <div className="ctx-menu-divider" />} + </div> + ))} + </div> + ); +} diff --git a/frontend/src/components/document/DirectiveFileTree.tsx b/frontend/src/components/document/DirectiveFileTree.tsx new file mode 100644 index 0000000..21050ca --- /dev/null +++ b/frontend/src/components/document/DirectiveFileTree.tsx @@ -0,0 +1,154 @@ +import React, { useEffect, useState } from 'react' +import { listDirectives, DirectiveSummary } from '../../services/directiveApi' + +interface DirectiveFileTreeProps { + selectedDirectiveId: string | null + onSelectDirective: (id: string) => void + onNewDirective: () => void +} + +interface GroupState { + [key: string]: boolean +} + +const STATUS_GROUPS = [ + { key: 'active', label: 'Active', defaultExpanded: true }, + { key: 'idle', label: 'Idle', defaultExpanded: true }, + { key: 'draft', label: 'Draft', defaultExpanded: false }, + { key: 'archived', label: 'Archived', defaultExpanded: false }, +] as const + +function statusColor(status: string): string { + switch (status.toLowerCase()) { + case 'active': + case 'running': + return '#4caf50' + case 'idle': + case 'paused': + return '#ffc107' + case 'draft': + case 'pending': + return '#9e9e9e' + case 'archived': + case 'failed': + return '#f44336' + default: + return '#9e9e9e' + } +} + +function groupDirectives(directives: DirectiveSummary[]): Record<string, DirectiveSummary[]> { + const groups: Record<string, DirectiveSummary[]> = { + active: [], + idle: [], + draft: [], + archived: [], + } + + for (const d of directives) { + const s = d.status.toLowerCase() + if (s === 'active' || s === 'running') { + groups.active.push(d) + } else if (s === 'idle' || s === 'paused') { + groups.idle.push(d) + } else if (s === 'draft' || s === 'pending') { + groups.draft.push(d) + } else { + groups.archived.push(d) + } + } + + return groups +} + +export function DirectiveFileTree({ selectedDirectiveId, onSelectDirective, onNewDirective }: DirectiveFileTreeProps) { + const [directives, setDirectives] = useState<DirectiveSummary[]>([]) + const [loading, setLoading] = useState(true) + const [error, setError] = useState<string | null>(null) + const [expanded, setExpanded] = useState<GroupState>(() => { + const state: GroupState = {} + for (const g of STATUS_GROUPS) { + state[g.key] = g.defaultExpanded + } + return state + }) + + useEffect(() => { + async function load() { + try { + setLoading(true) + const data = await listDirectives() + setDirectives(data) + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load directives') + } finally { + setLoading(false) + } + } + load() + }, []) + + const toggleGroup = (key: string) => { + setExpanded(prev => ({ ...prev, [key]: !prev[key] })) + } + + const grouped = groupDirectives(directives) + + return ( + <div className="directive-file-tree"> + <div className="file-tree-header"> + <span className="file-tree-title">Directives</span> + <button className="file-tree-new-btn" onClick={onNewDirective} title="New Directive"> + + + </button> + </div> + + {loading && <div className="file-tree-loading">Loading...</div>} + {error && <div className="file-tree-error">{error}</div>} + + {!loading && !error && ( + <div className="file-tree-groups"> + {STATUS_GROUPS.map(group => { + const items = grouped[group.key] + if (!items || items.length === 0) return null + + return ( + <div key={group.key} className="file-tree-group"> + <button + className="file-tree-group-header" + onClick={() => toggleGroup(group.key)} + > + <span className={`file-tree-chevron ${expanded[group.key] ? 'expanded' : ''}`}> + {'\u25B6'} + </span> + <span className="file-tree-group-label">{group.label}</span> + <span className="file-tree-group-count">{items.length}</span> + </button> + + {expanded[group.key] && ( + <div className="file-tree-items"> + {items.map(directive => ( + <button + key={directive.id} + className={`file-tree-item ${selectedDirectiveId === directive.id ? 'selected' : ''}`} + onClick={() => onSelectDirective(directive.id)} + title={directive.title} + > + <span + className="file-tree-status-dot" + style={{ backgroundColor: statusColor(directive.status) }} + /> + <span className="file-tree-doc-icon">{'\u{1F4C4}'}</span> + <span className="file-tree-item-title">{directive.title || 'Untitled'}</span> + </button> + ))} + </div> + )} + </div> + ) + })} + </div> + )} + </div> + ) +} diff --git a/frontend/src/components/document/DocumentEditor.css b/frontend/src/components/document/DocumentEditor.css new file mode 100644 index 0000000..0be1151 --- /dev/null +++ b/frontend/src/components/document/DocumentEditor.css @@ -0,0 +1,246 @@ +/* ============================================ + Document Editor - Clean, modern document UI + ============================================ */ + +.document-editor-container { + max-width: 800px; + margin: 0 auto; + padding: 2rem 1rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + position: relative; +} + +/* ---- Lexical Root ---- */ +.doc-editor-root { + outline: none; + min-height: 400px; + padding: 1rem 0; + color: #1a1a2e; + line-height: 1.7; + font-size: 16px; +} + +/* ---- Headings ---- */ +.doc-editor-h1 { + font-size: 2.25rem; + font-weight: 700; + color: #0f0f23; + margin: 0 0 0.25rem 0; + padding: 0; + line-height: 1.3; + letter-spacing: -0.02em; + border: none; +} + +.doc-editor-h2 { + font-size: 1.5rem; + font-weight: 600; + color: #1a1a2e; + margin: 1.5rem 0 0.5rem 0; + line-height: 1.4; +} + +.doc-editor-h3 { + font-size: 1.2rem; + font-weight: 600; + color: #2a2a4a; + margin: 1.25rem 0 0.4rem 0; + line-height: 1.4; +} + +/* ---- Paragraphs ---- */ +.doc-editor-paragraph { + margin: 0.4rem 0; + padding: 0; + color: #374151; + line-height: 1.7; +} + +/* ---- Text Formatting ---- */ +.doc-editor-text-bold { + font-weight: 700; +} + +.doc-editor-text-italic { + font-style: italic; +} + +.doc-editor-text-underline { + text-decoration: underline; +} + +.doc-editor-text-strikethrough { + text-decoration: line-through; +} + +.doc-editor-text-code { + background: #f3f4f6; + border-radius: 3px; + padding: 0.15em 0.35em; + font-family: 'SF Mono', 'Fira Code', 'Fira Mono', Menlo, Consolas, monospace; + font-size: 0.9em; + color: #e11d48; +} + +/* ---- Lists ---- */ +.doc-editor-list-ul { + padding-left: 1.5rem; + margin: 0.5rem 0; + list-style-type: disc; +} + +.doc-editor-list-ol { + padding-left: 1.5rem; + margin: 0.5rem 0; + list-style-type: decimal; +} + +.doc-editor-listitem { + margin: 0.25rem 0; + color: #374151; +} + +.doc-editor-nested-listitem { + list-style-type: circle; +} + +/* ---- Links ---- */ +.doc-editor-link { + color: #2563eb; + text-decoration: underline; + cursor: pointer; +} + +.doc-editor-link:hover { + color: #1d4ed8; +} + +/* ---- Placeholder ---- */ +.doc-editor-placeholder { + color: #9ca3af; + position: absolute; + top: 1rem; + left: 0; + pointer-events: none; + font-size: 16px; + user-select: none; +} + +/* ---- Content Editable wrapper ---- */ +.doc-editor-input { + position: relative; +} + +.doc-editor-content-editable { + outline: none; + position: relative; +} + +/* ---- Divider between title and body ---- */ +.doc-editor-title-divider { + height: 1px; + background: #e5e7eb; + margin: 0.5rem 0 1rem 0; + border: none; +} + +/* ============================================ + Auto-Save Countdown Bar + ============================================ */ + +.autosave-bar { + position: sticky; + bottom: 0; + left: 0; + right: 0; + z-index: 50; + background: #fefce8; + border-top: 1px solid #fde68a; + padding: 0.5rem 1rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.85rem; + color: #92400e; + transition: opacity 0.2s ease; +} + +.autosave-bar-hidden { + opacity: 0; + pointer-events: none; + height: 0; + padding: 0; + overflow: hidden; +} + +.autosave-bar-text { + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.autosave-bar-text kbd { + background: #fef3c7; + border: 1px solid #fde68a; + border-radius: 3px; + padding: 0.1em 0.4em; + font-size: 0.8em; + font-family: inherit; +} + +.autosave-bar-progress-track { + flex: 1; + height: 4px; + background: #fde68a; + border-radius: 2px; + overflow: hidden; + min-width: 80px; +} + +.autosave-bar-progress-fill { + height: 100%; + background: #f59e0b; + border-radius: 2px; + transition: width 0.1s linear; +} + +.autosave-bar-cancel { + background: none; + border: 1px solid #d97706; + border-radius: 4px; + color: #92400e; + padding: 0.2rem 0.6rem; + cursor: pointer; + font-size: 0.8rem; + white-space: nowrap; + transition: background 0.15s ease; +} + +.autosave-bar-cancel:hover { + background: #fef3c7; +} + +/* ============================================ + Responsive + ============================================ */ + +@media (max-width: 640px) { + .document-editor-container { + padding: 1rem 0.75rem; + } + + .doc-editor-h1 { + font-size: 1.75rem; + } + + .doc-editor-root { + font-size: 15px; + } + + .autosave-bar { + font-size: 0.78rem; + padding: 0.4rem 0.75rem; + } +} diff --git a/frontend/src/components/document/DocumentEditor.tsx b/frontend/src/components/document/DocumentEditor.tsx new file mode 100644 index 0000000..d50c093 --- /dev/null +++ b/frontend/src/components/document/DocumentEditor.tsx @@ -0,0 +1,233 @@ +import { useCallback, useRef, useState } from 'react'; +import { LexicalComposer } from '@lexical/react/LexicalComposer'; +import { RichTextPlugin } from '@lexical/react/LexicalRichTextPlugin'; +import { ContentEditable } from '@lexical/react/LexicalContentEditable'; +import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'; +import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'; +import { LexicalErrorBoundary } from '@lexical/react/LexicalErrorBoundary'; +import { HeadingNode } from '@lexical/rich-text'; +import { ListNode, ListItemNode } from '@lexical/list'; +import { LinkNode } from '@lexical/link'; +import { + $getRoot, + $createParagraphNode, + $createTextNode, + type EditorState, + type LexicalEditor, +} from 'lexical'; +import { $createHeadingNode } from '@lexical/rich-text'; + +import { StepsDiagramNode, $isStepsDiagramNode, $createStepsDiagramNode } from './nodes/StepsDiagramNode'; +import editorTheme from './EditorTheme'; +import AutoSavePlugin from './AutoSavePlugin'; +import ContextMenu, { type ContextMenuAction } from './ContextMenu'; +import './DocumentEditor.css'; +import './nodes/StepsDiagram.css'; + +interface DocumentEditorProps { + directiveId: string; + title: string; + goal: string; + status: string; + prBranch?: string | null; + onGoalChange?: (newGoal: string) => void; + onTitleChange?: (newTitle: string) => void; + onCleanup?: () => void; + onCreatePr?: () => void; + onPlanOrders?: () => void; + onTogglePause?: () => void; + readOnly?: boolean; +} + +function buildInitialEditorState(directiveId: string, title: string, goal: string) { + return () => { + const root = $getRoot(); + + // Title as H1 + const heading = $createHeadingNode('h1'); + heading.append($createTextNode(title)); + root.append(heading); + + // Goal as paragraph(s), split by newlines + const lines = goal.split('\n'); + for (const line of lines) { + const paragraph = $createParagraphNode(); + if (line.trim()) { + paragraph.append($createTextNode(line)); + } + root.append(paragraph); + } + + // Insert steps diagram node after the goal content + const stepsNode = $createStepsDiagramNode(directiveId); + root.append(stepsNode); + + // Add a trailing paragraph so the user can type below the diagram + const trailingParagraph = $createParagraphNode(); + root.append(trailingParagraph); + }; +} + +function onError(error: Error) { + console.error('[DocumentEditor] Lexical error:', error); +} + +export default function DocumentEditor({ + directiveId, + title, + goal, + status, + prBranch, + onGoalChange, + onTitleChange, + onCleanup, + onCreatePr, + onPlanOrders, + onTogglePause, + readOnly = false, +}: DocumentEditorProps) { + const editorRef = useRef<LexicalEditor | null>(null); + const latestGoalRef = useRef(goal); + const latestTitleRef = useRef(title); + + // Context menu state + const [ctxMenu, setCtxMenu] = useState<{ x: number; y: number } | null>(null); + + const initialConfig = { + namespace: `DocumentEditor-${directiveId}`, + theme: editorTheme, + editorState: buildInitialEditorState(directiveId, title, goal), + nodes: [HeadingNode, ListNode, ListItemNode, LinkNode, StepsDiagramNode], + onError, + editable: !readOnly, + }; + + const handleChange = useCallback( + (_editorState: EditorState, editor: LexicalEditor) => { + editorRef.current = editor; + + editor.getEditorState().read(() => { + const root = $getRoot(); + const children = root.getChildren(); + + let newTitle = ''; + const goalLines: string[] = []; + + for (let i = 0; i < children.length; i++) { + const child = children[i]; + // Skip the steps diagram node when extracting text + if ($isStepsDiagramNode(child)) continue; + + const text = child.getTextContent(); + + if (i === 0 && child.getType() === 'heading') { + newTitle = text; + } else { + goalLines.push(text); + } + } + + const newGoal = goalLines.join('\n'); + + if (newTitle !== latestTitleRef.current) { + latestTitleRef.current = newTitle; + onTitleChange?.(newTitle); + } + + if (newGoal !== latestGoalRef.current) { + latestGoalRef.current = newGoal; + onGoalChange?.(newGoal); + } + }); + }, + [onGoalChange, onTitleChange] + ); + + const getContent = useCallback(() => { + return latestGoalRef.current; + }, []); + + const handleAutoSave = useCallback( + (content: string) => { + onGoalChange?.(content); + }, + [onGoalChange] + ); + + // Context menu handler + const handleContextMenu = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + setCtxMenu({ x: e.clientX, y: e.clientY }); + }, + [] + ); + + const closeCtxMenu = useCallback(() => setCtxMenu(null), []); + + const isPaused = status === 'paused'; + const isIdle = status === 'idle'; + + const ctxActions: ContextMenuAction[] = [ + { + label: 'Clean Up', + icon: '\uD83E\uDDF9', // broom + disabled: !isIdle, + onClick: () => onCleanup?.(), + }, + { + label: 'Update PR', + icon: '\uD83D\uDD00', // shuffle + disabled: !prBranch, + onClick: () => onCreatePr?.(), + }, + { + label: 'Plan Orders', + icon: '\uD83D\uDCCB', // clipboard + onClick: () => onPlanOrders?.(), + }, + { + label: isPaused ? 'Resume Directive' : 'Pause Directive', + icon: isPaused ? '\u25B6' : '\u23F8', + onClick: () => onTogglePause?.(), + }, + ]; + + return ( + <div className="document-editor-container" onContextMenu={handleContextMenu}> + <LexicalComposer initialConfig={initialConfig}> + <div className="doc-editor-input"> + <RichTextPlugin + contentEditable={ + <ContentEditable className="doc-editor-content-editable doc-editor-root" /> + } + placeholder={ + <div className="doc-editor-placeholder">Start writing...</div> + } + ErrorBoundary={LexicalErrorBoundary} + /> + </div> + <HistoryPlugin /> + <OnChangePlugin onChange={handleChange} /> + {!readOnly && ( + <AutoSavePlugin + onAutoSave={handleAutoSave} + getContent={getContent} + enabled={!readOnly} + /> + )} + </LexicalComposer> + + {ctxMenu && ( + <ContextMenu + x={ctxMenu.x} + y={ctxMenu.y} + actions={ctxActions} + dividerAfter={[2]} + onClose={closeCtxMenu} + /> + )} + </div> + ); +} + diff --git a/frontend/src/components/document/DocumentLayout.css b/frontend/src/components/document/DocumentLayout.css new file mode 100644 index 0000000..b18bb81 --- /dev/null +++ b/frontend/src/components/document/DocumentLayout.css @@ -0,0 +1,347 @@ +/* Document Layout - Main container */ +.document-layout { + display: flex; + height: 100vh; + width: 100vw; + overflow: hidden; + background: #1a1a2e; + color: #e0e0e0; +} + +/* Sidebar */ +.document-sidebar { + flex-shrink: 0; + height: 100%; + overflow-y: auto; + overflow-x: hidden; + background: #16162a; + border-right: 1px solid #2a2a4a; +} + +/* Back link */ +.document-sidebar-back { + padding: 8px 12px; + border-bottom: 1px solid #2a2a4a; +} + +.document-back-link { + color: #9ca3af; + text-decoration: none; + font-size: 0.85rem; + display: flex; + align-items: center; + gap: 4px; + transition: color 0.15s; +} + +.document-back-link:hover { + color: #e0e0e0; +} + +/* Resize handle */ +.document-resize-handle { + width: 4px; + cursor: col-resize; + background: transparent; + flex-shrink: 0; + transition: background 0.15s; +} + +.document-resize-handle:hover, +.document-resize-handle:active { + background: #4a4a8a; +} + +/* Main content area */ +.document-main { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; + overflow: hidden; +} + +/* Top bar */ +.document-topbar { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 20px; + border-bottom: 1px solid #2a2a4a; + background: #1e1e38; + flex-shrink: 0; +} + +.document-topbar-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.document-topbar-title { + font-size: 16px; + font-weight: 600; + margin: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: #f0f0f0; +} + +.document-topbar-right { + display: flex; + align-items: center; + gap: 8px; +} + +.document-topbar-gear { + background: none; + border: none; + color: #888; + font-size: 20px; + cursor: pointer; + padding: 4px 8px; + border-radius: 4px; + transition: color 0.15s, background 0.15s; +} + +.document-topbar-gear:hover { + color: #fff; + background: rgba(255, 255, 255, 0.08); +} + +/* Status badge */ +.doc-status-badge { + display: inline-block; + padding: 2px 10px; + border-radius: 10px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: #fff; + letter-spacing: 0.5px; +} + +/* Content area */ +.document-content { + flex: 1; + overflow-y: auto; + display: flex; + flex-direction: column; + align-items: center; +} + +/* Placeholder / empty state */ +.document-placeholder { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + color: #888; + padding: 40px; +} + +.document-placeholder-icon { + font-size: 48px; + margin-bottom: 16px; + opacity: 0.5; +} + +.document-placeholder h2 { + font-size: 20px; + font-weight: 500; + margin: 0 0 8px; + color: #aaa; +} + +.document-placeholder p { + font-size: 14px; + margin: 0; + max-width: 400px; + line-height: 1.5; +} + +.document-error { + color: #f44336; +} + +/* File Tree styles */ +.directive-file-tree { + display: flex; + flex-direction: column; + height: 100%; + font-size: 13px; +} + +.file-tree-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 14px; + border-bottom: 1px solid #2a2a4a; + flex-shrink: 0; +} + +.file-tree-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1px; + color: #888; +} + +.file-tree-new-btn { + background: none; + border: 1px solid #3a3a6a; + color: #aaa; + font-size: 16px; + width: 24px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + cursor: pointer; + padding: 0; + line-height: 1; + transition: all 0.15s; +} + +.file-tree-new-btn:hover { + background: #3a3a6a; + color: #fff; + border-color: #5a5a9a; +} + +.file-tree-loading, +.file-tree-error { + padding: 16px; + color: #888; + font-size: 12px; + text-align: center; +} + +.file-tree-error { + color: #f44336; +} + +.file-tree-groups { + flex: 1; + overflow-y: auto; + padding: 4px 0; +} + +/* Group header */ +.file-tree-group { + margin-bottom: 2px; +} + +.file-tree-group-header { + display: flex; + align-items: center; + gap: 6px; + width: 100%; + padding: 6px 14px; + background: none; + border: none; + color: #999; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + cursor: pointer; + text-align: left; + transition: color 0.15s; +} + +.file-tree-group-header:hover { + color: #ccc; +} + +.file-tree-chevron { + font-size: 8px; + transition: transform 0.15s; + display: inline-block; +} + +.file-tree-chevron.expanded { + transform: rotate(90deg); +} + +.file-tree-group-count { + margin-left: auto; + color: #666; + font-size: 10px; +} + +.file-tree-group-label { + flex: 1; +} + +/* Tree items */ +.file-tree-items { + padding: 0; +} + +.file-tree-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 5px 14px 5px 28px; + background: none; + border: none; + color: #ccc; + font-size: 13px; + cursor: pointer; + text-align: left; + transition: background 0.1s; + white-space: nowrap; + overflow: hidden; +} + +.file-tree-item:hover { + background: rgba(255, 255, 255, 0.05); +} + +.file-tree-item.selected { + background: rgba(100, 100, 200, 0.15); + color: #fff; +} + +.file-tree-status-dot { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; +} + +.file-tree-doc-icon { + font-size: 14px; + flex-shrink: 0; + opacity: 0.7; +} + +.file-tree-item-title { + overflow: hidden; + text-overflow: ellipsis; +} + +/* Responsive: mobile */ +@media (max-width: 768px) { + .document-sidebar { + position: absolute; + z-index: 100; + left: 0; + top: 0; + height: 100%; + box-shadow: 4px 0 20px rgba(0, 0, 0, 0.5); + } + + .document-resize-handle { + display: none; + } +} diff --git a/frontend/src/components/document/DocumentLayout.tsx b/frontend/src/components/document/DocumentLayout.tsx new file mode 100644 index 0000000..a555ad0 --- /dev/null +++ b/frontend/src/components/document/DocumentLayout.tsx @@ -0,0 +1,316 @@ +import { useEffect, useState, useCallback, useRef } from 'react' +import { useParams, useNavigate, Link } from 'react-router-dom' +import { DirectiveFileTree } from './DirectiveFileTree' +import DocumentEditor from './DocumentEditor' +import { ToastProvider, useToast } from './Toast' +import { + type DirectiveWithSteps, + type DirectiveStep, + getDirective, + getDirectiveSteps, + updateGoal, + updateDirective, + cleanupDirective, + createPr, + pickUpOrders, + pauseDirective, + startDirective, +} from '../../services/directiveApi' +import './DocumentLayout.css' + +function StatusBadge({ status }: { status: string }) { + const colors: Record<string, string> = { + active: '#4caf50', + running: '#4caf50', + idle: '#ffc107', + paused: '#ffc107', + draft: '#9e9e9e', + pending: '#9e9e9e', + archived: '#f44336', + failed: '#f44336', + } + const color = colors[status.toLowerCase()] || '#9e9e9e' + + return ( + <span className="doc-status-badge" style={{ backgroundColor: color }}> + {status} + </span> + ) +} + +function DocumentLayoutInner() { + const { id: urlDirectiveId } = useParams<{ id: string }>() + const navigate = useNavigate() + const { addToast } = useToast() + + const [selectedId, setSelectedId] = useState<string | null>(urlDirectiveId || null) + const [directive, setDirective] = useState<DirectiveWithSteps | null>(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState<string | null>(null) + const [sidebarWidth, setSidebarWidth] = useState(250) + const resizingRef = useRef(false) + const startXRef = useRef(0) + const startWidthRef = useRef(250) + const pollRef = useRef<ReturnType<typeof setInterval> | null>(null) + + // Sync URL param on mount + useEffect(() => { + if (urlDirectiveId && urlDirectiveId !== selectedId) { + setSelectedId(urlDirectiveId) + } + }, [urlDirectiveId]) + + // Handle directive selection - update URL + const handleSelectDirective = useCallback((id: string) => { + setSelectedId(id) + navigate(`/directives/${id}`, { replace: true }) + }, [navigate]) + + // Load directive when selected + useEffect(() => { + if (!selectedId) { + setDirective(null) + return + } + + let cancelled = false + async function load() { + try { + setLoading(true) + setError(null) + const data = await getDirective(selectedId!) + if (!cancelled) setDirective(data) + } catch (err) { + if (!cancelled) { + const msg = err instanceof Error ? err.message : 'Failed to load directive' + setError(msg) + addToast(msg, 'error') + } + } finally { + if (!cancelled) setLoading(false) + } + } + load() + + return () => { cancelled = true } + }, [selectedId, addToast]) + + // Step polling (after goal update triggers supervisor) + const startStepPolling = useCallback(() => { + if (pollRef.current) clearInterval(pollRef.current) + pollRef.current = setInterval(async () => { + if (!selectedId) return + try { + const data = await getDirective(selectedId) + setDirective(data) + } catch { + // Silently fail for polling + } + }, 3000) + // Stop after 60 seconds + setTimeout(() => { + if (pollRef.current) { + clearInterval(pollRef.current) + pollRef.current = null + } + }, 60000) + }, [selectedId]) + + useEffect(() => { + return () => { + if (pollRef.current) clearInterval(pollRef.current) + } + }, []) + + // Auto-save goal changes + const handleGoalChange = useCallback(async (newGoal: string) => { + if (!selectedId) return + try { + const updated = await updateGoal(selectedId, newGoal) + setDirective(updated) + addToast('Goal saved', 'success') + startStepPolling() + } catch (err) { + addToast(`Failed to save goal: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast, startStepPolling]) + + const handleTitleChange = useCallback(async (newTitle: string) => { + if (!selectedId || !directive) return + try { + const updated = await updateDirective(selectedId, { + title: newTitle, + version: directive.version, + }) + setDirective(updated) + } catch (err) { + addToast(`Failed to update title: ${(err as Error).message}`, 'error') + } + }, [selectedId, directive, addToast]) + + const handleCleanup = useCallback(async () => { + if (!selectedId) return + try { + await cleanupDirective(selectedId) + addToast('Cleanup task spawned', 'success') + startStepPolling() + } catch (err) { + addToast(`Cleanup failed: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast, startStepPolling]) + + const handleCreatePr = useCallback(async () => { + if (!selectedId) return + try { + await createPr(selectedId) + addToast('PR update triggered', 'success') + } catch (err) { + addToast(`PR update failed: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast]) + + const handlePlanOrders = useCallback(async () => { + if (!selectedId) return + try { + await pickUpOrders(selectedId) + addToast('Planning orders...', 'info') + startStepPolling() + } catch (err) { + addToast(`Plan orders failed: ${(err as Error).message}`, 'error') + } + }, [selectedId, addToast, startStepPolling]) + + const handleTogglePause = useCallback(async () => { + if (!selectedId || !directive) return + try { + if (directive.status === 'paused') { + const result = await startDirective(selectedId) + setDirective(result) + addToast('Directive resumed', 'success') + } else { + const updated = await pauseDirective(selectedId) + setDirective(updated) + addToast('Directive paused', 'info') + } + } catch (err) { + addToast(`Failed to toggle pause: ${(err as Error).message}`, 'error') + } + }, [selectedId, directive, addToast]) + + // Sidebar resize handlers + const handleMouseDown = useCallback((e: React.MouseEvent) => { + resizingRef.current = true + startXRef.current = e.clientX + startWidthRef.current = sidebarWidth + document.body.style.cursor = 'col-resize' + document.body.style.userSelect = 'none' + + const handleMouseMove = (e: MouseEvent) => { + if (!resizingRef.current) return + const diff = e.clientX - startXRef.current + const newWidth = Math.max(180, Math.min(500, startWidthRef.current + diff)) + setSidebarWidth(newWidth) + } + + const handleMouseUp = () => { + resizingRef.current = false + document.body.style.cursor = '' + document.body.style.userSelect = '' + document.removeEventListener('mousemove', handleMouseMove) + document.removeEventListener('mouseup', handleMouseUp) + } + + document.addEventListener('mousemove', handleMouseMove) + document.addEventListener('mouseup', handleMouseUp) + }, [sidebarWidth]) + + const handleNewDirective = useCallback(() => { + // Placeholder - will be implemented with full directive creation flow + console.log('New directive requested') + }, []) + + return ( + <div className="document-layout"> + {/* Sidebar */} + <div className="document-sidebar" style={{ width: sidebarWidth }}> + <div className="document-sidebar-back"> + <Link to="/" className="document-back-link"> + {'\u2190'} Back to Main + </Link> + </div> + <DirectiveFileTree + selectedDirectiveId={selectedId} + onSelectDirective={handleSelectDirective} + onNewDirective={handleNewDirective} + /> + </div> + + {/* Resize handle */} + <div className="document-resize-handle" onMouseDown={handleMouseDown} /> + + {/* Main content */} + <div className="document-main"> + {directive && ( + <div className="document-topbar"> + <div className="document-topbar-left"> + <h1 className="document-topbar-title">{directive.title || 'Untitled'}</h1> + <StatusBadge status={directive.status} /> + </div> + <div className="document-topbar-right"> + <button className="document-topbar-gear" title="Settings"> + {'\u2699'} + </button> + </div> + </div> + )} + + <div className="document-content"> + {loading && ( + <div className="document-placeholder"> + <p>Loading directive...</p> + </div> + )} + + {error && ( + <div className="document-placeholder"> + <p className="document-error">Error: {error}</p> + </div> + )} + + {!loading && !error && !directive && ( + <div className="document-placeholder"> + <div className="document-placeholder-icon">{'\u{1F4DD}'}</div> + <h2>No directive selected</h2> + <p>Select a directive from the sidebar or create a new one to get started.</p> + </div> + )} + + {!loading && !error && directive && ( + <DocumentEditor + directiveId={directive.id} + title={directive.title || 'Untitled'} + goal={directive.goal || ''} + status={directive.status} + prBranch={directive.prBranch || directive.pr_branch} + onGoalChange={handleGoalChange} + onTitleChange={handleTitleChange} + onCleanup={handleCleanup} + onCreatePr={handleCreatePr} + onPlanOrders={handlePlanOrders} + onTogglePause={handleTogglePause} + /> + )} + </div> + </div> + </div> + ) +} + +// Wrapper that provides toast context +export default function DocumentLayout() { + return ( + <ToastProvider> + <DocumentLayoutInner /> + </ToastProvider> + ) +} diff --git a/frontend/src/components/document/DocumentSettings.tsx b/frontend/src/components/document/DocumentSettings.tsx new file mode 100644 index 0000000..b575b3d --- /dev/null +++ b/frontend/src/components/document/DocumentSettings.tsx @@ -0,0 +1,76 @@ +import { useState, useCallback } from 'react' +import { upsertUserSetting } from '../../services/directiveApi' + +interface DocumentSettingsProps { + isOpen: boolean + onClose: () => void + enabled: boolean + onToggle: (enabled: boolean) => void +} + +export default function DocumentSettings({ + isOpen, + onClose, + enabled, + onToggle, +}: DocumentSettingsProps) { + const [saving, setSaving] = useState(false) + + const handleToggle = useCallback(async () => { + const newValue = !enabled + setSaving(true) + try { + // Update localStorage immediately for instant UI response + localStorage.setItem('document_ui_enabled', JSON.stringify(newValue)) + onToggle(newValue) + + // Persist to backend + await upsertUserSetting('document_ui_enabled', newValue) + } catch (err) { + console.error('Failed to save document UI setting:', err) + // Revert on failure + localStorage.setItem('document_ui_enabled', JSON.stringify(!newValue)) + onToggle(!newValue) + } finally { + setSaving(false) + } + }, [enabled, onToggle]) + + if (!isOpen) return null + + return ( + <div className="modal-overlay" onClick={onClose}> + <div className="config-modal" onClick={(e) => e.stopPropagation()}> + <div className="modal-header"> + <h2>Document UI Settings</h2> + <button className="close-btn" onClick={onClose}>{'\u00D7'}</button> + </div> + + <div className="modal-content"> + <div className="config-option"> + <label className="config-label" style={{ cursor: 'pointer' }}> + <input + type="checkbox" + checked={enabled} + onChange={handleToggle} + disabled={saving} + className="config-checkbox" + /> + <span className="config-text"> + Enable Document UI (Experimental) + </span> + </label> + <div className="config-description"> + Replace the directive management interface with an interactive + document editor. This is a proof of concept. + </div> + </div> + </div> + + <div className="modal-footer"> + <button className="modal-btn" onClick={onClose}>Close</button> + </div> + </div> + </div> + ) +} diff --git a/frontend/src/components/document/EditorTheme.ts b/frontend/src/components/document/EditorTheme.ts new file mode 100644 index 0000000..5b336ad --- /dev/null +++ b/frontend/src/components/document/EditorTheme.ts @@ -0,0 +1,30 @@ +import type { EditorThemeClasses } from 'lexical'; + +const editorTheme: EditorThemeClasses = { + root: 'doc-editor-root', + paragraph: 'doc-editor-paragraph', + heading: { + h1: 'doc-editor-h1', + h2: 'doc-editor-h2', + h3: 'doc-editor-h3', + }, + text: { + bold: 'doc-editor-text-bold', + italic: 'doc-editor-text-italic', + underline: 'doc-editor-text-underline', + strikethrough: 'doc-editor-text-strikethrough', + code: 'doc-editor-text-code', + }, + list: { + ul: 'doc-editor-list-ul', + ol: 'doc-editor-list-ol', + listitem: 'doc-editor-listitem', + nested: { + listitem: 'doc-editor-nested-listitem', + }, + }, + link: 'doc-editor-link', + placeholder: 'doc-editor-placeholder', +}; + +export default editorTheme; diff --git a/frontend/src/components/document/Toast.css b/frontend/src/components/document/Toast.css new file mode 100644 index 0000000..e97304c --- /dev/null +++ b/frontend/src/components/document/Toast.css @@ -0,0 +1,100 @@ +/* ============================================ + Toast Notifications + ============================================ */ + +.toast-container { + position: fixed; + bottom: 1.5rem; + right: 1.5rem; + z-index: 9999; + display: flex; + flex-direction: column-reverse; + gap: 0.5rem; + pointer-events: none; +} + +.toast-item { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.65rem 1rem; + border-radius: 8px; + font-size: 0.875rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12), 0 1px 3px rgba(0, 0, 0, 0.08); + pointer-events: auto; + max-width: 360px; + backdrop-filter: blur(6px); +} + +/* Types */ +.toast-success { + background: #ecfdf5; + color: #065f46; + border: 1px solid #a7f3d0; +} + +.toast-error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} + +.toast-info { + background: #eff6ff; + color: #1e40af; + border: 1px solid #bfdbfe; +} + +/* Icon */ +.toast-icon { + flex-shrink: 0; + font-size: 1rem; + line-height: 1; +} + +.toast-message { + line-height: 1.4; +} + +/* Animations */ +.toast-enter { + animation: toastSlideIn 0.25s ease-out forwards; +} + +.toast-exit { + animation: toastSlideOut 0.3s ease-in forwards; +} + +@keyframes toastSlideIn { + from { + opacity: 0; + transform: translateY(8px) scale(0.96); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes toastSlideOut { + from { + opacity: 1; + transform: translateY(0) scale(1); + } + to { + opacity: 0; + transform: translateY(8px) scale(0.96); + } +} + +@media (max-width: 640px) { + .toast-container { + right: 0.75rem; + bottom: 0.75rem; + } + .toast-item { + max-width: calc(100vw - 1.5rem); + font-size: 0.8rem; + } +} diff --git a/frontend/src/components/document/Toast.tsx b/frontend/src/components/document/Toast.tsx new file mode 100644 index 0000000..653db8f --- /dev/null +++ b/frontend/src/components/document/Toast.tsx @@ -0,0 +1,97 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useRef, + useState, + type ReactNode, +} from 'react'; +import './Toast.css'; + +// -- Types ------------------------------------------------------------------- + +export type ToastType = 'success' | 'error' | 'info'; + +interface ToastItem { + id: number; + message: string; + type: ToastType; +} + +interface ToastContextValue { + addToast: (message: string, type?: ToastType) => void; +} + +// -- Context ----------------------------------------------------------------- + +const ToastContext = createContext<ToastContextValue | null>(null); + +export function useToast(): ToastContextValue { + const ctx = useContext(ToastContext); + if (!ctx) throw new Error('useToast must be used within a ToastProvider'); + return ctx; +} + +// -- Provider ---------------------------------------------------------------- + +const DISMISS_MS = 3000; + +export function ToastProvider({ children }: { children: ReactNode }) { + const [toasts, setToasts] = useState<ToastItem[]>([]); + const nextId = useRef(0); + + const addToast = useCallback((message: string, type: ToastType = 'info') => { + const id = nextId.current++; + setToasts((prev) => [...prev, { id, message, type }]); + }, []); + + const removeToast = useCallback((id: number) => { + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + return ( + <ToastContext.Provider value={{ addToast }}> + {children} + <div className="toast-container"> + {toasts.map((t) => ( + <ToastItem key={t.id} toast={t} onDismiss={removeToast} /> + ))} + </div> + </ToastContext.Provider> + ); +} + +// -- Single toast ------------------------------------------------------------ + +function ToastItem({ + toast, + onDismiss, +}: { + toast: ToastItem; + onDismiss: (id: number) => void; +}) { + const [exiting, setExiting] = useState(false); + + useEffect(() => { + const timer = setTimeout(() => setExiting(true), DISMISS_MS - 300); + const remove = setTimeout(() => onDismiss(toast.id), DISMISS_MS); + return () => { + clearTimeout(timer); + clearTimeout(remove); + }; + }, [toast.id, onDismiss]); + + const icon = + toast.type === 'success' ? '\u2713' : toast.type === 'error' ? '\u2717' : '\u2139'; + + return ( + <div + className={`toast-item toast-${toast.type} ${exiting ? 'toast-exit' : 'toast-enter'}`} + role="status" + > + <span className="toast-icon">{icon}</span> + <span className="toast-message">{toast.message}</span> + </div> + ); +} diff --git a/frontend/src/components/document/index.ts b/frontend/src/components/document/index.ts new file mode 100644 index 0000000..af9e362 --- /dev/null +++ b/frontend/src/components/document/index.ts @@ -0,0 +1,4 @@ +export { default as DocumentLayout } from './DocumentLayout' +export { default as DocumentEditor } from './DocumentEditor' +export { DirectiveFileTree } from './DirectiveFileTree' +export { default as DocumentSettings } from './DocumentSettings' diff --git a/frontend/src/components/document/nodes/StepsDiagram.css b/frontend/src/components/document/nodes/StepsDiagram.css new file mode 100644 index 0000000..f3e9305 --- /dev/null +++ b/frontend/src/components/document/nodes/StepsDiagram.css @@ -0,0 +1,360 @@ +/* ============================================ + Steps Diagram Block + ============================================ */ + +.steps-diagram-block { + margin: 1.5rem 0; + user-select: none; +} + +.steps-diagram { + background: #f8f9fc; + border: 1px solid #e2e5ef; + border-radius: 10px; + padding: 1rem 1.25rem; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + font-size: 14px; + color: #374151; +} + +/* ---- Header ---- */ +.steps-diagram-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 1rem; + padding-bottom: 0.6rem; + border-bottom: 1px solid #e5e7eb; +} + +.steps-diagram-header-left { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.steps-diagram-header-title { + font-weight: 600; + font-size: 0.9rem; + color: #1f2937; + letter-spacing: 0.01em; +} + +.steps-diagram-header-count { + font-size: 0.78rem; + color: #6b7280; + background: #e5e7eb; + border-radius: 10px; + padding: 0.15rem 0.55rem; +} + +.steps-diagram-header-author { + font-size: 0.72rem; + color: #9ca3af; + font-style: italic; +} + +/* ---- DAG Layout ---- */ +.steps-diagram-dag { + display: flex; + flex-direction: column; + align-items: center; + gap: 0; +} + +.steps-diagram-group { + display: flex; + flex-wrap: wrap; + gap: 0.6rem; + justify-content: center; + width: 100%; +} + +/* ---- Arrow between groups ---- */ +.steps-diagram-arrow { + display: flex; + flex-direction: column; + align-items: center; + padding: 0.15rem 0; +} + +.steps-diagram-arrow-line { + width: 2px; + height: 16px; + background: #cbd5e1; +} + +.steps-diagram-arrow-head { + width: 0; + height: 0; + border-left: 5px solid transparent; + border-right: 5px solid transparent; + border-top: 6px solid #cbd5e1; +} + +/* ---- Step Card ---- */ +.steps-diagram-card { + flex: 1 1 180px; + max-width: 280px; + background: #ffffff; + border: 1px solid #e5e7eb; + border-radius: 8px; + padding: 0.65rem 0.8rem; + transition: box-shadow 0.2s ease, border-color 0.2s ease; + animation: stepCardAppear 0.35s ease-out both; +} + +@keyframes stepCardAppear { + from { + opacity: 0; + transform: translateY(8px) scale(0.97); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +.steps-diagram-card:hover { + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); +} + +.steps-diagram-card-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + margin-bottom: 0.3rem; +} + +.steps-diagram-card-name { + font-weight: 600; + font-size: 0.85rem; + color: #1f2937; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.steps-diagram-card-desc { + font-size: 0.78rem; + color: #6b7280; + margin: 0.2rem 0 0.4rem 0; + line-height: 1.4; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +.steps-diagram-card-footer { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.72rem; + color: #9ca3af; +} + +.steps-diagram-card-index { + font-weight: 500; +} + +.steps-diagram-card-progress { + color: #d97706; + font-style: italic; +} + +.steps-diagram-card-time { + color: #6b7280; +} + +/* ---- Status Badge ---- */ +.steps-diagram-status-badge { + font-size: 0.68rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + padding: 0.12rem 0.45rem; + border-radius: 9px; + white-space: nowrap; + flex-shrink: 0; +} + +.steps-diagram-status-badge--pending { + background: #f3f4f6; + color: #6b7280; +} + +.steps-diagram-status-badge--ready { + background: #dbeafe; + color: #2563eb; +} + +.steps-diagram-status-badge--running { + background: #fef3c7; + color: #d97706; + animation: statusPulse 2s ease-in-out infinite; +} + +.steps-diagram-status-badge--completed { + background: #d1fae5; + color: #059669; +} + +.steps-diagram-status-badge--failed { + background: #fee2e2; + color: #dc2626; +} + +.steps-diagram-status-badge--skipped { + background: repeating-linear-gradient( + 45deg, + #f3f4f6, + #f3f4f6 4px, + #e5e7eb 4px, + #e5e7eb 8px + ); + color: #9ca3af; +} + +/* ---- Status-specific Card Borders ---- */ +.steps-diagram-card--pending { + border-left: 3px solid #d1d5db; +} + +.steps-diagram-card--ready { + border-left: 3px solid #3b82f6; +} + +.steps-diagram-card--running { + border-left: 3px solid #f59e0b; + animation: cardGlow 2s ease-in-out infinite; +} + +.steps-diagram-card--completed { + border-left: 3px solid #10b981; +} + +.steps-diagram-card--failed { + border-left: 3px solid #ef4444; +} + +.steps-diagram-card--skipped { + border-left: 3px solid #d1d5db; + opacity: 0.7; +} + +/* ---- Animations ---- */ +@keyframes statusPulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.65; + } +} + +@keyframes cardGlow { + 0%, 100% { + box-shadow: 0 0 0 0 rgba(245, 158, 11, 0); + } + 50% { + box-shadow: 0 0 8px 2px rgba(245, 158, 11, 0.15); + } +} + +/* ---- Loading State ---- */ +.steps-diagram-loading { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 1rem 0; + color: #9ca3af; + font-size: 0.85rem; +} + +.steps-diagram-spinner { + width: 16px; + height: 16px; + border: 2px solid #e5e7eb; + border-top-color: #6b7280; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ---- Planning State ---- */ +.steps-diagram-planning { + display: flex; + align-items: center; + gap: 0.75rem; + padding: 1.25rem 0; + color: #6b7280; + font-size: 0.85rem; + font-style: italic; +} + +.steps-diagram-planning-dots { + display: flex; + gap: 4px; +} + +.steps-diagram-planning-dots span { + width: 6px; + height: 6px; + background: #9ca3af; + border-radius: 50%; + animation: dotBounce 1.4s ease-in-out infinite; +} + +.steps-diagram-planning-dots span:nth-child(2) { + animation-delay: 0.2s; +} + +.steps-diagram-planning-dots span:nth-child(3) { + animation-delay: 0.4s; +} + +@keyframes dotBounce { + 0%, 80%, 100% { + transform: scale(0.6); + opacity: 0.4; + } + 40% { + transform: scale(1); + opacity: 1; + } +} + +/* ---- Empty / Error ---- */ +.steps-diagram-empty { + padding: 1rem 0; + color: #9ca3af; + font-size: 0.85rem; + text-align: center; +} + +.steps-diagram-error { + padding: 0.75rem; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + color: #dc2626; + font-size: 0.82rem; +} + +/* ---- Responsive ---- */ +@media (max-width: 640px) { + .steps-diagram { + padding: 0.75rem; + } + + .steps-diagram-card { + flex: 1 1 100%; + max-width: 100%; + } +} diff --git a/frontend/src/components/document/nodes/StepsDiagramComponent.tsx b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx new file mode 100644 index 0000000..606c0ab --- /dev/null +++ b/frontend/src/components/document/nodes/StepsDiagramComponent.tsx @@ -0,0 +1,180 @@ +import React, { useEffect, useState, useRef, useCallback } from 'react'; +import { getDirective, DirectiveStep, DirectiveWithSteps } from '../../../services/directiveApi'; +import './StepsDiagram.css'; + +interface StepsDiagramComponentProps { + directiveId: string; +} + +type StepStatus = 'pending' | 'ready' | 'running' | 'completed' | 'failed' | 'skipped'; + +const STATUS_LABELS: Record<string, string> = { + pending: 'Pending', + ready: 'Ready', + running: 'Running', + completed: 'Done', + failed: 'Failed', + skipped: 'Skipped', +}; + +function formatTime(dateStr: string): string { + if (!dateStr) return ''; + const d = new Date(dateStr); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); +} + +function StepCard({ step }: { step: DirectiveStep }) { + const status = (step.status || 'pending').toLowerCase() as StepStatus; + + return ( + <div className={`steps-diagram-card steps-diagram-card--${status}`}> + <div className="steps-diagram-card-header"> + <span className="steps-diagram-card-name">{step.name}</span> + <span className={`steps-diagram-status-badge steps-diagram-status-badge--${status}`}> + {STATUS_LABELS[status] || status} + </span> + </div> + {step.description && ( + <p className="steps-diagram-card-desc">{step.description}</p> + )} + <div className="steps-diagram-card-footer"> + <span className="steps-diagram-card-index">#{step.orderIndex}</span> + {status === 'running' && ( + <span className="steps-diagram-card-progress">In progress...</span> + )} + {status === 'completed' && step.completedAt && ( + <span className="steps-diagram-card-time"> + Completed {formatTime(step.completedAt)} + </span> + )} + </div> + </div> + ); +} + +export function StepsDiagramComponent({ directiveId }: StepsDiagramComponentProps) { + const [steps, setSteps] = useState<DirectiveStep[]>([]); + const [directiveStatus, setDirectiveStatus] = useState<string>(''); + const [loading, setLoading] = useState(true); + const [error, setError] = useState<string | null>(null); + const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null); + const prevStepCountRef = useRef(0); + + const fetchSteps = useCallback(async () => { + try { + const data: DirectiveWithSteps = await getDirective(directiveId); + setSteps(data.steps || []); + setDirectiveStatus(data.status || ''); + setError(null); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load steps'); + } finally { + setLoading(false); + } + }, [directiveId]); + + useEffect(() => { + fetchSteps(); + intervalRef.current = setInterval(fetchSteps, 5000); + return () => { + if (intervalRef.current) clearInterval(intervalRef.current); + }; + }, [fetchSteps]); + + // Track when new steps appear for animation + useEffect(() => { + prevStepCountRef.current = steps.length; + }, [steps.length]); + + const completedCount = steps.filter(s => s.status?.toLowerCase() === 'completed').length; + const totalCount = steps.length; + const isActive = ['active', 'running', 'planning'].includes(directiveStatus.toLowerCase()); + const isBuilding = isActive && steps.length === 0; + const isAddingSteps = isActive && steps.length > 0 && steps.length > prevStepCountRef.current; + + // Group steps by orderIndex + const groupedSteps: Map<number, DirectiveStep[]> = new Map(); + const sortedSteps = [...steps].sort((a, b) => a.orderIndex - b.orderIndex); + for (const step of sortedSteps) { + const idx = step.orderIndex; + if (!groupedSteps.has(idx)) groupedSteps.set(idx, []); + groupedSteps.get(idx)!.push(step); + } + const orderGroups = Array.from(groupedSteps.entries()).sort((a, b) => a[0] - b[0]); + + if (loading) { + return ( + <div className="steps-diagram" contentEditable={false}> + <div className="steps-diagram-header"> + <span className="steps-diagram-header-title">Steps</span> + <span className="steps-diagram-header-author">Authored by Makima</span> + </div> + <div className="steps-diagram-loading"> + <div className="steps-diagram-spinner" /> + <span>Loading steps...</span> + </div> + </div> + ); + } + + if (error) { + return ( + <div className="steps-diagram" contentEditable={false}> + <div className="steps-diagram-header"> + <span className="steps-diagram-header-title">Steps</span> + <span className="steps-diagram-header-author">Authored by Makima</span> + </div> + <div className="steps-diagram-error">Failed to load steps: {error}</div> + </div> + ); + } + + return ( + <div className="steps-diagram" contentEditable={false}> + <div className="steps-diagram-header"> + <div className="steps-diagram-header-left"> + <span className="steps-diagram-header-title">Steps</span> + {totalCount > 0 && ( + <span className="steps-diagram-header-count"> + {completedCount}/{totalCount} completed + </span> + )} + </div> + <span className="steps-diagram-header-author">Authored by Makima</span> + </div> + + {isBuilding && ( + <div className="steps-diagram-planning"> + <div className="steps-diagram-planning-dots"> + <span /><span /><span /> + </div> + <span>Makima is building the plan...</span> + </div> + )} + + {totalCount === 0 && !isBuilding && ( + <div className="steps-diagram-empty">No steps defined yet.</div> + )} + + {totalCount > 0 && ( + <div className="steps-diagram-dag"> + {orderGroups.map(([orderIndex, groupSteps], groupIdx) => ( + <React.Fragment key={orderIndex}> + {groupIdx > 0 && ( + <div className="steps-diagram-arrow"> + <div className="steps-diagram-arrow-line" /> + <div className="steps-diagram-arrow-head" /> + </div> + )} + <div className="steps-diagram-group"> + {groupSteps.map((step) => ( + <StepCard key={step.id} step={step} /> + ))} + </div> + </React.Fragment> + ))} + </div> + )} + </div> + ); +} diff --git a/frontend/src/components/document/nodes/StepsDiagramNode.tsx b/frontend/src/components/document/nodes/StepsDiagramNode.tsx new file mode 100644 index 0000000..8b37f52 --- /dev/null +++ b/frontend/src/components/document/nodes/StepsDiagramNode.tsx @@ -0,0 +1,91 @@ +import { + DecoratorNode, + DOMExportOutput, + LexicalNode, + NodeKey, + SerializedLexicalNode, + Spread, +} from 'lexical'; +import React from 'react'; +import { StepsDiagramComponent } from './StepsDiagramComponent'; + +export type SerializedStepsDiagramNode = Spread< + { + directiveId: string; + }, + SerializedLexicalNode +>; + +export class StepsDiagramNode extends DecoratorNode<JSX.Element> { + __directiveId: string; + + static getType(): string { + return 'steps-diagram'; + } + + static clone(node: StepsDiagramNode): StepsDiagramNode { + return new StepsDiagramNode(node.__directiveId, node.__key); + } + + constructor(directiveId: string, key?: NodeKey) { + super(key); + this.__directiveId = directiveId; + } + + createDOM(): HTMLElement { + const div = document.createElement('div'); + div.className = 'steps-diagram-block'; + return div; + } + + updateDOM(): boolean { + return false; + } + + decorate(): JSX.Element { + return <StepsDiagramComponent directiveId={this.__directiveId} />; + } + + exportJSON(): SerializedStepsDiagramNode { + return { + ...super.exportJSON(), + type: 'steps-diagram', + directiveId: this.__directiveId, + version: 1, + }; + } + + static importJSON(serializedNode: SerializedStepsDiagramNode): StepsDiagramNode { + return $createStepsDiagramNode(serializedNode.directiveId); + } + + isInline(): boolean { + return false; + } + + canInsertTextBefore(): boolean { + return false; + } + + canInsertTextAfter(): boolean { + return false; + } + + exportDOM(): DOMExportOutput { + const element = document.createElement('div'); + element.className = 'steps-diagram-block'; + element.setAttribute('data-directive-id', this.__directiveId); + element.textContent = '[Steps Diagram]'; + return { element }; + } +} + +export function $createStepsDiagramNode(directiveId: string): StepsDiagramNode { + return new StepsDiagramNode(directiveId); +} + +export function $isStepsDiagramNode( + node: LexicalNode | null | undefined, +): node is StepsDiagramNode { + return node instanceof StepsDiagramNode; +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 04b8cde..3987f30 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -7,6 +7,7 @@ import { ContractDetail } from './components/ContractDetail' import { FileDetail } from './components/FileDetail' import { DaemonList } from './components/DaemonList' import { DaemonDetail } from './components/DaemonDetail' +import { DocumentLayout } from './components/document' import './styles/pc98.css' import './styles/mobile.css' @@ -43,6 +44,14 @@ const router = createBrowserRouter([ path: '/daemons/:id', element: <DaemonDetail />, }, + { + path: '/directives', + element: <DocumentLayout />, + }, + { + path: '/directives/:id', + element: <DocumentLayout />, + }, ]) ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/frontend/src/services/directiveApi.ts b/frontend/src/services/directiveApi.ts new file mode 100644 index 0000000..b82f594 --- /dev/null +++ b/frontend/src/services/directiveApi.ts @@ -0,0 +1,136 @@ +// API service for directive operations + +export interface DirectiveStepCounts { + pending: number + ready: number + running: number + completed: number + failed: number + skipped: number +} + +export interface DirectiveSummary { + id: string + title: string + goal: string + status: string + repositoryUrl: string + prUrl: string + prBranch: string + createdAt: string + updatedAt: string + goalUpdatedAt: string + stepCounts: DirectiveStepCounts + version?: number + pr_branch?: string | null +} + +export interface DirectiveStep { + id: string + directiveId: string + directive_id?: string + name: string + title?: string + description: string | null + taskPlan: string + dependsOn: string[] + status: string + taskId: string + contractId: string + orderIndex: number + sort_order?: number + completedAt: string +} + +export interface DirectiveWithSteps extends DirectiveSummary { + steps: DirectiveStep[] + reconcileMode: string +} + +// Alias for compatibility with context-menu branch types +export type Directive = DirectiveSummary + +async function apiFetch(path: string, options?: RequestInit): Promise<Response> { + const response = await fetch(path, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options?.headers, + }, + }) + if (!response.ok) { + const body = await response.json().catch(() => ({ message: response.statusText })) + throw new Error(body.message ?? body.error ?? `API error ${response.status}: ${response.statusText}`) + } + return response +} + +export async function listDirectives(): Promise<DirectiveSummary[]> { + const response = await apiFetch('/api/v1/directives') + const data = await response.json() + return data.directives || [] +} + +export async function getDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}`) + return response.json() +} + +export async function getDirectiveSteps(id: string): Promise<DirectiveStep[]> { + const response = await apiFetch(`/api/v1/directives/${id}/steps`) + return response.json() +} + +export async function updateGoal(id: string, goal: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}/goal`, { + method: 'PUT', + body: JSON.stringify({ goal }), + }) + return response.json() +} + +export async function updateDirective( + id: string, + updates: { title?: string; goal?: string; version?: number }, +): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}`, { + method: 'PUT', + body: JSON.stringify(updates), + }) + return response.json() +} + +export async function cleanupDirective(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/cleanup`, { method: 'POST' }) +} + +export async function createPr(id: string): Promise<{ prUrl: string }> { + const response = await apiFetch(`/api/v1/directives/${id}/create-pr`, { method: 'POST' }) + return response.json() +} + +export async function pickUpOrders(id: string): Promise<void> { + await apiFetch(`/api/v1/directives/${id}/pick-up-orders`, { method: 'POST' }) +} + +export async function startDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}/start`, { method: 'POST' }) + return response.json() +} + +export async function pauseDirective(id: string): Promise<DirectiveWithSteps> { + const response = await apiFetch(`/api/v1/directives/${id}/pause`, { method: 'POST' }) + return response.json() +} + +export async function getUserSetting(key: string): Promise<any> { + const response = await apiFetch(`/api/v1/settings/${key}`) + return response.json() +} + +export async function upsertUserSetting(key: string, value: any): Promise<void> { + await apiFetch('/api/v1/settings', { + method: 'PUT', + body: JSON.stringify({ key, value }), + }) +} diff --git a/frontend/src/stores/index.ts b/frontend/src/stores/index.ts index 58f461c..5ee9b08 100644 --- a/frontend/src/stores/index.ts +++ b/frontend/src/stores/index.ts @@ -36,6 +36,29 @@ export const skipIntroStore = atom<boolean>( })() ) +// Document UI feature flag +export const documentUiEnabledStore = atom<boolean>( + (() => { + try { + const saved = localStorage.getItem('document_ui_enabled') + return saved === 'true' + } catch { + return false + } + })() +) + +export const setDocumentUiEnabled = (enabled: boolean) => { + documentUiEnabledStore.set(enabled) + localStorage.setItem('document_ui_enabled', JSON.stringify(enabled)) + // Persist to backend (fire-and-forget) + fetch('/api/v1/settings', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ key: 'document_ui_enabled', value: enabled }), + }).catch((err) => console.error('Failed to persist document_ui_enabled:', err)) +} + // Actions export const login = () => { isLoggedInStore.set(true) diff --git a/frontend/tsconfig.tsbuildinfo b/frontend/tsconfig.tsbuildinfo index 79408dc..83d0161 100644 --- a/frontend/tsconfig.tsbuildinfo +++ b/frontend/tsconfig.tsbuildinfo @@ -1 +1 @@ -{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file +{"root":["./src/app.tsx","./src/main.tsx","./src/types.ts","./src/components/bottombar.tsx","./src/components/choicemenu.tsx","./src/components/cityscapebackground.tsx","./src/components/configmodal.tsx","./src/components/contractdetail.tsx","./src/components/contractlist.tsx","./src/components/daemondetail.tsx","./src/components/daemonlist.tsx","./src/components/dialoguebox.tsx","./src/components/filedetail.tsx","./src/components/heartlogo.tsx","./src/components/landingpage.tsx","./src/components/loadingscreen.tsx","./src/components/origamidragonlogo.tsx","./src/components/topbar.tsx","./src/components/vnapp.tsx","./src/components/vninterface.tsx","./src/components/vnviewport.tsx","./src/components/document/autosaveplugin.tsx","./src/components/document/contextmenu.tsx","./src/components/document/directivefiletree.tsx","./src/components/document/documenteditor.tsx","./src/components/document/documentlayout.tsx","./src/components/document/documentsettings.tsx","./src/components/document/editortheme.ts","./src/components/document/toast.tsx","./src/components/document/index.ts","./src/components/document/nodes/stepsdiagramcomponent.tsx","./src/components/document/nodes/stepsdiagramnode.tsx","./src/services/directiveapi.ts","./src/services/ws.ts","./src/stores/index.ts"],"version":"5.9.2"}
\ No newline at end of file diff --git a/makima/migrations/20260427000000_create_user_settings.sql b/makima/migrations/20260427000000_create_user_settings.sql new file mode 100644 index 0000000..60acbcc --- /dev/null +++ b/makima/migrations/20260427000000_create_user_settings.sql @@ -0,0 +1,11 @@ +-- Create user_settings table for per-user feature flags and preferences +CREATE TABLE IF NOT EXISTS user_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + owner_id UUID NOT NULL, + key TEXT NOT NULL, + value JSONB NOT NULL DEFAULT '"false"'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(owner_id, key) +); +CREATE INDEX idx_user_settings_owner ON user_settings(owner_id); diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs index 97657dc..c03b4ac 100644 --- a/makima/src/db/models.rs +++ b/makima/src/db/models.rs @@ -3050,4 +3050,31 @@ pub struct DirectiveOrderGroupListResponse { pub total: i64, } +/// User setting record from the database (key-value per owner). +#[derive(Debug, Clone, FromRow, Serialize, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSetting { + pub id: Uuid, + pub owner_id: Uuid, + pub key: String, + pub value: serde_json::Value, + pub created_at: DateTime<Utc>, + pub updated_at: DateTime<Utc>, +} + +/// Request body for upserting a user setting. +#[derive(Debug, Deserialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UpsertUserSettingRequest { + pub key: String, + pub value: serde_json::Value, +} + +/// Response containing a list of user settings. +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct UserSettingsResponse { + pub settings: Vec<UserSetting>, +} + diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs index 57e8a78..401da94 100644 --- a/makima/src/db/repository.rs +++ b/makima/src/db/repository.rs @@ -21,6 +21,7 @@ use super::models::{ PhaseDefinition, SupervisorHeartbeatRecord, SupervisorState, Task, TaskCheckpoint, TaskEvent, TaskSummary, UpdateContractRequest, UpdateFileRequest, UpdateTaskRequest, UpdateTemplateRequest, + UserSetting, }; /// Repository error types. @@ -6698,3 +6699,84 @@ pub async fn get_available_orders_for_dog_pickup( .await } +// ─── User Settings ─────────────────────────────────────────────────────────── + +/// Get all settings for a given owner. +pub async fn get_user_settings( + pool: &PgPool, + owner_id: Uuid, +) -> Result<Vec<UserSetting>, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + SELECT id, owner_id, key, value, created_at, updated_at + FROM user_settings + WHERE owner_id = $1 + ORDER BY key ASC + "#, + ) + .bind(owner_id) + .fetch_all(pool) + .await +} + +/// Get a single setting by owner and key. +pub async fn get_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, +) -> Result<Option<UserSetting>, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + SELECT id, owner_id, key, value, created_at, updated_at + FROM user_settings + WHERE owner_id = $1 AND key = $2 + "#, + ) + .bind(owner_id) + .bind(key) + .fetch_optional(pool) + .await +} + +/// Upsert a user setting (insert or update on conflict). +pub async fn upsert_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, + value: &serde_json::Value, +) -> Result<UserSetting, sqlx::Error> { + sqlx::query_as::<_, UserSetting>( + r#" + INSERT INTO user_settings (owner_id, key, value) + VALUES ($1, $2, $3) + ON CONFLICT (owner_id, key) DO UPDATE + SET value = EXCLUDED.value, updated_at = now() + RETURNING id, owner_id, key, value, created_at, updated_at + "#, + ) + .bind(owner_id) + .bind(key) + .bind(value) + .fetch_one(pool) + .await +} + +/// Delete a user setting. Returns true if a row was deleted. +pub async fn delete_user_setting( + pool: &PgPool, + owner_id: Uuid, + key: &str, +) -> Result<bool, sqlx::Error> { + let result = sqlx::query( + r#" + DELETE FROM user_settings + WHERE owner_id = $1 AND key = $2 + "#, + ) + .bind(owner_id) + .bind(key) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + diff --git a/makima/src/orchestration/directive.rs b/makima/src/orchestration/directive.rs index 1e025c8..19077c9 100644 --- a/makima/src/orchestration/directive.rs +++ b/makima/src/orchestration/directive.rs @@ -442,8 +442,34 @@ impl DirectiveOrchestrator { let directives = repository::get_directives_needing_replanning(&self.pool).await?; for directive in directives { - if let Err(e) = async { - tracing::info!( + tracing::info!( + directive_id = %directive.id, + title = %directive.title, + "Directive goal updated — spawning re-planning task" + ); + + let existing_steps = + repository::list_directive_steps(&self.pool, directive.id).await?; + let generation = + repository::get_directive_max_generation(&self.pool, directive.id).await? + 1; + let goal_history = + repository::get_directive_goal_history(&self.pool, directive.id, 3).await?; + + let plan = + build_planning_prompt(&directive, &existing_steps, generation, &goal_history); + + if let Err(e) = self + .spawn_orchestrator_task( + directive.id, + directive.owner_id, + format!("Re-plan: {}", directive.title), + plan, + directive.repository_url.as_deref(), + directive.base_branch.as_deref(), + ) + .await + { + tracing::warn!( directive_id = %directive.id, title = %directive.title, "Directive goal updated — spawning re-planning task" diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs index 4bdb424..b3c433b 100644 --- a/makima/src/server/handlers/mod.rs +++ b/makima/src/server/handlers/mod.rs @@ -20,6 +20,7 @@ pub mod mesh_merge; pub mod mesh_supervisor; pub mod mesh_ws; pub mod repository_history; +pub mod settings; pub mod speak; pub mod templates; pub mod voice; diff --git a/makima/src/server/handlers/settings.rs b/makima/src/server/handlers/settings.rs new file mode 100644 index 0000000..ae52d5a --- /dev/null +++ b/makima/src/server/handlers/settings.rs @@ -0,0 +1,196 @@ +//! HTTP handlers for user settings (feature flags / preferences). + +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::IntoResponse, + Json, +}; + +use crate::db::models::{UpsertUserSettingRequest, UserSettingsResponse}; +use crate::db::repository; +use crate::server::auth::Authenticated; +use crate::server::messages::ApiError; +use crate::server::state::SharedState; + +/// List all settings for the authenticated user. +#[utoipa::path( + get, + path = "/api/v1/settings", + responses( + (status = 200, description = "User settings", body = UserSettingsResponse), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn list_settings( + State(state): State<SharedState>, + Authenticated(user): Authenticated, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_user_settings(pool, user.owner_id).await { + Ok(settings) => Json(UserSettingsResponse { settings }).into_response(), + Err(e) => { + tracing::error!("Failed to list user settings: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Get a specific setting by key. +#[utoipa::path( + get, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "Setting key") + ), + responses( + (status = 200, description = "User setting", body = crate::db::models::UserSetting), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "Setting not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn get_setting( + State(state): State<SharedState>, + Authenticated(user): Authenticated, + Path(key): Path<String>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::get_user_setting(pool, user.owner_id, &key).await { + Ok(Some(setting)) => Json(setting).into_response(), + Ok(None) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to get user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Upsert a user setting (create or update). +#[utoipa::path( + put, + path = "/api/v1/settings", + request_body = UpsertUserSettingRequest, + responses( + (status = 200, description = "Setting upserted", body = crate::db::models::UserSetting), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn upsert_setting( + State(state): State<SharedState>, + Authenticated(user): Authenticated, + Json(req): Json<UpsertUserSettingRequest>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::upsert_user_setting(pool, user.owner_id, &req.key, &req.value).await { + Ok(setting) => Json(setting).into_response(), + Err(e) => { + tracing::error!("Failed to upsert user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} + +/// Delete a user setting by key. +#[utoipa::path( + delete, + path = "/api/v1/settings/{key}", + params( + ("key" = String, Path, description = "Setting key") + ), + responses( + (status = 200, description = "Setting deleted"), + (status = 401, description = "Not authenticated", body = ApiError), + (status = 404, description = "Setting not found", body = ApiError), + (status = 503, description = "Database not configured", body = ApiError), + (status = 500, description = "Internal server error", body = ApiError), + ), + security( + ("bearer_auth" = []) + ), + tag = "Settings" +)] +pub async fn delete_setting( + State(state): State<SharedState>, + Authenticated(user): Authenticated, + Path(key): Path<String>, +) -> impl IntoResponse { + let Some(ref pool) = state.db_pool else { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")), + ) + .into_response(); + }; + + match repository::delete_user_setting(pool, user.owner_id, &key).await { + Ok(true) => StatusCode::OK.into_response(), + Ok(false) => ( + StatusCode::NOT_FOUND, + Json(ApiError::new("NOT_FOUND", format!("Setting '{}' not found", key))), + ) + .into_response(), + Err(e) => { + tracing::error!("Failed to delete user setting: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(ApiError::new("DB_ERROR", e.to_string())), + ) + .into_response() + } + } +} diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs index b382f04..025ec85 100644 --- a/makima/src/server/mod.rs +++ b/makima/src/server/mod.rs @@ -18,7 +18,7 @@ use tower_http::trace::TraceLayer; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; -use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, speak, templates, transcript_analysis, users, versions}; +use crate::server::handlers::{api_keys, chat, contract_chat, contract_daemon, contract_discuss, contracts, daemon_download, directives, file_ws, files, history, listen, mesh, mesh_chat, mesh_daemon, mesh_merge, mesh_supervisor, mesh_ws, orders, repository_history, settings, speak, templates, transcript_analysis, users, versions}; use crate::server::openapi::ApiDoc; use crate::server::state::SharedState; @@ -281,6 +281,15 @@ pub fn make_router(state: SharedState) -> Router { .route("/timeline", get(history::get_timeline)) // Contract type templates (built-in only) .route("/contract-types", get(templates::list_contract_types)) + // User settings (feature flags) endpoints + .route( + "/user-settings", + get(settings::list_settings).put(settings::upsert_setting), + ) + .route( + "/user-settings/{key}", + get(settings::get_setting).delete(settings::delete_setting), + ) // Settings endpoints .route( "/settings/repository-history", |
