From 1dc40fa22c8042d329a4315cde682c46eeccc03f Mon Sep 17 00:00:00 2001 From: Rokojori Date: Mon, 6 Jul 2026 19:28:59 +0200 Subject: [PATCH] initial commit: source, workspace docs, locale system, gitignore --- .gitignore | 7 + .gitmodules | 3 + CLAUDE.md | 3 + .../commands/file-tree-commands/add-file.txt | 1 + locales/en/greeting.txt | 1 + package-lock.json | 1242 +++++++++++++++++ package.json | 22 + server/db.ts | 124 ++ server/index.ts | 43 + server/localeGenerator.ts | 82 ++ server/middleware/auth.ts | 16 + server/routes/auth.ts | 47 + server/routes/files.ts | 59 + server/routes/groups.ts | 40 + server/routes/layout.ts | 44 + server/routes/locales.ts | 26 + server/routes/projects.ts | 42 + server/sessionStore.ts | 66 + server/storage.ts | 105 ++ src/commands/Command.ts | 34 + src/commands/CommandAssignments.ts | 5 + src/commands/CommandContext.ts | 5 + src/commands/CommandManager.ts | 18 + src/commands/file-tree-commands/AddFile.ts | 17 + .../file-tree-commands/FileTreeCommands.ts | 4 + src/components/app-nav/app-nav.ts | 40 + .../confirm-dialog/confirm-dialog.ts | 84 ++ src/components/context-menu/context-menu.ts | 202 +++ src/components/editor-shell/editor-shell.ts | 320 +++++ .../file-tree-panel/file-tree-panel.ts | 378 +++++ src/components/group-editor/group-editor.ts | 118 ++ .../html-editor-panel/html-editor-panel.ts | 239 ++++ src/components/login-form/login-form.ts | 31 + .../project-editor/project-editor.ts | 141 ++ src/components/register-form/register-form.ts | 31 + src/components/tab-container/tab-container.ts | 199 +++ src/editor/Editor.ts | 90 ++ src/library-ts | 1 + src/locales/LocaleManager.ts | 72 + src/locales/Locales.ts | 9 + tsconfig.client.json | 19 + tsconfig.json | 12 + tsconfig.ts-node.json | 7 + workspace/_assets_/nav-data.js | 38 + workspace/_assets_/nav.css | 92 ++ workspace/_assets_/nav.js | 133 ++ workspace/_assets_/styles.css | 200 +++ workspace/actions/index.html | 54 + workspace/actions/update-history/index.html | 148 ++ workspace/actions/update-outline/index.html | 108 ++ workspace/guides/index.html | 55 + workspace/guides/locales/index.html | 272 ++++ .../guides/writing-typescript-code/index.html | 215 +++ .../01-Wednesday/implementation-details.txt | 32 + .../2026/07-July/01-Wednesday/index.html | 159 +++ .../01-Wednesday/project-definition.txt | 32 + .../07-July/02-Thursday/desktop-layout.jpg | Bin 0 -> 31143 bytes .../2026/07-July/02-Thursday/editor-mocks.svg | 418 ++++++ .../2026/07-July/02-Thursday/ideas.txt | 9 + .../2026/07-July/02-Thursday/index.html | 233 ++++ .../07-July/02-Thursday/mobile-layout.jpg | Bin 0 -> 23807 bytes .../02-Thursday/project-editing-update.txt | 64 + .../07-July/02-Thursday/tab-container.jpg | Bin 0 -> 16585 bytes .../history/2026/07-July/03-Friday/index.html | 221 +++ .../04-Saturday/documentation-update.txt | 29 + .../2026/07-July/04-Saturday/index.html | 176 +++ .../2026/07-July/05-Sunday/Editor-Updates.txt | 37 + .../history/2026/07-July/05-Sunday/index.html | 347 +++++ .../2026/07-July/06-Monday/Locales Update.txt | 18 + .../07-July/06-Monday/Structural Update.txt | 22 + .../history/2026/07-July/06-Monday/index.html | 309 ++++ workspace/history/index.html | 63 + workspace/index.html | 55 + workspace/outline/index.html | 184 +++ 74 files changed, 7772 insertions(+) create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 CLAUDE.md create mode 100644 locales/en/commands/file-tree-commands/add-file.txt create mode 100644 locales/en/greeting.txt create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 server/db.ts create mode 100644 server/index.ts create mode 100644 server/localeGenerator.ts create mode 100644 server/middleware/auth.ts create mode 100644 server/routes/auth.ts create mode 100644 server/routes/files.ts create mode 100644 server/routes/groups.ts create mode 100644 server/routes/layout.ts create mode 100644 server/routes/locales.ts create mode 100644 server/routes/projects.ts create mode 100644 server/sessionStore.ts create mode 100644 server/storage.ts create mode 100644 src/commands/Command.ts create mode 100644 src/commands/CommandAssignments.ts create mode 100644 src/commands/CommandContext.ts create mode 100644 src/commands/CommandManager.ts create mode 100644 src/commands/file-tree-commands/AddFile.ts create mode 100644 src/commands/file-tree-commands/FileTreeCommands.ts create mode 100644 src/components/app-nav/app-nav.ts create mode 100644 src/components/confirm-dialog/confirm-dialog.ts create mode 100644 src/components/context-menu/context-menu.ts create mode 100644 src/components/editor-shell/editor-shell.ts create mode 100644 src/components/file-tree-panel/file-tree-panel.ts create mode 100644 src/components/group-editor/group-editor.ts create mode 100644 src/components/html-editor-panel/html-editor-panel.ts create mode 100644 src/components/login-form/login-form.ts create mode 100644 src/components/project-editor/project-editor.ts create mode 100644 src/components/register-form/register-form.ts create mode 100644 src/components/tab-container/tab-container.ts create mode 100644 src/editor/Editor.ts create mode 160000 src/library-ts create mode 100644 src/locales/LocaleManager.ts create mode 100644 src/locales/Locales.ts create mode 100644 tsconfig.client.json create mode 100644 tsconfig.json create mode 100644 tsconfig.ts-node.json create mode 100644 workspace/_assets_/nav-data.js create mode 100644 workspace/_assets_/nav.css create mode 100644 workspace/_assets_/nav.js create mode 100644 workspace/_assets_/styles.css create mode 100644 workspace/actions/index.html create mode 100644 workspace/actions/update-history/index.html create mode 100644 workspace/actions/update-outline/index.html create mode 100644 workspace/guides/index.html create mode 100644 workspace/guides/locales/index.html create mode 100644 workspace/guides/writing-typescript-code/index.html create mode 100644 workspace/history/2026/07-July/01-Wednesday/implementation-details.txt create mode 100644 workspace/history/2026/07-July/01-Wednesday/index.html create mode 100644 workspace/history/2026/07-July/01-Wednesday/project-definition.txt create mode 100644 workspace/history/2026/07-July/02-Thursday/desktop-layout.jpg create mode 100644 workspace/history/2026/07-July/02-Thursday/editor-mocks.svg create mode 100644 workspace/history/2026/07-July/02-Thursday/ideas.txt create mode 100644 workspace/history/2026/07-July/02-Thursday/index.html create mode 100644 workspace/history/2026/07-July/02-Thursday/mobile-layout.jpg create mode 100644 workspace/history/2026/07-July/02-Thursday/project-editing-update.txt create mode 100644 workspace/history/2026/07-July/02-Thursday/tab-container.jpg create mode 100644 workspace/history/2026/07-July/03-Friday/index.html create mode 100644 workspace/history/2026/07-July/04-Saturday/documentation-update.txt create mode 100644 workspace/history/2026/07-July/04-Saturday/index.html create mode 100644 workspace/history/2026/07-July/05-Sunday/Editor-Updates.txt create mode 100644 workspace/history/2026/07-July/05-Sunday/index.html create mode 100644 workspace/history/2026/07-July/06-Monday/Locales Update.txt create mode 100644 workspace/history/2026/07-July/06-Monday/Structural Update.txt create mode 100644 workspace/history/2026/07-July/06-Monday/index.html create mode 100644 workspace/history/index.html create mode 100644 workspace/index.html create mode 100644 workspace/outline/index.html diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4001da4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +data/ +storage/ +public/ +!public/*.html +tsconfig.client.tsbuildinfo +.claude/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..d9f728f --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "src/library-ts"] + path = src/library-ts + url = git@development.rokojori.com:Josef/library-ts.git diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..565d682 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +# Roject + +See **[workspace/outline/index.html](workspace/outline/index.html)** for the full developer documentation — project outline, tech stack, coding conventions, and repeatable actions. diff --git a/locales/en/commands/file-tree-commands/add-file.txt b/locales/en/commands/file-tree-commands/add-file.txt new file mode 100644 index 0000000..3e0d7bd --- /dev/null +++ b/locales/en/commands/file-tree-commands/add-file.txt @@ -0,0 +1 @@ +Add file \ No newline at end of file diff --git a/locales/en/greeting.txt b/locales/en/greeting.txt new file mode 100644 index 0000000..5ab2f8a --- /dev/null +++ b/locales/en/greeting.txt @@ -0,0 +1 @@ +Hello \ No newline at end of file diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..43463b1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,1242 @@ +{ + "name": "roject", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "roject", + "version": "1.0.0", + "dependencies": { + "bcryptjs": "^2.4.3", + "express": "^4.18.2", + "express-session": "^1.17.3" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", + "@types/node": "^20.11.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } + }, + "node_modules/@cspotcode/source-map-support": { + "version": "0.8.1", + "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", + "integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.9" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz", + "integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.0.3", + "@jridgewell/sourcemap-codec": "^1.4.10" + } + }, + "node_modules/@tsconfig/node10": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz", + "integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node12": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz", + "integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node14": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz", + "integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==", + "dev": true, + "license": "MIT" + }, + "node_modules/@tsconfig/node16": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz", + "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/bcryptjs": { + "version": "2.4.6", + "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", + "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz", + "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz", + "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/express": { + "version": "4.17.25", + "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.25.tgz", + "integrity": "sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^4.17.33", + "@types/qs": "*", + "@types/serve-static": "^1" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "4.19.8", + "resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.8.tgz", + "integrity": "sha512-02S5fmqeoKzVZCHPZid4b8JH2eM5HzQLZWN2FohQEy/0eXTq8VXZfSN6Pcr3F6N9R/vNrj7cpgbhjie6m/1tCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/@types/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-GbypG0bog68UbOq2tSAp7SclvCUm3ha1uDi58OPRGK1NfRvCIu7Gz0M7fTGtpNG1T9a29GpuurQj9zEcT/lMXQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz", + "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/mime": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/@types/mime/-/mime-1.3.5.tgz", + "integrity": "sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.43", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz", + "integrity": "sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.15.1.tgz", + "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz", + "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz", + "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "1.15.10", + "resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.10.tgz", + "integrity": "sha512-tRs1dB+g8Itk72rlSI2ZrW6vZg0YrLI81iQSTkMmOqnqCaNr/8Ek4VwWcN5vZgCYWbg/JJSGBlUaYGAOP73qBw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*", + "@types/send": "<1" + } + }, + "node_modules/@types/serve-static/node_modules/@types/send": { + "version": "0.17.6", + "resolved": "https://registry.npmjs.org/@types/send/-/send-0.17.6.tgz", + "integrity": "sha512-Uqt8rPBE8SY0RK8JB1EzVOIZ32uqy8HwdxCnoCOsYrvnswqmFZ/k+9Ikidlk/ImhsdvBsloHbAlewb2IEBV/Og==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/mime": "^1", + "@types/node": "*" + } + }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-walk": { + "version": "8.3.5", + "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", + "integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==", + "dev": true, + "license": "MIT", + "dependencies": { + "acorn": "^8.11.0" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/arg": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz", + "integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==", + "dev": true, + "license": "MIT" + }, + "node_modules/array-flatten": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", + "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", + "license": "MIT" + }, + "node_modules/bcryptjs": { + "version": "2.4.3", + "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", + "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", + "license": "MIT" + }, + "node_modules/body-parser": { + "version": "1.20.5", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz", + "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "content-type": "~1.0.5", + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.15.1", + "raw-body": "~2.5.3", + "type-is": "~1.6.18", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "0.5.4", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", + "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "license": "MIT", + "dependencies": { + "safe-buffer": "5.2.1" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", + "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", + "license": "MIT" + }, + "node_modules/create-require": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz", + "integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "license": "MIT", + "dependencies": { + "ms": "2.0.0" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destroy": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", + "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", + "license": "MIT", + "engines": { + "node": ">= 0.8", + "npm": "1.2.8000 || >= 1.4.16" + } + }, + "node_modules/diff": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/express": { + "version": "4.22.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", + "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.8", + "array-flatten": "1.1.1", + "body-parser": "~1.20.5", + "content-disposition": "~0.5.4", + "content-type": "~1.0.4", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", + "debug": "2.6.9", + "depd": "2.0.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", + "merge-descriptors": "1.0.3", + "methods": "~1.1.2", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "path-to-regexp": "~0.1.12", + "proxy-addr": "~2.0.7", + "qs": "~6.15.1", + "range-parser": "~1.2.1", + "safe-buffer": "5.2.1", + "send": "~0.19.0", + "serve-static": "~1.16.2", + "setprototypeof": "1.2.0", + "statuses": "~2.0.1", + "type-is": "~1.6.18", + "utils-merge": "1.0.1", + "vary": "~1.1.2" + }, + "engines": { + "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-session": { + "version": "1.19.0", + "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", + "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", + "license": "MIT", + "dependencies": { + "cookie": "~0.7.2", + "cookie-signature": "~1.0.7", + "debug": "~2.6.9", + "depd": "~2.0.0", + "on-headers": "~1.1.0", + "parseurl": "~1.3.3", + "safe-buffer": "~5.2.1", + "uid-safe": "~2.1.5" + }, + "engines": { + "node": ">= 0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/finalhandler": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", + "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "on-finished": "~2.4.1", + "parseurl": "~1.3.3", + "statuses": "~2.0.2", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", + "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz", + "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/merge-descriptors": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/methods": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", + "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", + "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", + "license": "MIT", + "bin": { + "mime": "cli.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==", + "license": "MIT" + }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/on-headers": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz", + "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-to-regexp": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", + "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", + "license": "MIT" + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/random-bytes": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/random-bytes/-/random-bytes-1.0.0.tgz", + "integrity": "sha512-iv7LhNVO047HzYR3InF6pUcUsPQiHTM1Qal51DcGSuZFBil1aBBWG5eHPNek7bvILMaYJ/8RU1e8w1AMdHmLQQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/range-parser": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", + "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/raw-body": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", + "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "license": "MIT", + "dependencies": { + "debug": "2.6.9", + "depd": "2.0.0", + "destroy": "1.2.0", + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "etag": "~1.8.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.1", + "mime": "1.6.0", + "ms": "2.1.3", + "on-finished": "~2.4.1", + "range-parser": "~1.2.1", + "statuses": "~2.0.2" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/send/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/serve-static": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", + "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "license": "MIT", + "dependencies": { + "encodeurl": "~2.0.0", + "escape-html": "~1.0.3", + "parseurl": "~1.3.3", + "send": "~0.19.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/ts-node": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz", + "integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@cspotcode/source-map-support": "^0.8.0", + "@tsconfig/node10": "^1.0.7", + "@tsconfig/node12": "^1.0.7", + "@tsconfig/node14": "^1.0.0", + "@tsconfig/node16": "^1.0.2", + "acorn": "^8.4.1", + "acorn-walk": "^8.1.1", + "arg": "^4.1.0", + "create-require": "^1.1.0", + "diff": "^4.0.1", + "make-error": "^1.1.1", + "v8-compile-cache-lib": "^3.0.1", + "yn": "3.1.1" + }, + "bin": { + "ts-node": "dist/bin.js", + "ts-node-cwd": "dist/bin-cwd.js", + "ts-node-esm": "dist/bin-esm.js", + "ts-node-script": "dist/bin-script.js", + "ts-node-transpile-only": "dist/bin-transpile.js", + "ts-script": "dist/bin-script-deprecated.js" + }, + "peerDependencies": { + "@swc/core": ">=1.2.50", + "@swc/wasm": ">=1.2.50", + "@types/node": "*", + "typescript": ">=2.7" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "@swc/wasm": { + "optional": true + } + } + }, + "node_modules/type-is": { + "version": "1.6.18", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", + "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "peer": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid-safe": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/uid-safe/-/uid-safe-2.1.5.tgz", + "integrity": "sha512-KPHm4VL5dDXKz01UuEd88Df+KzynaohSL9fBh096KWAxSKZQDI2uBrVqtvRM4rwrIrRRKsdLNML/lnaaVSRioA==", + "license": "MIT", + "dependencies": { + "random-bytes": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/utils-merge": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", + "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", + "license": "MIT", + "engines": { + "node": ">= 0.4.0" + } + }, + "node_modules/v8-compile-cache-lib": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz", + "integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==", + "dev": true, + "license": "MIT" + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/yn": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz", + "integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..91531ef --- /dev/null +++ b/package.json @@ -0,0 +1,22 @@ +{ + "name": "roject", + "version": "1.0.0", + "main": "server/index.ts", + "scripts": { + "start": "npm run build && ts-node --project tsconfig.ts-node.json server/index.ts", + "build": "tsc --build tsconfig.client.json" + }, + "dependencies": { + "bcryptjs": "^2.4.3", + "express": "^4.18.2", + "express-session": "^1.17.3" + }, + "devDependencies": { + "@types/bcryptjs": "^2.4.6", + "@types/express": "^4.17.21", + "@types/express-session": "^1.17.10", + "@types/node": "^20.11.0", + "ts-node": "^10.9.2", + "typescript": "^5.3.3" + } +} diff --git a/server/db.ts b/server/db.ts new file mode 100644 index 0000000..ca0480b --- /dev/null +++ b/server/db.ts @@ -0,0 +1,124 @@ +import fs from 'fs'; +import path from 'path'; +import { randomUUID } from 'crypto'; + +const DATA_DIR = path.join(__dirname, '..', 'data'); +if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR); + +export interface User { + id: string; + username: string; + password: string; + created_at: string; +} + +export interface Group { + id: string; + name: string; + created_at: string; +} + +export interface GroupMember { + id: string; + group_id: string; + member_type: 'user' | 'group'; + member_id: string; +} + +export interface Project { + id: string; + name: string; + owner_id: string; + created_at: string; +} + +export interface ProjectMember { + id: string; + project_id: string; + member_type: 'user' | 'group'; + member_id: string; + role: string; +} + +function read(table: string): T[] { + const file = path.join(DATA_DIR, `${table}.json`); + if (!fs.existsSync(file)) return []; + return JSON.parse(fs.readFileSync(file, 'utf8')) as T[]; +} + +function write(table: string, rows: T[]): void { + fs.writeFileSync(path.join(DATA_DIR, `${table}.json`), JSON.stringify(rows, null, 2)); +} + +export const users = { + all: (): User[] => read('users'), + findById: (id: string): User | undefined => read('users').find(u => u.id === id), + findByUsername: (name: string): User | undefined => read('users').find(u => u.username === name), + create(data: Omit): User { + const rows = read('users'); + if (rows.find(u => u.username === data.username)) throw new Error('Taken'); + const user: User = { id: randomUUID(), ...data, created_at: new Date().toISOString() }; + write('users', [...rows, user]); + return user; + }, + delete(id: string): void { + write('users', read('users').filter(u => u.id !== id)); + } +}; + +export const groups = { + all: (): Group[] => read('groups'), + findById: (id: string): Group | undefined => read('groups').find(g => g.id === id), + create(data: Omit): Group { + const rows = read('groups'); + if (rows.find(g => g.name === data.name)) throw new Error('Taken'); + const group: Group = { id: randomUUID(), ...data, created_at: new Date().toISOString() }; + write('groups', [...rows, group]); + return group; + }, + delete(id: string): void { + write('groups', read('groups').filter(g => g.id !== id)); + write('group_members', read('group_members').filter(m => m.group_id !== id)); + } +}; + +export const groupMembers = { + forGroup: (groupId: string): GroupMember[] => read('group_members').filter(m => m.group_id === groupId), + add(data: Omit): GroupMember { + const rows = read('group_members'); + const member: GroupMember = { id: randomUUID(), ...data }; + write('group_members', [...rows, member]); + return member; + }, + remove(id: string): void { + write('group_members', read('group_members').filter(m => m.id !== id)); + } +}; + +export const projects = { + all: (): Project[] => read('projects'), + findById: (id: string): Project | undefined => read('projects').find(p => p.id === id), + create(data: Omit): Project { + const rows = read('projects'); + const project: Project = { id: randomUUID(), ...data, created_at: new Date().toISOString() }; + write('projects', [...rows, project]); + return project; + }, + delete(id: string): void { + write('projects', read('projects').filter(p => p.id !== id)); + write('project_members', read('project_members').filter(m => m.project_id !== id)); + } +}; + +export const projectMembers = { + forProject: (projectId: string): ProjectMember[] => read('project_members').filter(m => m.project_id === projectId), + add(data: Omit): ProjectMember { + const rows = read('project_members'); + const member: ProjectMember = { id: randomUUID(), ...data }; + write('project_members', [...rows, member]); + return member; + }, + remove(id: string): void { + write('project_members', read('project_members').filter(m => m.id !== id)); + } +}; diff --git a/server/index.ts b/server/index.ts new file mode 100644 index 0000000..e7c9eb9 --- /dev/null +++ b/server/index.ts @@ -0,0 +1,43 @@ +import express from 'express'; +import session from 'express-session'; +import path from 'path'; +import { JsonSessionStore } from './sessionStore'; +import authRouter from './routes/auth'; +import groupsRouter from './routes/groups'; +import projectsRouter from './routes/projects'; +import filesRouter from './routes/files'; +import localesRouter from './routes/locales'; +import layoutRouter from './routes/layout'; +import { generateLocales } from './localeGenerator'; + +generateLocales(); + +const app = express(); + +app.use( express.json() ); +app.use( express.text( { type: 'text/plain' } ) ); +app.use( session( +{ + store: new JsonSessionStore( path.join( __dirname, '..', 'storage', 'sessions' ) ), + secret: 'roject-secret-key', + resave: false, + saveUninitialized: false, + cookie: { maxAge: 7 * 24 * 60 * 60 * 1000 } +} ) ); + +app.use( express.static( path.join( __dirname, '..', 'public' ) ) ); + +app.use( '/api/auth', authRouter ); +app.use( '/api/groups', groupsRouter ); +app.use( '/api/projects', projectsRouter ); +app.use( '/api/files', filesRouter ); +app.use( '/api/locales', localesRouter ); +app.use( '/api/layout', layoutRouter ); + +app.get( '/', ( req, res ) => +{ + res.redirect( req.session.userId ? '/dashboard.html' : '/login.html' ); +} ); + +const PORT = process.env.PORT ? Number( process.env.PORT ) : 3000; +app.listen( PORT, () => console.log( `Roject running on http://localhost:${PORT}` ) ); diff --git a/server/localeGenerator.ts b/server/localeGenerator.ts new file mode 100644 index 0000000..2b997f9 --- /dev/null +++ b/server/localeGenerator.ts @@ -0,0 +1,82 @@ +import fs from 'fs'; +import path from 'path'; + +const LOCALES_DIR = path.join( __dirname, '..', 'locales', 'en' ); +const GENERATED_DIR = path.join( __dirname, '..', 'src', 'locales', 'generated' ); + +function toPascalCase( name: string ): string +{ + return name + .split( /[-_]/ ) + .map( part => part.charAt( 0 ).toUpperCase() + part.slice( 1 ) ) + .join( '' ); +} + +function toMemberName( fileName: string ): string +{ + const withUnderscores = fileName.replace( /\./g, '_' ); + return withUnderscores.replace( /-([a-zA-Z])/g, ( _, c: string ) => c.toUpperCase() ); +} + +function generateDir( absLocaleDir: string, enRoot: string, absOutDir: string, className: string ): void +{ + const files: { memberName: string; relativePath: string }[] = []; + const dirs: { dirName: string; childClassName: string }[] = []; + + for ( const entry of fs.readdirSync( absLocaleDir ) ) + { + if ( entry.endsWith( '.md' ) ) continue; + + const abs = path.join( absLocaleDir, entry ); + + if ( fs.statSync( abs ).isDirectory() ) + { + const childClassName = toPascalCase( entry ); + dirs.push( { dirName: entry, childClassName } ); + generateDir( abs, enRoot, path.join( absOutDir, entry ), childClassName ); + } + else + { + const rel = path.relative( enRoot, abs ).replace( /\\/g, '/' ); + files.push( { memberName: toMemberName( entry ), relativePath: rel } ); + } + } + + const lines: string[] = []; + lines.push( '// Auto-generated by localeGenerator — do not edit manually.' ); + + if ( dirs.length > 0 ) + { + lines.push( '' ); + + for ( const { dirName, childClassName } of dirs ) + { + lines.push( `import { ${childClassName} } from './${dirName}/${childClassName}.js';` ); + } + } + + lines.push( '' ); + lines.push( `export class ${className}` ); + lines.push( '{' ); + + for ( const { memberName, relativePath } of files ) + { + lines.push( ` static readonly ${memberName} = '${relativePath}';` ); + } + + for ( const { childClassName } of dirs ) + { + lines.push( ` static readonly ${childClassName} = ${childClassName};` ); + } + + lines.push( '}' ); + + fs.mkdirSync( absOutDir, { recursive: true } ); + fs.writeFileSync( path.join( absOutDir, `${className}.ts` ), lines.join( '\n' ) + '\n', 'utf8' ); +} + +export function generateLocales(): void +{ + if ( !fs.existsSync( LOCALES_DIR ) ) return; + generateDir( LOCALES_DIR, LOCALES_DIR, GENERATED_DIR, 'Locales' ); +} diff --git a/server/middleware/auth.ts b/server/middleware/auth.ts new file mode 100644 index 0000000..478ec84 --- /dev/null +++ b/server/middleware/auth.ts @@ -0,0 +1,16 @@ +import { Request, Response, NextFunction } from 'express'; + +declare module 'express-session' { + interface SessionData { + userId?: string; + username?: string; + } +} + +export function requireAuth(req: Request, res: Response, next: NextFunction): void { + if (!req.session.userId) { + res.status(401).json({ error: 'Not authenticated' }); + return; + } + next(); +} diff --git a/server/routes/auth.ts b/server/routes/auth.ts new file mode 100644 index 0000000..1a56350 --- /dev/null +++ b/server/routes/auth.ts @@ -0,0 +1,47 @@ +import { Router } from 'express'; +import bcrypt from 'bcryptjs'; +import { users } from '../db'; +import { requireAuth } from '../middleware/auth'; + +const router = Router(); + +router.post('/register', async (req, res) => { + const { username, password } = req.body as { username?: string; password?: string }; + if (!username || !password) { res.status(400).json({ error: 'Username and password required' }); return; } + try { + const hash = await bcrypt.hash(password, 10); + const user = users.create({ username, password: hash }); + req.session.userId = user.id; + req.session.username = user.username; + res.json({ id: user.id, username: user.username }); + } catch { + res.status(409).json({ error: 'Username already taken' }); + } +}); + +router.post('/login', async (req, res) => { + const { username, password } = req.body as { username?: string; password?: string }; + const user = username ? users.findByUsername(username) : undefined; + if (!user || !password || !(await bcrypt.compare(password, user.password))) { + res.status(401).json({ error: 'Invalid credentials' }); + return; + } + req.session.userId = user.id; + req.session.username = user.username; + res.json({ id: user.id, username: user.username }); +}); + +router.post('/logout', (req, res) => { + req.session.destroy(() => res.json({ ok: true })); +}); + +router.delete('/me', requireAuth, (req, res) => { + users.delete(req.session.userId!); + req.session.destroy(() => res.json({ ok: true })); +}); + +router.get('/me', requireAuth, (req, res) => { + res.json({ id: req.session.userId, username: req.session.username }); +}); + +export default router; diff --git a/server/routes/files.ts b/server/routes/files.ts new file mode 100644 index 0000000..d6ed0db --- /dev/null +++ b/server/routes/files.ts @@ -0,0 +1,59 @@ +import { Router } from 'express'; +import { getFileTree, readProjectFile, writeProjectFile, createProjectFile, createProjectDirectory, renameProjectEntry, deleteProjectEntry } from '../storage'; +import { requireAuth } from '../middleware/auth'; + +const router = Router(); +router.use(requireAuth); + +router.get('/:projectId/tree', (req, res) => { + res.json(getFileTree(req.params.projectId)); +}); + +router.get('/:projectId/*', (req, res) => { + const filePath = (req.params as Record)[0]; + const content = readProjectFile(req.params.projectId, filePath); + if (content === null) { res.status(404).json({ error: 'Not found' }); return; } + res.type('text/plain').send(content); +}); + +router.post('/:projectId/create-file', (req, res) => { + const { path: filePath } = req.body as { path: string }; + if (!filePath) { res.status(400).json({ error: 'path required' }); return; } + const ok = createProjectFile(req.params.projectId, filePath); + if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; } + res.json({ ok: true }); +}); + +router.post('/:projectId/create-directory', (req, res) => { + const { path: dirPath } = req.body as { path: string }; + if (!dirPath) { res.status(400).json({ error: 'path required' }); return; } + const ok = createProjectDirectory(req.params.projectId, dirPath); + if (!ok) { res.status(409).json({ error: 'Already exists or invalid path' }); return; } + res.json({ ok: true }); +}); + +router.post( '/:projectId/rename', ( req, res ) => { + const { path: oldPath, newName } = req.body as { path: string; newName: string }; + if ( !oldPath || !newName ) { res.status( 400 ).json( { error: 'path and newName required' } ); return; } + const ok = renameProjectEntry( req.params.projectId, oldPath, newName ); + if ( !ok ) { res.status( 409 ).json( { error: 'Rename failed' } ); return; } + res.json( { ok: true } ); +} ); + +router.post( '/:projectId/delete', ( req, res ) => { + const { path: targetPath } = req.body as { path: string }; + if ( !targetPath ) { res.status( 400 ).json( { error: 'path required' } ); return; } + const ok = deleteProjectEntry( req.params.projectId, targetPath ); + if ( !ok ) { res.status( 409 ).json( { error: 'Delete failed' } ); return; } + res.json( { ok: true } ); +} ); + +router.put('/:projectId/*', (req, res) => { + const filePath = (req.params as Record)[0]; + if (typeof req.body !== 'string') { res.status(400).json({ error: 'Content must be text' }); return; } + const ok = writeProjectFile(req.params.projectId, filePath, req.body); + if (!ok) { res.status(403).json({ error: 'Invalid path' }); return; } + res.json({ ok: true }); +}); + +export default router; diff --git a/server/routes/groups.ts b/server/routes/groups.ts new file mode 100644 index 0000000..f5acb6c --- /dev/null +++ b/server/routes/groups.ts @@ -0,0 +1,40 @@ +import { Router } from 'express'; +import { groups, groupMembers } from '../db'; +import { requireAuth } from '../middleware/auth'; + +const router = Router(); +router.use(requireAuth); + +router.get('/', (_req, res) => res.json(groups.all())); + +router.post('/', (req, res) => { + const { name } = req.body as { name?: string }; + if (!name) { res.status(400).json({ error: 'Name required' }); return; } + try { + res.json(groups.create({ name })); + } catch { + res.status(409).json({ error: 'Group name already taken' }); + } +}); + +router.delete('/:id', (req, res) => { + groups.delete(req.params.id); + res.json({ ok: true }); +}); + +router.get('/:id/members', (req, res) => { + res.json(groupMembers.forGroup(req.params.id)); +}); + +router.post('/:id/members', (req, res) => { + const { member_type, member_id } = req.body as { member_type?: 'user' | 'group'; member_id?: string }; + if (!member_type || !member_id) { res.status(400).json({ error: 'member_type and member_id required' }); return; } + res.json(groupMembers.add({ group_id: req.params.id, member_type, member_id })); +}); + +router.delete('/:id/members/:memberId', (req, res) => { + groupMembers.remove(req.params.memberId); + res.json({ ok: true }); +}); + +export default router; diff --git a/server/routes/layout.ts b/server/routes/layout.ts new file mode 100644 index 0000000..d008c24 --- /dev/null +++ b/server/routes/layout.ts @@ -0,0 +1,44 @@ +import { Router } from 'express'; +import fs from 'fs'; +import path from 'path'; +import { requireAuth } from '../middleware/auth'; +import { RJLog } from '../../src/library-ts/node/log/RJLog'; + +const router = Router(); +router.use( requireAuth ); + +const LAYOUTS_DIR = path.join( __dirname, '..', '..', 'storage', 'layouts' ); + +function layoutFilePath( userId: string, deviceId: string ): string +{ + RJLog.log( { userId, deviceId } ); + const safe = ( s: string ) => s.replace( /[^a-zA-Z0-9_-]/g, '_' ); + const dir = path.join( LAYOUTS_DIR, safe( userId ) ); + if ( !fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } ); + return path.join( dir, safe( deviceId ) + '.json' ); +} + +router.get( '/', ( req, res ) => +{ + const deviceId = req.query.deviceId as string; + if ( !deviceId ) { res.json( null ); return; } + const fp = layoutFilePath( req.session.userId!, deviceId ); + if ( !fs.existsSync( fp ) ) { res.json( null ); return; } + try { res.json( JSON.parse( fs.readFileSync( fp, 'utf8' ) ) ); } + catch { res.json( null ); } +} ); + +router.put( '/', ( req, res ) => +{ + const deviceId = req.query.deviceId as string; + if ( !deviceId ) { res.status( 400 ).json( { error: 'Missing deviceId' } ); return; } + const fp = layoutFilePath( req.session.userId!, deviceId ); + try + { + fs.writeFileSync( fp, JSON.stringify( req.body ), 'utf8' ); + res.json( { ok: true } ); + } + catch ( e ) { res.status( 500 ).json( { error: String( e ) } ); } +} ); + +export default router; diff --git a/server/routes/locales.ts b/server/routes/locales.ts new file mode 100644 index 0000000..3737b46 --- /dev/null +++ b/server/routes/locales.ts @@ -0,0 +1,26 @@ +import { Router } from 'express'; +import fs from 'fs'; +import path from 'path'; + +const router = Router(); +const LOCALES_DIR = path.join(__dirname, '..', '..', 'locales'); + +router.get('/:locale/*', (req, res) => { + const locale = req.params.locale; + const filePath = (req.params as Record)[0]; + const abs = path.resolve(path.join(LOCALES_DIR, locale, filePath)); + if (!abs.startsWith(path.resolve(LOCALES_DIR) + path.sep)) { + res.status(403).json({ error: 'Forbidden' }); + return; + } + if (!fs.existsSync(abs)) { + res.status(404).json({ error: 'Not found' }); + return; + } + const ext = path.extname(abs).slice(1); + if (ext === 'html') res.type('text/html').send(fs.readFileSync(abs, 'utf8')); + else if (ext === 'json') res.json(JSON.parse(fs.readFileSync(abs, 'utf8'))); + else res.type('text/plain').send(fs.readFileSync(abs, 'utf8')); +}); + +export default router; diff --git a/server/routes/projects.ts b/server/routes/projects.ts new file mode 100644 index 0000000..9732282 --- /dev/null +++ b/server/routes/projects.ts @@ -0,0 +1,42 @@ +import { Router } from 'express'; +import { projects, projectMembers } from '../db'; +import { requireAuth } from '../middleware/auth'; +import { createProjectStorage } from '../storage'; +import { RJLog } from '../../src/library-ts/node/log/RJLog'; + +const router = Router(); +router.use(requireAuth); + +router.get('/', (_req, res) => res.json(projects.all())); + +router.post('/', (req, res) => { + const { name } = req.body as { name?: string }; + if (!name) { res.status(400).json({ error: 'Name required' }); return; } + const project = projects.create({ name, owner_id: req.session.userId! }); + createProjectStorage(project.id); + res.json(project); +}); + +router.delete('/:id', (req, res) => { + + RJLog.log( "Deleting:", req.params.id ); + projects.delete(req.params.id); + res.json({ ok: true }); +}); + +router.get('/:id/members', (req, res) => { + res.json(projectMembers.forProject(req.params.id)); +}); + +router.post('/:id/members', (req, res) => { + const { member_type, member_id, role } = req.body as { member_type?: 'user' | 'group'; member_id?: string; role?: string }; + if (!member_type || !member_id) { res.status(400).json({ error: 'member_type and member_id required' }); return; } + res.json(projectMembers.add({ project_id: req.params.id, member_type, member_id, role: role ?? 'viewer' })); +}); + +router.delete('/:id/members/:memberId', (req, res) => { + projectMembers.remove(req.params.memberId); + res.json({ ok: true }); +}); + +export default router; diff --git a/server/sessionStore.ts b/server/sessionStore.ts new file mode 100644 index 0000000..98fcf0e --- /dev/null +++ b/server/sessionStore.ts @@ -0,0 +1,66 @@ +import session from 'express-session'; +import fs from 'fs'; +import path from 'path'; + +interface SessionFile +{ + session: session.SessionData; + expires: number; +} + +export class JsonSessionStore extends session.Store +{ + _dir: string; + + constructor( dir: string ) + { + super(); + this._dir = dir; + if ( ! fs.existsSync( dir ) ) fs.mkdirSync( dir, { recursive: true } ); + } + + _filePath( sid: string ): string + { + const safe = sid.replace( /[^a-zA-Z0-9_-]/g, '_' ); + return path.join( this._dir, safe + '.json' ); + } + + get( sid: string, callback: ( err: any, session?: session.SessionData ) => void ): void + { + try + { + const fp = this._filePath( sid ); + if ( ! fs.existsSync( fp ) ) { callback( null, null ); return; } + const data: SessionFile = JSON.parse( fs.readFileSync( fp, 'utf8' ) ); + if ( Date.now() > data.expires ) { fs.unlinkSync( fp ); callback( null, null ); return; } + callback( null, data.session ); + } + catch ( e ) { callback( e ); } + } + + set( sid: string, sessionData: session.SessionData, callback?: ( err?: any ) => void ): void + { + try + { + const fp = this._filePath( sid ); + const expires = sessionData.cookie?.expires + ? new Date( sessionData.cookie.expires ).getTime() + : Date.now() + 7 * 24 * 60 * 60 * 1000; + const file: SessionFile = { session: sessionData, expires }; + fs.writeFileSync( fp, JSON.stringify( file ), 'utf8' ); + callback?.(); + } + catch ( e ) { callback?.( e ); } + } + + destroy( sid: string, callback?: ( err?: any ) => void ): void + { + try + { + const fp = this._filePath( sid ); + if ( fs.existsSync( fp ) ) fs.unlinkSync( fp ); + callback?.(); + } + catch ( e ) { callback?.( e ); } + } +} diff --git a/server/storage.ts b/server/storage.ts new file mode 100644 index 0000000..bb15f26 --- /dev/null +++ b/server/storage.ts @@ -0,0 +1,105 @@ +import fs from 'fs'; +import path from 'path'; + +const STORAGE_DIR = path.join(__dirname, '..', 'storage'); +if (!fs.existsSync(STORAGE_DIR)) fs.mkdirSync(STORAGE_DIR); + +const INITIAL_HTML = ` + +New Page +

Hello World!

+`; + +export interface FileNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileNode[]; +} + +export function createProjectStorage(projectId: string): void { + const rootDir = path.join(STORAGE_DIR, projectId, 'root'); + fs.mkdirSync(rootDir, { recursive: true }); + fs.writeFileSync(path.join(rootDir, 'index.html'), INITIAL_HTML); +} + +function buildTree(absDir: string, rootDir: string): FileNode[] { + return fs.readdirSync(absDir).map(name => { + const abs = path.join(absDir, name); + const rel = path.relative(rootDir, abs).replace(/\\/g, '/'); + if (fs.statSync(abs).isDirectory()) { + return { name, path: rel, type: 'directory' as const, children: buildTree(abs, rootDir) }; + } + return { name, path: rel, type: 'file' as const }; + }); +} + +export function getFileTree(projectId: string): FileNode[] { + const rootDir = path.join(STORAGE_DIR, projectId, 'root'); + if (!fs.existsSync(rootDir)) return []; + return buildTree(rootDir, rootDir); +} + +function safeResolve(projectId: string, filePath: string): string | null { + const rootDir = path.resolve(path.join(STORAGE_DIR, projectId, 'root')); + const full = path.resolve(path.join(rootDir, filePath)); + if (!full.startsWith(rootDir + path.sep) && full !== rootDir) return null; + return full; +} + +export function readProjectFile(projectId: string, filePath: string): string | null { + const full = safeResolve(projectId, filePath); + if (!full || !fs.existsSync(full)) return null; + return fs.readFileSync(full, 'utf8'); +} + +export function writeProjectFile(projectId: string, filePath: string, content: string): boolean { + const full = safeResolve(projectId, filePath); + if (!full) return false; + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, content, 'utf8'); + return true; +} + +export function createProjectFile(projectId: string, filePath: string): boolean { + const full = safeResolve(projectId, filePath); + if (!full || fs.existsSync(full)) return false; + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, '', 'utf8'); + return true; +} + +export function createProjectDirectory(projectId: string, dirPath: string): boolean { + const full = safeResolve(projectId, dirPath); + if (!full || fs.existsSync(full)) return false; + fs.mkdirSync(full, { recursive: true }); + return true; +} + +export function projectPathExists(projectId: string, filePath: string): boolean { + const full = safeResolve(projectId, filePath); + return !!full && fs.existsSync(full); +} + +export function renameProjectEntry( projectId: string, oldPath: string, newName: string ): boolean +{ + if ( !oldPath || !newName ) return false; + if ( newName.includes( '/' ) || newName.includes( '\\' ) ) return false; + const full = safeResolve( projectId, oldPath ); + if ( !full || !fs.existsSync( full ) ) return false; + const rootDir = path.resolve( path.join( STORAGE_DIR, projectId, 'root' ) ); + const newFull = path.join( path.dirname( full ), newName ); + if ( !newFull.startsWith( rootDir + path.sep ) ) return false; + if ( fs.existsSync( newFull ) ) return false; + fs.renameSync( full, newFull ); + return true; +} + +export function deleteProjectEntry( projectId: string, targetPath: string ): boolean +{ + if ( !targetPath ) return false; + const full = safeResolve( projectId, targetPath ); + if ( !full || !fs.existsSync( full ) ) return false; + fs.rmSync( full, { recursive: true, force: true } ); + return true; +} diff --git a/src/commands/Command.ts b/src/commands/Command.ts new file mode 100644 index 0000000..549a09b --- /dev/null +++ b/src/commands/Command.ts @@ -0,0 +1,34 @@ +import { LocaleManager } from "../locales/LocaleManager"; +import { Locales } from "../locales/Locales"; +import { CommandAssignment } from "./CommandAssignments"; +import { CommandContext } from "./CommandContext"; +import { CommandManager } from "./CommandManager"; + + +export abstract class Command +{ + _titleLocalePath: string = ""; + _infoLocalePath: string = ""; + + assignments: CommandAssignment[] = []; + + constructor( title: string, info: string) + { + this._titleLocalePath = title; + this._infoLocalePath = info; + } + + getLocalizedTitle():Promise + { + return LocaleManager.$().get( this._titleLocalePath ); + } + + getLocalizedInfo():Promise + { + return LocaleManager.$().get( this._infoLocalePath ); + } + + abstract execute( context: CommandContext ): Promise + + +} diff --git a/src/commands/CommandAssignments.ts b/src/commands/CommandAssignments.ts new file mode 100644 index 0000000..d6be156 --- /dev/null +++ b/src/commands/CommandAssignments.ts @@ -0,0 +1,5 @@ +export class CommandAssignment +{ + inputs:string[] = []; + conditions:string[] = []; +} \ No newline at end of file diff --git a/src/commands/CommandContext.ts b/src/commands/CommandContext.ts new file mode 100644 index 0000000..c5cf260 --- /dev/null +++ b/src/commands/CommandContext.ts @@ -0,0 +1,5 @@ +export class CommandContext +{ + depth: number; + path: string[]; +} \ No newline at end of file diff --git a/src/commands/CommandManager.ts b/src/commands/CommandManager.ts new file mode 100644 index 0000000..0baf550 --- /dev/null +++ b/src/commands/CommandManager.ts @@ -0,0 +1,18 @@ +import { CommandContext } from "./CommandContext"; +import { FileTreeCommands } from "./file-tree-commands/FileTreeCommands"; + +export class CommandManager +{ + static fileTreeCommands = new FileTreeCommands(); + + + static createContext(): CommandContext + { + return { depth: 0, path: [] }; + } + + static childContext( parent: CommandContext, label: string): CommandContext + { + return { depth: parent.depth + 1, path: [...parent.path, label] }; + } +} \ No newline at end of file diff --git a/src/commands/file-tree-commands/AddFile.ts b/src/commands/file-tree-commands/AddFile.ts new file mode 100644 index 0000000..bbe41ed --- /dev/null +++ b/src/commands/file-tree-commands/AddFile.ts @@ -0,0 +1,17 @@ +import { Command } from "../Command"; +import { CommandContext } from "../CommandContext"; + +export class AddFileCommand extends Command +{ + constructor(){ super( "", "" ); } + + async _doAction( context: CommandContext ): Promise + { + + } + + async _undoAction( context: CommandContext ): Promise + { + + } +} \ No newline at end of file diff --git a/src/commands/file-tree-commands/FileTreeCommands.ts b/src/commands/file-tree-commands/FileTreeCommands.ts new file mode 100644 index 0000000..428555e --- /dev/null +++ b/src/commands/file-tree-commands/FileTreeCommands.ts @@ -0,0 +1,4 @@ +export class FileTreeCommands +{ + +} \ No newline at end of file diff --git a/src/components/app-nav/app-nav.ts b/src/components/app-nav/app-nav.ts new file mode 100644 index 0000000..5d9990e --- /dev/null +++ b/src/components/app-nav/app-nav.ts @@ -0,0 +1,40 @@ +interface AuthUser { + id: number; + username: string; +} + +class AppNav extends HTMLElement { + async connectedCallback(): Promise { + const res = await fetch('/api/auth/me'); + if (!res.ok) { location.href = '/login.html'; return; } + const user = await res.json() as AuthUser; + + this.innerHTML = ` + + `; + + this.querySelector('.btn-logout')!.addEventListener('click', async () => { + await fetch('/api/auth/logout', { method: 'POST' }); + location.href = '/login.html'; + }); + + this.querySelector('.btn-delete')!.addEventListener('click', async () => { + if (!confirm('Delete your account? This cannot be undone.')) return; + await fetch('/api/auth/me', { method: 'DELETE' }); + location.href = '/login.html'; + }); + } +} +customElements.define('app-nav', AppNav); diff --git a/src/components/confirm-dialog/confirm-dialog.ts b/src/components/confirm-dialog/confirm-dialog.ts new file mode 100644 index 0000000..44095e7 --- /dev/null +++ b/src/components/confirm-dialog/confirm-dialog.ts @@ -0,0 +1,84 @@ +export interface ConfirmDialogOptions { + icon?: string; + title: string; + message: string; + confirmLabel?: string; + cancelLabel?: string; + danger?: boolean; +} + +class ConfirmDialog extends HTMLElement { + private resolver: ((value: boolean) => void) | null = null; + + connectedCallback(): void { + this.style.display = 'none'; + this.innerHTML = ` +
+ + `; + + this.querySelector('.cd-backdrop')!.addEventListener('click', () => this.close(false)); + this.querySelector('.cd-close')!.addEventListener('click', () => this.close(false)); + document.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Escape' && this.style.display !== 'none') this.close(false); + }); + } + + show(options: ConfirmDialogOptions): Promise { + + console.log( "Show dialog" ); + + this.querySelector('.cd-icon')!.textContent = options.icon ?? ''; + this.querySelector('.cd-title')!.textContent = options.title; + this.querySelector('.cd-message')!.textContent = options.message; + + const footer = this.querySelector('.cd-footer')!; + footer.innerHTML = ''; + + if (options.cancelLabel) { + const cancel = document.createElement('button'); + cancel.className = 'cd-btn cd-btn-cancel'; + cancel.textContent = options.cancelLabel; + cancel.addEventListener('click', () => this.close(false)); + footer.appendChild(cancel); + } + + const confirm = document.createElement('button'); + confirm.className = `cd-btn cd-btn-confirm${options.danger ? ' cd-btn-danger' : ''}`; + confirm.textContent = options.confirmLabel ?? 'OK'; + confirm.addEventListener('click', () => this.close(true)); + footer.appendChild(confirm); + + this.style.display = ''; + confirm.focus(); + + return new Promise(resolve => { this.resolver = resolve; }); + } + + private close(value: boolean): void { + this.style.display = 'none'; + console.log( "Closing:", value ); + if (this.resolver) { this.resolver(value); this.resolver = null; } + } +} + +customElements.define('confirm-dialog', ConfirmDialog); + +export function showConfirmDialog(options: ConfirmDialogOptions): Promise { + let dialog = document.querySelector('confirm-dialog') as ConfirmDialog | null; + if (!dialog) { + dialog = document.createElement('confirm-dialog') as ConfirmDialog; + document.body.appendChild(dialog); + } + return dialog.show(options); +} diff --git a/src/components/context-menu/context-menu.ts b/src/components/context-menu/context-menu.ts new file mode 100644 index 0000000..5cb6777 --- /dev/null +++ b/src/components/context-menu/context-menu.ts @@ -0,0 +1,202 @@ +export abstract class ContextMenuItem +{ + readonly parent: ContextMenuDirectory | null; + + constructor( parent: ContextMenuDirectory | null ) + { + this.parent = parent; + } + + abstract createEl(): HTMLElement; +} + +export class ContextMenuSeparator extends ContextMenuItem +{ + createEl(): HTMLElement + { + const el = document.createElement( 'div' ); + el.className = 'ctx-separator'; + return el; + } +} + +export class ContextMenuReadOnlyEntry extends ContextMenuItem +{ + label: string; + + constructor( parent: ContextMenuDirectory | null, label: string ) + { + super( parent ); + this.label = label; + } + + createEl(): HTMLElement + { + const el = document.createElement( 'div' ); + el.className = 'ctx-readonly'; + el.textContent = this.label; + return el; + } +} + +export class ContextMenuEntry extends ContextMenuItem +{ + label: string; + _action: () => void; + noClose: boolean; + + constructor( parent: ContextMenuDirectory | null, label: string, action: () => void, noClose = false ) + { + super( parent ); + this.label = label; + this._action = action; + this.noClose = noClose; + } + + createEl(): HTMLElement + { + const el = document.createElement( 'div' ); + el.className = 'ctx-entry'; + el.textContent = this.label; + el.addEventListener( 'click', ( e ) => + { + e.stopPropagation(); + this._action(); + + if ( ! this.noClose ) + { + let dir: ContextMenuDirectory = this.parent; + while ( dir.parent ) dir = dir.parent; + dir.close(); + } + } ); + return el; + } +} + +export class ContextMenuDirectory extends ContextMenuItem +{ + label: string | null; + items: ContextMenuItem[] = []; + _menuEl: HTMLElement = null; + _closeTimer: number = 0; + + constructor( parent: ContextMenuDirectory | null, label: string | null = null ) + { + super( parent ); + this.label = label; + } + + add( item: ContextMenuItem ): this + { + this.items.push( item ); + return this; + } + + show( x: number, y: number ): void + { + this.clearCloseUpwards(); + + if ( this._menuEl ) + { + return; + } + + const menu = document.createElement( 'div' ); + menu.className = 'ctx-menu'; + menu.style.cssText = 'position:fixed;visibility:hidden'; + + for ( const item of this.items ) + { + menu.appendChild( item.createEl() ); + } + + document.body.appendChild( menu ); + this._menuEl = menu; + + const w = menu.offsetWidth; + const h = menu.offsetHeight; + const vw = window.innerWidth; + const vh = window.innerHeight; + + const left = ( x + w > vw && x - w >= 0 ) ? x - w : x; + const top = ( y + h > vh && y - h >= 0 ) ? y - h : y; + + menu.style.left = `${left}px`; + menu.style.top = `${top}px`; + menu.style.visibility = ''; + + menu.addEventListener( 'mouseenter', () => this.clearCloseUpwards() ); + menu.addEventListener( 'mouseleave', () => this.scheduleClose() ); + + if ( ! this.parent ) + { + const onOutside = ( e: MouseEvent ) => + { + if ( ! menu.contains( e.target as Node ) ) + { + this.close(); + document.removeEventListener( 'click', onOutside, { capture: true } ); + } + }; + setTimeout( () => document.addEventListener( 'click', onOutside, { capture: true } ), 0 ); + } + } + + close(): void + { + for ( const item of this.items ) + { + if ( item instanceof ContextMenuDirectory ) item.close(); + } + + this._menuEl?.remove(); + this._menuEl = null; + } + + scheduleClose(): void + { + this._closeTimer = window.setTimeout( () => this.close(), 150 ); + } + + clearClose(): void + { + clearTimeout( this._closeTimer ); + } + + clearCloseUpwards(): void + { + let dir: ContextMenuDirectory = this; + + while ( dir ) + { + dir.clearClose(); + dir = dir.parent; + } + } + + createEl(): HTMLElement + { + const el = document.createElement( 'div' ); + el.className = 'ctx-entry ctx-entry-dir'; + el.innerHTML = `${this.label ?? ''}`; + + el.addEventListener( 'mouseenter', () => + { + if ( this.parent ) + { + for ( const item of this.parent.items ) + { + if ( item instanceof ContextMenuDirectory && item !== this ) item.close(); + } + } + + const rect = el.getBoundingClientRect(); + this.show( rect.right, rect.top ); + } ); + + el.addEventListener( 'mouseleave', () => this.scheduleClose() ); + + return el; + } +} diff --git a/src/components/editor-shell/editor-shell.ts b/src/components/editor-shell/editor-shell.ts new file mode 100644 index 0000000..6d08ed2 --- /dev/null +++ b/src/components/editor-shell/editor-shell.ts @@ -0,0 +1,320 @@ +import { Editor } from '../../editor/Editor.js'; + +// ── Layout helpers ──────────────────────────────────────────────────────────── + +let tcCounter = 0; +function nextTcId(): string { return `tc-${++tcCounter}`; } + +function makeResizeHandle(direction: 'v' | 'h'): HTMLElement { + const h = document.createElement('div'); + h.className = direction === 'v' ? 'es-v-handle' : 'es-h-handle'; + h.addEventListener('pointerdown', (e: PointerEvent) => { + e.preventDefault(); + h.setPointerCapture(e.pointerId); + const prev = h.previousElementSibling as HTMLElement | null; + const next = h.nextElementSibling as HTMLElement | null; + if (!prev || !next) return; + const startPos = direction === 'v' ? e.clientX : e.clientY; + const startPrev = direction === 'v' ? prev.offsetWidth : prev.offsetHeight; + const startNext = direction === 'v' ? next.offsetWidth : next.offsetHeight; + const total = startPrev + startNext; + + const onMove = (ev: PointerEvent) => { + const delta = (direction === 'v' ? ev.clientX : ev.clientY) - startPos; + const newPrev = Math.max(60, Math.min(total - 60, startPrev + delta)); + prev.style.flexBasis = `${newPrev}px`; + next.style.flexBasis = `${total - newPrev}px`; + prev.style.flex = `0 0 ${newPrev}px`; + next.style.flex = `0 0 ${total - newPrev}px`; + }; + h.addEventListener('pointermove', onMove); + h.addEventListener('pointerup', () => h.removeEventListener('pointermove', onMove), { once: true }); + }); + return h; +} + +function makeSection(): HTMLElement { + const sec = document.createElement('div'); + sec.className = 'es-section'; + const tc = document.createElement('tab-container') as HTMLElement; + tc.id = nextTcId(); + sec.appendChild(tc); + return sec; +} + +function makePanelInner(): HTMLElement { + const inner = document.createElement('div'); + inner.className = 'es-sections'; + const sec = makeSection(); + inner.appendChild(sec); + return inner; +} + +// ── EditorShell ─────────────────────────────────────────────────────────────── + +class EditorShell extends HTMLElement { + private activePortraitPanel: string = 'center'; + private _deviceId: string = ''; + private _saveTimer: ReturnType | null = null; + + async connectedCallback(): Promise { + const params = new URLSearchParams(location.search); + const projectId = params.get('project') ?? ''; + const projectName = params.get('name') ?? 'Project'; + Editor.get().projectId = projectId; + Editor.get().projectName = projectName; + + this.innerHTML = ` +
+ + ${projectName} +
+ + + +
+
+
+
${makePanelInner().outerHTML}
+
+
${makePanelInner().outerHTML}
+
+
${makePanelInner().outerHTML}
+
+ `; + + this.setupMainHandles(); + this.setupPortrait(); + this.setupSplitListener(); + this.setupResizeHandler(); + + await Promise.all( [ + customElements.whenDefined( 'tab-container' ), + customElements.whenDefined( 'file-tree-panel' ), + customElements.whenDefined( 'html-editor-panel' ), + this._loadLayout(), + ] ); + + this.initDefaultLayout(); + } + + private initDefaultLayout(): void { + const leftTc = this.querySelector('[data-panel="left"] tab-container') as any; + const centerTc = this.querySelector('[data-panel="center"] tab-container') as any; + + leftTc?.addTab({ id: 'file-tree', label: 'Files', panelType: 'file-tree' }, () => { + return document.createElement('file-tree-panel'); + }); + + centerTc?.addTab({ id: 'html-editor', label: 'Editor', panelType: 'html-editor' }, () => { + return document.createElement('html-editor-panel'); + }); + + Editor.get().openDocument('index.html'); + } + + private setupMainHandles(): void { + const workspace = this.querySelector('.es-workspace')!; + workspace.querySelectorAll(':scope > .es-v-handle').forEach(h => { + const handle = h as HTMLElement; + handle.addEventListener('pointerdown', (e: PointerEvent) => { + e.preventDefault(); + handle.setPointerCapture(e.pointerId); + const prev = handle.previousElementSibling as HTMLElement; + const next = handle.nextElementSibling as HTMLElement; + const start = e.clientX; + const startPrev = prev.offsetWidth; + const startNext = next.offsetWidth; + const total = startPrev + startNext; + + const onMove = (ev: PointerEvent) => { + const delta = ev.clientX - start; + const np = Math.max(80, Math.min(total - 80, startPrev + delta)); + prev.style.flex = `0 0 ${np}px`; + next.style.flex = `0 0 ${total - np}px`; + }; + handle.addEventListener('pointermove', onMove); + handle.addEventListener('pointerup', () => { + handle.removeEventListener('pointermove', onMove); + this._scheduleLayoutSave(); + }, { once: true }); + }); + }); + + this.querySelectorAll('.es-sections').forEach(sections => { + this.observeNewHandles(sections as HTMLElement); + }); + + } + + private setupSplitListener(): void { + this.addEventListener('tab-container:split', (e: Event) => { + const { containerId } = (e as CustomEvent).detail as { containerId: string }; + const tc = document.getElementById(containerId); + if (!tc) return; + const section = tc.closest('.es-section') as HTMLElement | null; + const sections = tc.closest('.es-sections') as HTMLElement | null; + if (!section || !sections) return; + const newSec = makeSection(); + const handle = makeResizeHandle('v'); + sections.insertBefore(handle, section.nextSibling); + sections.insertBefore(newSec, handle.nextSibling); + }); + + this.addEventListener('tab-container:add-panel', (e: Event) => { + const { containerId, panelType, tag, label } = (e as CustomEvent).detail as { containerId: string; panelType: string; tag: string; label: string }; + const tc = document.getElementById(containerId) as any; + if (!tc) return; + const id = panelType + '-' + Math.random().toString(36).slice(2); + tc.addTab({ id, label: label ?? panelType, panelType }, () => document.createElement(tag)); + }); + } + + private _getDeviceId(): string + { + if ( this._deviceId ) return this._deviceId; + let id = localStorage.getItem( 'roject:deviceId' ); + if ( !id ) + { + id = Math.random().toString( 36 ).slice( 2 ) + Math.random().toString( 36 ).slice( 2 ); + localStorage.setItem( 'roject:deviceId', id ); + } + this._deviceId = id; + return id; + } + + private async _loadLayout(): Promise + { + try + { + const res = await fetch( `/api/layout?deviceId=${this._getDeviceId()}` ); + if ( !res.ok ) return; + const layout = await res.json(); + if ( !layout ) return; + + if ( layout.panels ) + { + const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; + for ( const [ panel, flex ] of Object.entries( layout.panels ) ) + { + if ( flex ) + { + const el = workspace.querySelector( `[data-panel="${panel}"]` ) as HTMLElement; + if ( el ) el.style.flex = flex as string; + } + } + } + + if ( layout.activePortraitPanel ) + { + this.activePortraitPanel = layout.activePortraitPanel; + } + } + catch {} + } + + private _scheduleLayoutSave(): void + { + if ( this._saveTimer ) clearTimeout( this._saveTimer ); + this._saveTimer = setTimeout( () => this._saveLayout(), 800 ); + } + + private async _saveLayout(): Promise + { + const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; + const panels: Record = {}; + for ( const p of [ 'left', 'center', 'right' ] ) + { + const el = workspace.querySelector( `[data-panel="${p}"]` ) as HTMLElement; + panels[ p ] = el?.style.flex || null; + } + const layout = { panels, activePortraitPanel: this.activePortraitPanel }; + try + { + await fetch( `/api/layout?deviceId=${this._getDeviceId()}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( layout ), + } ); + } + catch {} + } + + private setupResizeHandler(): void + { + const workspace = this.querySelector( '.es-workspace' ) as HTMLElement; + const obs = new ResizeObserver( () => + { + this._redistributeFlex( workspace, '.es-panel' ); + workspace.querySelectorAll( '.es-sections' ).forEach( container => + { + this._redistributeFlex( container as HTMLElement, '.es-section' ); + } ); + } ); + obs.observe( workspace ); + } + + private _redistributeFlex( container: HTMLElement, childSelector: string ): void + { + const children = Array.from( container.querySelectorAll( `:scope > ${childSelector}` ) ) as HTMLElement[]; + if ( children.length < 2 ) return; + if ( ! children.some( c => c.style.flex ) ) return; + + const totalPanel = children.reduce( ( sum, c ) => sum + c.offsetWidth, 0 ); + if ( totalPanel === 0 ) return; + + const handles = Array.from( container.children ).filter( + c => ! ( c as HTMLElement ).matches( childSelector ) + ) as HTMLElement[]; + const handleTotal = handles.reduce( ( sum, h ) => sum + ( h as HTMLElement ).offsetWidth, 0 ); + const available = container.clientWidth - handleTotal; + + children.forEach( c => + { + const ratio = c.offsetWidth / totalPanel; + c.style.flex = `0 0 ${Math.round( ratio * available )}px`; + } ); + } + + private observeNewHandles(sections: HTMLElement): void { + const observer = new MutationObserver(() => { + sections.querySelectorAll('.es-h-handle:not([data-bound])').forEach(h => { + (h as HTMLElement).dataset.bound = '1'; + }); + }); + observer.observe(sections, { childList: true }); + } + + private setupPortrait(): void { + const btns = this.querySelector( '.es-portrait-btns' ) as HTMLElement; + const mq = window.matchMedia( '(orientation: portrait)' ); + + const apply = ( portrait: boolean ) => { + this.classList.toggle( 'portrait', portrait ); + if ( portrait ) this.showPortraitPanel( this.activePortraitPanel ); + }; + + btns.querySelectorAll( '.es-pb-btn' ).forEach( btn => { + btn.addEventListener( 'click', () => { + const panel = ( btn as HTMLElement ).dataset.panel!; + this.activePortraitPanel = panel; + btns.querySelectorAll( '.es-pb-btn' ).forEach( b => b.classList.remove( 'active' ) ); + btn.classList.add( 'active' ); + this.showPortraitPanel( panel ); + this._scheduleLayoutSave(); + } ); + } ); + + mq.addEventListener( 'change', e => apply( e.matches ) ); + apply( mq.matches ); + } + + private showPortraitPanel(panelId: string): void { + this.querySelectorAll('.es-panel').forEach(p => { + (p as HTMLElement).style.display = (p as HTMLElement).dataset.panel === panelId ? '' : 'none'; + }); + this.querySelectorAll('.es-v-handle').forEach(h => { (h as HTMLElement).style.display = 'none'; }); + } +} + +customElements.define('editor-shell', EditorShell); diff --git a/src/components/file-tree-panel/file-tree-panel.ts b/src/components/file-tree-panel/file-tree-panel.ts new file mode 100644 index 0000000..ba6c521 --- /dev/null +++ b/src/components/file-tree-panel/file-tree-panel.ts @@ -0,0 +1,378 @@ +import { Editor } from '../../editor/Editor.js'; +import { ContextMenuDirectory, ContextMenuEntry, ContextMenuReadOnlyEntry, ContextMenuSeparator } from '../context-menu/context-menu.js'; +import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; + +interface FileNode { + name: string; + path: string; + type: 'file' | 'directory'; + children?: FileNode[]; +} + +class FileTreePanel extends HTMLElement { + selectedPath: string | null = null; + _rootPath: string = ''; + _initialized = false; + + async connectedCallback(): Promise { + if ( this._initialized ) return; + this._initialized = true; + + this.className = 'file-tree-panel'; + this.innerHTML = ` +
+ + +
+
Loading…
+ `; + + this.querySelector( '[data-action="add-file"]' )!.addEventListener( 'click', () => this.addFile() ); + this.querySelector( '[data-action="add-dir"]' )!.addEventListener( 'click', () => this.addDirectory() ); + + Editor.get().onFilesChanged.addListener( () => this.refresh() ); + + await this.refresh(); + } + + addContextMenuEntries( dir: ContextMenuDirectory ): void { + dir.add( new ContextMenuReadOnlyEntry( dir, 'File Tree' ) ); + } + + _updateTabLabel(): void { + const label = '📁 ' + this._dirnameDisplay(); + this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label } } ) ); + } + + _dirnameDisplay(): string { + if ( !this._rootPath ) return '/'; + const lastSlash = this._rootPath.lastIndexOf( '/' ); + return lastSlash === -1 ? this._rootPath : this._rootPath.slice( lastSlash + 1 ); + } + + _findNode( nodes: FileNode[], targetPath: string ): FileNode | null { + for ( const n of nodes ) { + if ( n.path === targetPath ) return n; + if ( n.children ) { + const found = this._findNode( n.children, targetPath ); + if ( found ) return found; + } + } + return null; + } + + _goUp(): void { + const lastSlash = this._rootPath.lastIndexOf( '/' ); + this._rootPath = lastSlash === -1 ? '' : this._rootPath.slice( 0, lastSlash ); + this.selectedPath = null; + this.refresh(); + } + + async refresh(): Promise { + const state = Editor.get(); + const res = await fetch( `/api/files/${state.projectId}/tree` ); + const allNodes = await res.json() as FileNode[]; + const tree = this.querySelector( '.ftp-tree' )!; + + let nodes: FileNode[]; + + if ( this._rootPath ) { + const rootNode = this._findNode( allNodes, this._rootPath ); + nodes = rootNode ? ( rootNode.children ?? [] ) : []; + } + else { + nodes = allNodes; + } + + let html = ''; + + if ( this._rootPath ) { + html += `
[ .. ]
`; + } + + html += nodes.length ? this.renderNodes( nodes ) : 'Empty'; + tree.innerHTML = html; + + const upBtn = tree.querySelector( '[data-action="go-up"]' ); + if ( upBtn ) { + upBtn.addEventListener( 'click', () => this._goUp() ); + } + + this._updateTabLabel(); + this.bindTree( tree ); + } + + bindTree( tree: Element ): void { + const state = Editor.get(); + tree.querySelectorAll( '.ftp-file' ).forEach( el => { + el.addEventListener( 'click', () => { + const path = ( el as HTMLElement ).dataset.path!; + this.selectedPath = path; + tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); + el.classList.add( 'active' ); + state.openDocument( path ); + } ); + el.addEventListener( 'contextmenu', ( e: Event ) => { + e.preventDefault(); + const me = e as MouseEvent; + this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY ); + } ); + } ); + tree.querySelectorAll( '.ftp-dir-label' ).forEach( el => { + el.addEventListener( 'click', () => { + const li = el.closest( 'li' )!; + li.classList.toggle( 'open' ); + const path = ( el as HTMLElement ).dataset.path!; + this.selectedPath = path; + tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); + el.classList.add( 'active' ); + } ); + el.addEventListener( 'dblclick', () => { + const path = ( el as HTMLElement ).dataset.path!; + this._rootPath = path; + this.selectedPath = null; + this.refresh(); + } ); + el.addEventListener( 'contextmenu', ( e: Event ) => { + e.preventDefault(); + const me = e as MouseEvent; + this.showItemMenu( ( el as HTMLElement ).dataset.path!, me.clientX, me.clientY ); + } ); + } ); + } + + showItemMenu( targetPath: string, x: number, y: number ): void + { + this.selectedPath = targetPath; + const tree = this.querySelector( '.ftp-tree' )!; + tree.querySelectorAll( '.ftp-file, .ftp-dir-label' ).forEach( f => f.classList.remove( 'active' ) ); + tree.querySelector( `[data-path="${CSS.escape( targetPath )}"]` )?.classList.add( 'active' ); + + const menu = new ContextMenuDirectory( null ); + menu.add( new ContextMenuReadOnlyEntry( menu, targetPath ) ); + menu.add( new ContextMenuSeparator( menu ) ); + menu.add( new ContextMenuEntry( menu, 'Rename…', () => this.startInlineRename( targetPath ) ) ); + menu.add( new ContextMenuEntry( menu, 'Delete', () => this.deleteEntry( targetPath ) ) ); + menu.show( x, y ); + } + + startInlineRename( oldPath: string ): void + { + const tree = this.querySelector( '.ftp-tree' )!; + const lastSlash = oldPath.lastIndexOf( '/' ); + const currentName = lastSlash === -1 ? oldPath : oldPath.slice( lastSlash + 1 ); + + const overlay = document.createElement( 'div' ); + overlay.className = 'ftp-inline-create'; + + const input = document.createElement( 'input' ); + input.className = 'ftp-rename-input'; + input.value = currentName; + input.type = 'text'; + + overlay.innerHTML = `✎ Rename: ${oldPath}`; + overlay.appendChild( input ); + + const btnRename = document.createElement( 'button' ); + btnRename.textContent = 'Rename'; + btnRename.className = 'ftp-btn'; + const btnCancel = document.createElement( 'button' ); + btnCancel.textContent = 'Cancel'; + btnCancel.className = 'ftp-btn'; + + overlay.appendChild( btnRename ); + overlay.appendChild( btnCancel ); + tree.prepend( overlay ); + input.focus(); + input.select(); + + const confirm = async () => + { + const newName = input.value.trim(); + if ( !newName || newName === currentName ) { overlay.remove(); return; } + await this.renameEntry( oldPath, newName ); + overlay.remove(); + }; + + const cancel = () => overlay.remove(); + + btnRename.addEventListener( 'click', confirm ); + btnCancel.addEventListener( 'click', cancel ); + input.addEventListener( 'keydown', ( e: KeyboardEvent ) => + { + if ( 'Enter' === e.key ) confirm(); + if ( 'Escape' === e.key ) cancel(); + } ); + } + + async renameEntry( oldPath: string, newName: string ): Promise + { + const state = Editor.get(); + const res = await fetch( `/api/files/${state.projectId}/rename`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { path: oldPath, newName } ), + } ); + + if ( !res.ok ) return; + + const lastSlash = oldPath.lastIndexOf( '/' ); + const parent = lastSlash === -1 ? '' : oldPath.slice( 0, lastSlash ); + this.selectedPath = parent ? `${parent}/${newName}` : newName; + Editor.get().onFilesChanged.dispatch(); + } + + async deleteEntry( targetPath: string ): Promise + { + const lastSlash = targetPath.lastIndexOf( '/' ); + const name = lastSlash === -1 ? targetPath : targetPath.slice( lastSlash + 1 ); + const confirmed = await showConfirmDialog( { + icon: '🗑', + title: 'Delete', + message: `Delete "${name}"? This cannot be undone.`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + danger: true, + } ); + + if ( !confirmed ) return; + + const state = Editor.get(); + const res = await fetch( `/api/files/${state.projectId}/delete`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify( { path: targetPath } ), + } ); + + if ( !res.ok ) return; + + if ( this.selectedPath === targetPath ) this.selectedPath = null; + Editor.get().onFilesChanged.dispatch(); + } + + resolveTargetDir(): string { + if ( !this.selectedPath ) return this._rootPath; + + const tree = this.querySelector( '.ftp-tree' )!; + const dirLabel = tree.querySelector( `.ftp-dir-label[data-path="${CSS.escape( this.selectedPath )}"]` ); + if ( dirLabel ) return this.selectedPath; + + const lastSlash = this.selectedPath.lastIndexOf( '/' ); + return lastSlash === -1 ? this._rootPath : this.selectedPath.slice( 0, lastSlash ); + } + + async addFile(): Promise { + const state = Editor.get(); + const dir = this.resolveTargetDir(); + const name = await this.findFreeName(state.projectId, dir, 'file', 'txt'); + const filePath = dir ? `${dir}/${name}` : name; + this.startInlineCreate(filePath, 'file', name); + } + + async addDirectory(): Promise { + const state = Editor.get(); + const dir = this.resolveTargetDir(); + const name = await this.findFreeName(state.projectId, dir, 'directory', ''); + const dirPath = dir ? `${dir}/${name}` : name; + this.startInlineCreate(dirPath, 'directory', name); + } + + async findFreeName(projectId: string, dir: string, type: 'file' | 'directory', ext: string): Promise { + const baseName = type === 'file' ? 'file' : 'directory'; + const res = await fetch(`/api/files/${projectId}/tree`); + const tree = await res.json() as FileNode[]; + for (let i = 1; i <= 999; i++) { + const name = ext ? `${baseName}${i > 1 ? i : ''}.${ext}` : `${baseName}${i > 1 ? i : ''}`; + const testPath = dir ? `${dir}/${name}` : name; + if (!this.pathExistsInTree(tree, testPath)) return name; + } + return `${baseName}-${Date.now()}${ext ? '.' + ext : ''}`; + } + + pathExistsInTree(nodes: FileNode[], targetPath: string): boolean { + for (const n of nodes) { + if (n.path === targetPath) return true; + if (n.children && this.pathExistsInTree(n.children, targetPath)) return true; + } + return false; + } + + startInlineCreate(fullPath: string, type: 'file' | 'directory', defaultName: string): void { + const tree = this.querySelector('.ftp-tree')!; + const overlay = document.createElement('div'); + overlay.className = 'ftp-inline-create'; + + const input = document.createElement('input'); + input.className = 'ftp-rename-input'; + input.value = defaultName; + input.type = 'text'; + + overlay.innerHTML = `${type === 'file' ? '📄' : '📁'} New ${type} in: ${fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) || '/' : '/'}`; + overlay.appendChild(input); + + const btnCreate = document.createElement('button'); + btnCreate.textContent = 'Create'; + btnCreate.className = 'ftp-btn'; + const btnCancel = document.createElement('button'); + btnCancel.textContent = 'Cancel'; + btnCancel.className = 'ftp-btn'; + + overlay.appendChild(btnCreate); + overlay.appendChild(btnCancel); + tree.prepend(overlay); + input.focus(); + input.select(); + + const confirm = async () => { + const name = input.value.trim(); + if (!name) return; + const dir = fullPath.includes('/') ? fullPath.slice(0, fullPath.lastIndexOf('/')) : ''; + const finalPath = dir ? `${dir}/${name}` : name; + await this.createEntry(type, finalPath); + overlay.remove(); + }; + + const cancel = () => overlay.remove(); + + btnCreate.addEventListener('click', confirm); + btnCancel.addEventListener('click', cancel); + input.addEventListener('keydown', (e: KeyboardEvent) => { + if (e.key === 'Enter') confirm(); + if (e.key === 'Escape') cancel(); + }); + } + + async createEntry(type: 'file' | 'directory', path: string): Promise { + const state = Editor.get(); + const endpoint = type === 'file' ? 'create-file' : 'create-directory'; + const res = await fetch(`/api/files/${state.projectId}/${endpoint}`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ path }), + }); + if ( res.ok ) + { + if ( type === 'file' ) + { + this.selectedPath = path; + } + + Editor.get().onFilesChanged.dispatch(); + } + } + + renderNodes(nodes: FileNode[], depth = 0): string { + return `
    ` + nodes.map(n => { + if (n.type === 'directory') { + return `
  • + ▸ ${n.name} + ${this.renderNodes(n.children ?? [], depth + 1)} +
  • `; + } + const isHtml = n.name.endsWith('.html'); + return `
  • ${n.name}
  • `; + }).join('') + '
'; + } +} + +customElements.define('file-tree-panel', FileTreePanel); diff --git a/src/components/group-editor/group-editor.ts b/src/components/group-editor/group-editor.ts new file mode 100644 index 0000000..175254f --- /dev/null +++ b/src/components/group-editor/group-editor.ts @@ -0,0 +1,118 @@ +interface Group { + id: string; + name: string; +} + +interface GroupMember { + id: string; + group_id: string; + member_type: 'user' | 'group'; + member_id: string; +} + +class GroupEditor extends HTMLElement { + connectedCallback(): void { + this.render(); + } + + async render(): Promise { + const res = await fetch('/api/groups'); + const groups = await res.json() as Group[]; + + this.innerHTML = ` +
+

Groups

+
+ + +
+
    + ${groups.map(g => ` +
  • +
    + ${g.name} + + +
    + +
  • + `).join('')} +
+
+ `; + + this.querySelector('.create-form')!.addEventListener('submit', async (e: Event) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim(); + const res = await fetch('/api/groups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (res.ok) this.render(); + }); + + this.querySelectorAll('.btn-delete').forEach(btn => { + btn.addEventListener('click', async () => { + if (!confirm('Delete group?')) return; + await fetch(`/api/groups/${(btn as HTMLElement).dataset.id}`, { method: 'DELETE' }); + this.render(); + }); + }); + + this.querySelectorAll('.btn-members').forEach(btn => { + btn.addEventListener('click', () => this.toggleMembers((btn as HTMLElement).dataset.id!)); + }); + } + + async toggleMembers(groupId: string): Promise { + const panel = this.querySelector(`.members-panel[data-group="${groupId}"]`) as HTMLElement; + if (panel.style.display !== 'none') { panel.style.display = 'none'; return; } + panel.style.display = 'block'; + await this.loadMembers(groupId, panel); + } + + async loadMembers(groupId: string, panel: HTMLElement): Promise { + const res = await fetch(`/api/groups/${groupId}/members`); + const members = await res.json() as GroupMember[]; + + panel.innerHTML = ` +
    + ${members.map(m => ` +
  • ${m.member_type}: ${m.member_id} + +
  • + `).join('') || '
  • No members
  • '} +
+
+ + + +
+ `; + + panel.querySelectorAll('.btn-remove-member').forEach(btn => { + btn.addEventListener('click', async () => { + const el = btn as HTMLElement; + await fetch(`/api/groups/${el.dataset.gid}/members/${el.dataset.mid}`, { method: 'DELETE' }); + await this.loadMembers(groupId, panel); + }); + }); + + (panel.querySelector('.add-member-form') as HTMLFormElement).addEventListener('submit', async (e: Event) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target as HTMLFormElement)); + await fetch(`/api/groups/${groupId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + await this.loadMembers(groupId, panel); + }); + } +} +customElements.define('group-editor', GroupEditor); diff --git a/src/components/html-editor-panel/html-editor-panel.ts b/src/components/html-editor-panel/html-editor-panel.ts new file mode 100644 index 0000000..592103e --- /dev/null +++ b/src/components/html-editor-panel/html-editor-panel.ts @@ -0,0 +1,239 @@ +import { Editor } from '../../editor/Editor.js'; +import { ContextMenuDirectory, ContextMenuReadOnlyEntry } from '../context-menu/context-menu.js'; + +class HtmlEditorPanel extends HTMLElement +{ + currentPath: string | null = null; + _pinned: boolean = false; + _iframe: HTMLIFrameElement | null = null; + _undoStack: string[] = []; + _redoStack: string[] = []; + _mutationObserver: MutationObserver | null = null; + _initialized = false; + _needsRestore = false; + + connectedCallback(): void + { + if ( this._initialized ) return; + this._initialized = true; + + this.className = 'html-editor-panel'; + this.innerHTML = ` +
+ + + + + +
+
Open an HTML file from the file tree
+ + `; + + this._iframe = this.querySelector( 'iframe' ); + + this.querySelector( '.hep-pin' )!.addEventListener( 'click', () => this._togglePin() ); + this.querySelector( '.hep-save' )!.addEventListener( 'click', () => this._save() ); + this.querySelector( '.hep-undo' )!.addEventListener( 'click', () => this._undo() ); + this.querySelector( '.hep-redo' )!.addEventListener( 'click', () => this._redo() ); + this.querySelector( '.hep-init' )!.addEventListener( 'click', () => this._initTemplate() ); + + Editor.get().onDocumentOpened.addListener( ( e ) => + { + if ( ! this._pinned ) this._loadDocument( e.path, e.content ); + } ); + + document.addEventListener( 'keydown', ( e: KeyboardEvent ) => + { + if ( ! this.currentPath ) + { + return; + } + + if ( e.ctrlKey && e.key === 's' ) { e.preventDefault(); this._save(); } + if ( e.ctrlKey && ! e.shiftKey && e.key === 'z' ) { e.preventDefault(); this._undo(); } + if ( e.ctrlKey && ( e.key === 'y' || ( e.shiftKey && e.key === 'z' ) ) ) { e.preventDefault(); this._redo(); } + } ); + } + + disconnectedCallback(): void + { + if ( this._undoStack.length > 0 ) + { + this._needsRestore = true; + } + } + + _updateTabLabel( path: string ): void + { + const name = path ? path.slice( path.lastIndexOf( '/' ) + 1 ) : ''; + this.dispatchEvent( new CustomEvent( 'panel:label-change', { bubbles: true, detail: { label: '📄 ' + name } } ) ); + } + + _loadDocument( path: string, content: string ): void + { + this.currentPath = path; + this._undoStack = [ content ]; + this._redoStack = []; + this._updateTabLabel( path ); + this.querySelector( '.hep-empty' )!.setAttribute( 'style', 'display:none' ); + this._iframe!.style.display = ''; + this._renderContent( content ); + this._updateButtons( false ); + } + + _renderContent( html: string ): void + { + const iframe = this._iframe!; + + if ( this._mutationObserver ) + { + this._mutationObserver.disconnect(); + this._mutationObserver = null; + } + + iframe.srcdoc = html; + iframe.onload = () => + { + if ( this._needsRestore ) + { + this._needsRestore = false; + this._renderContent( this._undoStack[ this._undoStack.length - 1 ] ); + return; + } + + const doc = iframe.contentDocument!; + const pc = doc.querySelector( 'page-content' ); + + if ( pc ) + { + ( pc as HTMLElement ).contentEditable = 'true'; + ( pc as HTMLElement ).style.outline = 'none'; + this._mutationObserver = new MutationObserver( () => this._onContentChanged() ); + this._mutationObserver.observe( pc, { subtree: true, childList: true, characterData: true, attributes: true } ); + } + }; + } + + _onContentChanged(): void + { + if ( ! this.currentPath || ! this._iframe?.contentDocument ) + { + return; + } + + const html = '\n' + this._iframe.contentDocument.documentElement.outerHTML; + const last = this._undoStack[ this._undoStack.length - 1 ]; + + if ( html === last ) + { + return; + } + + this._undoStack.push( html ); + + if ( this._undoStack.length > 200 ) + { + this._undoStack.shift(); + } + + this._redoStack = []; + Editor.get().markDirty( this.currentPath, html ); + this._updateButtons( true ); + } + + _undo(): void + { + if ( this._undoStack.length < 2 ) + { + return; + } + + const current = this._undoStack.pop()!; + this._redoStack.push( current ); + const prev = this._undoStack[ this._undoStack.length - 1 ]; + Editor.get().markDirty( this.currentPath!, prev ); + this._renderContent( prev ); + this._updateButtons( true ); + } + + _redo(): void + { + if ( ! this._redoStack.length ) + { + return; + } + + const next = this._redoStack.pop()!; + this._undoStack.push( next ); + Editor.get().markDirty( this.currentPath!, next ); + this._renderContent( next ); + this._updateButtons( true ); + } + + _togglePin(): void + { + this._pinned = ! this._pinned; + this.querySelector( '.hep-pin' )!.classList.toggle( 'pinned', this._pinned ); + } + + _initTemplate(): void + { + if ( ! this.currentPath ) return; + + const template = [ + '', + '', + '', + ' ', + ' Hello World', + '', + '', + ' ', + '

Hello World

', + '

Welcome.

', + '
', + '', + '', + ].join( '\n' ); + + this._undoStack.push( template ); + this._redoStack = []; + Editor.get().markDirty( this.currentPath, template ); + this._renderContent( template ); + this._updateButtons( true ); + } + + async _save(): Promise + { + if ( ! this.currentPath ) + { + return; + } + + await Editor.get().save( this.currentPath ); + this._updateButtons( false ); + } + + addContextMenuEntries( dir: ContextMenuDirectory ): void + { + if ( this.currentPath ) + { + dir.add( new ContextMenuReadOnlyEntry( dir, `Editing: ${this.currentPath}` ) ); + } + else + { + dir.add( new ContextMenuReadOnlyEntry( dir, 'No document open' ) ); + } + } + + _updateButtons( dirty: boolean ): void + { + ( this.querySelector( '.hep-save' ) as HTMLButtonElement ).disabled = ! dirty; + ( this.querySelector( '.hep-undo' ) as HTMLButtonElement ).disabled = this._undoStack.length < 2; + ( this.querySelector( '.hep-redo' ) as HTMLButtonElement ).disabled = this._redoStack.length === 0; + ( this.querySelector( '.hep-init' ) as HTMLButtonElement ).disabled = ! this.currentPath; + } +} + +customElements.define( 'html-editor-panel', HtmlEditorPanel ); diff --git a/src/components/login-form/login-form.ts b/src/components/login-form/login-form.ts new file mode 100644 index 0000000..73eee63 --- /dev/null +++ b/src/components/login-form/login-form.ts @@ -0,0 +1,31 @@ +class LoginForm extends HTMLElement { + connectedCallback(): void { + this.innerHTML = ` +
+

Login

+

+ + + + Create account +
+ `; + this.querySelector('form')!.addEventListener('submit', async (e: Event) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const data = Object.fromEntries(new FormData(form)); + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (res.ok) { + location.href = '/dashboard.html'; + } else { + const err = await res.json() as { error: string }; + this.querySelector('.error')!.textContent = err.error; + } + }); + } +} +customElements.define('login-form', LoginForm); diff --git a/src/components/project-editor/project-editor.ts b/src/components/project-editor/project-editor.ts new file mode 100644 index 0000000..69b83d8 --- /dev/null +++ b/src/components/project-editor/project-editor.ts @@ -0,0 +1,141 @@ +import { showConfirmDialog } from '../confirm-dialog/confirm-dialog.js'; + +interface Project { + id: string; + name: string; + owner_id: string; +} + +interface ProjectMember { + id: string; + project_id: string; + member_type: 'user' | 'group'; + member_id: string; + role: string; +} + +class ProjectEditor extends HTMLElement { + connectedCallback(): void { + this.render(); + } + + async render(): Promise { + const res = await fetch('/api/projects'); + const projects = await res.json() as Project[]; + + this.innerHTML = ` +
+

Projects

+
+ + +
+
    + ${projects.map(p => ` +
  • +
    + ${p.name} + Edit + + +
    + +
  • + `).join('')} +
+
+ `; + + this.querySelector('.create-form')!.addEventListener('submit', async (e: Event) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const name = (form.elements.namedItem('name') as HTMLInputElement).value.trim(); + const res = await fetch('/api/projects', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }) + }); + if (res.ok) this.render(); + }); + + this.querySelectorAll('.btn-delete').forEach(btn => { + btn.addEventListener('click', async ( e ) => { + + console.log( "Clicking:", e ); + const el = btn as HTMLElement; + const ok = await showConfirmDialog({ + icon: '🗑', + title: 'Delete Project', + message: `Delete "${el.dataset.name}"? This cannot be undone.`, + confirmLabel: 'Delete', + cancelLabel: 'Cancel', + danger: true + }); + + console.log( "Result:", ok ); + if (!ok) return; + await fetch(`/api/projects/${el.dataset.id}`, { method: 'DELETE' }); + this.render(); + }); + }); + + this.querySelectorAll('.btn-members').forEach(btn => { + btn.addEventListener('click', () => this.toggleMembers((btn as HTMLElement).dataset.id!)); + }); + } + + async toggleMembers(projectId: string): Promise { + const panel = this.querySelector(`.members-panel[data-project="${projectId}"]`) as HTMLElement; + if (panel.style.display !== 'none') { panel.style.display = 'none'; return; } + panel.style.display = 'block'; + await this.loadMembers(projectId, panel); + } + + async loadMembers(projectId: string, panel: HTMLElement): Promise { + const res = await fetch(`/api/projects/${projectId}/members`); + const members = await res.json() as ProjectMember[]; + + panel.innerHTML = ` +
    + ${members.map(m => ` +
  • ${m.member_type}: ${m.member_id} — ${m.role} + +
  • + `).join('') || '
  • No members
  • '} +
+
+ + + + +
+ `; + + panel.querySelectorAll('.btn-remove-member').forEach(btn => { + btn.addEventListener('click', async () => { + const el = btn as HTMLElement; + await fetch(`/api/projects/${el.dataset.pid}/members/${el.dataset.mid}`, { method: 'DELETE' }); + await this.loadMembers(projectId, panel); + }); + }); + + (panel.querySelector('.add-member-form') as HTMLFormElement).addEventListener('submit', async (e: Event) => { + e.preventDefault(); + const data = Object.fromEntries(new FormData(e.target as HTMLFormElement)); + await fetch(`/api/projects/${projectId}/members`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + await this.loadMembers(projectId, panel); + }); + } +} +customElements.define('project-editor', ProjectEditor); diff --git a/src/components/register-form/register-form.ts b/src/components/register-form/register-form.ts new file mode 100644 index 0000000..20e63d1 --- /dev/null +++ b/src/components/register-form/register-form.ts @@ -0,0 +1,31 @@ +class RegisterForm extends HTMLElement { + connectedCallback(): void { + this.innerHTML = ` +
+

Create Account

+

+ + + + Already have an account? +
+ `; + this.querySelector('form')!.addEventListener('submit', async (e: Event) => { + e.preventDefault(); + const form = e.target as HTMLFormElement; + const data = Object.fromEntries(new FormData(form)); + const res = await fetch('/api/auth/register', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(data) + }); + if (res.ok) { + location.href = '/dashboard.html'; + } else { + const err = await res.json() as { error: string }; + this.querySelector('.error')!.textContent = err.error; + } + }); + } +} +customElements.define('register-form', RegisterForm); diff --git a/src/components/tab-container/tab-container.ts b/src/components/tab-container/tab-container.ts new file mode 100644 index 0000000..864fe3d --- /dev/null +++ b/src/components/tab-container/tab-container.ts @@ -0,0 +1,199 @@ +import { Editor } from '../../editor/Editor.js'; +import { ContextMenuDirectory, ContextMenuEntry, ContextMenuSeparator } from '../context-menu/context-menu.js'; + +interface TabEntry { + id: string; + label: string; + panelType: string; + element: HTMLElement; + dirty: boolean; + factory: () => HTMLElement; +} + +export interface EditorPanel extends HTMLElement { + addContextMenuEntries(dir: ContextMenuDirectory): void; +} + +class TabContainer extends HTMLElement { + tabs: TabEntry[] = []; + activeId: string | null = null; + uid = Math.random().toString(36).slice(2); + + connectedCallback(): void { + if (!this.id) this.id = 'tc-' + this.uid; + this.classList.add('tab-container'); + this.innerHTML = `
`; + + this.querySelector('.tc-menu')!.addEventListener('click', (e: Event) => { + this.openMenu(e as MouseEvent); + }); + + this.addEventListener('dragover', (e: DragEvent) => { + if (e.dataTransfer?.types.includes('application/editor-tab')) { + e.preventDefault(); + this.classList.add('tc-drop-target'); + } + }); + this.addEventListener('dragleave', () => this.classList.remove('tc-drop-target')); + this.addEventListener('drop', (e: DragEvent) => { + e.preventDefault(); + this.classList.remove('tc-drop-target'); + const raw = e.dataTransfer?.getData('application/editor-tab'); + if (!raw) return; + const { tabId, sourceContainerId } = JSON.parse(raw) as { tabId: string; sourceContainerId: string }; + if (sourceContainerId === this.id) return; + const source = document.getElementById(sourceContainerId) as TabContainer | null; + if (!source) return; + const tab = source.extractTab(tabId); + if (tab) this.receiveTab(tab); + }); + + this.addEventListener( 'panel:label-change', ( e: Event ) => { + const panel = e.target as HTMLElement; + const tab = this.tabs.find( t => t.element === panel ); + if ( tab ) { tab.label = ( e as CustomEvent ).detail.label; this.renderBar(); } + } ); + + Editor.get().onDocumentDirty.addListener( ( e ) => + { + const tab = this.tabs.find( t => t.panelType === 'html-editor' && ( t.element as any ).currentPath === e.path ); + if ( tab ) { tab.dirty = true; this.renderBar(); } + } ); + + Editor.get().onDocumentSaved.addListener( ( e ) => + { + const tab = this.tabs.find( t => t.panelType === 'html-editor' && ( t.element as any ).currentPath === e.path ); + if ( tab ) { tab.dirty = false; this.renderBar(); } + } ); + } + + openMenu(e: MouseEvent): void { + const btn = e.currentTarget as HTMLElement; + const rect = btn.getBoundingClientRect(); + const root = new ContextMenuDirectory(null); + + const addDir = new ContextMenuDirectory(root, 'Add'); + const panelTypes = [ + { label: 'HTML Editor', panelType: 'html-editor', tag: 'html-editor-panel' }, + { label: 'File Tree', panelType: 'file-tree', tag: 'file-tree-panel' }, + ]; + for (const pt of panelTypes) { + addDir.add(new ContextMenuEntry(addDir, pt.label, () => { + this.dispatchEvent(new CustomEvent('tab-container:add-panel', { + bubbles: true, + detail: { containerId: this.id, panelType: pt.panelType, tag: pt.tag, label: pt.label }, + })); + })); + } + root.add(addDir); + + root.add(new ContextMenuEntry(root, 'Duplicate', () => this.duplicateActive())); + root.add(new ContextMenuEntry(root, 'Split', () => { + this.dispatchEvent(new CustomEvent('tab-container:split', { bubbles: true, detail: { containerId: this.id } })); + })); + + const activeTab = this.tabs.find(t => t.id === this.activeId); + if (activeTab) { + const panel = activeTab.element as EditorPanel; + if (typeof panel.addContextMenuEntries === 'function') { + root.add(new ContextMenuSeparator(root)); + panel.addContextMenuEntries(root); + } + } + + root.add(new ContextMenuSeparator(root)); + root.add(new ContextMenuEntry(root, 'Close', () => this.closeActive())); + + root.show(rect.left, rect.bottom); + } + + duplicateActive(): void + { + if ( ! this.activeId ) + { + return; + } + + const tab = this.tabs.find( t => t.id === this.activeId ); + + if ( ! tab ) + { + return; + } + + const newId = tab.id + '-' + Math.random().toString( 36 ).slice( 2 ); + this.addTab( { id: newId, label: tab.label, panelType: tab.panelType }, tab.factory ); + } + + closeActive(): void + { + if ( ! this.activeId ) + { + return; + + } + + this.extractTab( this.activeId ); + } + + addTab(config: { id: string; label: string; panelType: string }, factory: () => HTMLElement): void + { + const element = factory(); + element.style.display = 'none'; + this.querySelector('.tc-content')!.appendChild(element); + this.tabs.push({ ...config, element, dirty: false, factory }); + this.activateTab(config.id); + } + + extractTab(tabId: string): TabEntry | null { + const idx = this.tabs.findIndex(t => t.id === tabId); + if (idx === -1) return null; + const [tab] = this.tabs.splice(idx, 1); + tab.element.remove(); + if (this.activeId === tabId) { + this.activeId = this.tabs[0]?.id ?? null; + } + this.renderBar(); + this.showActive(); + return tab; + } + + receiveTab(tab: TabEntry): void { + tab.element.style.display = 'none'; + this.querySelector('.tc-content')!.appendChild(tab.element); + this.tabs.push(tab); + this.activateTab(tab.id); + } + + activateTab(tabId: string): void { + this.activeId = tabId; + this.renderBar(); + this.showActive(); + } + + renderBar(): void { + const bar = this.querySelector('.tc-tabs')!; + bar.innerHTML = this.tabs.map(t => ` +
+ ${t.dirty ? '' : ''}${t.label} +
+ `).join(''); + + bar.querySelectorAll('.tc-tab').forEach(el => { + el.addEventListener('click', () => this.activateTab((el as HTMLElement).dataset.tabId!)); + el.addEventListener('dragstart', (e: Event) => { + const de = e as DragEvent; + const tabId = (el as HTMLElement).dataset.tabId!; + de.dataTransfer?.setData('application/editor-tab', JSON.stringify({ tabId, sourceContainerId: this.id })); + de.dataTransfer!.effectAllowed = 'move'; + }); + }); + } + + showActive(): void { + this.tabs.forEach(t => { t.element.style.display = t.id === this.activeId ? '' : 'none'; }); + } +} + +customElements.define('tab-container', TabContainer); diff --git a/src/editor/Editor.ts b/src/editor/Editor.ts new file mode 100644 index 0000000..4bb74ca --- /dev/null +++ b/src/editor/Editor.ts @@ -0,0 +1,90 @@ +import { EventSlot } from '../library-ts/browser/events/EventSlot.js'; +import UndoManager from '../library-ts/browser/undo/UndoManager.js'; + +export interface DocumentOpenedEvent +{ + path: string; + content: string; +} + +export interface DocumentPathEvent +{ + path: string; +} + +export class Editor +{ + static _instance: Editor = null; + + static get(): Editor + { + if ( ! this._instance ) + { + this._instance = new Editor(); + } + + return this._instance; + } + + projectId: string = ''; + projectName: string = ''; + openDocs: Map = new Map(); + activeDoc: string | null = null; + undoManager: UndoManager + + + readonly onDocumentOpened: EventSlot = new EventSlot(); + readonly onDocumentDirty: EventSlot = new EventSlot(); + readonly onDocumentSaved: EventSlot = new EventSlot(); + readonly onFilesChanged: EventSlot = new EventSlot(); + + async openDocument( filePath: string ): Promise + { + if ( ! this.openDocs.has( filePath ) ) + { + const res = await fetch( `/api/files/${this.projectId}/${filePath}` ); + const content = await res.text(); + this.openDocs.set( filePath, { content, dirty: false } ); + } + + this.activeDoc = filePath; + const entry = this.openDocs.get( filePath )!; + this.onDocumentOpened.dispatch( { path: filePath, content: entry.content } ); + } + + markDirty( filePath: string, content: string ): void + { + const doc = this.openDocs.get( filePath ); + + if ( ! doc ) + { + return; + } + + doc.content = content; + doc.dirty = true; + this.onDocumentDirty.dispatch( { path: filePath } ); + } + + async save( filePath: string ): Promise + { + const doc = this.openDocs.get( filePath ); + + if ( ! doc || ! doc.dirty ) + { + return; + } + + await fetch( + `/api/files/${this.projectId}/${filePath}`, + { + method: 'PUT', + headers: { 'Content-Type': 'text/plain' }, + body: doc.content + } + ); + + doc.dirty = false; + this.onDocumentSaved.dispatch( { path: filePath } ); + } +} diff --git a/src/library-ts b/src/library-ts new file mode 160000 index 0000000..89534bb --- /dev/null +++ b/src/library-ts @@ -0,0 +1 @@ +Subproject commit 89534bb72b8d2119ca5e12da05549edbd9133945 diff --git a/src/locales/LocaleManager.ts b/src/locales/LocaleManager.ts new file mode 100644 index 0000000..6e8a6a4 --- /dev/null +++ b/src/locales/LocaleManager.ts @@ -0,0 +1,72 @@ + +export class LocaleManager +{ + static _instance:LocaleManager; + + static get $() + { + if ( ! this._instance ) + { + this._instance = new LocaleManager(); + } + + return this._instance; + } + + _currentLocale = 'en'; + _cache = new Map(); + + get currentLocale(): string + { + return this._currentLocale; + } + + setLocale( locale: string ):void + { + this._currentLocale = locale; + } + + async get( localePath:string ):Promise + { + return this._loadLocale( localePath ); + } + + async _loadLocale( relativePath: string ): Promise + { + const key = `${this._currentLocale}/${relativePath}`; + + if ( this._cache.has( key ) ) + { + return this._cache.get( key ); + } + + const res = await fetch(`/api/locales/${this._currentLocale}/${relativePath}`); + + if ( ! res.ok ) + { + return relativePath; + } + + const ext = relativePath.split('.').pop() ?? ''; + let value: string; + + if ( ext === 'json' ) + { + const data = await res.json() as { value: string }; + value = data.value; + } + else + { + value = await res.text(); + } + + this._cache.set( key, value ); + return value; + } + + invalidateCache(): void + { + this._cache.clear(); + } + +} \ No newline at end of file diff --git a/src/locales/Locales.ts b/src/locales/Locales.ts new file mode 100644 index 0000000..b8d3faa --- /dev/null +++ b/src/locales/Locales.ts @@ -0,0 +1,9 @@ +// Auto-generated by localeGenerator — do not edit manually. +export class Locales { + static greeting_txt = "greeting.txt"; + static commands = new class { + static fileTreeCommands = new class { + addFile_txt = "commands/file-tree-commands/add-file.txt"; + }(); + }(); +} diff --git a/tsconfig.client.json b/tsconfig.client.json new file mode 100644 index 0000000..c7c9dc9 --- /dev/null +++ b/tsconfig.client.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "strict": false, + "strictNullChecks": false, + "skipLibCheck": true, + "types": [], + "outDir": "public", + "rootDir": "src" + }, + "include": ["src/**/*"], + "exclude": ["src/library-ts/**/*"], + "references": [ + { "path": "./src/library-ts/browser/tsconfig.roject.json" } + ] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..e77d98c --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["server/**/*"] +} diff --git a/tsconfig.ts-node.json b/tsconfig.ts-node.json new file mode 100644 index 0000000..8414e8b --- /dev/null +++ b/tsconfig.ts-node.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "strictNullChecks": false + }, + "include": ["server/**/*", "src/library-ts/node/**/*"] +} diff --git a/workspace/_assets_/nav-data.js b/workspace/_assets_/nav-data.js new file mode 100644 index 0000000..3fcfbff --- /dev/null +++ b/workspace/_assets_/nav-data.js @@ -0,0 +1,38 @@ +var NAV_DATA = { + title: 'Workspace', + path: 'index.html', + children: [ + { + title: 'Outline', + path: 'outline/index.html', + }, + { + title: 'Guides', + path: 'guides/index.html', + children: [ + { title: 'Writing TypeScript Code', path: 'guides/writing-typescript-code/index.html' }, + { title: 'Locales', path: 'guides/locales/index.html' }, + ] + }, + { + title: 'Actions', + path: 'actions/index.html', + children: [ + { title: 'Update History', path: 'actions/update-history/index.html' }, + { title: 'Update Outline', path: 'actions/update-outline/index.html' }, + ] + }, + { + title: 'History', + path: 'history/index.html', + children: [ + { title: 'Monday, 6 July 2026', path: 'history/2026/07-July/06-Monday/index.html' }, + { title: 'Sunday, 5 July 2026', path: 'history/2026/07-July/05-Sunday/index.html' }, + { title: 'Saturday, 4 July 2026', path: 'history/2026/07-July/04-Saturday/index.html' }, + { title: 'Friday, 3 July 2026', path: 'history/2026/07-July/03-Friday/index.html' }, + { title: 'Thursday, 2 July 2026', path: 'history/2026/07-July/02-Thursday/index.html' }, + { title: 'Wednesday, 1 July 2026', path: 'history/2026/07-July/01-Wednesday/index.html' }, + ] + }, + ] +}; diff --git a/workspace/_assets_/nav.css b/workspace/_assets_/nav.css new file mode 100644 index 0000000..c8fa4ad --- /dev/null +++ b/workspace/_assets_/nav.css @@ -0,0 +1,92 @@ +.wsnav +{ + position: fixed; + top: 0; + left: 0; + right: 0; + z-index: 100; + background: #1a1d27; + border-bottom: 1px solid #2a2d3a; + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + font-size: 0.82rem; +} + +.wsnav-inner +{ + width: 57em; + margin: 0 auto; + padding: 0.45rem 1.5rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.wsnav a +{ + color: #7c8cff; + text-decoration: none; +} + +.wsnav a:hover +{ + text-decoration: underline; +} + +/* ── Breadcrumb ── */ + +.wsnav-crumb +{ + display: flex; + align-items: center; + gap: 0.35rem; + flex-wrap: wrap; +} + +.wsnav-sep +{ + color: #4a4e6a; + font-size: 0.9em; +} + +.wsnav-current +{ + color: #e2e4ed; + font-weight: 600; +} + +/* ── Siblings ── */ + +.wsnav-siblings +{ + display: flex; + align-items: center; + gap: 0.45rem; + flex-wrap: wrap; + color: #7b7f96; + font-size: 0.88em; +} + +.wsnav-sib-current +{ + color: #e2e4ed; + font-weight: 600; +} + +.wsnav-sib-sep +{ + color: #4a4e6a; +} + +/* ── Children ── */ + +.wsnav-children +{ + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; + font-size: 0.88em; + padding-top: 0.1rem; + border-top: 1px solid #2a2d3a; + margin-top: 0.1rem; +} diff --git a/workspace/_assets_/nav.js b/workspace/_assets_/nav.js new file mode 100644 index 0000000..5eefc0b --- /dev/null +++ b/workspace/_assets_/nav.js @@ -0,0 +1,133 @@ +(function () +{ + function getCurrentPath() + { + var href = window.location.href.replace(/\\/g, '/'); + var marker = 'workspace/'; + var idx = href.lastIndexOf(marker); + if (idx === -1) return 'index.html'; + var after = href.slice(idx + marker.length); + return after || 'index.html'; + } + + function findNode(node, targetPath, ancestors) + { + if (node.path === targetPath) return { node: node, ancestors: ancestors }; + var children = node.children || []; + for (var i = 0; i < children.length; i++) + { + var result = findNode(children[i], targetPath, ancestors.concat(node)); + if (result) return result; + } + return null; + } + + function makeLink(node, label) + { + var a = document.createElement('a'); + a.href = NAV_ROOT + node.path; + a.textContent = label || node.title; + return a; + } + + function render() + { + var currentPath = getCurrentPath(); + var found = findNode(NAV_DATA, currentPath, []); + + var nav = document.createElement('nav'); + nav.className = 'wsnav'; + + var inner = document.createElement('div'); + inner.className = 'wsnav-inner'; + nav.appendChild(inner); + + // ── Breadcrumb ─────────────────────────────────────────────────── + var crumb = document.createElement('div'); + crumb.className = 'wsnav-crumb'; + + var ancestors = found ? found.ancestors : []; + var currentNode = found ? found.node : null; + + for (var i = 0; i < ancestors.length; i++) + { + var a = makeLink(ancestors[i]); + crumb.appendChild(a); + var sep = document.createElement('span'); + sep.className = 'wsnav-sep'; + sep.textContent = '›'; + crumb.appendChild(sep); + } + + var current = document.createElement('span'); + current.className = 'wsnav-current'; + current.textContent = currentNode ? currentNode.title : currentPath; + crumb.appendChild(current); + + inner.appendChild(crumb); + + // ── Siblings ───────────────────────────────────────────────────── + var parent = ancestors.length > 0 ? ancestors[ancestors.length - 1] : null; + var siblings = parent ? (parent.children || []) : []; + + if (siblings.length > 1) + { + var sibRow = document.createElement('div'); + sibRow.className = 'wsnav-siblings'; + + for (var j = 0; j < siblings.length; j++) + { + var sib = siblings[j]; + if (sib.path === currentPath) + { + var mark = document.createElement('span'); + mark.className = 'wsnav-sib-current'; + mark.textContent = sib.title; + sibRow.appendChild(mark); + } + else + { + sibRow.appendChild(makeLink(sib)); + } + + if (j < siblings.length - 1) + { + var div = document.createElement('span'); + div.className = 'wsnav-sib-sep'; + div.textContent = '·'; + sibRow.appendChild(div); + } + } + + inner.appendChild(sibRow); + } + + // ── Children ───────────────────────────────────────────────────── + var children = currentNode ? (currentNode.children || []) : []; + + if (children.length > 0) + { + var childRow = document.createElement('div'); + childRow.className = 'wsnav-children'; + + for (var k = 0; k < children.length; k++) + { + childRow.appendChild(makeLink(children[k])); + } + + inner.appendChild(childRow); + } + + document.body.insertBefore(nav, document.body.firstChild); + document.body.style.paddingTop = (nav.offsetHeight + 8) + 'px'; + } + + if (document.readyState === 'loading') + { + document.addEventListener('DOMContentLoaded', render); + } + else + { + render(); + } +})(); diff --git a/workspace/_assets_/styles.css b/workspace/_assets_/styles.css new file mode 100644 index 0000000..85195ac --- /dev/null +++ b/workspace/_assets_/styles.css @@ -0,0 +1,200 @@ +*, *::before, *::after +{ + box-sizing: border-box; + margin: 0; + padding: 0; +} + +:root +{ + --bg: #0f1117; + --surface: #1a1d27; + --border: #2a2d3a; + --text: #e2e4ed; + --muted: #7b7f96; + --accent: #7c8cff; + --tag-bg: #1e2235; + --tag-text: #9ba4c7; + --font-size: 20px; +} + +html +{ + font-size: calc( var( --font-size ) ); + -webkit-font-smoothing: antialiased; +} + +body +{ + background: var(--bg); + color: var(--text); + font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + line-height: 1.7; + padding: calc( var( --font-size ) * 3 ) calc( var( --font-size ) * 1.5 ) calc( var( --font-size ) * 6 ); +} + +.page +{ + max-width: 40em; + margin: 0 auto; +} + +header +{ + margin-bottom: calc( var( --font-size ) * 3 ); + padding-bottom: calc( var( --font-size ) * 1.5 ); + border-bottom: 1px solid var(--border); +} + +header .date +{ + font-size: calc( var( --font-size ) * 0.8 ); + letter-spacing: 0.1em; + text-transform: uppercase; + color: var(--muted); + margin-bottom: calc( var( --font-size ) * 0.75 ); +} + +header h1 +{ + font-size: calc( var( --font-size ) * 1.9 ); + font-weight: 700; + letter-spacing: -0.02em; + color: var(--text); +} + +header .subtitle +{ + margin-top: calc( var( --font-size ) * 0.5 ); + color: var(--muted); + font-size: calc( var( --font-size ) * 0.95 ); +} + +section +{ + margin-bottom: calc( var( --font-size ) * 2.5 ); +} + +section h2 +{ + font-size: calc( var( --font-size ) * 1.25 ); + font-weight: 600; + letter-spacing: 0.12em; + text-transform: uppercase; + color: var(--accent); + margin-bottom: calc( var( --font-size ) * 1 ); +} + +.card +{ + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + padding: calc( var( --font-size ) * 1.25 ) calc( var( --font-size ) * 1.5 ); + margin-bottom: calc( var( --font-size ) * 0.75 ); +} + +.card h3 +{ + font-size: calc( var( --font-size ) * 1 ); + font-weight: 600; + margin-bottom: calc( var( --font-size ) * 0.35 ); + color: var(--text); +} + +.card p +{ + font-size: calc( var( --font-size ) * 0.9 ); + color: var(--muted); + line-height: 1.6; +} + +.tags +{ + display: flex; + flex-wrap: wrap; + gap: calc( var( --font-size ) * 0.4 ); + margin-top: calc( var( --font-size ) * 0.75 ); +} + +.tag +{ + background: var(--tag-bg); + color: var(--tag-text); + font-size: calc( var( --font-size ) * 0.75 ); + padding: calc( var( --font-size ) * 0.2 ) calc( var( --font-size ) * 0.6 ); + border-radius: 4px; + font-family: ui-monospace, "Cascadia Code", monospace; +} + +.decision +{ + border-left: 3px solid var(--accent); + padding-left: calc( var( --font-size ) * 1 ); + margin-bottom: calc( var( --font-size ) * 2 ); +} + +.decision p +{ + font-size: calc( var( --font-size ) * 0.9 ); + color: var(--muted); +} + +.decision strong +{ + color: var(--text); + display: block; + margin-bottom: calc( var( --font-size ) * 0.2 ); +} + +footer +{ + margin-top: calc( var( --font-size ) * 4 ); + padding-top: calc( var( --font-size ) * 1.5 ); + border-top: 1px solid var(--border); + font-size: calc( var( --font-size ) * 0.8 ); + color: var(--muted); +} + +a +{ + color: var(--accent); + text-decoration: none; +} + +a:hover +{ + text-decoration: underline; +} + +code +{ + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: 0.85em; + color: var(--accent); + background: var(--tag-bg); + padding: 0.1em 0.35em; + border-radius: 3px; +} + +pre +{ + background: #0a0c14; + border: 1px solid var(--border); + border-radius: 6px; + padding: calc( var( --font-size ) * 1 ) calc( var( --font-size ) * 1.25 ); + overflow-x: auto; + font-family: ui-monospace, "Cascadia Code", "Fira Code", monospace; + font-size: calc( var( --font-size ) * 0.82 ); + line-height: 1.6; + color: var(--text); + margin-top: calc( var( --font-size ) * 0.75 ); +} + +pre code +{ + background: none; + color: inherit; + padding: 0; + font-size: inherit; +} diff --git a/workspace/actions/index.html b/workspace/actions/index.html new file mode 100644 index 0000000..83c0367 --- /dev/null +++ b/workspace/actions/index.html @@ -0,0 +1,54 @@ + + + + + + Actions — Roject + + + + +
+ +
+

Actions

+

Repeatable procedures for maintaining the project. Run these at the appropriate moment in a working session.

+
+ +
+ +
+

Update History

+

+ At the end of a working session, create a day entry summarising what was built, + key decisions made, and any structural changes. Then register it in the history + index and nav data. +

+

+ Read the action +

+
+ +
+

Update Outline

+

+ When the project's scope, structure, or conventions change, update the outline + and keep CLAUDE.md in sync. Covers which cards to update and when. +

+

+ Read the action +

+
+ +
+ +
+ Roject — actions +
+ +
+ + + + + diff --git a/workspace/actions/update-history/index.html b/workspace/actions/update-history/index.html new file mode 100644 index 0000000..ff5f4d8 --- /dev/null +++ b/workspace/actions/update-history/index.html @@ -0,0 +1,148 @@ + + + + + + Update History — Roject + + + + +
+ +
+

Update History

+

How to write and register a session summary at the end of a working session.

+
+ +
+

Summary

+ +
+

+ At the end of every working session, determine the correct day entry, + create or update it, keep the history index and nav data in sync, then + confirm with the user before committing and pushing everything to main. + Always ask before acting — never assume the date and never run git commands + without explicit confirmation. +

+
+
+ +
+

Steps

+ +
+

Step 1 — Determine the date

+

+ Check whether a day entry already exists for today + (i.e. workspace/history/YYYY/MM-Month/DD-Day/index.html + for the current calendar date). +

+

+ If no entry exists for today: ask the user which day to use. + A session may have started on the previous day, so do not assume today is + correct. Present the options clearly and wait for an answer before continuing. + Example prompt: +

+
There is no entry for today (Monday, 6 July 2026) yet.
+Should I create one for today, or update an existing day?
+

+ If an entry already exists for today: skip this question — + you will be updating that entry. +

+
+ +
+

Step 2 — Create or update the day entry

+

Creating a new entry:

+

+ Create the file at the path matching the chosen date: +

+
workspace/history/YYYY/MM-Month/DD-Day/index.html
+

+ Use workspace/_assets_/styles.css and nav.css. + The relative path from a day entry is: +

+
../../../../_assets_/styles.css
+../../../../_assets_/nav.css
+

+ Set NAV_ROOT to '../../../../' for day entries. + Cover: what was built, key decisions made, and any structural changes to the project. +

+ +

Updating an existing entry:

+

+ If the session produced enough new distinct changes, append new cards to the + "What we built" section and add decisions and structural changes as needed. + If the changes are minor (small fixes, wording tweaks, no new features), + adjust the existing content in place rather than appending. +

+
+ +
+

Step 3 — Update the history index

+

+ Open workspace/history/index.html. +

+

+ New day: add a new card at the top of the list with a + relative link — always include index.html explicitly: +

+
<div class="card">
+  <h3><a href="2026/07-July/06-Monday/index.html">Monday, 6 July 2026</a></h3>
+  <p>One-line summary of the session.</p>
+</div>
+

+ Existing day: update the one-line summary in the existing card. +

+
+ +
+

Step 4 — Update nav-data.js (new day only)

+

+ Skip this step if you are updating an existing day entry. +

+

+ Open workspace/_assets_/nav-data.js and add the new entry to the + History children array at the top of the list (most recent first): +

+
{ title: 'Monday, 6 July 2026', path: 'history/2026/07-July/06-Monday/index.html' },
+
+ +
+

Step 5 — Confirm then commit and push

+

+ Before running any git command, present a confirmation prompt to the user. + State exactly what day is being recorded, what the commit message will be, + and where it will be pushed. Wait for the user to approve. +

+

Example prompt:

+
I would update Monday (06.07.26), commit everything as
+"history: file tree rename/delete, locale generator rewrite, workspace nav system"
+and push to main. Shall I proceed?
+

+ The commit message should be a useful one-liner that summarises the session. + Only after the user confirms, run: +

+
git add .
+git commit -m "MESSAGE"
+git push
+

+ If the push fails (e.g. remote has diverged), stop and report the error to + the user. Do not attempt to resolve it automatically. +

+
+ +
+ +
+ Roject — update history +
+ +
+ + + + + diff --git a/workspace/actions/update-outline/index.html b/workspace/actions/update-outline/index.html new file mode 100644 index 0000000..f3beccc --- /dev/null +++ b/workspace/actions/update-outline/index.html @@ -0,0 +1,108 @@ + + + + + + Update Outline — Roject + + + + +
+ +
+

Update Outline

+

When and how to keep the project outline current.

+
+ +
+

Summary

+ +
+

+ The outline at workspace/outline/index.html is the single source of + truth for what the project is, what exists, and what still needs work. Keep it + current whenever the project's scope, structure, or conventions change. Also keep + CLAUDE.md in sync — it should only point to the outline, never + duplicate content. +

+
+
+ +
+

When to Update

+ +
+ After adding or completing a feature +

+ Move items between What exists now and What still needs work + to reflect the current state of the application. +

+
+ +
+ After structural changes +

+ Update the Technical Implementation cards when tsconfig setup, build commands, + directory layout, or major architectural patterns change. +

+
+ +
+ After adding guides or actions +

+ Add links to new guides or actions pages in the outline's reference section so + they are discoverable. Also update workspace/index.html if a new + top-level section (guides, actions) was added. +

+
+
+ +
+

Steps

+ +
+

Step 1 — Update outline/index.html

+

+ Edit workspace/outline/index.html. At minimum update: +

+
    +
  • The What exists now card
  • +
  • The What still needs work card
  • +
  • Any Technical Implementation card whose content changed
  • +
+

+ Always use correct links — never omit index.html from hrefs. +

+
+ +
+

Step 2 — Check CLAUDE.md

+

+ Open CLAUDE.md at the project root and verify it still just points + to the outline. If it has drifted and contains duplicated or stale content, + trim it back so it only references workspace/outline/index.html. +

+
+ +
+

Step 3 — Update nav-data.js if pages were added

+

+ If new guide or action pages were created, add them to + workspace/_assets_/nav-data.js under the correct parent node. + Always use workspace-relative paths. +

+
{ title: 'My New Guide', path: 'guides/my-new-guide/index.html' }
+
+
+ +
+ Roject — update outline +
+ +
+ + + + + diff --git a/workspace/guides/index.html b/workspace/guides/index.html new file mode 100644 index 0000000..8d1931e --- /dev/null +++ b/workspace/guides/index.html @@ -0,0 +1,55 @@ + + + + + + Guides — Roject + + + + +
+ +
+

Guides

+

Reference guides for working in this project. Read the relevant guide before starting a task in that area.

+
+ +
+ +
+

Writing TypeScript Code

+

+ Formatting rules, naming conventions, design patterns, and architectural + principles for all TypeScript in this project. Read this before writing + any application code. +

+

+ Read the guide +

+
+ +
+

Locales

+

+ How user-visible strings are stored as files, auto-generated into typed + TypeScript constants, and fetched at runtime via LocaleManager. + Read this before adding or changing any user-visible text. +

+

+ Read the guide +

+
+ +
+ +
+ Roject — guides +
+ +
+ + + + + diff --git a/workspace/guides/locales/index.html b/workspace/guides/locales/index.html new file mode 100644 index 0000000..05cf7b2 --- /dev/null +++ b/workspace/guides/locales/index.html @@ -0,0 +1,272 @@ + + + + + + Locales — Roject + + + + +
+ +
+

Locales

+

How user-visible strings are stored, generated, and used across the project.

+
+ +
+

Summary

+ +
+

+ Roject uses a file-based locale system. Every user-visible string lives as a + plain file under locales/en/. On server start the generator walks + that tree and produces typed TypeScript classes into + src/locales/generated/. In code, strings are fetched at runtime + through LocaleManager using the generated path constants — for + example LocaleManager.$.get( Locales.FileTree.renameButton_txt ). +

+

+ Rule: whenever you add a new user-visible string, add a locale + file first, regenerate, then reference the constant — never hardcode the string + in TypeScript directly. +

+
+
+ +
+

Locale Source Files

+ +
+ Location +

+ All locale source files live under locales/en/. The en + directory is the English source of truth. One file = one string. The directory + hierarchy is free-form — organise by feature area. +

+
+ +
+ Supported formats +

+ .txt — raw UTF-8 text, used for plain labels and messages.
+ .html — HTML markup, used when the string contains tags.
+ .json — flexible format; must contain at least { "value": "…" }. + Extra fields are ignored by the loader but can carry metadata for tools. +

+
+ +
+ Companion context files (.md) +

+ Any locale file may have a sibling whose name is the full filename plus + .md. For example rename-button.txt may have a companion + rename-button.txt.md. These files are human-readable notes for + translators and contributors. The generator and the app ignore them completely — + they are never served or compiled. +

+
+
+ +
+

Code Generation

+ +
+ When it runs +

+ The generator (server/localeGenerator.ts) runs automatically every + time the server starts via npm start. After adding, renaming, or + removing any locale file, restart the server to regenerate + src/locales/generated/. The generated files are checked into the + repo so the frontend TypeScript compiler can see them without a running server. +

+
+ +
+ Output structure +

+ The generator mirrors the locales/en/ directory tree into + src/locales/generated/. Each directory produces exactly one + .ts file. The root directory produces + src/locales/generated/Locales.ts. Never edit generated files + manually — changes will be overwritten on the next server start. +

+
locales/en/                          →  src/locales/generated/
+  greeting.txt                        →    Locales.ts  (member: greeting_txt)
+  commands/                           →    commands/
+    file-tree-commands/               →      Commands.ts  (member: FileTreeCommands)
+      add-file.txt                    →      file-tree-commands/
+                                      →        FileTreeCommands.ts  (member: addFile_txt)
+
+ +
+ Naming — class names (directories) +

+ Directory names are converted to PascalCase: dashes and underscores split words, + each word is capitalised, the first letter is always uppercase regardless of the + original name. +

+
commands              →  Commands
+file-tree-commands    →  FileTreeCommands
+my_panel              →  MyPanel
+
+ +
+ Naming — member names (files) +

+ File names keep the case of their first character. Dashes convert subsequent + words to camelCase. The file extension is appended after an underscore, with + dots in the extension also replaced by underscores. +

+
add-file.txt          →  addFile_txt
+greeting.txt          →  greeting_txt
+MyFile.html           →  MyFile_html
+config.min.json       →  config_min_json
+
+ +
+ Generated class shape +

+ Each generated class has static readonly members. File members hold + the locale path string (relative to locales/en/, extension included). + Subdirectory members import and re-expose the child class so the entire tree is + reachable from Locales. +

+
// src/locales/generated/commands/file-tree-commands/FileTreeCommands.ts
+// Auto-generated — do not edit manually.
+
+export class FileTreeCommands
+{
+  static readonly addFile_txt = 'commands/file-tree-commands/add-file.txt';
+}
+
+// src/locales/generated/commands/Commands.ts
+// Auto-generated — do not edit manually.
+import { FileTreeCommands } from './file-tree-commands/FileTreeCommands.js';
+
+export class Commands
+{
+  static readonly FileTreeCommands = FileTreeCommands;
+}
+
+// src/locales/generated/Locales.ts
+// Auto-generated — do not edit manually.
+import { Commands } from './commands/Commands.js';
+
+export class Locales
+{
+  static readonly Commands = Commands;
+}
+
+
+ +
+

LocaleManager API

+ +
+ Singleton access +

+ LocaleManager.$ is a static getter that returns the single instance. + No parentheses needed. Import from + src/locales/LocaleManager.ts (compiled to + public/locales/LocaleManager.js). +

+
+ +
+ Fetching a string +

+ LocaleManager.$.get( path ) is async and returns + Promise<string>. Pass a generated path constant — never a raw + string literal. The result is cached by key after the first fetch; subsequent + calls return immediately from cache. If the server returns an error the method + falls back to returning the path string itself so the UI never breaks silently. +

+
const label = await LocaleManager.$.get( Locales.Commands.FileTreeCommands.addFile_txt );
+// → "Add file"
+
+ +
+ Format handling +

+ The manager detects the format from the file extension in the path. + .json files are parsed and data.value is returned. + All other extensions are returned as raw text. +

+
+ +
+ Changing the active locale +

+ The current locale defaults to 'en'. Switch with + LocaleManager.$.setLocale( 'de' ). After switching, call + LocaleManager.$.invalidateCache() to clear cached strings so + the next get call fetches from the new locale. +

+
+
+ +
+

How To — Add and Use a Locale String

+ +
+

Step 1 — Create the locale file

+

+ Choose a location under locales/en/ that matches the feature. + Create a .txt file whose name describes the string. +

+
locales/en/file-tree/rename-button.txt
+content: Rename
+

+ Optionally add a companion context file: +

+
locales/en/file-tree/rename-button.txt.md
+content: Label on the Rename entry in the file tree right-click context menu.
+
+ +
+

Step 2 — Restart the server to regenerate

+

+ The generator runs on every npm start and creates or updates the + generated TypeScript files. After this step the following files exist: +

+
npm start
+
+src/locales/generated/file-tree/FileTree.ts    ← created
+src/locales/generated/Locales.ts               ← updated (FileTree member added)
+
+ +
+

Step 3 — Build

+

+ Compile the generated TypeScript so the browser can import the new constants. +

+
npm run build
+
+ +
+

Step 4 — Use in TypeScript

+

+ Import LocaleManager and Locales, then await the string. + Always import Locales (not individual child classes) so the full + path is visible at the call site and easy to trace back to the source file. +

+
import { LocaleManager } from '../../locales/LocaleManager.js';
+import { Locales } from '../../locales/generated/Locales.js';
+
+const label = await LocaleManager.$.get( Locales.FileTree.renameButton_txt );
+button.textContent = label;
+
+
+ +
+ Roject — locales +
+ +
+ + + + + diff --git a/workspace/guides/writing-typescript-code/index.html b/workspace/guides/writing-typescript-code/index.html new file mode 100644 index 0000000..36329b2 --- /dev/null +++ b/workspace/guides/writing-typescript-code/index.html @@ -0,0 +1,215 @@ + + + + + + Writing TypeScript Code — Roject + + + + +
+ +
+

Writing TypeScript Code

+

Conventions, formatting rules, and architectural patterns for all TypeScript in this project.

+
+ +
+

Summary

+ +
+

+ This is a real application — not a library, not a framework demo. Write code accordingly: + use singletons and hard coupling where they make sense, avoid event buses and loose + indirection, prefer classes over interfaces except at data boundaries. Follow the + formatting and naming rules below consistently across all files. +

+
+
+ +
+

Formatting

+ +
+ Brackets on a new line +

All opening and closing brackets go on their own line, never at the end of the preceding line.

+
+ +
+ Indentation — soft tabs, 2 spaces +

Never use hard tabs. Always use soft tabs with two spaces.

+
+ +
+ Spacing around operators +

All operators have spaces on both sides. Add a space after opening brackets and before closing brackets.

+
+
+ +
+

Naming

+ +
+ Default: lowercase camelCase +

+ Variables, parameters, and instance members use lowercase camelCase. + The following always start with an uppercase letter: class names, + const / static / readonly members. +

+
+ +
+ Underscore prefix for internal members +

+ Prefer public or protected over private. + Use a leading underscore on public members that are considered internal or + unstable — this signals intent without locking down access. +

+
+
+ +
+

Design

+ +
+ Static factory methods over parametric constructors +

+ Avoid constructors with parameters that shadow a no-argument version. + Use named static factory methods instead — they make intent explicit and + keep construction flexible. +

+
+ +
+ Early return over nesting +

+ Avoid deeply nested conditionals. Return, continue, or break as early as + possible to keep the happy path at the lowest indentation level. +

+
+ +
+ Extract blocks into named functions +

+ Keep functions focused and concise. When a logical block deserves explanation, + extract it into a separately named function rather than writing a long inline comment. +

+
+ +
+ Prefer if / else if over switch +

Conditional chains using if / else if are preferred over switch statements.

+
+ +
+ Initialise all members +

+ Assign an initial value to every class member at declaration. + Nullable types are permitted — the goal is to make the default state + explicit, not to avoid null entirely. +

+
+ +
+ Constants on the left in comparisons +

+ In equality checks, place the constant on the left: + null == value, "set" == command. + Null checks remain on the right. +

+
+
+ +
+

Architecture

+ +
+ Classes, not interfaces, for application code +

+ Use classes for all application logic. Reserve interfaces for data that + crosses a system boundary — serialisable objects, API request/response shapes, + data read from streams or files. +

+
+ +
+ Singletons and hard coupling +

+ This is a real app with known, stable relationships between its parts. Use + singletons where a single shared instance makes sense. Couple components + directly when the relationship is fixed — do not introduce indirection for + its own sake. +

+
+ +
+ No event buses +

+ Do not use event buses, pub/sub systems, or loose message-passing patterns. + Call things directly. If a component needs to react to something, wire it up + explicitly at the point where both sides are known. +

+
+ +
+ EventSlot for callbacks +

+ When a component needs to expose a callback hook, use EventSlot + from the shared library — not a plain function property or a custom event. +

+
+
+ +
+

Example

+ +
+
export class MyClass
+{
+  name = "";
+  value = 0;
+  data:string[] = [];
+
+  static create( name:string, value:number = 1 )
+  {
+    let mc = new MyClass();
+    mc.name = name;
+    mc.value = value;
+    return mc;
+  }
+
+  doStuff( command:string )
+  {
+    if ( command === null || command === undefined )
+    {
+      return;
+    }
+
+    if ( "set" === command )
+    {
+      this.value = 1;
+    }
+    else if ( "add:3" === command )
+    {
+      this.value = 3;
+    }
+    else if ( "data:4:x" === command )
+    {
+      this.data[ 4 ] = "x";
+    }
+  }
+}
+
+
+ +
+ Roject — writing TypeScript code +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/01-Wednesday/implementation-details.txt b/workspace/history/2026/07-July/01-Wednesday/implementation-details.txt new file mode 100644 index 0000000..dac39f5 --- /dev/null +++ b/workspace/history/2026/07-July/01-Wednesday/implementation-details.txt @@ -0,0 +1,32 @@ + + + +[ Frontend ] + +No tailwind. Raw css. + +Use only custom elements with JS implementation: + + + + + + +And than create for each a css: +Like login-header.css + +login-header +{ + display:block +} + + +[ Base Structure ] + +For the user-managment create: +create, login, logout, delete + +For users create: +group-editing (creating, delete, adding/removings users/group ) +project-editing creating, delete, adding/removings users/group ) + diff --git a/workspace/history/2026/07-July/01-Wednesday/index.html b/workspace/history/2026/07-July/01-Wednesday/index.html new file mode 100644 index 0000000..10c16b8 --- /dev/null +++ b/workspace/history/2026/07-July/01-Wednesday/index.html @@ -0,0 +1,159 @@ + + + + + + Session Summary — 1 July 2026 + + + + +
+ +
+

Wednesday, 1 July 2026

+

Roject — Initial Build

+

Session summary: from blank directory to a typed, UUID-based CMS skeleton.

+
+ +
+

What we built

+ +
+

Node.js + Express Backend

+

+ A REST API server handling authentication, groups, and projects. + Organised into route modules with a shared auth middleware that guards + all non-public endpoints via express-session. +

+
+ express + express-session + bcryptjs +
+
+ +
+

User Management

+

+ Four operations: register, login, logout, and delete account. + Passwords are hashed with bcrypt. Session stores the user's UUID and + username for the lifetime of the browser session. +

+
+ POST /api/auth/register + POST /api/auth/login + POST /api/auth/logout + DELETE /api/auth/me +
+
+ +
+

Groups & Projects

+

+ Both entities support create, delete, and member management. + Members can be either a user or another group. Project members + additionally carry a role (viewer, editor, admin). +

+
+ /api/groups + /api/projects + member_type: user | group + role +
+
+ +
+

Web Component Frontend

+

+ Five custom elements, each with its own .ts and .css file. + No framework, no build tool beyond plain tsc, no Tailwind. + CSS uses the element tag as the root selector with display: block. +

+
+ <login-form> + <register-form> + <app-nav> + <group-editor> + <project-editor> +
+
+
+ +
+

Key Decisions

+ +
+ JSON file store instead of SQLite +

+ better-sqlite3 requires native compilation and failed on Node 24 without + build tools. Replaced with plain fs.readFileSync / fs.writeFileSync + on per-table JSON files. Zero dependencies, good enough for MVP scale. +

+
+ +
+ TypeScript via plain tsc — no bundler +

+ Server runs with ts-node. Frontend TypeScript lives in + src/components/ and compiles to public/components/ + via a separate tsconfig.client.json with module: none, + keeping each component as a standalone global script. +

+
+ +
+ UUIDs for all IDs +

+ Integer IDs would collide across parallel server instances. Switching to + crypto.randomUUID() (built-in, no extra dependency) means + any server can generate IDs independently, making future data merging + or multi-node deployments straightforward. +

+
+ +
+ Scripts at bottom of body +

+ Custom elements are registered after the browser has parsed the HTML, + so connectedCallback fires on an already-present element. + Equivalent to defer in head, but explicit by position. +

+
+
+ +
+

Project Structure

+
+

+ server/ — TypeScript backend source
+ src/components/ — TypeScript frontend source
+ public/ — compiled JS, CSS, and HTML pages served statically
+ data/ — auto-created JSON data files
+ history/ — session logs (this file)
+ CLAUDE.md — project conventions loaded by Claude Code automatically +

+
+
+ +
+

What is missing

+
+

+ The HTML document editor itself — creating and editing actual content — is + not yet built. The session covers only the scaffolding: auth, group management, + project management, and the TypeScript + component infrastructure to build on. +

+
+
+ +
+ Roject — session log — 1 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/01-Wednesday/project-definition.txt b/workspace/history/2026/07-July/01-Wednesday/project-definition.txt new file mode 100644 index 0000000..d20abe7 --- /dev/null +++ b/workspace/history/2026/07-July/01-Wednesday/project-definition.txt @@ -0,0 +1,32 @@ + + +[ Technical ] + +I want to create an application a server that uses Node.js in the backend and +has an HTML frontend that is run in a browser (mobile and desktop). + +[ MVP ] + + +The app is used to create, edit and store (simple) HTML documents with assets in projects. +The HTML documents are like normal web documents, mostly no JS from the user side, only as special plugins. + + +The app has: +- users +- groups +- projects + + +User and groups have similar settings: +- roles + + +The roles are used for permissions that a user or group can have. + + +[ Extra ] +- The frontend can be run as desktop program (an electron app?) +- The frontend can be run as native app pn android/iOS + + diff --git a/workspace/history/2026/07-July/02-Thursday/desktop-layout.jpg b/workspace/history/2026/07-July/02-Thursday/desktop-layout.jpg new file mode 100644 index 0000000000000000000000000000000000000000..59c2cb3e38184853fafe211dc188cf7cb3ba85d0 GIT binary patch literal 31143 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 + + +DESKTOP LAYOUTMOBILE LAYOUTTAB CONTAINERTAB ATAB BTAB CEDITOR PANEL diff --git a/workspace/history/2026/07-July/02-Thursday/ideas.txt b/workspace/history/2026/07-July/02-Thursday/ideas.txt new file mode 100644 index 0000000..d8a2ebe --- /dev/null +++ b/workspace/history/2026/07-July/02-Thursday/ideas.txt @@ -0,0 +1,9 @@ +Layout for Project Root + +[ project-XXX ] +- History +- Documentation +-- What is it about? +- Root +- Collections +- To Do \ No newline at end of file diff --git a/workspace/history/2026/07-July/02-Thursday/index.html b/workspace/history/2026/07-July/02-Thursday/index.html new file mode 100644 index 0000000..fe59dd1 --- /dev/null +++ b/workspace/history/2026/07-July/02-Thursday/index.html @@ -0,0 +1,233 @@ + + + + + + Session Summary — 2 July 2026 + + + + +
+ +
+

Thursday, 2 July 2026

+

Roject — Editor Build

+

Session summary: TypeScript conversion, UUID IDs, CLAUDE.md, and a full project editor with WYSIWYG HTML editing.

+
+ +
+

What we built

+ +
+

TypeScript Conversion

+

+ All server and frontend JavaScript converted to TypeScript. Server runs via + ts-node using tsconfig.json. Frontend components + compile from src/components/ to public/components/ + via tsconfig.client.json with module: none — no bundler, + each component is a self-contained global script. Interfaces added for all + data shapes. +

+
+ typescript + ts-node + tsconfig.client.json + module: none +
+
+ +
+

UUID IDs

+

+ All entity IDs (users, groups, projects, members) switched from integers to + UUIDs via crypto.randomUUID(). This allows multiple server instances + to generate IDs independently without collision, enabling data merging and + parallel deployments without a central ID authority. +

+
+ crypto.randomUUID() + id: string + no extra dependency +
+
+ +
+

Project File Storage

+

+ Creating a project now creates a real directory structure on the server under + storage/<project-id>/root/. A default index.html + with a Hello World <page-content> element is generated automatically. + A new /api/files route handles tree listing and file read/write + with path traversal protection. +

+
+ storage/ + GET /api/files/:id/tree + GET /api/files/:id/* + PUT /api/files/:id/* +
+
+ +
+

Editor Page

+

+ A full editor page (/editor.html) with a 3-panel layout: Left + (file tree), Center (HTML editor), Right (empty, accepts dropped tabs). Panels + are resizable via drag handles. Sections within a panel are arranged + side by side and can be split using the ⊟ button on each tab container. + Portrait mode shows one panel at a time, switched via a top bar. +

+
+ <editor-shell> + 3-panel layout + resize handles + portrait mode +
+
+ +
+

Tab Container

+

+ A custom <tab-container> element manages tabs within each + panel section. Tabs are draggable between containers using the HTML5 Drag and + Drop API. A dirty dot (●) appears on tabs with unsaved changes. The ⊟ split + button creates a new side-by-side section in the same panel. +

+
+ <tab-container> + drag and drop + dirty indicator + section split +
+
+ +
+

HTML Editor Panel

+

+ A WYSIWYG editor that loads HTML documents in an iframe and makes the + <page-content> element contenteditable. Changes are tracked + via MutationObserver. Includes an undo/redo stack (up to 200 steps), + Ctrl+S save, and a save button that highlights when the document is dirty. +

+
+ <html-editor-panel> + iframe WYSIWYG + MutationObserver + undo/redo + Ctrl+S +
+
+ +
+

File Tree Panel

+

+ A <file-tree-panel> component fetches and renders the + project's root/ directory structure. HTML files are clickable + and open in the HTML editor. Directories are collapsible. Non-HTML files + are shown but not openable in this version. +

+
+ <file-tree-panel> + collapsible dirs + HTML-only editing +
+
+
+ +
+

Key Decisions

+ +
+ LayoutPanel sections are arranged horizontally +

+ Sections within a panel sit side by side (not stacked), so the Right panel + can hold an object graph next to an inspector. Each section can contain + multiple TabContainers stacked vertically. Portrait mode shows one section + at a time via a secondary tab row below the main panel switcher. +

+
+ +
+ Fixed 3-column structure, flexible rows +

+ The editor has three named panels (Left, Center, Right). The column count + is fixed because portrait mode maps each column to one top-bar button — a + clean 1-to-1 relationship. Within each column, sections and tab containers + can be freely split and resized. +

+
+ +
+ Tab drag uses HTML5 DnD, not pointer events +

+ HTML5 Drag and Drop handles cross-container tab moves via + application/editor-tab in dataTransfer. The source container + exposes an extractTab() method and the target calls + receiveTab(), preserving the panel's DOM element and state + across moves. +

+
+ +
+ EditorState on window, not imported +

+ Since module: none forbids imports between frontend files, + shared state lives on window.editorState, initialised + synchronously in editor-shell before any child component + connects. Other components wait via customElements.whenDefined() + before accessing it. +

+
+ +
+ Real-time collaboration deferred +

+ The spec described multi-user selections but building WebSocket sync would + have consumed the full session. The document/state model is designed for it — + EditorState can emit events that a future sync layer hooks into. +

+
+
+ +
+

Project Structure

+
+

+ server/storage.ts — project file storage helpers
+ server/routes/files.ts — file tree + read/write API
+ storage/ — project file directories (auto-created)
+ src/components/editor-shell/ — main editor layout + EditorState
+ src/components/tab-container/ — tabbed panel with drag-drop
+ src/components/file-tree-panel/ — project file browser
+ src/components/html-editor-panel/ — WYSIWYG iframe editor
+ public/editor.html — editor page entry point
+ CLAUDE.md — project conventions (UUID IDs, raw CSS, custom elements) +

+
+
+ +
+

What is missing

+
+

+ Real-time multi-user collaboration and shared selections are not implemented. + The file tree is read-only — creating, renaming, and deleting files from the + editor is not yet supported. The Right panel is empty by default and requires + manual tab dragging to populate. Portrait secondary section switching (when a + panel has multiple side-by-side sections) is defined but not yet wired up as + a secondary tab row. +

+
+
+ +
+ Roject — session log — 2 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/02-Thursday/mobile-layout.jpg b/workspace/history/2026/07-July/02-Thursday/mobile-layout.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f60684ce1c9043a8de2fe14ad26ce79e0655a75b GIT binary patch literal 23807 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

G}eVmP3C=~AMxhS*!Fv*tNAv)v__ zr5uB61lYk9&aOeN}S0z7YW1*G{w)0&pEJVoCA01IaodJ>3Wcn6U{d0X=k> zUat4t!vNo#$ZX!49S17_{yXdlgs?$okdr?w_U|m#ugKrIE@102yJg4CXcwBP$HyjK z?CxpHK9DG9e2Y-}VSI!QzK?)D_e{)QM0!LDXJm)6ChGkBQ#=PSNe9)R7>p|RTfgt; zL1Je-w&|^*eT#REQ@$~Kc!hHR1qygLwM|nnC~k>4HEQjQh7xy)UB!Ke)L^YFP(E#| UUsyx^@%w+@Sp5H*&UnrL2dVrui2wiq literal 0 HcmV?d00001 diff --git a/workspace/history/2026/07-July/02-Thursday/project-editing-update.txt b/workspace/history/2026/07-July/02-Thursday/project-editing-update.txt new file mode 100644 index 0000000..7f3197b --- /dev/null +++ b/workspace/history/2026/07-July/02-Thursday/project-editing-update.txt @@ -0,0 +1,64 @@ +Updating the project editing: +[ Feature Update: Project Creation and Editing ] + +As a user I want when I create a project to create a directory on the server. +The root of this project directory and its files is not accessible directly for the user. +It will contain settings and other organizational things. +However there's one directory called 'root' where the user is able to edit all files and subdirectories. +The creation of the project will create a first file "index.html" that can be viewed and edited, it contains a "Hello World!". + + +This update will add the feature, that projects create a real directory structure with an initial file in root. + +After the project was created in projects, in the project entry a button "Edit" appears next to "Members" and "Delete". + +It will open the "editor", which is a new page that is used for editing several things inside the project. + + + +[ Editor Page ] +The editor page uses a system known from other editing system, where the editor has an active document, multiple selections +and maybe other editor related states (maybe somthing indicating loading or dialogs etc). The selections represent +possible other users that are also editing the document (via networking in realtime). +It has typical (asynchronous) undo system, that allows to edit a document. It should also + +The EditorUI is built up as a customizable UI, which contains EditorPanels +that do things by listening to changes of the document, the selections +and editor states. + +The editor UI allows to rearrange the EditorPanels. They are organized in a resizable grid of LayoutPanels, +which contain TabContainers, that hold multiple EditorPanels but show only the active one that is the selected tab. + +For Landscape layouts: +3 LayoutPanels are used as base. Each layout panel can be divided horizontally which forms a LayoutPanelSection, which can +hold up to 3 TabContainers. + +For Portrait layouts: +Only one LayoutPanelSection is visible. The others can be selected by the mainbar on top. They also contain up to 3 TabContainers. + + +Mocks can be found here: +history/2026/07-July/02-Thursday/desktop-layout.jpg +history/2026/07-July/02-Thursday/mobile-layout.jpg +history/2026/07-July/02-Thursday/tab-container.jpg + + +[ Editor Panels] +There will be multiple, different EditorPanels that have different tasks. + +-- ActiveDocumentTabContainer:TabContainer +A special tab container, that contains a list of the active files that are currently in editing as well as +an DocumentEditor, an archetype for document editors that will be able to save/load document + +-- HTMLEditor:DocumentEditor +A very rudimentary editor, that doesn't do much than just displaying the content and makes most elements "contenteditable" when they are +clicked. Head and Body should not be changable. There will one element named "" which will be editable. + +-- Project File Tree Viewer +Lists the directory structure of the project root + + +[ Creating/Opening a fresh project ] + +On Desktop opening a project in the editor should load the index.html file in the HTMLEditor in the center and +show on the left side the project tree view. \ No newline at end of file diff --git a/workspace/history/2026/07-July/02-Thursday/tab-container.jpg b/workspace/history/2026/07-July/02-Thursday/tab-container.jpg new file mode 100644 index 0000000000000000000000000000000000000000..7de9ba43af4e1a356948617c838a39a2b00d92d2 GIT binary patch literal 16585 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 z cOX>-^dEi^44*!AQWd4u*7WSXA!>FJC6K_hs9{>OV literal 0 HcmV?d00001 diff --git a/workspace/history/2026/07-July/03-Friday/index.html b/workspace/history/2026/07-July/03-Friday/index.html new file mode 100644 index 0000000..df5c385 --- /dev/null +++ b/workspace/history/2026/07-July/03-Friday/index.html @@ -0,0 +1,221 @@ + + + + + + Session Summary — 3 July 2026 + + + + +

+ +
+

Friday, 3 July 2026

+

Roject — ES Modules & Library Integration

+

Session summary: confirm-dialog fix, ES module refactor, shared library submodule, TypeScript project references.

+
+ +
+

What we fixed

+ +
+

Confirm Dialog & Build Workflow

+

+ The project delete confirm dialog was not working because npm run build + had not been run after the last code change, and the server had not been restarted. + The compiled .js in public/ was stale. No code change + was needed — the fix was running the build and restart. Established that debugging + frontend issues always requires a fresh build first. +

+
+ npm run build + stale compiled output + confirm-dialog +
+
+
+ +
+

What we built

+ +
+

ES Module Refactor

+

+ The frontend was rewritten from module: none (global scripts) to + proper ES modules. tsconfig.client.json now uses + module: ESNext and moduleResolution: bundler. + All HTML pages switched from <script src> to + <script type="module" src>. Components now use + import / export and no longer rely on global scope. + The browser deduplicates shared module imports automatically. +

+
+ module: ESNext + moduleResolution: bundler + script type="module" + import / export +
+
+ +
+

EditorState as a Proper Module

+

+ EditorState was extracted from editor-shell.ts into its + own module at src/components/editor-state.ts. It exports + setEditorState() (called by editor-shell on init) and + getEditorState() (called by any component that needs it). + The window.editorState global is gone. Components import what they + need directly. +

+
+ editor-state.ts + getEditorState() + setEditorState() + no window globals +
+
+ +
+

Confirm Dialog Export

+

+ showConfirmDialog and ConfirmDialogOptions are now + properly exported from confirm-dialog.ts. + project-editor.ts imports showConfirmDialog directly + instead of using a /// <reference path> directive. The + redundant <script> tag for confirm-dialog.js in + projects.html was removed — the browser loads it via the import graph. +

+
+ export showConfirmDialog + import instead of reference + no redundant script tag +
+
+ +
+

Shared Library as Git Submodule

+

+ A personal TypeScript library was added as a git submodule at + src/library-ts/. The library has two parts: + browser/ (DOM-capable, usable with shims on Node) and + node/ (Node.js only). Placing the submodule inside src/ + keeps rootDir: "src" intact so the output URL structure in + public/ is unchanged. +

+
+ git submodule + src/library-ts/ + browser/ + node/ +
+
+ +
+

TypeScript Project References

+

+ The library was written without strict: true, causing hundreds of + errors when compiled with Roject's strict settings. TypeScript project references + solve this: a new tsconfig.roject.json inside + src/library-ts/browser/ compiles the browser part separately with + strict: false, composite: true, and + declaration: true. Roject's tsconfig.client.json + excludes src/library-ts/**/* and references the library project. + Build now runs as tsc --build tsconfig.client.json. +

+
+ project references + composite: true + declaration: true + tsc --build +
+
+ +
+

Server-Side Library Configuration

+

+ The node part of the library caused the same strict errors when compiled by + ts-node. A dedicated tsconfig.ts-node.json extends the + server tsconfig but turns off strictNullChecks (the only check the + library violates) and adds src/library-ts/node/**/* to the include. + The start script now passes --project tsconfig.ts-node.json to + ts-node. +

+
+ tsconfig.ts-node.json + strictNullChecks: false + ts-node --project +
+
+
+ +
+

Key Decisions

+ +
+ ES modules over global scripts +

+ module: none was originally chosen to avoid a bundler, but it also + ruled out import / export and forced the + window.editorState workaround. Browsers support ES modules natively + via type="module" scripts — no bundler needed. The switch removes + all global-scope workarounds and makes the library integration straightforward. +

+
+ +
+ Submodule inside src/, not at project root +

+ Placing the submodule at src/library-ts/ keeps + rootDir: "src" in the client tsconfig unchanged. A submodule at the + project root would require either changing rootDir (breaking the + /components/... URL structure) or setting up path aliases. +

+
+ +
+ Separate tsconfig per compilation unit, not a single monolithic one +

+ Three tsconfigs now serve distinct purposes: tsconfig.json (server, + strict), tsconfig.client.json (frontend, strict, references library), + tsconfig.ts-node.json (runtime, relaxed for library compatibility). + This keeps strict checking on all first-party code without patching the library. +

+
+ +
+ Only strictNullChecks turned off for ts-node +

+ All three errors in the node library were strictNullChecks violations. + Rather than disabling all of strict, only that one flag is overridden + in tsconfig.ts-node.json. Server code retains + noImplicitAny, strictFunctionTypes, + strictPropertyInitialization, and all other strict checks. +

+
+
+ +
+

Project Structure

+
+

+ src/components/editor-state.ts — EditorState module (getEditorState / setEditorState)
+ src/library-ts/browser/ — shared browser library (git submodule)
+ src/library-ts/node/ — shared node library (git submodule)
+ src/library-ts/browser/tsconfig.roject.json — library browser tsconfig (strict: false, composite)
+ tsconfig.client.json — frontend: ESNext modules, references library
+ tsconfig.ts-node.json — ts-node runtime: extends server tsconfig, strictNullChecks: false
+ public/library-ts/browser/ — compiled library output with .d.ts files +

+
+
+ +
+ Roject — session log — 3 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/04-Saturday/documentation-update.txt b/workspace/history/2026/07-July/04-Saturday/documentation-update.txt new file mode 100644 index 0000000..b9de226 --- /dev/null +++ b/workspace/history/2026/07-July/04-Saturday/documentation-update.txt @@ -0,0 +1,29 @@ +[ Documentation Update ] +The project needs a proper html developer documentation. +It will get an html documentation that will be readable for the human and agent developers (replacing claude.md) + +It will get its first index.html in "doc/outline/index.html" in the root path. + +It will contain the entry point for the agent to ensure it is on track. + + +It contains the following paragraphs: + +- project outline: +Explains what the project is about (high level short), what it does (currently), and what is should do (todos for the ). + +- technical implementation: +Explains the tech stack, includes a link to the coding guidelines "doc/outline/coding-guidelines", +which should be copied from https://rokojori.com/en/labs/rokojori-action-library/coding-guidelines. + +- actions: +Contains a set of tasks that are used repeatadly. It should contain for each action a short summary +what it does. The first two actions are: + +Update History: +This action updates what was done in the last session of the day. It should create an entry for the day the session started +and use the "history/styles.css" to create an html document. An example path for the 4th July is "history/2026/07-July/04-Saturday" +It should also update the main "history/index.html", which contains a list of all history entries. On every update this file is updated. + +Update Documentation: +This action updates the documentation, by updating the project outline and maybe more things for the documentation. diff --git a/workspace/history/2026/07-July/04-Saturday/index.html b/workspace/history/2026/07-July/04-Saturday/index.html new file mode 100644 index 0000000..00d12d6 --- /dev/null +++ b/workspace/history/2026/07-July/04-Saturday/index.html @@ -0,0 +1,176 @@ + + + + + + Session Summary — 4 July 2026 + + + + +
+ +
+

Saturday, 4 July 2026 — Sunday, 5 July 2026

+

Roject — Developer Documentation

+

Session summary: doc/ folder restructure, project outline, coding guidelines, history index, CLAUDE.md cleanup.

+
+ +
+

What we built

+ +
+

Folder Restructure

+

+ The history/ folder moved to doc/history/ and + history/styles.css moved to doc/_assets_/styles.css + to serve as a shared stylesheet for all documentation. All existing history + entry CSS paths were updated accordingly. The new doc/ root is + the single home for all project documentation going forward. +

+
+ doc/ + doc/_assets_/styles.css + doc/history/ + doc/outline/ +
+
+ +
+

Project Documentation Outline

+

+ Created doc/outline/index.html — the main developer reference, + readable by both humans and agent contributors. It contains three sections: + a project outline (what Roject is, current state, loose todos), a technical + implementation overview (backend, frontend, shared library with tags), and + an actions section that defines repeatable tasks for Update History and + Update Documentation. +

+
+ doc/outline/index.html + project outline + tech stack + actions +
+
+ +
+

Coding Guidelines

+

+ Created doc/outline/coding-guidelines/index.html based on the + rokojori action library guidelines. Covers formatting (brackets on new line, + 2-space soft tabs, spacing around operators), naming (camelCase, uppercase + for class names and statics, underscore prefix for internal members), and + design rules (static factory methods, early return, extract to named + functions, if/else over switch, initialise all members). Includes a + TypeScript example. C# and PHP examples were omitted. +

+
+ doc/outline/coding-guidelines/ + TypeScript example + formatting + naming +
+
+ +
+

History Index

+

+ Created doc/history/index.html — a chronological list of all + session entries, most recent first, each with a one-line summary and a link + to the full entry. This file is updated by the Update History action at the + end of every session. +

+
+ doc/history/index.html + session index +
+
+ +
+

Stylesheet Extensions

+

+ Added a, code, pre, and + pre code styles to doc/_assets_/styles.css to + support the coding guidelines page. Inline code gets the accent colour on a + dark tag background; code blocks get a darker background with monospace font + and horizontal scrolling. +

+
+ pre + code + doc/_assets_/styles.css +
+
+ +
+

CLAUDE.md

+

+ Stripped CLAUDE.md down to a single line pointing to + doc/outline/index.html. All content that was in CLAUDE.md + (tech stack, conventions, IDs, TypeScript setup, commands, project structure) + is now in the outline with more detail. +

+
+ CLAUDE.md + pointer only +
+
+
+ +
+

Key Decisions

+ +
+ Documentation lives in doc/, not project root +

+ Keeping all documentation under a single doc/ directory makes the + root cleaner and gives history and outline a shared home without polluting the + server or source directories. +

+
+ +
+ Todos are a loose reminder, not a backlog +

+ The "what still needs work" card in the outline is intentionally written as + prose, not a checklist. It is updated on the go as features are added rather + than maintained as a formal task tracker. +

+
+ +
+ Actions are written for agents and humans alike +

+ The actions section in the outline describes repeatable tasks (Update History, + Update Documentation) with enough detail that an agent can execute them + correctly without asking follow-up questions: which file to create, what CSS + path to use, what to update after. +

+
+
+ +
+

Project Structure

+
+

+ doc/_assets_/styles.css — shared stylesheet for all doc pages
+ doc/outline/index.html — main developer documentation
+ doc/outline/coding-guidelines/index.html — formatting and naming rules
+ doc/history/index.html — session history index
+ doc/history/YYYY/MM-Month/DD-Day/index.html — individual session entries
+ CLAUDE.md — one-line pointer to doc/outline/index.html +

+
+
+ +
+ Roject — session log — 4–5 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/05-Sunday/Editor-Updates.txt b/workspace/history/2026/07-July/05-Sunday/Editor-Updates.txt new file mode 100644 index 0000000..58096e7 --- /dev/null +++ b/workspace/history/2026/07-July/05-Sunday/Editor-Updates.txt @@ -0,0 +1,37 @@ +[ Localization System ] +There will be a directory "locales", with a subdirectory called "en". In this directory can have files or subdirectories. It can be either a file ending with html, txt, json. All the files are local files containing localization data. html will have a root element, txt just raw utf8 text and the json file will be an object containing at least a member "value" with a string. There will also be md files that are only valid when they are also available as html, txt or json. For instance a "greeting.html" can have a "greeting.html.md". This md file which is markdown and contains context information for the translation, it is optional. + +Every time a file was added, removed or changed the system updates and creates a new TypeScript class that represents the structure as nested classes where a class represents a directory and a file a member. The root class name is Locales. The names should be the dash separated names, that are converted to allowed names in TypeScript, the dots in the filenames are replaced by "_". So a "greeting.html" becomes "greetings_html". The member types are strings that represent the path relative from their root locale "en". So "locales/en/greeting.html" will be Locales.greeting_html and contains "greeting.html". Something in a directory "locales/en/editor/confirm-project-deletion.html" becomes Locales.editor.confirmProjectDeletion_html with "editor/confirm-project-deletion.html" as value + +The app will have a LocaleManager that can change the language at runtime where it will change the path from en to the selected locale. A translation for German would be in de. All strings will be replaced after placed in elements directly. Most elements that are created/rendered need to resolve the text. + +[ Command Class ] +There will be commands in the editor, which have: +- A short title +- A small info +- Multiple assignments for assigning shortcuts for keys, mouse with conditions +The conditions are simple flags of the application like hasSelection or textDialogOpen etc. + +The commands can be in its own directory commands. With the highest class a CommandManager which can than have member classes for sub domains. Like FileTreeCommands or HTMLEditorCommands => CommandManager.FileTreeCommands.addFile. +The commands should have a structure where the have an execute function that gives the execution context, it can be null and than its created automatically. It's for tracking recursive commands that maybe are buggy and later stuff: + +CommandManager.FileTreeCommands.addFile.execute( CommandManager.createContext() ); +CommandManager.FileTreeCommands.addFile.execute( context ); + + + +[ ContextMenu Class ] +There should be a context menu class that can be used in the editor to create nested menus. +For the first iteration the main ContextMenuItems are a ContextMenuEntry for a "close panel" or a ContextMenuDirectory which opens another nested menu like "panels >" that contains more ContextMenuEntries. There should also be ContextMenuReadOnlyEntry to allow for showing info text. Also there should be a ContextMenuSeparator which adds a separator. In the future there could be other types like a row of buttons or maybe a slider or an image that is as high as multiple rows. It should also be possible to define a non-closing behaviour, where items are not closed on Click/Tap. +A ContextMenu is constructed by creating a root ContextMenuDirectory without a parent (it is null) and then adding ContextMenuItems (like the entry, directory, separator etc). A ContextMenuItem always gets the parent ContextMenuDirectory passed to be able to resolve its hierarchy. The root has null as parent. +The ContextMenuDirectory has a method named show which will add the DOM elements. They can be created "lazy" on show. +A ContextMenuDirectory will hide itself when a sibling is shown, so that only one child of a parent is visible and not multiple siblings. + +[ Tab Container/EditorPanel Update ] +The button that is used for splitting should be replaced by a generic menu button that opens a ContextMenu. Its first options should be "add >", "duplicate", "split" and "close". "Add" contains a list of possible EditorPanels, which will be added to the tab container. "Duplicate" duplicates the active tab. "Split" is the current function that is used to split the panels and "close" will close the active tab. + +Every EditorPanel should have a function to add ContextMenuEntries to a passed ContextMenuDirectory. If it has no items, it should pass an empty info. + +[ File Tree Panel Update ] +It will get two commands that are AddFile and AddDirectory, which will do what their names say. When nothing is selected it creates it in the root, when something is selected it takes the most first, highest directory. + diff --git a/workspace/history/2026/07-July/05-Sunday/index.html b/workspace/history/2026/07-July/05-Sunday/index.html new file mode 100644 index 0000000..1828d4e --- /dev/null +++ b/workspace/history/2026/07-July/05-Sunday/index.html @@ -0,0 +1,347 @@ + + + + + + Session Summary — 5 July 2026 + + + + +
+ +
+

Sunday, 5 July 2026

+

Roject — Session Summary

+

+ Editor overhaul: ContextMenu, EventSlot refactor, session persistence, + tab drag fixes, panel header redesign, FileTree sub-root, Init/Pin buttons, + resize handling, portrait bar move, layout persistence per user per device. +

+
+ +
+

What we built

+ +
+

ContextMenu Class Hierarchy

+

+ Introduced a plain class hierarchy (not a Web Component) for building + nested context menus: ContextMenuDirectory, + ContextMenuEntry, ContextMenuReadOnlyEntry, + and ContextMenuSeparator. The root directory has + show(x, y) which renders lazily and positions itself in the + correct viewport corner (right/left, below/above) based on available space. + A clearCloseUpwards() walk was added to cancel all ancestor + close timers when a submenu is entered, fixing the bug where moving the + cursor from a parent menu into a child submenu closed the parent. +

+
+ src/components/context-menu/ + smart corner positioning + clearCloseUpwards +
+
+ +
+

Tab Container ⋮ Menu

+

+ Replaced the split button with a menu button + that opens a ContextMenu. Options: Add > (HTML Editor, File Tree), + Duplicate, Split, Close. Duplicate was fixed by + storing a factory: () => HTMLElement function in each + TabEntry so new instances can be created without re-using the + existing element. The tab-container:add-panel event is now + handled in EditorShell's setupSplitListener. +

+
+ tab-container.ts + TabEntry.factory + tab-container:add-panel +
+
+ +
+

FileTree: Add File / Add Directory

+

+ Added +F and +D buttons to the FileTree header. + Clicking either resolves the target directory from the current selection, + finds a free name by scanning the tree, then shows an inline overlay with + an input field, Create, and Cancel. Server-side endpoints + POST /:projectId/create-file and + POST /:projectId/create-directory were added to + server/routes/files.ts. On success the component dispatches + Editor.onFilesChanged so all FileTree instances refresh. +

+
+ file-tree-panel.ts + server/routes/files.ts + create-file / create-directory +
+
+ +
+

EventSlot Refactor

+

+ Replaced all string-based document.dispatchEvent / + addEventListener calls with typed + EventSlot<T> instances on the Editor + singleton. Slots: onDocumentOpened, + onDocumentDirty, onDocumentSaved, + onFilesChanged. Components call + Editor.get().onDocumentOpened.addListener(…) directly — + no string keys, no silent mismatches. +

+
+ EventSlot<T> + src/editor/Editor.ts + hard coupling +
+
+ +
+

Session Persistence

+

+ session-file-store was removed after it caused an + EPERM: operation not permitted, rename error on Windows + (atomic rename not permitted on the session file). Replaced with a custom + JsonSessionStore extends session.Store that writes directly + with fs.writeFileSync — no temporary file, no rename. + Sessions are stored per-ID in storage/sessions/. +

+
+ server/sessionStore.ts + JsonSessionStore + Windows EPERM fix +
+
+ +
+

Tab Drag State Preservation

+

+ Dragging a tab between containers re-attaches the panel element to a new + parent, triggering connectedCallback and re-running setup. + Fixed with an _initialized guard on both FileTreePanel and + HtmlEditorPanel. For HtmlEditorPanel the iframe reloads from stale + srcdoc on re-attach, losing typed content; fixed by setting + _needsRestore = true in disconnectedCallback + so the next onload re-renders from the top of the undo stack + instead of running normal setup. +

+
+ _initialized guard + _needsRestore + disconnectedCallback +
+
+ +
+

Localization System

+

+ A localeGenerator.ts walks locales/en/ on server + startup and generates src/locales/Locales.ts — a file of + nested static classes mirroring the directory structure. File names become + members with dots replaced by underscores and dashes converted to camelCase. + A LocaleManager on the frontend fetches locale strings from + GET /api/locales/:locale/:path and caches them by key. +

+
+ server/localeGenerator.ts + src/locales/Locales.ts + src/locales/LocaleManager.ts + server/routes/locales.ts +
+
+ +
+

Editor Panel Header Redesign

+

+ Panel headers now show an icon and the panel's dynamic name inside the + div.tc-tab tab strip label, updated via a bubbling + panel:label-change custom event that TabContainer listens for. + FileTree shows 📁 dirname; HtmlEditor shows + 📄 filename. The panel headers and toolbars were simplified + to contain only action buttons, left-aligned. +

+
+ panel:label-change + tc-tab label + ftp-header + hep-toolbar +
+
+ +
+

FileTree Sub-Directory Root

+

+ Each FileTreePanel instance now has a _rootPath that can be + changed independently of other instances. Double-clicking a directory label + drills into it; a [ .. ] entry at the top navigates up one + level. The tree is fetched in full each time and the subtree at + _rootPath is sliced out client-side. The dirname shown in the + tab label updates on every refresh. +

+
+ _rootPath + dblclick to drill + [ .. ] entry + per-instance +
+
+ +
+

HTML Editor: Init and Pin Buttons

+

+ Init inserts a Hello World HTML template (with a + <page-content> root element) into the current document, + pushing it onto the undo stack so it can be undone. Enabled only when a + document is open. Pin is a toggle button on the left of the + toolbar that prevents the onDocumentOpened listener from + switching the editor to a different file when a file is clicked in the tree. + Turns orange when active. +

+
+ hep-init + hep-pin + _pinned flag +
+
+ +
+

Window Resize Handling

+

+ A ResizeObserver on .es-workspace recalculates + panel flex sizes proportionally when the window changes size. It only acts + when panels have been explicitly sized by dragging (guarded by + c.style.flex being non-empty), leaving the default CSS + flex: 1 layout untouched. Both the main three panels and any + inner section splits within a panel are covered. +

+
+ ResizeObserver + _redistributeFlex + editor-shell.ts +
+
+ +
+

Portrait Bar Moved into Top Header

+

+ The three portrait panel selector buttons ( + ) were moved from a separate full-width bar below the header + into the header itself on the right side. They are hidden via CSS + (display:none) in landscape and shown as a flex group when the + .portrait class is active. The back link lost its "Projects" + text label, keeping only the arrow. The title gained + flex: 1 to push the buttons to the right edge. +

+
+ .es-portrait-btns + es-header + portrait CSS class +
+
+ +
+

Layout Persistence — Per User Per Device

+

+ Each browser gets a stable random device ID stored in + localStorage under the key roject:deviceId. + On load, EditorShell fetches the saved layout from + GET /api/layout?deviceId=… and applies panel flex values and + the last active portrait panel. After any panel resize or portrait switch, + a debounced (800 ms) save fires to PUT /api/layout. Layout + files are stored server-side at + storage/layouts/{userId}/{deviceId}.json, making them + per-user and per-device without any client-side cookie dependency. +

+
+ server/routes/layout.ts + storage/layouts/ + localStorage deviceId + debounced save +
+
+
+ +
+

Key Decisions

+ +
+ EventSlot over string-based DOM events for all inter-component communication +

+ String event names silently fail when mistyped. EventSlot is typed, + directly coupled, and makes all listeners explicit and findable. No + intermediate bus, no global event name registry. +

+
+ +
+ panel:label-change as a bubbling DOM event (not EventSlot) +

+ Panel-to-container communication crosses a DOM boundary that EventSlot + cannot bridge directly (the container doesn't hold a reference to the panel + at construction time). A bubbling CustomEvent is the right mechanism here — + the container listens once and matches the event target against its + tabs array. +

+
+ +
+ FileTree root is per-instance, not global editor state +

+ Two FileTree panels in two different tab containers should be able to show + different subdirectories simultaneously. Storing _rootPath on + the element instance (not on Editor) achieves this with no + coordination overhead. +

+
+ +
+ Layout stored server-side, device ID stored client-side +

+ Storing layout in localStorage alone would lose it when the + browser data is cleared and would not survive a device change. Storing it + server-side keyed by a stable device ID gives persistence across browser + resets while keeping it per-device as requested. +

+
+ +
+ Direct writeFileSync in session store (no atomic rename) +

+ session-file-store uses a write-to-temp-then-rename strategy + that fails on Windows with EPERM when the target file is held open. Direct + writeFileSync avoids the rename entirely at the cost of + non-atomic writes, which is acceptable for session data. +

+
+
+ +
+

Structural Changes

+
+

+ src/editor/Editor.ts — central singleton (moved from src/components/EditorState.ts)
+ server/sessionStore.ts — custom JsonSessionStore, replaces session-file-store
+ server/routes/layout.ts — new: GET/PUT layout per user per device
+ server/localeGenerator.ts — new: generates src/locales/Locales.ts on startup
+ server/routes/locales.ts — new: serves locale files
+ src/locales/LocaleManager.ts — new: frontend locale fetcher with cache
+ src/components/context-menu/ — new: ContextMenu class hierarchy
+ storage/layouts/ — new: per-user per-device layout JSON files
+ storage/sessions/ — session files (now written by custom store) +

+
+
+ +
+ Roject — session log — 5 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/2026/07-July/06-Monday/Locales Update.txt b/workspace/history/2026/07-July/06-Monday/Locales Update.txt new file mode 100644 index 0000000..77c81fc --- /dev/null +++ b/workspace/history/2026/07-July/06-Monday/Locales Update.txt @@ -0,0 +1,18 @@ +[ Locales ] +All strings in the application need to be replaced by localized strings (not now, but on the long run). +We already started to implement it, but we're still not using it. +The idea is the following: The locales are stored in "locales" with "locales/en" being the English source +of the application and are then generated. Each directory will be also a directory and has its own class that will be put into +"src/locales/generated". So a "locales/en/commands" will be created as "src/locales/generated/commands/Commands.ts" and will +contain all files of the source directory "locales/en/commands" as members. + +The locales can have multiple formats: +- .txt: Raw utf8 text +- .html: Html text +- .json: A flexible json format that has at least { value:string } + +Also any of these locales can have a companion starting with the same name but ending with an .md +- greetings.txt +- greetings.text.md + +This files are context files. They give some context for the localization. \ No newline at end of file diff --git a/workspace/history/2026/07-July/06-Monday/Structural Update.txt b/workspace/history/2026/07-July/06-Monday/Structural Update.txt new file mode 100644 index 0000000..bee288d --- /dev/null +++ b/workspace/history/2026/07-July/06-Monday/Structural Update.txt @@ -0,0 +1,22 @@ +[ Structural Update ] +The app needs to slowly (step by step) use locales, commands and the undo manager. +We also need to update the outline. + +[ Locales ] +All strings in the application need to be replaced by localized strings (not now, but on the long run). +We already started to implement it, but we're still not using it. +The idea is the following: The locales are stored in "locales" with "locales/en" being the English source +of the application and are then generated. Each directory will be also a directory and has its own class that will be put into +"src/locales/generated". So a "locales/en/commands" will be created as "src/locales/generated/commands/Commands.ts" and will +contain all files of the source directory "locales/en/commands" as members. + + +[ Commands ] +We need to move all actions that can be done in the application to Commands which should be used from now on +for most file/data changing operations. Commands only have one execute function. When needed they will create +an undoable function inside the execute method. However, not all commands will have a need for undo/redo. + +[ Undo Manager ] +When possible ever file editor should take care of an undo/redo. When an undo/redo is needed the +UndoManager "src/library-ts/browser/undo/UndoManager.ts" should be used. It can work with asynchronous UndoActions +and can compress multiple actions to one. diff --git a/workspace/history/2026/07-July/06-Monday/index.html b/workspace/history/2026/07-July/06-Monday/index.html new file mode 100644 index 0000000..813ca5e --- /dev/null +++ b/workspace/history/2026/07-July/06-Monday/index.html @@ -0,0 +1,309 @@ + + + + + + Session Summary — 6 July 2026 + + + + +
+ +
+

Monday, 6 July 2026

+

Roject — Session Summary

+

+ File tree rename/delete, locale generator rewrite, locale documentation, + doc/ renamed to workspace/, fixed navigation header system, + Guides/Actions workspace restructure, and Update History action rewrite. +

+
+ +
+

What we built

+ +
+

File Tree: Rename and Delete

+

+ Right-clicking any file or directory label in the file tree now opens a + context menu with Rename… and Delete entries. + Rename shows an inline overlay (same pattern as Add File) with the current + name pre-filled. Delete requires confirmation via + <confirm-dialog> before proceeding. +

+

+ Two new server-side storage functions were added: + renameProjectEntry (validates new name has no path separators, + target does not already exist, result stays within project root) and + deleteProjectEntry (uses fs.rmSync with + recursive: true). Exposed as + POST /:projectId/rename and POST /:projectId/delete + in server/routes/files.ts. + confirm-dialog.css and confirm-dialog.js were added + to public/editor.html. +

+
+ file-tree-panel.ts + server/storage.ts + server/routes/files.ts + confirm-dialog +
+
+ +
+

Locale Generator Rewrite

+

+ server/localeGenerator.ts was rewritten to generate one + .ts file per source directory instead of a single monolithic + Locales.ts. Output goes to src/locales/generated/. + The root directory generates Locales.ts; each subdirectory + generates a file named in PascalCase (e.g. commands/ → + Commands.ts). +

+

+ Naming rules: directory names → PascalCase class names; file names → + camelCase member names, with all dots replaced by underscores first, then + dashes converted to camelCase on the remaining segments. The extension is + always kept as a suffix after an underscore. Example: + add-file.txtaddFile_txt, + config.min.jsonconfig_min_json. + Parent classes re-expose child classes as static readonly + members, so paths like + Locales.Commands.FileTreeCommands.addFile_txt work naturally. +

+

+ LocaleManager.$ was changed from a static method to a static + getter, so usage is LocaleManager.$.get(…) without parentheses. +

+
+ server/localeGenerator.ts + src/locales/generated/ + LocaleManager.ts + static get $ +
+
+ +
+

Locale Documentation

+

+ A full reference guide was written at + workspace/guides/locales/index.html covering: what the locale + system is, how source files are structured under locales/en/, + how the generator turns them into TypeScript, the naming conventions + (PascalCase directories, camelCase+extension members), how to add a new + locale file, and how to use it in code via + LocaleManager.$.get( Locales.X.Y.member_ext ). +

+
+ workspace/guides/locales/index.html + naming conventions + usage examples +
+
+ +
+

doc/ Renamed to workspace/

+

+ The documentation root was renamed from doc/ to + workspace/. Because the directory was open in the IDE, + PowerShell Rename-Item and Bash mv both failed + with Permission Denied. The workaround was to copy the tree with + robocopy then delete the original with + Remove-Item -Recurse -Force. + CLAUDE.md was updated to point to + workspace/outline/index.html. +

+
+ robocopy workaround + CLAUDE.md + workspace/ +
+
+ +
+

Workspace Navigation Header

+

+ Every workspace HTML page now includes a fixed navigation header generated + from a central sitemap. The system consists of three files in + workspace/_assets_/: +

+
    +
  • nav-data.js — declares var NAV_DATA, a tree of + { title, path, children? } nodes mirroring all workspace pages.
  • +
  • nav.js — finds the current page in the tree and renders a + three-row header: breadcrumb (ancestors), siblings row (peers at the same level), + and children row (direct children). After inserting the <nav>, + it sets document.body.style.paddingTop dynamically to prevent content + hiding under the fixed bar.
  • +
  • nav.css.wsnav is position: fixed + spanning the full viewport width; .wsnav-inner is centered at 57 em + to keep the content aligned with the page body.
  • +
+

+ Each page sets var NAV_ROOT to its relative depth from + workspace/ so nav links resolve correctly regardless of nesting + level. Day entries use '../../../../'; the outline uses + '../'. +

+
+ workspace/_assets_/nav-data.js + workspace/_assets_/nav.js + workspace/_assets_/nav.css + position: fixed + NAV_ROOT +
+
+ +
+

Workspace Restructure: Guides and Actions

+

+ The workspace was reorganised into four top-level sections: Outline, Guides, + Actions, and History. Guides hold informational reference + material (how to approach a type of work). Actions hold + repeatable procedures that an agent can be told to execute. +

+

+ Pages created: workspace/guides/index.html, + workspace/guides/writing-typescript-code/index.html, + workspace/guides/locales/index.html, + workspace/actions/index.html, + workspace/actions/update-history/index.html, + workspace/actions/update-outline/index.html. + The outline index was updated with Guides and Actions summary cards. + nav-data.js was updated to include both sections. +

+
+ workspace/guides/ + workspace/actions/ + nav-data.js + outline/index.html +
+
+ +
+ +
+

Update History Action: Commit and Push

+

+ The Update History action was rewritten with two new behaviours. First, + when no entry exists for today, the agent now always asks which day to use + (a session may have started the previous day) and waits for an answer before + continuing. Second, a Step 5 was added: before running any git command, the + agent presents a confirmation prompt stating the exact date, the commit + message (a one-liner summary), and the push target (main), then + waits for the user to approve. After approval it runs + git add ., git commit -m "…", and + git push. A push failure is reported to the user without any + automatic recovery attempt. Same-day updates append new cards when there is + enough new material, or adjust existing content in place for minor changes. +

+
+ workspace/actions/update-history/index.html + git add . / commit / push + confirmation prompt +
+
+ + + +
+

Key Decisions

+ +
+ Right-click context menu for rename/delete (not header buttons) +

+ The file tree header already had Add File and Add Directory buttons. + Adding more buttons would crowd the header. Right-click is the conventional + gesture for per-item operations on file trees and uses the existing + ContextMenu infrastructure. +

+
+ +
+ One generated .ts file per locale directory +

+ A single flat Locales.ts would grow unbounded and force a + full regeneration for every locale change. Per-directory files allow + TypeScript to cache unchanged files and make the generated output easier + to read and trace back to source. +

+
+ +
+ All dots in file names become underscores before dash-to-camelCase +

+ File names like config.min.json have structural dots that + are not word separators in the same way dashes are. Replacing all dots + with underscores first produces unambiguous member names + (config_min_json) without any special-casing for extensions. +

+
+ +
+ LocaleManager.$ as a static getter, not a method +

+ LocaleManager.$ is a singleton accessor. Callers should read + it like a property, not invoke it like a factory. Removing the parentheses + at every call site makes the access pattern clear and consistent. +

+
+ +
+ Fixed nav with .wsnav-inner for centered content +

+ position: fixed and margin: auto cannot coexist + on the same element because fixed positioning takes the element out of + normal flow. Splitting into an outer full-width .wsnav and + an inner .wsnav-inner (57 em, margin auto) solves this cleanly. +

+
+ +
+ Guides for style/approach, Actions for repeatable procedures +

+ "Guides" describes informational reference material that helps a contributor + understand how to work in an area. "Actions" describes discrete, repeatable + procedures that a human or agent can be told to execute by name — they have + concrete steps, not just advice. +

+
+ +
+ +
+

Structural Changes

+
+

+ doc/workspace/ — root rename
+ workspace/_assets_/nav-data.js — new: sitemap for nav system
+ workspace/_assets_/nav.js — new: nav renderer
+ workspace/_assets_/nav.css — new: fixed nav styles
+ workspace/guides/ — new section
+ workspace/guides/writing-typescript-code/index.html — new
+ workspace/guides/locales/index.html — new
+ workspace/actions/ — new section
+ workspace/actions/update-history/index.html — new
+ workspace/actions/update-outline/index.html — new
+ server/storage.ts — added renameProjectEntry, deleteProjectEntry
+ server/routes/files.ts — added POST /:projectId/rename, POST /:projectId/delete
+ src/components/file-tree-panel/file-tree-panel.ts — added showItemMenu, startInlineRename, renameEntry, deleteEntry
+ public/editor.html — added confirm-dialog CSS and script
+ server/localeGenerator.ts — complete rewrite (per-directory output)
+ src/locales/generated/ — new: generated locale classes
+ src/locales/LocaleManager.tsstatic $ changed to static get $
+ CLAUDE.md — updated to point to workspace/outline/index.html +

+
+
+ +
+ Roject — session log — 6 July 2026 +
+ +
+ + + + + diff --git a/workspace/history/index.html b/workspace/history/index.html new file mode 100644 index 0000000..07b6fd1 --- /dev/null +++ b/workspace/history/index.html @@ -0,0 +1,63 @@ + + + + + + Session History — Roject + + + + +
+ +
+

Session History

+

Roject

+

Log of all working sessions, most recent first.

+
+ +
+

2026 — July

+ +
+

Monday, 6 July 2026

+

File tree rename/delete (right-click context menu), locale generator rewrite (per-directory output, new naming conventions), locale documentation, doc/ renamed to workspace/, fixed navigation header system, Guides/Actions workspace restructure, Update History action rewrite (commit/push with confirmation).

+
+ +
+

Sunday, 5 July 2026

+

ContextMenu with smart positioning, EventSlot refactor, session persistence, tab drag fixes, panel header redesign, FileTree sub-root, Init/Pin buttons, resize handling, portrait bar in header, layout persistence per user per device.

+
+ +
+

Saturday, 4 July 2026

+

Folder restructure (doc/ root), project documentation outline, coding guidelines, history index, stylesheet extensions, CLAUDE.md reduced to a pointer.

+
+ +
+

Friday, 3 July 2026

+

ES module refactor, EditorState extracted to its own module, shared library added as git submodule, TypeScript project references for browser and node library parts.

+
+ +
+

Thursday, 2 July 2026

+

TypeScript conversion, UUID IDs, project file storage, full editor with 3-panel layout, tab containers with drag-and-drop, WYSIWYG HTML editor, file tree, confirm dialog.

+
+ +
+

Wednesday, 1 July 2026

+

Initial project setup: Node.js + Express backend, auth (register/login/logout), groups and projects with member management, JSON file storage.

+
+ +
+ +
+ Roject — session history +
+ +
+ + + + + diff --git a/workspace/index.html b/workspace/index.html new file mode 100644 index 0000000..ead380d --- /dev/null +++ b/workspace/index.html @@ -0,0 +1,55 @@ + + + + + + Roject — Documentation Overview + + + + +
+ +
+

Project Documentation

+

Roject

+

For editing files in projects

+
+ +
+ + +
+

Outline

+

+ Explains Roject's goals, features and future plans and actions that are used for + the project. +
+ + Read more about the outline +

+
+ +
+

History

+

+ Per-day history that gives insights about the daily work. +
+ + Read more about the history +

+
+ +
+ + +
+ Roject documentation +
+ +
+ + + + + diff --git a/workspace/outline/index.html b/workspace/outline/index.html new file mode 100644 index 0000000..4ccfa6f --- /dev/null +++ b/workspace/outline/index.html @@ -0,0 +1,184 @@ + + + + + + Roject — Developer Documentation + + + + +
+ +
+

Project Documentation

+

Roject

+

Developer reference for human and agent contributors. Keep this file up to date as the project evolves.

+
+ +
+

Project Outline

+ +
+

What it is

+

+ Roject is a self-hosted, browser-based CMS for creating, editing, and storing + HTML documents with assets, organised into projects. It is designed for individual + developers or small teams who want a lightweight authoring environment with no + external database dependency. +

+
+ +
+

What exists now

+

+ User accounts with registration, login, logout, and account deletion. + Groups and projects with member management (viewer / editor / admin roles). + Each project gets a real directory on disk at storage/<uuid>/root/ + with a default index.html on creation. +

+

+ A full editor page (/editor.html) with a 3-panel resizable layout + (Left / Center / Right). Each panel holds one or more sections side by side, + each section holds a <tab-container>. Tabs are draggable + between containers. The Left panel shows the file tree; the Center panel holds + the WYSIWYG HTML editor (iframe, contenteditable, + MutationObserver, undo/redo, Ctrl+S save). The Right panel is empty by default + and receives dropped tabs. +

+

+ A reusable <confirm-dialog> component replaces browser + confirm() for destructive actions. Currently wired up for project + deletion only. +

+
+ +
+

What still needs work

+

+ This is a loose reminder, not a fixed backlog. Things we know are missing or + incomplete: a file manager inside the editor (create, rename, delete files and + folders from the tree); the Right panel has no default content and relies on + manual tab dragging to populate; portrait mode's secondary section switcher + (when a panel has multiple side-by-side sections) is not yet wired up; the + member list UI shows raw UUIDs instead of usernames; the group editor and + account delete button still use the browser confirm() instead of + the custom dialog; non-HTML files in the tree are visible but not openable; + and there is no real-time multi-user collaboration yet. +

+
+
+ +
+

Technical Implementation

+ +
+

Backend

+

+ Node.js + Express, TypeScript compiled on the fly with ts-node. + No database — all data lives as JSON files in data/ + (auto-created on first run). Auth uses express-session + + bcryptjs. All entity IDs are UUIDs via + crypto.randomUUID() — no central counter, safe for parallel + instances. Start the server with npm start. +

+
+ Node.js + Express + ts-node + express-session + bcryptjs + UUID IDs +
+
+ +
+

Frontend

+

+ Vanilla HTML, raw CSS (no Tailwind, no framework). Every UI component is a + custom element with its own .ts and .css file in + src/components/<name>/. CSS uses the element tag as root + selector with display: block. TypeScript compiles to + public/components/ via tsconfig.client.json + (module: ESNext, moduleResolution: bundler, no + bundler). HTML pages load components with + <script type="module">. Shared state uses a module-level + singleton (editor-state.ts) rather than globals. + Build with npm run build. +

+
+ Web Components + raw CSS + module: ESNext + no bundler + tsc --build +
+
+ +
+

Shared Library

+

+ A personal TypeScript library lives as a git submodule at + src/library-ts/. It has two parts: browser/ + (DOM-capable) and node/ (Node.js only). The browser part is + compiled separately via TypeScript project references + (src/library-ts/browser/tsconfig.roject.json, strict: false) + into public/library-ts/browser/. The node part is included by + tsconfig.ts-node.json (extends server config, strictNullChecks: false). +

+
+ git submodule + src/library-ts/ + project references + composite: true +
+
+ +
+

Locales

+

+ All user-visible strings are stored as plain files under locales/en/ + and accessed in code via LocaleManager.$.get( Locales.X.Y.member_ext ). + Never hardcode a user-visible string directly in TypeScript. +
+ Read more about the locale system +

+
+ +
+

Guides

+

+ Reference guides for working in specific areas of the project. + Read the relevant guide before starting a task in that area. +
+ Browse all guides +

+ +
+ +
+

Actions

+

+ Repeatable procedures for maintaining the project. +
+ Browse all actions +

+ +
+
+ +
+ Roject — developer documentation +
+ +
+ + + + +