diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..97d949a --- /dev/null +++ b/.prettierrc @@ -0,0 +1,11 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "es5", + "printWidth": 100, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf" +} diff --git a/bun.lock b/bun.lock index bbadb17..f1b98dd 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], } } diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..f2420e7 --- /dev/null +++ b/eslint.config.js @@ -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", + }, + }, +]; diff --git a/lefthook.yml b/lefthook.yml index 2ea3ea9..27f2d6f 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -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' }, + }, + }, } diff --git a/package.json b/package.json index a249cff..899ef7e 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/static/anime.ts b/static/anime.ts index fe1288f..75622d6 100644 --- a/static/anime.ts +++ b/static/anime.ts @@ -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(); diff --git a/static/dedupe.ts b/static/dedupe.ts index 266bcde..3f33d48 100644 --- a/static/dedupe.ts +++ b/static/dedupe.ts @@ -1,25 +1,25 @@ const dedupe = (): void => { - const seen = new Set() - const elements = document.querySelectorAll('[data-id]') + const seen = new Set(); + 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); diff --git a/static/discover.ts b/static/discover.ts index e00eb41..239b016 100644 --- a/static/discover.ts +++ b/static/discover.ts @@ -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(); diff --git a/static/dropdown.ts b/static/dropdown.ts index 9a3c89c..42ce7a6 100644 --- a/static/dropdown.ts +++ b/static/dropdown.ts @@ -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); diff --git a/static/player/controls.ts b/static/player/controls.ts index 03ba4ab..227f509 100644 --- a/static/player/controls.ts +++ b/static/player/controls.ts @@ -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(); +}; diff --git a/static/player/episodes/complete.ts b/static/player/episodes/complete.ts index c4057a0..710dd2f 100644 --- a/static/player/episodes/complete.ts +++ b/static/player/episodes/complete.ts @@ -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 => { - 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 => { 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 => { 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); } } -} +}; diff --git a/static/player/episodes/nav.ts b/static/player/episodes/nav.ts index ce1d225..7482e90 100644 --- a/static/player/episodes/nav.ts +++ b/static/player/episodes/nav.ts @@ -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 => { - 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(); } -} +}; diff --git a/static/player/episodes/thumbnails.ts b/static/player/episodes/thumbnails.ts index f6fd96c..4241663 100644 --- a/static/player/episodes/thumbnails.ts +++ b/static/player/episodes/thumbnails.ts @@ -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)); +}; diff --git a/static/player/episodes/ui.ts b/static/player/episodes/ui.ts index 1e7c211..ca9d999 100644 --- a/static/player/episodes/ui.ts +++ b/static/player/episodes/ui.ts @@ -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); + }); +}; diff --git a/static/player/keyboard.ts b/static/player/keyboard.ts index ebdcf71..c522988 100644 --- a/static/player/keyboard.ts +++ b/static/player/keyboard.ts @@ -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(); } } } - }) -} + }); +}; diff --git a/static/player/main.ts b/static/player/main.ts index 749cf75..1b8c13f 100644 --- a/static/player/main.ts +++ b/static/player/main.ts @@ -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 } - - state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration))) - - 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` -} - -const initPlayer = (): void => { - const container = document.querySelector('[data-video-player]') as HTMLElement | null - if (!container || initialized) return - initialized = true - - initState(container) - - const loading = container.querySelector('[data-loading]') as HTMLElement | null - const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null - - const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best' - const streamToken = state.modeSources[state.currentMode]?.token - if (streamToken) { - state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}` + 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; } - setupProgress() - setupControls() - setupKeyboard() - setupSkip() - setupSubtitles() - setupQuality() - setupMode() + state.previewTime.textContent = formatTime(Math.max(0, Math.min(b.duration, ratio * b.duration))); - updateSubtitleOptions() - updateQualityOptions() - updateModeButtons() - setupAutoplayButton() - updateAutoSkipButton() - showControls() + 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`; +}; + +const initPlayer = (): void => { + const container = document.querySelector('[data-video-player]') as HTMLElement | null; + if (!container || initialized) return; + initialized = true; + + initState(container); + + const loading = container.querySelector('[data-loading]') as HTMLElement | null; + const progressWrap = container.querySelector('[data-progress-wrap]') as HTMLElement | null; + + const preferredQuality = localStorage.getItem('mal:preferred-quality') || 'best'; + const streamToken = state.modeSources[state.currentMode]?.token; + if (streamToken) { + state.video.src = `${state.streamURL}?mode=${encodeURIComponent(state.currentMode)}&token=${encodeURIComponent(streamToken)}${preferredQuality !== 'best' ? `&quality=${encodeURIComponent(preferredQuality)}` : ''}`; + } + + setupProgress(); + setupControls(); + setupKeyboard(); + setupSkip(); + setupSubtitles(); + setupQuality(); + setupMode(); + + updateSubtitleOptions(); + updateQualityOptions(); + updateModeButtons(); + setupAutoplayButton(); + updateAutoSkipButton(); + showControls(); state.video.addEventListener('loadedmetadata', () => { - 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(); +}); diff --git a/static/player/mode.ts b/static/player/mode.ts index 28d3a8b..9911279 100644 --- a/static/player/mode.ts +++ b/static/player/mode.ts @@ -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(); + }); +}; diff --git a/static/player/progress.ts b/static/player/progress.ts index 7e1033a..3c65827 100644 --- a/static/player/progress.ts +++ b/static/player/progress.ts @@ -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({ - mal_id: state.malID, - episode, - time_seconds: seconds, -}) +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 => { - 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))); + }); +}; diff --git a/static/player/quality.ts b/static/player/quality.ts index 01d641b..b3277b3 100644 --- a/static/player/quality.ts +++ b/static/player/quality.ts @@ -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); + }); +}; diff --git a/static/player/skip/index.ts b/static/player/skip/index.ts index 4a7a234..2972432 100644 --- a/static/player/skip/index.ts +++ b/static/player/skip/index.ts @@ -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(); } - }) -} + }); +}; diff --git a/static/player/skip/segments.ts b/static/player/skip/segments.ts index 26a9393..0ca3b27 100644 --- a/static/player/skip/segments.ts +++ b/static/player/skip/segments.ts @@ -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 - } - if (s.type === 'ed') { - return s.start >= bounds * MIN_OUTRO_START_RATIO - } - return false - }) -} + if (s.type === 'op') { + return s.start <= MAX_INTRO_START && s.start <= bounds * 0.5; + } + if (s.type === 'ed') { + return s.start >= bounds * MIN_OUTRO_START_RATIO; + } + 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); + }); +}; diff --git a/static/player/state.ts b/static/player/state.ts index d60f721..9f0b167 100644 --- a/static/player/state.ts +++ b/static/player/state.ts @@ -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 - 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; + 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(c, 'video')! - state.progress = q(c, '[data-progress]') - state.scrubber = q(c, '[data-scrubber]') - state.buffered = q(c, '[data-buffered]') - state.timeDisplay = q(c, '[data-time]') - state.durationDisplay = q(c, '[data-duration]') - state.previewPopover = q(c, '[data-preview-popover]') - state.previewTime = q(c, '[data-preview-time]') - state.videoOverlay = q(c, '[data-video-overlay]') + state.container = c; + state.video = q(c, 'video')!; + state.progress = q(c, '[data-progress]'); + state.scrubber = q(c, '[data-scrubber]'); + state.buffered = q(c, '[data-buffered]'); + state.timeDisplay = q(c, '[data-time]'); + state.durationDisplay = q(c, '[data-duration]'); + state.previewPopover = q(c, '[data-preview-popover]'); + state.previewTime = q(c, '[data-preview-time]'); + state.videoOverlay = q(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('[data-episode-grid]') - state.episodeList = qs('[data-episode-list]') + state.episodeGrid = qs('[data-episode-grid]'); + state.episodeList = qs('[data-episode-list]'); const safeJson = (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) - state.availableModes = safeJson(dataset(c, 'availableModes'), [] as string[]) + state.modeSources = safeJson(dataset(c, 'modeSources'), {} as Record); + 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); +}; diff --git a/static/player/subtitles/index.ts b/static/player/subtitles/index.ts index 55adbb9..b883277 100644 --- a/static/player/subtitles/index.ts +++ b/static/player/subtitles/index.ts @@ -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 => { 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); + }); +}; diff --git a/static/player/subtitles/vtt.ts b/static/player/subtitles/vtt.ts index 2ebf1b1..db296f9 100644 --- a/static/player/subtitles/vtt.ts +++ b/static/player/subtitles/vtt.ts @@ -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; +}; diff --git a/static/player/timeline.ts b/static/player/timeline.ts index a7967b6..0eb1736 100644 --- a/static/player/timeline.ts +++ b/static/player/timeline.ts @@ -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}%`; +}; diff --git a/static/player/types.ts b/static/player/types.ts index 40f86ac..9d6b4cd 100644 --- a/static/player/types.ts +++ b/static/player/types.ts @@ -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; } diff --git a/static/q.ts b/static/q.ts index 824fe6f..75c6699 100644 --- a/static/q.ts +++ b/static/q.ts @@ -1,8 +1,7 @@ export const q = (container: HTMLElement, selector: string): T | null => - container.querySelector(selector) as T | null + container.querySelector(selector) as T | null; export const qs = (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] ?? ''; diff --git a/static/search.ts b/static/search.ts index 28b3032..c85ce5b 100644 --- a/static/search.ts +++ b/static/search.ts @@ -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(); diff --git a/static/sort_filter.ts b/static/sort_filter.ts index 4bfe270..9c80a57 100644 --- a/static/sort_filter.ts +++ b/static/sort_filter.ts @@ -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); diff --git a/static/theme.ts b/static/theme.ts index 7db0a66..9298075 100644 --- a/static/theme.ts +++ b/static/theme.ts @@ -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(); } diff --git a/static/timezone.ts b/static/timezone.ts index ccb31a0..60f7f28 100644 --- a/static/timezone.ts +++ b/static/timezone.ts @@ -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 = { 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(); diff --git a/static/toast.ts b/static/toast.ts index 3e5ef6a..f7e0b55 100644 --- a/static/toast.ts +++ b/static/toast.ts @@ -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; diff --git a/static/utils.ts b/static/utils.ts index 173b189..d4f3e88 100644 --- a/static/utils.ts +++ b/static/utils.ts @@ -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); +};