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": {
"@tailwindcss/cli": "^4.2.4",
"@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",
"prettier": "^3.8.3",
"tailwindcss": "^4.2.4",
"typescript": "^6.0.3",
},
},
},
"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/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=="],
"@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/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=="],
"@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=="],
"@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=="],
"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=="],
"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=="],
"ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
"imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="],
"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=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"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-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=="],
"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-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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"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=="],
"synckit": ["synckit@0.11.12", "", { "dependencies": { "@pkgr/core": "^0.2.9" } }, "sha512-Bh7QjT8/SuKUIfObSXNHNSK6WHo6J1tHCqJsuaFDP7gP0fkzSfTxI8y85JrppZ0h8l0maIgc2tfuZQ6/t3GtnQ=="],
"tailwindcss": ["tailwindcss@4.2.4", "", {}, "sha512-HhKppgO81FQof5m6TEnuBWCZGgfRAWbaeOaGT00KOy/Pf/j6oUihdvBpA7ltCeAvZpFhW3j0PTclkxsd4IXYDA=="],
"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=="],
"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/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/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",
"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" }
}
}
'$schema': 'https://json.schemastore.org/lefthook.json',
'pre-commit':
{
'commands':
{
'prettier': { 'run': 'bunx prettier . --write' },
'eslint': { 'run': 'bunx eslint . --fix' },
},
},
'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",
"private": true,
"type": "module",
"scripts": {
"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",
"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",
"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": {
"@tailwindcss/cli": "^4.2.4",
"@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",
"prettier": "^3.8.3",
"tailwindcss": "^4.2.4",
"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 openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'))
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'))
const openClasses = parseClassList(menu.getAttribute('data-dropdown-open-classes'));
const closedClasses = parseClassList(menu.getAttribute('data-dropdown-closed-classes'));
if (isOpen) {
menu.classList.remove(...closedClasses)
menu.classList.add(...openClasses)
return
menu.classList.remove(...closedClasses);
menu.classList.add(...openClasses);
return;
}
menu.classList.remove(...openClasses)
menu.classList.add(...closedClasses)
}
menu.classList.remove(...openClasses);
menu.classList.add(...closedClasses);
};
const setWatchlistDropdownState = (isOpen: boolean): void => {
const dropdown = document.getElementById('watchlist-dropdown')
const dropdown = document.getElementById('watchlist-dropdown');
if (!dropdown) {
return
return;
}
dropdown.classList.toggle('open', isOpen)
const menu = dropdown.querySelector('[data-dropdown-menu]')
dropdown.classList.toggle('open', isOpen);
const menu = dropdown.querySelector('[data-dropdown-menu]');
if (menu instanceof HTMLElement) {
setDropdownMenuState(menu, isOpen)
setDropdownMenuState(menu, isOpen);
}
}
};
const toggleWatchlistDropdown = (): void => {
const dropdown = document.getElementById('watchlist-dropdown')
const dropdown = document.getElementById('watchlist-dropdown');
if (!dropdown) {
return
return;
}
setWatchlistDropdownState(!dropdown.classList.contains('open'))
}
setWatchlistDropdownState(!dropdown.classList.contains('open'));
};
const closeDropdownOnOutsideClick = (event: MouseEvent): void => {
const dropdown = document.getElementById('watchlist-dropdown')
const dropdown = document.getElementById('watchlist-dropdown');
if (!dropdown) {
return
return;
}
const target = event.target
const target = event.target;
if (!(target instanceof Node)) {
return
return;
}
if (!dropdown.contains(target)) {
setWatchlistDropdownState(false)
setWatchlistDropdownState(false);
}
}
};
const initWatchlistDropdown = (): void => {
;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown
document.addEventListener('click', closeDropdownOnOutsideClick)
}
(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleWatchlistDropdown;
document.addEventListener('click', closeDropdownOnOutsideClick);
};
initWatchlistDropdown()
initWatchlistDropdown();

View File

@@ -1,25 +1,25 @@
const dedupe = (): void => {
const seen = new Set<string>()
const elements = document.querySelectorAll('[data-id]')
const seen = new Set<string>();
const elements = document.querySelectorAll('[data-id]');
elements.forEach((item) => {
const id = item.getAttribute('data-id')
elements.forEach(item => {
const id = item.getAttribute('data-id');
if (!id) {
return
return;
}
if (seen.has(id)) {
item.remove()
item.remove();
} else {
seen.add(id)
seen.add(id);
}
})
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', dedupe)
} else {
dedupe()
}
document.addEventListener('DOMContentLoaded', dedupe);
} else {
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 group = clickedTab.closest('[data-tab-group="discover"]')
const group = clickedTab.closest('[data-tab-group="discover"]');
if (!group) {
return
return;
}
const triggers = group.querySelectorAll('[data-tab-trigger]')
triggers.forEach((tab) => {
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'))
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'))
tab.classList.remove(...activeClasses)
tab.classList.add(...inactiveClasses)
})
const triggers = group.querySelectorAll('[data-tab-trigger]');
triggers.forEach(tab => {
const activeClasses = parseClassList(tab.getAttribute('data-tab-active-classes'));
const inactiveClasses = parseClassList(tab.getAttribute('data-tab-inactive-classes'));
tab.classList.remove(...activeClasses);
tab.classList.add(...inactiveClasses);
});
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'))
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'))
clickedTab.classList.remove(...inactiveClasses)
clickedTab.classList.add(...activeClasses)
}
const activeClasses = parseClassList(clickedTab.getAttribute('data-tab-active-classes'));
const inactiveClasses = parseClassList(clickedTab.getAttribute('data-tab-inactive-classes'));
clickedTab.classList.remove(...inactiveClasses);
clickedTab.classList.add(...activeClasses);
};
const onDiscoverTabClick = (event: MouseEvent): void => {
const target = event.target
const target = event.target;
if (!(target instanceof Element)) {
return
return;
}
const trigger = target.closest('[data-tab-trigger]')
const trigger = target.closest('[data-tab-trigger]');
if (!trigger) {
return
return;
}
setActiveDiscoverTab(trigger)
}
setActiveDiscoverTab(trigger);
};
const initDiscoverTabs = (): void => {
document.addEventListener('click', onDiscoverTabClick)
}
document.addEventListener('click', onDiscoverTabClick);
};
initDiscoverTabs()
initDiscoverTabs();

View File

@@ -1,66 +1,66 @@
class UIDropdown extends HTMLElement {
isOpen: boolean = false
contentEl: HTMLElement | null = null
isClosing: boolean = false
isOpen: boolean = false;
contentEl: HTMLElement | null = null;
isClosing: boolean = false;
constructor() {
super()
this.toggle = this.toggle.bind(this)
this.handleClickOutside = this.handleClickOutside.bind(this)
super();
this.toggle = this.toggle.bind(this);
this.handleClickOutside = this.handleClickOutside.bind(this);
}
connectedCallback(): void {
const trigger = this.querySelector('[data-trigger]')
this.contentEl = this.querySelector('[data-content]')
const trigger = this.querySelector('[data-trigger]');
this.contentEl = this.querySelector('[data-content]');
if (trigger) {
trigger.addEventListener('click', this.toggle)
trigger.addEventListener('click', this.toggle);
}
document.addEventListener('click', this.handleClickOutside)
document.addEventListener('click', this.handleClickOutside);
}
disconnectedCallback(): void {
const trigger = this.querySelector('[data-trigger]')
const trigger = this.querySelector('[data-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 {
if (this.isClosing) {
return
return;
}
this.isOpen = !this.isOpen
this.isOpen = !this.isOpen;
if (this.contentEl) {
if (this.isOpen) {
this.contentEl.classList.remove('hidden')
this.contentEl.classList.remove('hidden');
} else {
this.contentEl.classList.add('hidden')
this.contentEl.classList.add('hidden');
}
}
}
close(): void {
if (this.isClosing) {
return
return;
}
this.isClosing = true
this.isOpen = false
this.isClosing = true;
this.isOpen = false;
if (this.contentEl) {
this.contentEl.classList.add('hidden')
this.contentEl.classList.add('hidden');
}
setTimeout(() => {
this.isClosing = false
}, 100)
this.isClosing = false;
}, 100);
}
handleClickOutside(event: MouseEvent): void {
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 => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
export const showControls = (): void => {
state.container.classList.add('show-controls')
window.clearTimeout(state.playerControlsTimeout)
state.container.classList.add('show-controls');
window.clearTimeout(state.playerControlsTimeout);
state.playerControlsTimeout = window.setTimeout(() => {
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 => {
if (state.video.duration <= 0) return
state.video.currentTime = Math.max(0, Math.min(state.video.duration, state.video.currentTime + delta))
showControls()
}
if (state.video.duration <= 0) return;
state.video.currentTime = Math.max(
0,
Math.min(state.video.duration, state.video.currentTime + delta)
);
showControls();
};
export const togglePlayPause = (): void => {
if (state.video.paused) {
state.video.play()
state.video.play();
} else {
state.video.pause()
state.video.pause();
}
}
};
export const toggleMute = (): void => {
if (state.video.muted || state.video.volume === 0) {
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1
state.video.muted = false
state.video.volume = restored
const restored = state.lastKnownVolume > 0 ? state.lastKnownVolume : 1;
state.video.muted = false;
state.video.volume = restored;
} else {
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume
state.video.muted = true
state.lastKnownVolume = state.video.volume > 0 ? state.video.volume : state.lastKnownVolume;
state.video.muted = true;
}
}
};
export const setVolume = (value: number): void => {
state.video.volume = Math.max(0, Math.min(1, value))
state.video.muted = value === 0
if (value > 0) state.lastKnownVolume = value
}
state.video.volume = Math.max(0, Math.min(1, value));
state.video.muted = value === 0;
if (value > 0) state.lastKnownVolume = value;
};
export const toggleFullscreen = (): void => {
if (document.fullscreenElement) {
document.exitFullscreen()
return
document.exitFullscreen();
return;
}
state.container.requestFullscreen?.()
}
state.container.requestFullscreen?.();
};
export const syncVolumeUI = (): void => {
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls()
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100)
const { volumeRange, volumeUnderline, iconVolume, iconMuted } = getControls();
const value = state.video.muted ? 0 : Math.round(state.video.volume * 100);
if (volumeRange) {
volumeRange.value = String(value)
volumeRange.style.setProperty('--volume-percent', `${value}%`)
volumeRange.value = String(value);
volumeRange.style.setProperty('--volume-percent', `${value}%`);
}
if (volumeUnderline) volumeUnderline.style.height = `${value}%`
updateMuteIcons(state.video.muted || state.video.volume === 0)
}
if (volumeUnderline) volumeUnderline.style.height = `${value}%`;
updateMuteIcons(state.video.muted || state.video.volume === 0);
};
interface Controls {
playPause: HTMLButtonElement | null
muteBtn: HTMLButtonElement | null
volumePanel: HTMLElement | null
volumeRange: HTMLInputElement | null
volumeUnderline: HTMLElement | null
backwardBtn: HTMLButtonElement | null
forwardBtn: HTMLButtonElement | null
fullscreenBtn: HTMLButtonElement | null
iconPlay: SVGElement | null
iconPause: SVGElement | null
iconVolume: SVGElement | null
iconMuted: SVGElement | null
skipSegmentBtn: HTMLButtonElement | null
subtitleText: HTMLElement | null
autoplayBtn: HTMLInputElement | null
playPause: HTMLButtonElement | null;
muteBtn: HTMLButtonElement | null;
volumePanel: HTMLElement | null;
volumeRange: HTMLInputElement | null;
volumeUnderline: HTMLElement | null;
backwardBtn: HTMLButtonElement | null;
forwardBtn: HTMLButtonElement | null;
fullscreenBtn: HTMLButtonElement | null;
iconPlay: SVGElement | null;
iconPause: SVGElement | null;
iconVolume: SVGElement | null;
iconMuted: SVGElement | null;
skipSegmentBtn: HTMLButtonElement | null;
subtitleText: HTMLElement | null;
autoplayBtn: HTMLInputElement | null;
}
let controlsCache: Controls | null = null
let controlsCache: Controls | null = null;
const getControls = (): Controls => {
if (controlsCache) return controlsCache
const c = state.container
if (controlsCache) return controlsCache;
const c = state.container;
controlsCache = {
playPause: c.querySelector('[data-play-pause]'),
muteBtn: c.querySelector('[data-mute]'),
@@ -106,64 +109,88 @@ const getControls = (): Controls => {
skipSegmentBtn: c.querySelector('[data-skip]'),
subtitleText: c.querySelector('[data-subtitle-text]'),
autoplayBtn: document.querySelector('[data-autoplay]'),
}
return controlsCache
}
};
return controlsCache;
};
const updatePlayPauseIcons = (isPlaying: boolean): void => {
const { iconPlay, iconPause } = getControls()
iconPlay?.classList.toggle('hidden', isPlaying)
iconPause?.classList.toggle('hidden', !isPlaying)
}
const { iconPlay, iconPause } = getControls();
iconPlay?.classList.toggle('hidden', isPlaying);
iconPause?.classList.toggle('hidden', !isPlaying);
};
const updateMuteIcons = (isMuted: boolean): void => {
const { iconVolume, iconMuted } = getControls()
iconVolume?.classList.toggle('hidden', isMuted)
iconMuted?.classList.toggle('hidden', !isMuted)
}
const { iconVolume, iconMuted } = getControls();
iconVolume?.classList.toggle('hidden', isMuted);
iconMuted?.classList.toggle('hidden', !isMuted);
};
export const setupControls = (): void => {
const {
playPause, muteBtn, volumePanel, volumeRange,
backwardBtn, forwardBtn, fullscreenBtn, skipSegmentBtn,
} = getControls()
playPause,
muteBtn,
volumePanel,
volumeRange,
backwardBtn,
forwardBtn,
fullscreenBtn,
skipSegmentBtn,
} = getControls();
playPause?.addEventListener('click', () => { togglePlayPause(); showControls() })
state.video.addEventListener('click', () => { togglePlayPause(); showControls() })
playPause?.addEventListener('click', () => {
togglePlayPause();
showControls();
});
state.video.addEventListener('click', () => {
togglePlayPause();
showControls();
});
muteBtn?.addEventListener('click', () => { toggleMute(); showControls() })
muteBtn?.addEventListener('click', () => {
toggleMute();
showControls();
});
volumeRange?.addEventListener('input', () => {
const value = Number(volumeRange.value) / 100
setVolume(value)
showControls()
})
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'))
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'))
const value = Number(volumeRange.value) / 100;
setVolume(value);
showControls();
});
volumeRange?.addEventListener('pointerdown', () => volumePanel?.classList.add('is-dragging'));
window.addEventListener('pointerup', () => volumePanel?.classList.remove('is-dragging'));
backwardBtn?.addEventListener('click', () => seekBy(-10))
forwardBtn?.addEventListener('click', () => seekBy(10))
backwardBtn?.addEventListener('click', () => seekBy(-10));
forwardBtn?.addEventListener('click', () => seekBy(10));
fullscreenBtn?.addEventListener('click', () => { toggleFullscreen(); showControls() })
fullscreenBtn?.addEventListener('click', () => {
toggleFullscreen();
showControls();
});
skipSegmentBtn?.addEventListener('click', () => {
if (!state.activeSkipSegment) return
state.video.currentTime = state.activeSkipSegment.end + 0.01
showControls()
})
if (!state.activeSkipSegment) return;
state.video.currentTime = state.activeSkipSegment.end + 0.01;
showControls();
});
document.addEventListener('fullscreenchange', () => {
state.isFullscreen = !!document.fullscreenElement
state.container.classList.toggle('fullscreen', state.isFullscreen)
if (state.isFullscreen) showControls()
})
state.isFullscreen = !!document.fullscreenElement;
state.container.classList.toggle('fullscreen', state.isFullscreen);
if (state.isFullscreen) showControls();
});
state.video.addEventListener('play', () => { updatePlayPauseIcons(true); showControls() })
state.video.addEventListener('pause', () => { updatePlayPauseIcons(false); showControls() })
state.video.addEventListener('volumechange', syncVolumeUI)
state.video.addEventListener('play', () => {
updatePlayPauseIcons(true);
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)
syncVolumeUI()
}
updatePlayPauseIcons(false);
syncVolumeUI();
};

View File

@@ -1,9 +1,9 @@
import DOMPurify from 'dompurify'
import { state } from '../state'
import DOMPurify from 'dompurify';
import { state } from '../state';
export const completeAnime = async (episodeNumber: number): Promise<void> => {
if (state.completionSent || !state.malID || !episodeNumber) return
state.completionSent = true
if (state.completionSent || !state.malID || !episodeNumber) return;
state.completionSent = true;
try {
const res = await fetch('/api/watch-complete', {
@@ -11,27 +11,27 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
headers: { 'Content-Type': 'application/json' },
keepalive: true,
body: JSON.stringify({ mal_id: state.malID, episode: episodeNumber }),
})
});
if (!res.ok) {
state.completionSent = false
state.completionSent = false;
if (state.completionAttempts < 2) {
state.completionAttempts++
setTimeout(() => completeAnime(episodeNumber), 1000)
state.completionAttempts++;
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) {
trigger.textContent = 'Completed '
const caret = document.createElement('span')
caret.className = 'text-xs'
caret.textContent = '▾'
trigger.appendChild(caret)
trigger.textContent = 'Completed ';
const caret = document.createElement('span');
caret.className = 'text-xs';
caret.textContent = '▾';
trigger.appendChild(caret);
}
const dropdown = document.getElementById('watch-status-dropdown')
const dropdown = document.getElementById('watch-status-dropdown');
if (dropdown) {
const payload = {
anime_id: String(state.malID),
@@ -41,27 +41,29 @@ export const completeAnime = async (episodeNumber: number): Promise<void> => {
anime_image: state.container.dataset.animeImage ?? '',
status: 'completed',
airing: state.container.dataset.animeAiring === 'true',
}
};
fetch('/api/watchlist', {
method: 'POST',
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))}`,
credentials: 'same-origin',
}).then(async res => {
if (!res.ok) return
const html = await res.text()
const wrapper = document.createElement('span')
wrapper.id = 'watch-status-dropdown'
wrapper.innerHTML = DOMPurify.sanitize(html)
dropdown.replaceWith(wrapper)
}).catch(() => {})
})
.then(async res => {
if (!res.ok) return;
const html = await res.text();
const wrapper = document.createElement('span');
wrapper.id = 'watch-status-dropdown';
wrapper.innerHTML = DOMPurify.sanitize(html);
dropdown.replaceWith(wrapper);
})
.catch(() => {});
}
} catch {
state.completionSent = false
state.completionSent = false;
if (state.completionAttempts < 2) {
state.completionAttempts++
setTimeout(() => completeAnime(episodeNumber), 1000)
state.completionAttempts++;
setTimeout(() => completeAnime(episodeNumber), 1000);
}
}
}
};

View File

@@ -1,95 +1,97 @@
import { state } from '../state'
import { SkipSegment } from '../types'
import { displayTimeFromAbsolute } from '../timeline'
import { resolveActiveSegments, renderSegments } from '../skip/segments'
import { updateSubtitleOptions } from '../subtitles'
import { updateQualityOptions } from '../quality'
import { updateModeButtons } from '../mode'
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui'
import { markEpisodeTransition } from '../progress'
import { state } from '../state';
import { SkipSegment } from '../types';
import { displayTimeFromAbsolute } from '../timeline';
import { resolveActiveSegments, renderSegments } from '../skip/segments';
import { updateSubtitleOptions } from '../subtitles';
import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode';
import { updateOverlay, isAutoplayEnabled, updateEpisodeHighlight, switchEpisodeRange } from './ui';
import { markEpisodeTransition } from '../progress';
export const goToNextEpisode = async (): Promise<void> => {
const currentEp = Number.parseInt(state.currentEpisode, 10)
if (!currentEp) return
const currentEp = Number.parseInt(state.currentEpisode, 10);
if (!currentEp) return;
if (state.totalEpisodes > 0 && currentEp >= state.totalEpisodes) {
import('./complete').then(m => m.completeAnime(currentEp))
return
import('./complete').then(m => m.completeAnime(currentEp));
return;
}
if (!isAutoplayEnabled()) return
if (!isAutoplayEnabled()) return;
const nextEp = currentEp + 1
markEpisodeTransition(nextEp)
const nextEp = currentEp + 1;
markEpisodeTransition(nextEp);
try {
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`)
const res = await fetch(`/api/watch/episode/${state.malID}/${nextEp}`);
if (!res.ok) {
sessionStorage.setItem('mal:autoplay-next', 'true')
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
window.location.href = url.toString()
return
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
return;
}
const data = await res.json()
const data = await res.json();
state.modeSources = data.mode_sources ?? {}
state.availableModes = data.available_modes ?? []
state.modeSources = data.mode_sources ?? {};
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) {
sessionStorage.setItem('mal:autoplay-next', 'true')
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
window.location.href = url.toString()
return
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
return;
}
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`
state.video.load()
if (!state.video.paused) state.video.play().catch(() => {})
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(fallback)}&token=${encodeURIComponent(state.modeSources[fallback].token)}`;
state.video.load();
if (!state.video.paused) state.video.play().catch(() => {});
state.currentEpisode = String(nextEp)
state.pendingSeekTime = null
state.completionSent = false
state.completionAttempts = 0
state.activeSubtitles = []
state.currentEpisode = String(nextEp);
state.pendingSeekTime = null;
state.completionSent = false;
state.completionAttempts = 0;
state.activeSubtitles = [];
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons()
updateOverlay(state.currentEpisode, data.episode_title ?? '')
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
updateOverlay(state.currentEpisode, data.episode_title ?? '');
if (data.segments?.length) {
state.parsedSegments = data.segments
.map((s: SkipSegment) => ({ ...s, start: Number(s.start) || 0, end: Number(s.end) || 0 }))
.filter((s: SkipSegment) => s.end > s.start)
resolveActiveSegments()
renderSegments()
.filter((s: SkipSegment) => s.end > s.start);
resolveActiveSegments();
renderSegments();
}
state.episodeList?.querySelectorAll('[data-episode-id]').forEach(el => el.classList.remove('bg-accent/20'))
const newListEl = state.episodeList?.querySelector(`[data-episode-id="${nextEp}"]`)
newListEl?.classList.add('bg-accent/20')
state.episodeList
?.querySelectorAll('[data-episode-id]')
.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) {
state.episodeGrid.querySelectorAll('[data-episode-id]').forEach(el => {
el.classList.remove('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent')
})
switchEpisodeRange(Math.floor((nextEp - 1) / 100))
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`)
newGridEl?.classList.add('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));
const newGridEl = state.episodeGrid.querySelector(`[data-episode-id="${nextEp}"]`);
newGridEl?.classList.add('bg-accent/20', 'ring-2', 'ring-accent', 'text-accent');
}
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
history.pushState(null, '', url.toString())
state.transitionEpisode = null
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
history.pushState(null, '', url.toString());
state.transitionEpisode = null;
} catch {
sessionStorage.setItem('mal:autoplay-next', 'true')
const url = new URL(window.location.href)
url.searchParams.set('ep', String(nextEp))
window.location.href = url.toString()
sessionStorage.setItem('mal:autoplay-next', 'true');
const url = new URL(window.location.href);
url.searchParams.set('ep', String(nextEp));
window.location.href = url.toString();
}
}
};

View File

@@ -1,35 +1,38 @@
import { state } from '../state'
import { state } from '../state';
export const setupThumbnails = (): void => {
fetch(`/api/watch/thumbnails/${state.malID}`)
.then(res => res.json())
.then((data: Array<{ mal_id: number; url: string; title?: string }>) => {
if (!state.episodeList) return
if (!state.episodeList) return;
data.forEach(item => {
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`)
if (!card) return
const card = state.episodeList.querySelector(`[data-episode-id="${item.mal_id}"]`);
if (!card) return;
if (item.url) {
const imgContainer = card.querySelector('.relative.aspect-video')
const imgContainer = card.querySelector('.relative.aspect-video');
if (imgContainer) {
let img = imgContainer.querySelector('img')
let img = imgContainer.querySelector('img');
if (!img) {
img = document.createElement('img')
img.className = 'h-full w-full object-cover transition-transform group-hover:scale-105'
img.loading = 'lazy'
imgContainer.querySelector('.flex.h-full.w-full.items-center.justify-center')?.remove()
imgContainer.prepend(img)
img = document.createElement('img');
img.className =
'h-full w-full object-cover transition-transform group-hover:scale-105';
img.loading = 'lazy';
imgContainer
.querySelector('.flex.h-full.w-full.items-center.justify-center')
?.remove();
imgContainer.prepend(img);
}
img.src = item.url
img.alt = item.title ?? `Episode ${item.mal_id}`
img.src = item.url;
img.alt = item.title ?? `Episode ${item.mal_id}`;
}
}
if (item.title) {
const titleEl = card.querySelector('[data-episode-title]')
if (titleEl) titleEl.textContent = item.title
const titleEl = card.querySelector('[data-episode-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 { updateSubtitleOptions } from '../subtitles'
import { updateQualityOptions } from '../quality'
import { updateModeButtons } from '../mode'
import { state } from '../state';
import { updateSubtitleOptions } from '../subtitles';
import { updateQualityOptions } from '../quality';
import { updateModeButtons } from '../mode';
export const setupAutoplayButton = (): void => {
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null
if (!btn) return
btn.checked = localStorage.getItem('mal:autoplay-enabled') !== 'false'
}
const btn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
if (!btn) return;
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 => {
if (!state.videoOverlay) return
const p = state.videoOverlay.querySelector('p')
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`)
}
if (!state.videoOverlay) return;
const p = state.videoOverlay.querySelector('p');
p && (p.textContent = title ? `Episode ${episode}, ${title}` : `Episode ${episode}`);
};
const getEpisodeEls = () => {
const grid = state.episodeGrid
const list = state.episodeList
const grid = state.episodeGrid;
const list = state.episodeList;
return {
gridEls: grid ? Array.from(grid.querySelectorAll('[data-episode-id]')) : [],
listEls: list ? Array.from(list.querySelectorAll('[data-episode-id]')) : [],
}
}
};
};
export const updateEpisodeHighlight = (num: number): void => {
const { gridEls, listEls } = getEpisodeEls()
;[...gridEls, ...listEls].forEach(el => el.classList.remove('ring-2', 'ring-accent', 'bg-accent/20', 'text-accent'))
const { gridEls, listEls } = getEpisodeEls();
[...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 listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`)
gridEl?.classList.add('ring-2', 'ring-accent')
listEl?.classList.add('ring-2', 'ring-accent')
;(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
const gridEl = state.episodeGrid?.querySelector(`[data-episode-id="${num}"]`);
const listEl = state.episodeList?.querySelector(`[data-episode-id="${num}"]`);
gridEl?.classList.add('ring-2', 'ring-accent');
listEl?.classList.add('ring-2', 'ring-accent');
(gridEl ?? listEl)?.scrollIntoView({ behavior: 'smooth', block: 'center' });
};
export const switchEpisodeRange = (idx: number): void => {
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null
if (!dropdown) return
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[]
const target = btns[idx]
if (!target) return
const dropdown = state.container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
if (!dropdown) return;
const btns = Array.from(dropdown.querySelectorAll('.episode-range-btn')) as HTMLButtonElement[];
const target = btns[idx];
if (!target) return;
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10)
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10)
const start = Number.parseInt(target.dataset.rangeStart ?? '1', 10);
const end = Number.parseInt(target.dataset.rangeEnd ?? '100', 10);
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null
if (label) label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`
const label = dropdown.querySelector('[data-dropdown-label]') as HTMLElement | null;
if (label)
label.textContent = `${String(start).padStart(2, '0')}-${String(end).padStart(2, '0')}`;
state.episodeGrid?.querySelectorAll('[data-episode-id]').forEach(el => {
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10)
el.classList.toggle('hidden', n < start || n > end)
})
}
const n = Number.parseInt((el as HTMLElement).dataset.episodeId ?? '0', 10);
el.classList.toggle('hidden', n < start || n > end);
});
};

View File

@@ -1,58 +1,72 @@
import { state } from './state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay, absoluteTimeFromRatio, getBounds } from './timeline'
import { showControls, toggleMute, togglePlayPause, toggleFullscreen, seekBy, setVolume, formatTime } from './controls'
import { state } from './state';
import {
displayTimeFromAbsolute,
absoluteTimeFromDisplay,
absoluteTimeFromRatio,
getBounds,
} from './timeline';
import {
showControls,
toggleMute,
togglePlayPause,
toggleFullscreen,
seekBy,
setVolume,
formatTime,
} from './controls';
export const setupKeyboard = (): void => {
document.addEventListener('keydown', (e) => {
const target = e.target as HTMLElement
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return
document.addEventListener('keydown', e => {
const target = e.target as HTMLElement;
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)
return;
switch (e.code) {
case 'Space':
case 'KeyK':
e.preventDefault()
togglePlayPause()
showControls()
break
e.preventDefault();
togglePlayPause();
showControls();
break;
case 'ArrowLeft':
case 'KeyJ':
e.preventDefault()
seekBy(-10)
break
e.preventDefault();
seekBy(-10);
break;
case 'ArrowRight':
case 'KeyL':
e.preventDefault()
seekBy(10)
break
e.preventDefault();
seekBy(10);
break;
case 'ArrowUp':
e.preventDefault()
setVolume(state.video.volume + 0.05)
showControls()
break
e.preventDefault();
setVolume(state.video.volume + 0.05);
showControls();
break;
case 'ArrowDown':
e.preventDefault()
setVolume(state.video.volume - 0.05)
showControls()
break
e.preventDefault();
setVolume(state.video.volume - 0.05);
showControls();
break;
case 'KeyM':
e.preventDefault()
toggleMute()
showControls()
break
e.preventDefault();
toggleMute();
showControls();
break;
case 'KeyF':
e.preventDefault()
toggleFullscreen()
showControls()
break
e.preventDefault();
toggleFullscreen();
showControls();
break;
default:
if (/^\d$/.test(e.key)) {
const b = getBounds()
const b = getBounds();
if (b.duration > 0) {
e.preventDefault()
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10)
showControls()
e.preventDefault();
state.video.currentTime = absoluteTimeFromRatio(parseInt(e.key, 10) / 10);
showControls();
}
}
}
})
}
});
};

View File

@@ -1,193 +1,214 @@
import { state, initState } from './state'
import { invalidateBounds, updateTimeline } from './timeline'
import { setupControls, showControls } from './controls'
import { setupKeyboard } from './keyboard'
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles'
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip'
import { setupQuality, updateQualityOptions } from './quality'
import { setupMode, updateModeButtons } from './mode'
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui'
import { goToNextEpisode } from './episodes/nav'
import { resolveActiveSegments, renderSegments } from './skip/segments'
import { setupThumbnails } from './episodes/thumbnails'
import { markEpisodeTransition, setupProgress } from './progress'
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline'
import { formatTime } from './controls'
import { state, initState } from './state';
import { invalidateBounds, updateTimeline } from './timeline';
import { setupControls, showControls } from './controls';
import { setupKeyboard } from './keyboard';
import { setupSubtitles, updateSubtitleOptions, updateSubtitleRender } from './subtitles';
import { setupSkip, updateSkipButton, updateAutoSkipButton } from './skip';
import { setupQuality, updateQualityOptions } from './quality';
import { setupMode, updateModeButtons } from './mode';
import { setupAutoplayButton, updateEpisodeHighlight, switchEpisodeRange } from './episodes/ui';
import { goToNextEpisode } from './episodes/nav';
import { resolveActiveSegments, renderSegments } from './skip/segments';
import { setupThumbnails } from './episodes/thumbnails';
import { markEpisodeTransition, setupProgress } from './progress';
import { absoluteTimeFromRatio, getBounds, displayTimeFromAbsolute } from './timeline';
import { formatTime } from './controls';
let initialized = false
let initialized = false;
const hidePreviewPopover = (): void => {
state.previewPopover?.classList.remove('block')
state.previewPopover?.classList.add('hidden')
state.previewPopover!.style.left = '0px'
}
state.previewPopover?.classList.remove('block');
state.previewPopover?.classList.add('hidden');
state.previewPopover!.style.left = '0px';
};
const showPreviewPopover = (): void => {
state.previewPopover?.classList.remove('hidden')
state.previewPopover?.classList.add('block')
}
state.previewPopover?.classList.remove('hidden');
state.previewPopover?.classList.add('block');
};
const updatePreviewUI = (ratio: number): void => {
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null
if (!progressWrap || !state.previewPopover || !state.previewTime) { hidePreviewPopover(); return }
const b = getBounds()
if (b.duration <= 0) { hidePreviewPopover(); return }
const progressWrap = state.container.querySelector('[data-progress-wrap]') as HTMLElement | null;
if (!progressWrap || !state.previewPopover || !state.previewTime) {
hidePreviewPopover();
return;
}
const b = getBounds();
if (b.duration <= 0) {
hidePreviewPopover();
return;
}
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)))
state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration)));
const barWidth = progressWrap.clientWidth
if (barWidth <= 0) { hidePreviewPopover(); return }
const barWidth = progressWrap.clientWidth;
if (barWidth <= 0) {
hidePreviewPopover();
return;
}
showPreviewPopover()
const popoverWidth = state.previewPopover.offsetWidth || 72
state.previewPopover.style.left = `${Math.max(popoverWidth / 2, Math.min(barWidth - popoverWidth / 2, ratio * barWidth))}px`
}
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
const container = document.querySelector('[data-video-player]') as HTMLElement | null;
if (!container || initialized) return;
initialized = true;
initState(container)
initState(container);
const loading = container.querySelector('[data-loading]') as HTMLElement | null
const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null
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
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)}` : ''}`
state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`;
}
setupProgress()
setupControls()
setupKeyboard()
setupSkip()
setupSubtitles()
setupQuality()
setupMode()
setupProgress();
setupControls();
setupKeyboard();
setupSkip();
setupSubtitles();
setupQuality();
setupMode();
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons()
setupAutoplayButton()
updateAutoSkipButton()
showControls()
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
setupAutoplayButton();
updateAutoSkipButton();
showControls();
state.video.addEventListener('loadedmetadata', () => {
loading && (loading.style.display = 'none')
invalidateBounds()
loading && (loading.style.display = 'none');
invalidateBounds();
resolveActiveSegments()
renderSegments()
resolveActiveSegments();
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) {
state.video.currentTime = startTime
state.video.currentTime = startTime;
}
if (state.pendingSeekTime !== null) {
state.video.currentTime = state.pendingSeekTime
state.pendingSeekTime = null
state.video.currentTime = state.pendingSeekTime;
state.pendingSeekTime = null;
}
if (state.shouldAutoPlay) state.video.play().catch(() => {})
if (state.shouldAutoPlay) state.video.play().catch(() => {});
updateTimeline(state.video.currentTime)
updateSkipButton(state.video.currentTime)
})
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
});
state.video.addEventListener('waiting', () => { loading && (loading.style.display = 'flex') })
state.video.addEventListener('playing', () => { loading && (loading.style.display = 'none') })
state.video.addEventListener('progress', () => { updateTimeline(state.video.currentTime) })
state.video.addEventListener('waiting', () => {
loading && (loading.style.display = 'flex');
});
state.video.addEventListener('playing', () => {
loading && (loading.style.display = 'none');
});
state.video.addEventListener('progress', () => {
updateTimeline(state.video.currentTime);
});
state.video.addEventListener('timeupdate', () => {
updateTimeline(state.video.currentTime)
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime))
updateSkipButton(state.video.currentTime)
})
updateTimeline(state.video.currentTime);
updateSubtitleRender(displayTimeFromAbsolute(state.video.currentTime));
updateSkipButton(state.video.currentTime);
});
state.video.addEventListener('ended', () => { goToNextEpisode() })
state.video.addEventListener('ended', () => {
goToNextEpisode();
});
progressWrap?.addEventListener('mousedown', (e) => {
state.isScrubbing = true
const rect = progressWrap.getBoundingClientRect()
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
updateTimeline(state.video.currentTime)
updateSkipButton(state.video.currentTime)
showControls()
})
progressWrap?.addEventListener('mousedown', e => {
state.isScrubbing = true;
const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
showControls();
});
progressWrap?.addEventListener('mousemove', (e) => {
const rect = progressWrap.getBoundingClientRect()
updatePreviewUI(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
})
progressWrap?.addEventListener('mousemove', e => {
const rect = progressWrap.getBoundingClientRect();
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) => {
if (!state.isScrubbing || !progressWrap) return
const rect = progressWrap.getBoundingClientRect()
state.video.currentTime = absoluteTimeFromRatio(Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width)))
updateTimeline(state.video.currentTime)
updateSkipButton(state.video.currentTime)
})
window.addEventListener('mousemove', e => {
if (!state.isScrubbing || !progressWrap) return;
const rect = progressWrap.getBoundingClientRect();
state.video.currentTime = absoluteTimeFromRatio(
Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
);
updateTimeline(state.video.currentTime);
updateSkipButton(state.video.currentTime);
});
container.addEventListener('click', (e) => {
const anchor = (e.target as Node).parentElement?.closest('a[href]')
if (!(anchor instanceof HTMLAnchorElement)) return
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean)
container.addEventListener('click', e => {
const anchor = (e.target as Node).parentElement?.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return;
const parts = new URL(anchor.href, location.origin).pathname.split('/').filter(Boolean);
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 dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null
let searchDebounce: number | undefined
const searchInput = document.querySelector('[data-episode-search]') as HTMLInputElement | null;
const dropdown = container.querySelector('[data-episode-dropdown]') as HTMLElement | null;
let searchDebounce: number | undefined;
if (searchInput) {
searchInput.addEventListener('input', () => {
clearTimeout(searchDebounce)
clearTimeout(searchDebounce);
searchDebounce = window.setTimeout(() => {
const val = searchInput.value.replace(/\D/g, '')
const val = searchInput.value.replace(/\D/g, '');
if (!val) {
const cur = Number.parseInt(state.currentEpisode, 10)
switchEpisodeRange(Math.floor((cur - 1) / 100))
updateEpisodeHighlight(cur)
return
const cur = Number.parseInt(state.currentEpisode, 10);
switchEpisodeRange(Math.floor((cur - 1) / 100));
updateEpisodeHighlight(cur);
return;
}
const ep = Number.parseInt(val, 10)
if (!ep || ep <= 0) return
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500
const clamped = Math.min(ep, maxEp)
searchInput.value = String(clamped)
const ep = Number.parseInt(val, 10);
if (!ep || ep <= 0) return;
const maxEp = state.totalEpisodes > 0 ? state.totalEpisodes : 500;
const clamped = Math.min(ep, maxEp);
searchInput.value = String(clamped);
if (state.episodeGrid) {
switchEpisodeRange(Math.floor((clamped - 1) / 100))
updateEpisodeHighlight(clamped)
switchEpisodeRange(Math.floor((clamped - 1) / 100));
updateEpisodeHighlight(clamped);
}
}, 300)
})
}, 300);
});
}
if (dropdown) {
dropdown.querySelectorAll('.episode-range-btn').forEach(btn => {
btn.addEventListener('click', () => {
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10)
switchEpisodeRange(idx)
})
})
const idx = Number.parseInt((btn as HTMLElement).dataset.rangeIndex ?? '0', 10);
switchEpisodeRange(idx);
});
});
}
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) => {
const target = (e as CustomEvent).detail?.target as HTMLElement | null
if (target?.querySelector('[data-video-player]')) initPlayer()
})
const target = (e as CustomEvent).detail?.target as HTMLElement | null;
if (target?.querySelector('[data-video-player]')) initPlayer();
});

View File

@@ -1,66 +1,79 @@
import { state } from './state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
import { showControls } from './controls'
import { updateSubtitleOptions } from './subtitles'
import { updateQualityOptions } from './quality'
import { ModeSource } from './types'
import { state } from './state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
import { showControls } from './controls';
import { updateSubtitleOptions } from './subtitles';
import { updateQualityOptions } from './quality';
import { ModeSource } from './types';
const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.modeSources[mode]
if (!src?.token) return ''
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
return url
}
const src = state.modeSources[mode];
if (!src?.token) return '';
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
return url;
};
const loadVideo = (url: string): void => {
if (!url) return
const wasPlaying = !state.video.paused
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
state.video.src = url
state.video.load()
state.pendingSeekTime = prevTime
if (wasPlaying) state.video.play().catch(() => {})
}
if (!url) return;
const wasPlaying = !state.video.paused;
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
state.video.src = url;
state.video.load();
state.pendingSeekTime = prevTime;
if (wasPlaying) state.video.play().catch(() => {});
};
export const switchMode = (mode: string): void => {
if (!state.availableModes.includes(mode) || mode === state.currentMode) return
state.currentMode = mode
localStorage.setItem('player-audio-mode', mode)
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value))
updateSubtitleOptions()
updateQualityOptions()
updateModeButtons()
}
if (!state.availableModes.includes(mode) || mode === state.currentMode) return;
state.currentMode = mode;
localStorage.setItem('player-audio-mode', mode);
loadVideo(streamUrlForMode(mode, state.container.querySelector('[data-quality-select]')?.value));
updateSubtitleOptions();
updateQualityOptions();
updateModeButtons();
};
export const updateModeButtons = (): void => {
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null
const m = state.currentMode
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null;
const sub = state.container.querySelector('[data-mode-sub]') as HTMLButtonElement | null;
const m = state.currentMode;
dub?.classList.toggle('text-accent', m === 'dub')
dub?.classList.toggle('text-white', m !== 'dub')
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'))
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'))
dub && (dub.disabled = !state.availableModes.includes('dub'))
dub?.classList.toggle('text-accent', m === 'dub');
dub?.classList.toggle('text-white', m !== 'dub');
dub?.classList.toggle('opacity-50', !state.availableModes.includes('dub'));
dub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('dub'));
dub && (dub.disabled = !state.availableModes.includes('dub'));
sub?.classList.toggle('text-accent', m === 'sub')
sub?.classList.toggle('text-white', m !== 'sub')
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'))
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'))
sub && (sub.disabled = !state.availableModes.includes('sub'))
}
sub?.classList.toggle('text-accent', m === 'sub');
sub?.classList.toggle('text-white', m !== 'sub');
sub?.classList.toggle('opacity-50', !state.availableModes.includes('sub'));
sub?.classList.toggle('cursor-not-allowed', !state.availableModes.includes('sub'));
sub && (sub.disabled = !state.availableModes.includes('sub'));
};
export const setupMode = (): void => {
const dub = state.container.querySelector('[data-mode-dub]') as HTMLButtonElement | null
const sub = state.container.querySelector('[data-mode-sub]') 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;
dub?.addEventListener('click', () => { if (state.availableModes.includes('dub')) { switchMode('dub'); showControls() } })
sub?.addEventListener('click', () => { if (state.availableModes.includes('sub')) { switchMode('sub'); showControls() } })
dub?.addEventListener('click', () => {
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
autoplayBtn?.addEventListener('change', (e) => {
localStorage.setItem('mal:autoplay-enabled', (e.target as HTMLInputElement).checked ? 'true' : 'false')
showControls()
})
}
const autoplayBtn = document.querySelector('[data-autoplay]') as HTMLInputElement | null;
autoplayBtn?.addEventListener('change', e => {
localStorage.setItem(
'mal:autoplay-enabled',
(e.target as HTMLInputElement).checked ? 'true' : 'false'
);
showControls();
});
};

View File

@@ -1,70 +1,89 @@
import { state } from './state'
import { displayTimeFromAbsolute } from './timeline'
import { state } from './state';
import { displayTimeFromAbsolute } from './timeline';
const buildPayload = (episode: number, seconds: number) => JSON.stringify({
const buildPayload = (episode: number, seconds: number) =>
JSON.stringify({
mal_id: state.malID,
episode,
time_seconds: seconds,
})
});
const sendBeacon = (payload: string) => {
if (!navigator.sendBeacon) return false
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }))
return true
}
if (!navigator.sendBeacon) return false;
navigator.sendBeacon('/api/watch-progress', new Blob([payload], { type: 'application/json' }));
return true;
};
export const saveProgress = async (): Promise<void> => {
if (!state.malID || state.video.currentTime < 1) return
const episode = Number.parseInt(state.currentEpisode, 10)
if (!episode) return
if (!state.malID || state.video.currentTime < 1) return;
const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return;
const safeTime = displayTimeFromAbsolute(state.video.currentTime)
if (state.lastSavedProgress.episode === state.currentEpisode &&
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5) return
const safeTime = displayTimeFromAbsolute(state.video.currentTime);
if (
state.lastSavedProgress.episode === state.currentEpisode &&
Math.abs(state.lastSavedProgress.seconds - safeTime) < 5
)
return;
const payload = buildPayload(episode, safeTime)
const payload = buildPayload(episode, safeTime);
try {
const res = await fetch('/api/watch-progress', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload })
if (!res.ok) return
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime }
const res = await fetch('/api/watch-progress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: payload,
});
if (!res.ok) return;
state.lastSavedProgress = { episode: state.currentEpisode, seconds: safeTime };
} catch {}
}
};
const scheduleProgressSave = (): void => {
if (state.progressSaveTimer !== undefined) return
if (state.progressSaveTimer !== undefined) return;
state.progressSaveTimer = window.setTimeout(() => {
state.progressSaveTimer = undefined
saveProgress()
}, 30000)
}
state.progressSaveTimer = undefined;
saveProgress();
}, 30000);
};
export const markEpisodeTransition = (episodeNumber: number): void => {
if (!state.malID || !episodeNumber) return
if (state.progressSaveTimer !== undefined) { window.clearTimeout(state.progressSaveTimer); state.progressSaveTimer = undefined }
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(() => {})
if (!state.malID || !episodeNumber) return;
if (state.progressSaveTimer !== undefined) {
window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined;
}
}
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 => {
state.video.addEventListener('timeupdate', () => {
scheduleProgressSave()
})
scheduleProgressSave();
});
state.video.addEventListener('pause', () => {
window.clearTimeout(state.progressSaveTimer)
state.progressSaveTimer = undefined
saveProgress()
})
window.clearTimeout(state.progressSaveTimer);
state.progressSaveTimer = undefined;
saveProgress();
});
window.addEventListener('mouseup', () => { state.isScrubbing = false; saveProgress() })
window.addEventListener('mouseup', () => {
state.isScrubbing = false;
saveProgress();
});
window.addEventListener('beforeunload', () => {
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return
const episode = Number.parseInt(state.currentEpisode, 10)
if (!episode) return
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)))
})
}
if (state.transitionEpisode !== null || state.completionSent || !state.malID) return;
const episode = Number.parseInt(state.currentEpisode, 10);
if (!episode) return;
sendBeacon(buildPayload(episode, displayTimeFromAbsolute(state.video.currentTime)));
});
};

View File

@@ -1,59 +1,59 @@
import { state } from './state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline'
import { state } from './state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from './timeline';
const streamUrlForMode = (mode: string, quality?: string): string => {
const src = state.modeSources[mode]
if (!src?.token) return ''
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`
return url
}
const src = state.modeSources[mode];
if (!src?.token) return '';
let url = `${state.streamURL}?mode=${encodeURIComponent(mode)}&token=${encodeURIComponent(src.token)}`;
if (quality && quality !== 'best') url += `&quality=${encodeURIComponent(quality)}`;
return url;
};
const loadVideo = (url: string): void => {
if (!url) return
const wasPlaying = !state.video.paused
const prevTime = displayTimeFromAbsolute(state.video.currentTime)
state.video.src = url
state.video.load()
state.pendingSeekTime = prevTime
if (wasPlaying) state.video.play().catch(() => {})
}
if (!url) return;
const wasPlaying = !state.video.paused;
const prevTime = displayTimeFromAbsolute(state.video.currentTime);
state.video.src = url;
state.video.load();
state.pendingSeekTime = prevTime;
if (wasPlaying) state.video.play().catch(() => {});
};
export const switchQuality = (quality: string): void => {
const url = streamUrlForMode(state.currentMode, quality)
if (!url) return
localStorage.setItem('mal:preferred-quality', quality)
loadVideo(url)
}
const url = streamUrlForMode(state.currentMode, quality);
if (!url) return;
localStorage.setItem('mal:preferred-quality', quality);
loadVideo(url);
};
export const updateQualityOptions = (): void => {
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
if (!select) return
const qualities = state.modeSources[state.currentMode]?.qualities ?? []
select.innerHTML = ''
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
if (!select) return;
const qualities = state.modeSources[state.currentMode]?.qualities ?? [];
select.innerHTML = '';
const best = document.createElement('option')
best.value = 'best'
best.textContent = 'Auto / Best'
select.appendChild(best)
const best = document.createElement('option');
best.value = 'best';
best.textContent = 'Auto / Best';
select.appendChild(best);
qualities.forEach(q => {
const opt = document.createElement('option')
opt.value = q
opt.textContent = q
select.appendChild(opt)
})
const opt = document.createElement('option');
opt.value = q;
opt.textContent = q;
select.appendChild(opt);
});
const preferred = localStorage.getItem('mal:preferred-quality') || 'best'
select.value = qualities.includes(preferred) ? preferred : 'best'
const preferred = localStorage.getItem('mal:preferred-quality') || 'best';
select.value = qualities.includes(preferred) ? preferred : 'best';
const wrapper = select.parentElement
wrapper?.classList.toggle('hidden', qualities.length === 0)
}
const wrapper = select.parentElement;
wrapper?.classList.toggle('hidden', qualities.length === 0);
};
export const setupQuality = (): void => {
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null
select?.addEventListener('change', (e) => {
switchQuality((e.target as HTMLSelectElement).value)
})
}
const select = state.container.querySelector('[data-quality-select]') as HTMLSelectElement | null;
select?.addEventListener('change', e => {
switchQuality((e.target as HTMLSelectElement).value);
});
};

View File

@@ -1,50 +1,53 @@
import { state } from '../state'
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline'
import { showControls } from '../controls'
import { resolveActiveSegments, renderSegments } from './segments'
import { state } from '../state';
import { displayTimeFromAbsolute, absoluteTimeFromDisplay } from '../timeline';
import { showControls } from '../controls';
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 => {
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null
const displayTime = displayTimeFromAbsolute(currentTime)
const btn = state.container.querySelector('[data-skip]') as HTMLButtonElement | null;
const displayTime = displayTimeFromAbsolute(currentTime);
const segment = state.activeSegments.find(s => {
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02))
return displayTime >= s.start + delay && displayTime < s.end
})
const delay = Math.min(1, Math.max(0.25, (s.end - s.start) * 0.02));
return displayTime >= s.start + delay && displayTime < s.end;
});
if (!segment) {
state.activeSkipSegment = null
btn?.classList.add('hidden')
return
state.activeSkipSegment = null;
btn?.classList.add('hidden');
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) {
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01)
return
state.video.currentTime = absoluteTimeFromDisplay(segment.end + 0.01);
return;
}
state.activeSkipSegment = segment
state.activeSkipSegment = segment;
if (btn) {
btn.textContent = skipLabel(segment.type)
btn.title = skipLabel(segment.type)
btn.classList.remove('hidden')
btn.textContent = skipLabel(segment.type);
btn.title = skipLabel(segment.type);
btn.classList.remove('hidden');
}
}
};
export const updateAutoSkipButton = (): void => {
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true')
}
const btn = document.querySelector('[data-autoskip]') as HTMLInputElement | null;
btn && (btn.checked = localStorage.getItem('mal:autoskip-enabled') === 'true');
};
export const setupSkip = (): void => {
document.addEventListener('change', (e) => {
const target = e.target as HTMLElement
document.addEventListener('change', e => {
const target = e.target as HTMLElement;
if (target.hasAttribute('data-autoskip')) {
localStorage.setItem('mal:autoskip-enabled', (target as HTMLInputElement).checked ? 'true' : 'false')
showControls()
localStorage.setItem(
'mal:autoskip-enabled',
(target as HTMLInputElement).checked ? 'true' : 'false'
);
showControls();
}
})
}
});
};

View File

@@ -1,44 +1,46 @@
import { SkipSegment } from '../types'
import { state } from '../state'
import { SkipSegment } from '../types';
import { state } from '../state';
const MIN_SEGMENT_DURATION = 20
const MAX_SEGMENT_DURATION = 240
const MAX_INTRO_START = 180
const MIN_OUTRO_START_RATIO = 0.5
const MIN_SEGMENT_DURATION = 20;
const MAX_SEGMENT_DURATION = 240;
const MAX_INTRO_START = 180;
const MIN_OUTRO_START_RATIO = 0.5;
export const resolveActiveSegments = (): void => {
const bounds = state.video.duration
if (bounds <= 0) { state.activeSegments = []; return }
const bounds = state.video.duration;
if (bounds <= 0) {
state.activeSegments = [];
return;
}
state.activeSegments = state.parsedSegments
.filter(s => {
const len = s.end - s.start
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
state.activeSegments = state.parsedSegments.filter(s => {
const len = s.end - s.start;
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.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') {
return s.start >= bounds * MIN_OUTRO_START_RATIO
return s.start >= bounds * MIN_OUTRO_START_RATIO;
}
return false
})
}
return false;
});
};
export const renderSegments = (): void => {
const track = state.container.querySelector('[data-segments]') as HTMLElement | null
if (!track) return
track.innerHTML = ''
const track = state.container.querySelector('[data-segments]') as HTMLElement | null;
if (!track) return;
track.innerHTML = '';
const bounds = state.video.duration
if (bounds <= 0) return
const bounds = state.video.duration;
if (bounds <= 0) return;
state.activeSegments.forEach(s => {
const bar = document.createElement('div')
bar.className = 'absolute top-0 h-full bg-white/80'
bar.style.left = `${(s.start / bounds) * 100}%`
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`
track.appendChild(bar)
})
}
const bar = document.createElement('div');
bar.className = 'absolute top-0 h-full bg-white/80';
bar.style.left = `${(s.start / bounds) * 100}%`;
bar.style.width = `${((s.end - s.start) / bounds) * 100}%`;
track.appendChild(bar);
});
};

View File

@@ -1,43 +1,43 @@
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types'
import { q, qs, dataset } from '../q'
import { ModeSource, SkipSegment, SubtitleCue, SubtitleTrack, ActiveSegment } from './types';
import { q, qs, dataset } from '../q';
export interface PlayerState {
container: HTMLElement
video: HTMLVideoElement
progress: HTMLElement
scrubber: HTMLElement
buffered: HTMLElement
timeDisplay: HTMLElement
durationDisplay: HTMLElement
modeSources: Record<string, ModeSource>
availableModes: string[]
currentMode: string
currentEpisode: string
totalEpisodes: number
malID: number
streamURL: string
initialStreamToken: string
shouldAutoPlay: boolean
parsedSegments: SkipSegment[]
activeSegments: ActiveSegment[]
activeSkipSegment: ActiveSegment | null
activeSubtitles: SubtitleCue[]
currentSubtitleTracks: SubtitleTrack[]
lastKnownVolume: number
pendingSeekTime: number | null
isScrubbing: boolean
isFullscreen: boolean
playerControlsTimeout: number | undefined
progressSaveTimer: number | undefined
transitionEpisode: number | null
completionSent: boolean
completionAttempts: number
lastSavedProgress: { episode: string; seconds: number }
episodeGrid: HTMLElement | null
episodeList: HTMLElement | null
previewPopover: HTMLElement | null
previewTime: HTMLElement | null
videoOverlay: HTMLElement | null
container: HTMLElement;
video: HTMLVideoElement;
progress: HTMLElement;
scrubber: HTMLElement;
buffered: HTMLElement;
timeDisplay: HTMLElement;
durationDisplay: HTMLElement;
modeSources: Record<string, ModeSource>;
availableModes: string[];
currentMode: string;
currentEpisode: string;
totalEpisodes: number;
malID: number;
streamURL: string;
initialStreamToken: string;
shouldAutoPlay: boolean;
parsedSegments: SkipSegment[];
activeSegments: ActiveSegment[];
activeSkipSegment: ActiveSegment | null;
activeSubtitles: SubtitleCue[];
currentSubtitleTracks: SubtitleTrack[];
lastKnownVolume: number;
pendingSeekTime: number | null;
isScrubbing: boolean;
isFullscreen: boolean;
playerControlsTimeout: number | undefined;
progressSaveTimer: number | undefined;
transitionEpisode: number | null;
completionSent: boolean;
completionAttempts: number;
lastSavedProgress: { episode: string; seconds: number };
episodeGrid: HTMLElement | null;
episodeList: HTMLElement | null;
previewPopover: HTMLElement | null;
previewTime: HTMLElement | null;
videoOverlay: HTMLElement | null;
}
export const state: PlayerState = {
@@ -77,50 +77,53 @@ export const state: PlayerState = {
previewPopover: null,
previewTime: null,
videoOverlay: null,
}
};
export const initState = (c: HTMLElement): void => {
state.container = c
state.video = q<HTMLVideoElement>(c, 'video')!
state.progress = q<HTMLElement>(c, '[data-progress]')
state.scrubber = q<HTMLElement>(c, '[data-scrubber]')
state.buffered = q<HTMLElement>(c, '[data-buffered]')
state.timeDisplay = q<HTMLElement>(c, '[data-time]')
state.durationDisplay = q<HTMLElement>(c, '[data-duration]')
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]')
state.previewTime = q<HTMLElement>(c, '[data-preview-time]')
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]')
state.container = c;
state.video = q<HTMLVideoElement>(c, 'video')!;
state.progress = q<HTMLElement>(c, '[data-progress]');
state.scrubber = q<HTMLElement>(c, '[data-scrubber]');
state.buffered = q<HTMLElement>(c, '[data-buffered]');
state.timeDisplay = q<HTMLElement>(c, '[data-time]');
state.durationDisplay = q<HTMLElement>(c, '[data-duration]');
state.previewPopover = q<HTMLElement>(c, '[data-preview-popover]');
state.previewTime = q<HTMLElement>(c, '[data-preview-time]');
state.videoOverlay = q<HTMLElement>(c, '[data-video-overlay]');
state.malID = Number.parseInt(dataset(c, 'malId'), 10)
state.currentEpisode = dataset(c, 'currentEpisode') || '1'
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10)
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream'
state.initialStreamToken = dataset(c, 'streamToken') || ''
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true'
sessionStorage.removeItem('mal:autoplay-next')
state.malID = Number.parseInt(dataset(c, 'malId'), 10);
state.currentEpisode = dataset(c, 'currentEpisode') || '1';
state.totalEpisodes = Number.parseInt(dataset(c, 'totalEpisodes'), 10);
state.streamURL = dataset(c, 'streamUrl') || '/watch/proxy/stream';
state.initialStreamToken = dataset(c, 'streamToken') || '';
state.shouldAutoPlay = sessionStorage.getItem('mal:autoplay-next') === 'true';
sessionStorage.removeItem('mal:autoplay-next');
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]')
state.episodeList = qs<HTMLElement>('[data-episode-list]')
state.episodeGrid = qs<HTMLElement>('[data-episode-grid]');
state.episodeList = qs<HTMLElement>('[data-episode-list]');
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.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[])
state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record<string, ModeSource>);
state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]);
const backendInitialMode = dataset(c, 'initialMode') || 'dub'
const storedMode = localStorage.getItem('player-audio-mode')
const initialMode = (storedMode && state.availableModes.includes(storedMode)) ? storedMode : backendInitialMode
const fallbackMode = Object.keys(state.modeSources).find(
m => state.modeSources[m]?.token
)
state.currentMode =
(state.modeSources[initialMode]?.token) ? initialMode :
(fallbackMode ?? state.availableModes[0] ?? 'dub')
const backendInitialMode = dataset(c, 'initialMode') || 'dub';
const storedMode = localStorage.getItem('player-audio-mode');
const initialMode =
storedMode && state.availableModes.includes(storedMode) ? storedMode : backendInitialMode;
const fallbackMode = Object.keys(state.modeSources).find(m => state.modeSources[m]?.token);
state.currentMode = state.modeSources[initialMode]?.token
? initialMode
: (fallbackMode ?? state.availableModes[0] ?? 'dub');
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[])
const segments = safeJson(dataset(c, 'segments'), [] as SkipSegment[]);
state.parsedSegments = segments
.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 { state } from '../state'
import { parseVtt } from './vtt'
import { SubtitleCue, SubtitleTrack } from '../types';
import { state } from '../state';
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 src = state.modeSources[state.currentMode]
if (!src?.subtitles) return []
const src = state.modeSources[state.currentMode];
if (!src?.subtitles) return [];
return src.subtitles
.map(t => ({ lang: (t.lang || 'unknown').toLowerCase(), label: t.lang || 'Unknown', url: proxyUrl(t.token) }))
.filter(t => t.url !== '')
}
.map(t => ({
lang: (t.lang || 'unknown').toLowerCase(),
label: t.lang || 'Unknown',
url: proxyUrl(t.token),
}))
.filter(t => t.url !== '');
};
const hideSubtitleText = (): void => {
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
if (!el) return
el.textContent = ''
el.classList.remove('block')
el.classList.add('hidden')
}
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
if (!el) return;
el.textContent = '';
el.classList.remove('block');
el.classList.add('hidden');
};
const loadSubtitle = async (url: string): Promise<SubtitleCue[]> => {
try {
const res = await fetch(url)
if (!res.ok) return []
return parseVtt(await res.text())
} catch { return [] }
}
const res = await fetch(url);
if (!res.ok) return [];
return parseVtt(await res.text());
} catch {
return [];
}
};
export const updateSubtitleOptions = (): void => {
const select = state.container.querySelector('[data-subtitle-select]') as HTMLSelectElement | null
if (!select) return
state.currentSubtitleTracks = subtitlesForMode()
select.innerHTML = ''
const select = state.container.querySelector(
'[data-subtitle-select]'
) as HTMLSelectElement | null;
if (!select) return;
state.currentSubtitleTracks = subtitlesForMode();
select.innerHTML = '';
const none = document.createElement('option')
none.value = 'none'
none.textContent = 'Off'
select.appendChild(none)
select.value = 'none'
const none = document.createElement('option');
none.value = 'none';
none.textContent = 'Off';
select.appendChild(none);
select.value = 'none';
state.currentSubtitleTracks.forEach((t, i) => {
const opt = document.createElement('option')
opt.value = String(i)
opt.textContent = t.label
select.appendChild(opt)
})
const opt = document.createElement('option');
opt.value = String(i);
opt.textContent = t.label;
select.appendChild(opt);
});
const wrapper = select.parentElement
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0)
state.activeSubtitles = []
hideSubtitleText()
}
const wrapper = select.parentElement;
wrapper?.classList.toggle('hidden', state.currentSubtitleTracks.length === 0);
state.activeSubtitles = [];
hideSubtitleText();
};
export const updateSubtitleRender = (time: number): void => {
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null
if (!el) return
if (!state.activeSubtitles.length) { hideSubtitleText(); return }
const el = state.container.querySelector('[data-subtitle-text]') as HTMLElement | null;
if (!el) return;
if (!state.activeSubtitles.length) {
hideSubtitleText();
return;
}
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end)
if (!cue) { hideSubtitleText(); return }
const cue = state.activeSubtitles.find(c => time >= c.start && time <= c.end);
if (!cue) {
hideSubtitleText();
return;
}
el.textContent = cue.text
el.classList.remove('hidden')
}
el.textContent = cue.text;
el.classList.remove('hidden');
};
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 () => {
if (select.value === 'none') { state.activeSubtitles = []; hideSubtitleText(); return }
const track = state.currentSubtitleTracks[Number(select.value)]
if (!track) { state.activeSubtitles = []; return }
state.activeSubtitles = await loadSubtitle(track.url)
})
}
if (select.value === 'none') {
state.activeSubtitles = [];
hideSubtitleText();
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 => {
const parts = raw.trim().split(':')
if (parts.length < 2) return 0
const secPart = parts.pop()!
const minPart = parts.pop()!
const hourPart = parts.pop() ?? '0'
return (Number(hourPart) * 3600) + (Number(minPart) * 60) + Number(secPart.replace(',', '.'))
}
const parts = raw.trim().split(':');
if (parts.length < 2) return 0;
const secPart = parts.pop()!;
const minPart = parts.pop()!;
const hourPart = parts.pop() ?? '0';
return Number(hourPart) * 3600 + Number(minPart) * 60 + Number(secPart.replace(',', '.'));
};
export const parseVttCue = (line: string, lines: string[], i: number) => {
if (!line.includes('-->')) return null
const [startRaw, endRaw] = line.split('-->')
const payload: string[] = []
let j = i + 1
if (!line.includes('-->')) return null;
const [startRaw, endRaw] = line.split('-->');
const payload: string[] = [];
let j = i + 1;
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()
if (!text) return null
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text }
}
const text = payload
.join('\n')
.replace(/<[^>]+>/g, '')
.trim();
if (!text) return null;
return { start: parseVttTime(startRaw), end: parseVttTime(endRaw), text };
};
export const parseVtt = (text: string) => {
const lines = text.replace(/\r/g, '').split('\n')
const cues = []
const lines = text.replace(/\r/g, '').split('\n');
const cues = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim()
if (!line) continue
const line = lines[i].trim();
if (!line) continue;
if (i + 1 < lines.length && !line.includes('-->') && lines[i + 1].includes('-->')) {
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1)
if (cue) cues.push(cue); i++
const cue = parseVttCue(lines[i + 1].trim(), lines, i + 1);
if (cue) cues.push(cue);
i++;
} else if (line.includes('-->')) {
const cue = parseVttCue(line, lines, i)
if (cue) cues.push(cue)
const cue = parseVttCue(line, lines, i);
if (cue) cues.push(cue);
}
}
return cues
}
return cues;
};

View File

@@ -1,97 +1,101 @@
import { TimelineBounds } from './types'
import { state } from './state'
import { TimelineBounds } from './types';
import { state } from './state';
const formatTime = (seconds: number): string => {
if (!Number.isFinite(seconds) || seconds < 0) return '00:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`
}
if (!Number.isFinite(seconds) || seconds < 0) return '00:00';
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
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 => {
const duration = Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0
let start = 0
const duration =
Number.isFinite(state.video.duration) && state.video.duration > 0 ? state.video.duration : 0;
let start = 0;
if (state.video.seekable.length > 0) {
const seekableStart = state.video.seekable.start(0)
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart
const seekableStart = state.video.seekable.start(0);
if (Number.isFinite(seekableStart) && seekableStart > 0) start = seekableStart;
}
if (duration > start) {
return { start, end: duration, duration: duration - start }
return { start, end: duration, duration: duration - start };
}
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) {
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 => {
cachedBounds = timelineBounds()
}
cachedBounds = timelineBounds();
};
export const getBounds = (): TimelineBounds => cachedBounds
export const getBounds = (): TimelineBounds => cachedBounds;
export const displayTimeFromAbsolute = (absoluteTime: number): number => {
const b = getBounds()
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start
}
const b = getBounds();
if (!Number.isFinite(absoluteTime) || b.duration <= 0) return 0;
return Math.max(b.start, Math.min(b.end, absoluteTime)) - b.start;
};
export const absoluteTimeFromDisplay = (displayTime: number): number => {
const b = getBounds()
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0
return b.start + Math.max(0, Math.min(b.duration, displayTime))
}
const b = getBounds();
if (!Number.isFinite(displayTime) || b.duration <= 0) return 0;
return b.start + Math.max(0, Math.min(b.duration, displayTime));
};
export const absoluteTimeFromRatio = (ratio: number): number => {
const b = getBounds()
if (!Number.isFinite(ratio) || b.duration <= 0) return 0
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration
}
const b = getBounds();
if (!Number.isFinite(ratio) || b.duration <= 0) return 0;
return b.start + Math.max(0, Math.min(1, ratio)) * b.duration;
};
export const getBufferedEnd = (): number => {
const currentTime = state.video.currentTime
let end = 0
const currentTime = state.video.currentTime;
let end = 0;
for (let i = 0; i < state.video.buffered.length; i++) {
if (state.video.buffered.start(i) <= currentTime && state.video.buffered.end(i) >= currentTime) {
end = state.video.buffered.end(i)
break
if (
state.video.buffered.start(i) <= currentTime &&
state.video.buffered.end(i) >= currentTime
) {
end = state.video.buffered.end(i);
break;
}
}
if (end === 0) {
for (let i = 0; i < state.video.buffered.length; i++) {
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 => {
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state
const b = getBounds()
const { progress, scrubber, timeDisplay, durationDisplay, buffered } = state;
const b = getBounds();
if (b.duration <= 0) {
progress.style.width = '0%'
buffered.style.width = '0%'
scrubber.style.left = '0%'
timeDisplay.textContent = '00:00'
durationDisplay.textContent = '00:00'
return
progress.style.width = '0%';
buffered.style.width = '0%';
scrubber.style.left = '0%';
timeDisplay.textContent = '00:00';
durationDisplay.textContent = '00:00';
return;
}
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100
progress.style.width = `${pct}%`
scrubber.style.left = `${pct}%`
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime))
durationDisplay.textContent = formatTime(b.duration)
const pct = (displayTimeFromAbsolute(currentTime) / b.duration) * 100;
progress.style.width = `${pct}%`;
scrubber.style.left = `${pct}%`;
timeDisplay.textContent = formatTime(displayTimeFromAbsolute(currentTime));
durationDisplay.textContent = formatTime(b.duration);
const bufferedEnd = getBufferedEnd()
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100
buffered.style.width = `${bufferedPct}%`
}
const bufferedEnd = getBufferedEnd();
const bufferedPct = (displayTimeFromAbsolute(bufferedEnd) / b.duration) * 100;
buffered.style.width = `${bufferedPct}%`;
};

View File

@@ -1,40 +1,40 @@
export interface ModeSource {
token: string
subtitles: SubtitleItem[]
qualities?: string[]
token: string;
subtitles: SubtitleItem[];
qualities?: string[];
}
export interface SubtitleItem {
lang: string
token: string
lang: string;
token: string;
}
export interface SkipSegment {
type: string
start: number
end: number
type: string;
start: number;
end: number;
}
export interface SubtitleCue {
start: number
end: number
text: string
start: number;
end: number;
text: string;
}
export interface SubtitleTrack {
lang: string
label: string
url: string
lang: string;
label: string;
url: string;
}
export interface ActiveSegment {
type: string
start: number
end: number
type: string;
start: number;
end: number;
}
export interface TimelineBounds {
start: number
end: number
duration: number
start: number;
end: number;
duration: number;
}

View File

@@ -1,8 +1,7 @@
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 =>
document.querySelector(selector) as T | null
document.querySelector(selector) as T | null;
export const dataset = (el: HTMLElement, key: string): string =>
el.dataset[key] ?? ''
export const dataset = (el: HTMLElement, key: string): string => el.dataset[key] ?? '';

View File

@@ -1,168 +1,173 @@
export {}
export {};
type QuickSearchResult = {
id?: number
image?: string
title?: string
type?: string
}
id?: number;
image?: string;
title?: string;
type?: string;
};
const searchInitializedKey = Symbol('searchInitialized')
const globalWindow = window as Window & { [searchInitializedKey]?: boolean }
const searchInitializedKey = Symbol('searchInitialized');
const globalWindow = window as Window & { [searchInitializedKey]?: boolean };
let searchTimeout: number | undefined
const searchInput = document.getElementById('search-input') as HTMLInputElement | null
const searchDropdown = document.querySelector('[data-search-results-container]') as HTMLElement | null
let searchTimeout: number | undefined;
const searchInput = document.getElementById('search-input') as HTMLInputElement | null;
const searchDropdown = document.querySelector(
'[data-search-results-container]'
) as HTMLElement | null;
const isSafeImageUrl = (rawUrl?: string): boolean => {
if (!rawUrl || typeof rawUrl !== 'string') {
return false
return false;
}
try {
const parsed = new URL(rawUrl, window.location.origin)
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
const parsed = new URL(rawUrl, window.location.origin);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false
return false;
}
}
};
const clearSearchResults = (): void => {
if (!searchDropdown) {
return
return;
}
searchDropdown.replaceChildren()
}
searchDropdown.replaceChildren();
};
const buildSearchResultItem = (result: QuickSearchResult): HTMLAnchorElement => {
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.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')))
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.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || '')));
if (isSafeImageUrl(result.image)) {
const img = document.createElement('img')
img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)'
img.setAttribute('src', result.image || '')
img.setAttribute('alt', String(result.title || ''))
item.appendChild(img)
const img = document.createElement('img');
img.className = 'aspect-2/3 w-[42px] shrink-0 object-cover bg-(--surface-thumb)';
img.setAttribute('src', result.image || '');
img.setAttribute('alt', String(result.title || ''));
item.appendChild(img);
} else {
const noImage = document.createElement('div')
noImage.className = 'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent'
noImage.textContent = 'no image'
item.appendChild(noImage)
const noImage = document.createElement('div');
noImage.className =
'aspect-2/3 w-[42px] shrink-0 bg-(--surface-thumb) text-[0] text-transparent';
noImage.textContent = 'no image';
item.appendChild(noImage);
}
const info = document.createElement('div')
info.className = 'grid min-w-0 gap-px'
const info = document.createElement('div');
info.className = 'grid min-w-0 gap-px';
const itemTitle = document.createElement('div')
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)'
itemTitle.textContent = String(result.title || '')
info.appendChild(itemTitle)
const itemTitle = document.createElement('div');
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-(--text)';
itemTitle.textContent = String(result.title || '');
info.appendChild(itemTitle);
const itemType = document.createElement('div')
itemType.className = 'text-[0.67rem] text-(--text-faint)'
itemType.textContent = String(result.type || '')
info.appendChild(itemType)
const itemType = document.createElement('div');
itemType.className = 'text-[0.67rem] text-(--text-faint)';
itemType.textContent = String(result.type || '');
info.appendChild(itemType);
item.appendChild(info)
return item
}
item.appendChild(info);
return item;
};
const renderQuickSearchResults = (query: string, results: QuickSearchResult[]): void => {
if (!searchDropdown) {
return
return;
}
if (!results || results.length === 0) {
clearSearchResults()
return
clearSearchResults();
return;
}
const searchResults = document.createElement('div')
searchResults.className = 'grid'
const searchResults = document.createElement('div');
searchResults.className = 'grid';
const title = document.createElement('div')
title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)'
title.textContent = 'Anime'
searchResults.appendChild(title)
const title = document.createElement('div');
title.className = 'px-3 py-2 text-[0.68rem] text-(--text-faint)';
title.textContent = 'Anime';
searchResults.appendChild(title);
results.forEach((result: QuickSearchResult) => {
searchResults.appendChild(buildSearchResultItem(result))
})
searchResults.appendChild(buildSearchResultItem(result));
});
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.setAttribute('href', '/search?q=' + encodeURIComponent(query))
viewAll.textContent = 'View all results for ' + query
searchResults.appendChild(viewAll)
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.setAttribute('href', '/search?q=' + encodeURIComponent(query));
viewAll.textContent = 'View all results for ' + query;
searchResults.appendChild(viewAll);
searchDropdown.replaceChildren(searchResults)
}
searchDropdown.replaceChildren(searchResults);
};
const fetchAndRenderQuickSearch = (query: string): void => {
fetch('/api/search-quick?q=' + encodeURIComponent(query))
.then((res: Response) => res.json())
.then((results: QuickSearchResult[]) => {
renderQuickSearchResults(query, results)
renderQuickSearchResults(query, results);
})
.catch((err: unknown) => {
console.error('Search error:', err)
})
}
console.error('Search error:', err);
});
};
const onSearchInput = (event: Event): void => {
if (searchTimeout) {
window.clearTimeout(searchTimeout)
window.clearTimeout(searchTimeout);
}
const target = event.target
const target = event.target;
if (!(target instanceof HTMLInputElement)) {
return
return;
}
const query = target.value.trim()
const query = target.value.trim();
if (query.length < 2) {
clearSearchResults()
return
clearSearchResults();
return;
}
searchTimeout = window.setTimeout(() => {
fetchAndRenderQuickSearch(query)
}, 300)
}
fetchAndRenderQuickSearch(query);
}, 300);
};
const onSearchBlur = (): void => {
window.setTimeout(() => {
clearSearchResults()
}, 200)
}
clearSearchResults();
}, 200);
};
const onDocumentClick = (event: MouseEvent): void => {
const target = event.target
const target = event.target;
if (!(target instanceof Element)) {
return
return;
}
if (!target.closest('[data-search-root]')) {
clearSearchResults()
clearSearchResults();
}
}
};
const initQuickSearch = (): void => {
if (globalWindow[searchInitializedKey]) {
return
return;
}
globalWindow[searchInitializedKey] = true
globalWindow[searchInitializedKey] = true;
if (!searchInput || !searchDropdown) {
return
return;
}
searchInput.addEventListener('input', onSearchInput)
searchInput.addEventListener('blur', onSearchBlur)
document.addEventListener('click', onDocumentClick)
}
searchInput.addEventListener('input', onSearchInput);
searchInput.addEventListener('blur', onSearchBlur);
document.addEventListener('click', onDocumentClick);
};
initQuickSearch()
initQuickSearch();

View File

@@ -1,23 +1,23 @@
const initSortFilter = (): void => {
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null
const orderSelect = document.getElementById('order-select') as HTMLSelectElement | null
const sortSelect = document.getElementById('sort-select') as HTMLSelectElement | null;
const orderSelect = document.getElementById('order-select') as HTMLSelectElement | null;
const submitForm = (): void => {
const form = document.getElementById('sort-form') as HTMLFormElement | null
if (form) form.submit()
}
const form = document.getElementById('sort-form') as HTMLFormElement | null;
if (form) form.submit();
};
sortSelect?.addEventListener('change', () => {
const input = document.getElementById('sort-input') as HTMLInputElement | null
if (input) input.value = sortSelect.value
submitForm()
})
const input = document.getElementById('sort-input') as HTMLInputElement | null;
if (input) input.value = sortSelect.value;
submitForm();
});
orderSelect?.addEventListener('change', () => {
const input = document.getElementById('order-input') as HTMLInputElement | null
if (input) input.value = orderSelect.value
submitForm()
})
}
const input = document.getElementById('order-input') as HTMLInputElement | null;
if (input) input.value = orderSelect.value;
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 raw = localStorage.getItem(STORAGE_KEY)
const raw = localStorage.getItem(STORAGE_KEY);
if (raw === 'light' || raw === 'dark') {
return raw
return raw;
}
return 'dark'
}
return 'dark';
};
const applyTheme = (theme: Theme): void => {
document.documentElement.setAttribute('data-theme', theme)
localStorage.setItem(STORAGE_KEY, theme)
}
document.documentElement.setAttribute('data-theme', theme);
localStorage.setItem(STORAGE_KEY, theme);
};
const cycleTheme = (): void => {
const current = getSavedTheme()
const next: Theme = current === 'light' ? 'dark' : 'light'
applyTheme(next)
}
const current = getSavedTheme();
const next: Theme = current === 'light' ? 'dark' : 'light';
applyTheme(next);
};
const initTheme = (): void => {
const saved = getSavedTheme()
applyTheme(saved)
const saved = getSavedTheme();
applyTheme(saved);
document.addEventListener('click', (e) => {
const target = e.target as HTMLElement
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null
document.addEventListener('click', e => {
const target = e.target as HTMLElement;
const btn = target.closest('#theme-toggle, #footer-theme-toggle') as HTMLButtonElement | null;
if (btn) {
cycleTheme()
cycleTheme();
}
})
}
});
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initTheme)
document.addEventListener('DOMContentLoaded', initTheme);
} else {
initTheme()
initTheme();
}

View File

@@ -1,85 +1,92 @@
export {}
export {};
const jstOffsetMinutes = 9 * 60
const jstOffsetMinutes = 9 * 60;
type ParsedBroadcast = {
day: string
hour: number
minute: number
}
day: string;
hour: number;
minute: number;
};
const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => {
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) {
return null
return null;
}
const hour = Number.parseInt(match[1], 10)
const minute = Number.parseInt(match[2], 10)
if (Number.isNaN(hour) || Number.isNaN(minute) || hour < 0 || hour > 23 || minute < 0 || minute > 59) {
return null
const hour = Number.parseInt(match[1], 10);
const minute = Number.parseInt(match[2], 10);
if (
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 => {
if (!timezone) {
return true
return true;
}
const normalized = timezone.trim().toLowerCase()
return normalized === 'asia/tokyo' || normalized === 'jst'
}
const normalized = timezone.trim().toLowerCase();
return normalized === 'asia/tokyo' || normalized === 'jst';
};
const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => {
const day = node.getAttribute('data-broadcast-day')
const time = node.getAttribute('data-broadcast-time')
const timezone = node.getAttribute('data-broadcast-timezone')
const day = node.getAttribute('data-broadcast-day');
const time = node.getAttribute('data-broadcast-time');
const timezone = node.getAttribute('data-broadcast-timezone');
if (!day || !time || !isJstTimezone(timezone)) {
return null
return null;
}
const parsedTime = parseBroadcastTime(time)
const parsedTime = parseBroadcastTime(time);
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 => {
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) {
return null
return null;
}
const day = match[1].trim()
const hour = Number.parseInt(match[2], 10)
const minute = Number.parseInt(match[3], 10)
const day = match[1].trim();
const hour = Number.parseInt(match[2], 10);
const minute = Number.parseInt(match[3], 10);
if (Number.isNaN(hour) || Number.isNaN(minute)) {
return null
return null;
}
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 key = day.trim().toLowerCase().replace(/s$/, '')
const key = day.trim().toLowerCase().replace(/s$/, '');
const days: Record<string, number> = {
mon: 1,
monday: 1,
@@ -98,153 +105,155 @@ const normalizeDay = (day: string): number | null => {
saturday: 6,
sun: 0,
sunday: 0,
}
};
if (typeof days[key] !== 'number') {
return null
return null;
}
return days[key]
}
return days[key];
};
const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => {
const sourceMinutes = parsed.hour * 60 + parsed.minute
const diff = jstOffsetMinutes - localOffsetMinutes
const localTotal = sourceMinutes - diff
const sourceMinutes = parsed.hour * 60 + parsed.minute;
const diff = jstOffsetMinutes - localOffsetMinutes;
const localTotal = sourceMinutes - diff;
const dayShift = Math.floor(localTotal / 1440)
const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440
const localHour = Math.floor(normalizedMinutes / 60)
const localMinute = normalizedMinutes % 60
const dayShift = Math.floor(localTotal / 1440);
const normalizedMinutes = ((localTotal % 1440) + 1440) % 1440;
const localHour = Math.floor(normalizedMinutes / 60);
const localMinute = normalizedMinutes % 60;
const sourceDayIndex = normalizeDay(parsed.day)
const sourceDayIndex = normalizeDay(parsed.day);
if (sourceDayIndex === null) {
return null
return null;
}
const localDayIndex = ((sourceDayIndex + dayShift) % 7 + 7) % 7
const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][localDayIndex]
const localDayIndex = (((sourceDayIndex + dayShift) % 7) + 7) % 7;
const localDay = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'][
localDayIndex
];
const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}`
return `${localDay} at ${time} (Local)`
}
const time = `${localHour.toString().padStart(2, '0')}:${localMinute.toString().padStart(2, '0')}`;
return `${localDay} at ${time} (Local)`;
};
const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => {
const targetDay = normalizeDay(parsed.day)
const targetDay = normalizeDay(parsed.day);
if (targetDay === null) {
return null
return null;
}
const now = new Date()
const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000)
const now = new Date();
const jstNow = new Date(now.getTime() + jstOffsetMinutes * 60 * 1000);
const currentDay = jstNow.getUTCDay()
const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes()
const targetMinuteOfDay = parsed.hour * 60 + parsed.minute
const currentDay = jstNow.getUTCDay();
const currentMinuteOfDay = jstNow.getUTCHours() * 60 + jstNow.getUTCMinutes();
const targetMinuteOfDay = parsed.hour * 60 + parsed.minute;
let dayDelta = (targetDay - currentDay + 7) % 7
let dayDelta = (targetDay - currentDay + 7) % 7;
if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) {
dayDelta = 7
dayDelta = 7;
}
const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay)
return new Date(now.getTime() + minuteDelta * 60 * 1000)
}
const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay);
return new Date(now.getTime() + minuteDelta * 60 * 1000);
};
const formatRelative = (value: number, unit: Intl.RelativeTimeFormatUnit): string => {
if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') {
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
return formatter.format(value, unit)
const formatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' });
return formatter.format(value, unit);
}
const suffix = value === 1 ? unit : `${unit}s`
return `in ${value} ${suffix}`
}
const suffix = value === 1 ? unit : `${unit}s`;
return `in ${value} ${suffix}`;
};
const relativeText = (target: Date): string => {
const diffMs = target.getTime() - Date.now()
const diffMs = target.getTime() - Date.now();
if (diffMs <= 0) {
return 'soon'
return 'soon';
}
const minutes = Math.ceil(diffMs / 60000)
const minutes = Math.ceil(diffMs / 60000);
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) {
return formatRelative(hours, 'hour')
return formatRelative(hours, 'hour');
}
const days = Math.ceil(hours / 24)
return formatRelative(days, 'day')
}
const days = Math.ceil(hours / 24);
return formatRelative(days, 'day');
};
const localDateTimeText = (date: Date): string => {
const formatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short',
hour: '2-digit',
minute: '2-digit',
})
return formatter.format(date)
}
});
return formatter.format(date);
};
const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => {
const card = node.closest('[data-notification-content]')
const card = node.closest('[data-notification-content]');
if (!card) {
return
return;
}
const nextNode = card.querySelector('[data-next-airing]')
const nextNode = card.querySelector('[data-next-airing]');
if (!(nextNode instanceof HTMLElement)) {
return
return;
}
const nextDate = nextAiringUTC(parsed)
const nextDate = nextAiringUTC(parsed);
if (!nextDate) {
nextNode.remove()
return
nextNode.remove();
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 card = node.closest('[data-notification-content]')
const nextNode = card ? card.querySelector('[data-next-airing]') : null
const card = node.closest('[data-notification-content]');
const nextNode = card ? card.querySelector('[data-next-airing]') : null;
const structured = parseFromStructuredAttrs(node)
const source = node.getAttribute('data-jst-text')
const parsed = structured || parseBroadcast(source)
const structured = parseFromStructuredAttrs(node);
const source = node.getAttribute('data-jst-text');
const parsed = structured || parseBroadcast(source);
if (!parsed) {
if (nextNode instanceof HTMLElement) {
nextNode.remove()
nextNode.remove();
}
return
return;
}
const converted = convertToLocal(parsed, localOffsetMinutes)
const converted = convertToLocal(parsed, localOffsetMinutes);
if (!converted) {
if (nextNode instanceof HTMLElement) {
nextNode.remove()
nextNode.remove();
}
return
return;
}
node.textContent = converted
updateNextAiring(node, parsed)
}
node.textContent = converted;
updateNextAiring(node, parsed);
};
const updateAll = (): void => {
const localOffsetMinutes = -new Date().getTimezoneOffset()
const nodes = document.querySelectorAll('[data-jst-text]')
nodes.forEach((node) => updateNode(node, localOffsetMinutes))
}
const localOffsetMinutes = -new Date().getTimezoneOffset();
const nodes = document.querySelectorAll('[data-jst-text]');
nodes.forEach(node => updateNode(node, localOffsetMinutes));
};
const initTimezoneConversion = (): void => {
document.addEventListener('DOMContentLoaded', updateAll)
document.body.addEventListener('htmx:afterSwap', updateAll)
}
document.addEventListener('DOMContentLoaded', updateAll);
document.body.addEventListener('htmx:afterSwap', updateAll);
};
initTimezoneConversion()
initTimezoneConversion();

View File

@@ -1,55 +1,55 @@
export {}
export {};
interface ToastOptions {
message: string
duration?: number
message: string;
duration?: number;
}
const toastContainer = (): HTMLElement => {
let container = document.getElementById('toast-container')
let container = document.getElementById('toast-container');
if (!container) {
container = document.createElement('div')
container.id = 'toast-container'
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2'
document.body.appendChild(container)
container = document.createElement('div');
container.id = 'toast-container';
container.className = 'fixed bottom-4 right-4 z-100 flex flex-col gap-2';
document.body.appendChild(container);
}
return container
}
return container;
};
const showToast = ({ message, duration = 3000 }: ToastOptions): void => {
const container = toastContainer()
const template = document.getElementById('toast-template') as HTMLTemplateElement | null
const container = toastContainer();
const template = document.getElementById('toast-template') as HTMLTemplateElement | null;
if (!template) {
return
return;
}
const toast = template.content.cloneNode(true) as HTMLElement
const messageEl = toast.querySelector('.toast-message')
const closeBtn = toast.querySelector('.toast-close')
const toast = template.content.cloneNode(true) as HTMLElement;
const messageEl = toast.querySelector('.toast-message');
const closeBtn = toast.querySelector('.toast-close');
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(() => {
toast.classList.remove('translate-y-2', 'opacity-0')
})
toast.classList.remove('translate-y-2', 'opacity-0');
});
setTimeout(() => {
toast.classList.add('translate-y-2', 'opacity-0')
setTimeout(() => toast.remove(), 300)
}, duration)
}
toast.classList.add('translate-y-2', 'opacity-0');
setTimeout(() => toast.remove(), 300);
}, duration);
};
declare global {
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[] => {
if (!value) {
return []
return [];
}
return value
.split(' ')
.map((entry: string): string => entry.trim())
.filter((entry: string): boolean => entry.length > 0)
}
.filter((entry: string): boolean => entry.length > 0);
};