feat: add prettier and eslint with pre-commit hook

This commit is contained in:
2026-05-10 19:23:53 +02:00
parent be9fbe0f64
commit 3703bbfcfe
33 changed files with 1643 additions and 1245 deletions

11
.prettierrc Normal file
View 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
View File

@@ -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
View 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",
},
},
];

View File

@@ -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' },
},
},
} }

View File

@@ -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"
}, },

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
} };

View File

@@ -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);
} }
} }
} };

View File

@@ -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();
} }
} };

View File

@@ -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));
} };

View File

@@ -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);
}) });
} };

View File

@@ -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();
} }
} }
} }
}) });
} };

View File

@@ -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();
}) });

View File

@@ -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();
});
};

View File

@@ -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)));
}) });
} };

View File

@@ -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);
}) });
} };

View File

@@ -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();
} }
}) });
} };

View File

@@ -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);
}) });
} };

View File

@@ -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);
} };

View File

@@ -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);
});
};

View File

@@ -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;
} };

View File

@@ -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}%`;
} };

View File

@@ -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;
} }

View File

@@ -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] ?? ''

View File

@@ -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();

View File

@@ -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);

View File

@@ -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();
} }

View File

@@ -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();

View File

@@ -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;

View File

@@ -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);
} };