1
0

improve: frontend & backend

This commit is contained in:
alikia2x (寒寒) 2025-11-10 07:30:21 +08:00
parent 429efa0c9f
commit 50d6e0f498
WARNING! Although there is a key with this ID in the database it does not verify this commit! This commit is SUSPICIOUS.
GPG Key ID: 56209E0CCD8420C6
14 changed files with 706 additions and 46 deletions

View File

@ -203,6 +203,7 @@
"@nivo/line": "^0.99.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@react-router/node": "^7.7.1",
@ -518,6 +519,14 @@
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.4.1", "", { "dependencies": { "@eslint/core": "^0.17.0", "levn": "^0.4.1" } }, "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA=="],
"@floating-ui/core": ["@floating-ui/core@1.7.3", "", { "dependencies": { "@floating-ui/utils": "^0.2.10" } }, "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w=="],
"@floating-ui/dom": ["@floating-ui/dom@1.7.4", "", { "dependencies": { "@floating-ui/core": "^1.7.3", "@floating-ui/utils": "^0.2.10" } }, "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA=="],
"@floating-ui/react-dom": ["@floating-ui/react-dom@2.1.6", "", { "dependencies": { "@floating-ui/dom": "^1.7.4" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-4JX6rEatQEvlmgU80wZyq9RT96HZJa88q8hp0pBd+LrczeDI4o6uA2M+uvxngVHo4Ihr8uibXxH6+70zhAFrVw=="],
"@floating-ui/utils": ["@floating-ui/utils@0.2.10", "", {}, "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ=="],
"@grpc/grpc-js": ["@grpc/grpc-js@1.14.1", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-sPxgEWtPUR3EnRJCEtbGZG2iX8LQDUls2wUS3o27jg07KqJFMq6YDeWvMo1wfpmy3rqRdS0rivpLwhqQtEyCuQ=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.0", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.3", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-rc1hOQtjIWGxcxpb9aHAfLpIctjEnsDehj0DAiVfBlmT84uvR0uUtN2hEi/ecvWVjXUGf5qPF4qEgiLOx1YIMQ=="],
@ -782,10 +791,14 @@
"@rabbit-company/argon2id": ["@rabbit-company/argon2id@2.1.0", "", { "peerDependencies": { "typescript": "^5.6.2" } }, "sha512-X/kt89qjmS9+Zh+DYCGcWeTwHa4C8vY8T3EnSma+vWj7spMzAYX4F8vmGUkny9hygpTOeC/yXwAUdJAfJ52H+w=="],
"@radix-ui/number": ["@radix-ui/number@1.1.1", "", {}, "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g=="],
"@radix-ui/primitive": ["@radix-ui/primitive@1.1.3", "", {}, "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg=="],
"@radix-ui/react-alert-dialog": ["@radix-ui/react-alert-dialog@1.1.15", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-dialog": "1.1.15", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-oTVLkEw5GpdRe29BqJ0LSDFWI3qu0vR1M0mUkOQWDIUnY/QIkLpgDMWuKxP94c2NAC2LGcgVhG1ImF3jkZ5wXw=="],
"@radix-ui/react-arrow": ["@radix-ui/react-arrow@1.1.7", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w=="],
"@radix-ui/react-collection": ["@radix-ui/react-collection@1.1.7", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw=="],
"@radix-ui/react-compose-refs": ["@radix-ui/react-compose-refs@1.1.2", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg=="],
@ -804,6 +817,8 @@
"@radix-ui/react-id": ["@radix-ui/react-id@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg=="],
"@radix-ui/react-popper": ["@radix-ui/react-popper@1.2.8", "", { "dependencies": { "@floating-ui/react-dom": "^2.0.0", "@radix-ui/react-arrow": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-rect": "1.1.1", "@radix-ui/react-use-size": "1.1.1", "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw=="],
"@radix-ui/react-portal": ["@radix-ui/react-portal@1.1.9", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ=="],
"@radix-ui/react-presence": ["@radix-ui/react-presence@1.1.5", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ=="],
@ -812,6 +827,8 @@
"@radix-ui/react-roving-focus": ["@radix-ui/react-roving-focus@1.1.11", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7A6S9jSgm/S+7MdtNDSb+IU859vQqJ/QAtcYQcfFC6W8RS4IxIZDldLR0xqCFZ6DCyrQLjLPsxtTNch5jVA4lA=="],
"@radix-ui/react-select": ["@radix-ui/react-select@2.2.6", "", { "dependencies": { "@radix-ui/number": "1.1.1", "@radix-ui/primitive": "1.1.3", "@radix-ui/react-collection": "1.1.7", "@radix-ui/react-compose-refs": "1.1.2", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-dismissable-layer": "1.1.11", "@radix-ui/react-focus-guards": "1.1.3", "@radix-ui/react-focus-scope": "1.1.7", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-popper": "1.2.8", "@radix-ui/react-portal": "1.1.9", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-slot": "1.2.3", "@radix-ui/react-use-callback-ref": "1.1.1", "@radix-ui/react-use-controllable-state": "1.2.2", "@radix-ui/react-use-layout-effect": "1.1.1", "@radix-ui/react-use-previous": "1.1.1", "@radix-ui/react-visually-hidden": "1.2.3", "aria-hidden": "^1.2.4", "react-remove-scroll": "^2.6.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ=="],
"@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.4", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA=="],
"@radix-ui/react-tabs": ["@radix-ui/react-tabs@1.1.13", "", { "dependencies": { "@radix-ui/primitive": "1.1.3", "@radix-ui/react-context": "1.1.2", "@radix-ui/react-direction": "1.1.1", "@radix-ui/react-id": "1.1.1", "@radix-ui/react-presence": "1.1.5", "@radix-ui/react-primitive": "2.1.3", "@radix-ui/react-roving-focus": "1.1.11", "@radix-ui/react-use-controllable-state": "1.2.2" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A=="],
@ -826,6 +843,16 @@
"@radix-ui/react-use-layout-effect": ["@radix-ui/react-use-layout-effect@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ=="],
"@radix-ui/react-use-previous": ["@radix-ui/react-use-previous@1.1.1", "", { "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ=="],
"@radix-ui/react-use-rect": ["@radix-ui/react-use-rect@1.1.1", "", { "dependencies": { "@radix-ui/rect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w=="],
"@radix-ui/react-use-size": ["@radix-ui/react-use-size@1.1.1", "", { "dependencies": { "@radix-ui/react-use-layout-effect": "1.1.1" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ=="],
"@radix-ui/react-visually-hidden": ["@radix-ui/react-visually-hidden@1.2.3", "", { "dependencies": { "@radix-ui/react-primitive": "2.1.3" }, "peerDependencies": { "@types/react": "*", "@types/react-dom": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug=="],
"@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/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=="],
@ -3064,6 +3091,8 @@
"@radix-ui/react-primitive/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@radix-ui/react-select/@radix-ui/react-slot": ["@radix-ui/react-slot@1.2.3", "", { "dependencies": { "@radix-ui/react-compose-refs": "1.1.2" }, "peerDependencies": { "@types/react": "*", "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A=="],
"@react-router/dev/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"@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=="],

View File

@ -13,8 +13,11 @@ export const getMileStoneETAfactor = (x: number) => {
}
};
export const getClosetMilestone = (views: number) => {
export const getClosetMilestone = (views: number, strict: boolean = false) => {
if (views < 100000) return 100000;
if (views < 1000000) return 1000000;
if (views < 10000000) {
return strict ? 10000000 : Math.ceil(views / 1000000) * 1000000;
}
return Math.ceil(views / 1000000) * 1000000;
};

View File

@ -11,6 +11,7 @@ import logger from "@core/log";
import { SnapshotQueue } from "mq/index";
import { sql } from "@core/db/dbNew";
import { jobCounter, jobDurationRaw } from "metrics";
import { getClosetMilestone as closetMilestone } from "@core/lib/milestone";
const priorityMap: { [key: string]: number } = {
milestone: 1,
@ -88,11 +89,4 @@ export const snapshotTickWorker = async (_job: Job) => {
}
};
export const closetMilestone = (views: number, strict: boolean = false) => {
if (views < 100000) return 100000;
if (views < 1000000) return 1000000;
if (views < 10000000) {
return strict ? 10000000 : Math.ceil(views / 1000000) * 1000000;
}
return Math.ceil(views / 1000000) * 1000000;
};
export { closetMilestone };

View File

@ -2,6 +2,9 @@ import { Elysia, t } from "elysia";
import { biliIDToAID } from "@elysia/lib/bilibiliID";
import { requireAuth } from "@elysia/middlewares/auth";
import { LatestVideosQueue } from "@elysia/lib/mq";
import { db } from "@core/drizzle";
import { songs } from "@core/drizzle/main/schema";
import { eq, and } from "drizzle-orm";
export const addSongHandler = new Elysia()
.use(requireAuth)
@ -10,6 +13,23 @@ export const addSongHandler = new Elysia()
async ({ body, status }) => {
const id = body.id;
const aid = biliIDToAID(id);
if (!aid) {
return status(400, {
message:
"We cannot parse the video ID, or we currently do not support this format."
});
}
const aidExistsInSongs = await db
.select()
.from(songs)
.where(and(eq(songs.aid, aid), eq(songs.deleted, false)))
.limit(1);
if (aidExistsInSongs.length > 0) {
return {
jobID: -1,
message: "Video already exists in the songs table."
};
}
const job = await LatestVideosQueue.add("getVideoInfo", {
aid: aid,
insertSongs: true
@ -30,6 +50,9 @@ export const addSongHandler = new Elysia()
message: t.String(),
jobID: t.String()
}),
400: t.Object({
message: t.String()
}),
401: t.Object({
message: t.String()
}),
@ -46,6 +69,15 @@ export const addSongHandler = new Elysia()
"/song/import/:id/status",
async ({ params, status }) => {
const jobID = params.id;
if (parseInt(jobID) === -1) {
return {
id: jobID,
state: "completed",
result: {
message: "Video already exists in the songs table."
}
}
}
const job = await LatestVideosQueue.getJob(jobID);
if (!job) {
return status(404, {

View File

@ -0,0 +1,55 @@
import { Elysia, t } from "elysia";
import { db } from "@core/drizzle";
import { eta } from "@core/drizzle/main/schema";
import { eq } from "drizzle-orm";
import { biliIDToAID } from "@elysia/lib/bilibiliID";
export const songEtaHandler = new Elysia({ prefix: "/video" }).get(
"/:id/eta",
async ({ params, status }) => {
const id = params.id;
const aid = biliIDToAID(id);
if (!aid) {
return status(400, {
code: "MALFORMED_SLOT",
message: "We cannot parse the video ID, or we currently do not support this format."
});
}
const data = await db.select().from(eta).where(eq(eta.aid, aid));
if (data.length === 0) {
return status(404, {
code: "VIDEO_NOT_FOUND",
message: "Video not found."
});
}
return {
aid: data[0].aid,
eta: data[0].eta,
views: data[0].currentViews,
speed: data[0].speed,
updatedAt: data[0].updatedAt
};
},
{
response: {
200: t.Object({
aid: t.Number(),
eta: t.Number(),
views: t.Number(),
speed: t.Number(),
updatedAt: t.String()
}),
400: t.Object({
code: t.String(),
message: t.String()
}),
404: t.Object({
code: t.String(),
message: t.String()
})
},
headers: t.Object({
Authorization: t.Optional(t.String())
})
}
);

View File

@ -13,6 +13,7 @@ import { searchHandler } from "@elysia/routes/search";
import { getVideoSnapshotsHandler } from "@elysia/routes/video/snapshots";
import { addSongHandler } from "@elysia/routes/song/add";
import { deleteSongHandler } from "@elysia/routes/song/delete";
import { songEtaHandler } from "@elysia/routes/video/eta";
const [host, port] = getBindingInfo();
logStartup(host, port);
@ -43,6 +44,7 @@ const app = new Elysia({
.use(getVideoSnapshotsHandler)
.use(addSongHandler)
.use(deleteSongHandler)
.use(songEtaHandler)
.listen(15412);
export const VERSION = "0.7.0";

View File

@ -4,7 +4,7 @@ import { Search } from "./Search";
export function Layout({ children }: { children?: React.ReactNode }) {
return (
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl">
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
<div className="flex items-center justify-between">
<a href="/">
<h1 className="text-3xl mb-5">V档案馆</h1>
@ -41,7 +41,7 @@ const LoginOrLogout = () => {
export function LayoutWithoutSearch({ children }: { children?: React.ReactNode }) {
return (
<div className="w-screen min-h-screen relative left-0 top-0 flex justify-center">
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl">
<main className="w-full max-sm:mx-3 pt-14 sm:w-xl xl:w-2xl 2xl:w-3xl mb-20">
<div className="flex items-center justify-between">
<a href="/">
<h1 className="text-3xl mb-5">V档案馆</h1>

View File

@ -0,0 +1,185 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib//utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "popper",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span className="absolute right-2 flex size-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@ -4,8 +4,8 @@ export default [
index("routes/home.tsx"),
route("song/:id/info", "routes/song/[id]/info/index.tsx"),
route("song/:id/add", "routes/song/[id]/add.tsx"),
route("chart-demo", "routes/chartDemo.tsx"),
route("search", "routes/search/index.tsx"),
route("login", "routes/login.tsx"),
route("video/:id/info", "routes/video/[id]/info/index.tsx"),
route("util/time-calculator", "routes/util/time-calculator.tsx"),
] satisfies RouteConfig;

View File

@ -1,10 +1,30 @@
import { Layout } from "@/components/Layout";
import type { Route } from "./+types/home";
import { useState } from "react";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
export function meta({}: Route.MetaArgs) {
return [{ title: "中V档案馆" }];
}
export default function Home() {
return <Layout></Layout>;
const [input, setInput] = useState("");
return (
<Layout>
<h2 className="text-2xl mt-5 mb-2"></h2>
<div className="flex items-center gap-7">
<Button>
<a href="/util/time-calculator"></a>
</Button>
<div className="flex 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>
</Layout>
);
}

View File

@ -1,7 +1,7 @@
import type { Route } from "./+types/index";
import { treaty } from "@elysiajs/eden";
import type { App } from "@elysia/src";
import { useEffect, useState } from "react";
import { memo, useEffect, useState, useMemo } from "react";
import { Skeleton } from "@/components/ui/skeleton";
import { TriangleAlert } from "lucide-react";
import { Title } from "@/components/Title";
@ -11,9 +11,10 @@ import { Layout } from "@/components/Layout";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { formatDateTime } from "@/components/SearchResults";
import { ViewsChart } from "./views-chart";
import { processSnapshots } from "./lib";
import { processSnapshots, detectMilestoneAchievements, type MilestoneAchievement } from "./lib";
import { DataTable } from "./data-table";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { If, Then } from "react-if";
@ -35,14 +36,29 @@ import { columns, type Snapshot } from "./columns";
const app = treaty<App>(import.meta.env.VITE_API_URL!);
type SongInfo = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["data"];
type EtaInfo = Awaited<ReturnType<ReturnType<typeof app.video>["eta"]["get"]>>["data"];
export type Snapshots = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["data"];
type SongInfoError = Awaited<ReturnType<ReturnType<typeof app.song>["info"]["get"]>>["error"];
type SnapshotsError = Awaited<ReturnType<ReturnType<typeof app.video>["snapshots"]["get"]>>["error"];
type EtaInfoError = Awaited<ReturnType<ReturnType<typeof app.video>["eta"]["get"]>>["error"];
export async function clientLoader({ params }: Route.LoaderArgs) {
return { id: params.id };
}
function formatHours(hours: number): string {
if (hours >= 24 * 14) return `${Math.floor(hours / 24)}`;
if (hours >= 24) return `${Math.floor(hours / 24)}${hours % 24} 小时`;
if (hours >= 1) return `${Math.floor(hours)}${Math.round((hours % 1) * 60)}`;
return `${Math.round(hours * 60)} 分钟`;
}
function addHoursToNow(hours: number): string {
const d = new Date();
d.setHours(d.getHours() + hours);
return `${d.getFullYear()}-${(d.getMonth() + 1).toString().padStart(2, "0")}-${d.getDate().toString().padStart(2, "0")} ${d.getHours().toString().padStart(2, "0")}:${d.getMinutes().toString().padStart(2, "0")}`;
}
const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
if (!snapshots || snapshots.length === 0) {
return null;
@ -61,7 +77,58 @@ const StatsTable = ({ snapshots }: { snapshots: Snapshots | null }) => {
return <DataTable columns={columns} data={tableData} />;
};
const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => {
const getMileStoneName = (views: number) => {
if (views < 100000) return "殿堂";
if (views < 1000000) return "传说";
return "神话";
};
const SnapshotsView = ({
snapshots,
etaData,
publishedAt,
}: {
snapshots: Snapshots | null;
etaData: EtaInfo | null;
publishedAt?: string;
}) => {
const [timeRange, setTimeRange] = useState<string>("all");
const [currentPage, setCurrentPage] = useState(1);
const [pageSize] = useState(10);
const [timeOffsetHours, setTimeOffsetHours] = useState(0);
// Calculate time range in hours
const timeRangeHours = useMemo(() => {
switch (timeRange) {
case "6h":
return 6;
case "12h":
return 12;
case "24h":
return 24;
case "3d":
return 72;
case "7d":
return 168;
case "14d":
return 336;
case "30d":
return 720;
default:
return undefined; // "all"
}
}, [timeRange]);
// Pagination for table data
const paginatedSnapshots = useMemo(() => {
if (!snapshots) return [];
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
return snapshots.slice(startIndex, endIndex);
}, [snapshots, currentPage, pageSize]);
const totalPages = snapshots ? Math.ceil(snapshots.length / pageSize) : 0;
if (!snapshots) {
return <Skeleton className="w-full h-50 rounded-lg mt-4" />;
}
@ -74,49 +141,168 @@ const SnapshotsView = ({ snapshots }: { snapshots: Snapshots | null }) => {
);
}
const processedData = processSnapshots(snapshots);
const processedData = processSnapshots(snapshots, timeRangeHours, timeOffsetHours);
const milestoneAchievements = detectMilestoneAchievements(snapshots, publishedAt);
// Handle time range navigation
const totalDataHours =
snapshots && snapshots.length > 0
? (new Date(snapshots[snapshots.length - 1].createdAt).getTime() -
new Date(snapshots[0].createdAt).getTime()) /
(1000 * 60 * 60)
: 0;
// Simplified logic: always allow navigation if we have a time range
const canGoBack = timeRangeHours !== undefined && timeRangeHours > 0;
const canGoForward = timeRangeHours !== undefined && timeRangeHours > 0;
const handleBack = () => {
if (timeRangeHours && timeRangeHours > 0) {
setTimeOffsetHours((prev) => prev + timeRangeHours);
}
};
const handleForward = () => {
if (timeRangeHours && timeRangeHours > 0) {
setTimeOffsetHours((prev) => Math.max(0, prev - timeRangeHours));
}
};
return (
<div className="mt-4">
<p>
: {snapshots[0].views.toLocaleString()}
<span className="text-secondary-foreground"> {formatDateTime(new Date(snapshots[0].createdAt))}</span>
<span className="text-secondary-foreground">
{" "}
{formatDateTime(new Date(snapshots[0].createdAt))}
</span>
</p>
{etaData && (
<>
{etaData!.views <= 10000000 && (
<p>
{getMileStoneName(etaData!.views)}
<span className="text-secondary-foreground">
{" "}
{formatHours(etaData!.eta)} {addHoursToNow(etaData!.eta)}
</span>
</p>
)}
{milestoneAchievements.length > 0 && (
<div className="mt-2">
<p className="text-sm text-secondary-foreground"></p>
{milestoneAchievements.map((achievement) => (
<p key={achievement.milestone} className="text-sm text-secondary-foreground ml-2">
{achievement.milestoneName}{achievement.milestone.toLocaleString()} -{" "}
{formatDateTime(new Date(achievement.achievedAt))}
{achievement.timeTaken && ` - 用时 ${achievement.timeTaken}`}
</p>
))}
</div>
)}
</>
)}
<Tabs defaultValue="chart" className="mt-4">
<div className="flex justify-between mb-4">
<div className="flex justify-between items-center mb-4">
<h2 className="text-2xl font-medium"></h2>
<TabsList>
<TabsTrigger value="chart"></TabsTrigger>
<TabsTrigger value="table"></TabsTrigger>
</TabsList>
<div className="flex items-center gap-4">
<TabsList>
<TabsTrigger value="chart"></TabsTrigger>
<TabsTrigger value="table"></TabsTrigger>
</TabsList>
<Select value={timeRange} onValueChange={setTimeRange}>
<SelectTrigger className="w-32">
<SelectValue placeholder="时间范围" />
</SelectTrigger>
<SelectContent>
<SelectItem value="6h">6</SelectItem>
<SelectItem value="12h">12</SelectItem>
<SelectItem value="24h">24</SelectItem>
<SelectItem value="3d">3</SelectItem>
<SelectItem value="7d">7</SelectItem>
<SelectItem value="14d">14</SelectItem>
<SelectItem value="30d">30</SelectItem>
<SelectItem value="all"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<TabsContent value="chart">
<ViewsChart chartData={processedData} />
<div className="flex flex-col gap-2">
<div className="flex justify-between items-center">
<Button variant="outline" size="sm" onClick={handleBack} disabled={!canGoBack}>
</Button>
<span className="text-sm text-secondary-foreground">
{timeRangeHours ? `${timeRangeHours}小时范围` : "全部数据"}
</span>
<Button variant="outline" size="sm" onClick={handleForward} disabled={!canGoForward}>
</Button>
</div>
<ViewsChart chartData={processedData} />
</div>
</TabsContent>
<TabsContent value="table">
<StatsTable snapshots={snapshots} />
<StatsTable snapshots={paginatedSnapshots} />
{totalPages > 1 && (
<div className="flex justify-center items-center gap-2 mt-4">
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
>
</Button>
<span className="text-sm text-secondary-foreground">
{currentPage} {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => setCurrentPage((prev) => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
>
</Button>
</div>
)}
</TabsContent>
</Tabs>
</div>
);
};
const MemoizedSnapshotsView = memo(SnapshotsView);
export default function SongInfo({ loaderData }: Route.ComponentProps) {
const [songInfo, setData] = useState<SongInfo | null>(null);
const [snapshots, setSnapshots] = useState<Snapshots | null>(null);
const [error, setError] = useState<SongInfoError | SnapshotsError | null>(null);
const [etaData, setEtaData] = useState<EtaInfo | null>(null);
const [error, setError] = useState<SongInfoError | SnapshotsError | EtaInfoError | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [songName, setSongName] = useState("");
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const getEta = async (aid: number) => {
const { data, error } = await app.video({ id: `av${aid}` }).eta.get();
if (error) {
console.log(error);
return;
}
setEtaData(data);
};
const getSnapshots = async (aid: number) => {
const { data, error } = await app.video({ id: `av${aid}` }).snapshots.get();
if (error) {
console.log(error);
setError(error);
return;
}
setSnapshots(data);
@ -141,6 +327,7 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
const aid = songInfo.aid;
if (!aid) return;
getSnapshots(aid);
getEta(aid);
}, [songInfo]);
useEffect(() => {
@ -330,7 +517,11 @@ export default function SongInfo({ loaderData }: Route.ComponentProps) {
</AlertDialog>
</div>
</div>
<SnapshotsView snapshots={snapshots} />
<MemoizedSnapshotsView
snapshots={snapshots}
etaData={etaData}
publishedAt={songInfo!.publishedAt || undefined}
/>
</Layout>
);
}

View File

@ -1,23 +1,31 @@
import { HOUR, MINUTE } from "@core/lib";
import type { Snapshots } from "./index";
const getDataIntervalMins = (interval: number) => {
export interface MilestoneAchievement {
milestone: number;
milestoneName: string;
achievedAt: string;
views: number;
timeTaken?: string;
}
const getDataIntervalMins = (interval: number, timeRangeHours?: number) => {
if (!timeRangeHours ||timeRangeHours >= 7 * 24) {
return 24 * 60;
}
if (interval >= 6 * HOUR) {
return 6 * 60;
}
else if (interval >= 1 * HOUR) {
} else if (interval >= 1 * HOUR) {
return 60;
}
else if (interval >= 15 * MINUTE) {
} else if (interval >= 15 * MINUTE) {
return 15;
}
else if (interval >= 5 * MINUTE) {
} else if (interval >= 5 * MINUTE) {
return 5;
}
return 1;
}
};
export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: number = 14 * 24) => {
export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours?: number, timeOffsetHours: number = 0) => {
if (!snapshots || snapshots.length === 0) {
return [];
}
@ -27,17 +35,24 @@ export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: nu
);
const oldestDate = new Date(sortedSnapshots[0].createdAt);
const newestDate = new Date(sortedSnapshots[sortedSnapshots.length - 1].createdAt);
const targetStartTime = new Date(new Date().getTime() - timeRangeHours * HOUR);
// Calculate the time range with offset
const targetEndTime = timeRangeHours ? new Date(newestDate.getTime() - timeOffsetHours * HOUR) : newestDate;
const targetStartTime = timeRangeHours ? new Date(targetEndTime.getTime() - timeRangeHours * HOUR) : null;
const startTime = oldestDate > targetStartTime ? oldestDate : targetStartTime;
const startTime = targetStartTime ? (oldestDate > targetStartTime ? oldestDate : targetStartTime) : oldestDate;
const endTime = targetEndTime;
const hourlyTimePoints: Date[] = [];
const currentTime = new Date(sortedSnapshots[sortedSnapshots.length - 1].createdAt);
const currentTime = endTime;
const timeDiff = currentTime.getTime() - startTime.getTime();
const length = sortedSnapshots.filter((s) => new Date(s.createdAt) >= startTime).length;
const length = sortedSnapshots.filter((s) => {
const snapshotTime = new Date(s.createdAt).getTime();
return snapshotTime >= startTime.getTime() && snapshotTime <= endTime.getTime();
}).length;
const avgInterval = timeDiff / length;
const dataIntervalMins = getDataIntervalMins(avgInterval);
const dataIntervalMins = getDataIntervalMins(avgInterval, timeRangeHours);
for (let time = new Date(startTime); time <= currentTime; time.setMinutes(time.getMinutes() + dataIntervalMins)) {
hourlyTimePoints.push(new Date(time));
@ -45,9 +60,15 @@ export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: nu
const processedData = hourlyTimePoints
.map((timePoint) => {
const previousSnapshots = sortedSnapshots.filter((s) => new Date(s.createdAt) <= timePoint);
const nextSnapshots = sortedSnapshots.filter((s) => new Date(s.createdAt) >= timePoint);
const previousSnapshots = sortedSnapshots.filter((s) => {
const snapshotTime = new Date(s.createdAt).getTime();
return snapshotTime <= timePoint.getTime() && snapshotTime >= startTime.getTime();
});
const nextSnapshots = sortedSnapshots.filter((s) => {
const snapshotTime = new Date(s.createdAt).getTime();
return snapshotTime >= timePoint.getTime() && snapshotTime <= endTime.getTime();
});
const previousSnapshot = previousSnapshots[previousSnapshots.length - 1];
const nextSnapshot = nextSnapshots[0];
@ -117,4 +138,100 @@ export const processSnapshots = (snapshots: Snapshots | null, timeRangeHours: nu
.filter((d) => d !== null);
return processedData;
};
};
export const detectMilestoneAchievements = (snapshots: Snapshots | null, publishedAt?: string): MilestoneAchievement[] => {
if (!snapshots || snapshots.length < 2) {
return [];
}
const sortedSnapshots = [...snapshots].sort(
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(),
);
const milestones = [100000, 1000000, 10000000];
const milestoneNames = ["殿堂", "传说", "神话"];
const achievements: MilestoneAchievement[] = [];
// Find the earliest snapshot for each milestone
const earliestAchievements = new Map<number, MilestoneAchievement>();
for (let i = 1; i < sortedSnapshots.length; i++) {
const prevSnapshot = sortedSnapshots[i - 1];
const currentSnapshot = sortedSnapshots[i];
const prevTime = new Date(prevSnapshot.createdAt).getTime();
const currentTime = new Date(currentSnapshot.createdAt).getTime();
const timeDiff = currentTime - prevTime;
// Check if snapshots are within 10 minutes
if (timeDiff <= 10 * 60 * 1000) {
for (let j = 0; j < milestones.length; j++) {
const milestone = milestones[j];
const milestoneName = milestoneNames[j];
// Check if milestone was crossed between these two snapshots
if (prevSnapshot.views < milestone && currentSnapshot.views >= milestone) {
// Find the exact time when milestone was reached (linear interpolation)
const ratio = (milestone - prevSnapshot.views) / (currentSnapshot.views - prevSnapshot.views);
const milestoneTime = new Date(prevTime + ratio * timeDiff);
const achievement: MilestoneAchievement = {
milestone,
milestoneName,
achievedAt: milestoneTime.toISOString(),
views: milestone,
};
// Only keep the earliest achievement for each milestone
if (
!earliestAchievements.has(milestone) ||
new Date(achievement.achievedAt) < new Date(earliestAchievements.get(milestone)!.achievedAt)
) {
earliestAchievements.set(milestone, achievement);
}
}
// Check if a snapshot exactly equals a milestone
if (prevSnapshot.views === milestone || currentSnapshot.views === milestone) {
const exactSnapshot = prevSnapshot.views === milestone ? prevSnapshot : currentSnapshot;
const achievement: MilestoneAchievement = {
milestone,
milestoneName,
achievedAt: exactSnapshot.createdAt,
views: milestone,
};
if (
!earliestAchievements.has(milestone) ||
new Date(achievement.achievedAt) < new Date(earliestAchievements.get(milestone)!.achievedAt)
) {
earliestAchievements.set(milestone, achievement);
}
}
}
}
}
// Convert map to array and sort by milestone value
const achievementsWithTime = Array.from(earliestAchievements.values()).sort((a, b) => a.milestone - b.milestone);
// Calculate time taken for each achievement
if (publishedAt) {
const publishTime = new Date(publishedAt).getTime();
for (const achievement of achievementsWithTime) {
const achievementTime = new Date(achievement.achievedAt).getTime();
const timeDiffMs = achievementTime - publishTime;
// Convert to days, hours, minutes
const days = Math.floor(timeDiffMs / (1000 * 60 * 60 * 24));
const hours = Math.floor((timeDiffMs % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
const minutes = Math.floor((timeDiffMs % (1000 * 60 * 60)) / (1000 * 60));
achievement.timeTaken = `${days}${hours}${minutes}`;
}
}
return achievementsWithTime;
};

View File

@ -0,0 +1,31 @@
import { Layout } from "@/components/Layout";
import type { Route } from "./+types/time-calculator";
import { useEffect, useState } from "react";
import { Input } from "@/components/ui/input";
import { formatDateTime } from "@/components/SearchResults";
export default function Home() {
const now = new Date();
const [time1Input, setTime1Input] = useState(formatDateTime(now));
const [time2Input, setTime2Input] = useState(formatDateTime(now));
const time1 = new Date(time1Input);
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 minutes = Math.floor((difference / (1000 * 60)) % 60);
const diffString = `${days || 0}${hours || 0}${minutes || 0}`;
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>
<p className="mt-3">{diffString}</p>
</Layout>
);
}

View File

@ -16,6 +16,7 @@
"@nivo/line": "^0.99.0",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-tabs": "^1.1.13",
"@react-router/node": "^7.7.1",