feat: add prettier and eslint with pre-commit hook
This commit is contained in:
11
.prettierrc
Normal file
11
.prettierrc
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"tabWidth": 2,
|
||||||
|
"trailingComma": "es5",
|
||||||
|
"printWidth": 100,
|
||||||
|
"bracketSpacing": true,
|
||||||
|
"bracketSameLine": false,
|
||||||
|
"arrowParens": "avoid",
|
||||||
|
"endOfLine": "lf"
|
||||||
|
}
|
||||||
184
bun.lock
184
bun.lock
@@ -10,13 +10,43 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.2.4",
|
"@tailwindcss/cli": "^4.2.4",
|
||||||
"@toolwind/anchors": "^1.0.10",
|
"@toolwind/anchors": "^1.0.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||||
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
"packages": {
|
"packages": {
|
||||||
|
"@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.9.1", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ=="],
|
||||||
|
|
||||||
|
"@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.2", "", {}, "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew=="],
|
||||||
|
|
||||||
|
"@eslint/config-array": ["@eslint/config-array@0.23.5", "", { "dependencies": { "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" } }, "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA=="],
|
||||||
|
|
||||||
|
"@eslint/config-helpers": ["@eslint/config-helpers@0.5.5", "", { "dependencies": { "@eslint/core": "^1.2.1" } }, "sha512-eIJYKTCECbP/nsKaaruF6LW967mtbQbsw4JTtSVkUQc9MneSkbrgPJAbKl9nWr0ZeowV8BfsarBmPpBzGelA2w=="],
|
||||||
|
|
||||||
|
"@eslint/core": ["@eslint/core@1.2.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ=="],
|
||||||
|
|
||||||
|
"@eslint/object-schema": ["@eslint/object-schema@3.0.5", "", {}, "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw=="],
|
||||||
|
|
||||||
|
"@eslint/plugin-kit": ["@eslint/plugin-kit@0.7.1", "", { "dependencies": { "@eslint/core": "^1.2.1", "levn": "^0.4.1" } }, "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ=="],
|
||||||
|
|
||||||
|
"@humanfs/core": ["@humanfs/core@0.19.2", "", { "dependencies": { "@humanfs/types": "^0.15.0" } }, "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA=="],
|
||||||
|
|
||||||
|
"@humanfs/node": ["@humanfs/node@0.16.8", "", { "dependencies": { "@humanfs/core": "^0.19.2", "@humanfs/types": "^0.15.0", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ=="],
|
||||||
|
|
||||||
|
"@humanfs/types": ["@humanfs/types@0.15.0", "", {}, "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="],
|
||||||
|
|
||||||
|
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
|
||||||
|
|
||||||
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
|
||||||
|
|
||||||
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
"@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="],
|
||||||
@@ -55,6 +85,8 @@
|
|||||||
|
|
||||||
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
"@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="],
|
||||||
|
|
||||||
|
"@pkgr/core": ["@pkgr/core@0.2.9", "", {}, "sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA=="],
|
||||||
|
|
||||||
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
|
"@tailwindcss/cli": ["@tailwindcss/cli@4.2.4", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.4", "@tailwindcss/oxide": "4.2.4", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.4" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-e87GGhuXxnyQPyA0TS8an/3wNpj+OUmx8u0F4BicYr48TF72032AIu5917rRYaWm7HorXi3GSZ/uG+ohqP6AKA=="],
|
||||||
|
|
||||||
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
"@tailwindcss/node": ["@tailwindcss/node@4.2.4", "", { "dependencies": { "@jridgewell/remapping": "^2.3.5", "enhanced-resolve": "^5.19.0", "jiti": "^2.6.1", "lightningcss": "1.32.0", "magic-string": "^0.30.21", "source-map-js": "^1.2.1", "tailwindcss": "4.2.4" } }, "sha512-Ai7+yQPxz3ddrDQzFfBKdHEVBg0w3Zl83jnjuwxnZOsnH9pGn93QHQtpU0p/8rYWxvbFZHneni6p1BSLK4DkGA=="],
|
||||||
@@ -87,22 +119,120 @@
|
|||||||
|
|
||||||
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
|
"@toolwind/anchors": ["@toolwind/anchors@1.0.10", "", { "peerDependencies": { "tailwindcss": ">=3.0.0 || >=4.0.0" } }, "sha512-F3J/lxGGPUy+GIpT49NmYMF1X7l0d7UzdDASni29il2ro5sT4cYfPBFHBAfOM0lpgKOr/HnqINlomngt8BcvnA=="],
|
||||||
|
|
||||||
|
"@types/esrecurse": ["@types/esrecurse@4.3.1", "", {}, "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw=="],
|
||||||
|
|
||||||
|
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
|
||||||
|
|
||||||
|
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
|
||||||
|
|
||||||
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
"@types/trusted-types": ["@types/trusted-types@2.0.7", "", {}, "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.59.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/type-utils": "8.59.2", "@typescript-eslint/utils": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.59.2", "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.59.2", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.59.2", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.59.2", "@typescript-eslint/types": "^8.59.2", "debug": "^4.4.3" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2" } }, "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.59.2", "", { "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2", "@typescript-eslint/utils": "8.59.2", "debug": "^4.4.3", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/types": ["@typescript-eslint/types@8.59.2", "", {}, "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.59.2", "", { "dependencies": { "@typescript-eslint/project-service": "8.59.2", "@typescript-eslint/tsconfig-utils": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/visitor-keys": "8.59.2", "debug": "^4.4.3", "minimatch": "^10.2.2", "semver": "^7.7.3", "tinyglobby": "^0.2.15", "ts-api-utils": "^2.5.0" }, "peerDependencies": { "typescript": ">=4.8.4 <6.1.0" } }, "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/utils": ["@typescript-eslint/utils@8.59.2", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.9.1", "@typescript-eslint/scope-manager": "8.59.2", "@typescript-eslint/types": "8.59.2", "@typescript-eslint/typescript-estree": "8.59.2" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", "typescript": ">=4.8.4 <6.1.0" } }, "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q=="],
|
||||||
|
|
||||||
|
"@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.59.2", "", { "dependencies": { "@typescript-eslint/types": "8.59.2", "eslint-visitor-keys": "^5.0.0" } }, "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA=="],
|
||||||
|
|
||||||
|
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||||
|
|
||||||
|
"acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="],
|
||||||
|
|
||||||
|
"ajv": ["ajv@6.15.0", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw=="],
|
||||||
|
|
||||||
|
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
|
||||||
|
|
||||||
|
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
|
||||||
|
|
||||||
|
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||||
|
|
||||||
|
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
|
||||||
|
|
||||||
|
"deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="],
|
||||||
|
|
||||||
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
"detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
|
||||||
|
|
||||||
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
"dompurify": ["dompurify@3.4.1", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw=="],
|
||||||
|
|
||||||
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
"enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="],
|
||||||
|
|
||||||
|
"escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="],
|
||||||
|
|
||||||
|
"eslint": ["eslint@10.3.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", "@eslint/config-array": "^0.23.5", "@eslint/config-helpers": "^0.5.5", "@eslint/core": "^1.2.1", "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", "ajv": "^6.14.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "minimatch": "^10.2.4", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-XbEXaRva5cF0ZQB8w6MluHA0kZZfV2DuCMJ3ozyEOHLwDpZX2Lmm/7Pp0xdJmI0GL1W05VH5VwIFHEm1Vcw2gw=="],
|
||||||
|
|
||||||
|
"eslint-config-prettier": ["eslint-config-prettier@10.1.8", "", { "peerDependencies": { "eslint": ">=7.0.0" }, "bin": { "eslint-config-prettier": "bin/cli.js" } }, "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w=="],
|
||||||
|
|
||||||
|
"eslint-plugin-prettier": ["eslint-plugin-prettier@5.5.5", "", { "dependencies": { "prettier-linter-helpers": "^1.0.1", "synckit": "^0.11.12" }, "peerDependencies": { "@types/eslint": ">=8.0.0", "eslint": ">=8.0.0", "eslint-config-prettier": ">= 7.0.0 <10.0.0 || >=10.1.0", "prettier": ">=3.0.0" }, "optionalPeers": ["@types/eslint", "eslint-config-prettier"] }, "sha512-hscXkbqUZ2sPithAuLm5MXL+Wph+U7wHngPBv9OMWwlP8iaflyxpjTYZkmdgB4/vPIhemRlBEoLrH7UC1n7aUw=="],
|
||||||
|
|
||||||
|
"eslint-scope": ["eslint-scope@9.1.2", "", { "dependencies": { "@types/esrecurse": "^4.3.1", "@types/estree": "^1.0.8", "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ=="],
|
||||||
|
|
||||||
|
"eslint-visitor-keys": ["eslint-visitor-keys@5.0.1", "", {}, "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA=="],
|
||||||
|
|
||||||
|
"espree": ["espree@11.2.0", "", { "dependencies": { "acorn": "^8.16.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^5.0.1" } }, "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw=="],
|
||||||
|
|
||||||
|
"esquery": ["esquery@1.7.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g=="],
|
||||||
|
|
||||||
|
"esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="],
|
||||||
|
|
||||||
|
"estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="],
|
||||||
|
|
||||||
|
"esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="],
|
||||||
|
|
||||||
|
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
|
||||||
|
|
||||||
|
"fast-diff": ["fast-diff@1.3.0", "", {}, "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw=="],
|
||||||
|
|
||||||
|
"fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="],
|
||||||
|
|
||||||
|
"fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="],
|
||||||
|
|
||||||
|
"fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="],
|
||||||
|
|
||||||
|
"file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="],
|
||||||
|
|
||||||
|
"find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="],
|
||||||
|
|
||||||
|
"flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="],
|
||||||
|
|
||||||
|
"flatted": ["flatted@3.4.2", "", {}, "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA=="],
|
||||||
|
|
||||||
|
"glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="],
|
||||||
|
|
||||||
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
||||||
|
|
||||||
|
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
|
||||||
|
|
||||||
|
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
|
||||||
|
|
||||||
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
"is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="],
|
||||||
|
|
||||||
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
"is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="],
|
||||||
|
|
||||||
|
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
|
||||||
|
|
||||||
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
"jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="],
|
||||||
|
|
||||||
|
"json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="],
|
||||||
|
|
||||||
|
"json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="],
|
||||||
|
|
||||||
|
"json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="],
|
||||||
|
|
||||||
|
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
|
||||||
|
|
||||||
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
|
"lefthook": ["lefthook@2.1.6", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.6", "lefthook-darwin-x64": "2.1.6", "lefthook-freebsd-arm64": "2.1.6", "lefthook-freebsd-x64": "2.1.6", "lefthook-linux-arm64": "2.1.6", "lefthook-linux-x64": "2.1.6", "lefthook-openbsd-arm64": "2.1.6", "lefthook-openbsd-x64": "2.1.6", "lefthook-windows-arm64": "2.1.6", "lefthook-windows-x64": "2.1.6" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-w9sBoR0mdN+kJc3SB85VzpiAAl451/rxdCRcZlwW71QLjkeH3EBQFgc4VMj5apePychYDHAlqEWTB8J8JK/j1Q=="],
|
||||||
|
|
||||||
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
"lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-hyB7eeiX78BS66f70byTJacDLC/xV1vgMv9n+idFUsrM7J3Udd/ag9Ag5NP3t0eN0EqQqAtrNnt35EH01lxnRQ=="],
|
||||||
@@ -125,6 +255,8 @@
|
|||||||
|
|
||||||
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
"lefthook-windows-x64": ["lefthook-windows-x64@2.1.6", "", { "os": "win32", "cpu": "x64" }, "sha512-q4z2n3xucLscoWiyMwFViEj3N8MDSkPulMwcJYuCYFHoPhP1h+icqNu7QRLGYj6AnVrCQweiUJY3Tb2X+GbD/A=="],
|
||||||
|
|
||||||
|
"levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="],
|
||||||
|
|
||||||
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
"lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="],
|
||||||
|
|
||||||
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
"lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="],
|
||||||
@@ -149,24 +281,74 @@
|
|||||||
|
|
||||||
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
"lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="],
|
||||||
|
|
||||||
|
"locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|
||||||
|
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
|
||||||
|
|
||||||
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
"mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="],
|
||||||
|
|
||||||
|
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
|
||||||
|
|
||||||
|
"natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="],
|
||||||
|
|
||||||
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
"node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="],
|
||||||
|
|
||||||
|
"optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="],
|
||||||
|
|
||||||
|
"p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
|
||||||
|
|
||||||
|
"p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="],
|
||||||
|
|
||||||
|
"path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="],
|
||||||
|
|
||||||
|
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
|
||||||
|
|
||||||
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
|
||||||
|
|
||||||
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
"picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="],
|
||||||
|
|
||||||
|
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
|
||||||
|
|
||||||
|
"prettier": ["prettier@3.8.3", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw=="],
|
||||||
|
|
||||||
|
"prettier-linter-helpers": ["prettier-linter-helpers@1.0.1", "", { "dependencies": { "fast-diff": "^1.1.2" } }, "sha512-SxToR7P8Y2lWmv/kTzVLC1t/GDI2WGjMwNhLLE9qtH8Q13C+aEmuRlzDst4Up4s0Wc8sF2M+J57iB3cMLqftfg=="],
|
||||||
|
|
||||||
|
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
|
||||||
|
|
||||||
|
"semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
|
||||||
|
|
||||||
|
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
|
||||||
|
|
||||||
|
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
|
||||||
|
|
||||||
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
|
||||||
|
|
||||||
|
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
|
||||||
|
|
||||||
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
|
||||||
|
|
||||||
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
"tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="],
|
||||||
|
|
||||||
|
"tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="],
|
||||||
|
|
||||||
|
"ts-api-utils": ["ts-api-utils@2.5.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA=="],
|
||||||
|
|
||||||
|
"type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="],
|
||||||
|
|
||||||
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
"typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
|
||||||
|
|
||||||
|
"uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="],
|
||||||
|
|
||||||
|
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
||||||
|
|
||||||
|
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
||||||
|
|
||||||
|
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
||||||
|
|
||||||
|
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
"@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="],
|
||||||
@@ -178,5 +360,7 @@
|
|||||||
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
"@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="],
|
||||||
|
|
||||||
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
"@tailwindcss/oxide-wasm32-wasi/tslib": ["tslib@2.8.1", "", { "bundled": true }, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
|
||||||
|
|
||||||
|
"eslint/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
26
eslint.config.js
Normal file
26
eslint.config.js
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import tseslint from "@typescript-eslint/eslint-plugin";
|
||||||
|
import tsParser from "@typescript-eslint/parser";
|
||||||
|
import prettier from "eslint-plugin-prettier";
|
||||||
|
import eslintConfigPrettier from "eslint-config-prettier";
|
||||||
|
|
||||||
|
export default [
|
||||||
|
{
|
||||||
|
ignores: ["dist/**", "node_modules/**", "server", "*.js"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ["**/*.ts"],
|
||||||
|
plugins: {
|
||||||
|
"@typescript-eslint": tseslint,
|
||||||
|
prettier,
|
||||||
|
},
|
||||||
|
languageOptions: {
|
||||||
|
parser: tsParser,
|
||||||
|
},
|
||||||
|
rules: {
|
||||||
|
...eslintConfigPrettier.rules,
|
||||||
|
"@typescript-eslint/no-explicit-any": "off",
|
||||||
|
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_", varsIgnorePattern: "^_", caughtErrorsIgnorePattern: "^_" }],
|
||||||
|
"prettier/prettier": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
32
lefthook.yml
32
lefthook.yml
@@ -1,13 +1,23 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://json.schemastore.org/lefthook.json",
|
'$schema': 'https://json.schemastore.org/lefthook.json',
|
||||||
"pre-push": {
|
'pre-commit':
|
||||||
"commands": {
|
{
|
||||||
"go-fmt": { "run": "go fmt ./..." },
|
'commands':
|
||||||
"go-vet": { "run": "go vet ./..." },
|
{
|
||||||
"go-test": { "run": "go test ./..." },
|
'prettier': { 'run': 'bunx prettier . --write' },
|
||||||
"ts-typecheck": { "run": "bunx tsc -p tsconfig.json --noEmit" },
|
'eslint': { 'run': 'bunx eslint . --fix' },
|
||||||
"build-assets": { "run": "bun run build:assets" },
|
},
|
||||||
"go-build": { "run": "go build -o server ./cmd/server" }
|
},
|
||||||
}
|
'pre-push':
|
||||||
}
|
{
|
||||||
|
'commands':
|
||||||
|
{
|
||||||
|
'go-fmt': { 'run': 'go fmt ./...' },
|
||||||
|
'go-vet': { 'run': 'go vet ./...' },
|
||||||
|
'go-test': { 'run': 'go test ./...' },
|
||||||
|
'ts-typecheck': { 'run': 'bunx tsc -p tsconfig.json --noEmit' },
|
||||||
|
'build-assets': { 'run': 'bun run build:assets' },
|
||||||
|
'go-build': { 'run': 'go build -o server ./cmd/server' },
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,17 +1,26 @@
|
|||||||
{
|
{
|
||||||
"name": "myanimelist-ui",
|
"name": "myanimelist-ui",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./dist/tailwind.css",
|
"build:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./dist/tailwind.css",
|
||||||
"watch:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./dist/tailwind.css --watch",
|
"watch:css": "bunx @tailwindcss/cli -i ./static/style.css -o ./dist/tailwind.css --watch",
|
||||||
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser",
|
"build:ts": "bun build ./static/player/main.ts --outdir ./dist/static/player --target browser --splitting && bun build ./static/*.ts --outdir ./dist/static --target browser",
|
||||||
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
|
"typecheck": "bunx tsc -p tsconfig.json --noEmit",
|
||||||
"build:assets": "bun run build:css && bun run build:ts"
|
"build:assets": "bun run build:css && bun run build:ts",
|
||||||
|
"format": "bunx prettier . --write",
|
||||||
|
"lint": "bunx eslint . --fix"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@tailwindcss/cli": "^4.2.4",
|
"@tailwindcss/cli": "^4.2.4",
|
||||||
"@toolwind/anchors": "^1.0.10",
|
"@toolwind/anchors": "^1.0.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.59.2",
|
||||||
|
"@typescript-eslint/parser": "^8.59.2",
|
||||||
|
"eslint": "^10.3.0",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-prettier": "^5.5.5",
|
||||||
"lefthook": "^2.1.6",
|
"lefthook": "^2.1.6",
|
||||||
|
"prettier": "^3.8.3",
|
||||||
"tailwindcss": "^4.2.4",
|
"tailwindcss": "^4.2.4",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,60 +1,60 @@
|
|||||||
import { parseClassList } from './utils'
|
import { parseClassList } from './utils';
|
||||||
|
|
||||||
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
|
const setDropdownMenuState = (menu: HTMLElement, isOpen: boolean): void => {
|
||||||
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'))
|
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));
|
||||||
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'))
|
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'));
|
||||||
|
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
menu.classList.remove(...closedClasses)
|
menu.classList.remove(...closedClasses);
|
||||||
menu.classList.add(...openClasses)
|
menu.classList.add(...openClasses);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
menu.classList.remove(...openClasses)
|
menu.classList.remove(...openClasses);
|
||||||
menu.classList.add(...closedClasses)
|
menu.classList.add(...closedClasses);
|
||||||
}
|
};
|
||||||
|
|
||||||
const setWatchlistDropdownState = (isOpen: boolean): void => {
|
const setWatchlistDropdownState = (isOpen: boolean): void => {
|
||||||
const dropdown = document.getElementById('watchlist-dropdown')
|
const dropdown = document.getElementById('watchlist-dropdown');
|
||||||
if (!dropdown) {
|
if (!dropdown) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
dropdown.classList.toggle('open', isOpen)
|
dropdown.classList.toggle('open', isOpen);
|
||||||
const menu = dropdown.querySelector('[data-dropdown-menu]')
|
const menu = dropdown.querySelector('[data-dropdown-menu]');
|
||||||
if (menu instanceof HTMLElement) {
|
if (menu instanceof HTMLElement) {
|
||||||
setDropdownMenuState(menu, isOpen)
|
setDropdownMenuState(menu, isOpen);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const toggleWatchlistDropdown = (): void => {
|
const toggleWatchlistDropdown = (): void => {
|
||||||
const dropdown = document.getElementById('watchlist-dropdown')
|
const dropdown = document.getElementById('watchlist-dropdown');
|
||||||
if (!dropdown) {
|
if (!dropdown) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setWatchlistDropdownState(!dropdown.classList.contains('open'))
|
setWatchlistDropdownState(!dropdown.classList.contains('open'));
|
||||||
}
|
};
|
||||||
|
|
||||||
const closeDropdownOnOutsideClick = (event: MouseEvent): void => {
|
const closeDropdownOnOutsideClick = (event: MouseEvent): void => {
|
||||||
const dropdown = document.getElementById('watchlist-dropdown')
|
const dropdown = document.getElementById('watchlist-dropdown');
|
||||||
if (!dropdown) {
|
if (!dropdown) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target
|
const target = event.target;
|
||||||
if (!(target instanceof Node)) {
|
if (!(target instanceof Node)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!dropdown.contains(target)) {
|
if (!dropdown.contains(target)) {
|
||||||
setWatchlistDropdownState(false)
|
setWatchlistDropdownState(false);
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const initWatchlistDropdown = (): void => {
|
const initWatchlistDropdown = (): void => {
|
||||||
;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown
|
(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown;
|
||||||
document.addEventListener('click', closeDropdownOnOutsideClick)
|
document.addEventListener('click', closeDropdownOnOutsideClick);
|
||||||
}
|
};
|
||||||
|
|
||||||
initWatchlistDropdown()
|
initWatchlistDropdown();
|
||||||
|
|||||||
@@ -1,25 +1,25 @@
|
|||||||
const dedupe = (): void => {
|
const dedupe = (): void => {
|
||||||
const seen = new Set<string>()
|
const seen = new Set<string>();
|
||||||
const elements = document.querySelectorAll('[data-id]')
|
const elements = document.querySelectorAll('[data-id]');
|
||||||
|
|
||||||
elements.forEach((item) => {
|
elements.forEach(item => {
|
||||||
const id = item.getAttribute('data-id')
|
const id = item.getAttribute('data-id');
|
||||||
if (!id) {
|
if (!id) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
if (seen.has(id)) {
|
if (seen.has(id)) {
|
||||||
item.remove()
|
item.remove();
|
||||||
} else {
|
} else {
|
||||||
seen.add(id)
|
seen.add(id);
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', dedupe)
|
document.addEventListener('DOMContentLoaded', dedupe);
|
||||||
} else {
|
} else {
|
||||||
dedupe()
|
dedupe();
|
||||||
}
|
}
|
||||||
|
|
||||||
window.addEventListener('load', dedupe)
|
window.addEventListener('load', dedupe);
|
||||||
window.addEventListener('load', dedupe)
|
window.addEventListener('load', dedupe);
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
import { parseClassList } from './utils'
|
import { parseClassList } from './utils';
|
||||||
|
|
||||||
const setActiveDiscoverTab = (clickedTab: Element): void => {
|
const setActiveDiscoverTab = (clickedTab: Element): void => {
|
||||||
const group = clickedTab.closest('[data-tab-group="discover"]')
|
const group = clickedTab.closest('[data-tab-group="discover"]');
|
||||||
if (!group) {
|
if (!group) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const triggers = group.querySelectorAll('[data-tab-trigger]')
|
const triggers = group.querySelectorAll('[data-tab-trigger]');
|
||||||
triggers.forEach((tab) => {
|
triggers.forEach(tab => {
|
||||||
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'))
|
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'));
|
||||||
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
|
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'));
|
||||||
tab.classList.remove(...activeClasses)
|
tab.classList.remove(...activeClasses);
|
||||||
tab.classList.add(...inactiveClasses)
|
tab.classList.add(...inactiveClasses);
|
||||||
})
|
});
|
||||||
|
|
||||||
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'))
|
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'));
|
||||||
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'))
|
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'));
|
||||||
clickedTab.classList.remove(...inactiveClasses)
|
clickedTab.classList.remove(...inactiveClasses);
|
||||||
clickedTab.classList.add(...activeClasses)
|
clickedTab.classList.add(...activeClasses);
|
||||||
}
|
};
|
||||||
|
|
||||||
const onDiscoverTabClick = (event: MouseEvent): void => {
|
const onDiscoverTabClick = (event: MouseEvent): void => {
|
||||||
const target = event.target
|
const target = event.target;
|
||||||
if (!(target instanceof Element)) {
|
if (!(target instanceof Element)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trigger = target.closest('[data-tab-trigger]')
|
const trigger = target.closest('[data-tab-trigger]');
|
||||||
if (!trigger) {
|
if (!trigger) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setActiveDiscoverTab(trigger)
|
setActiveDiscoverTab(trigger);
|
||||||
}
|
};
|
||||||
|
|
||||||
const initDiscoverTabs = (): void => {
|
const initDiscoverTabs = (): void => {
|
||||||
document.addEventListener('click', onDiscoverTabClick)
|
document.addEventListener('click', onDiscoverTabClick);
|
||||||
}
|
};
|
||||||
|
|
||||||
initDiscoverTabs()
|
initDiscoverTabs();
|
||||||
|
|||||||
@@ -1,66 +1,66 @@
|
|||||||
class UIDropdown extends HTMLElement {
|
class UIDropdown extends HTMLElement {
|
||||||
isOpen: boolean = false
|
isOpen: boolean = false;
|
||||||
contentEl: HTMLElement | null = null
|
contentEl: HTMLElement | null = null;
|
||||||
isClosing: boolean = false
|
isClosing: boolean = false;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super();
|
||||||
this.toggle = this.toggle.bind(this)
|
this.toggle = this.toggle.bind(this);
|
||||||
this.handleClickOutside = this.handleClickOutside.bind(this)
|
this.handleClickOutside = this.handleClickOutside.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
connectedCallback(): void {
|
connectedCallback(): void {
|
||||||
const trigger = this.querySelector('[data-trigger]')
|
const trigger = this.querySelector('[data-trigger]');
|
||||||
this.contentEl = this.querySelector('[data-content]')
|
this.contentEl = this.querySelector('[data-content]');
|
||||||
|
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger.addEventListener('click', this.toggle)
|
trigger.addEventListener('click', this.toggle);
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('click', this.handleClickOutside)
|
document.addEventListener('click', this.handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
disconnectedCallback(): void {
|
disconnectedCallback(): void {
|
||||||
const trigger = this.querySelector('[data-trigger]')
|
const trigger = this.querySelector('[data-trigger]');
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger.removeEventListener('click', this.toggle)
|
trigger.removeEventListener('click', this.toggle);
|
||||||
}
|
}
|
||||||
document.removeEventListener('click', this.handleClickOutside)
|
document.removeEventListener('click', this.handleClickOutside);
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(): void {
|
toggle(): void {
|
||||||
if (this.isClosing) {
|
if (this.isClosing) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
this.isOpen = !this.isOpen
|
this.isOpen = !this.isOpen;
|
||||||
if (this.contentEl) {
|
if (this.contentEl) {
|
||||||
if (this.isOpen) {
|
if (this.isOpen) {
|
||||||
this.contentEl.classList.remove('hidden')
|
this.contentEl.classList.remove('hidden');
|
||||||
} else {
|
} else {
|
||||||
this.contentEl.classList.add('hidden')
|
this.contentEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
if (this.isClosing) {
|
if (this.isClosing) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
this.isClosing = true
|
this.isClosing = true;
|
||||||
this.isOpen = false
|
this.isOpen = false;
|
||||||
if (this.contentEl) {
|
if (this.contentEl) {
|
||||||
this.contentEl.classList.add('hidden')
|
this.contentEl.classList.add('hidden');
|
||||||
}
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.isClosing = false
|
this.isClosing = false;
|
||||||
}, 100)
|
}, 100);
|
||||||
}
|
}
|
||||||
|
|
||||||
handleClickOutside(event: MouseEvent): void {
|
handleClickOutside(event: MouseEvent): void {
|
||||||
if (!this.contains(event.target as Node)) {
|
if (!this.contains(event.target as Node)) {
|
||||||
this.close()
|
this.close();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
customElements.define('ui-dropdown', UIDropdown)
|
customElements.define('ui-dropdown', UIDropdown);
|
||||||
|
|||||||
@@ -1,95 +1,98 @@
|
|||||||
import { state } from './state'
|
import { state } from './state';
|
||||||
|
|
||||||
export const formatTime = (seconds: number): string => {
|
export const formatTime = (seconds: number): string => {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
|
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const showControls = (): void => {
|
export const showControls = (): void => {
|
||||||
state.container.classList.add('show-controls')
|
state.container.classList.add('show-controls');
|
||||||
window.clearTimeout(state.playerControlsTimeout)
|
window.clearTimeout(state.playerControlsTimeout);
|
||||||
state.playerControlsTimeout = window.setTimeout(() => {
|
state.playerControlsTimeout = window.setTimeout(() => {
|
||||||
if (!state.isScrubbing && !state.video.paused) {
|
if (!state.isScrubbing && !state.video.paused) {
|
||||||
state.container.classList.remove('show-controls')
|
state.container.classList.remove('show-controls');
|
||||||
}
|
}
|
||||||
}, 2000)
|
}, 2000);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const seekBy = (delta: number): void => {
|
export const seekBy = (delta: number): void => {
|
||||||
if (state.video.duration <= 0) return
|
if (state.video.duration <= 0) return;
|
||||||
state.video.currentTime = Math.max(0, Math.min(state.video.duration, state.video.currentTime + delta))
|
state.video.currentTime = Math.max(
|
||||||
showControls()
|
0,
|
||||||
}
|
Math.min(state.video.duration, state.video.currentTime + delta)
|
||||||
|
);
|
||||||
|
showControls();
|
||||||
|
};
|
||||||
|
|
||||||
export const togglePlayPause = (): void => {
|
export const togglePlayPause = (): void => {
|
||||||
if (state.video.paused) {
|
if (state.video.paused) {
|
||||||
state.video.play()
|
state.video.play();
|
||||||
} else {
|
} else {
|
||||||
state.video.pause()
|
state.video.pause();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const toggleMute = (): void => {
|
export const toggleMute = (): void => {
|
||||||
if (state.video.muted || state.video.volume === 0) {
|
if (state.video.muted || state.video.volume === 0) {
|
||||||
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1
|
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1;
|
||||||
state.video.muted = false
|
state.video.muted = false;
|
||||||
state.video.volume = restored
|
state.video.volume = restored;
|
||||||
} else {
|
} else {
|
||||||
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume
|
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume;
|
||||||
state.video.muted = true
|
state.video.muted = true;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setVolume = (value: number): void => {
|
export const setVolume = (value: number): void => {
|
||||||
state.video.volume = Math.max(0, Math.min(1, value))
|
state.video.volume = Math.max(0, Math.min(1, value));
|
||||||
state.video.muted = value === 0
|
state.video.muted = value === 0;
|
||||||
if (value > 0) state.lastKnownVolume = value
|
if (value > 0) state.lastKnownVolume = value;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const toggleFullscreen = (): void => {
|
export const toggleFullscreen = (): void => {
|
||||||
if (document.fullscreenElement) {
|
if (document.fullscreenElement) {
|
||||||
document.exitFullscreen()
|
document.exitFullscreen();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
state.container.requestFullscreen?.()
|
state.container.requestFullscreen?.();
|
||||||
}
|
};
|
||||||
|
|
||||||
export const syncVolumeUI = (): void => {
|
export const syncVolumeUI = (): void => {
|
||||||
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls()
|
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls();
|
||||||
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100)
|
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100);
|
||||||
if (volumeRange) {
|
if (volumeRange) {
|
||||||
volumeRange.value = String(value)
|
volumeRange.value = String(value);
|
||||||
volumeRange.style.setProperty('--volume-percent', `${value}%`)
|
volumeRange.style.setProperty('--volume-percent', `${value}%`);
|
||||||
}
|
}
|
||||||
if (volumeUnderline) volumeUnderline.style.height = `${value}%`
|
if (volumeUnderline) volumeUnderline.style.height = `${value}%`;
|
||||||
updateMuteIcons(state.video.muted || state.video.volume === 0)
|
updateMuteIcons(state.video.muted || state.video.volume === 0);
|
||||||
}
|
};
|
||||||
|
|
||||||
interface Controls {
|
interface Controls {
|
||||||
playPause: HTMLButtonElement | null
|
playPause: HTMLButtonElement | null;
|
||||||
muteBtn: HTMLButtonElement | null
|
muteBtn: HTMLButtonElement | null;
|
||||||
volumePanel: HTMLElement | null
|
volumePanel: HTMLElement | null;
|
||||||
volumeRange: HTMLInputElement | null
|
volumeRange: HTMLInputElement | null;
|
||||||
volumeUnderline: HTMLElement | null
|
volumeUnderline: HTMLElement | null;
|
||||||
backwardBtn: HTMLButtonElement | null
|
backwardBtn: HTMLButtonElement | null;
|
||||||
forwardBtn: HTMLButtonElement | null
|
forwardBtn: HTMLButtonElement | null;
|
||||||
fullscreenBtn: HTMLButtonElement | null
|
fullscreenBtn: HTMLButtonElement | null;
|
||||||
iconPlay: SVGElement | null
|
iconPlay: SVGElement | null;
|
||||||
iconPause: SVGElement | null
|
iconPause: SVGElement | null;
|
||||||
iconVolume: SVGElement | null
|
iconVolume: SVGElement | null;
|
||||||
iconMuted: SVGElement | null
|
iconMuted: SVGElement | null;
|
||||||
skipSegmentBtn: HTMLButtonElement | null
|
skipSegmentBtn: HTMLButtonElement | null;
|
||||||
subtitleText: HTMLElement | null
|
subtitleText: HTMLElement | null;
|
||||||
autoplayBtn: HTMLInputElement | null
|
autoplayBtn: HTMLInputElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let controlsCache: Controls | null = null
|
let controlsCache: Controls | null = null;
|
||||||
|
|
||||||
const getControls = (): Controls => {
|
const getControls = (): Controls => {
|
||||||
if (controlsCache) return controlsCache
|
if (controlsCache) return controlsCache;
|
||||||
const c = state.container
|
const c = state.container;
|
||||||
controlsCache = {
|
controlsCache = {
|
||||||
playPause: c.querySelector('[data-play-pause]'),
|
playPause: c.querySelector('[data-play-pause]'),
|
||||||
muteBtn: c.querySelector('[data-mute]'),
|
muteBtn: c.querySelector('[data-mute]'),
|
||||||
@@ -106,64 +109,88 @@ const getControls = (): Controls => {
|
|||||||
skipSegmentBtn: c.querySelector('[data-skip]'),
|
skipSegmentBtn: c.querySelector('[data-skip]'),
|
||||||
subtitleText: c.querySelector('[data-subtitle-text]'),
|
subtitleText: c.querySelector('[data-subtitle-text]'),
|
||||||
autoplayBtn: document.querySelector('[data-autoplay]'),
|
autoplayBtn: document.querySelector('[data-autoplay]'),
|
||||||
}
|
};
|
||||||
return controlsCache
|
return controlsCache;
|
||||||
}
|
};
|
||||||
|
|
||||||
const updatePlayPauseIcons = (isPlaying: boolean): void => {
|
const updatePlayPauseIcons = (isPlaying: boolean): void => {
|
||||||
const { iconPlay, iconPause } = getControls()
|
const { iconPlay, iconPause } = getControls();
|
||||||
iconPlay?.classList.toggle('hidden', isPlaying)
|
iconPlay?.classList.toggle('hidden', isPlaying);
|
||||||
iconPause?.classList.toggle('hidden', !isPlaying)
|
iconPause?.classList.toggle('hidden', !isPlaying);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateMuteIcons = (isMuted: boolean): void => {
|
const updateMuteIcons = (isMuted: boolean): void => {
|
||||||
const { iconVolume, iconMuted } = getControls()
|
const { iconVolume, iconMuted } = getControls();
|
||||||
iconVolume?.classList.toggle('hidden', isMuted)
|
iconVolume?.classList.toggle('hidden', isMuted);
|
||||||
iconMuted?.classList.toggle('hidden', !isMuted)
|
iconMuted?.classList.toggle('hidden', !isMuted);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setupControls = (): void => {
|
export const setupControls = (): void => {
|
||||||
const {
|
const {
|
||||||
playPause, muteBtn, volumePanel, volumeRange,
|
playPause,
|
||||||
backwardBtn, forwardBtn, fullscreenBtn, skipSegmentBtn,
|
muteBtn,
|
||||||
} = getControls()
|
volumePanel,
|
||||||
|
volumeRange,
|
||||||
|
backwardBtn,
|
||||||
|
forwardBtn,
|
||||||
|
fullscreenBtn,
|
||||||
|
skipSegmentBtn,
|
||||||
|
} = getControls();
|
||||||
|
|
||||||
playPause?.addEventListener('click', () => { togglePlayPause(); showControls() })
|
playPause?.addEventListener('click', () => {
|
||||||
state.video.addEventListener('click', () => { togglePlayPause(); showControls() })
|
togglePlayPause();
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
state.video.addEventListener('click', () => {
|
||||||
|
togglePlayPause();
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
|
||||||
muteBtn?.addEventListener('click', () => { toggleMute(); showControls() })
|
muteBtn?.addEventListener('click', () => {
|
||||||
|
toggleMute();
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
|
||||||
volumeRange?.addEventListener('input', () => {
|
volumeRange?.addEventListener('input', () => {
|
||||||
const value = Number(volumeRange.value) / 100
|
const value = Number(volumeRange.value) / 100;
|
||||||
setVolume(value)
|
setVolume(value);
|
||||||
showControls()
|
showControls();
|
||||||
})
|
});
|
||||||
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'))
|
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'));
|
||||||
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'))
|
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'));
|
||||||
|
|
||||||
backwardBtn?.addEventListener('click', () => seekBy(-10))
|
backwardBtn?.addEventListener('click', () => seekBy(-10));
|
||||||
forwardBtn?.addEventListener('click', () => seekBy(10))
|
forwardBtn?.addEventListener('click', () => seekBy(10));
|
||||||
|
|
||||||
fullscreenBtn?.addEventListener('click', () => { toggleFullscreen(); showControls() })
|
fullscreenBtn?.addEventListener('click', () => {
|
||||||
|
toggleFullscreen();
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
|
||||||
skipSegmentBtn?.addEventListener('click', () => {
|
skipSegmentBtn?.addEventListener('click', () => {
|
||||||
if (!state.activeSkipSegment) return
|
if (!state.activeSkipSegment) return;
|
||||||
state.video.currentTime = state.activeSkipSegment.end + 0.01
|
state.video.currentTime = state.activeSkipSegment.end + 0.01;
|
||||||
showControls()
|
showControls();
|
||||||
})
|
});
|
||||||
|
|
||||||
document.addEventListener('fullscreenchange', () => {
|
document.addEventListener('fullscreenchange', () => {
|
||||||
state.isFullscreen = !!document.fullscreenElement
|
state.isFullscreen = !!document.fullscreenElement;
|
||||||
state.container.classList.toggle('fullscreen', state.isFullscreen)
|
state.container.classList.toggle('fullscreen', state.isFullscreen);
|
||||||
if (state.isFullscreen) showControls()
|
if (state.isFullscreen) showControls();
|
||||||
})
|
});
|
||||||
|
|
||||||
state.video.addEventListener('play', () => { updatePlayPauseIcons(true); showControls() })
|
state.video.addEventListener('play', () => {
|
||||||
state.video.addEventListener('pause', () => { updatePlayPauseIcons(false); showControls() })
|
updatePlayPauseIcons(true);
|
||||||
state.video.addEventListener('volumechange', syncVolumeUI)
|
showControls();
|
||||||
|
});
|
||||||
|
state.video.addEventListener('pause', () => {
|
||||||
|
updatePlayPauseIcons(false);
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
state.video.addEventListener('volumechange', syncVolumeUI);
|
||||||
|
|
||||||
state.container.addEventListener('mousemove', showControls)
|
state.container.addEventListener('mousemove', showControls);
|
||||||
|
|
||||||
updatePlayPauseIcons(false)
|
updatePlayPauseIcons(false);
|
||||||
syncVolumeUI()
|
syncVolumeUI();
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import DOMPurify from 'dompurify'
|
import DOMPurify from 'dompurify';
|
||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
|
|
||||||
export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
||||||
if (state.completionSent || !state.malID || !episodeNumber) return
|
if (state.completionSent || !state.malID || !episodeNumber) return;
|
||||||
state.completionSent = true
|
state.completionSent = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/watch-complete', {
|
const res = await fetch('/api/watch-complete', {
|
||||||
@@ -11,27 +11,27 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
keepalive: true,
|
keepalive: true,
|
||||||
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
|
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
state.completionSent = false
|
state.completionSent = false;
|
||||||
if (state.completionAttempts < 2) {
|
if (state.completionAttempts < 2) {
|
||||||
state.completionAttempts++
|
state.completionAttempts++;
|
||||||
setTimeout(() => completeAnime(episodeNumber), 1000)
|
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null
|
const trigger = document.querySelector('[data-dropdown-trigger]') as HTMLButtonElement | null;
|
||||||
if (trigger) {
|
if (trigger) {
|
||||||
trigger.textContent = 'Completed '
|
trigger.textContent = 'Completed ';
|
||||||
const caret = document.createElement('span')
|
const caret = document.createElement('span');
|
||||||
caret.className = 'text-xs'
|
caret.className = 'text-xs';
|
||||||
caret.textContent = '▾'
|
caret.textContent = '▾';
|
||||||
trigger.appendChild(caret)
|
trigger.appendChild(caret);
|
||||||
}
|
}
|
||||||
|
|
||||||
const dropdown = document.getElementById('watch-status-dropdown')
|
const dropdown = document.getElementById('watch-status-dropdown');
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
const payload = {
|
const payload = {
|
||||||
anime_id: String(state.malID),
|
anime_id: String(state.malID),
|
||||||
@@ -41,27 +41,29 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
|
|||||||
anime_image: state.container.dataset.animeImage ?? '',
|
anime_image: state.container.dataset.animeImage ?? '',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
airing: state.container.dataset.animeAiring === 'true',
|
airing: state.container.dataset.animeAiring === 'true',
|
||||||
}
|
};
|
||||||
|
|
||||||
fetch('/api/watchlist', {
|
fetch('/api/watchlist', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded', 'HX-Request': 'true' },
|
||||||
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
|
body: `anime_id=${encodeURIComponent(payload.anime_id)}&anime_title=${encodeURIComponent(payload.anime_title)}&anime_title_english=${encodeURIComponent(payload.anime_title_english)}&anime_title_japanese=${encodeURIComponent(payload.anime_title_japanese)}&anime_image=${encodeURIComponent(payload.anime_image)}&status=${encodeURIComponent(payload.status)}&airing=${encodeURIComponent(String(payload.airing))}`,
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
}).then(async res => {
|
})
|
||||||
if (!res.ok) return
|
.then(async res => {
|
||||||
const html = await res.text()
|
if (!res.ok) return;
|
||||||
const wrapper = document.createElement('span')
|
const html = await res.text();
|
||||||
wrapper.id = 'watch-status-dropdown'
|
const wrapper = document.createElement('span');
|
||||||
wrapper.innerHTML = DOMPurify.sanitize(html)
|
wrapper.id = 'watch-status-dropdown';
|
||||||
dropdown.replaceWith(wrapper)
|
wrapper.innerHTML = DOMPurify.sanitize(html);
|
||||||
}).catch(() => {})
|
dropdown.replaceWith(wrapper);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
state.completionSent = false
|
state.completionSent = false;
|
||||||
if (state.completionAttempts < 2) {
|
if (state.completionAttempts < 2) {
|
||||||
state.completionAttempts++
|
state.completionAttempts++;
|
||||||
setTimeout(() => completeAnime(episodeNumber), 1000)
|
setTimeout(() => completeAnime(episodeNumber), 1000);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,95 +1,97 @@
|
|||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
import { SkipSegment } from '../types'
|
import { SkipSegment } from '../types';
|
||||||
import { displayTimeFromAbsolute } from '../timeline'
|
import { displayTimeFromAbsolute } from '../timeline';
|
||||||
import { resolveActiveSegments, renderSegments } from '../skip/segments'
|
import { resolveActiveSegments, renderSegments } from '../skip/segments';
|
||||||
import { updateSubtitleOptions } from '../subtitles'
|
import { updateSubtitleOptions } from '../subtitles';
|
||||||
import { updateQualityOptions } from '../quality'
|
import { updateQualityOptions } from '../quality';
|
||||||
import { updateModeButtons } from '../mode'
|
import { updateModeButtons } from '../mode';
|
||||||
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui'
|
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui';
|
||||||
import { markEpisodeTransition } from '../progress'
|
import { markEpisodeTransition } from '../progress';
|
||||||
|
|
||||||
export const goToNextEpisode = async (): Promise<void> => {
|
export const goToNextEpisode = async (): Promise<void> => {
|
||||||
const currentEp = Number.parseInt(state.currentEpisode, 10)
|
const currentEp = Number.parseInt(state.currentEpisode, 10);
|
||||||
if (!currentEp) return
|
if (!currentEp) return;
|
||||||
|
|
||||||
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
|
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
|
||||||
import('./complete').then(m => m.completeAnime(currentEp))
|
import('./complete').then(m => m.completeAnime(currentEp));
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isAutoplayEnabled()) return
|
if (!isAutoplayEnabled()) return;
|
||||||
|
|
||||||
const nextEp = currentEp + 1
|
const nextEp = currentEp + 1;
|
||||||
markEpisodeTransition(nextEp)
|
markEpisodeTransition(nextEp);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`)
|
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`);
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('ep', String(nextEp))
|
url.searchParams.set('ep', String(nextEp));
|
||||||
window.location.href = url.toString()
|
window.location.href = url.toString();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json()
|
const data = await res.json();
|
||||||
|
|
||||||
state.modeSources = data.mode_sources ?? {}
|
state.modeSources = data.mode_sources ?? {};
|
||||||
state.availableModes = data.available_modes ?? []
|
state.availableModes = data.available_modes ?? [];
|
||||||
|
|
||||||
const fallback = state.availableModes.find(m => state.modeSources[m]?.token)
|
const fallback = state.availableModes.find(m => state.modeSources[m]?.token);
|
||||||
if (!fallback) {
|
if (!fallback) {
|
||||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('ep', String(nextEp))
|
url.searchParams.set('ep', String(nextEp));
|
||||||
window.location.href = url.toString()
|
window.location.href = url.toString();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`
|
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`;
|
||||||
state.video.load()
|
state.video.load();
|
||||||
if (!state.video.paused) state.video.play().catch(() => {})
|
if (!state.video.paused) state.video.play().catch(() => {});
|
||||||
|
|
||||||
state.currentEpisode = String(nextEp)
|
state.currentEpisode = String(nextEp);
|
||||||
state.pendingSeekTime = null
|
state.pendingSeekTime = null;
|
||||||
state.completionSent = false
|
state.completionSent = false;
|
||||||
state.completionAttempts = 0
|
state.completionAttempts = 0;
|
||||||
state.activeSubtitles = []
|
state.activeSubtitles = [];
|
||||||
|
|
||||||
updateSubtitleOptions()
|
updateSubtitleOptions();
|
||||||
updateQualityOptions()
|
updateQualityOptions();
|
||||||
updateModeButtons()
|
updateModeButtons();
|
||||||
updateOverlay(state.currentEpisode, data.episode_title ?? '')
|
updateOverlay(state.currentEpisode, data.episode_title ?? '');
|
||||||
|
|
||||||
if (data.segments?.length) {
|
if (data.segments?.length) {
|
||||||
state.parsedSegments = data.segments
|
state.parsedSegments = data.segments
|
||||||
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
||||||
.filter((s: SkipSegment) => s.end > s.start)
|
.filter((s: SkipSegment) => s.end > s.start);
|
||||||
resolveActiveSegments()
|
resolveActiveSegments();
|
||||||
renderSegments()
|
renderSegments();
|
||||||
}
|
}
|
||||||
|
|
||||||
state.episodeList?.querySelectorAll('[data-episode-id]').forEach(el => el.classList.remove('bg-accent/20'))
|
state.episodeList
|
||||||
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`)
|
?.querySelectorAll('[data-episode-id]')
|
||||||
newListEl?.classList.add('bg-accent/20')
|
.forEach(el => el.classList.remove('bg-accent/20'));
|
||||||
|
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`);
|
||||||
|
newListEl?.classList.add('bg-accent/20');
|
||||||
|
|
||||||
if (state.episodeGrid) {
|
if (state.episodeGrid) {
|
||||||
state.episodeGrid.querySelectorAll('[data-episode-id]').forEach(el => {
|
state.episodeGrid.querySelectorAll('[data-episode-id]').forEach(el => {
|
||||||
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
|
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
|
||||||
})
|
});
|
||||||
switchEpisodeRange(Math.floor((nextEp - 1) / 100))
|
switchEpisodeRange(Math.floor((nextEp - 1) / 100));
|
||||||
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`)
|
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
|
||||||
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
|
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
|
||||||
}
|
}
|
||||||
|
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('ep', String(nextEp))
|
url.searchParams.set('ep', String(nextEp));
|
||||||
history.pushState(null, '', url.toString())
|
history.pushState(null, '', url.toString());
|
||||||
state.transitionEpisode = null
|
state.transitionEpisode = null;
|
||||||
} catch {
|
} catch {
|
||||||
sessionStorage.setItem('mal:autoplay-next', 'true')
|
sessionStorage.setItem('mal:autoplay-next', 'true');
|
||||||
const url = new URL(window.location.href)
|
const url = new URL(window.location.href);
|
||||||
url.searchParams.set('ep', String(nextEp))
|
url.searchParams.set('ep', String(nextEp));
|
||||||
window.location.href = url.toString()
|
window.location.href = url.toString();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,35 +1,38 @@
|
|||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
|
|
||||||
export const setupThumbnails = (): void => {
|
export const setupThumbnails = (): void => {
|
||||||
fetch(`/api/watch/thumbnails/${state.malID}`)
|
fetch(`/api/watch/thumbnails/${state.malID}`)
|
||||||
.then(res => res.json())
|
.then(res => res.json())
|
||||||
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
|
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
|
||||||
if (!state.episodeList) return
|
if (!state.episodeList) return;
|
||||||
data.forEach(item => {
|
data.forEach(item => {
|
||||||
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`)
|
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
|
||||||
if (!card) return
|
if (!card) return;
|
||||||
|
|
||||||
if (item.url) {
|
if (item.url) {
|
||||||
const imgContainer = card.querySelector('.relative.aspect-video')
|
const imgContainer = card.querySelector('.relative.aspect-video');
|
||||||
if (imgContainer) {
|
if (imgContainer) {
|
||||||
let img = imgContainer.querySelector('img')
|
let img = imgContainer.querySelector('img');
|
||||||
if (!img) {
|
if (!img) {
|
||||||
img = document.createElement('img')
|
img = document.createElement('img');
|
||||||
img.className = 'h-full w-full object-cover transition-transform group-hover:scale-105'
|
img.className =
|
||||||
img.loading = 'lazy'
|
'h-full w-full object-cover transition-transform group-hover:scale-105';
|
||||||
imgContainer.querySelector('.flex.h-full.w-full.items-center.justify-center')?.remove()
|
img.loading = 'lazy';
|
||||||
imgContainer.prepend(img)
|
imgContainer
|
||||||
|
.querySelector('.flex.h-full.w-full.items-center.justify-center')
|
||||||
|
?.remove();
|
||||||
|
imgContainer.prepend(img);
|
||||||
}
|
}
|
||||||
img.src = item.url
|
img.src = item.url;
|
||||||
img.alt = item.title ?? `Episode ${item.mal_id}`
|
img.alt = item.title ?? `Episode ${item.mal_id}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.title) {
|
if (item.title) {
|
||||||
const titleEl = card.querySelector('[data-episode-title]')
|
const titleEl = card.querySelector('[data-episode-title]');
|
||||||
if (titleEl) titleEl.textContent = item.title
|
if (titleEl) titleEl.textContent = item.title;
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
})
|
})
|
||||||
.catch(err => console.error('Failed to fetch thumbnails:', err))
|
.catch(err => console.error('Failed to fetch thumbnails:', err));
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,57 +1,61 @@
|
|||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
import { updateSubtitleOptions } from '../subtitles'
|
import { updateSubtitleOptions } from '../subtitles';
|
||||||
import { updateQualityOptions } from '../quality'
|
import { updateQualityOptions } from '../quality';
|
||||||
import { updateModeButtons } from '../mode'
|
import { updateModeButtons } from '../mode';
|
||||||
|
|
||||||
export const setupAutoplayButton = (): void => {
|
export const setupAutoplayButton = (): void => {
|
||||||
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
|
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
||||||
if (!btn) return
|
if (!btn) return;
|
||||||
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'
|
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false';
|
||||||
}
|
};
|
||||||
|
|
||||||
export const isAutoplayEnabled = (): boolean => localStorage.getItem('mal:autoplay-enabled') !== 'false'
|
export const isAutoplayEnabled = (): boolean =>
|
||||||
|
localStorage.getItem('mal:autoplay-enabled') !== 'false';
|
||||||
|
|
||||||
export const updateOverlay = (episode: string, title: string): void => {
|
export const updateOverlay = (episode: string, title: string): void => {
|
||||||
if (!state.videoOverlay) return
|
if (!state.videoOverlay) return;
|
||||||
const p = state.videoOverlay.querySelector('p')
|
const p = state.videoOverlay.querySelector('p');
|
||||||
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`)
|
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`);
|
||||||
}
|
};
|
||||||
|
|
||||||
const getEpisodeEls = () => {
|
const getEpisodeEls = () => {
|
||||||
const grid = state.episodeGrid
|
const grid = state.episodeGrid;
|
||||||
const list = state.episodeList
|
const list = state.episodeList;
|
||||||
return {
|
return {
|
||||||
gridEls: grid ? Array.from(grid.querySelectorAll('[data-episode-id]')) : [],
|
gridEls: grid ? Array.from(grid.querySelectorAll('[data-episode-id]')) : [],
|
||||||
listEls: list ? Array.from(list.querySelectorAll('[data-episode-id]')) : [],
|
listEls: list ? Array.from(list.querySelectorAll('[data-episode-id]')) : [],
|
||||||
}
|
};
|
||||||
}
|
};
|
||||||
|
|
||||||
export const updateEpisodeHighlight = (num: number): void => {
|
export const updateEpisodeHighlight = (num: number): void => {
|
||||||
const { gridEls, listEls } = getEpisodeEls()
|
const { gridEls, listEls } = getEpisodeEls();
|
||||||
;[...gridEls, ...listEls].forEach(el => el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent'))
|
[...gridEls, ...listEls].forEach(el =>
|
||||||
|
el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent')
|
||||||
|
);
|
||||||
|
|
||||||
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`)
|
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
|
||||||
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`)
|
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`);
|
||||||
gridEl?.classList.add('ring-2', 'ring-accent')
|
gridEl?.classList.add('ring-2', 'ring-accent');
|
||||||
listEl?.classList.add('ring-2', 'ring-accent')
|
listEl?.classList.add('ring-2', 'ring-accent');
|
||||||
;(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||||
}
|
};
|
||||||
|
|
||||||
export const switchEpisodeRange = (idx: number): void => {
|
export const switchEpisodeRange = (idx: number): void => {
|
||||||
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null
|
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
||||||
if (!dropdown) return
|
if (!dropdown) return;
|
||||||
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[]
|
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
|
||||||
const target = btns[idx]
|
const target = btns[idx];
|
||||||
if (!target) return
|
if (!target) return;
|
||||||
|
|
||||||
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10)
|
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10);
|
||||||
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10)
|
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10);
|
||||||
|
|
||||||
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null
|
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null;
|
||||||
if (label) label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`
|
if (label)
|
||||||
|
label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`;
|
||||||
|
|
||||||
state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => {
|
state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => {
|
||||||
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10)
|
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10);
|
||||||
el.classList.toggle('hidden', n < start || n > end)
|
el.classList.toggle('hidden', n < start || n > end);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,58 +1,72 @@
|
|||||||
import { state } from './state'
|
import { state } from './state';
|
||||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay, absoluteTimeFromRatio, getBounds } from './timeline'
|
import {
|
||||||
import { showControls, toggleMute, togglePlayPause, toggleFullscreen, seekBy, setVolume, formatTime } from './controls'
|
displayTimeFromAbsolute,
|
||||||
|
absoluteTimeFromDisplay,
|
||||||
|
absoluteTimeFromRatio,
|
||||||
|
getBounds,
|
||||||
|
} from './timeline';
|
||||||
|
import {
|
||||||
|
showControls,
|
||||||
|
toggleMute,
|
||||||
|
togglePlayPause,
|
||||||
|
toggleFullscreen,
|
||||||
|
seekBy,
|
||||||
|
setVolume,
|
||||||
|
formatTime,
|
||||||
|
} from './controls';
|
||||||
|
|
||||||
export const setupKeyboard = (): void => {
|
export const setupKeyboard = (): void => {
|
||||||
document.addEventListener('keydown', (e) => {
|
document.addEventListener('keydown', e => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
|
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
|
||||||
|
return;
|
||||||
|
|
||||||
switch (e.code) {
|
switch (e.code) {
|
||||||
case 'Space':
|
case 'Space':
|
||||||
case 'KeyK':
|
case 'KeyK':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
togglePlayPause()
|
togglePlayPause();
|
||||||
showControls()
|
showControls();
|
||||||
break
|
break;
|
||||||
case 'ArrowLeft':
|
case 'ArrowLeft':
|
||||||
case 'KeyJ':
|
case 'KeyJ':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
seekBy(-10)
|
seekBy(-10);
|
||||||
break
|
break;
|
||||||
case 'ArrowRight':
|
case 'ArrowRight':
|
||||||
case 'KeyL':
|
case 'KeyL':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
seekBy(10)
|
seekBy(10);
|
||||||
break
|
break;
|
||||||
case 'ArrowUp':
|
case 'ArrowUp':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setVolume(state.video.volume + 0.05)
|
setVolume(state.video.volume + 0.05);
|
||||||
showControls()
|
showControls();
|
||||||
break
|
break;
|
||||||
case 'ArrowDown':
|
case 'ArrowDown':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
setVolume(state.video.volume - 0.05)
|
setVolume(state.video.volume - 0.05);
|
||||||
showControls()
|
showControls();
|
||||||
break
|
break;
|
||||||
case 'KeyM':
|
case 'KeyM':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
toggleMute()
|
toggleMute();
|
||||||
showControls()
|
showControls();
|
||||||
break
|
break;
|
||||||
case 'KeyF':
|
case 'KeyF':
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
toggleFullscreen()
|
toggleFullscreen();
|
||||||
showControls()
|
showControls();
|
||||||
break
|
break;
|
||||||
default:
|
default:
|
||||||
if (/^\d$/.test(e.key)) {
|
if (/^\d$/.test(e.key)) {
|
||||||
const b = getBounds()
|
const b = getBounds();
|
||||||
if (b.duration > 0) {
|
if (b.duration > 0) {
|
||||||
e.preventDefault()
|
e.preventDefault();
|
||||||
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10)
|
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
|
||||||
showControls()
|
showControls();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,193 +1,214 @@
|
|||||||
import { state, initState } from './state'
|
import { state, initState } from './state';
|
||||||
import { invalidateBounds, updateTimeline } from './timeline'
|
import { invalidateBounds, updateTimeline } from './timeline';
|
||||||
import { setupControls, showControls } from './controls'
|
import { setupControls, showControls } from './controls';
|
||||||
import { setupKeyboard } from './keyboard'
|
import { setupKeyboard } from './keyboard';
|
||||||
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles'
|
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles';
|
||||||
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip'
|
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip';
|
||||||
import { setupQuality, updateQualityOptions } from './quality'
|
import { setupQuality, updateQualityOptions } from './quality';
|
||||||
import { setupMode, updateModeButtons } from './mode'
|
import { setupMode, updateModeButtons } from './mode';
|
||||||
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui'
|
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui';
|
||||||
import { goToNextEpisode } from './episodes/nav'
|
import { goToNextEpisode } from './episodes/nav';
|
||||||
import { resolveActiveSegments, renderSegments } from './skip/segments'
|
import { resolveActiveSegments, renderSegments } from './skip/segments';
|
||||||
import { setupThumbnails } from './episodes/thumbnails'
|
import { setupThumbnails } from './episodes/thumbnails';
|
||||||
import { markEpisodeTransition, setupProgress } from './progress'
|
import { markEpisodeTransition, setupProgress } from './progress';
|
||||||
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline'
|
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline';
|
||||||
import { formatTime } from './controls'
|
import { formatTime } from './controls';
|
||||||
|
|
||||||
let initialized = false
|
let initialized = false;
|
||||||
|
|
||||||
const hidePreviewPopover = (): void => {
|
const hidePreviewPopover = (): void => {
|
||||||
state.previewPopover?.classList.remove('block')
|
state.previewPopover?.classList.remove('block');
|
||||||
state.previewPopover?.classList.add('hidden')
|
state.previewPopover?.classList.add('hidden');
|
||||||
state.previewPopover!.style.left = '0px'
|
state.previewPopover!.style.left = '0px';
|
||||||
}
|
};
|
||||||
|
|
||||||
const showPreviewPopover = (): void => {
|
const showPreviewPopover = (): void => {
|
||||||
state.previewPopover?.classList.remove('hidden')
|
state.previewPopover?.classList.remove('hidden');
|
||||||
state.previewPopover?.classList.add('block')
|
state.previewPopover?.classList.add('block');
|
||||||
}
|
};
|
||||||
|
|
||||||
const updatePreviewUI = (ratio: number): void => {
|
const updatePreviewUI = (ratio: number): void => {
|
||||||
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null
|
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||||
if (!progressWrap || !state.previewPopover || !state.previewTime) { hidePreviewPopover(); return }
|
if (!progressWrap || !state.previewPopover || !state.previewTime) {
|
||||||
const b = getBounds()
|
hidePreviewPopover();
|
||||||
if (b.duration <= 0) { hidePreviewPopover(); return }
|
return;
|
||||||
|
}
|
||||||
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)))
|
const b = getBounds();
|
||||||
|
if (b.duration <= 0) {
|
||||||
const barWidth = progressWrap.clientWidth
|
hidePreviewPopover();
|
||||||
if (barWidth <= 0) { hidePreviewPopover(); return }
|
return;
|
||||||
|
|
||||||
showPreviewPopover()
|
|
||||||
const popoverWidth = state.previewPopover.offsetWidth || 72
|
|
||||||
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`
|
|
||||||
}
|
|
||||||
|
|
||||||
const initPlayer = (): void => {
|
|
||||||
const container = document.querySelector('[data-video-player]') as HTMLElement | null
|
|
||||||
if (!container || initialized) return
|
|
||||||
initialized = true
|
|
||||||
|
|
||||||
initState(container)
|
|
||||||
|
|
||||||
const loading = container.querySelector('[data-loading]') as HTMLElement | null
|
|
||||||
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null
|
|
||||||
|
|
||||||
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'
|
|
||||||
const streamToken = state.modeSources[state.currentMode]?.token
|
|
||||||
if (streamToken) {
|
|
||||||
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setupProgress()
|
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)));
|
||||||
setupControls()
|
|
||||||
setupKeyboard()
|
|
||||||
setupSkip()
|
|
||||||
setupSubtitles()
|
|
||||||
setupQuality()
|
|
||||||
setupMode()
|
|
||||||
|
|
||||||
updateSubtitleOptions()
|
const barWidth = progressWrap.clientWidth;
|
||||||
updateQualityOptions()
|
if (barWidth <= 0) {
|
||||||
updateModeButtons()
|
hidePreviewPopover();
|
||||||
setupAutoplayButton()
|
return;
|
||||||
updateAutoSkipButton()
|
}
|
||||||
showControls()
|
|
||||||
|
showPreviewPopover();
|
||||||
|
const popoverWidth = state.previewPopover.offsetWidth || 72;
|
||||||
|
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const initPlayer = (): void => {
|
||||||
|
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
|
||||||
|
if (!container || initialized) return;
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
|
initState(container);
|
||||||
|
|
||||||
|
const loading = container.querySelector('[data-loading]') as HTMLElement | null;
|
||||||
|
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null;
|
||||||
|
|
||||||
|
const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best';
|
||||||
|
const streamToken = state.modeSources[state.currentMode]?.token;
|
||||||
|
if (streamToken) {
|
||||||
|
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
setupProgress();
|
||||||
|
setupControls();
|
||||||
|
setupKeyboard();
|
||||||
|
setupSkip();
|
||||||
|
setupSubtitles();
|
||||||
|
setupQuality();
|
||||||
|
setupMode();
|
||||||
|
|
||||||
|
updateSubtitleOptions();
|
||||||
|
updateQualityOptions();
|
||||||
|
updateModeButtons();
|
||||||
|
setupAutoplayButton();
|
||||||
|
updateAutoSkipButton();
|
||||||
|
showControls();
|
||||||
|
|
||||||
state.video.addEventListener('loadedmetadata', () => {
|
state.video.addEventListener('loadedmetadata', () => {
|
||||||
loading && (loading.style.display = 'none')
|
loading && (loading.style.display = 'none');
|
||||||
invalidateBounds()
|
invalidateBounds();
|
||||||
|
|
||||||
resolveActiveSegments()
|
resolveActiveSegments();
|
||||||
renderSegments()
|
renderSegments();
|
||||||
|
|
||||||
const startTime = Number(container.dataset.startTimeSeconds ?? '0')
|
const startTime = Number(container.dataset.startTimeSeconds ?? '0');
|
||||||
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
|
if (startTime > 0 && state.video.currentTime <= 0.5 && state.video.duration > startTime) {
|
||||||
state.video.currentTime = startTime
|
state.video.currentTime = startTime;
|
||||||
}
|
}
|
||||||
if (state.pendingSeekTime !== null) {
|
if (state.pendingSeekTime !== null) {
|
||||||
state.video.currentTime = state.pendingSeekTime
|
state.video.currentTime = state.pendingSeekTime;
|
||||||
state.pendingSeekTime = null
|
state.pendingSeekTime = null;
|
||||||
}
|
}
|
||||||
if (state.shouldAutoPlay) state.video.play().catch(() => {})
|
if (state.shouldAutoPlay) state.video.play().catch(() => {});
|
||||||
|
|
||||||
updateTimeline(state.video.currentTime)
|
updateTimeline(state.video.currentTime);
|
||||||
updateSkipButton(state.video.currentTime)
|
updateSkipButton(state.video.currentTime);
|
||||||
})
|
});
|
||||||
|
|
||||||
state.video.addEventListener('waiting', () => { loading && (loading.style.display = 'flex') })
|
state.video.addEventListener('waiting', () => {
|
||||||
state.video.addEventListener('playing', () => { loading && (loading.style.display = 'none') })
|
loading && (loading.style.display = 'flex');
|
||||||
state.video.addEventListener('progress', () => { updateTimeline(state.video.currentTime) })
|
});
|
||||||
|
state.video.addEventListener('playing', () => {
|
||||||
|
loading && (loading.style.display = 'none');
|
||||||
|
});
|
||||||
|
state.video.addEventListener('progress', () => {
|
||||||
|
updateTimeline(state.video.currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
state.video.addEventListener('timeupdate', () => {
|
state.video.addEventListener('timeupdate', () => {
|
||||||
updateTimeline(state.video.currentTime)
|
updateTimeline(state.video.currentTime);
|
||||||
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime))
|
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
|
||||||
updateSkipButton(state.video.currentTime)
|
updateSkipButton(state.video.currentTime);
|
||||||
})
|
});
|
||||||
|
|
||||||
state.video.addEventListener('ended', () => { goToNextEpisode() })
|
state.video.addEventListener('ended', () => {
|
||||||
|
goToNextEpisode();
|
||||||
|
});
|
||||||
|
|
||||||
progressWrap?.addEventListener('mousedown', (e) => {
|
progressWrap?.addEventListener('mousedown', e => {
|
||||||
state.isScrubbing = true
|
state.isScrubbing = true;
|
||||||
const rect = progressWrap.getBoundingClientRect()
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
state.video.currentTime = absoluteTimeFromRatio(
|
||||||
updateTimeline(state.video.currentTime)
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
updateSkipButton(state.video.currentTime)
|
);
|
||||||
showControls()
|
updateTimeline(state.video.currentTime);
|
||||||
})
|
updateSkipButton(state.video.currentTime);
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
|
||||||
progressWrap?.addEventListener('mousemove', (e) => {
|
progressWrap?.addEventListener('mousemove', e => {
|
||||||
const rect = progressWrap.getBoundingClientRect()
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)));
|
||||||
})
|
});
|
||||||
|
|
||||||
progressWrap?.addEventListener('mouseleave', hidePreviewPopover)
|
progressWrap?.addEventListener('mouseleave', hidePreviewPopover);
|
||||||
|
|
||||||
window.addEventListener('mousemove', (e) => {
|
window.addEventListener('mousemove', e => {
|
||||||
if (!state.isScrubbing || !progressWrap) return
|
if (!state.isScrubbing || !progressWrap) return;
|
||||||
const rect = progressWrap.getBoundingClientRect()
|
const rect = progressWrap.getBoundingClientRect();
|
||||||
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
|
state.video.currentTime = absoluteTimeFromRatio(
|
||||||
updateTimeline(state.video.currentTime)
|
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||||
updateSkipButton(state.video.currentTime)
|
);
|
||||||
})
|
updateTimeline(state.video.currentTime);
|
||||||
|
updateSkipButton(state.video.currentTime);
|
||||||
|
});
|
||||||
|
|
||||||
container.addEventListener('click', (e) => {
|
container.addEventListener('click', e => {
|
||||||
const anchor = (e.target as Node).parentElement?.closest('a[href]')
|
const anchor = (e.target as Node).parentElement?.closest('a[href]');
|
||||||
if (!(anchor instanceof HTMLAnchorElement)) return
|
if (!(anchor instanceof HTMLAnchorElement)) return;
|
||||||
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean)
|
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean);
|
||||||
if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) {
|
if (parts[0] === 'watch' && Number.parseInt(parts[2], 10) > 0) {
|
||||||
markEpisodeTransition(Number.parseInt(parts[2], 10))
|
markEpisodeTransition(Number.parseInt(parts[2], 10));
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
|
|
||||||
state.video.addEventListener('click', showControls)
|
state.video.addEventListener('click', showControls);
|
||||||
|
|
||||||
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null
|
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
|
||||||
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null
|
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
|
||||||
let searchDebounce: number | undefined
|
let searchDebounce: number | undefined;
|
||||||
|
|
||||||
if (searchInput) {
|
if (searchInput) {
|
||||||
searchInput.addEventListener('input', () => {
|
searchInput.addEventListener('input', () => {
|
||||||
clearTimeout(searchDebounce)
|
clearTimeout(searchDebounce);
|
||||||
searchDebounce = window.setTimeout(() => {
|
searchDebounce = window.setTimeout(() => {
|
||||||
const val = searchInput.value.replace(/\D/g, '')
|
const val = searchInput.value.replace(/\D/g, '');
|
||||||
if (!val) {
|
if (!val) {
|
||||||
const cur = Number.parseInt(state.currentEpisode, 10)
|
const cur = Number.parseInt(state.currentEpisode, 10);
|
||||||
switchEpisodeRange(Math.floor((cur - 1) / 100))
|
switchEpisodeRange(Math.floor((cur - 1) / 100));
|
||||||
updateEpisodeHighlight(cur)
|
updateEpisodeHighlight(cur);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
const ep = Number.parseInt(val, 10)
|
const ep = Number.parseInt(val, 10);
|
||||||
if (!ep || ep <= 0) return
|
if (!ep || ep <= 0) return;
|
||||||
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500
|
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
|
||||||
const clamped = Math.min(ep, maxEp)
|
const clamped = Math.min(ep, maxEp);
|
||||||
searchInput.value = String(clamped)
|
searchInput.value = String(clamped);
|
||||||
if (state.episodeGrid) {
|
if (state.episodeGrid) {
|
||||||
switchEpisodeRange(Math.floor((clamped - 1) / 100))
|
switchEpisodeRange(Math.floor((clamped - 1) / 100));
|
||||||
updateEpisodeHighlight(clamped)
|
updateEpisodeHighlight(clamped);
|
||||||
}
|
}
|
||||||
}, 300)
|
}, 300);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (dropdown) {
|
if (dropdown) {
|
||||||
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
|
||||||
btn.addEventListener('click', () => {
|
btn.addEventListener('click', () => {
|
||||||
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10)
|
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
|
||||||
switchEpisodeRange(idx)
|
switchEpisodeRange(idx);
|
||||||
})
|
});
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.episodeGrid && state.totalEpisodes > 100) {
|
if (state.episodeGrid && state.totalEpisodes > 100) {
|
||||||
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100))
|
switchEpisodeRange(Math.floor((Number.parseInt(state.currentEpisode, 10) - 1) / 100));
|
||||||
}
|
}
|
||||||
|
|
||||||
setupThumbnails()
|
setupThumbnails();
|
||||||
}
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initPlayer)
|
document.addEventListener('DOMContentLoaded', initPlayer);
|
||||||
document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
document.body.addEventListener('htmx:afterSwap', (e: Event) => {
|
||||||
const target = (e as CustomEvent).detail?.target as HTMLElement | null
|
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
|
||||||
if (target?.querySelector('[data-video-player]')) initPlayer()
|
if (target?.querySelector('[data-video-player]')) initPlayer();
|
||||||
})
|
});
|
||||||
|
|||||||
@@ -1,66 +1,79 @@
|
|||||||
import { state } from './state'
|
import { state } from './state';
|
||||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
|
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
|
||||||
import { showControls } from './controls'
|
import { showControls } from './controls';
|
||||||
import { updateSubtitleOptions } from './subtitles'
|
import { updateSubtitleOptions } from './subtitles';
|
||||||
import { updateQualityOptions } from './quality'
|
import { updateQualityOptions } from './quality';
|
||||||
import { ModeSource } from './types'
|
import { ModeSource } from './types';
|
||||||
|
|
||||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||||
const src = state.modeSources[mode]
|
const src = state.modeSources[mode];
|
||||||
if (!src?.token) return ''
|
if (!src?.token) return '';
|
||||||
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
|
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
|
||||||
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
|
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
|
||||||
return url
|
return url;
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadVideo = (url: string): void => {
|
const loadVideo = (url: string): void => {
|
||||||
if (!url) return
|
if (!url) return;
|
||||||
const wasPlaying = !state.video.paused
|
const wasPlaying = !state.video.paused;
|
||||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
|
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||||
state.video.src = url
|
state.video.src = url;
|
||||||
state.video.load()
|
state.video.load();
|
||||||
state.pendingSeekTime = prevTime
|
state.pendingSeekTime = prevTime;
|
||||||
if (wasPlaying) state.video.play().catch(() => {})
|
if (wasPlaying) state.video.play().catch(() => {});
|
||||||
}
|
};
|
||||||
|
|
||||||
export const switchMode = (mode: string): void => {
|
export const switchMode = (mode: string): void => {
|
||||||
if (!state.availableModes.includes(mode) || mode === state.currentMode) return
|
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
|
||||||
state.currentMode = mode
|
state.currentMode = mode;
|
||||||
localStorage.setItem('player-audio-mode', mode)
|
localStorage.setItem('player-audio-mode', mode);
|
||||||
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value))
|
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value));
|
||||||
updateSubtitleOptions()
|
updateSubtitleOptions();
|
||||||
updateQualityOptions()
|
updateQualityOptions();
|
||||||
updateModeButtons()
|
updateModeButtons();
|
||||||
}
|
};
|
||||||
|
|
||||||
export const updateModeButtons = (): void => {
|
export const updateModeButtons = (): void => {
|
||||||
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
|
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
|
||||||
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
|
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
|
||||||
const m = state.currentMode
|
const m = state.currentMode;
|
||||||
|
|
||||||
dub?.classList.toggle('text-accent', m === 'dub')
|
dub?.classList.toggle('text-accent', m === 'dub');
|
||||||
dub?.classList.toggle('text-white', m !== 'dub')
|
dub?.classList.toggle('text-white', m !== 'dub');
|
||||||
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'))
|
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
|
||||||
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'))
|
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
|
||||||
dub && (dub.disabled = !state.availableModes.includes('dub'))
|
dub && (dub.disabled = !state.availableModes.includes('dub'));
|
||||||
|
|
||||||
sub?.classList.toggle('text-accent', m === 'sub')
|
sub?.classList.toggle('text-accent', m === 'sub');
|
||||||
sub?.classList.toggle('text-white', m !== 'sub')
|
sub?.classList.toggle('text-white', m !== 'sub');
|
||||||
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'))
|
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
|
||||||
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'))
|
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
|
||||||
sub && (sub.disabled = !state.availableModes.includes('sub'))
|
sub && (sub.disabled = !state.availableModes.includes('sub'));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setupMode = (): void => {
|
export const setupMode = (): void => {
|
||||||
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
|
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
|
||||||
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
|
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
|
||||||
|
|
||||||
dub?.addEventListener('click', () => { if (state.availableModes.includes('dub')) { switchMode('dub'); showControls() } })
|
dub?.addEventListener('click', () => {
|
||||||
sub?.addEventListener('click', () => { if (state.availableModes.includes('sub')) { switchMode('sub'); showControls() } })
|
if (state.availableModes.includes('dub')) {
|
||||||
|
switchMode('dub');
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sub?.addEventListener('click', () => {
|
||||||
|
if (state.availableModes.includes('sub')) {
|
||||||
|
switchMode('sub');
|
||||||
|
showControls();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
|
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
|
||||||
autoplayBtn?.addEventListener('change', (e) => {
|
autoplayBtn?.addEventListener('change', e => {
|
||||||
localStorage.setItem('mal:autoplay-enabled', (e.target as HTMLInputElement).checked ? 'true' : 'false')
|
localStorage.setItem(
|
||||||
showControls()
|
'mal:autoplay-enabled',
|
||||||
})
|
(e.target as HTMLInputElement).checked ? 'true' : 'false'
|
||||||
}
|
);
|
||||||
|
showControls();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,70 +1,89 @@
|
|||||||
import { state } from './state'
|
import { state } from './state';
|
||||||
import { displayTimeFromAbsolute } from './timeline'
|
import { displayTimeFromAbsolute } from './timeline';
|
||||||
|
|
||||||
const buildPayload = (episode: number, seconds: number) => JSON.stringify({
|
const buildPayload = (episode: number, seconds: number) =>
|
||||||
mal_id: state.malID,
|
JSON.stringify({
|
||||||
episode,
|
mal_id: state.malID,
|
||||||
time_seconds: seconds,
|
episode,
|
||||||
})
|
time_seconds: seconds,
|
||||||
|
});
|
||||||
|
|
||||||
const sendBeacon = (payload: string) => {
|
const sendBeacon = (payload: string) => {
|
||||||
if (!navigator.sendBeacon) return false
|
if (!navigator.sendBeacon) return false;
|
||||||
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }))
|
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }));
|
||||||
return true
|
return true;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const saveProgress = async (): Promise<void> => {
|
export const saveProgress = async (): Promise<void> => {
|
||||||
if (!state.malID || state.video.currentTime < 1) return
|
if (!state.malID || state.video.currentTime < 1) return;
|
||||||
const episode = Number.parseInt(state.currentEpisode, 10)
|
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||||
if (!episode) return
|
if (!episode) return;
|
||||||
|
|
||||||
const safeTime = displayTimeFromAbsolute(state.video.currentTime)
|
const safeTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||||
if (state.lastSavedProgress.episode === state.currentEpisode &&
|
if (
|
||||||
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5) return
|
state.lastSavedProgress.episode === state.currentEpisode &&
|
||||||
|
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5
|
||||||
|
)
|
||||||
|
return;
|
||||||
|
|
||||||
const payload = buildPayload(episode, safeTime)
|
const payload = buildPayload(episode, safeTime);
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload })
|
const res = await fetch('/api/watch-progress', {
|
||||||
if (!res.ok) return
|
method: 'POST',
|
||||||
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime }
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime };
|
||||||
} catch {}
|
} catch {}
|
||||||
}
|
};
|
||||||
|
|
||||||
const scheduleProgressSave = (): void => {
|
const scheduleProgressSave = (): void => {
|
||||||
if (state.progressSaveTimer !== undefined) return
|
if (state.progressSaveTimer !== undefined) return;
|
||||||
state.progressSaveTimer = window.setTimeout(() => {
|
state.progressSaveTimer = window.setTimeout(() => {
|
||||||
state.progressSaveTimer = undefined
|
state.progressSaveTimer = undefined;
|
||||||
saveProgress()
|
saveProgress();
|
||||||
}, 30000)
|
}, 30000);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const markEpisodeTransition = (episodeNumber: number): void => {
|
export const markEpisodeTransition = (episodeNumber: number): void => {
|
||||||
if (!state.malID || !episodeNumber) return
|
if (!state.malID || !episodeNumber) return;
|
||||||
if (state.progressSaveTimer !== undefined) { window.clearTimeout(state.progressSaveTimer); state.progressSaveTimer = undefined }
|
if (state.progressSaveTimer !== undefined) {
|
||||||
state.transitionEpisode = episodeNumber
|
window.clearTimeout(state.progressSaveTimer);
|
||||||
const payload = buildPayload(episodeNumber, 0)
|
state.progressSaveTimer = undefined;
|
||||||
if (!sendBeacon(payload)) {
|
|
||||||
fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, keepalive: true, body: payload }).catch(() => {})
|
|
||||||
}
|
}
|
||||||
}
|
state.transitionEpisode = episodeNumber;
|
||||||
|
const payload = buildPayload(episodeNumber, 0);
|
||||||
|
if (!sendBeacon(payload)) {
|
||||||
|
fetch('/api/watch-progress', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
keepalive: true,
|
||||||
|
body: payload,
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const setupProgress = (): void => {
|
export const setupProgress = (): void => {
|
||||||
state.video.addEventListener('timeupdate', () => {
|
state.video.addEventListener('timeupdate', () => {
|
||||||
scheduleProgressSave()
|
scheduleProgressSave();
|
||||||
})
|
});
|
||||||
|
|
||||||
state.video.addEventListener('pause', () => {
|
state.video.addEventListener('pause', () => {
|
||||||
window.clearTimeout(state.progressSaveTimer)
|
window.clearTimeout(state.progressSaveTimer);
|
||||||
state.progressSaveTimer = undefined
|
state.progressSaveTimer = undefined;
|
||||||
saveProgress()
|
saveProgress();
|
||||||
})
|
});
|
||||||
|
|
||||||
window.addEventListener('mouseup', () => { state.isScrubbing = false; saveProgress() })
|
window.addEventListener('mouseup', () => {
|
||||||
|
state.isScrubbing = false;
|
||||||
|
saveProgress();
|
||||||
|
});
|
||||||
|
|
||||||
window.addEventListener('beforeunload', () => {
|
window.addEventListener('beforeunload', () => {
|
||||||
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return
|
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
|
||||||
const episode = Number.parseInt(state.currentEpisode, 10)
|
const episode = Number.parseInt(state.currentEpisode, 10);
|
||||||
if (!episode) return
|
if (!episode) return;
|
||||||
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)))
|
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,59 +1,59 @@
|
|||||||
import { state } from './state'
|
import { state } from './state';
|
||||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
|
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
|
||||||
|
|
||||||
const streamUrlForMode = (mode: string, quality?: string): string => {
|
const streamUrlForMode = (mode: string, quality?: string): string => {
|
||||||
const src = state.modeSources[mode]
|
const src = state.modeSources[mode];
|
||||||
if (!src?.token) return ''
|
if (!src?.token) return '';
|
||||||
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
|
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
|
||||||
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
|
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
|
||||||
return url
|
return url;
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadVideo = (url: string): void => {
|
const loadVideo = (url: string): void => {
|
||||||
if (!url) return
|
if (!url) return;
|
||||||
const wasPlaying = !state.video.paused
|
const wasPlaying = !state.video.paused;
|
||||||
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
|
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
|
||||||
state.video.src = url
|
state.video.src = url;
|
||||||
state.video.load()
|
state.video.load();
|
||||||
state.pendingSeekTime = prevTime
|
state.pendingSeekTime = prevTime;
|
||||||
if (wasPlaying) state.video.play().catch(() => {})
|
if (wasPlaying) state.video.play().catch(() => {});
|
||||||
}
|
};
|
||||||
|
|
||||||
export const switchQuality = (quality: string): void => {
|
export const switchQuality = (quality: string): void => {
|
||||||
const url = streamUrlForMode(state.currentMode, quality)
|
const url = streamUrlForMode(state.currentMode, quality);
|
||||||
if (!url) return
|
if (!url) return;
|
||||||
localStorage.setItem('mal:preferred-quality', quality)
|
localStorage.setItem('mal:preferred-quality', quality);
|
||||||
loadVideo(url)
|
loadVideo(url);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const updateQualityOptions = (): void => {
|
export const updateQualityOptions = (): void => {
|
||||||
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
|
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
|
||||||
if (!select) return
|
if (!select) return;
|
||||||
const qualities = state.modeSources[state.currentMode]?.qualities ?? []
|
const qualities = state.modeSources[state.currentMode]?.qualities ?? [];
|
||||||
select.innerHTML = ''
|
select.innerHTML = '';
|
||||||
|
|
||||||
const best = document.createElement('option')
|
const best = document.createElement('option');
|
||||||
best.value = 'best'
|
best.value = 'best';
|
||||||
best.textContent = 'Auto / Best'
|
best.textContent = 'Auto / Best';
|
||||||
select.appendChild(best)
|
select.appendChild(best);
|
||||||
|
|
||||||
qualities.forEach(q => {
|
qualities.forEach(q => {
|
||||||
const opt = document.createElement('option')
|
const opt = document.createElement('option');
|
||||||
opt.value = q
|
opt.value = q;
|
||||||
opt.textContent = q
|
opt.textContent = q;
|
||||||
select.appendChild(opt)
|
select.appendChild(opt);
|
||||||
})
|
});
|
||||||
|
|
||||||
const preferred = localStorage.getItem('mal:preferred-quality') || 'best'
|
const preferred = localStorage.getItem('mal:preferred-quality') || 'best';
|
||||||
select.value = qualities.includes(preferred) ? preferred : 'best'
|
select.value = qualities.includes(preferred) ? preferred : 'best';
|
||||||
|
|
||||||
const wrapper = select.parentElement
|
const wrapper = select.parentElement;
|
||||||
wrapper?.classList.toggle('hidden', qualities.length === 0)
|
wrapper?.classList.toggle('hidden', qualities.length === 0);
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setupQuality = (): void => {
|
export const setupQuality = (): void => {
|
||||||
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
|
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
|
||||||
select?.addEventListener('change', (e) => {
|
select?.addEventListener('change', e => {
|
||||||
switchQuality((e.target as HTMLSelectElement).value)
|
switchQuality((e.target as HTMLSelectElement).value);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,50 +1,53 @@
|
|||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline'
|
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
|
||||||
import { showControls } from '../controls'
|
import { showControls } from '../controls';
|
||||||
import { resolveActiveSegments, renderSegments } from './segments'
|
import { resolveActiveSegments, renderSegments } from './segments';
|
||||||
|
|
||||||
const skipLabel = (type: string): string => type === 'ed' ? 'Skip outro' : 'Skip intro'
|
const skipLabel = (type: string): string => (type === 'ed' ? 'Skip outro' : 'Skip intro');
|
||||||
|
|
||||||
export const updateSkipButton = (currentTime: number): void => {
|
export const updateSkipButton = (currentTime: number): void => {
|
||||||
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null
|
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null;
|
||||||
const displayTime = displayTimeFromAbsolute(currentTime)
|
const displayTime = displayTimeFromAbsolute(currentTime);
|
||||||
|
|
||||||
const segment = state.activeSegments.find(s => {
|
const segment = state.activeSegments.find(s => {
|
||||||
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02))
|
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02));
|
||||||
return displayTime >= s.start + delay && displayTime < s.end
|
return displayTime >= s.start + delay && displayTime < s.end;
|
||||||
})
|
});
|
||||||
|
|
||||||
if (!segment) {
|
if (!segment) {
|
||||||
state.activeSkipSegment = null
|
state.activeSkipSegment = null;
|
||||||
btn?.classList.add('hidden')
|
btn?.classList.add('hidden');
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true'
|
const autoSkip = localStorage.getItem('mal:autoskip-enabled') === 'true';
|
||||||
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
|
if (autoSkip && displayTime >= segment.start && displayTime < segment.end) {
|
||||||
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01)
|
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
state.activeSkipSegment = segment
|
state.activeSkipSegment = segment;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
btn.textContent = skipLabel(segment.type)
|
btn.textContent = skipLabel(segment.type);
|
||||||
btn.title = skipLabel(segment.type)
|
btn.title = skipLabel(segment.type);
|
||||||
btn.classList.remove('hidden')
|
btn.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
export const updateAutoSkipButton = (): void => {
|
export const updateAutoSkipButton = (): void => {
|
||||||
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null
|
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
|
||||||
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true')
|
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true');
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setupSkip = (): void => {
|
export const setupSkip = (): void => {
|
||||||
document.addEventListener('change', (e) => {
|
document.addEventListener('change', e => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
if (target.hasAttribute('data-autoskip')) {
|
if (target.hasAttribute('data-autoskip')) {
|
||||||
localStorage.setItem('mal:autoskip-enabled', (target as HTMLInputElement).checked ? 'true' : 'false')
|
localStorage.setItem(
|
||||||
showControls()
|
'mal:autoskip-enabled',
|
||||||
|
(target as HTMLInputElement).checked ? 'true' : 'false'
|
||||||
|
);
|
||||||
|
showControls();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,44 +1,46 @@
|
|||||||
import { SkipSegment } from '../types'
|
import { SkipSegment } from '../types';
|
||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
|
|
||||||
const MIN_SEGMENT_DURATION = 20
|
const MIN_SEGMENT_DURATION = 20;
|
||||||
const MAX_SEGMENT_DURATION = 240
|
const MAX_SEGMENT_DURATION = 240;
|
||||||
const MAX_INTRO_START = 180
|
const MAX_INTRO_START = 180;
|
||||||
const MIN_OUTRO_START_RATIO = 0.5
|
const MIN_OUTRO_START_RATIO = 0.5;
|
||||||
|
|
||||||
export const resolveActiveSegments = (): void => {
|
export const resolveActiveSegments = (): void => {
|
||||||
const bounds = state.video.duration
|
const bounds = state.video.duration;
|
||||||
if (bounds <= 0) { state.activeSegments = []; return }
|
if (bounds <= 0) {
|
||||||
|
state.activeSegments = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
state.activeSegments = state.parsedSegments
|
state.activeSegments = state.parsedSegments.filter(s => {
|
||||||
.filter(s => {
|
const len = s.end - s.start;
|
||||||
const len = s.end - s.start
|
if (len < MIN_SEGMENT_DURATION || len > MAX_SEGMENT_DURATION) return false;
|
||||||
if (len < MIN_SEGMENT_DURATION || len > MAX_SEGMENT_DURATION) return false
|
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false;
|
||||||
if (s.start < 0 || s.end <= s.start || s.end > bounds + 1) return false
|
|
||||||
|
|
||||||
if (s.type === 'op') {
|
if (s.type === 'op') {
|
||||||
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5
|
return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5;
|
||||||
}
|
}
|
||||||
if (s.type === 'ed') {
|
if (s.type === 'ed') {
|
||||||
return s.start >= bounds * MIN_OUTRO_START_RATIO
|
return s.start >= bounds * MIN_OUTRO_START_RATIO;
|
||||||
}
|
}
|
||||||
return false
|
return false;
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
export const renderSegments = (): void => {
|
export const renderSegments = (): void => {
|
||||||
const track = state.container.querySelector('[data-segments]') as HTMLElement | null
|
const track = state.container.querySelector('[data-segments]') as HTMLElement | null;
|
||||||
if (!track) return
|
if (!track) return;
|
||||||
track.innerHTML = ''
|
track.innerHTML = '';
|
||||||
|
|
||||||
const bounds = state.video.duration
|
const bounds = state.video.duration;
|
||||||
if (bounds <= 0) return
|
if (bounds <= 0) return;
|
||||||
|
|
||||||
state.activeSegments.forEach(s => {
|
state.activeSegments.forEach(s => {
|
||||||
const bar = document.createElement('div')
|
const bar = document.createElement('div');
|
||||||
bar.className = 'absolute top-0 h-full bg-white/80'
|
bar.className = 'absolute top-0 h-full bg-white/80';
|
||||||
bar.style.left = `${(s.start / bounds) * 100}%`
|
bar.style.left = `${(s.start / bounds) * 100}%`;
|
||||||
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`
|
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
|
||||||
track.appendChild(bar)
|
track.appendChild(bar);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,43 +1,43 @@
|
|||||||
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'
|
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types';
|
||||||
import { q, qs, dataset } from '../q'
|
import { q, qs, dataset } from '../q';
|
||||||
|
|
||||||
export interface PlayerState {
|
export interface PlayerState {
|
||||||
container: HTMLElement
|
container: HTMLElement;
|
||||||
video: HTMLVideoElement
|
video: HTMLVideoElement;
|
||||||
progress: HTMLElement
|
progress: HTMLElement;
|
||||||
scrubber: HTMLElement
|
scrubber: HTMLElement;
|
||||||
buffered: HTMLElement
|
buffered: HTMLElement;
|
||||||
timeDisplay: HTMLElement
|
timeDisplay: HTMLElement;
|
||||||
durationDisplay: HTMLElement
|
durationDisplay: HTMLElement;
|
||||||
modeSources: Record<string, ModeSource>
|
modeSources: Record<string, ModeSource>;
|
||||||
availableModes: string[]
|
availableModes: string[];
|
||||||
currentMode: string
|
currentMode: string;
|
||||||
currentEpisode: string
|
currentEpisode: string;
|
||||||
totalEpisodes: number
|
totalEpisodes: number;
|
||||||
malID: number
|
malID: number;
|
||||||
streamURL: string
|
streamURL: string;
|
||||||
initialStreamToken: string
|
initialStreamToken: string;
|
||||||
shouldAutoPlay: boolean
|
shouldAutoPlay: boolean;
|
||||||
parsedSegments: SkipSegment[]
|
parsedSegments: SkipSegment[];
|
||||||
activeSegments: ActiveSegment[]
|
activeSegments: ActiveSegment[];
|
||||||
activeSkipSegment: ActiveSegment | null
|
activeSkipSegment: ActiveSegment | null;
|
||||||
activeSubtitles: SubtitleCue[]
|
activeSubtitles: SubtitleCue[];
|
||||||
currentSubtitleTracks: SubtitleTrack[]
|
currentSubtitleTracks: SubtitleTrack[];
|
||||||
lastKnownVolume: number
|
lastKnownVolume: number;
|
||||||
pendingSeekTime: number | null
|
pendingSeekTime: number | null;
|
||||||
isScrubbing: boolean
|
isScrubbing: boolean;
|
||||||
isFullscreen: boolean
|
isFullscreen: boolean;
|
||||||
playerControlsTimeout: number | undefined
|
playerControlsTimeout: number | undefined;
|
||||||
progressSaveTimer: number | undefined
|
progressSaveTimer: number | undefined;
|
||||||
transitionEpisode: number | null
|
transitionEpisode: number | null;
|
||||||
completionSent: boolean
|
completionSent: boolean;
|
||||||
completionAttempts: number
|
completionAttempts: number;
|
||||||
lastSavedProgress: { episode: string; seconds: number }
|
lastSavedProgress: { episode: string; seconds: number };
|
||||||
episodeGrid: HTMLElement | null
|
episodeGrid: HTMLElement | null;
|
||||||
episodeList: HTMLElement | null
|
episodeList: HTMLElement | null;
|
||||||
previewPopover: HTMLElement | null
|
previewPopover: HTMLElement | null;
|
||||||
previewTime: HTMLElement | null
|
previewTime: HTMLElement | null;
|
||||||
videoOverlay: HTMLElement | null
|
videoOverlay: HTMLElement | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const state: PlayerState = {
|
export const state: PlayerState = {
|
||||||
@@ -77,50 +77,53 @@ export const state: PlayerState = {
|
|||||||
previewPopover: null,
|
previewPopover: null,
|
||||||
previewTime: null,
|
previewTime: null,
|
||||||
videoOverlay: null,
|
videoOverlay: null,
|
||||||
}
|
};
|
||||||
|
|
||||||
export const initState = (c: HTMLElement): void => {
|
export const initState = (c: HTMLElement): void => {
|
||||||
state.container = c
|
state.container = c;
|
||||||
state.video = q<HTMLVideoElement>(c, 'video')!
|
state.video = q<HTMLVideoElement>(c, 'video')!;
|
||||||
state.progress = q<HTMLElement>(c, '[data-progress]')
|
state.progress = q<HTMLElement>(c, '[data-progress]');
|
||||||
state.scrubber = q<HTMLElement>(c, '[data-scrubber]')
|
state.scrubber = q<HTMLElement>(c, '[data-scrubber]');
|
||||||
state.buffered = q<HTMLElement>(c, '[data-buffered]')
|
state.buffered = q<HTMLElement>(c, '[data-buffered]');
|
||||||
state.timeDisplay = q<HTMLElement>(c, '[data-time]')
|
state.timeDisplay = q<HTMLElement>(c, '[data-time]');
|
||||||
state.durationDisplay = q<HTMLElement>(c, '[data-duration]')
|
state.durationDisplay = q<HTMLElement>(c, '[data-duration]');
|
||||||
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]')
|
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]');
|
||||||
state.previewTime = q<HTMLElement>(c, '[data-preview-time]')
|
state.previewTime = q<HTMLElement>(c, '[data-preview-time]');
|
||||||
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]')
|
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]');
|
||||||
|
|
||||||
state.malID = Number.parseInt(dataset(c, 'malId'), 10)
|
state.malID = Number.parseInt(dataset(c, 'malId'), 10);
|
||||||
state.currentEpisode = dataset(c, 'currentEpisode') || '1'
|
state.currentEpisode = dataset(c, 'currentEpisode') || '1';
|
||||||
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10)
|
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10);
|
||||||
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream'
|
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream';
|
||||||
state.initialStreamToken = dataset(c, 'streamToken') || ''
|
state.initialStreamToken = dataset(c, 'streamToken') || '';
|
||||||
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true'
|
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true';
|
||||||
sessionStorage.removeItem('mal:autoplay-next')
|
sessionStorage.removeItem('mal:autoplay-next');
|
||||||
|
|
||||||
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]')
|
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]');
|
||||||
state.episodeList = qs<HTMLElement>('[data-episode-list]')
|
state.episodeList = qs<HTMLElement>('[data-episode-list]');
|
||||||
|
|
||||||
const safeJson = <T>(raw: string | undefined, fallback: T): T => {
|
const safeJson = <T>(raw: string | undefined, fallback: T): T => {
|
||||||
try { return JSON.parse(raw ?? '') as T } catch { return fallback }
|
try {
|
||||||
}
|
return JSON.parse(raw ?? '') as T;
|
||||||
|
} catch {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>)
|
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>);
|
||||||
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[])
|
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]);
|
||||||
|
|
||||||
const backendInitialMode = dataset(c, 'initialMode') || 'dub'
|
const backendInitialMode = dataset(c, 'initialMode') || 'dub';
|
||||||
const storedMode = localStorage.getItem('player-audio-mode')
|
const storedMode = localStorage.getItem('player-audio-mode');
|
||||||
const initialMode = (storedMode && state.availableModes.includes(storedMode)) ? storedMode : backendInitialMode
|
const initialMode =
|
||||||
const fallbackMode = Object.keys(state.modeSources).find(
|
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode;
|
||||||
m => state.modeSources[m]?.token
|
const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token);
|
||||||
)
|
state.currentMode = state.modeSources[initialMode]?.token
|
||||||
state.currentMode =
|
? initialMode
|
||||||
(state.modeSources[initialMode]?.token) ? initialMode :
|
: (fallbackMode ?? state.availableModes[0] ?? 'dub');
|
||||||
(fallbackMode ?? state.availableModes[0] ?? 'dub')
|
|
||||||
|
|
||||||
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[])
|
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[]);
|
||||||
state.parsedSegments = segments
|
state.parsedSegments = segments
|
||||||
.map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
.map(s => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
|
||||||
.filter(s => s.end > s.start)
|
.filter(s => s.end > s.start);
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,76 +1,99 @@
|
|||||||
import { SubtitleCue, SubtitleTrack } from '../types'
|
import { SubtitleCue, SubtitleTrack } from '../types';
|
||||||
import { state } from '../state'
|
import { state } from '../state';
|
||||||
import { parseVtt } from './vtt'
|
import { parseVtt } from './vtt';
|
||||||
|
|
||||||
const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`
|
const proxyUrl = (token: string) => `/watch/proxy/subtitle?token=${encodeURIComponent(token)}`;
|
||||||
|
|
||||||
const subtitlesForMode = (): SubtitleTrack[] => {
|
const subtitlesForMode = (): SubtitleTrack[] => {
|
||||||
const src = state.modeSources[state.currentMode]
|
const src = state.modeSources[state.currentMode];
|
||||||
if (!src?.subtitles) return []
|
if (!src?.subtitles) return [];
|
||||||
return src.subtitles
|
return src.subtitles
|
||||||
.map(t => ({ lang: (t.lang || 'unknown').toLowerCase(), label: t.lang || 'Unknown', url: proxyUrl(t.token) }))
|
.map(t => ({
|
||||||
.filter(t => t.url !== '')
|
lang: (t.lang || 'unknown').toLowerCase(),
|
||||||
}
|
label: t.lang || 'Unknown',
|
||||||
|
url: proxyUrl(t.token),
|
||||||
|
}))
|
||||||
|
.filter(t => t.url !== '');
|
||||||
|
};
|
||||||
|
|
||||||
const hideSubtitleText = (): void => {
|
const hideSubtitleText = (): void => {
|
||||||
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
|
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
el.textContent = ''
|
el.textContent = '';
|
||||||
el.classList.remove('block')
|
el.classList.remove('block');
|
||||||
el.classList.add('hidden')
|
el.classList.add('hidden');
|
||||||
}
|
};
|
||||||
|
|
||||||
const loadSubtitle = async (url: string): Promise<SubtitleCue[]> => {
|
const loadSubtitle = async (url: string): Promise<SubtitleCue[]> => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(url)
|
const res = await fetch(url);
|
||||||
if (!res.ok) return []
|
if (!res.ok) return [];
|
||||||
return parseVtt(await res.text())
|
return parseVtt(await res.text());
|
||||||
} catch { return [] }
|
} catch {
|
||||||
}
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
export const updateSubtitleOptions = (): void => {
|
export const updateSubtitleOptions = (): void => {
|
||||||
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
|
const select = state.container.querySelector(
|
||||||
if (!select) return
|
'[data-subtitle-select]'
|
||||||
state.currentSubtitleTracks = subtitlesForMode()
|
) as HTMLSelectElement | null;
|
||||||
select.innerHTML = ''
|
if (!select) return;
|
||||||
|
state.currentSubtitleTracks = subtitlesForMode();
|
||||||
|
select.innerHTML = '';
|
||||||
|
|
||||||
const none = document.createElement('option')
|
const none = document.createElement('option');
|
||||||
none.value = 'none'
|
none.value = 'none';
|
||||||
none.textContent = 'Off'
|
none.textContent = 'Off';
|
||||||
select.appendChild(none)
|
select.appendChild(none);
|
||||||
select.value = 'none'
|
select.value = 'none';
|
||||||
|
|
||||||
state.currentSubtitleTracks.forEach((t, i) => {
|
state.currentSubtitleTracks.forEach((t, i) => {
|
||||||
const opt = document.createElement('option')
|
const opt = document.createElement('option');
|
||||||
opt.value = String(i)
|
opt.value = String(i);
|
||||||
opt.textContent = t.label
|
opt.textContent = t.label;
|
||||||
select.appendChild(opt)
|
select.appendChild(opt);
|
||||||
})
|
});
|
||||||
|
|
||||||
const wrapper = select.parentElement
|
const wrapper = select.parentElement;
|
||||||
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0)
|
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0);
|
||||||
state.activeSubtitles = []
|
state.activeSubtitles = [];
|
||||||
hideSubtitleText()
|
hideSubtitleText();
|
||||||
}
|
};
|
||||||
|
|
||||||
export const updateSubtitleRender = (time: number): void => {
|
export const updateSubtitleRender = (time: number): void => {
|
||||||
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
|
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
|
||||||
if (!el) return
|
if (!el) return;
|
||||||
if (!state.activeSubtitles.length) { hideSubtitleText(); return }
|
if (!state.activeSubtitles.length) {
|
||||||
|
hideSubtitleText();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end)
|
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end);
|
||||||
if (!cue) { hideSubtitleText(); return }
|
if (!cue) {
|
||||||
|
hideSubtitleText();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
el.textContent = cue.text
|
el.textContent = cue.text;
|
||||||
el.classList.remove('hidden')
|
el.classList.remove('hidden');
|
||||||
}
|
};
|
||||||
|
|
||||||
export const setupSubtitles = (): void => {
|
export const setupSubtitles = (): void => {
|
||||||
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
|
const select = state.container.querySelector(
|
||||||
|
'[data-subtitle-select]'
|
||||||
|
) as HTMLSelectElement | null;
|
||||||
select?.addEventListener('change', async () => {
|
select?.addEventListener('change', async () => {
|
||||||
if (select.value === 'none') { state.activeSubtitles = []; hideSubtitleText(); return }
|
if (select.value === 'none') {
|
||||||
const track = state.currentSubtitleTracks[Number(select.value)]
|
state.activeSubtitles = [];
|
||||||
if (!track) { state.activeSubtitles = []; return }
|
hideSubtitleText();
|
||||||
state.activeSubtitles = await loadSubtitle(track.url)
|
return;
|
||||||
})
|
}
|
||||||
}
|
const track = state.currentSubtitleTracks[Number(select.value)];
|
||||||
|
if (!track) {
|
||||||
|
state.activeSubtitles = [];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
state.activeSubtitles = await loadSubtitle(track.url);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,38 +1,43 @@
|
|||||||
export const parseVttTime = (raw: string): number => {
|
export const parseVttTime = (raw: string): number => {
|
||||||
const parts = raw.trim().split(':')
|
const parts = raw.trim().split(':');
|
||||||
if (parts.length < 2) return 0
|
if (parts.length < 2) return 0;
|
||||||
const secPart = parts.pop()!
|
const secPart = parts.pop()!;
|
||||||
const minPart = parts.pop()!
|
const minPart = parts.pop()!;
|
||||||
const hourPart = parts.pop() ?? '0'
|
const hourPart = parts.pop() ?? '0';
|
||||||
return (Number(hourPart) * 3600) + (Number(minPart) * 60) + Number(secPart.replace(',', '.'))
|
return Number(hourPart) * 3600 + Number(minPart) * 60 + Number(secPart.replace(',', '.'));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const parseVttCue = (line: string, lines: string[], i: number) => {
|
export const parseVttCue = (line: string, lines: string[], i: number) => {
|
||||||
if (!line.includes('-->')) return null
|
if (!line.includes('-->')) return null;
|
||||||
const [startRaw, endRaw] = line.split('-->')
|
const [startRaw, endRaw] = line.split('-->');
|
||||||
const payload: string[] = []
|
const payload: string[] = [];
|
||||||
let j = i + 1
|
let j = i + 1;
|
||||||
while (j < lines.length && lines[j].trim() !== '') {
|
while (j < lines.length && lines[j].trim() !== '') {
|
||||||
payload.push(lines[j]); j++
|
payload.push(lines[j]);
|
||||||
|
j++;
|
||||||
}
|
}
|
||||||
const text = payload.join('\n').replace(/<[^>]+>/g, '').trim()
|
const text = payload
|
||||||
if (!text) return null
|
.join('\n')
|
||||||
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text }
|
.replace(/<[^>]+>/g, '')
|
||||||
}
|
.trim();
|
||||||
|
if (!text) return null;
|
||||||
|
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text };
|
||||||
|
};
|
||||||
|
|
||||||
export const parseVtt = (text: string) => {
|
export const parseVtt = (text: string) => {
|
||||||
const lines = text.replace(/\r/g, '').split('\n')
|
const lines = text.replace(/\r/g, '').split('\n');
|
||||||
const cues = []
|
const cues = [];
|
||||||
for (let i = 0; i < lines.length; i++) {
|
for (let i = 0; i < lines.length; i++) {
|
||||||
const line = lines[i].trim()
|
const line = lines[i].trim();
|
||||||
if (!line) continue
|
if (!line) continue;
|
||||||
if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) {
|
if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) {
|
||||||
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1)
|
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1);
|
||||||
if (cue) cues.push(cue); i++
|
if (cue) cues.push(cue);
|
||||||
|
i++;
|
||||||
} else if (line.includes('-->')) {
|
} else if (line.includes('-->')) {
|
||||||
const cue = parseVttCue(line, lines, i)
|
const cue = parseVttCue(line, lines, i);
|
||||||
if (cue) cues.push(cue)
|
if (cue) cues.push(cue);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return cues
|
return cues;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,97 +1,101 @@
|
|||||||
import { TimelineBounds } from './types'
|
import { TimelineBounds } from './types';
|
||||||
import { state } from './state'
|
import { state } from './state';
|
||||||
|
|
||||||
const formatTime = (seconds: number): string => {
|
const formatTime = (seconds: number): string => {
|
||||||
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
|
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60);
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60);
|
||||||
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
|
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 }
|
let cachedBounds: TimelineBounds = { start: 0, end: 0, duration: 0 };
|
||||||
|
|
||||||
export const timelineBounds = (): TimelineBounds => {
|
export const timelineBounds = (): TimelineBounds => {
|
||||||
const duration = Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0
|
const duration =
|
||||||
let start = 0
|
Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0;
|
||||||
|
let start = 0;
|
||||||
if (state.video.seekable.length > 0) {
|
if (state.video.seekable.length > 0) {
|
||||||
const seekableStart = state.video.seekable.start(0)
|
const seekableStart = state.video.seekable.start(0);
|
||||||
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart
|
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart;
|
||||||
}
|
}
|
||||||
if (duration > start) {
|
if (duration > start) {
|
||||||
return { start, end: duration, duration: duration - start }
|
return { start, end: duration, duration: duration - start };
|
||||||
}
|
}
|
||||||
if (state.video.seekable.length > 0) {
|
if (state.video.seekable.length > 0) {
|
||||||
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1)
|
const seekableEnd = state.video.seekable.end(state.video.seekable.length - 1);
|
||||||
if (Number.isFinite(seekableEnd) && seekableEnd > start) {
|
if (Number.isFinite(seekableEnd) && seekableEnd > start) {
|
||||||
return { start, end: seekableEnd, duration: seekableEnd - start }
|
return { start, end: seekableEnd, duration: seekableEnd - start };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { start: 0, end: duration, duration }
|
return { start: 0, end: duration, duration };
|
||||||
}
|
};
|
||||||
|
|
||||||
export const invalidateBounds = (): void => {
|
export const invalidateBounds = (): void => {
|
||||||
cachedBounds = timelineBounds()
|
cachedBounds = timelineBounds();
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getBounds = (): TimelineBounds => cachedBounds
|
export const getBounds = (): TimelineBounds => cachedBounds;
|
||||||
|
|
||||||
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
|
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
|
||||||
const b = getBounds()
|
const b = getBounds();
|
||||||
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0
|
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0;
|
||||||
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start
|
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const absoluteTimeFromDisplay = (displayTime: number): number => {
|
export const absoluteTimeFromDisplay = (displayTime: number): number => {
|
||||||
const b = getBounds()
|
const b = getBounds();
|
||||||
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0
|
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0;
|
||||||
return b.start + Math.max(0, Math.min(b.duration, displayTime))
|
return b.start + Math.max(0, Math.min(b.duration, displayTime));
|
||||||
}
|
};
|
||||||
|
|
||||||
export const absoluteTimeFromRatio = (ratio: number): number => {
|
export const absoluteTimeFromRatio = (ratio: number): number => {
|
||||||
const b = getBounds()
|
const b = getBounds();
|
||||||
if (!Number.isFinite(ratio) || b.duration <= 0) return 0
|
if (!Number.isFinite(ratio) || b.duration <= 0) return 0;
|
||||||
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration
|
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const getBufferedEnd = (): number => {
|
export const getBufferedEnd = (): number => {
|
||||||
const currentTime = state.video.currentTime
|
const currentTime = state.video.currentTime;
|
||||||
let end = 0
|
let end = 0;
|
||||||
for (let i = 0; i < state.video.buffered.length; i++) {
|
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||||
if (state.video.buffered.start(i) <= currentTime && state.video.buffered.end(i) >= currentTime) {
|
if (
|
||||||
end = state.video.buffered.end(i)
|
state.video.buffered.start(i) <= currentTime &&
|
||||||
break
|
state.video.buffered.end(i) >= currentTime
|
||||||
|
) {
|
||||||
|
end = state.video.buffered.end(i);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (end === 0) {
|
if (end === 0) {
|
||||||
for (let i = 0; i < state.video.buffered.length; i++) {
|
for (let i = 0; i < state.video.buffered.length; i++) {
|
||||||
if (state.video.buffered.end(i) > currentTime) {
|
if (state.video.buffered.end(i) > currentTime) {
|
||||||
end = Math.max(end, state.video.buffered.end(i))
|
end = Math.max(end, state.video.buffered.end(i));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return end
|
return end;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const updateTimeline = (currentTime: number): void => {
|
export const updateTimeline = (currentTime: number): void => {
|
||||||
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state
|
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state;
|
||||||
const b = getBounds()
|
const b = getBounds();
|
||||||
|
|
||||||
if (b.duration <= 0) {
|
if (b.duration <= 0) {
|
||||||
progress.style.width = '0%'
|
progress.style.width = '0%';
|
||||||
buffered.style.width = '0%'
|
buffered.style.width = '0%';
|
||||||
scrubber.style.left = '0%'
|
scrubber.style.left = '0%';
|
||||||
timeDisplay.textContent = '00:00'
|
timeDisplay.textContent = '00:00';
|
||||||
durationDisplay.textContent = '00:00'
|
durationDisplay.textContent = '00:00';
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100
|
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100;
|
||||||
progress.style.width = `${pct}%`
|
progress.style.width = `${pct}%`;
|
||||||
scrubber.style.left = `${pct}%`
|
scrubber.style.left = `${pct}%`;
|
||||||
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime))
|
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime));
|
||||||
durationDisplay.textContent = formatTime(b.duration)
|
durationDisplay.textContent = formatTime(b.duration);
|
||||||
|
|
||||||
const bufferedEnd = getBufferedEnd()
|
const bufferedEnd = getBufferedEnd();
|
||||||
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100
|
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100;
|
||||||
buffered.style.width = `${bufferedPct}%`
|
buffered.style.width = `${bufferedPct}%`;
|
||||||
}
|
};
|
||||||
|
|||||||
@@ -1,40 +1,40 @@
|
|||||||
export interface ModeSource {
|
export interface ModeSource {
|
||||||
token: string
|
token: string;
|
||||||
subtitles: SubtitleItem[]
|
subtitles: SubtitleItem[];
|
||||||
qualities?: string[]
|
qualities?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleItem {
|
export interface SubtitleItem {
|
||||||
lang: string
|
lang: string;
|
||||||
token: string
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkipSegment {
|
export interface SkipSegment {
|
||||||
type: string
|
type: string;
|
||||||
start: number
|
start: number;
|
||||||
end: number
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleCue {
|
export interface SubtitleCue {
|
||||||
start: number
|
start: number;
|
||||||
end: number
|
end: number;
|
||||||
text: string
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SubtitleTrack {
|
export interface SubtitleTrack {
|
||||||
lang: string
|
lang: string;
|
||||||
label: string
|
label: string;
|
||||||
url: string
|
url: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ActiveSegment {
|
export interface ActiveSegment {
|
||||||
type: string
|
type: string;
|
||||||
start: number
|
start: number;
|
||||||
end: number
|
end: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TimelineBounds {
|
export interface TimelineBounds {
|
||||||
start: number
|
start: number;
|
||||||
end: number
|
end: number;
|
||||||
duration: number
|
duration: number;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
export const q = <T extends Element>(container: HTMLElement, selector: string): T | null =>
|
export const q = <T extends Element>(container: HTMLElement, selector: string): T | null =>
|
||||||
container.querySelector(selector) as T | null
|
container.querySelector(selector) as T | null;
|
||||||
|
|
||||||
export const qs = <T extends Element>(selector: string): T | null =>
|
export const qs = <T extends Element>(selector: string): T | null =>
|
||||||
document.querySelector(selector) as T | null
|
document.querySelector(selector) as T | null;
|
||||||
|
|
||||||
export const dataset = (el: HTMLElement, key: string): string =>
|
export const dataset = (el: HTMLElement, key: string): string => el.dataset[key] ?? '';
|
||||||
el.dataset[key] ?? ''
|
|
||||||
|
|||||||
185
static/search.ts
185
static/search.ts
@@ -1,168 +1,173 @@
|
|||||||
export {}
|
export {};
|
||||||
|
|
||||||
type QuickSearchResult = {
|
type QuickSearchResult = {
|
||||||
id?: number
|
id?: number;
|
||||||
image?: string
|
image?: string;
|
||||||
title?: string
|
title?: string;
|
||||||
type?: string
|
type?: string;
|
||||||
}
|
};
|
||||||
|
|
||||||
const searchInitializedKey = Symbol('searchInitialized')
|
const searchInitializedKey = Symbol('searchInitialized');
|
||||||
const globalWindow = window as Window & { [searchInitializedKey]?: boolean }
|
const globalWindow = window as Window & { [searchInitializedKey]?: boolean };
|
||||||
|
|
||||||
let searchTimeout: number | undefined
|
let searchTimeout: number | undefined;
|
||||||
const searchInput = document.getElementById('search-input') as HTMLInputElement | null
|
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
|
||||||
const searchDropdown = document.querySelector('[data-search-results-container]') as HTMLElement | null
|
const searchDropdown = document.querySelector(
|
||||||
|
'[data-search-results-container]'
|
||||||
|
) as HTMLElement | null;
|
||||||
|
|
||||||
const isSafeImageUrl = (rawUrl?: string): boolean => {
|
const isSafeImageUrl = (rawUrl?: string): boolean => {
|
||||||
if (!rawUrl || typeof rawUrl !== 'string') {
|
if (!rawUrl || typeof rawUrl !== 'string') {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const parsed = new URL(rawUrl, window.location.origin)
|
const parsed = new URL(rawUrl, window.location.origin);
|
||||||
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
|
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
|
||||||
} catch {
|
} catch {
|
||||||
return false
|
return false;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const clearSearchResults = (): void => {
|
const clearSearchResults = (): void => {
|
||||||
if (!searchDropdown) {
|
if (!searchDropdown) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchDropdown.replaceChildren()
|
searchDropdown.replaceChildren();
|
||||||
}
|
};
|
||||||
|
|
||||||
const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => {
|
const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => {
|
||||||
const item = document.createElement('a')
|
const item = document.createElement('a');
|
||||||
item.className = 'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-(--panel-soft) hover:no-underline'
|
item.className =
|
||||||
item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')))
|
'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-(--panel-soft) hover:no-underline';
|
||||||
|
item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')));
|
||||||
|
|
||||||
if (isSafeImageUrl(result.image)) {
|
if (isSafeImageUrl(result.image)) {
|
||||||
const img = document.createElement('img')
|
const img = document.createElement('img');
|
||||||
img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)'
|
img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)';
|
||||||
img.setAttribute('src', result.image || '')
|
img.setAttribute('src', result.image || '');
|
||||||
img.setAttribute('alt', String(result.title || ''))
|
img.setAttribute('alt', String(result.title || ''));
|
||||||
item.appendChild(img)
|
item.appendChild(img);
|
||||||
} else {
|
} else {
|
||||||
const noImage = document.createElement('div')
|
const noImage = document.createElement('div');
|
||||||
noImage.className = 'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent'
|
noImage.className =
|
||||||
noImage.textContent = 'no image'
|
'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent';
|
||||||
item.appendChild(noImage)
|
noImage.textContent = 'no image';
|
||||||
|
item.appendChild(noImage);
|
||||||
}
|
}
|
||||||
|
|
||||||
const info = document.createElement('div')
|
const info = document.createElement('div');
|
||||||
info.className = 'grid min-w-0 gap-px'
|
info.className = 'grid min-w-0 gap-px';
|
||||||
|
|
||||||
const itemTitle = document.createElement('div')
|
const itemTitle = document.createElement('div');
|
||||||
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)'
|
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)';
|
||||||
itemTitle.textContent = String(result.title || '')
|
itemTitle.textContent = String(result.title || '');
|
||||||
info.appendChild(itemTitle)
|
info.appendChild(itemTitle);
|
||||||
|
|
||||||
const itemType = document.createElement('div')
|
const itemType = document.createElement('div');
|
||||||
itemType.className = 'text-[0.67rem] text-(--text-faint)'
|
itemType.className = 'text-[0.67rem] text-(--text-faint)';
|
||||||
itemType.textContent = String(result.type || '')
|
itemType.textContent = String(result.type || '');
|
||||||
info.appendChild(itemType)
|
info.appendChild(itemType);
|
||||||
|
|
||||||
item.appendChild(info)
|
item.appendChild(info);
|
||||||
return item
|
return item;
|
||||||
}
|
};
|
||||||
|
|
||||||
const renderQuickSearchResults = (query: string, results: QuickSearchResult[]): void => {
|
const renderQuickSearchResults = (query: string, results: QuickSearchResult[]): void => {
|
||||||
if (!searchDropdown) {
|
if (!searchDropdown) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!results || results.length === 0) {
|
if (!results || results.length === 0) {
|
||||||
clearSearchResults()
|
clearSearchResults();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const searchResults = document.createElement('div')
|
const searchResults = document.createElement('div');
|
||||||
searchResults.className = 'grid'
|
searchResults.className = 'grid';
|
||||||
|
|
||||||
const title = document.createElement('div')
|
const title = document.createElement('div');
|
||||||
title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)'
|
title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)';
|
||||||
title.textContent = 'Anime'
|
title.textContent = 'Anime';
|
||||||
searchResults.appendChild(title)
|
searchResults.appendChild(title);
|
||||||
|
|
||||||
results.forEach((result: QuickSearchResult) => {
|
results.forEach((result: QuickSearchResult) => {
|
||||||
searchResults.appendChild(buildSearchResultItem(result))
|
searchResults.appendChild(buildSearchResultItem(result));
|
||||||
})
|
});
|
||||||
|
|
||||||
const viewAll = document.createElement('a')
|
const viewAll = document.createElement('a');
|
||||||
viewAll.className = 'bg-(--surface-search-view-all) px-3 py-2 text-center text-[0.8rem] text-(--text-muted) no-underline hover:bg-(--panel-soft) hover:text-(--text) hover:no-underline'
|
viewAll.className =
|
||||||
viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query))
|
'bg-(--surface-search-view-all) px-3 py-2 text-center text-[0.8rem] text-(--text-muted) no-underline hover:bg-(--panel-soft) hover:text-(--text) hover:no-underline';
|
||||||
viewAll.textContent = 'View all results for ' + query
|
viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query));
|
||||||
searchResults.appendChild(viewAll)
|
viewAll.textContent = 'View all results for ' + query;
|
||||||
|
searchResults.appendChild(viewAll);
|
||||||
|
|
||||||
searchDropdown.replaceChildren(searchResults)
|
searchDropdown.replaceChildren(searchResults);
|
||||||
}
|
};
|
||||||
|
|
||||||
const fetchAndRenderQuickSearch = (query: string): void => {
|
const fetchAndRenderQuickSearch = (query: string): void => {
|
||||||
fetch('/api/search-quick?q=' + encodeURIComponent(query))
|
fetch('/api/search-quick?q=' + encodeURIComponent(query))
|
||||||
.then((res: Response) => res.json())
|
.then((res: Response) => res.json())
|
||||||
.then((results: QuickSearchResult[]) => {
|
.then((results: QuickSearchResult[]) => {
|
||||||
renderQuickSearchResults(query, results)
|
renderQuickSearchResults(query, results);
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
console.error('Search error:', err)
|
console.error('Search error:', err);
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
const onSearchInput = (event: Event): void => {
|
const onSearchInput = (event: Event): void => {
|
||||||
if (searchTimeout) {
|
if (searchTimeout) {
|
||||||
window.clearTimeout(searchTimeout)
|
window.clearTimeout(searchTimeout);
|
||||||
}
|
}
|
||||||
|
|
||||||
const target = event.target
|
const target = event.target;
|
||||||
if (!(target instanceof HTMLInputElement)) {
|
if (!(target instanceof HTMLInputElement)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const query = target.value.trim()
|
const query = target.value.trim();
|
||||||
if (query.length < 2) {
|
if (query.length < 2) {
|
||||||
clearSearchResults()
|
clearSearchResults();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchTimeout = window.setTimeout(() => {
|
searchTimeout = window.setTimeout(() => {
|
||||||
fetchAndRenderQuickSearch(query)
|
fetchAndRenderQuickSearch(query);
|
||||||
}, 300)
|
}, 300);
|
||||||
}
|
};
|
||||||
|
|
||||||
const onSearchBlur = (): void => {
|
const onSearchBlur = (): void => {
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
clearSearchResults()
|
clearSearchResults();
|
||||||
}, 200)
|
}, 200);
|
||||||
}
|
};
|
||||||
|
|
||||||
const onDocumentClick = (event: MouseEvent): void => {
|
const onDocumentClick = (event: MouseEvent): void => {
|
||||||
const target = event.target
|
const target = event.target;
|
||||||
if (!(target instanceof Element)) {
|
if (!(target instanceof Element)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!target.closest('[data-search-root]')) {
|
if (!target.closest('[data-search-root]')) {
|
||||||
clearSearchResults()
|
clearSearchResults();
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
const initQuickSearch = (): void => {
|
const initQuickSearch = (): void => {
|
||||||
if (globalWindow[searchInitializedKey]) {
|
if (globalWindow[searchInitializedKey]) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
globalWindow[searchInitializedKey] = true
|
globalWindow[searchInitializedKey] = true;
|
||||||
|
|
||||||
if (!searchInput || !searchDropdown) {
|
if (!searchInput || !searchDropdown) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
searchInput.addEventListener('input', onSearchInput)
|
searchInput.addEventListener('input', onSearchInput);
|
||||||
searchInput.addEventListener('blur', onSearchBlur)
|
searchInput.addEventListener('blur', onSearchBlur);
|
||||||
document.addEventListener('click', onDocumentClick)
|
document.addEventListener('click', onDocumentClick);
|
||||||
}
|
};
|
||||||
|
|
||||||
initQuickSearch()
|
initQuickSearch();
|
||||||
|
|||||||
@@ -1,23 +1,23 @@
|
|||||||
const initSortFilter = (): void => {
|
const initSortFilter = (): void => {
|
||||||
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
|
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
|
||||||
const orderSelect = document.getElementById('order-select') as HTMLSelectElement | null
|
const orderSelect = document.getElementById('order-select') as HTMLSelectElement | null;
|
||||||
|
|
||||||
const submitForm = (): void => {
|
const submitForm = (): void => {
|
||||||
const form = document.getElementById('sort-form') as HTMLFormElement | null
|
const form = document.getElementById('sort-form') as HTMLFormElement | null;
|
||||||
if (form) form.submit()
|
if (form) form.submit();
|
||||||
}
|
};
|
||||||
|
|
||||||
sortSelect?.addEventListener('change', () => {
|
sortSelect?.addEventListener('change', () => {
|
||||||
const input = document.getElementById('sort-input') as HTMLInputElement | null
|
const input = document.getElementById('sort-input') as HTMLInputElement | null;
|
||||||
if (input) input.value = sortSelect.value
|
if (input) input.value = sortSelect.value;
|
||||||
submitForm()
|
submitForm();
|
||||||
})
|
});
|
||||||
|
|
||||||
orderSelect?.addEventListener('change', () => {
|
orderSelect?.addEventListener('change', () => {
|
||||||
const input = document.getElementById('order-input') as HTMLInputElement | null
|
const input = document.getElementById('order-input') as HTMLInputElement | null;
|
||||||
if (input) input.value = orderSelect.value
|
if (input) input.value = orderSelect.value;
|
||||||
submitForm()
|
submitForm();
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', initSortFilter)
|
document.addEventListener('DOMContentLoaded', initSortFilter);
|
||||||
|
|||||||
@@ -1,41 +1,41 @@
|
|||||||
type Theme = 'light' | 'dark'
|
type Theme = 'light' | 'dark';
|
||||||
|
|
||||||
const STORAGE_KEY = 'theme'
|
const STORAGE_KEY = 'theme';
|
||||||
|
|
||||||
const getSavedTheme = (): Theme => {
|
const getSavedTheme = (): Theme => {
|
||||||
const raw = localStorage.getItem(STORAGE_KEY)
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
if (raw === 'light' || raw === 'dark') {
|
if (raw === 'light' || raw === 'dark') {
|
||||||
return raw
|
return raw;
|
||||||
}
|
}
|
||||||
return 'dark'
|
return 'dark';
|
||||||
}
|
};
|
||||||
|
|
||||||
const applyTheme = (theme: Theme): void => {
|
const applyTheme = (theme: Theme): void => {
|
||||||
document.documentElement.setAttribute('data-theme', theme)
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
localStorage.setItem(STORAGE_KEY, theme)
|
localStorage.setItem(STORAGE_KEY, theme);
|
||||||
}
|
};
|
||||||
|
|
||||||
const cycleTheme = (): void => {
|
const cycleTheme = (): void => {
|
||||||
const current = getSavedTheme()
|
const current = getSavedTheme();
|
||||||
const next: Theme = current === 'light' ? 'dark' : 'light'
|
const next: Theme = current === 'light' ? 'dark' : 'light';
|
||||||
applyTheme(next)
|
applyTheme(next);
|
||||||
}
|
};
|
||||||
|
|
||||||
const initTheme = (): void => {
|
const initTheme = (): void => {
|
||||||
const saved = getSavedTheme()
|
const saved = getSavedTheme();
|
||||||
applyTheme(saved)
|
applyTheme(saved);
|
||||||
|
|
||||||
document.addEventListener('click', (e) => {
|
document.addEventListener('click', e => {
|
||||||
const target = e.target as HTMLElement
|
const target = e.target as HTMLElement;
|
||||||
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null
|
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null;
|
||||||
if (btn) {
|
if (btn) {
|
||||||
cycleTheme()
|
cycleTheme();
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
if (document.readyState === 'loading') {
|
if (document.readyState === 'loading') {
|
||||||
document.addEventListener('DOMContentLoaded', initTheme)
|
document.addEventListener('DOMContentLoaded', initTheme);
|
||||||
} else {
|
} else {
|
||||||
initTheme()
|
initTheme();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,85 +1,92 @@
|
|||||||
export {}
|
export {};
|
||||||
|
|
||||||
const jstOffsetMinutes = 9 * 60
|
const jstOffsetMinutes = 9 * 60;
|
||||||
|
|
||||||
type ParsedBroadcast = {
|
type ParsedBroadcast = {
|
||||||
day: string
|
day: string;
|
||||||
hour: number
|
hour: number;
|
||||||
minute: number
|
minute: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => {
|
const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => {
|
||||||
if (!value || typeof value !== 'string') {
|
if (!value || typeof value !== 'string') {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/)
|
const match = value.trim().match(/^(\d{1,2}):(\d{2})$/);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const hour = Number.parseInt(match[1], 10)
|
const hour = Number.parseInt(match[1], 10);
|
||||||
const minute = Number.parseInt(match[2], 10)
|
const minute = Number.parseInt(match[2], 10);
|
||||||
if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
if (
|
||||||
return null
|
Number.isNaN(hour) ||
|
||||||
|
Number.isNaN(minute) ||
|
||||||
|
hour < 0 ||
|
||||||
|
hour > 23 ||
|
||||||
|
minute < 0 ||
|
||||||
|
minute > 59
|
||||||
|
) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { hour, minute }
|
return { hour, minute };
|
||||||
}
|
};
|
||||||
|
|
||||||
const isJstTimezone = (timezone: string | null): boolean => {
|
const isJstTimezone = (timezone: string | null): boolean => {
|
||||||
if (!timezone) {
|
if (!timezone) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalized = timezone.trim().toLowerCase()
|
const normalized = timezone.trim().toLowerCase();
|
||||||
return normalized === 'asia/tokyo' || normalized === 'jst'
|
return normalized === 'asia/tokyo' || normalized === 'jst';
|
||||||
}
|
};
|
||||||
|
|
||||||
const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => {
|
const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => {
|
||||||
const day = node.getAttribute('data-broadcast-day')
|
const day = node.getAttribute('data-broadcast-day');
|
||||||
const time = node.getAttribute('data-broadcast-time')
|
const time = node.getAttribute('data-broadcast-time');
|
||||||
const timezone = node.getAttribute('data-broadcast-timezone')
|
const timezone = node.getAttribute('data-broadcast-timezone');
|
||||||
|
|
||||||
if (!day || !time || !isJstTimezone(timezone)) {
|
if (!day || !time || !isJstTimezone(timezone)) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const parsedTime = parseBroadcastTime(time)
|
const parsedTime = parseBroadcastTime(time);
|
||||||
if (!parsedTime) {
|
if (!parsedTime) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute }
|
return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute };
|
||||||
}
|
};
|
||||||
|
|
||||||
const parseBroadcast = (text: string | null): ParsedBroadcast | null => {
|
const parseBroadcast = (text: string | null): ParsedBroadcast | null => {
|
||||||
if (!text || typeof text !== 'string') {
|
if (!text || typeof text !== 'string') {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i)
|
const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i);
|
||||||
if (!match) {
|
if (!match) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const day = match[1].trim()
|
const day = match[1].trim();
|
||||||
const hour = Number.parseInt(match[2], 10)
|
const hour = Number.parseInt(match[2], 10);
|
||||||
const minute = Number.parseInt(match[3], 10)
|
const minute = Number.parseInt(match[3], 10);
|
||||||
|
|
||||||
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
if (Number.isNaN(hour) || Number.isNaN(minute)) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
if (hour < 0 || hour > 23 || minute < 0 || minute > 59) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { day, hour, minute }
|
return { day, hour, minute };
|
||||||
}
|
};
|
||||||
|
|
||||||
const normalizeDay = (day: string): number | null => {
|
const normalizeDay = (day: string): number | null => {
|
||||||
const key = day.trim().toLowerCase().replace(/s$/, '')
|
const key = day.trim().toLowerCase().replace(/s$/, '');
|
||||||
const days: Record<string, number> = {
|
const days: Record<string, number> = {
|
||||||
mon: 1,
|
mon: 1,
|
||||||
monday: 1,
|
monday: 1,
|
||||||
@@ -98,153 +105,155 @@ const normalizeDay = (day: string): number | null => {
|
|||||||
saturday: 6,
|
saturday: 6,
|
||||||
sun: 0,
|
sun: 0,
|
||||||
sunday: 0,
|
sunday: 0,
|
||||||
}
|
};
|
||||||
|
|
||||||
if (typeof days[key] !== 'number') {
|
if (typeof days[key] !== 'number') {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return days[key]
|
return days[key];
|
||||||
}
|
};
|
||||||
|
|
||||||
const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => {
|
const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => {
|
||||||
const sourceMinutes = parsed.hour * 60 + parsed.minute
|
const sourceMinutes = parsed.hour * 60 + parsed.minute;
|
||||||
const diff = jstOffsetMinutes - localOffsetMinutes
|
const diff = jstOffsetMinutes - localOffsetMinutes;
|
||||||
const localTotal = sourceMinutes - diff
|
const localTotal = sourceMinutes - diff;
|
||||||
|
|
||||||
const dayShift = Math.floor(localTotal / 1440)
|
const dayShift = Math.floor(localTotal / 1440);
|
||||||
const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440
|
const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440;
|
||||||
const localHour = Math.floor(normalizedMinutes / 60)
|
const localHour = Math.floor(normalizedMinutes / 60);
|
||||||
const localMinute = normalizedMinutes % 60
|
const localMinute = normalizedMinutes % 60;
|
||||||
|
|
||||||
const sourceDayIndex = normalizeDay(parsed.day)
|
const sourceDayIndex = normalizeDay(parsed.day);
|
||||||
if (sourceDayIndex === null) {
|
if (sourceDayIndex === null) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const localDayIndex = ((sourceDayIndex + dayShift) % 7 + 7) % 7
|
const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7;
|
||||||
const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][localDayIndex]
|
const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][
|
||||||
|
localDayIndex
|
||||||
|
];
|
||||||
|
|
||||||
const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}`
|
const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}`;
|
||||||
return `${localDay} at ${time} (Local)`
|
return `${localDay} at ${time} (Local)`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => {
|
const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => {
|
||||||
const targetDay = normalizeDay(parsed.day)
|
const targetDay = normalizeDay(parsed.day);
|
||||||
if (targetDay === null) {
|
if (targetDay === null) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const now = new Date()
|
const now = new Date();
|
||||||
const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000)
|
const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000);
|
||||||
|
|
||||||
const currentDay = jstNow.getUTCDay()
|
const currentDay = jstNow.getUTCDay();
|
||||||
const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes()
|
const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes();
|
||||||
const targetMinuteOfDay = parsed.hour * 60 + parsed.minute
|
const targetMinuteOfDay = parsed.hour * 60 + parsed.minute;
|
||||||
|
|
||||||
let dayDelta = (targetDay - currentDay + 7) % 7
|
let dayDelta = (targetDay - currentDay + 7) % 7;
|
||||||
if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) {
|
if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) {
|
||||||
dayDelta = 7
|
dayDelta = 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay)
|
const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay);
|
||||||
return new Date(now.getTime() + minuteDelta * 60 * 1000)
|
return new Date(now.getTime() + minuteDelta * 60 * 1000);
|
||||||
}
|
};
|
||||||
|
|
||||||
const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => {
|
const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => {
|
||||||
if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') {
|
if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') {
|
||||||
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
|
||||||
return formatter.format(value, unit)
|
return formatter.format(value, unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
const suffix = value === 1 ? unit : `${unit}s`
|
const suffix = value === 1 ? unit : `${unit}s`;
|
||||||
return `in ${value} ${suffix}`
|
return `in ${value} ${suffix}`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const relativeText = (target: Date): string => {
|
const relativeText = (target: Date): string => {
|
||||||
const diffMs = target.getTime() - Date.now()
|
const diffMs = target.getTime() - Date.now();
|
||||||
if (diffMs <= 0) {
|
if (diffMs <= 0) {
|
||||||
return 'soon'
|
return 'soon';
|
||||||
}
|
}
|
||||||
|
|
||||||
const minutes = Math.ceil(diffMs / 60000)
|
const minutes = Math.ceil(diffMs / 60000);
|
||||||
if (minutes < 60) {
|
if (minutes < 60) {
|
||||||
return formatRelative(minutes, 'minute')
|
return formatRelative(minutes, 'minute');
|
||||||
}
|
}
|
||||||
|
|
||||||
const hours = Math.ceil(minutes / 60)
|
const hours = Math.ceil(minutes / 60);
|
||||||
if (hours < 36) {
|
if (hours < 36) {
|
||||||
return formatRelative(hours, 'hour')
|
return formatRelative(hours, 'hour');
|
||||||
}
|
}
|
||||||
|
|
||||||
const days = Math.ceil(hours / 24)
|
const days = Math.ceil(hours / 24);
|
||||||
return formatRelative(days, 'day')
|
return formatRelative(days, 'day');
|
||||||
}
|
};
|
||||||
|
|
||||||
const localDateTimeText = (date: Date): string => {
|
const localDateTimeText = (date: Date): string => {
|
||||||
const formatter = new Intl.DateTimeFormat(undefined, {
|
const formatter = new Intl.DateTimeFormat(undefined, {
|
||||||
weekday: 'short',
|
weekday: 'short',
|
||||||
hour: '2-digit',
|
hour: '2-digit',
|
||||||
minute: '2-digit',
|
minute: '2-digit',
|
||||||
})
|
});
|
||||||
return formatter.format(date)
|
return formatter.format(date);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => {
|
const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => {
|
||||||
const card = node.closest('[data-notification-content]')
|
const card = node.closest('[data-notification-content]');
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextNode = card.querySelector('[data-next-airing]')
|
const nextNode = card.querySelector('[data-next-airing]');
|
||||||
if (!(nextNode instanceof HTMLElement)) {
|
if (!(nextNode instanceof HTMLElement)) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextDate = nextAiringUTC(parsed)
|
const nextDate = nextAiringUTC(parsed);
|
||||||
if (!nextDate) {
|
if (!nextDate) {
|
||||||
nextNode.remove()
|
nextNode.remove();
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`
|
nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`;
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateNode = (node: Element, localOffsetMinutes: number): void => {
|
const updateNode = (node: Element, localOffsetMinutes: number): void => {
|
||||||
const card = node.closest('[data-notification-content]')
|
const card = node.closest('[data-notification-content]');
|
||||||
const nextNode = card ? card.querySelector('[data-next-airing]') : null
|
const nextNode = card ? card.querySelector('[data-next-airing]') : null;
|
||||||
|
|
||||||
const structured = parseFromStructuredAttrs(node)
|
const structured = parseFromStructuredAttrs(node);
|
||||||
const source = node.getAttribute('data-jst-text')
|
const source = node.getAttribute('data-jst-text');
|
||||||
const parsed = structured || parseBroadcast(source)
|
const parsed = structured || parseBroadcast(source);
|
||||||
if (!parsed) {
|
if (!parsed) {
|
||||||
if (nextNode instanceof HTMLElement) {
|
if (nextNode instanceof HTMLElement) {
|
||||||
nextNode.remove()
|
nextNode.remove();
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const converted = convertToLocal(parsed, localOffsetMinutes)
|
const converted = convertToLocal(parsed, localOffsetMinutes);
|
||||||
if (!converted) {
|
if (!converted) {
|
||||||
if (nextNode instanceof HTMLElement) {
|
if (nextNode instanceof HTMLElement) {
|
||||||
nextNode.remove()
|
nextNode.remove();
|
||||||
}
|
}
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
node.textContent = converted
|
node.textContent = converted;
|
||||||
updateNextAiring(node, parsed)
|
updateNextAiring(node, parsed);
|
||||||
}
|
};
|
||||||
|
|
||||||
const updateAll = (): void => {
|
const updateAll = (): void => {
|
||||||
const localOffsetMinutes = -new Date().getTimezoneOffset()
|
const localOffsetMinutes = -new Date().getTimezoneOffset();
|
||||||
const nodes = document.querySelectorAll('[data-jst-text]')
|
const nodes = document.querySelectorAll('[data-jst-text]');
|
||||||
nodes.forEach((node) => updateNode(node, localOffsetMinutes))
|
nodes.forEach(node => updateNode(node, localOffsetMinutes));
|
||||||
}
|
};
|
||||||
|
|
||||||
const initTimezoneConversion = (): void => {
|
const initTimezoneConversion = (): void => {
|
||||||
document.addEventListener('DOMContentLoaded', updateAll)
|
document.addEventListener('DOMContentLoaded', updateAll);
|
||||||
document.body.addEventListener('htmx:afterSwap', updateAll)
|
document.body.addEventListener('htmx:afterSwap', updateAll);
|
||||||
}
|
};
|
||||||
|
|
||||||
initTimezoneConversion()
|
initTimezoneConversion();
|
||||||
|
|||||||
@@ -1,55 +1,55 @@
|
|||||||
export {}
|
export {};
|
||||||
|
|
||||||
interface ToastOptions {
|
interface ToastOptions {
|
||||||
message: string
|
message: string;
|
||||||
duration?: number
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toastContainer = (): HTMLElement => {
|
const toastContainer = (): HTMLElement => {
|
||||||
let container = document.getElementById('toast-container')
|
let container = document.getElementById('toast-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
container = document.createElement('div')
|
container = document.createElement('div');
|
||||||
container.id = 'toast-container'
|
container.id = 'toast-container';
|
||||||
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2'
|
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2';
|
||||||
document.body.appendChild(container)
|
document.body.appendChild(container);
|
||||||
}
|
}
|
||||||
return container
|
return container;
|
||||||
}
|
};
|
||||||
|
|
||||||
const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
|
const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
|
||||||
const container = toastContainer()
|
const container = toastContainer();
|
||||||
const template = document.getElementById('toast-template') as HTMLTemplateElement | null
|
const template = document.getElementById('toast-template') as HTMLTemplateElement | null;
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
return
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const toast = template.content.cloneNode(true) as HTMLElement
|
const toast = template.content.cloneNode(true) as HTMLElement;
|
||||||
const messageEl = toast.querySelector('.toast-message')
|
const messageEl = toast.querySelector('.toast-message');
|
||||||
const closeBtn = toast.querySelector('.toast-close')
|
const closeBtn = toast.querySelector('.toast-close');
|
||||||
|
|
||||||
if (messageEl) {
|
if (messageEl) {
|
||||||
messageEl.textContent = message
|
messageEl.textContent = message;
|
||||||
}
|
}
|
||||||
|
|
||||||
closeBtn?.addEventListener('click', () => toast.remove())
|
closeBtn?.addEventListener('click', () => toast.remove());
|
||||||
|
|
||||||
container.appendChild(toast)
|
container.appendChild(toast);
|
||||||
|
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
toast.classList.remove('translate-y-2', 'opacity-0')
|
toast.classList.remove('translate-y-2', 'opacity-0');
|
||||||
})
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
toast.classList.add('translate-y-2', 'opacity-0')
|
toast.classList.add('translate-y-2', 'opacity-0');
|
||||||
setTimeout(() => toast.remove(), 300)
|
setTimeout(() => toast.remove(), 300);
|
||||||
}, duration)
|
}, duration);
|
||||||
}
|
};
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
showToast: typeof showToast
|
showToast: typeof showToast;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.showToast = showToast
|
window.showToast = showToast;
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
export const parseClassList = (value: string | null): string[] => {
|
export const parseClassList = (value: string | null): string[] => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return value
|
return value
|
||||||
.split(' ')
|
.split(' ')
|
||||||
.map((entry: string): string => entry.trim())
|
.map((entry: string): string => entry.trim())
|
||||||
.filter((entry: string): boolean => entry.length > 0)
|
.filter((entry: string): boolean => entry.length > 0);
|
||||||
}
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user