diff --git a/bun.lock b/bun.lock index 87e0ab0..1872de7 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,5 @@ { "lockfileVersion": 1, - "configVersion": 0, "workspaces": { "": { "name": "cvsa", @@ -126,6 +125,7 @@ "@nivo/line": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6", @@ -789,13 +789,13 @@ "@radix-ui/rect": ["@radix-ui/rect@1.1.1", "", {}, "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw=="], - "@react-router/dev": ["@react-router/dev@7.9.5", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.9.5", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.1.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.9.5", "@vitejs/plugin-rsc": "*", "react-router": "^7.9.5", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-MkWI4zN7VbQ0tteuJtX5hmDINNS26IW236a8lM8+o1344xdnT/ZsBvcUh8AkzDdCRYEz1blgzgirpj0Wc1gmXg=="], + "@react-router/dev": ["@react-router/dev@7.9.6", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.9.6", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.1.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.9.6", "@vitejs/plugin-rsc": "*", "react-router": "^7.9.6", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-pBkbczGwI+NcZPcK8JPvWGWdjUpT/+okXYp6IXvt7zI3WLxr5hQLLRox5FkLiVxkykbqARO1hk9NRp9KFwJ2sA=="], - "@react-router/express": ["@react-router/express@7.9.5", "", { "dependencies": { "@react-router/node": "7.9.5" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.9.5", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-Mg94Tw9JSaRuwkvIC6PaODRzsLs6mo70ppz5qdIK/G3iotSxsH08TDNdzot7CaXXevk/pIiD/+Tbn0H/asHsYA=="], + "@react-router/express": ["@react-router/express@7.9.6", "", { "dependencies": { "@react-router/node": "7.9.6" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.9.6", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-YykIWqZSkcaOnC72k0BtPZJK9781Ge623pWkTn0svzFLsqWW2/tX/Y1/Le6eG2xWrGeGfaeTSzi9dy3agP0OIw=="], - "@react-router/node": ["@react-router/node@7.9.5", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.9.5", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-3mDd32mXh3gEkG0cLPnUaoLkY1pApsTPqn7O1j+P8aLf997uYz5lYDjt33vtMhaotlRM0x+5JziAKtz/76YBpQ=="], + "@react-router/node": ["@react-router/node@7.9.6", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.9.6", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-XzU8gPHwSl2Qh8/bOV30npbpH2fWOO3sFg+SwhX3+IddD1a/0C2KQzRiW/qAngkvZTJVdbca5Qp+FJjCCE7sNw=="], - "@react-router/serve": ["@react-router/serve@7.9.5", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.9.5", "@react-router/node": "7.9.5", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.9.5" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-sww8oDNqz8SgaXEQ3maqTuMlibCMpmWvLE0s5zyEyOQb1G99clYMcXceQ2HNU2jtXJkp+P5XI1CngpGpngyTnw=="], + "@react-router/serve": ["@react-router/serve@7.9.6", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.9.6", "@react-router/node": "7.9.6", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.9.6" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-qIT8hp1RJ0VAHyXpfuwoO31b9evrjPLRhUugqYJ7BZLpyAwhRsJIaQvvj60yZwWBMF2/3LdZu7M39rf0FhL6Iw=="], "@react-spring/animated": ["@react-spring/animated@9.4.5", "", { "dependencies": { "@react-spring/shared": "~9.4.5", "@react-spring/types": "~9.4.5" }, "peerDependencies": { "react": "^16.8.0 || >=17.0.0 || >=18.0.0" } }, "sha512-KWqrtvJSMx6Fj9nMJkhTwM9r6LIriExDRV6YHZV9HKQsaolUFppgkOXpC+rsL1JEtEvKv6EkLLmSqHTnuYjiIA=="], @@ -1883,7 +1883,7 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-router": ["react-router@7.9.5", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-JmxqrnBZ6E9hWmf02jzNn9Jm3UqyeimyiwzD69NjxGySG6lIz/1LVPsoTCwN7NBX2XjCEa1LIX5EMz1j2b6u6A=="], + "react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="], "react-smooth": ["react-smooth@4.0.4", "", { "dependencies": { "fast-equals": "^5.0.1", "prop-types": "^15.8.1", "react-transition-group": "^4.4.5" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q=="], @@ -2305,8 +2305,6 @@ "@react-router/dev/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - "@react-router/dev/prettier": ["prettier@3.6.2", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ=="], - "@react-router/dev/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], "@react-router/serve/express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "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.13.0", "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" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], @@ -2421,16 +2419,14 @@ "temp_frontend/@types/node": ["@types/node@20.19.24", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-FE5u0ezmi6y9OZEzlJfg37mqqf6ZDSF2V/NLjUyGrR9uTZ7Sb9F7bLNZ03S4XVUNRWGA7Ck4c1kK+YnuWjl+DA=="], + "temp_frontend/@types/react": ["@types/react@19.2.7", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg=="], + + "temp_frontend/@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "temp_frontend/lucide-react": ["lucide-react@0.553.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-BRgX5zrWmNy/lkVAe0dXBgd7XQdZ3HTf+Hwe3c9WK6dqgnj9h+hxV+MDncM88xDWlCq27+TKvHGE70ViODNILw=="], "temp_frontend/vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], - "tracker/@react-router/dev": ["@react-router/dev@7.9.6", "", { "dependencies": { "@babel/core": "^7.27.7", "@babel/generator": "^7.27.5", "@babel/parser": "^7.27.7", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/preset-typescript": "^7.27.1", "@babel/traverse": "^7.27.7", "@babel/types": "^7.27.7", "@npmcli/package-json": "^4.0.1", "@react-router/node": "7.9.6", "@remix-run/node-fetch-server": "^0.9.0", "arg": "^5.0.1", "babel-dead-code-elimination": "^1.0.6", "chokidar": "^4.0.0", "dedent": "^1.5.3", "es-module-lexer": "^1.3.1", "exit-hook": "2.2.1", "isbot": "^5.1.11", "jsesc": "3.0.2", "lodash": "^4.17.21", "p-map": "^7.0.3", "pathe": "^1.1.2", "picocolors": "^1.1.1", "prettier": "^3.6.2", "react-refresh": "^0.14.0", "semver": "^7.3.7", "tinyglobby": "^0.2.14", "valibot": "^1.1.0", "vite-node": "^3.2.2" }, "peerDependencies": { "@react-router/serve": "^7.9.6", "@vitejs/plugin-rsc": "*", "react-router": "^7.9.6", "typescript": "^5.1.0", "vite": "^5.1.0 || ^6.0.0 || ^7.0.0", "wrangler": "^3.28.2 || ^4.0.0" }, "optionalPeers": ["@react-router/serve", "@vitejs/plugin-rsc", "typescript", "wrangler"], "bin": { "react-router": "bin.js" } }, "sha512-pBkbczGwI+NcZPcK8JPvWGWdjUpT/+okXYp6IXvt7zI3WLxr5hQLLRox5FkLiVxkykbqARO1hk9NRp9KFwJ2sA=="], - - "tracker/@react-router/node": ["@react-router/node@7.9.6", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0" }, "peerDependencies": { "react-router": "7.9.6", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-XzU8gPHwSl2Qh8/bOV30npbpH2fWOO3sFg+SwhX3+IddD1a/0C2KQzRiW/qAngkvZTJVdbca5Qp+FJjCCE7sNw=="], - - "tracker/@react-router/serve": ["@react-router/serve@7.9.6", "", { "dependencies": { "@mjackson/node-fetch-server": "^0.2.0", "@react-router/express": "7.9.6", "@react-router/node": "7.9.6", "compression": "^1.7.4", "express": "^4.19.2", "get-port": "5.1.1", "morgan": "^1.10.0", "source-map-support": "^0.5.21" }, "peerDependencies": { "react-router": "7.9.6" }, "bin": { "react-router-serve": "bin.js" } }, "sha512-qIT8hp1RJ0VAHyXpfuwoO31b9evrjPLRhUugqYJ7BZLpyAwhRsJIaQvvj60yZwWBMF2/3LdZu7M39rf0FhL6Iw=="], - "tracker/@types/bun": ["@types/bun@1.3.3", "", { "dependencies": { "bun-types": "1.3.3" } }, "sha512-ogrKbJ2X5N0kWLLFKeytG0eHDleBYtngtlbu9cyBKFtNL3cnpDZkNdQj8flVf6WTZUX5ulI9AY1oa7ljhSrp+g=="], "tracker/@types/node": ["@types/node@22.19.1", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-LCCV0HdSZZZb34qifBsyWlUmok6W7ouER+oQIGBScS8EsZsQbrtFTUrDX4hOl+CS6p7cnNC4td+qrSVGSCTUfQ=="], @@ -2443,8 +2439,6 @@ "tracker/lucide-react": ["lucide-react@0.555.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA=="], - "tracker/react-router": ["react-router@7.9.6", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-Y1tUp8clYRXpfPITyuifmSoE2vncSME18uVLgaqyxh9H35JWpIfzHo+9y3Fzh5odk/jxPW29IgLgzcdwxGqyNA=="], - "tracker/vite": ["vite@7.2.4", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-NL8jTlbo0Tn4dUEXEsUg8KeyG/Lkmc4Fnzb8JXN/Ykm9G4HNImjtABMJgkQoVjOBN/j2WAwDTRytdqJbZsah7w=="], "unconfig/jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], @@ -2637,13 +2631,7 @@ "temp_frontend/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - "tracker/@react-router/dev/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], - - "tracker/@react-router/dev/react-refresh": ["react-refresh@0.14.2", "", {}, "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA=="], - - "tracker/@react-router/serve/@react-router/express": ["@react-router/express@7.9.6", "", { "dependencies": { "@react-router/node": "7.9.6" }, "peerDependencies": { "express": "^4.17.1 || ^5", "react-router": "7.9.6", "typescript": "^5.1.0" }, "optionalPeers": ["typescript"] }, "sha512-YykIWqZSkcaOnC72k0BtPZJK9781Ge623pWkTn0svzFLsqWW2/tX/Y1/Le6eG2xWrGeGfaeTSzi9dy3agP0OIw=="], - - "tracker/@react-router/serve/express": ["express@4.21.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "1.20.3", "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.13.0", "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" } }, "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA=="], + "temp_frontend/@types/react/csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "tracker/@types/node/undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], @@ -2731,60 +2719,8 @@ "sucrase/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], - "tracker/@react-router/serve/express/accepts": ["accepts@1.3.8", "", { "dependencies": { "mime-types": "~2.1.34", "negotiator": "0.6.3" } }, "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw=="], - - "tracker/@react-router/serve/express/body-parser": ["body-parser@1.20.3", "", { "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.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" } }, "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g=="], - - "tracker/@react-router/serve/express/content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], - - "tracker/@react-router/serve/express/cookie": ["cookie@0.7.1", "", {}, "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w=="], - - "tracker/@react-router/serve/express/cookie-signature": ["cookie-signature@1.0.6", "", {}, "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ=="], - - "tracker/@react-router/serve/express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], - - "tracker/@react-router/serve/express/finalhandler": ["finalhandler@1.3.1", "", { "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.1", "unpipe": "~1.0.0" } }, "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ=="], - - "tracker/@react-router/serve/express/fresh": ["fresh@0.5.2", "", {}, "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q=="], - - "tracker/@react-router/serve/express/merge-descriptors": ["merge-descriptors@1.0.3", "", {}, "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ=="], - - "tracker/@react-router/serve/express/path-to-regexp": ["path-to-regexp@0.1.12", "", {}, "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ=="], - - "tracker/@react-router/serve/express/qs": ["qs@6.13.0", "", { "dependencies": { "side-channel": "^1.0.6" } }, "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg=="], - - "tracker/@react-router/serve/express/send": ["send@0.19.0", "", { "dependencies": { "debug": "2.6.9", "depd": "2.0.0", "destroy": "1.2.0", "encodeurl": "~1.0.2", "escape-html": "~1.0.3", "etag": "~1.8.1", "fresh": "0.5.2", "http-errors": "2.0.0", "mime": "1.6.0", "ms": "2.1.3", "on-finished": "2.4.1", "range-parser": "~1.2.1", "statuses": "2.0.1" } }, "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw=="], - - "tracker/@react-router/serve/express/serve-static": ["serve-static@1.16.2", "", { "dependencies": { "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", "send": "0.19.0" } }, "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw=="], - - "tracker/@react-router/serve/express/statuses": ["statuses@2.0.1", "", {}, "sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ=="], - - "tracker/@react-router/serve/express/type-is": ["type-is@1.6.18", "", { "dependencies": { "media-typer": "0.3.0", "mime-types": "~2.1.24" } }, "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g=="], - "@react-router/serve/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], "@react-router/serve/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "tracker/@react-router/serve/express/accepts/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "tracker/@react-router/serve/express/accepts/negotiator": ["negotiator@0.6.3", "", {}, "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg=="], - - "tracker/@react-router/serve/express/body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], - - "tracker/@react-router/serve/express/body-parser/raw-body": ["raw-body@2.5.2", "", { "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", "iconv-lite": "0.4.24", "unpipe": "1.0.0" } }, "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA=="], - - "tracker/@react-router/serve/express/debug/ms": ["ms@2.0.0", "", {}, "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="], - - "tracker/@react-router/serve/express/send/encodeurl": ["encodeurl@1.0.2", "", {}, "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w=="], - - "tracker/@react-router/serve/express/send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], - - "tracker/@react-router/serve/express/type-is/media-typer": ["media-typer@0.3.0", "", {}, "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ=="], - - "tracker/@react-router/serve/express/type-is/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="], - - "tracker/@react-router/serve/express/accepts/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], - - "tracker/@react-router/serve/express/type-is/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="], } } diff --git a/packages/backend/routes/song/milestone.ts b/packages/backend/routes/song/milestone.ts index 7fde260..2657d9d 100644 --- a/packages/backend/routes/song/milestone.ts +++ b/packages/backend/routes/song/milestone.ts @@ -8,30 +8,27 @@ import { BiliVideoSchema } from "@backend/lib/schema"; type MileStoneType = "dendou" | "densetsu" | "shinwa"; const range = { - dendou: [0, 100000, 2160], - densetsu: [100000, 1000000, 8760], - shinwa: [1000000, 10000000, 43800] + dendou: [0, 100000], + densetsu: [100000, 1000000], + shinwa: [1000000, 10000000] }; export const closeMileStoneHandler = new Elysia({ prefix: "/songs" }).use(serverTiming()).get( "/close-milestone/:type", - async ({ params, timeLog }) => { - timeLog.startTime("retrieveCandidates"); + async ({ params }) => { const type = params.type; + const offset = params.offset; + const limit = params.limit; const min = range[type as MileStoneType][0]; const max = range[type as MileStoneType][1]; - return db + const query = db .select() .from(eta) .innerJoin(bilibiliMetadata, eq(bilibiliMetadata.aid, eta.aid)) - .where( - and( - gte(eta.currentViews, min), - lt(eta.currentViews, max), - lt(eta.eta, range[type as MileStoneType][2]) - ) - ) - .orderBy(eta.eta); + .where(and(gte(eta.currentViews, min), lt(eta.currentViews, max))) + .orderBy(eta.eta) + .$dynamic(); + return query.limit(limit || 20).offset(offset || 0); }, { response: { @@ -51,6 +48,11 @@ export const closeMileStoneHandler = new Elysia({ prefix: "/songs" }).use(server message: t.String() }) }, + params: t.Object({ + type: t.String({ enum: ["dendou", "densetsu", "shinwa"] }), + offset: t.Optional(t.Number()), + limit: t.Optional(t.Number()) + }), detail: { summary: "Get songs close to milestones", description: diff --git a/packages/temp_frontend/app/components/Error.tsx b/packages/temp_frontend/app/components/Error.tsx index c06ac6c..a0133b0 100644 --- a/packages/temp_frontend/app/components/Error.tsx +++ b/packages/temp_frontend/app/components/Error.tsx @@ -5,19 +5,21 @@ export function Error({ error }: { error: { status: number; value: { message?: s return (
- <div className="max-w-md w-full mx-4 bg-gray-100 dark:bg-neutral-900 rounded-2xl - shadow-lg p-6 flex flex-col gap-4 items-center text-center"> + <div + className="max-w-md w-full mx-4 bg-gray-100 dark:bg-neutral-900 rounded-2xl + shadow-lg p-6 flex flex-col gap-4 items-center text-center" + > <div className="w-16 h-16 flex items-center justify-center rounded-full bg-red-500 text-white text-3xl"> <TriangleAlert size={34} className="-translate-y-0.5" /> </div> <h1 className="text-3xl font-semibold text-neutral-900 dark:text-neutral-100">出错了</h1> - <p className="text-neutral-700 dark:text-neutral-300">状态码:{error.status}</p> {error.value.message && ( - <p className="text-neutral-600 dark:text-neutral-400 break-words"> - <span className="font-medium text-neutral-700 dark:text-neutral-300">错误信息</span> - <br /> - {error.value.message} - </p> + <p className="text-neutral-600 dark:text-neutral-400 wrap-break-word">{error.value.message}</p> + )} + {error.status === 404 && ( + <a href="/" className="hover:underline"> + 返回首页 + </a> )} </div> </div> diff --git a/packages/temp_frontend/app/components/ui/label.tsx b/packages/temp_frontend/app/components/ui/label.tsx new file mode 100644 index 0000000..1e0f8ca --- /dev/null +++ b/packages/temp_frontend/app/components/ui/label.tsx @@ -0,0 +1,22 @@ +import * as React from "react" +import * as LabelPrimitive from "@radix-ui/react-label" + +import { cn } from "@/lib//utils" + +function Label({ + className, + ...props +}: React.ComponentProps<typeof LabelPrimitive.Root>) { + return ( + <LabelPrimitive.Root + data-slot="label" + className={cn( + "flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50", + className + )} + {...props} + /> + ) +} + +export { Label } diff --git a/packages/temp_frontend/app/components/ui/scroll-area.tsx b/packages/temp_frontend/app/components/ui/scroll-area.tsx index fd01429..c60c97e 100644 --- a/packages/temp_frontend/app/components/ui/scroll-area.tsx +++ b/packages/temp_frontend/app/components/ui/scroll-area.tsx @@ -1,56 +1,44 @@ -import * as React from "react" -import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area" +import * as React from "react"; +import * as ScrollAreaPrimitive from "@radix-ui/react-scroll-area"; -import { cn } from "@/lib//utils" +import { cn } from "@/lib//utils"; -function ScrollArea({ - className, - children, - ...props -}: React.ComponentProps<typeof ScrollAreaPrimitive.Root>) { - return ( - <ScrollAreaPrimitive.Root - data-slot="scroll-area" - className={cn("relative", className)} - {...props} - > - <ScrollAreaPrimitive.Viewport - data-slot="scroll-area-viewport" - className="focus-visible:ring-ring/50 size-full rounded-[inherit] transition-[color,box-shadow] outline-none focus-visible:ring-[3px] focus-visible:outline-1" - > - {children} - </ScrollAreaPrimitive.Viewport> - <ScrollBar /> - <ScrollAreaPrimitive.Corner /> - </ScrollAreaPrimitive.Root> - ) -} +const ScrollArea = React.forwardRef< + React.ElementRef<typeof ScrollAreaPrimitive.Root>, + React.ComponentPropsWithoutRef<typeof ScrollAreaPrimitive.Root> +>(({ className, children, onScroll, ...props }, ref) => ( + <ScrollAreaPrimitive.Root className={cn("relative overflow-hidden", className)} {...props}> + <ScrollAreaPrimitive.Viewport ref={ref} className="h-full w-full rounded-[inherit]" onScroll={onScroll}> + {children} + </ScrollAreaPrimitive.Viewport> + <ScrollBar /> + <ScrollAreaPrimitive.Corner /> + </ScrollAreaPrimitive.Root> +)); function ScrollBar({ - className, - orientation = "vertical", - ...props + className, + orientation = "vertical", + ...props }: React.ComponentProps<typeof ScrollAreaPrimitive.ScrollAreaScrollbar>) { - return ( - <ScrollAreaPrimitive.ScrollAreaScrollbar - data-slot="scroll-area-scrollbar" - orientation={orientation} - className={cn( - "flex touch-none p-px transition-colors select-none", - orientation === "vertical" && - "h-full w-2.5 border-l border-l-transparent", - orientation === "horizontal" && - "h-2.5 flex-col border-t border-t-transparent", - className - )} - {...props} - > - <ScrollAreaPrimitive.ScrollAreaThumb - data-slot="scroll-area-thumb" - className="bg-border relative flex-1 rounded-full" - /> - </ScrollAreaPrimitive.ScrollAreaScrollbar> - ) + return ( + <ScrollAreaPrimitive.ScrollAreaScrollbar + data-slot="scroll-area-scrollbar" + orientation={orientation} + className={cn( + "flex touch-none p-px transition-colors select-none", + orientation === "vertical" && "h-full w-2.5 border-l border-l-transparent", + orientation === "horizontal" && "h-2.5 flex-col border-t border-t-transparent", + className, + )} + {...props} + > + <ScrollAreaPrimitive.ScrollAreaThumb + data-slot="scroll-area-thumb" + className="bg-border relative flex-1 rounded-full" + /> + </ScrollAreaPrimitive.ScrollAreaScrollbar> + ); } -export { ScrollArea, ScrollBar } +export { ScrollArea, ScrollBar }; diff --git a/packages/temp_frontend/app/routes.ts b/packages/temp_frontend/app/routes.ts index d2d1c6d..218c02f 100644 --- a/packages/temp_frontend/app/routes.ts +++ b/packages/temp_frontend/app/routes.ts @@ -1,7 +1,7 @@ import { type RouteConfig, index, route } from "@react-router/dev/routes"; export default [ - index("routes/home.tsx"), + index("routes/home/index.tsx"), route("song/:id/info", "routes/song/[id]/info/index.tsx"), route("song/:id/add", "routes/song/[id]/add.tsx"), route("search", "routes/search/index.tsx"), diff --git a/packages/temp_frontend/app/routes/home.tsx b/packages/temp_frontend/app/routes/home.tsx deleted file mode 100644 index 5b477db..0000000 --- a/packages/temp_frontend/app/routes/home.tsx +++ /dev/null @@ -1,222 +0,0 @@ -import { Layout } from "@/components/Layout"; -import type { Route } from "./+types/home"; -import { useEffect, useState } from "react"; -import { Input } from "@/components/ui/input"; -import { Button } from "@/components/ui/button"; -import { treaty } from "@elysiajs/eden"; -import type { App } from "@backend/src"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; -import { Skeleton } from "@/components/ui/skeleton"; -import { formatDateTime } from "@/components/SearchResults"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Card } from "@/components/ui/card"; -import { Progress } from "@/components/ui/progress"; -import { addHoursToNow, formatHours } from "./song/[id]/info"; - -// @ts-ignore idk -const app = treaty<App>(import.meta.env.VITE_API_URL!); - -type CloseMilestoneInfo = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["data"]; -type CloseMilestoneError = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["error"]; - -export function meta({}: Route.MetaArgs) { - return [{ title: "中V档案馆" }]; -} - -type MilestoneType = "dendou" | "densetsu" | "shinwa"; - -const milestoneConfig = { - dendou: { name: "殿堂", range: [90000, 99999], target: 100000 }, - densetsu: { name: "传说", range: [900000, 999999], target: 1000000 }, - shinwa: { name: "神话", range: [5000000, 9999999], target: 10000000 }, -}; - -export default function Home() { - const [input, setInput] = useState(""); - const [milestoneType, setMilestoneType] = useState<MilestoneType>("shinwa"); - const [closeMilestoneInfo, setCloseMilestoneInfo] = useState<CloseMilestoneInfo>(); - const [closeMilestoneError, setCloseMilestoneError] = useState<CloseMilestoneError>(); - const [isLoading, setIsLoading] = useState(false); - - const fetchMilestoneData = async (type: MilestoneType) => { - setIsLoading(true); - setCloseMilestoneError(undefined); - const { data, error } = await app.songs["close-milestone"]({ type }).get(); - if (error) { - setCloseMilestoneError(error); - } else { - setCloseMilestoneInfo(data); - } - setIsLoading(false); - }; - - useEffect(() => { - fetchMilestoneData(milestoneType); - }, [milestoneType]); - - const MilestoneVideoCard = ({ video }: { video: NonNullable<CloseMilestoneInfo>[number] }) => { - const config = milestoneConfig[milestoneType]; - const remainingViews = config.target - video.eta.currentViews; - const progressPercentage = (video.eta.currentViews / config.target) * 100; - - return ( - <Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0"> - <div className="w-full flex items-start space-x-4 mb-4"> - {video.bilibili_metadata.coverUrl && ( - <img - src={video.bilibili_metadata.coverUrl} - alt="视频封面" - className="h-25 w-40 rounded-sm object-cover shrink-0" - referrerPolicy="no-referrer" - loading="lazy" - /> - )} - <div className="flex flex-col w-full justify-between"> - <h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2"> - <a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline"> - {video.bilibili_metadata.title} - </a> - </h3> - - <div className="space-y-2 text-xs text-muted-foreground"> - <div className="flex items-center justify-between"> - <span>当前播放: {video.eta.currentViews.toLocaleString()}</span> - <span>目标: {config.target.toLocaleString()}</span> - </div> - - <Progress value={progressPercentage} /> - </div> - </div> - </div> - <div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2"> - <div> - <p>剩余播放: {remainingViews.toLocaleString()}</p> - <p>预计达成: {formatHours(video.eta.eta)}</p> - </div> - <div> - <p>播放速度: {Math.round(video.eta.speed)}/小时</p> - <p>达成时间: {addHoursToNow(video.eta.eta)}</p> - </div> - </div> - - <div className="flex gap-4 text-xs text-muted-foreground"> - {video.bilibili_metadata.publishedAt && ( - <span className="stat-num"> - 发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))} - </span> - )} - <a - href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`} - target="_blank" - rel="noopener noreferrer" - className="text-pink-400 text-xs hover:underline" - > - 观看视频 - </a> - <a - href={`/song/av${video.bilibili_metadata.aid}/info`} - className="text-xs text-secondary-foreground hover:underline" - > - 查看详情 - </a> - </div> - </Card> - ); - }; - - const MilestoneVideos = () => { - if (isLoading) { - return ( - <div className="space-y-4"> - {[1, 2, 3].map((i) => ( - <div - key={i} - className="bg-white dark:bg-neutral-800 rounded-lg shadow-sm border border-gray-200 dark:border-neutral-700 p-4" - > - <div className="flex items-start space-x-4"> - <Skeleton className="h-21 w-36 rounded-sm" /> - <div className="flex-1 space-y-2"> - <Skeleton className="h-6 w-3/4" /> - <Skeleton className="h-4 w-full" /> - <Skeleton className="h-4 w-2/3" /> - <Skeleton className="h-4 w-1/2" /> - </div> - </div> - </div> - ))} - </div> - ); - } - - if (closeMilestoneError) { - return ( - <div className="text-center py-8"> - <p className="text-red-500">加载失败: {closeMilestoneError.value?.message || "未知错误"}</p> - <Button variant="outline" className="mt-4" onClick={() => fetchMilestoneData(milestoneType)}> - 重试 - </Button> - </div> - ); - } - - if (!closeMilestoneInfo || closeMilestoneInfo.length === 0) { - return ( - <div className="text-center py-8"> - <p className="text-secondary-foreground">暂无接近{milestoneConfig[milestoneType].name}的视频</p> - </div> - ); - } - - return ( - <div className="space-y-4"> - <p className="text-xs text-muted-foreground"> - 找到 {closeMilestoneInfo.length} 个接近{milestoneConfig[milestoneType].name}的视频 - </p> - <ScrollArea className="h-140 w-full"> - {closeMilestoneInfo.map((video) => ( - <MilestoneVideoCard key={video.bilibili_metadata.aid} video={video} /> - ))} - </ScrollArea> - </div> - ); - }; - - return ( - <Layout> - <h2 className="text-2xl mt-5 mb-6">小工具</h2> - <div className="flex max-sm:flex-col sm:items-center gap-7 mb-8"> - <Button> - <a href="/util/time-calculator">时间计算器</a> - </Button> - - <div className="flex sm:w-96 gap-3"> - <Input placeholder="输入BV号或av号" value={input} onChange={(e) => setInput(e.target.value)} /> - <Button> - <a href={`/song/${input}/add`}>收录视频</a> - </Button> - </div> - </div> - - <h2 className="text-2xl mb-4">即将达成成就</h2> - <div className="flex items-center gap-4 mb-6"> - <Select value={milestoneType} onValueChange={(value: MilestoneType) => setMilestoneType(value)}> - <SelectTrigger className="w-20"> - <SelectValue placeholder="成就" /> - </SelectTrigger> - <SelectContent> - <SelectItem value="dendou">殿堂</SelectItem> - <SelectItem value="densetsu">传说</SelectItem> - <SelectItem value="shinwa">神话</SelectItem> - </SelectContent> - </Select> - <span className="text-xs text-muted-foreground"> - 播放量在 {milestoneConfig[milestoneType].range[0].toLocaleString()} -{" "} - {milestoneConfig[milestoneType].range[1].toLocaleString()} 之间,即将达成 - {milestoneConfig[milestoneType].name} - </span> - </div> - - <MilestoneVideos /> - </Layout> - ); -} diff --git a/packages/temp_frontend/app/routes/home/Milestone.tsx b/packages/temp_frontend/app/routes/home/Milestone.tsx new file mode 100644 index 0000000..c7e1910 --- /dev/null +++ b/packages/temp_frontend/app/routes/home/Milestone.tsx @@ -0,0 +1,174 @@ +import React, { useState, useCallback, useEffect, useRef } from "react"; +import { treaty } from "@elysiajs/eden"; +import type { App } from "@backend/src"; +import { Skeleton } from "@/components/ui/skeleton"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Button } from "@/components/ui/button"; +import { MilestoneVideoCard } from "./MilestoneVideoCard"; + +// @ts-ignore idk +const app = treaty<App>(import.meta.env.VITE_API_URL!); + +export type CloseMilestoneInfo = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["data"]; +type CloseMilestoneError = Awaited<ReturnType<ReturnType<(typeof app.songs)["close-milestone"]>["get"]>>["error"]; + +export type MilestoneType = "dendou" | "densetsu" | "shinwa"; + +export const milestoneConfig: Record<MilestoneType, { name: string; range: [number, number]; target: number }> = { + dendou: { name: "殿堂", range: [90000, 99999], target: 100000 }, + densetsu: { name: "传说", range: [900000, 999999], target: 1000000 }, + shinwa: { name: "神话", range: [5000000, 9999999], target: 10000000 }, +}; + +export const MilestoneVideos: React.FC = () => { + const [milestoneType, setMilestoneType] = useState<MilestoneType>("shinwa"); + const [milestoneData, setMilestoneData] = useState<CloseMilestoneInfo>([]); + const [closeMilestoneError, setCloseMilestoneError] = useState<CloseMilestoneError>(); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingMore, setIsLoadingMore] = useState(false); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(true); + + const scrollContainer = useRef<HTMLDivElement>(null); + + const fetchMilestoneData = useCallback( + async (type: MilestoneType, reset: boolean = false) => { + const currentOffset = reset ? 0 : offset; + + if (!reset) { + setIsLoadingMore(true); + } else { + setIsLoading(true); + } + + setCloseMilestoneError(undefined); + + try { + const { data, error } = await app.songs["close-milestone"]({ type }).get({ + query: { + offset: currentOffset, + limit: 20, + }, + }); + + if (error) { + setCloseMilestoneError(error); + } else { + if (reset) { + setMilestoneData(data); + } else { + setMilestoneData((prev) => [...prev!, ...data]); + } + setHasMore(data.length >= 20); + } + } catch (err) { + console.error("Fetch error:", err); + } finally { + setIsLoading(false); + setIsLoadingMore(false); + } + }, + [offset], + ); + + useEffect(() => { + setOffset(0); + setHasMore(true); + setMilestoneData([]); + fetchMilestoneData(milestoneType, true); + }, [milestoneType]); + + useEffect(() => { + if (offset > 0 && hasMore && !isLoadingMore) { + fetchMilestoneData(milestoneType); + } + }, [offset]); + + const handleScroll = useCallback( + (e: React.UIEvent<HTMLDivElement>) => { + const target = e.currentTarget; + const { scrollHeight, scrollTop, clientHeight } = target; + + if (scrollTop + clientHeight >= scrollHeight - 400 && !isLoadingMore && hasMore) { + setOffset((prev) => prev + 20); + } + }, + [hasMore, isLoadingMore], + ); + + const renderContent = () => { + if (!milestoneData) return null; + + if (isLoading && milestoneData.length === 0) { + return ( + <ScrollArea className="h-140 w-full"> + <div className="h-[0.1px]"></div> + {[1, 2, 3].map((i) => ( + <div + key={i} + className="rounded-xl my-4 shadow-sm border border-gray-200 dark:border-neutral-700" + > + <Skeleton className="h-49 sm:h-55 rounded-xl" /> + </div> + ))} + </ScrollArea> + ); + } + + if (closeMilestoneError && milestoneData.length === 0) { + return ( + <div className="text-center py-8"> + <p className="text-red-500">加载失败: {closeMilestoneError.value?.message || "未知错误"}</p> + <Button variant="outline" className="mt-4" onClick={() => fetchMilestoneData(milestoneType, true)}> + 重试 + </Button> + </div> + ); + } + + if (milestoneData.length === 0) { + return ( + <div className="text-center py-8"> + <p className="text-secondary-foreground">暂无接近{milestoneConfig[milestoneType].name}的视频</p> + </div> + ); + } + + return ( + <div className="space-y-4"> + <ScrollArea className="h-140 w-full" ref={scrollContainer} onScroll={handleScroll}> + {milestoneData.map((video) => ( + <MilestoneVideoCard + key={video.bilibili_metadata.aid} + video={video} + milestoneType={milestoneType} + /> + ))} + {isLoadingMore && ( + <div className="rounded-xl my-4 px-3 py-4"> + <Skeleton className="h-40 rounded-xl" /> + </div> + )} + </ScrollArea> + </div> + ); + }; + + return ( + <> + <div className="flex justify-between mt-6 mb-2"> + <h2 className="text-2xl font-medium">成就助攻</h2> + <Tabs value={milestoneType} onValueChange={(value: string) => setMilestoneType(value as MilestoneType)}> + <TabsList> + <TabsTrigger value="dendou">殿堂</TabsTrigger> + <TabsTrigger value="densetsu">传说</TabsTrigger> + <TabsTrigger value="shinwa">神话</TabsTrigger> + </TabsList> + </Tabs> + </div> + + {renderContent()} + </> + ); +}; diff --git a/packages/temp_frontend/app/routes/home/MilestoneVideoCard.tsx b/packages/temp_frontend/app/routes/home/MilestoneVideoCard.tsx new file mode 100644 index 0000000..559810c --- /dev/null +++ b/packages/temp_frontend/app/routes/home/MilestoneVideoCard.tsx @@ -0,0 +1,81 @@ +import { Card } from "@/components/ui/card"; +import { Progress } from "@/components/ui/progress"; +import { formatDateTime } from "@/components/SearchResults"; +import { addHoursToNow, formatHours } from "../song/[id]/info"; +import { milestoneConfig, type CloseMilestoneInfo, type MilestoneType } from "./Milestone"; + +export const MilestoneVideoCard = ({ + video, + milestoneType, +}: { + video: NonNullable<CloseMilestoneInfo>[number]; + milestoneType: MilestoneType; +}) => { + const config = milestoneConfig[milestoneType]; + const remainingViews = config.target - video.eta.currentViews; + const progressPercentage = (video.eta.currentViews / config.target) * 100; + + return ( + <Card className="px-3 max-md:py-3 md:px-4 my-4 gap-0"> + <div className="w-full flex items-start space-x-4 mb-4"> + {video.bilibili_metadata.coverUrl && ( + <img + src={video.bilibili_metadata.coverUrl} + alt="视频封面" + className="h-25 w-40 rounded-sm object-cover shrink-0" + referrerPolicy="no-referrer" + loading="lazy" + /> + )} + <div className="flex flex-col w-full justify-between"> + <h3 className="text-sm sm:text-lg font-medium line-clamp-2 text-wrap mb-2"> + <a href={`/song/av${video.bilibili_metadata.aid}/info`} className="hover:underline"> + {video.bilibili_metadata.title} + </a> + </h3> + + <div className="space-y-2 text-xs text-muted-foreground"> + <div className="flex items-center justify-between"> + <span>当前播放: {video.eta.currentViews.toLocaleString()}</span> + <span>目标: {config.target.toLocaleString()}</span> + </div> + + <Progress value={progressPercentage} /> + </div> + </div> + </div> + <div className="grid grid-cols-2 gap-5 text-xs text-muted-foreground mb-2"> + <div> + <p>剩余播放: {remainingViews.toLocaleString()}</p> + <p>预计达成: {formatHours(video.eta.eta)}</p> + </div> + <div> + <p>播放速度: {Math.round(video.eta.speed)}/小时</p> + <p>达成时间: {addHoursToNow(video.eta.eta)}</p> + </div> + </div> + + <div className="flex gap-4 text-xs text-muted-foreground"> + {video.bilibili_metadata.publishedAt && ( + <span className="stat-num"> + 发布于 {formatDateTime(new Date(video.bilibili_metadata.publishedAt))} + </span> + )} + <a + href={`https://www.bilibili.com/video/av${video.bilibili_metadata.aid}`} + target="_blank" + rel="noopener noreferrer" + className="text-pink-400 text-xs hover:underline" + > + 观看视频 + </a> + <a + href={`/song/av${video.bilibili_metadata.aid}/info`} + className="text-xs text-secondary-foreground hover:underline" + > + 查看详情 + </a> + </div> + </Card> + ); +}; diff --git a/packages/temp_frontend/app/routes/home/index.tsx b/packages/temp_frontend/app/routes/home/index.tsx new file mode 100644 index 0000000..73103a2 --- /dev/null +++ b/packages/temp_frontend/app/routes/home/index.tsx @@ -0,0 +1,33 @@ +import { Layout } from "@/components/Layout"; +import type { Route } from "./+types/index"; +import { useState } from "react"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { MilestoneVideos } from "@/routes/home/Milestone"; + +export function meta({}: Route.MetaArgs) { + return [{ title: "中V档案馆" }]; +} + +export default function Home() { + const [input, setInput] = useState(""); + return ( + <Layout> + <h2 className="text-2xl font-medium mt-8 mb-4">小工具</h2> + <div className="flex max-sm:flex-col sm:items-center gap-7 mb-8"> + <a href="/time-calculator"> + <Button>时间计算器</Button> + </a> + + <div className="flex sm:w-96 gap-3"> + <Input placeholder="输入 BV 号或 av 号" value={input} onChange={(e) => setInput(e.target.value)} /> + <a href={`/song/${input}/add`}> + <Button>收录视频</Button> + </a> + </div> + </div> + + <MilestoneVideos /> + </Layout> + ); +} diff --git a/packages/temp_frontend/app/routes/song/[id]/add.tsx b/packages/temp_frontend/app/routes/song/[id]/add.tsx index 999cc51..f7b803b 100644 --- a/packages/temp_frontend/app/routes/song/[id]/add.tsx +++ b/packages/temp_frontend/app/routes/song/[id]/add.tsx @@ -2,23 +2,19 @@ import type { Route } from "./+types/add"; import { treaty } from "@elysiajs/eden"; import type { App } from "@backend/src"; import { useEffect, useState } from "react"; -import { Skeleton } from "@/components/ui/skeleton"; -import { TriangleAlert, CheckCircle, Clock, AlertCircle } from "lucide-react"; +import { CheckCircle, Clock, AlertCircle } from "lucide-react"; import { Title } from "@/components/Title"; -import { Search } from "@/components/Search"; -import { Error } from "@/components/Error"; import { Layout } from "@/components/Layout"; -import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; import { toast } from "sonner"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { biliIDToAID } from "@backend/lib/bilibiliID"; +import { Error } from "@/components/Error"; // @ts-ignore idk const app = treaty<App>(import.meta.env.VITE_API_URL!); type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"]; -type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"]; type ImportStatus = { id: string; state: string; @@ -34,6 +30,10 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) { const [isImporting, setIsImporting] = useState(false); const [importStatus, setImportStatus] = useState<ImportStatus | null>(null); const [importInterval, setImportInterval] = useState<NodeJS.Timeout | null>(null); + const aid = biliIDToAID(loaderData.id); + if (!aid) { + return <Error error={{ status: 404, value: { message: "找不到页面" } }} />; + } const importSong = async () => { const response = await app.song.import.bilibili.post( @@ -51,7 +51,6 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) { return; } - // @ts-ignore - Type issues with Eden treaty const jobID = response.data?.jobID; if (!jobID) { toast.error("导入失败:未收到任务ID"); @@ -89,8 +88,8 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) { // Redirect to song info page after successful import setTimeout(() => { window.location.href = `/song/${loaderData.id}/info`; - }, 2000); - }, 2000); + }, 1200); + }, 500); setImportInterval(interval); }; @@ -106,9 +105,8 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) { useEffect(() => { return () => { - if (importInterval) { - clearInterval(importInterval); - } + if (!importInterval) return; + clearInterval(importInterval); }; }, [importInterval]); diff --git a/packages/temp_frontend/app/routes/time-calculator.tsx b/packages/temp_frontend/app/routes/time-calculator.tsx index ba1f69f..682b2df 100644 --- a/packages/temp_frontend/app/routes/time-calculator.tsx +++ b/packages/temp_frontend/app/routes/time-calculator.tsx @@ -1,7 +1,9 @@ import { Layout } from "@/components/Layout"; -import type { Route } from "./+types/time-calculator"; -import { useEffect, useState } from "react"; +import { useState } from "react"; import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; +import { Label } from "@/components/ui/label"; import { formatDateTime } from "@/components/SearchResults"; export default function Home() { @@ -13,19 +15,108 @@ export default function Home() { const time2 = new Date(time2Input); const difference = time2.getTime() - time1.getTime(); const days = Math.floor(difference / (1000 * 60 * 60 * 24)); - const hours = Math.floor(difference / (1000 * 60 * 60)); + const hours = Math.floor(difference / (1000 * 60 * 60)) % 24; const minutes = Math.floor((difference / (1000 * 60)) % 60); - const diffString = `${days || 0} 天 ${hours || 0} 时 ${minutes || 0} 分`; + const seconds = Math.floor((difference / 1000) % 60); + + const diffString = `${Math.abs(days) || 0} 天 ${Math.abs(hours) || 0} 时 ${Math.abs(minutes) || 0} 分`; + const isNegative = difference < 0; + + const setQuickTime = (hoursOffset: number) => { + const newTime = new Date(time1Input); + newTime.setHours(newTime.getHours() + hoursOffset); + setTime2Input(formatDateTime(newTime)); + }; return ( <Layout> - <h1 className="my-5 text-2xl">时间计算器</h1> - <p>在下方输入两个时间点,即可得到两个时间点之间的时间差</p> - <div className="flex gap-5 mt-3"> - <Input className="text-center w-50" value={time1Input} onChange={(e) => setTime1Input(e.target.value)} /> - <Input className="text-center w-50" value={time2Input} onChange={(e) => setTime2Input(e.target.value)} /> + <div className="max-w-4xl mx-auto space-y-6"> + <div className="space-y-2 mt-8"> + <h2 className="text-2xl font-bold tracking-tight">时间计算器</h2> + <p className="text-muted-foreground">输入两个时间点,计算精确的时间差</p> + </div> + + {/* 时间输入区域 */} + <Card> + <CardHeader> + <CardTitle>时间设置</CardTitle> + <CardDescription>选择或输入开始时间和结束时间</CardDescription> + </CardHeader> + <CardContent className="space-y-6"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-6"> + <div className="space-y-3"> + <Label htmlFor="start-time">开始时间</Label> + <Input + id="start-time" + value={time1Input} + onChange={(e) => setTime1Input(e.target.value)} + /> + <Button + variant="outline" + size="sm" + onClick={() => setTime1Input(formatDateTime(now))} + className="w-full" + > + 设为当前时间 + </Button> + </div> + + <div className="space-y-3"> + <Label htmlFor="end-time">结束时间</Label> + <Input + id="end-time" + value={time2Input} + onChange={(e) => setTime2Input(e.target.value)} + /> + <Button + variant="outline" + size="sm" + onClick={() => setTime2Input(formatDateTime(now))} + className="w-full" + > + 设为当前时间 + </Button> + </div> + </div> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle>计算结果</CardTitle> + <CardDescription>两个时间点之间的精确时间差</CardDescription> + </CardHeader> + <CardContent> + <div className="text-center space-y-4"> + <div className="space-y-2"> + <div className="text-2xl font-bold">{isNegative ? "时间差为负值" : diffString}</div> + {isNegative && ( + <div className="text-lg text-muted-foreground">结束时间早于开始时间</div> + )} + </div> + + <div className="grid grid-cols-2 md:grid-cols-4 gap-4 pt-4"> + <div className="space-y-2"> + <div className="text-2xl font-bold">{Math.abs(days)}</div> + <div className="text-sm text-muted-foreground">天数</div> + </div> + <div className="space-y-2"> + <div className="text-2xl font-bold">{Math.abs(hours)}</div> + <div className="text-sm text-muted-foreground">小时</div> + </div> + <div className="space-y-2"> + <div className="text-2xl font-bold">{Math.abs(minutes)}</div> + <div className="text-sm text-muted-foreground">分钟</div> + </div> + <div className="space-y-2"> + <div className="text-2xl font-bold">{Math.abs(seconds)}</div> + <div className="text-sm text-muted-foreground">秒数</div> + </div> + </div> + </div> + </CardContent> + </Card> </div> - <p className="mt-3">{diffString}</p> </Layout> ); } diff --git a/packages/temp_frontend/package.json b/packages/temp_frontend/package.json index de13a8b..5072e26 100644 --- a/packages/temp_frontend/package.json +++ b/packages/temp_frontend/package.json @@ -17,6 +17,7 @@ "@nivo/line": "^0.99.0", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-label": "^2.1.8", "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-select": "^2.2.6",