From ea52824a4b1cb3ca1e07e260f3a5f862d712da69 Mon Sep 17 00:00:00 2001 From: zk Date: Tue, 21 Apr 2026 23:25:54 +0000 Subject: [PATCH] seeds.tachi.ac --- Justfile-apps | 7 +- bun.lock | 76 + typescript/client/.env | 1 + typescript/client/example/.env | 1 + .../app/pages/dashboard/utils/SeedsViewer.tsx | 227 +- .../layout/header/UtilsDropdown.tsx | 13 +- typescript/common/src/config/config.ts | 15 +- typescript/common/src/lib/folder-slug.ts | 36 +- .../rerunners/v3/3-migrate-folders-tables.ts | 6 +- typescript/seeds-scripts/sort-seeds.js | 12 + typescript/seeds-webui/.gitignore | 4 + typescript/seeds-webui/README.md | 40 + typescript/seeds-webui/dev/util/git.ts | 124 ++ typescript/seeds-webui/dev/util/json-patch.ts | 111 + .../seeds-webui/dev/vite-plugin-seeds-dev.ts | 293 +++ typescript/seeds-webui/eslint.config.mjs | 19 + typescript/seeds-webui/index.html | 29 + typescript/seeds-webui/package.json | 61 + typescript/seeds-webui/public/favicon.ico | Bin 0 -> 38128 bytes .../seeds-webui/public/logos/logo-mark.png | Bin 0 -> 12409 bytes .../seeds-webui/public/logos/logo-with-bg.png | Bin 0 -> 7147 bytes .../public/logos/logo-wordmark.png | Bin 0 -> 9053 bytes typescript/seeds-webui/public/logos/logo.png | Bin 0 -> 14648 bytes typescript/seeds-webui/public/logos/logo.svg | 10 + .../scripts/bundle-current-seeds.ts | 63 + typescript/seeds-webui/src/App.tsx | 76 + typescript/seeds-webui/src/app/editRoutes.ts | 15 + .../seeds-webui/src/components/AppShell.tsx | 136 ++ .../src/components/CollectionDiffRows.tsx | 149 ++ .../src/components/CommitSparkline.tsx | 100 + .../seeds-webui/src/components/DevStrip.tsx | 45 + .../src/components/IngestToast.tsx | 78 + .../src/components/ResultTable.tsx | 249 +++ .../seeds-webui/src/components/RowEditor.tsx | 128 ++ .../seeds-webui/src/components/RunOutput.tsx | 22 + .../src/components/SchemaPanel.tsx | 56 + .../seeds-webui/src/components/SqlEditor.tsx | 241 +++ .../src/components/SqliteWorkspaceGate.tsx | 71 + typescript/seeds-webui/src/declarations.d.ts | 21 + typescript/seeds-webui/src/lib/config.ts | 41 + .../src/lib/diff/collection-diff.ts | 200 ++ .../src/lib/diff/row-primary-key.ts | 27 + .../src/lib/edits/bulk-merge-validation.ts | 39 + .../seeds-webui/src/lib/edits/draft-store.ts | 109 + .../src/lib/edits/patch-merge-ops.ts | 113 + .../seeds-webui/src/lib/edits/schemas.ts | 55 + .../src/lib/format/seed-doc-summary.ts | 87 + .../src/lib/format/use-song-by-id-lookup.ts | 34 + .../src/lib/ingest/IngestProvider.tsx | 77 + .../seeds-webui/src/lib/sqlite/builder.ts | 49 + .../seeds-webui/src/lib/sqlite/client.ts | 19 + .../seeds-webui/src/lib/sqlite/schema.ts | 183 ++ .../seeds-webui/src/lib/sqlite/types.ts | 40 + .../seeds-webui/src/lib/sqlite/worker.ts | 217 ++ .../src/lib/transport/collection-cache.ts | 28 + .../src/lib/transport/dev-transport.ts | 112 + .../src/lib/transport/github-transport.ts | 170 ++ .../seeds-webui/src/lib/transport/index.ts | 81 + .../src/lib/transport/transport.ts | 47 + typescript/seeds-webui/src/main.tsx | 6 + typescript/seeds-webui/src/pages/Bulk.tsx | 407 ++++ .../seeds-webui/src/pages/Collection.tsx | 386 ++++ typescript/seeds-webui/src/pages/Diff.tsx | 386 ++++ typescript/seeds-webui/src/pages/Drafts.tsx | 371 ++++ typescript/seeds-webui/src/pages/Overview.tsx | 119 ++ typescript/seeds-webui/src/pages/Query.tsx | 127 ++ typescript/seeds-webui/src/pages/Validate.tsx | 41 + .../seeds-webui/src/styles/_variables.scss | 37 + typescript/seeds-webui/src/styles/app.scss | 1856 +++++++++++++++++ typescript/seeds-webui/tsconfig.json | 17 + typescript/seeds-webui/tsconfig.node.json | 7 + typescript/seeds-webui/vite.config.ts | 152 ++ .../framework/calculated-data/score.ts | 2 +- .../framework/pb/rederive-scores.ts | 2 +- typescript/server/src/lib/targets/goals.ts | 4 +- 75 files changed, 7936 insertions(+), 247 deletions(-) create mode 100644 typescript/seeds-webui/.gitignore create mode 100644 typescript/seeds-webui/README.md create mode 100644 typescript/seeds-webui/dev/util/git.ts create mode 100644 typescript/seeds-webui/dev/util/json-patch.ts create mode 100644 typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts create mode 100644 typescript/seeds-webui/eslint.config.mjs create mode 100644 typescript/seeds-webui/index.html create mode 100644 typescript/seeds-webui/package.json create mode 100644 typescript/seeds-webui/public/favicon.ico create mode 100644 typescript/seeds-webui/public/logos/logo-mark.png create mode 100644 typescript/seeds-webui/public/logos/logo-with-bg.png create mode 100644 typescript/seeds-webui/public/logos/logo-wordmark.png create mode 100644 typescript/seeds-webui/public/logos/logo.png create mode 100644 typescript/seeds-webui/public/logos/logo.svg create mode 100644 typescript/seeds-webui/scripts/bundle-current-seeds.ts create mode 100644 typescript/seeds-webui/src/App.tsx create mode 100644 typescript/seeds-webui/src/app/editRoutes.ts create mode 100644 typescript/seeds-webui/src/components/AppShell.tsx create mode 100644 typescript/seeds-webui/src/components/CollectionDiffRows.tsx create mode 100644 typescript/seeds-webui/src/components/CommitSparkline.tsx create mode 100644 typescript/seeds-webui/src/components/DevStrip.tsx create mode 100644 typescript/seeds-webui/src/components/IngestToast.tsx create mode 100644 typescript/seeds-webui/src/components/ResultTable.tsx create mode 100644 typescript/seeds-webui/src/components/RowEditor.tsx create mode 100644 typescript/seeds-webui/src/components/RunOutput.tsx create mode 100644 typescript/seeds-webui/src/components/SchemaPanel.tsx create mode 100644 typescript/seeds-webui/src/components/SqlEditor.tsx create mode 100644 typescript/seeds-webui/src/components/SqliteWorkspaceGate.tsx create mode 100644 typescript/seeds-webui/src/declarations.d.ts create mode 100644 typescript/seeds-webui/src/lib/config.ts create mode 100644 typescript/seeds-webui/src/lib/diff/collection-diff.ts create mode 100644 typescript/seeds-webui/src/lib/diff/row-primary-key.ts create mode 100644 typescript/seeds-webui/src/lib/edits/bulk-merge-validation.ts create mode 100644 typescript/seeds-webui/src/lib/edits/draft-store.ts create mode 100644 typescript/seeds-webui/src/lib/edits/patch-merge-ops.ts create mode 100644 typescript/seeds-webui/src/lib/edits/schemas.ts create mode 100644 typescript/seeds-webui/src/lib/format/seed-doc-summary.ts create mode 100644 typescript/seeds-webui/src/lib/format/use-song-by-id-lookup.ts create mode 100644 typescript/seeds-webui/src/lib/ingest/IngestProvider.tsx create mode 100644 typescript/seeds-webui/src/lib/sqlite/builder.ts create mode 100644 typescript/seeds-webui/src/lib/sqlite/client.ts create mode 100644 typescript/seeds-webui/src/lib/sqlite/schema.ts create mode 100644 typescript/seeds-webui/src/lib/sqlite/types.ts create mode 100644 typescript/seeds-webui/src/lib/sqlite/worker.ts create mode 100644 typescript/seeds-webui/src/lib/transport/collection-cache.ts create mode 100644 typescript/seeds-webui/src/lib/transport/dev-transport.ts create mode 100644 typescript/seeds-webui/src/lib/transport/github-transport.ts create mode 100644 typescript/seeds-webui/src/lib/transport/index.ts create mode 100644 typescript/seeds-webui/src/lib/transport/transport.ts create mode 100644 typescript/seeds-webui/src/main.tsx create mode 100644 typescript/seeds-webui/src/pages/Bulk.tsx create mode 100644 typescript/seeds-webui/src/pages/Collection.tsx create mode 100644 typescript/seeds-webui/src/pages/Diff.tsx create mode 100644 typescript/seeds-webui/src/pages/Drafts.tsx create mode 100644 typescript/seeds-webui/src/pages/Overview.tsx create mode 100644 typescript/seeds-webui/src/pages/Query.tsx create mode 100644 typescript/seeds-webui/src/pages/Validate.tsx create mode 100644 typescript/seeds-webui/src/styles/_variables.scss create mode 100644 typescript/seeds-webui/src/styles/app.scss create mode 100644 typescript/seeds-webui/tsconfig.json create mode 100644 typescript/seeds-webui/tsconfig.node.json create mode 100644 typescript/seeds-webui/vite.config.ts diff --git a/Justfile-apps b/Justfile-apps index ade51026e..da7ac994d 100644 --- a/Justfile-apps +++ b/Justfile-apps @@ -1,6 +1,6 @@ start: #!/bin/bash - ARGS=(server client) + ARGS=(server client seeds-webui) echo "Starting: ${ARGS[@]}" set -e @@ -20,4 +20,7 @@ server: bun --watch src/main.ts client: - cd typescript/client && bun vite \ No newline at end of file + cd typescript/client && bun vite + +seeds-webui: + cd typescript/seeds-webui && bun vite diff --git a/bun.lock b/bun.lock index 1d2addcc3..79da9ed50 100644 --- a/bun.lock +++ b/bun.lock @@ -309,6 +309,42 @@ "tsconfig-paths": "catalog:", }, }, + "typescript/seeds-webui": { + "name": "tachi-seeds-webui", + "version": "0.1.0-see-project-root", + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@lezer/highlight": "^1.2.3", + "@sqlite.org/sqlite-wasm": "^3.51.2-build9", + "codemirror": "^6.0.2", + "comlink": "^4.4.2", + "fast-json-patch": "^3.1.1", + "nanoid": "catalog:", + "natural-compare": "catalog:", + "react": "catalog:", + "react-bootstrap": "catalog:", + "react-dom": "catalog:", + "react-query": "catalog:", + "react-router-dom": "catalog:", + "tachi-common": "workspace:*", + "zod": "catalog:", + }, + "devDependencies": { + "@types/natural-compare": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/react-router-dom": "catalog:", + "@typescript/native-preview": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bootstrap-scss": "catalog:", + "eslint-config-tachi": "workspace:*", + "sass": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-html": "catalog:", + }, + }, "typescript/server": { "name": "tachi-server", "version": "3.0.0", @@ -780,6 +816,22 @@ "@bufbuild/protoplugin": ["@bufbuild/protoplugin@2.11.0", "", { "dependencies": { "@bufbuild/protobuf": "2.11.0", "@typescript/vfs": "^1.6.2", "typescript": "5.4.5" } }, "sha512-lyZVNFUHArIOt4W0+dwYBe5GBwbKzbOy8ObaloEqsw9Mmiwv2O48TwddDoHN4itylC+BaEGqFdI1W8WQt2vWJQ=="], + "@codemirror/autocomplete": ["@codemirror/autocomplete@6.20.1", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.17.0", "@lezer/common": "^1.0.0" } }, "sha512-1cvg3Vz1dSSToCNlJfRA2WSI4ht3K+WplO0UMOgmUYPivCyy2oueZY6Lx7M9wThm7SDUBViRmuT+OG/i8+ON9A=="], + + "@codemirror/commands": ["@codemirror/commands@6.10.3", "", { "dependencies": { "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.6.0", "@codemirror/view": "^6.27.0", "@lezer/common": "^1.1.0" } }, "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q=="], + + "@codemirror/lang-sql": ["@codemirror/lang-sql@6.10.0", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/state": "^6.0.0", "@lezer/common": "^1.2.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0" } }, "sha512-6ayPkEd/yRw0XKBx5uAiToSgGECo/GY2NoJIHXIIQh1EVwLuKoU8BP/qK0qH5NLXAbtJRLuT73hx7P9X34iO4w=="], + + "@codemirror/language": ["@codemirror/language@6.12.3", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.23.0", "@lezer/common": "^1.5.0", "@lezer/highlight": "^1.0.0", "@lezer/lr": "^1.0.0", "style-mod": "^4.0.0" } }, "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA=="], + + "@codemirror/lint": ["@codemirror/lint@6.9.5", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.35.0", "crelt": "^1.0.5" } }, "sha512-GElsbU9G7QT9xXhpUg1zWGmftA/7jamh+7+ydKRuT0ORpWS3wOSP0yT1FOlIZa7mIJjpVPipErsyvVqB9cfTFA=="], + + "@codemirror/search": ["@codemirror/search@6.7.0", "", { "dependencies": { "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.37.0", "crelt": "^1.0.5" } }, "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg=="], + + "@codemirror/state": ["@codemirror/state@6.6.0", "", { "dependencies": { "@marijn/find-cluster-break": "^1.0.0" } }, "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ=="], + + "@codemirror/view": ["@codemirror/view@6.41.1", "", { "dependencies": { "@codemirror/state": "^6.6.0", "crelt": "^1.0.6", "style-mod": "^4.1.0", "w3c-keyname": "^2.2.4" } }, "sha512-ToDnWKbBnke+ZLrP6vgTTDScGi5H37YYuZGniQaBzxMVdtCxMrslsmtnOvbPZk4RX9bvkQqnWR/WS/35tJA0qg=="], + "@connectrpc/connect": ["@connectrpc/connect@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0" } }, "sha512-JzhkaTvM73m2K1URT6tv53k2RwngSmCXLZJgK580qNQOXRzZRR/BCMfZw3h+90JpnG6XksP5bYT+cz0rpUzUWQ=="], "@connectrpc/connect-node": ["@connectrpc/connect-node@2.1.1", "", { "peerDependencies": { "@bufbuild/protobuf": "^2.7.0", "@connectrpc/connect": "2.1.1" } }, "sha512-s3TfsI1XF+n+1z6MBS9rTnFsxxR4Rw5wmdEnkQINli81ESGxcsfaEet8duzq8LVuuCupmhUsgpRo0Nv9pZkufg=="], @@ -936,6 +988,14 @@ "@kristiandupont/recase": ["@kristiandupont/recase@1.4.1", "", { "dependencies": { "ramda": "^0.30.0" } }, "sha512-e5t4YqhnRGbS9sU4N52cQgTn37qKwTsxDDcIuIkgPvX0UmnL+7eoOR6oFXeCib5zYuw03vYKpR55NBq+W43j1A=="], + "@lezer/common": ["@lezer/common@1.5.2", "", {}, "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ=="], + + "@lezer/highlight": ["@lezer/highlight@1.2.3", "", { "dependencies": { "@lezer/common": "^1.3.0" } }, "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g=="], + + "@lezer/lr": ["@lezer/lr@1.4.10", "", { "dependencies": { "@lezer/common": "^1.0.0" } }, "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A=="], + + "@marijn/find-cluster-break": ["@marijn/find-cluster-break@1.0.2", "", {}, "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g=="], + "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="], "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="], @@ -1182,6 +1242,8 @@ "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + "@sqlite.org/sqlite-wasm": ["@sqlite.org/sqlite-wasm@3.51.2-build9", "", {}, "sha512-tj2Sa6X4gVZqS9SiT2xlEeChSUklSGq8Hz6zKNbHW6NRiGyrSXiFcspnrWru1wPBT76RX/VxweESKxawCxfDxg=="], + "@swc/helpers": ["@swc/helpers@0.5.19", "", { "dependencies": { "tslib": "^2.8.0" } }, "sha512-QamiFeIK3txNjgUTNppE6MiG3p7TdninpZu0E0PbqVh1a9FNLT2FRhisaa4NcaX52XVhA5l7Pk58Ft7Sqi/2sA=="], "@tsconfig/node10": ["@tsconfig/node10@1.0.12", "", {}, "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ=="], @@ -1574,6 +1636,8 @@ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="], + "codemirror": ["codemirror@6.0.2", "", { "dependencies": { "@codemirror/autocomplete": "^6.0.0", "@codemirror/commands": "^6.0.0", "@codemirror/language": "^6.0.0", "@codemirror/lint": "^6.0.0", "@codemirror/search": "^6.0.0", "@codemirror/state": "^6.0.0", "@codemirror/view": "^6.0.0" } }, "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw=="], + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1582,6 +1646,8 @@ "combined-stream": ["combined-stream@1.0.8", "", { "dependencies": { "delayed-stream": "~1.0.0" } }, "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg=="], + "comlink": ["comlink@4.4.2", "", {}, "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g=="], + "comma-separated-tokens": ["comma-separated-tokens@2.0.3", "", {}, "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg=="], "commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="], @@ -1620,6 +1686,8 @@ "create-require": ["create-require@1.1.1", "", {}, "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ=="], + "crelt": ["crelt@1.0.6", "", {}, "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g=="], + "cron-parser": ["cron-parser@2.18.0", "", { "dependencies": { "is-nan": "^1.3.0", "moment-timezone": "^0.5.31" } }, "sha512-s4odpheTyydAbTBQepsqd2rNWGa2iV3cyo8g7zbI2QQYGLVsfbhmwukayS1XHppe02Oy1fg7mg6xoaraVJeEcg=="], "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], @@ -1906,6 +1974,8 @@ "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + "fast-json-patch": ["fast-json-patch@3.1.1", "", {}, "sha512-vf6IHUX2SBcA+5/+4883dsIjpBTqmfBjmYiWK1savxQmFk4JfBMLa7ynTYOs1Rolp/T1betJxHiGD3g1Mn8lUQ=="], + "fast-json-stable-hash": ["fast-json-stable-hash@1.0.3", "", {}, "sha512-iLK3Tq2J/KbiUn/5OHyhxikHqzwFXSM2e803Dp2JtBdQjsCVuZwWvL+WqWXPPRUEuDX4c0kqID1SU84RCfxs3A=="], "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], @@ -2816,6 +2886,8 @@ "strnum": ["strnum@1.1.2", "", {}, "sha512-vrN+B7DBIoTTZjnPNewwhx6cBA/H+IS7rfW68n7XxC1y7uoiGQBxaKzqucGUgavX15dJgiGztLJ8vxuEzwqBdA=="], + "style-mod": ["style-mod@4.1.3", "", {}, "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ=="], + "style-to-object": ["style-to-object@0.3.0", "", { "dependencies": { "inline-style-parser": "0.1.1" } }, "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA=="], "stylis": ["stylis@4.2.0", "", {}, "sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw=="], @@ -2850,6 +2922,8 @@ "tachi-seeds-scripts": ["tachi-seeds-scripts@workspace:typescript/seeds-scripts"], + "tachi-seeds-webui": ["tachi-seeds-webui@workspace:typescript/seeds-webui"], + "tachi-server": ["tachi-server@workspace:typescript/server"], "tachi-sieglinde": ["tachi-sieglinde@workspace:typescript/sieglinde"], @@ -3004,6 +3078,8 @@ "vitest": ["vitest@3.2.4", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", "@vitest/mocker": "3.2.4", "@vitest/pretty-format": "^3.2.4", "@vitest/runner": "3.2.4", "@vitest/snapshot": "3.2.4", "@vitest/spy": "3.2.4", "@vitest/utils": "3.2.4", "chai": "^5.2.0", "debug": "^4.4.1", "expect-type": "^1.2.1", "magic-string": "^0.30.17", "pathe": "^2.0.3", "picomatch": "^4.0.2", "std-env": "^3.9.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.2", "tinyglobby": "^0.2.14", "tinypool": "^1.1.1", "tinyrainbow": "^2.0.0", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite-node": "3.2.4", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/debug": "^4.1.12", "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "@vitest/browser": "3.2.4", "@vitest/ui": "3.2.4", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/debug", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A=="], + "w3c-keyname": ["w3c-keyname@2.2.8", "", {}, "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ=="], + "warning": ["warning@4.0.3", "", { "dependencies": { "loose-envify": "^1.0.0" } }, "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w=="], "webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="], diff --git a/typescript/client/.env b/typescript/client/.env index c39d5e790..90eb78c5d 100644 --- a/typescript/client/.env +++ b/typescript/client/.env @@ -9,6 +9,7 @@ VITE_FLO_CLIENT_ID="" VITE_RULES_READ_TIME=1 VITE_IS_LOCAL_DEV=true VITE_IS_DEVELOPMENT=true +VITE_SEEDS_URL="http://127.0.0.1:3100" # Where routing should "base" from. # shouldn't be used, really. VITE_BASE_PATH="" diff --git a/typescript/client/example/.env b/typescript/client/example/.env index 06df5ef33..00fa0c5f2 100644 --- a/typescript/client/example/.env +++ b/typescript/client/example/.env @@ -9,6 +9,7 @@ VITE_MIN_CLIENT_ID="" VITE_FLO_CLIENT_ID="" VITE_RULES_READ_TIME=30 VITE_IS_LOCAL_DEV=true +VITE_SEEDS_URL="https://seeds.tachi.ac" TACHI_NAME="Tachi Local Dev" # turn this off if you're on linux for actually good performance diff --git a/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx b/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx index 022a36ac4..7c86fc736 100644 --- a/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx +++ b/typescript/client/src/app/pages/dashboard/utils/SeedsViewer.tsx @@ -1,226 +1,13 @@ -import useSetSubheader from "#components/layout/header/useSetSubheader"; -import SeedsPicker from "#components/seeds/SeedsPicker"; -import SeedsStateViewer from "#components/seeds/SeedsStateViewer"; -import Divider from "#components/util/Divider"; -import ExternalLink from "#components/util/ExternalLink"; -import Loading from "#components/util/Loading"; -import useApiQuery from "#components/util/query/useApiQuery"; -import SelectButton from "#components/util/SelectButton"; -import { TachiConfig } from "#lib/config"; -import { type Revision } from "#types/git"; -import { LoadCommit } from "#util/seeds"; -import React, { useEffect, useState } from "react"; -import { Alert, Col, Row } from "react-bootstrap"; +import { useEffect } from "react"; + +const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined; export default function SeedsViewer() { - useSetSubheader(["Developer Utils", "Database Seeds Management"]); - - const { data, error: failedToGetLocalAPI } = useApiQuery>("/seeds"); - - const { data: hasUncommittedRes } = useApiQuery("/seeds/has-uncommitted-changes"); - - if (!data && !failedToGetLocalAPI) { - return ; - } - - return ( - <> -

{TachiConfig.NAME} Database Management

- - - This tool is for viewing the database that powers {TachiConfig.NAME} in a more - efficient manner. -
- To view the state of a given commit or repository, use the select boxes below. -
- For more information what all of this is about and how it works, see{" "} - - the documentation - - . - {!failedToGetLocalAPI && ( - <> -
-
- - Also, it seems like you're running {TachiConfig.NAME} in local - development! - {" "} -
- You can use this UI to view your current changes on-your-disk before you - commit them! - - )} -
- - {hasUncommittedRes && ( - <> - -
You have uncommitted changes on your local disk.
-
- {/* this deliberately reloads the page. */} - - Click to view what you've changed. - -
-
{" "} - - - )} - - - ); -} - -function InnerSeedsViewer({ hasLocalAPI }: { hasLocalAPI: boolean }) { - // base, rev a-la traditional git comparisons. Head is expected to be 'after' - // the base, but no order is enforced. - const [baseRev, setBaseRev] = useState(null); - const [headRev, setHeadRev] = useState(null); - const [urlLoading, setURLLoading] = useState(false); - const [isFirstLoad, setIsFirstLoad] = useState(true); - - const params = new URLSearchParams(window.location.search); - const sha = params.get("sha"); - const repo = params.get("repo"); - const compareRepo = params.get("compareRepo"); - const compareSHA = params.get("compareSHA"); - - const [view, setView] = useState<"DIFF" | "FULL">("DIFF"); - useEffect(() => { - if (!isFirstLoad) { - return; + if (SEEDS_URL) { + window.location.replace(SEEDS_URL); } + }, []); - // if we've been given stuff in the URL and don't already have a baseRev - if (sha && repo && !baseRev) { - setURLLoading(true); - - (async () => { - const baseCommit = await LoadCommit(repo, sha); - - if (baseCommit) { - setBaseRev({ c: baseCommit, repo }); - } else { - setBaseRev(null); - } - - if (compareRepo && compareSHA) { - const headCommit = await LoadCommit(compareRepo, compareSHA); - - if (headCommit) { - setHeadRev({ c: headCommit, repo: compareRepo }); - } else { - setHeadRev(null); - } - } - - setURLLoading(false); - - setIsFirstLoad(false); - })(); - } - }, [isFirstLoad]); - - useEffect(() => { - if (baseRev) { - const params = new URLSearchParams({ - repo: baseRev.repo, - sha: baseRev.c.sha, - }); - - if (headRev) { - params.set("compareRepo", headRev.repo); - params.set("compareSHA", headRev.c.sha); - } - - window.history.replaceState(null, "", `?${params.toString()}`); - } else { - window.history.replaceState(null, "", ""); - } - }, [baseRev, headRev]); - - // if sha XOR repo, this should not be allowed. - if (!!sha !== !!repo) { - return
Invalid reference, both a repo and a sha must be provided in the URL.
; - } - - if (urlLoading) { - return ( -
- -
- Fetching information for{" "} - - {repo}/{sha} - - ... -
-
- ); - } - - return ( - <> - - - - - {baseRev && ( - - - - )} - - {baseRev && ( - - - - {!headRev && ( -
- - View Changes - - - View Full State - -
- )} - - - - -
- )} - - ); + return null; } diff --git a/typescript/client/src/components/layout/header/UtilsDropdown.tsx b/typescript/client/src/components/layout/header/UtilsDropdown.tsx index d5297453a..b2d95feeb 100644 --- a/typescript/client/src/components/layout/header/UtilsDropdown.tsx +++ b/typescript/client/src/components/layout/header/UtilsDropdown.tsx @@ -2,13 +2,22 @@ import DropdownNavLink from "#components/ui/DropdownNavLink"; import QuickDropdown from "#components/ui/QuickDropdown"; import { type SetState } from "#types/react"; import React from "react"; +import DropdownItem from "react-bootstrap/DropdownItem"; + +const SEEDS_URL = import.meta.env.VITE_SEEDS_URL as string | undefined; function UtilsLinks({ onClick }: { onClick: React.MouseEventHandler }) { return ( <> - + Seeds Management - + Import Management diff --git a/typescript/common/src/config/config.ts b/typescript/common/src/config/config.ts index e9052ca46..979255270 100644 --- a/typescript/common/src/config/config.ts +++ b/typescript/common/src/config/config.ts @@ -144,14 +144,23 @@ const v3GameMappings: Record = { "ddr:DP": "ddr-dp", }; +/** + * @deprecated + */ export function LEGACY_GameGroupPTToGame(game: GameGroup, playtype: LEGACY_Playtype): V3Game { - return GPTStringToGame(LEGACY_GetGPTString(game, playtype)); + return LEGACY_GPTStringToGame(LEGACY_GetGPTString(game, playtype)); } -export function GPTStringToGame(gptString: LEGACY_GPTString): V3Game { +/** + * @deprecated + */ +export function LEGACY_GPTStringToGame(gptString: LEGACY_GPTString): V3Game { return v3GameMappings[gptString]; } +/** + * @deprecated + */ export function LEGACY_GameToGPTString(game: V3Game): LEGACY_GPTString { const mapping: Record = { "iidx-sp": "iidx:SP", @@ -241,7 +250,7 @@ export function LEGACY_GetGamePTConfig( } export function LEGACY_GetGPTConfig(gptString: LEGACY_GPTString): GameConfig { - const game = GPTStringToGame(gptString); + const game = LEGACY_GPTStringToGame(gptString); return GAME_CONFIGS[game] as unknown as GameConfig; } diff --git a/typescript/common/src/lib/folder-slug.ts b/typescript/common/src/lib/folder-slug.ts index 93e5b6c8f..01fe41162 100644 --- a/typescript/common/src/lib/folder-slug.ts +++ b/typescript/common/src/lib/folder-slug.ts @@ -357,23 +357,35 @@ function encodeBmsTableKey(game: string, key: string): string { function encodeBmsTableValue(value: string): string { const parts: Array = []; + let alphanumRun = ""; for (const ch of value) { if (/[a-z0-9]/iu.test(ch)) { - parts.push(ch.toLowerCase()); - } else if (ch === "+") { - parts.push("p"); - } else if (ch === "?") { - parts.push("q"); - } else if (ch === "-") { - parts.push("dash"); - } else if (ch === "!") { - parts.push("excl"); + alphanumRun += ch.toLowerCase(); } else { - parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`); + if (alphanumRun.length > 0) { + parts.push(alphanumRun); + alphanumRun = ""; + } + + if (ch === "+") { + parts.push("p"); + } else if (ch === "?") { + parts.push("q"); + } else if (ch === "-") { + parts.push("dash"); + } else if (ch === "!") { + parts.push("excl"); + } else { + parts.push(`u${ch.codePointAt(0)?.toString(16) ?? "0"}`); + } } } + if (alphanumRun.length > 0) { + parts.push(alphanumRun); + } + return parts.join("-"); } @@ -386,7 +398,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string { const key = mKv[1].replaceAll("''", "'"); const val = mKv[2].replaceAll("''", "'"); - return `tf-${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`; + return `${encodeBmsTableKey(folder.game, key)}-${encodeBmsTableValue(val)}`; } const mPr = /tableFolders'\) \? '((?:[^']|'')+)'/u.exec(w); @@ -394,7 +406,7 @@ function slugBmsLike(folder: SeedFolderRow, label: string): string { if (mPr !== null) { const key = mPr[1].replaceAll("''", "'"); - return `tf-${encodeBmsTableKey(folder.game, key)}-present`; + return `${encodeBmsTableKey(folder.game, key)}-present`; } const mRg = diff --git a/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts b/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts index 2159147e0..ddd7ed182 100644 --- a/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts +++ b/typescript/seeds-scripts/rerunners/v3/3-migrate-folders-tables.ts @@ -8,8 +8,8 @@ import fs from "fs"; import path from "path"; import { type GameGroup, - GPTStringToGame, LEGACY_GetGPTString, + LEGACY_GPTStringToGame, type LEGACY_Playtype, } from "tachi-common"; @@ -58,7 +58,7 @@ for (const entry of folders) { delete entry.folderID; } - entry.game = GPTStringToGame( + entry.game = LEGACY_GPTStringToGame( LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype), ); delete entry.playtype; @@ -82,7 +82,7 @@ for (const entry of tables) { delete entry.tableID; } - entry.game = GPTStringToGame( + entry.game = LEGACY_GPTStringToGame( LEGACY_GetGPTString(entry.game as GameGroup, entry.playtype as LEGACY_Playtype), ); delete entry.playtype; diff --git a/typescript/seeds-scripts/sort-seeds.js b/typescript/seeds-scripts/sort-seeds.js index 0180f50ed..7feca09ea 100644 --- a/typescript/seeds-scripts/sort-seeds.js +++ b/typescript/seeds-scripts/sort-seeds.js @@ -1,5 +1,6 @@ const fs = require("fs"); const path = require("path"); +const { spawnSync } = require("child_process"); function ChartSort(a, b) { // sink all 2dxtra charts to the bottom @@ -98,6 +99,17 @@ function SortSeeds() { fs.writeFileSync(collPath, JSON.stringify(content, null, "\t")); } + + // Run the repo formatter so the output is consistent with `just fmt`. + const repoRoot = path.resolve(__dirname, "../.."); + const biome = path.join(repoRoot, "node_modules", ".bin", "biome"); + const result = spawnSync(biome, ["format", "--write", collectionsDir], { + cwd: repoRoot, + stdio: "inherit", + }); + if (result.status !== 0) { + throw new Error(`biome format exited ${result.status}`); + } } function SortObjectKeys(object) { diff --git a/typescript/seeds-webui/.gitignore b/typescript/seeds-webui/.gitignore new file mode 100644 index 000000000..054ac6fcc --- /dev/null +++ b/typescript/seeds-webui/.gitignore @@ -0,0 +1,4 @@ +build/ +dist/ +public/seeds-bundle/ +node_modules/ diff --git a/typescript/seeds-webui/README.md b/typescript/seeds-webui/README.md new file mode 100644 index 000000000..dc137f6ed --- /dev/null +++ b/typescript/seeds-webui/README.md @@ -0,0 +1,40 @@ +# tachi-seeds-webui + +The seeds workstation. Browse, query, diff over git history — and, in +localdev, edit `db/seeds/*.json` directly. + +## Running + +```sh +# Local dev (editing enabled, spawns a Vite dev server on :3100) +just seeds-webui + +# Build a static, read-only bundle (no editing) +cd typescript/seeds-webui && bun run build +``` + +## Shape + +- **Static SPA in prod.** Hosted at `seeds.tachi.ac`. No backend; reads + commit history from the GitHub REST API and the current seeds from a + pre-built `/seeds-bundle/` snapshot shipped with the site. +- **Dev mode adds a Vite plugin** (`dev/vite-plugin-seeds-dev.ts`) that + mounts `/__seeds/*` against the local repo's `db/seeds/`. That plugin is + _only_ loaded during `vite dev`; `vite build` never includes it. +- **In-browser SQLite** (`@sqlite.org/sqlite-wasm` + OPFS) for queries. + The worker at `src/lib/sqlite/worker.ts` ingests collections on first + load and caches them in OPFS by content hash. +- **Drafts** are queued in IndexedDB. The Drafts drawer composes them into + JSON Patches and calls `transport.writeCollection`, which in dev mode + rewrites the file and re-runs `sort-seeds.js`. + +## Edit-mode safety + +Two locks: + +1. `VITE_SEEDS_EDIT_MODE` is set to the literal `false` during `vite + build`, so every `EDIT_MODE`-guarded branch is tree-shaken out of the + prod bundle. +2. At runtime, the transport additionally probes `/__seeds/ping` before + unlocking edit paths. A static host where somebody deploys a dev build + by mistake will still act read-only. diff --git a/typescript/seeds-webui/dev/util/git.ts b/typescript/seeds-webui/dev/util/git.ts new file mode 100644 index 000000000..72f59a453 --- /dev/null +++ b/typescript/seeds-webui/dev/util/git.ts @@ -0,0 +1,124 @@ +import { execFile } from "node:child_process"; +import { promisify } from "node:util"; + +const pExecFile = promisify(execFile); + +export interface DevBranch { + name: string; + sha: string; +} + +export interface DevCommit { + sha: string; + message: string; + author: { date: string; email: string; name: string }; + committer: { date: string; email: string; name: string }; + parents: Array<{ sha: string }>; +} + +export async function gitExec( + repoRoot: string, + args: string[], +): Promise<{ stderr: string; stdout: string }> { + return pExecFile("git", args, { + cwd: repoRoot, + env: { ...process.env, GIT_PAGER: "cat", PAGER: "cat" }, + maxBuffer: 256 * 1024 * 1024, + }); +} + +export async function listBranches( + repoRoot: string, +): Promise<{ branches: DevBranch[]; current: DevBranch | null }> { + const { stdout } = await gitExec(repoRoot, [ + "for-each-ref", + "--format=%(refname:short)\t%(objectname:short)", + "refs/heads", + ]); + const branches: DevBranch[] = []; + for (const line of stdout.split("\n")) { + const [name, sha] = line.split("\t"); + if (name && sha) {branches.push({ name, sha });} + } + + let current: DevBranch | null = null; + try { + const { stdout: head } = await gitExec(repoRoot, ["rev-parse", "--abbrev-ref", "HEAD"]); + const name = head.trim(); + if (name && name !== "HEAD") { + const found = branches.find((b) => b.name === name); + if (found) {current = found;} + } + } catch { + // detached head — leave current null. + } + + return { branches, current }; +} + +// git log format: each commit is 7 records terminated by NUL, separated +// from the next commit by a literal "\x1e" (record separator). +const LOG_FORMAT = ["%H", "%P", "%an", "%ae", "%aI", "%cn", "%ce", "%cI", "%s"].join("%x00"); + +export async function listCommits( + repoRoot: string, + opts: { branch?: string; file?: string; limit?: number; skip?: number }, +): Promise { + const args = [ + "log", + `--format=${LOG_FORMAT}%x1e`, + `--max-count=${opts.limit ?? 30}`, + `--skip=${opts.skip ?? 0}`, + ]; + if (opts.branch) {args.push(opts.branch);} + if (opts.file) {args.push("--", opts.file);} + + const { stdout } = await gitExec(repoRoot, args); + const commits: DevCommit[] = []; + for (const record of stdout.split("\x1e")) { + const parts = record.trim().split("\x00"); + if (parts.length < 9) {continue;} + const [sha, parents, an, ae, ad, cn, ce, cd, message] = parts; + commits.push({ + sha: sha!, + parents: (parents ?? "") + .split(" ") + .filter(Boolean) + .map((p) => ({ sha: p })), + author: { name: an!, email: ae!, date: ad! }, + committer: { name: cn!, email: ce!, date: cd! }, + message: message!, + }); + } + return commits; +} + +export async function getCommit(repoRoot: string, sha: string): Promise { + const [c] = await listCommits(repoRoot, { branch: sha, limit: 1 }); + if (!c) {throw new Error(`No commit found for ${sha}`);} + return c; +} + +export async function showFileAt(repoRoot: string, rev: string, path: string): Promise { + const { stdout } = await gitExec(repoRoot, ["show", `${rev}:${path}`]); + return stdout; +} + +export async function statusPorcelain( + repoRoot: string, +): Promise<{ branch: string | null; changed: string[]; hasUncommitted: boolean }> { + const { stdout } = await gitExec(repoRoot, ["status", "--porcelain=v1", "--branch"]); + const lines = stdout.split("\n").filter(Boolean); + let branch: string | null = null; + const changed: string[] = []; + for (const line of lines) { + if (line.startsWith("##")) { + const m = /^## ([^.\s]+)(?:\.\.\.|$| )/u.exec(line); + branch = m?.[1] ?? null; + continue; + } + // Porcelain format: " XY path" — strip the two-char status + space. + changed.push(line.slice(3)); + } + return { branch, changed, hasUncommitted: changed.length > 0 }; +} diff --git a/typescript/seeds-webui/dev/util/json-patch.ts b/typescript/seeds-webui/dev/util/json-patch.ts new file mode 100644 index 000000000..13b3137ee --- /dev/null +++ b/typescript/seeds-webui/dev/util/json-patch.ts @@ -0,0 +1,111 @@ +// Minimal JSON Patch (RFC 6902) applier limited to the three ops the UI emits: +// add, remove, replace. Paths use '/' separators; '-' as last segment on an +// array means "append". +// +// We deliberately do NOT depend on fast-json-patch here because this runs in +// the dev plugin (Node side) and we want zero Node-side dependencies beyond +// Vite itself. + +export type JsonPatchOp = + | { op: "add"; path: string; value: unknown } + | { op: "remove"; path: string } + | { op: "replace"; path: string; value: unknown }; + +export type JsonPatch = JsonPatchOp[]; + +type Json = unknown; + +function decode(segment: string): string { + return segment.replace(/~1/gu, "/").replace(/~0/gu, "~"); +} + +function split(path: string): string[] { + if (path === "") {return [];} + if (path[0] !== "/") { + throw new Error(`Invalid JSON Pointer: ${path}`); + } + return path.slice(1).split("/").map(decode); +} + +function isObj(v: unknown): v is Record { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +function walk( + root: Json, + segments: string[], +): { exists: boolean; key: number | string; parent: Json } { + if (segments.length === 0) { + throw new Error( + "Cannot address the root via a patch op here; edit the whole file instead.", + ); + } + let cur: Json = root; + for (let i = 0; i < segments.length - 1; i++) { + const seg = segments[i]!; + if (Array.isArray(cur)) { + const idx = Number(seg); + if (!Number.isInteger(idx) || idx < 0 || idx >= cur.length) { + throw new Error( + `Array index out of bounds at ${segments.slice(0, i + 1).join("/")}`, + ); + } + cur = cur[idx]; + } else if (isObj(cur)) { + if (!(seg in cur)) { + throw new Error(`Missing key "${seg}" at ${segments.slice(0, i + 1).join("/")}`); + } + cur = cur[seg]; + } else { + throw new Error( + `Cannot descend into non-container at ${segments.slice(0, i + 1).join("/")}`, + ); + } + } + const last = segments[segments.length - 1]!; + if (Array.isArray(cur)) { + const key = last === "-" ? cur.length : Number(last); + if (last !== "-" && (!Number.isInteger(key) || key < 0 || key > cur.length)) { + throw new Error(`Array index out of bounds at ${segments.join("/")}`); + } + return { parent: cur, key, exists: last !== "-" && key < cur.length }; + } + if (isObj(cur)) { + return { parent: cur, key: last, exists: last in cur }; + } + throw new Error(`Cannot address ${segments.join("/")}: parent is not a container`); +} + +export function applyPatch(doc: T, patch: JsonPatch): T { + const clone = JSON.parse(JSON.stringify(doc)) as T; + for (const op of patch) { + const segs = split(op.path); + const { parent, key } = walk(clone, segs); + if (Array.isArray(parent)) { + const idx = key as number; + switch (op.op) { + case "add": + parent.splice(idx, 0, op.value); + break; + case "remove": + parent.splice(idx, 1); + break; + case "replace": + parent[idx] = op.value; + break; + } + } else if (isObj(parent)) { + const k = key as string; + switch (op.op) { + case "add": + case "replace": + parent[k] = op.value; + break; + case "remove": + delete parent[k]; + break; + } + } + } + return clone; +} diff --git a/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts b/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts new file mode 100644 index 000000000..b18f4c6f4 --- /dev/null +++ b/typescript/seeds-webui/dev/vite-plugin-seeds-dev.ts @@ -0,0 +1,293 @@ +import type { IncomingMessage, ServerResponse } from "node:http"; +import type { Plugin, ViteDevServer } from "vite"; + +import { spawn } from "node:child_process"; +import fs from "node:fs/promises"; +import path from "node:path"; + +import { + getCommit, + gitExec, + listBranches, + listCommits, + showFileAt, + statusPorcelain, +} from "./util/git"; +import { applyPatch, type JsonPatch } from "./util/json-patch"; + +// vite-plugin-seeds-dev — mounts /__seeds/* dev-only endpoints against the +// local repo's db/seeds directory so the seeds-webui can: +// +// - GET /__seeds/ping feature probe +// - GET /__seeds/collections list *.json in db/seeds +// - GET /__seeds/branches local git branches +// - GET /__seeds/commits log entries affecting db/seeds (optionally ?file=) +// - GET /__seeds/commit?sha=... details of a specific commit +// - GET /__seeds/collection?name=&rev= full JSON of a collection at a rev (or disk) +// - POST /__seeds/collection apply JSON patch, re-sort, write +// - POST /__seeds/sort run sort-seeds.js +// - GET /__seeds/status working-tree status +// - POST /__seeds/tests/run run seeds-scripts tests, stream (SSE) +// +// All endpoints are ONLY registered during `vite dev`. They never make it into +// the production bundle. + +export interface SeedsDevPluginOptions { + repoRoot: string; +} + +export function seedsDevPlugin(opts: SeedsDevPluginOptions): Plugin { + const seedsDir = path.join(opts.repoRoot, "db", "seeds"); + const seedsRepoRelPath = "db/seeds"; + const seedsScriptsDir = path.join(opts.repoRoot, "typescript", "seeds-scripts"); + + return { + name: "seeds-webui:dev", + apply: "serve", + configureServer(server: ViteDevServer) { + server.middlewares.use("/__seeds", async (req, res) => { + try { + await handle(req, res, { + repoRoot: opts.repoRoot, + seedsDir, + seedsRepoRelPath, + seedsScriptsDir, + }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + server.config.logger.error(`[seeds-webui] ${req.url ?? ""}: ${message}`); + if (!res.headersSent) { + res.statusCode = 500; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify({ error: message })); + } + } + }); + }, + }; +} + +interface Ctx { + repoRoot: string; + seedsDir: string; + seedsRepoRelPath: string; + seedsScriptsDir: string; +} + +// ---------- Dispatcher ---------- + +async function handle(req: IncomingMessage, res: ServerResponse, ctx: Ctx) { + const url = new URL(req.url ?? "/", "http://localhost"); + const method = (req.method ?? "GET").toUpperCase(); + // `req.url` on middleware is already stripped of the /__seeds prefix. + const route = `${method} ${url.pathname.replace(/\/+$/u, "")}`; + + switch (route) { + case "GET /ping": + case "GET /": + return respond(res, 200, { ok: true }); + + case "GET /collections": + return respond(res, 200, await listCollectionFiles(ctx)); + + case "GET /branches": + return respond(res, 200, await listBranches(ctx.repoRoot)); + + case "GET /commits": + return respond( + res, + 200, + await commitsHandler(ctx, { + branch: url.searchParams.get("branch") ?? undefined, + cursor: url.searchParams.get("cursor") ?? undefined, + file: url.searchParams.get("file") ?? undefined, + }), + ); + + case "GET /commit": { + const sha = url.searchParams.get("sha"); + if (!sha) {return respond(res, 400, { error: "missing sha" });} + return respond(res, 200, await getCommit(ctx.repoRoot, sha)); + } + + case "GET /collection": { + const name = url.searchParams.get("name"); + const rev = url.searchParams.get("rev") ?? undefined; + if (!name) {return respond(res, 400, { error: "missing name" });} + return respond(res, 200, await readCollection(ctx, name, rev)); + } + + case "POST /collection": { + const body = await readJson<{ name: string; patch: JsonPatch }>(req); + await writeCollection(ctx, body.name, body.patch); + return respond(res, 200, { ok: true }); + } + + case "POST /sort": { + await runSort(ctx); + return respond(res, 200, { ok: true }); + } + + case "GET /status": + return respond(res, 200, await statusHandler(ctx)); + + case "POST /tests/run": + return streamProcess(res, ctx.seedsScriptsDir, "bash", ["./run-tests.sh"]); + } + + return respond(res, 404, { error: `no seeds-webui dev route for ${route}` }); +} + +// ---------- Helpers ---------- + +function respond(res: ServerResponse, status: number, body: unknown) { + res.statusCode = status; + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(body)); +} + +async function readJson(req: IncomingMessage): Promise { + const chunks: Buffer[] = []; + for await (const c of req) { + chunks.push(c as Buffer); + } + const raw = Buffer.concat(chunks).toString("utf-8"); + if (raw.length === 0) {return {} as T;} + return JSON.parse(raw) as T; +} + +// ---------- Endpoint handlers ---------- + +async function listCollectionFiles(ctx: Ctx): Promise { + const names = await fs.readdir(ctx.seedsDir); + return names.filter((n) => n.endsWith(".json")).sort(); +} + +async function commitsHandler( + ctx: Ctx, + opts: { branch?: string; cursor?: string; file?: string }, +): Promise<{ + commits: Awaited>; + nextCursor?: string; +}> { + const skip = opts.cursor ? Number.parseInt(opts.cursor, 10) : 0; + const limit = 30; + const targetFile = opts.file ? `${ctx.seedsRepoRelPath}/${opts.file}` : ctx.seedsRepoRelPath; + + const commits = await listCommits(ctx.repoRoot, { + branch: opts.branch, + file: targetFile, + limit, + skip, + }); + return { + commits, + nextCursor: commits.length === limit ? String(skip + limit) : undefined, + }; +} + +async function readCollection(ctx: Ctx, name: string, rev?: string): Promise { + validateCollectionName(name); + if (rev === undefined) { + const raw = await fs.readFile(path.join(ctx.seedsDir, name), "utf-8"); + return JSON.parse(raw) as unknown[]; + } + validateRev(rev); + const raw = await showFileAt(ctx.repoRoot, rev, `${ctx.seedsRepoRelPath}/${name}`); + return JSON.parse(raw) as unknown[]; +} + +async function writeCollection(ctx: Ctx, name: string, patch: JsonPatch): Promise { + validateCollectionName(name); + const file = path.join(ctx.seedsDir, name); + const raw = await fs.readFile(file, "utf-8"); + const doc = JSON.parse(raw) as unknown[]; + const next = applyPatch(doc, patch); + + // Write atomically (same dir so it stays on the same fs). + const tmp = `${file}.tmp-${process.pid}`; + await fs.writeFile(tmp, JSON.stringify(next, null, "\t")); + await fs.rename(tmp, file); + + // Re-run sort-seeds so diffs stay canonical. + await runSort(ctx); +} + +async function runSort(ctx: Ctx): Promise { + await new Promise((resolve, reject) => { + const proc = spawn("node", ["sort-seeds.js"], { + cwd: ctx.seedsScriptsDir, + stdio: "inherit", + }); + proc.on("error", reject); + proc.on("exit", (code) => + code === 0 ? resolve() : reject(new Error(`sort-seeds exited ${code}`)), + ); + }); +} + +async function statusHandler(ctx: Ctx) { + const s = await statusPorcelain(ctx.repoRoot); + return { + branch: s.branch, + hasUncommittedChanges: s.changed.some((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)), + changedFiles: s.changed.filter((f) => f.startsWith(`${ctx.seedsRepoRelPath}/`)), + }; +} + +function streamProcess( + res: ServerResponse, + cwd: string, + cmd: string, + argv: string[], +): Promise { + return new Promise((resolve) => { + res.statusCode = 200; + res.setHeader("content-type", "text/event-stream"); + res.setHeader("cache-control", "no-cache"); + res.setHeader("x-accel-buffering", "no"); + + const write = (kind: "stderr" | "stdout", data: string) => { + res.write(`data: ${JSON.stringify({ kind, data })}\n\n`); + }; + + const proc = spawn(cmd, argv, { cwd }); + proc.stdout.on("data", (b: Buffer) => write("stdout", b.toString("utf-8"))); + proc.stderr.on("data", (b: Buffer) => write("stderr", b.toString("utf-8"))); + proc.on("error", (err) => write("stderr", `spawn error: ${err.message}\n`)); + proc.on("exit", (code) => { + res.write(`data: ${JSON.stringify({ kind: "exit", code: code ?? -1 })}\n\n`); + res.end(); + resolve(); + }); + }); +} + +// ---------- Validation ---------- + +function validateCollectionName(name: string): void { + if (!/^[a-z0-9][a-z0-9-]*\.json$/u.test(name)) { + throw new Error(`invalid collection name ${JSON.stringify(name)}`); + } +} + +// Allow branches, tags, shas, and short shas. Disallow shell metacharacters. +function validateRev(rev: string): void { + if (!/^[A-Za-z0-9._/-]+$/u.test(rev)) { + throw new Error(`invalid git revision ${JSON.stringify(rev)}`); + } +} + +// Re-export for the Vite config typecheck. +export type { JsonPatch }; + +// Used only in tests/dev to warm up git; exported so that callers can verify +// the repo is actually a git repo before mounting. +export async function gitSanityCheck(repoRoot: string): Promise { + try { + await gitExec(repoRoot, ["rev-parse", "--git-dir"]); + return true; + } catch { + return false; + } +} diff --git a/typescript/seeds-webui/eslint.config.mjs b/typescript/seeds-webui/eslint.config.mjs new file mode 100644 index 000000000..a2234ccdd --- /dev/null +++ b/typescript/seeds-webui/eslint.config.mjs @@ -0,0 +1,19 @@ +import configTachi from "eslint-config-tachi"; + +export default [ + ...configTachi.base, + { + ...configTachi.node, + files: ["vite.config.ts", "dev/**/*.ts", "scripts/**/*.ts"], + languageOptions: { + ...configTachi.node.languageOptions, + parserOptions: { + project: ["./tsconfig.node.json"], + }, + }, + }, + configTachi.react, + { + ignores: ["build/**", "public/**", "node_modules/**", "dist/**"], + }, +]; diff --git a/typescript/seeds-webui/index.html b/typescript/seeds-webui/index.html new file mode 100644 index 000000000..5eca68189 --- /dev/null +++ b/typescript/seeds-webui/index.html @@ -0,0 +1,29 @@ + + + + + Tachi Seeds + + + + + + + + + + + +
+ + diff --git a/typescript/seeds-webui/package.json b/typescript/seeds-webui/package.json new file mode 100644 index 000000000..68daa9a41 --- /dev/null +++ b/typescript/seeds-webui/package.json @@ -0,0 +1,61 @@ +{ + "name": "tachi-seeds-webui", + "version": "0.1.0-see-project-root", + "private": true, + "type": "module", + "license": "AGPL3", + "description": "Workstation UI for Tachi's db/seeds: browse, query, diff over git history, and (in localdev) edit.", + "scripts": { + "dev": "vite", + "build": "bun scripts/bundle-current-seeds.ts && vite build", + "preview": "vite preview", + "lint": "eslint .", + "lint-fix": "eslint . --fix", + "typecheck": "tsgo --noEmit", + "bench": "node -e \"process.exit(0)\"" + }, + "dependencies": { + "@codemirror/lang-sql": "^6.10.0", + "@codemirror/language": "^6.12.3", + "@lezer/highlight": "^1.2.3", + "@sqlite.org/sqlite-wasm": "^3.51.2-build9", + "codemirror": "^6.0.2", + "comlink": "^4.4.2", + "fast-json-patch": "^3.1.1", + "nanoid": "catalog:", + "natural-compare": "catalog:", + "react": "catalog:", + "react-bootstrap": "catalog:", + "react-dom": "catalog:", + "react-query": "catalog:", + "react-router-dom": "catalog:", + "tachi-common": "workspace:*", + "zod": "catalog:" + }, + "devDependencies": { + "@types/natural-compare": "catalog:", + "@types/react": "catalog:", + "@types/react-dom": "catalog:", + "@types/react-router-dom": "catalog:", + "@typescript/native-preview": "catalog:", + "@vitejs/plugin-react": "catalog:", + "bootstrap-scss": "catalog:", + "eslint-config-tachi": "workspace:*", + "sass": "catalog:", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plugin-html": "catalog:" + }, + "engines": { + "bun": ">=1.2.0" + }, + "imports": { + "#*": [ + "./src/*", + "./src/*.ts", + "./src/*.tsx", + "./src/*/index.ts", + "./src/*/index.tsx" + ] + } +} diff --git a/typescript/seeds-webui/public/favicon.ico b/typescript/seeds-webui/public/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..b8508033678ccf139e3f589167baf03c7de4c585 GIT binary patch literal 38128 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 KC5s;zzx*HePxBc7 literal 0 HcmV?d00001 diff --git a/typescript/seeds-webui/public/logos/logo.svg b/typescript/seeds-webui/public/logos/logo.svg new file mode 100644 index 000000000..2a50f5583 --- /dev/null +++ b/typescript/seeds-webui/public/logos/logo.svg @@ -0,0 +1,10 @@ + + + + + + \ No newline at end of file diff --git a/typescript/seeds-webui/scripts/bundle-current-seeds.ts b/typescript/seeds-webui/scripts/bundle-current-seeds.ts new file mode 100644 index 000000000..b65b8fd5a --- /dev/null +++ b/typescript/seeds-webui/scripts/bundle-current-seeds.ts @@ -0,0 +1,63 @@ +// Pre-build step: copy db/seeds/*.json into public/seeds-bundle/ so the prod +// SPA can render the "current state" without touching the GitHub API. +// +// Produces: +// public/seeds-bundle/.json (1:1 copy of each collection) +// public/seeds-bundle/manifest.json { sha, files: [{name, bytes, hash}] } +// +// Run with `bun scripts/bundle-current-seeds.ts`. + +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = path.resolve(__dirname, "../../.."); +const SRC = path.join(REPO_ROOT, "db", "seeds"); +const OUT = path.join(__dirname, "..", "public", "seeds-bundle"); + +async function main() { + await fs.mkdir(OUT, { recursive: true }); + + const files = (await fs.readdir(SRC)).filter((n) => n.endsWith(".json")).sort(); + + const manifest: { + files: Array<{ bytes: number; hash: string; name: string }>; + sha: string | null; + } = { + sha: detectSha(), + files: [], + }; + + for (const name of files) { + const srcPath = path.join(SRC, name); + const buf = await fs.readFile(srcPath); + const hash = createHash("sha256").update(buf).digest("hex").slice(0, 16); + await fs.writeFile(path.join(OUT, name), buf); + manifest.files.push({ bytes: buf.byteLength, hash, name }); + } + + await fs.writeFile(path.join(OUT, "manifest.json"), JSON.stringify(manifest, null, 2)); + + const totalBytes = manifest.files.reduce((s, f) => s + f.bytes, 0); + console.log( + `[seeds-webui] bundled ${files.length} files (${(totalBytes / 1024 / 1024).toFixed(1)} MiB) -> ${path.relative(REPO_ROOT, OUT)}`, + ); +} + +function detectSha(): string | null { + try { + return execFileSync("git", ["rev-parse", "HEAD"], { cwd: REPO_ROOT }) + .toString("utf-8") + .trim(); + } catch { + return null; + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/typescript/seeds-webui/src/App.tsx b/typescript/seeds-webui/src/App.tsx new file mode 100644 index 000000000..f7f285481 --- /dev/null +++ b/typescript/seeds-webui/src/App.tsx @@ -0,0 +1,76 @@ +import { AppShell } from "#components/AppShell"; +import { IngestToast } from "#components/IngestToast"; +import { EDIT_MODE } from "#lib/config"; +import { IngestProvider } from "#lib/ingest/IngestProvider"; +import { Collection } from "#pages/Collection"; +import { Diff } from "#pages/Diff"; +import { Overview } from "#pages/Overview"; +import { Query } from "#pages/Query"; +import React, { useEffect, useState } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { BrowserRouter, Redirect, Route, Switch } from "react-router-dom"; + +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + refetchOnWindowFocus: false, + retry: 1, + staleTime: 60_000, + }, + }, +}); + +// Load the editing routes lazily via a single dynamic import. The import is +// only reached when EDIT_MODE is true; since EDIT_MODE is replaced with the +// literal `false` during `vite build`, rollup drops the entire expression — +// which means `editRoutes.ts` and every page it references is never emitted +// into the prod bundle. +type EditRoute = { component: React.ComponentType; path: string }; + +function useEditRoutes(): EditRoute[] { + const [routes, setRoutes] = useState([]); + useEffect(() => { + if (!EDIT_MODE) {return;} + let alive = true; + import("./app/editRoutes") + .then((m) => { + if (alive) {setRoutes(m.editRoutes);} + }) + .catch((err) => { + console.error("[seeds-webui] failed to load edit routes:", err); + }); + return () => { + alive = false; + }; + }, []); + return routes; +} + +export default function App() { + const editRoutes = useEditRoutes(); + return ( + + + + + + + + } + /> + + + {editRoutes.map((r) => ( + + ))} + + + + + + + ); +} diff --git a/typescript/seeds-webui/src/app/editRoutes.ts b/typescript/seeds-webui/src/app/editRoutes.ts new file mode 100644 index 000000000..abbe5fc34 --- /dev/null +++ b/typescript/seeds-webui/src/app/editRoutes.ts @@ -0,0 +1,15 @@ +import type React from "react"; + +import { Bulk } from "#pages/Bulk"; +import { Drafts } from "#pages/Drafts"; +import { Validate } from "#pages/Validate"; + +// This module is only imported from a `EDIT_MODE ? import("...")` call site, +// which in a prod build (with EDIT_MODE === false literal) is dead code. As a +// result rollup never traces through this file and none of the edit-only +// pages end up in the output bundle. +export const editRoutes: Array<{ component: React.ComponentType; path: string }> = [ + { component: Bulk, path: "/bulk" }, + { component: Drafts, path: "/drafts" }, + { component: Validate, path: "/validate" }, +]; diff --git a/typescript/seeds-webui/src/components/AppShell.tsx b/typescript/seeds-webui/src/components/AppShell.tsx new file mode 100644 index 000000000..4936fb04f --- /dev/null +++ b/typescript/seeds-webui/src/components/AppShell.tsx @@ -0,0 +1,136 @@ +import { DevStrip } from "#components/DevStrip"; +import { EDIT_MODE } from "#lib/config"; +import { listDrafts } from "#lib/edits/draft-store"; +import React, { useEffect, useState } from "react"; +import { useQuery } from "react-query"; +import { Link, NavLink, useLocation } from "react-router-dom"; + +function useDraftCount(): number { + const { data = 0 } = useQuery( + "draft-count", + async () => { + if (!EDIT_MODE) {return 0;} + return (await listDrafts()).length; + }, + { + refetchInterval: EDIT_MODE ? 2000 : false, + staleTime: 0, + }, + ); + return data; +} + +export function AppShell({ children }: { children: React.ReactNode }) { + const [theme, setTheme] = useTheme(); + const draftCount = useDraftCount(); + const location = useLocation(); + const onDraftsPage = location.pathname === "/drafts"; + + return ( +
+
+ + Tachi +
+ Tachi + Seeds +
+
+ +
+ + {EDIT_MODE ? "Dev" : "Read-only"} + + +
+
+ +
+
{children}
+
+ {EDIT_MODE && draftCount > 0 && !onDraftsPage ? ( + + ) : null} +
+ ); +} + +function DraftsFloatingBar({ count }: { count: number }) { + return ( +
+ ✎ + + {count} staged {count === 1 ? "edit" : "edits"} + + + Review & Apply → + +
+ ); +} + +type Theme = "dark" | "light"; + +function useTheme(): [Theme, (t: Theme) => void] { + const [theme, setThemeState] = useState(() => { + if (typeof document === "undefined") {return "dark";} + return (document.documentElement.getAttribute("data-bs-theme") as Theme) ?? "dark"; + }); + + useEffect(() => { + document.documentElement.setAttribute("data-bs-theme", theme); + document.documentElement.style.setProperty( + "color-scheme", + theme === "light" ? "light" : "dark", + ); + }, [theme]); + + const setTheme = (t: Theme) => { + localStorage.setItem("seeds-webui-theme", t); + setThemeState(t); + }; + return [theme, setTheme]; +} diff --git a/typescript/seeds-webui/src/components/CollectionDiffRows.tsx b/typescript/seeds-webui/src/components/CollectionDiffRows.tsx new file mode 100644 index 000000000..b70d392e2 --- /dev/null +++ b/typescript/seeds-webui/src/components/CollectionDiffRows.tsx @@ -0,0 +1,149 @@ +import type { Row } from "#lib/diff/row-primary-key"; + +import { + type DiffRow, + type FieldDelta, + formatValue, + singleDocumentDiff, +} from "#lib/diff/collection-diff"; +import { useSongByIdLookup } from "#lib/format/use-song-by-id-lookup"; +import { useMemo, useState } from "react"; + +export function SingleRowDocumentDiff({ + before, + after, + collectionName, + songById: songByIdProp, +}: { + after: Record | null; + before: Record | null; + /** When set, used for pretty titles (e.g. FormatChart). If omitted but `songById` is not passed, songs are loaded for `charts-*`. */ + collectionName?: string; + songById?: Map | null; +}) { + const shouldFetchSongs = Boolean(collectionName && songByIdProp === undefined); + const songQuery = useSongByIdLookup( + collectionName && shouldFetchSongs ? collectionName : undefined, + ); + const songById = + songByIdProp !== undefined ? songByIdProp : (songQuery.data ?? undefined); + + const row = useMemo( + () => singleDocumentDiff(before, after, { collectionName, songById }), + [before, after, collectionName, songById], + ); + if (before === null && after === null) { + return
No diff data.
; + } + if (!row) { + return
No changes.
; + } + return ; +} + +export function DiffRows({ rows, limit = 200 }: { limit?: number; rows: DiffRow[] }) { + const visible = rows.slice(0, limit); + const truncated = rows.length - visible.length; + return ( +
+ {visible.map((r) => ( + + ))} + {truncated > 0 ? ( +

+ … {truncated.toLocaleString()} more rows hidden. Use the filter to narrow down. +

+ ) : null} +
+ ); +} + +export function DiffRowCard({ row }: { row: DiffRow }) { + const [expanded, setExpanded] = useState(false); + + return ( +
+
+ + {row.kind === "added" ? "+" : row.kind === "removed" ? "−" : "~"} + + {row.id} + {row.pretty !== row.id ? ( + {row.pretty} + ) : null} + + {row.kind === "changed" ? ( + + {row.fields.length} field + {row.fields.length === 1 ? "" : "s"} changed + + ) : null} + +
+ + {row.kind === "changed" ? : null} + + {row.kind === "added" ? ( +
{formatValue(row.after)}
+ ) : null} + + {row.kind === "removed" ? ( +
{formatValue(row.before)}
+ ) : null} + + {expanded && row.kind === "changed" ? ( +
+
+
before
+
{formatValue(row.before)}
+
+
+
after
+
{formatValue(row.after)}
+
+
+ ) : null} +
+ ); +} + +function FieldTable({ fields }: { fields: FieldDelta[] }) { + return ( + + + + + + + + + + {fields.map((f) => ( + + + + + + ))} + +
FieldBeforeAfter
{f.path} + {f.kind === "added" ? ( + — + ) : ( +
{formatValue(f.before)}
+ )} +
+ {f.kind === "removed" ? ( + — + ) : ( +
{formatValue(f.after)}
+ )} +
+ ); +} diff --git a/typescript/seeds-webui/src/components/CommitSparkline.tsx b/typescript/seeds-webui/src/components/CommitSparkline.tsx new file mode 100644 index 000000000..b086adcc8 --- /dev/null +++ b/typescript/seeds-webui/src/components/CommitSparkline.tsx @@ -0,0 +1,100 @@ +import type { Commit } from "#lib/transport/transport"; + +import { useMemo } from "react"; + +interface CommitSparklineProps { + commits: Commit[]; + /** Number of trailing days to bucket. Default 90. */ + days?: number; +} + +// Tiny inline SVG sparkline of commits per day, no chart lib. Good enough for +// the overview "pulse" view. Bars scale to the busiest day in the range. +export function CommitSparkline({ commits, days = 90 }: CommitSparklineProps) { + const { bins, maxCount, start, end, total } = useMemo(() => { + const now = Date.now(); + const bucket = new Map(); + const startDate = new Date(now - days * 86_400_000); + startDate.setHours(0, 0, 0, 0); + + // Seed every day in range with 0 so gaps show as empty bars. + for (let i = 0; i < days; i++) { + const d = new Date(startDate); + d.setDate(startDate.getDate() + i); + bucket.set(d.toISOString().slice(0, 10), 0); + } + + let total = 0; + for (const c of commits) { + const d = new Date(c.author.date); + if (Number.isNaN(d.getTime())) {continue;} + if (d.getTime() < startDate.getTime()) {continue;} + const key = d.toISOString().slice(0, 10); + if (!bucket.has(key)) {continue;} + bucket.set(key, (bucket.get(key) ?? 0) + 1); + total++; + } + + const bins = Array.from(bucket.entries()) + .sort((a, b) => a[0].localeCompare(b[0])) + .map(([day, count]) => ({ count, day })); + const maxCount = bins.reduce((m, b) => Math.max(m, b.count), 0); + + return { + bins, + end: new Date(), + maxCount, + start: startDate, + total, + }; + }, [commits, days]); + + const width = 600; + const height = 70; + const pad = 2; + const barW = (width - pad * (bins.length + 1)) / bins.length; + + return ( +
+
+ Commit activity + + {total} commit{total === 1 ? "" : "s"} in the last {days} days + +
+ + {bins.map((b, i) => { + const h = maxCount === 0 ? 0 : (b.count / maxCount) * (height - 4); + const x = pad + i * (barW + pad); + const y = height - h - 2; + const active = b.count > 0; + return ( + + + {b.day} — {b.count} commit{b.count === 1 ? "" : "s"} + + + ); + })} + +
+ {start.toLocaleDateString()} + {end.toLocaleDateString()} +
+
+ ); +} diff --git a/typescript/seeds-webui/src/components/DevStrip.tsx b/typescript/seeds-webui/src/components/DevStrip.tsx new file mode 100644 index 000000000..4df0d8861 --- /dev/null +++ b/typescript/seeds-webui/src/components/DevStrip.tsx @@ -0,0 +1,45 @@ +import { getTransport } from "#lib/transport/transport"; +import { useQuery } from "react-query"; + +// Thin horizontal strip under the header in dev mode that shows the current +// branch + working-tree dirtiness. Rendered only when the transport is the +// dev one (i.e. /__seeds/ping succeeded). +export function DevStrip() { + const { data: mode } = useQuery("transport-mode", async () => (await getTransport()).mode, { + staleTime: Infinity, + }); + const { data: status } = useQuery( + "git-status", + async () => { + const t = await getTransport(); + return t.gitStatus ? t.gitStatus() : null; + }, + { + enabled: mode === "dev", + refetchInterval: 15_000, + staleTime: 10_000, + }, + ); + + if (mode !== "dev") { + return null; + } + + return ( +
+ You are on branch + {status?.branch ?? "…"} + {status?.hasUncommittedChanges ? ( + + {status.changedFiles.length} changed file + {status.changedFiles.length === 1 ? "" : "s"} under db/seeds/ + + ) : ( + No seeds changes made! + )} + + You can edit files, and they will change the seed files on your system. + +
+ ); +} diff --git a/typescript/seeds-webui/src/components/IngestToast.tsx b/typescript/seeds-webui/src/components/IngestToast.tsx new file mode 100644 index 000000000..134c207f2 --- /dev/null +++ b/typescript/seeds-webui/src/components/IngestToast.tsx @@ -0,0 +1,78 @@ +import { useIngest } from "#lib/ingest/IngestProvider"; +import { useEffect, useState } from "react"; + +// Bottom-right toast that surfaces sqlite ingest progress from anywhere in +// the app. Stays pinned while ingesting; auto-hides ~1.5s after completion. +export function IngestToast() { + const { error, progress, ready } = useIngest(); + const [visible, setVisible] = useState(false); + const [hiding, setHiding] = useState(false); + + useEffect(() => { + if (error || (!ready && progress)) { + setVisible(true); + setHiding(false); + return; + } + + if (ready && visible) { + setHiding(true); + const t = setTimeout(() => { + setVisible(false); + setHiding(false); + }, 1500); + return () => clearTimeout(t); + } + }, [error, progress, ready, visible]); + + if (!visible) {return null;} + + const pct = + progress && progress.total > 0 + ? Math.round(((progress.idx + 1) / progress.total) * 100) + : 0; + + return ( +
+
+ {error ? ( + <> + + Ingest failed + + ) : ready ? ( + <> + + Database ready + + ) : ( + <> + + Loading seeds + + {progress ? `${progress.idx + 1}/${progress.total}` : ""} + + + )} +
+ {!ready && !error && progress ? ( + <> +
+ {progress.name} + + {progress.cached ? "cached" : `${progress.rows.toLocaleString()} rows`} + +
+
+
+
+ + ) : null} + {error ?
{error}
: null} +
+ ); +} diff --git a/typescript/seeds-webui/src/components/ResultTable.tsx b/typescript/seeds-webui/src/components/ResultTable.tsx new file mode 100644 index 000000000..bea6bb20e --- /dev/null +++ b/typescript/seeds-webui/src/components/ResultTable.tsx @@ -0,0 +1,249 @@ +import type { QueryResult } from "#lib/sqlite/types"; + +import { useMemo, useState } from "react"; + +// Threshold (in chars) above which a cell value is treated as a "blob" and +// gets its own scrollable monospace code box instead of an inline cell. +const LONG_VALUE_THRESHOLD = 80; + +interface ResultTableProps { + result: QueryResult; + // Soft cap on rows rendered to keep the DOM cheap. Extra rows are counted + // in the footer. Defaults to 500. + maxRows?: number; + // Optional footnote shown under the table (e.g. "first 500 of 1234 rows"). + footerNote?: React.ReactNode; + // Optional renderer for trailing action buttons per row. Wrap interactive + // elements in onClick={e => e.stopPropagation()} so they don't toggle row + // expansion. + rowActions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + // If provided, replaces the default pretty-printed JSON expansion with + // whatever this renders. In `toggle` mode, clicking the row shows/hides it. + // In `inline` mode, the detail is always shown under each data row (no + // expand column or click). Omit when you only have data rows. + expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + // `toggle` (default): chevron, click row to show detail. `inline`: always + // show detail under each row when `expandedContent` is set; data rows are + // not clickable. Used e.g. by Bulk to keep diffs visible without expanding. + rowDetailMode?: "inline" | "toggle"; +} + +// Shared, readable result table. Used by Query and Bulk pages. +// +// Design notes: +// - Cells wrap (`white-space: pre-wrap`) instead of ellipsising, so JSON +// and long text are actually visible. +// - Very long values get a scroll-capped code block, so one fat `raw` +// column doesn't blow up row height. +// - By default, clicking a row toggles a detail panel (see `rowDetailMode`). +// Pass `expandedContent` for custom content (e.g. a diff); otherwise the +// detail is the whole row as pretty-printed JSON. +// - `rowDetailMode="inline"` (with `expandedContent`) shows that detail +// under every row with no chevron or click — e.g. Bulk edit previews. +export function ResultTable({ + result, + maxRows = 500, + footerNote, + rowActions, + expandedContent, + rowDetailMode = "toggle", +}: ResultTableProps) { + const visible = result.rows.slice(0, maxRows); + const hidden = result.rows.length - visible.length; + const inline = rowDetailMode === "inline" && Boolean(expandedContent); + + // Heuristic: mark columns where any sampled value is long-ish as "wide", + // so we render them as blobs instead of trying to squeeze them into a cell. + const wideCols = useMemo(() => { + const set = new Set(); + const sample = visible.slice(0, 50); + for (let col = 0; col < result.columns.length; col++) { + for (const row of sample) { + const v = row[col]; + if (typeof v === "string" && v.length > LONG_VALUE_THRESHOLD) { + set.add(col); + break; + } + } + } + return set; + }, [visible, result.columns.length]); + + return ( +
+
+ + + + {inline ? null : ( + + ))} + {rowActions ? ( + + + + {visible.map((row, i) => ( + + ))} + +
+ )} + {result.columns.map((c, i) => ( + + {c} + + ) : null} +
+
+ {hidden > 0 ? ( +
+ Showing first {visible.length.toLocaleString()} of{" "} + {result.rows.length.toLocaleString()} rows +
+ ) : null} + {footerNote ?
{footerNote}
: null} +
+ ); +} + +function ResultRow({ + row, + columns, + wideCols, + actions, + expandedContent, + inlineDetail, +}: { + actions?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + columns: string[]; + expandedContent?: (ctx: { columns: string[]; row: unknown[] }) => React.ReactNode; + inlineDetail: boolean; + row: unknown[]; + wideCols: Set; +}) { + const [open, setOpen] = useState(false); + + function toggle() { + setOpen((x) => !x); + } + + const extraCols = actions ? 1 : 0; + const showPanel = inlineDetail || open; + const isInline = inlineDetail; + + return ( + <> + { + if (e.key === "Enter" || e.key === " ") {toggle();} + } + } + role={isInline ? undefined : "button"} + tabIndex={isInline ? undefined : 0} + > + {isInline ? null : ( + + + ▸ + + + )} + {row.map((v, j) => ( + + ))} + {actions ? ( + e.stopPropagation()} + > + {actions({ columns, row })} + + ) : null} + + {showPanel ? ( + + {isInline ? null : } + + {expandedContent ? ( + expandedContent({ columns, row }) + ) : ( +
{formatRowAsJson(row, columns)}
+ )} + + + ) : null} + + ); +} + +function ResultCell({ value, wide }: { value: unknown; wide: boolean }) { + if (value === null) { + return ( + + NULL + + ); + } + + const str = typeof value === "string" ? value : String(value); + const looksJson = + typeof value === "string" && str.length > 2 && (str.startsWith("{") || str.startsWith("[")); + + if (wide || str.length > LONG_VALUE_THRESHOLD || looksJson) { + return ( + +
{looksJson ? pretty(str) : str}
+ + ); + } + + return {str}; +} + +function pretty(s: string): string { + try { + return JSON.stringify(JSON.parse(s), null, 2); + } catch { + return s; + } +} + +function formatRowAsJson(row: unknown[], columns: string[]): string { + const obj: Record = {}; + for (let i = 0; i < columns.length; i++) { + const k = columns[i]!; + const v = row[i]; + if (typeof v === "string") { + // If the value itself is a JSON string (like the `raw` column), + // nest the parsed value so the row-json reads naturally. + if (v.length > 2 && (v.startsWith("{") || v.startsWith("["))) { + try { + obj[k] = JSON.parse(v); + continue; + } catch { + // fall through + } + } + obj[k] = v; + } else { + obj[k] = v; + } + } + return JSON.stringify(obj, null, 2); +} diff --git a/typescript/seeds-webui/src/components/RowEditor.tsx b/typescript/seeds-webui/src/components/RowEditor.tsx new file mode 100644 index 000000000..6e17fb9ff --- /dev/null +++ b/typescript/seeds-webui/src/components/RowEditor.tsx @@ -0,0 +1,128 @@ +import { useEffect, useState } from "react"; +import { type z } from "zod"; + +// --------------------------------------------------------------------------- +// JSON textarea editor with live Zod validation. +// +// Replaces the previous schema-driven field-tree form. Seeds documents are +// complex enough (discriminated unions, per-game chart data, etc.) that a +// generated form was always going to be leaky — it's cleaner to just edit +// the raw JSON and let the schema tell you exactly what's wrong. +// +// Behaviour: +// - Textarea is pre-filled with the initial value as pretty-printed JSON. +// - On every keystroke we try JSON.parse; if it succeeds we run safeParse +// against the Zod schema and show any issues inline. +// - Save is disabled until the JSON is both valid JSON and passes the schema. +// --------------------------------------------------------------------------- + +interface RowEditorProps { + schema: z.ZodType; + initial: unknown; + onSave: (value: unknown) => void; + onCancel: () => void; + title?: string; + submitLabel?: string; +} + +export function RowEditor({ + schema, + initial, + onSave, + onCancel, + title, + submitLabel = "Save", +}: RowEditorProps) { + const [text, setText] = useState(() => safeStringify(initial)); + const [parseError, setParseError] = useState(null); + const [zodErrors, setZodErrors] = useState([]); + const [parsed, setParsed] = useState(initial); + + useEffect(() => { + setText(safeStringify(initial)); + setParseError(null); + setZodErrors([]); + setParsed(initial); + }, [initial]); + + function handleChange(s: string) { + setText(s); + try { + const v = JSON.parse(s); + setParseError(null); + setParsed(v); + const result = schema.safeParse(v); + if (result.success) { + setZodErrors([]); + } else { + setZodErrors( + result.error.issues.map( + (issue) => `${issue.path.join(".") || "(root)"}: ${issue.message}`, + ), + ); + } + } catch (e) { + setParseError(e instanceof Error ? e.message : String(e)); + setZodErrors([]); + setParsed(undefined); + } + } + + function handleSave() { + const result = schema.safeParse(parsed); + if (!result.success) {return;} + onSave(result.data); + } + + const isValid = !parseError && zodErrors.length === 0 && parsed !== undefined; + + return ( +
+ {title ?

{title}

: null} +
+