From 93cb99fd9433fbe5f3299577a22f500f578188bd Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 14 Apr 2026 23:43:41 +0200 Subject: [PATCH 1/9] build: add tailwind v4 and ts tooling --- CONTRIBUTING.md | 6 + README.md | 4 +- bun.lock | 149 ++++++++++ internal/templates/layout.templ | 1 + package.json | 16 ++ static/css/tailwind.css | 477 ++++++++++++++++++++++++++++++++ static/css/tailwind.input.css | 15 + tsconfig.json | 17 ++ 8 files changed, 684 insertions(+), 1 deletion(-) create mode 100644 bun.lock create mode 100644 package.json create mode 100644 static/css/tailwind.css create mode 100644 static/css/tailwind.input.css create mode 100644 tsconfig.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e564a1..21a30a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,9 +14,15 @@ Thanks for your interest in improving MAL. # install templ CLI go install github.com/a-h/templ/cmd/templ@latest +# install frontend tooling +bun install + # generate templates templ generate +# build frontend assets (tailwind + typescript) +bun run build:assets + # run tests go test ./... diff --git a/README.md b/README.md index 73df747..28c866a 100644 --- a/README.md +++ b/README.md @@ -73,11 +73,13 @@ There are still honest limits. Metadata quality still depends partly on external ## Getting started -For local development, install Go `1.24+`, SQLite, and the `templ` CLI, then generate templates and run the server. +For local development, install Go `1.24+`, SQLite, Bun, and the `templ` CLI, then generate templates, build frontend assets, and run the server. ```bash go install github.com/a-h/templ/cmd/templ@latest +bun install templ generate +bun run build:assets go run ./cmd/server ``` diff --git a/bun.lock b/bun.lock new file mode 100644 index 0000000..67000b2 --- /dev/null +++ b/bun.lock @@ -0,0 +1,149 @@ +{ + "lockfileVersion": 1, + "configVersion": 1, + "workspaces": { + "": { + "name": "myanimelist-ui", + "devDependencies": { + "@tailwindcss/cli": "^4.1.14", + "tailwindcss": "^4.1.14", + "typescript": "^5.9.3", + }, + }, + }, + "packages": { + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], + + "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], + + "@parcel/watcher": ["@parcel/watcher@2.5.6", "", { "dependencies": { "detect-libc": "^2.0.3", "is-glob": "^4.0.3", "node-addon-api": "^7.0.0", "picomatch": "^4.0.3" }, "optionalDependencies": { "@parcel/watcher-android-arm64": "2.5.6", "@parcel/watcher-darwin-arm64": "2.5.6", "@parcel/watcher-darwin-x64": "2.5.6", "@parcel/watcher-freebsd-x64": "2.5.6", "@parcel/watcher-linux-arm-glibc": "2.5.6", "@parcel/watcher-linux-arm-musl": "2.5.6", "@parcel/watcher-linux-arm64-glibc": "2.5.6", "@parcel/watcher-linux-arm64-musl": "2.5.6", "@parcel/watcher-linux-x64-glibc": "2.5.6", "@parcel/watcher-linux-x64-musl": "2.5.6", "@parcel/watcher-win32-arm64": "2.5.6", "@parcel/watcher-win32-ia32": "2.5.6", "@parcel/watcher-win32-x64": "2.5.6" } }, "sha512-tmmZ3lQxAe/k/+rNnXQRawJ4NjxO2hqiOLTHvWchtGZULp4RyFeh6aU4XdOYBFe2KE1oShQTv4AblOs2iOrNnQ=="], + + "@parcel/watcher-android-arm64": ["@parcel/watcher-android-arm64@2.5.6", "", { "os": "android", "cpu": "arm64" }, "sha512-YQxSS34tPF/6ZG7r/Ih9xy+kP/WwediEUsqmtf0cuCV5TPPKw/PQHRhueUo6JdeFJaqV3pyjm0GdYjZotbRt/A=="], + + "@parcel/watcher-darwin-arm64": ["@parcel/watcher-darwin-arm64@2.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-Z2ZdrnwyXvvvdtRHLmM4knydIdU9adO3D4n/0cVipF3rRiwP+3/sfzpAwA/qKFL6i1ModaabkU7IbpeMBgiVEA=="], + + "@parcel/watcher-darwin-x64": ["@parcel/watcher-darwin-x64@2.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-HgvOf3W9dhithcwOWX9uDZyn1lW9R+7tPZ4sug+NGrGIo4Rk1hAXLEbcH1TQSqxts0NYXXlOWqVpvS1SFS4fRg=="], + + "@parcel/watcher-freebsd-x64": ["@parcel/watcher-freebsd-x64@2.5.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-vJVi8yd/qzJxEKHkeemh7w3YAn6RJCtYlE4HPMoVnCpIXEzSrxErBW5SJBgKLbXU3WdIpkjBTeUNtyBVn8TRng=="], + + "@parcel/watcher-linux-arm-glibc": ["@parcel/watcher-linux-arm-glibc@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-9JiYfB6h6BgV50CCfasfLf/uvOcJskMSwcdH1PHH9rvS1IrNy8zad6IUVPVUfmXr+u+Km9IxcfMLzgdOudz9EQ=="], + + "@parcel/watcher-linux-arm-musl": ["@parcel/watcher-linux-arm-musl@2.5.6", "", { "os": "linux", "cpu": "arm" }, "sha512-Ve3gUCG57nuUUSyjBq/MAM0CzArtuIOxsBdQ+ftz6ho8n7s1i9E1Nmk/xmP323r2YL0SONs1EuwqBp2u1k5fxg=="], + + "@parcel/watcher-linux-arm64-glibc": ["@parcel/watcher-linux-arm64-glibc@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-f2g/DT3NhGPdBmMWYoxixqYr3v/UXcmLOYy16Bx0TM20Tchduwr4EaCbmxh1321TABqPGDpS8D/ggOTaljijOA=="], + + "@parcel/watcher-linux-arm64-musl": ["@parcel/watcher-linux-arm64-musl@2.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-qb6naMDGlbCwdhLj6hgoVKJl2odL34z2sqkC7Z6kzir8b5W65WYDpLB6R06KabvZdgoHI/zxke4b3zR0wAbDTA=="], + + "@parcel/watcher-linux-x64-glibc": ["@parcel/watcher-linux-x64-glibc@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-kbT5wvNQlx7NaGjzPFu8nVIW1rWqV780O7ZtkjuWaPUgpv2NMFpjYERVi0UYj1msZNyCzGlaCWEtzc+exjMGbQ=="], + + "@parcel/watcher-linux-x64-musl": ["@parcel/watcher-linux-x64-musl@2.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-1JRFeC+h7RdXwldHzTsmdtYR/Ku8SylLgTU/reMuqdVD7CtLwf0VR1FqeprZ0eHQkO0vqsbvFLXUmYm/uNKJBg=="], + + "@parcel/watcher-win32-arm64": ["@parcel/watcher-win32-arm64@2.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-3ukyebjc6eGlw9yRt678DxVF7rjXatWiHvTXqphZLvo7aC5NdEgFufVwjFfY51ijYEWpXbqF5jtrK275z52D4Q=="], + + "@parcel/watcher-win32-ia32": ["@parcel/watcher-win32-ia32@2.5.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-k35yLp1ZMwwee3Ez/pxBi5cf4AoBKYXj00CZ80jUz5h8prpiaQsiRPKQMxoLstNuqe2vR4RNPEAEcjEFzhEz/g=="], + + "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + + "@tailwindcss/cli": ["@tailwindcss/cli@4.2.2", "", { "dependencies": { "@parcel/watcher": "^2.5.1", "@tailwindcss/node": "4.2.2", "@tailwindcss/oxide": "4.2.2", "enhanced-resolve": "^5.19.0", "mri": "^1.2.0", "picocolors": "^1.1.1", "tailwindcss": "4.2.2" }, "bin": { "tailwindcss": "dist/index.mjs" } }, "sha512-iJS+8kAFZ8HPqnh0O5DHCLjo4L6dD97DBQEkrhfSO4V96xeefUus2jqsBs1dUMt3OU9Ks4qIkiY0mpL5UW+4LQ=="], + + "@tailwindcss/node": ["@tailwindcss/node@4.2.2", "", { "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.2" } }, "sha512-pXS+wJ2gZpVXqFaUEjojq7jzMpTGf8rU6ipJz5ovJV6PUGmlJ+jvIwGrzdHdQ80Sg+wmQxUFuoW1UAAwHNEdFA=="], + + "@tailwindcss/oxide": ["@tailwindcss/oxide@4.2.2", "", { "optionalDependencies": { "@tailwindcss/oxide-android-arm64": "4.2.2", "@tailwindcss/oxide-darwin-arm64": "4.2.2", "@tailwindcss/oxide-darwin-x64": "4.2.2", "@tailwindcss/oxide-freebsd-x64": "4.2.2", "@tailwindcss/oxide-linux-arm-gnueabihf": "4.2.2", "@tailwindcss/oxide-linux-arm64-gnu": "4.2.2", "@tailwindcss/oxide-linux-arm64-musl": "4.2.2", "@tailwindcss/oxide-linux-x64-gnu": "4.2.2", "@tailwindcss/oxide-linux-x64-musl": "4.2.2", "@tailwindcss/oxide-wasm32-wasi": "4.2.2", "@tailwindcss/oxide-win32-arm64-msvc": "4.2.2", "@tailwindcss/oxide-win32-x64-msvc": "4.2.2" } }, "sha512-qEUA07+E5kehxYp9BVMpq9E8vnJuBHfJEC0vPC5e7iL/hw7HR61aDKoVoKzrG+QKp56vhNZe4qwkRmMC0zDLvg=="], + + "@tailwindcss/oxide-android-arm64": ["@tailwindcss/oxide-android-arm64@4.2.2", "", { "os": "android", "cpu": "arm64" }, "sha512-dXGR1n+P3B6748jZO/SvHZq7qBOqqzQ+yFrXpoOWWALWndF9MoSKAT3Q0fYgAzYzGhxNYOoysRvYlpixRBBoDg=="], + + "@tailwindcss/oxide-darwin-arm64": ["@tailwindcss/oxide-darwin-arm64@4.2.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-iq9Qjr6knfMpZHj55/37ouZeykwbDqF21gPFtfnhCCKGDcPI/21FKC9XdMO/XyBM7qKORx6UIhGgg6jLl7BZlg=="], + + "@tailwindcss/oxide-darwin-x64": ["@tailwindcss/oxide-darwin-x64@4.2.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-BlR+2c3nzc8f2G639LpL89YY4bdcIdUmiOOkv2GQv4/4M0vJlpXEa0JXNHhCHU7VWOKWT/CjqHdTP8aUuDJkuw=="], + + "@tailwindcss/oxide-freebsd-x64": ["@tailwindcss/oxide-freebsd-x64@4.2.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-YUqUgrGMSu2CDO82hzlQ5qSb5xmx3RUrke/QgnoEx7KvmRJHQuZHZmZTLSuuHwFf0DJPybFMXMYf+WJdxHy/nQ=="], + + "@tailwindcss/oxide-linux-arm-gnueabihf": ["@tailwindcss/oxide-linux-arm-gnueabihf@4.2.2", "", { "os": "linux", "cpu": "arm" }, "sha512-FPdhvsW6g06T9BWT0qTwiVZYE2WIFo2dY5aCSpjG/S/u1tby+wXoslXS0kl3/KXnULlLr1E3NPRRw0g7t2kgaQ=="], + + "@tailwindcss/oxide-linux-arm64-gnu": ["@tailwindcss/oxide-linux-arm64-gnu@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-4og1V+ftEPXGttOO7eCmW7VICmzzJWgMx+QXAJRAhjrSjumCwWqMfkDrNu1LXEQzNAwz28NCUpucgQPrR4S2yw=="], + + "@tailwindcss/oxide-linux-arm64-musl": ["@tailwindcss/oxide-linux-arm64-musl@4.2.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag=="], + + "@tailwindcss/oxide-linux-x64-gnu": ["@tailwindcss/oxide-linux-x64-gnu@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg=="], + + "@tailwindcss/oxide-linux-x64-musl": ["@tailwindcss/oxide-linux-x64-musl@4.2.2", "", { "os": "linux", "cpu": "x64" }, "sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ=="], + + "@tailwindcss/oxide-wasm32-wasi": ["@tailwindcss/oxide-wasm32-wasi@4.2.2", "", { "dependencies": { "@emnapi/core": "^1.8.1", "@emnapi/runtime": "^1.8.1", "@emnapi/wasi-threads": "^1.1.0", "@napi-rs/wasm-runtime": "^1.1.1", "@tybys/wasm-util": "^0.10.1", "tslib": "^2.8.1" }, "cpu": "none" }, "sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q=="], + + "@tailwindcss/oxide-win32-arm64-msvc": ["@tailwindcss/oxide-win32-arm64-msvc@4.2.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-qPmaQM4iKu5mxpsrWZMOZRgZv1tOZpUm+zdhhQP0VhJfyGGO3aUKdbh3gDZc/dPLQwW4eSqWGrrcWNBZWUWaXQ=="], + + "@tailwindcss/oxide-win32-x64-msvc": ["@tailwindcss/oxide-win32-x64-msvc@4.2.2", "", { "os": "win32", "cpu": "x64" }, "sha512-1T/37VvI7WyH66b+vqHj/cLwnCxt7Qt3WFu5Q8hk65aOvlwAhs7rAp1VkulBJw/N4tMirXjVnylTR72uI0HGcA=="], + + "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], + + "enhanced-resolve": ["enhanced-resolve@5.20.1", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA=="], + + "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], + + "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=="], + + "jiti": ["jiti@2.6.1", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ=="], + + "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], + + "lightningcss-android-arm64": ["lightningcss-android-arm64@1.32.0", "", { "os": "android", "cpu": "arm64" }, "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg=="], + + "lightningcss-darwin-arm64": ["lightningcss-darwin-arm64@1.32.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ=="], + + "lightningcss-darwin-x64": ["lightningcss-darwin-x64@1.32.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w=="], + + "lightningcss-freebsd-x64": ["lightningcss-freebsd-x64@1.32.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig=="], + + "lightningcss-linux-arm-gnueabihf": ["lightningcss-linux-arm-gnueabihf@1.32.0", "", { "os": "linux", "cpu": "arm" }, "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw=="], + + "lightningcss-linux-arm64-gnu": ["lightningcss-linux-arm64-gnu@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ=="], + + "lightningcss-linux-arm64-musl": ["lightningcss-linux-arm64-musl@1.32.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg=="], + + "lightningcss-linux-x64-gnu": ["lightningcss-linux-x64-gnu@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA=="], + + "lightningcss-linux-x64-musl": ["lightningcss-linux-x64-musl@1.32.0", "", { "os": "linux", "cpu": "x64" }, "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg=="], + + "lightningcss-win32-arm64-msvc": ["lightningcss-win32-arm64-msvc@1.32.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw=="], + + "lightningcss-win32-x64-msvc": ["lightningcss-win32-x64-msvc@1.32.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q=="], + + "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], + + "mri": ["mri@1.2.0", "", {}, "sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA=="], + + "node-addon-api": ["node-addon-api@7.1.1", "", {}, "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], + + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], + + "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], + + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], + + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], + + "@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=="], + } +} diff --git a/internal/templates/layout.templ b/internal/templates/layout.templ index 0ebea27..0b4fe9a 100644 --- a/internal/templates/layout.templ +++ b/internal/templates/layout.templ @@ -11,6 +11,7 @@ templ Layout(title string, showHeader bool) { { title } + diff --git a/package.json b/package.json new file mode 100644 index 0000000..b813482 --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "myanimelist-ui", + "private": true, + "scripts": { + "build:css": "bunx @tailwindcss/cli -i ./static/css/tailwind.input.css -o ./static/css/tailwind.css", + "watch:css": "bunx @tailwindcss/cli -i ./static/css/tailwind.input.css -o ./static/css/tailwind.css --watch", + "build:ts": "bunx tsc -p tsconfig.json", + "typecheck": "bunx tsc -p tsconfig.json --noEmit", + "build:assets": "bun run build:css && bun run build:ts" + }, + "devDependencies": { + "@tailwindcss/cli": "^4.1.14", + "tailwindcss": "^4.1.14", + "typescript": "^5.9.3" + } +} diff --git a/static/css/tailwind.css b/static/css/tailwind.css new file mode 100644 index 0000000..a1a7c3f --- /dev/null +++ b/static/css/tailwind.css @@ -0,0 +1,477 @@ +/*! tailwindcss v4.2.2 | MIT License | https://tailwindcss.com */ +@layer properties; +@layer theme, base, components, utilities; +@layer theme { + :root, :host { + --font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", + "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"; + --font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", + "Courier New", monospace; + --spacing: 0.25rem; + --text-sm: 0.875rem; + --text-sm--line-height: calc(1.25 / 0.875); + --text-base: 1rem; + --text-base--line-height: calc(1.5 / 1); + --font-weight-semibold: 600; + --default-font-family: var(--font-sans); + --default-mono-font-family: var(--font-mono); + --color-panel: #181d24; + --color-text-muted: #b8c0cd; + --color-text-faint: #8b97a8; + --color-accent: #cad4e4; + } +} +@layer base { + *, ::after, ::before, ::backdrop, ::file-selector-button { + box-sizing: border-box; + margin: 0; + padding: 0; + border: 0 solid; + } + html, :host { + line-height: 1.5; + -webkit-text-size-adjust: 100%; + tab-size: 4; + font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji"); + font-feature-settings: var(--default-font-feature-settings, normal); + font-variation-settings: var(--default-font-variation-settings, normal); + -webkit-tap-highlight-color: transparent; + } + hr { + height: 0; + color: inherit; + border-top-width: 1px; + } + abbr:where([title]) { + -webkit-text-decoration: underline dotted; + text-decoration: underline dotted; + } + h1, h2, h3, h4, h5, h6 { + font-size: inherit; + font-weight: inherit; + } + a { + color: inherit; + -webkit-text-decoration: inherit; + text-decoration: inherit; + } + b, strong { + font-weight: bolder; + } + code, kbd, samp, pre { + font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace); + font-feature-settings: var(--default-mono-font-feature-settings, normal); + font-variation-settings: var(--default-mono-font-variation-settings, normal); + font-size: 1em; + } + small { + font-size: 80%; + } + sub, sup { + font-size: 75%; + line-height: 0; + position: relative; + vertical-align: baseline; + } + sub { + bottom: -0.25em; + } + sup { + top: -0.5em; + } + table { + text-indent: 0; + border-color: inherit; + border-collapse: collapse; + } + :-moz-focusring { + outline: auto; + } + progress { + vertical-align: baseline; + } + summary { + display: list-item; + } + ol, ul, menu { + list-style: none; + } + img, svg, video, canvas, audio, iframe, embed, object { + display: block; + vertical-align: middle; + } + img, video { + max-width: 100%; + height: auto; + } + button, input, select, optgroup, textarea, ::file-selector-button { + font: inherit; + font-feature-settings: inherit; + font-variation-settings: inherit; + letter-spacing: inherit; + color: inherit; + border-radius: 0; + background-color: transparent; + opacity: 1; + } + :where(select:is([multiple], [size])) optgroup { + font-weight: bolder; + } + :where(select:is([multiple], [size])) optgroup option { + padding-inline-start: 20px; + } + ::file-selector-button { + margin-inline-end: 4px; + } + ::placeholder { + opacity: 1; + } + @supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) { + ::placeholder { + color: currentcolor; + @supports (color: color-mix(in lab, red, red)) { + color: color-mix(in oklab, currentcolor 50%, transparent); + } + } + } + textarea { + resize: vertical; + } + ::-webkit-search-decoration { + -webkit-appearance: none; + } + ::-webkit-date-and-time-value { + min-height: 1lh; + text-align: inherit; + } + ::-webkit-datetime-edit { + display: inline-flex; + } + ::-webkit-datetime-edit-fields-wrapper { + padding: 0; + } + ::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field { + padding-block: 0; + } + ::-webkit-calendar-picker-indicator { + line-height: 1; + } + :-moz-ui-invalid { + box-shadow: none; + } + button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button { + appearance: button; + } + ::-webkit-inner-spin-button, ::-webkit-outer-spin-button { + height: auto; + } + [hidden]:where(:not([hidden="until-found"])) { + display: none !important; + } +} +@layer utilities { + .static { + position: static; + } + .start { + inset-inline-start: var(--spacing); + } + .container { + width: 100%; + @media (width >= 40rem) { + max-width: 40rem; + } + @media (width >= 48rem) { + max-width: 48rem; + } + @media (width >= 64rem) { + max-width: 64rem; + } + @media (width >= 80rem) { + max-width: 80rem; + } + @media (width >= 96rem) { + max-width: 96rem; + } + } + .m-0 { + margin: calc(var(--spacing) * 0); + } + .mx-auto { + margin-inline: auto; + } + .my-3 { + margin-block: calc(var(--spacing) * 3); + } + .mt-5 { + margin-top: calc(var(--spacing) * 5); + } + .mb-0 { + margin-bottom: calc(var(--spacing) * 0); + } + .mb-2 { + margin-bottom: calc(var(--spacing) * 2); + } + .mb-5 { + margin-bottom: calc(var(--spacing) * 5); + } + .block { + display: block; + } + .grid { + display: grid; + } + .hidden { + display: none; + } + .inline-flex { + display: inline-flex; + } + .table { + display: table; + } + .h-1\.5 { + height: calc(var(--spacing) * 1.5); + } + .h-10 { + height: calc(var(--spacing) * 10); + } + .min-h-\[72vh\] { + min-height: 72vh; + } + .w-1\.5 { + width: calc(var(--spacing) * 1.5); + } + .w-\[min\(780px\,calc\(100vw-\(1\.5rem\*2\)\)\)\] { + width: min(780px, calc(100vw - (1.5rem * 2))); + } + .w-full { + width: 100%; + } + .max-w-\[560px\] { + max-width: 560px; + } + .cursor-pointer { + cursor: pointer; + } + .content-center { + align-content: center; + } + .items-center { + align-items: center; + } + .justify-items-center { + justify-items: center; + } + .gap-1 { + gap: calc(var(--spacing) * 1); + } + .gap-2 { + gap: calc(var(--spacing) * 2); + } + .gap-3 { + gap: calc(var(--spacing) * 3); + } + .gap-4 { + gap: calc(var(--spacing) * 4); + } + .rounded-full { + border-radius: calc(infinity * 1px); + } + .border-0 { + border-style: var(--tw-border-style); + border-width: 0px; + } + .bg-\[var\(--color-accent\)\] { + background-color: var(--color-accent); + } + .bg-\[var\(--color-panel\)\] { + background-color: var(--color-panel); + } + .bg-\[var\(--color-text-faint\)\] { + background-color: var(--color-text-faint); + } + .p-6 { + padding: calc(var(--spacing) * 6); + } + .px-7 { + padding-inline: calc(var(--spacing) * 7); + } + .py-4 { + padding-block: calc(var(--spacing) * 4); + } + .py-8 { + padding-block: calc(var(--spacing) * 8); + } + .text-center { + text-align: center; + } + .text-base { + font-size: var(--text-base); + line-height: var(--tw-leading, var(--text-base--line-height)); + } + .text-sm { + font-size: var(--text-sm); + line-height: var(--tw-leading, var(--text-sm--line-height)); + } + .text-\[0\.9rem\] { + font-size: 0.9rem; + } + .text-\[0\.95rem\] { + font-size: 0.95rem; + } + .text-\[1\.4rem\] { + font-size: 1.4rem; + } + .text-\[1\.05rem\] { + font-size: 1.05rem; + } + .text-\[clamp\(2rem\,4vw\,3rem\)\] { + font-size: clamp(2rem, 4vw, 3rem); + } + .text-\[clamp\(4rem\,15vw\,10rem\)\] { + font-size: clamp(4rem, 15vw, 10rem); + } + .leading-\[0\.9\] { + --tw-leading: 0.9; + line-height: 0.9; + } + .font-semibold { + --tw-font-weight: var(--font-weight-semibold); + font-weight: var(--font-weight-semibold); + } + .tracking-\[0\.04em\] { + --tw-tracking: 0.04em; + letter-spacing: 0.04em; + } + .text-\[var\(--color-accent\)\] { + color: var(--color-accent); + } + .text-\[var\(--color-text-muted\)\] { + color: var(--color-text-muted); + } + .text-\[var\(--text-on-accent\)\] { + color: var(--text-on-accent); + } + .lowercase { + text-transform: lowercase; + } + .uppercase { + text-transform: uppercase; + } + .no-underline { + text-decoration-line: none; + } + .blur { + --tw-blur: blur(8px); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + .hover\:underline { + &:hover { + @media (hover: hover) { + text-decoration-line: underline; + } + } + } + .hover\:brightness-95 { + &:hover { + @media (hover: hover) { + --tw-brightness: brightness(95%); + filter: var(--tw-blur,) var(--tw-brightness,) var(--tw-contrast,) var(--tw-grayscale,) var(--tw-hue-rotate,) var(--tw-invert,) var(--tw-saturate,) var(--tw-sepia,) var(--tw-drop-shadow,); + } + } + } +} +@property --tw-border-style { + syntax: "*"; + inherits: false; + initial-value: solid; +} +@property --tw-leading { + syntax: "*"; + inherits: false; +} +@property --tw-font-weight { + syntax: "*"; + inherits: false; +} +@property --tw-tracking { + syntax: "*"; + inherits: false; +} +@property --tw-blur { + syntax: "*"; + inherits: false; +} +@property --tw-brightness { + syntax: "*"; + inherits: false; +} +@property --tw-contrast { + syntax: "*"; + inherits: false; +} +@property --tw-grayscale { + syntax: "*"; + inherits: false; +} +@property --tw-hue-rotate { + syntax: "*"; + inherits: false; +} +@property --tw-invert { + syntax: "*"; + inherits: false; +} +@property --tw-opacity { + syntax: "*"; + inherits: false; +} +@property --tw-saturate { + syntax: "*"; + inherits: false; +} +@property --tw-sepia { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-color { + syntax: "*"; + inherits: false; +} +@property --tw-drop-shadow-alpha { + syntax: ""; + inherits: false; + initial-value: 100%; +} +@property --tw-drop-shadow-size { + syntax: "*"; + inherits: false; +} +@layer properties { + @supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) { + *, ::before, ::after, ::backdrop { + --tw-border-style: solid; + --tw-leading: initial; + --tw-font-weight: initial; + --tw-tracking: initial; + --tw-blur: initial; + --tw-brightness: initial; + --tw-contrast: initial; + --tw-grayscale: initial; + --tw-hue-rotate: initial; + --tw-invert: initial; + --tw-opacity: initial; + --tw-saturate: initial; + --tw-sepia: initial; + --tw-drop-shadow: initial; + --tw-drop-shadow-color: initial; + --tw-drop-shadow-alpha: 100%; + --tw-drop-shadow-size: initial; + } + } +} diff --git a/static/css/tailwind.input.css b/static/css/tailwind.input.css new file mode 100644 index 0000000..fda0d34 --- /dev/null +++ b/static/css/tailwind.input.css @@ -0,0 +1,15 @@ +@import 'tailwindcss'; + +@source '../../internal/**/*.templ'; + +@theme { + --color-bg: #111419; + --color-panel: #181d24; + --color-panel-soft: #1f2530; + --color-header: #1a2029; + --color-text: #e7eaf0; + --color-text-muted: #b8c0cd; + --color-text-faint: #8b97a8; + --color-accent: #cad4e4; + --color-danger: #d17f88; +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..9e6f4af --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ES2020", + "moduleResolution": "Bundler", + "strict": true, + "noEmitOnError": true, + "outDir": "./static/js", + "rootDir": "./static/ts", + "sourceMap": false, + "removeComments": false, + "skipLibCheck": true + }, + "include": [ + "static/ts/**/*.ts" + ] +} From b5bc9c23cc1e3536db3e6efdc9713137dde3bfd6 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 14 Apr 2026 23:43:50 +0200 Subject: [PATCH 2/9] refactor: migrate browser scripts to ts --- static/js/anime.js | 52 +++-- static/js/auth.js | 34 ++-- static/js/discover.js | 49 +++-- static/js/search.js | 211 ++++++++++---------- static/js/timezone.js | 435 +++++++++++++++++++----------------------- static/ts/anime.ts | 28 +++ static/ts/auth.ts | 25 +++ static/ts/discover.ts | 26 +++ static/ts/search.ts | 127 ++++++++++++ static/ts/timezone.ts | 246 ++++++++++++++++++++++++ 10 files changed, 819 insertions(+), 414 deletions(-) create mode 100644 static/ts/anime.ts create mode 100644 static/ts/auth.ts create mode 100644 static/ts/discover.ts create mode 100644 static/ts/search.ts create mode 100644 static/ts/timezone.ts diff --git a/static/js/anime.js b/static/js/anime.js index c10c5a1..d719dbf 100644 --- a/static/js/anime.js +++ b/static/js/anime.js @@ -1,28 +1,24 @@ -;(function () { - const toggleDropdown = () => { - const dropdown = document.getElementById('watchlist-dropdown') - if (!dropdown) { - return - } - - dropdown.classList.toggle('open') - } - - window.toggleDropdown = toggleDropdown - - document.addEventListener('click', (event) => { - const dropdown = document.getElementById('watchlist-dropdown') - if (!dropdown) { - return - } - - const target = event.target - if (!(target instanceof Node)) { - return - } - - if (!dropdown.contains(target)) { - dropdown.classList.remove('open') - } - }) -})() +"use strict"; +(() => { + const toggleDropdown = () => { + const dropdown = document.getElementById('watchlist-dropdown'); + if (!dropdown) { + return; + } + dropdown.classList.toggle('open'); + }; + window.toggleDropdown = toggleDropdown; + document.addEventListener('click', (event) => { + const dropdown = document.getElementById('watchlist-dropdown'); + if (!dropdown) { + return; + } + const target = event.target; + if (!(target instanceof Node)) { + return; + } + if (!dropdown.contains(target)) { + dropdown.classList.remove('open'); + } + }); +})(); diff --git a/static/js/auth.js b/static/js/auth.js index a1320a3..a1ce6fc 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1,19 +1,23 @@ +"use strict"; function copyRecoveryKey(keyElementId, feedbackElementId) { - var keyElement = document.getElementById(keyElementId) - var feedbackElement = document.getElementById(feedbackElementId) - - if (!keyElement || !feedbackElement) { - return - } - - var key = keyElement.textContent || '' - navigator.clipboard.writeText(key).then(function () { - feedbackElement.textContent = 'Recovery key copied.' - }).catch(function () { - feedbackElement.textContent = 'Copy failed. Select and copy manually.' - }) + const keyElement = document.getElementById(keyElementId); + const feedbackElement = document.getElementById(feedbackElementId); + if (!keyElement || !feedbackElement) { + return; + } + const key = keyElement.textContent || ''; + navigator.clipboard + .writeText(key) + .then(() => { + feedbackElement.textContent = 'Recovery key copied.'; + }) + .catch(() => { + feedbackElement.textContent = 'Copy failed. Select and copy manually.'; + }); } - function confirmDangerAction(message) { - return window.confirm(message) + return window.confirm(message); } +; +window.copyRecoveryKey = copyRecoveryKey; +window.confirmDangerAction = confirmDangerAction; diff --git a/static/js/discover.js b/static/js/discover.js index f445a94..8192ab5 100644 --- a/static/js/discover.js +++ b/static/js/discover.js @@ -1,26 +1,23 @@ -;(function () { - const setActiveTab = (clickedTab) => { - const group = clickedTab.closest('[data-tab-group="discover"]') - if (!group) { - return - } - - const triggers = group.querySelectorAll('[data-tab-trigger]') - triggers.forEach((tab) => tab.classList.remove('active')) - clickedTab.classList.add('active') - } - - document.addEventListener('click', (event) => { - const target = event.target - if (!(target instanceof Element)) { - return - } - - const trigger = target.closest('[data-tab-trigger]') - if (!trigger) { - return - } - - setActiveTab(trigger) - }) -})() +"use strict"; +(() => { + const setActiveTab = (clickedTab) => { + const group = clickedTab.closest('[data-tab-group="discover"]'); + if (!group) { + return; + } + const triggers = group.querySelectorAll('[data-tab-trigger]'); + triggers.forEach((tab) => tab.classList.remove('active')); + clickedTab.classList.add('active'); + }; + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + const trigger = target.closest('[data-tab-trigger]'); + if (!trigger) { + return; + } + setActiveTab(trigger); + }); +})(); diff --git a/static/js/search.js b/static/js/search.js index fce14d3..c3704c0 100644 --- a/static/js/search.js +++ b/static/js/search.js @@ -1,108 +1,109 @@ -(function() { - if (window.searchInitialized) return - window.searchInitialized = true - - let searchTimeout - const searchInput = document.getElementById('search-input') - const searchDropdown = document.getElementById('search-dropdown') - - if (searchInput) { - searchInput.addEventListener('input', function(e) { - clearTimeout(searchTimeout) - const query = e.target.value.trim() - - if (query.length < 2) { - searchDropdown.replaceChildren() - return - } - - searchTimeout = setTimeout(() => { - fetch('/api/search-quick?q=' + encodeURIComponent(query)) - .then(res => res.json()) - .then(results => { - if (!results || results.length === 0) { - searchDropdown.replaceChildren() - return - } - - const searchResults = document.createElement('div') - searchResults.className = 'search-results' - - const title = document.createElement('div') - title.className = 'search-results-title' - title.textContent = 'Anime' - searchResults.appendChild(title) - - results.forEach(r => { - const item = document.createElement('a') - item.className = 'search-result-item' - item.setAttribute('href', '/anime/' + encodeURIComponent(String(r.id || ''))) - - if (isSafeImageUrl(r.image)) { - const img = document.createElement('img') - img.className = 'search-result-thumb' - img.setAttribute('src', r.image) - img.setAttribute('alt', String(r.title || '')) - item.appendChild(img) - } else { - const noImage = document.createElement('div') - noImage.className = 'search-result-no-image' - noImage.textContent = 'no image' - item.appendChild(noImage) - } - - const info = document.createElement('div') - info.className = 'search-result-info' - - const itemTitle = document.createElement('div') - itemTitle.className = 'search-result-title' - itemTitle.textContent = String(r.title || '') - info.appendChild(itemTitle) - - const itemType = document.createElement('div') - itemType.className = 'search-result-type' - itemType.textContent = String(r.type || '') - info.appendChild(itemType) - - item.appendChild(info) - searchResults.appendChild(item) +"use strict"; +(() => { + const globalWindow = window; + if (globalWindow.searchInitialized) { + return; + } + globalWindow.searchInitialized = true; + let searchTimeout; + const searchInput = document.getElementById('search-input'); + const searchDropdown = document.getElementById('search-dropdown'); + if (!searchInput || !searchDropdown) { + return; + } + searchInput.addEventListener('input', (event) => { + if (searchTimeout) { + window.clearTimeout(searchTimeout); + } + const target = event.target; + if (!(target instanceof HTMLInputElement)) { + return; + } + const query = target.value.trim(); + if (query.length < 2) { + searchDropdown.replaceChildren(); + return; + } + searchTimeout = window.setTimeout(() => { + fetch('/api/search-quick?q=' + encodeURIComponent(query)) + .then((res) => res.json()) + .then((results) => { + if (!results || results.length === 0) { + searchDropdown.replaceChildren(); + return; + } + const searchResults = document.createElement('div'); + searchResults.className = 'search-results'; + const title = document.createElement('div'); + title.className = 'search-results-title'; + title.textContent = 'Anime'; + searchResults.appendChild(title); + results.forEach((result) => { + const item = document.createElement('a'); + item.className = 'search-result-item'; + item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))); + if (isSafeImageUrl(result.image)) { + const img = document.createElement('img'); + img.className = 'search-result-thumb'; + img.setAttribute('src', result.image || ''); + img.setAttribute('alt', String(result.title || '')); + item.appendChild(img); + } + else { + const noImage = document.createElement('div'); + noImage.className = 'search-result-no-image'; + noImage.textContent = 'no image'; + item.appendChild(noImage); + } + const info = document.createElement('div'); + info.className = 'search-result-info'; + const itemTitle = document.createElement('div'); + itemTitle.className = 'search-result-title'; + itemTitle.textContent = String(result.title || ''); + info.appendChild(itemTitle); + const itemType = document.createElement('div'); + itemType.className = 'search-result-type'; + itemType.textContent = String(result.type || ''); + info.appendChild(itemType); + item.appendChild(info); + searchResults.appendChild(item); + }); + const viewAll = document.createElement('a'); + viewAll.className = 'search-result-view-all'; + viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)); + viewAll.textContent = 'View all results for ' + query; + searchResults.appendChild(viewAll); + searchDropdown.replaceChildren(searchResults); }) - - const viewAll = document.createElement('a') - viewAll.className = 'search-result-view-all' - viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)) - viewAll.textContent = 'View all results for ' + query - searchResults.appendChild(viewAll) - - searchDropdown.replaceChildren(searchResults) - }) - .catch(err => console.error('Search error:', err)) - }, 300) - }) - + .catch((err) => { + console.error('Search error:', err); + }); + }, 300); + }); searchInput.addEventListener('blur', () => { - setTimeout(() => { - searchDropdown.replaceChildren() - }, 200) - }) - - document.addEventListener('click', (e) => { - if (!e.target.closest('.header-search-wrapper')) { - searchDropdown.replaceChildren() - } - }) - } - - function isSafeImageUrl(rawUrl) { - if (!rawUrl || typeof rawUrl !== 'string') { - return false + window.setTimeout(() => { + searchDropdown.replaceChildren(); + }, 200); + }); + document.addEventListener('click', (event) => { + const target = event.target; + if (!(target instanceof Element)) { + return; + } + if (!target.closest('.header-search-wrapper')) { + searchDropdown.replaceChildren(); + } + }); + function isSafeImageUrl(rawUrl) { + if (!rawUrl || typeof rawUrl !== 'string') { + return false; + } + try { + const parsed = new URL(rawUrl, window.location.origin); + return parsed.protocol === 'https:' || parsed.protocol === 'http:'; + } + catch { + return false; + } } - - try { - const parsed = new URL(rawUrl, window.location.origin) - return parsed.protocol === 'https:' || parsed.protocol === 'http:' - } catch { - return false - } - } -})() +})(); diff --git a/static/js/timezone.js b/static/js/timezone.js index 66ce69b..50ea426 100644 --- a/static/js/timezone.js +++ b/static/js/timezone.js @@ -1,240 +1,195 @@ -;(function () { - const jstOffsetMinutes = 9 * 60 - - const parseBroadcastTime = (value) => { - if (!value || typeof value !== 'string') { - return null - } - - const match = value.trim().match(/^(\d{1,2}):(\d{2})$/) - if (!match) { - 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 } - } - - const isJstTimezone = (timezone) => { - if (!timezone) { - return true - } - - const normalized = timezone.trim().toLowerCase() - return normalized === 'asia/tokyo' || normalized === 'jst' - } - - const parseFromStructuredAttrs = (node) => { - 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 - } - - const parsedTime = parseBroadcastTime(time) - if (!parsedTime) { - return null - } - - return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute } - } - - const parseBroadcast = (text) => { - if (!text || typeof text !== 'string') { - return null - } - - const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i) - if (!match) { - return null - } - - 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 - } - - if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { - return null - } - - return { day, hour, minute } - } - - const normalizeDay = (day) => { - const key = day.trim().toLowerCase().replace(/s$/, '') - const days = { - mon: 1, - monday: 1, - tue: 2, - tues: 2, - tuesday: 2, - wed: 3, - wednesday: 3, - thu: 4, - thur: 4, - thurs: 4, - thursday: 4, - fri: 5, - friday: 5, - sat: 6, - saturday: 6, - sun: 0, - sunday: 0, - } - - if (typeof days[key] !== 'number') { - return null - } - - return days[key] - } - - const convertToLocal = (parsed, localOffsetMinutes) => { - 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 sourceDayIndex = normalizeDay(parsed.day) - if (sourceDayIndex === null) { - return null - } - - 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 nextAiringUTC = (parsed) => { - const targetDay = normalizeDay(parsed.day) - if (targetDay === null) { - return null - } - - 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 - - let dayDelta = (targetDay - currentDay + 7) % 7 - if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) { - dayDelta = 7 - } - - const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay) - return new Date(now.getTime() + minuteDelta * 60 * 1000) - } - - const relativeText = (target) => { - const diffMs = target.getTime() - Date.now() - if (diffMs <= 0) { - return 'soon' - } - - const minutes = Math.ceil(diffMs / 60000) - if (minutes < 60) { - return formatRelative(minutes, 'minute') - } - - const hours = Math.ceil(minutes / 60) - if (hours < 36) { - return formatRelative(hours, 'hour') - } - - const days = Math.ceil(hours / 24) - return formatRelative(days, 'day') - } - - const formatRelative = (value, unit) => { - if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') { - 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 localDateTimeText = (date) => { - const formatter = new Intl.DateTimeFormat(undefined, { - weekday: 'short', - hour: '2-digit', - minute: '2-digit', - }) - return formatter.format(date) - } - - const updateNextAiring = (node, parsed) => { - const card = node.closest('.notification-content') - if (!card) { - return - } - - const nextNode = card.querySelector('[data-next-airing]') - if (!(nextNode instanceof HTMLElement)) { - return - } - - const nextDate = nextAiringUTC(parsed) - if (!nextDate) { - nextNode.remove() - return - } - - nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})` - } - - const updateNode = (node, localOffsetMinutes) => { - const card = node.closest('.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) - if (!parsed) { - if (nextNode instanceof HTMLElement) { - nextNode.remove() - } - return - } - - const converted = convertToLocal(parsed, localOffsetMinutes) - if (!converted) { - if (nextNode instanceof HTMLElement) { - nextNode.remove() - } - return - } - - node.textContent = converted - updateNextAiring(node, parsed) - } - - const updateAll = () => { - const localOffsetMinutes = -new Date().getTimezoneOffset() - const nodes = document.querySelectorAll('[data-jst-text]') - nodes.forEach((node) => updateNode(node, localOffsetMinutes)) - } - - document.addEventListener('DOMContentLoaded', updateAll) - document.body.addEventListener('htmx:afterSwap', updateAll) -})() +"use strict"; +(() => { + const jstOffsetMinutes = 9 * 60; + const parseBroadcastTime = (value) => { + if (!value || typeof value !== 'string') { + return null; + } + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/); + if (!match) { + 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 }; + }; + const isJstTimezone = (timezone) => { + if (!timezone) { + return true; + } + const normalized = timezone.trim().toLowerCase(); + return normalized === 'asia/tokyo' || normalized === 'jst'; + }; + const parseFromStructuredAttrs = (node) => { + 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; + } + const parsedTime = parseBroadcastTime(time); + if (!parsedTime) { + return null; + } + return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute }; + }; + const parseBroadcast = (text) => { + if (!text || typeof text !== 'string') { + return null; + } + const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i); + if (!match) { + return null; + } + 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; + } + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null; + } + return { day, hour, minute }; + }; + const normalizeDay = (day) => { + const key = day.trim().toLowerCase().replace(/s$/, ''); + const days = { + mon: 1, + monday: 1, + tue: 2, + tues: 2, + tuesday: 2, + wed: 3, + wednesday: 3, + thu: 4, + thur: 4, + thurs: 4, + thursday: 4, + fri: 5, + friday: 5, + sat: 6, + saturday: 6, + sun: 0, + sunday: 0, + }; + if (typeof days[key] !== 'number') { + return null; + } + return days[key]; + }; + const convertToLocal = (parsed, localOffsetMinutes) => { + 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 sourceDayIndex = normalizeDay(parsed.day); + if (sourceDayIndex === null) { + return null; + } + 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 nextAiringUTC = (parsed) => { + const targetDay = normalizeDay(parsed.day); + if (targetDay === null) { + return null; + } + 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; + let dayDelta = (targetDay - currentDay + 7) % 7; + if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) { + dayDelta = 7; + } + const minuteDelta = dayDelta * 1440 + (targetMinuteOfDay - currentMinuteOfDay); + return new Date(now.getTime() + minuteDelta * 60 * 1000); + }; + const formatRelative = (value, unit) => { + if (typeof Intl !== 'undefined' && typeof Intl.RelativeTimeFormat === 'function') { + 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 relativeText = (target) => { + const diffMs = target.getTime() - Date.now(); + if (diffMs <= 0) { + return 'soon'; + } + const minutes = Math.ceil(diffMs / 60000); + if (minutes < 60) { + return formatRelative(minutes, 'minute'); + } + const hours = Math.ceil(minutes / 60); + if (hours < 36) { + return formatRelative(hours, 'hour'); + } + const days = Math.ceil(hours / 24); + return formatRelative(days, 'day'); + }; + const localDateTimeText = (date) => { + const formatter = new Intl.DateTimeFormat(undefined, { + weekday: 'short', + hour: '2-digit', + minute: '2-digit', + }); + return formatter.format(date); + }; + const updateNextAiring = (node, parsed) => { + const card = node.closest('.notification-content'); + if (!card) { + return; + } + const nextNode = card.querySelector('[data-next-airing]'); + if (!(nextNode instanceof HTMLElement)) { + return; + } + const nextDate = nextAiringUTC(parsed); + if (!nextDate) { + nextNode.remove(); + return; + } + nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`; + }; + const updateNode = (node, localOffsetMinutes) => { + const card = node.closest('.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); + if (!parsed) { + if (nextNode instanceof HTMLElement) { + nextNode.remove(); + } + return; + } + const converted = convertToLocal(parsed, localOffsetMinutes); + if (!converted) { + if (nextNode instanceof HTMLElement) { + nextNode.remove(); + } + return; + } + node.textContent = converted; + updateNextAiring(node, parsed); + }; + const updateAll = () => { + const localOffsetMinutes = -new Date().getTimezoneOffset(); + const nodes = document.querySelectorAll('[data-jst-text]'); + nodes.forEach((node) => updateNode(node, localOffsetMinutes)); + }; + document.addEventListener('DOMContentLoaded', updateAll); + document.body.addEventListener('htmx:afterSwap', updateAll); +})(); diff --git a/static/ts/anime.ts b/static/ts/anime.ts new file mode 100644 index 0000000..d823c78 --- /dev/null +++ b/static/ts/anime.ts @@ -0,0 +1,28 @@ +((): void => { + const toggleDropdown = (): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + dropdown.classList.toggle('open') + } + + ;(window as Window & { toggleDropdown?: () => void }).toggleDropdown = toggleDropdown + + document.addEventListener('click', (event: MouseEvent): void => { + const dropdown = document.getElementById('watchlist-dropdown') + if (!dropdown) { + return + } + + const target = event.target + if (!(target instanceof Node)) { + return + } + + if (!dropdown.contains(target)) { + dropdown.classList.remove('open') + } + }) +})() diff --git a/static/ts/auth.ts b/static/ts/auth.ts new file mode 100644 index 0000000..dab5040 --- /dev/null +++ b/static/ts/auth.ts @@ -0,0 +1,25 @@ +function copyRecoveryKey(keyElementId: string, feedbackElementId: string): void { + const keyElement = document.getElementById(keyElementId) + const feedbackElement = document.getElementById(feedbackElementId) + + if (!keyElement || !feedbackElement) { + return + } + + const key = keyElement.textContent || '' + navigator.clipboard + .writeText(key) + .then((): void => { + feedbackElement.textContent = 'Recovery key copied.' + }) + .catch((): void => { + feedbackElement.textContent = 'Copy failed. Select and copy manually.' + }) +} + +function confirmDangerAction(message: string): boolean { + return window.confirm(message) +} + +;(window as Window & { copyRecoveryKey?: typeof copyRecoveryKey; confirmDangerAction?: typeof confirmDangerAction }).copyRecoveryKey = copyRecoveryKey +;(window as Window & { copyRecoveryKey?: typeof copyRecoveryKey; confirmDangerAction?: typeof confirmDangerAction }).confirmDangerAction = confirmDangerAction diff --git a/static/ts/discover.ts b/static/ts/discover.ts new file mode 100644 index 0000000..1ab193f --- /dev/null +++ b/static/ts/discover.ts @@ -0,0 +1,26 @@ +((): void => { + const setActiveTab = (clickedTab: Element): void => { + const group = clickedTab.closest('[data-tab-group="discover"]') + if (!group) { + return + } + + const triggers = group.querySelectorAll('[data-tab-trigger]') + triggers.forEach((tab: Element): void => tab.classList.remove('active')) + clickedTab.classList.add('active') + } + + document.addEventListener('click', (event: MouseEvent): void => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + const trigger = target.closest('[data-tab-trigger]') + if (!trigger) { + return + } + + setActiveTab(trigger) + }) +})() diff --git a/static/ts/search.ts b/static/ts/search.ts new file mode 100644 index 0000000..2ee2a55 --- /dev/null +++ b/static/ts/search.ts @@ -0,0 +1,127 @@ +((): void => { + const globalWindow = window as Window & { searchInitialized?: boolean } + if (globalWindow.searchInitialized) { + return + } + globalWindow.searchInitialized = true + + let searchTimeout: number | undefined + const searchInput = document.getElementById('search-input') as HTMLInputElement | null + const searchDropdown = document.getElementById('search-dropdown') + + if (!searchInput || !searchDropdown) { + return + } + + searchInput.addEventListener('input', (event: Event): void => { + if (searchTimeout) { + window.clearTimeout(searchTimeout) + } + + const target = event.target + if (!(target instanceof HTMLInputElement)) { + return + } + + const query = target.value.trim() + if (query.length < 2) { + searchDropdown.replaceChildren() + return + } + + searchTimeout = window.setTimeout((): void => { + fetch('/api/search-quick?q=' + encodeURIComponent(query)) + .then((res: Response) => res.json()) + .then((results: Array<{ id?: number; image?: string; title?: string; type?: string }>): void => { + if (!results || results.length === 0) { + searchDropdown.replaceChildren() + return + } + + const searchResults = document.createElement('div') + searchResults.className = 'search-results' + + const title = document.createElement('div') + title.className = 'search-results-title' + title.textContent = 'Anime' + searchResults.appendChild(title) + + results.forEach((result): void => { + const item = document.createElement('a') + item.className = 'search-result-item' + item.setAttribute('href', '/anime/' + encodeURIComponent(String(result.id || ''))) + + if (isSafeImageUrl(result.image)) { + const img = document.createElement('img') + img.className = 'search-result-thumb' + img.setAttribute('src', result.image || '') + img.setAttribute('alt', String(result.title || '')) + item.appendChild(img) + } else { + const noImage = document.createElement('div') + noImage.className = 'search-result-no-image' + noImage.textContent = 'no image' + item.appendChild(noImage) + } + + const info = document.createElement('div') + info.className = 'search-result-info' + + const itemTitle = document.createElement('div') + itemTitle.className = 'search-result-title' + itemTitle.textContent = String(result.title || '') + info.appendChild(itemTitle) + + const itemType = document.createElement('div') + itemType.className = 'search-result-type' + itemType.textContent = String(result.type || '') + info.appendChild(itemType) + + item.appendChild(info) + searchResults.appendChild(item) + }) + + const viewAll = document.createElement('a') + viewAll.className = 'search-result-view-all' + viewAll.setAttribute('href', '/search?q=' + encodeURIComponent(query)) + viewAll.textContent = 'View all results for ' + query + searchResults.appendChild(viewAll) + + searchDropdown.replaceChildren(searchResults) + }) + .catch((err: unknown): void => { + console.error('Search error:', err) + }) + }, 300) + }) + + searchInput.addEventListener('blur', (): void => { + window.setTimeout((): void => { + searchDropdown.replaceChildren() + }, 200) + }) + + document.addEventListener('click', (event: MouseEvent): void => { + const target = event.target + if (!(target instanceof Element)) { + return + } + + if (!target.closest('.header-search-wrapper')) { + searchDropdown.replaceChildren() + } + }) + + function isSafeImageUrl(rawUrl?: string): boolean { + if (!rawUrl || typeof rawUrl !== 'string') { + return false + } + + try { + const parsed = new URL(rawUrl, window.location.origin) + return parsed.protocol === 'https:' || parsed.protocol === 'http:' + } catch { + return false + } + } +})() diff --git a/static/ts/timezone.ts b/static/ts/timezone.ts new file mode 100644 index 0000000..6af5bd1 --- /dev/null +++ b/static/ts/timezone.ts @@ -0,0 +1,246 @@ +((): void => { + const jstOffsetMinutes = 9 * 60 + + type ParsedBroadcast = { + day: string + hour: number + minute: number + } + + const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => { + if (!value || typeof value !== 'string') { + return null + } + + const match = value.trim().match(/^(\d{1,2}):(\d{2})$/) + if (!match) { + 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 } + } + + const isJstTimezone = (timezone: string | null): boolean => { + if (!timezone) { + return true + } + + 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') + + if (!day || !time || !isJstTimezone(timezone)) { + return null + } + + const parsedTime = parseBroadcastTime(time) + if (!parsedTime) { + return null + } + + return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute } + } + + const parseBroadcast = (text: string | null): ParsedBroadcast | null => { + if (!text || typeof text !== 'string') { + return null + } + + const match = text.match(/^(.*) at (\d{1,2}):(\d{2}) \(JST\)$/i) + if (!match) { + return null + } + + 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 + } + + if (hour < 0 || hour > 23 || minute < 0 || minute > 59) { + return null + } + + return { day, hour, minute } + } + + const normalizeDay = (day: string): number | null => { + const key = day.trim().toLowerCase().replace(/s$/, '') + const days: Record = { + mon: 1, + monday: 1, + tue: 2, + tues: 2, + tuesday: 2, + wed: 3, + wednesday: 3, + thu: 4, + thur: 4, + thurs: 4, + thursday: 4, + fri: 5, + friday: 5, + sat: 6, + saturday: 6, + sun: 0, + sunday: 0, + } + + if (typeof days[key] !== 'number') { + return null + } + + 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 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) + if (sourceDayIndex === null) { + return null + } + + 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 nextAiringUTC = (parsed: ParsedBroadcast): Date | null => { + const targetDay = normalizeDay(parsed.day) + if (targetDay === null) { + return null + } + + 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 + + let dayDelta = (targetDay - currentDay + 7) % 7 + if (dayDelta === 0 && targetMinuteOfDay <= currentMinuteOfDay) { + dayDelta = 7 + } + + 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 suffix = value === 1 ? unit : `${unit}s` + return `in ${value} ${suffix}` + } + + const relativeText = (target: Date): string => { + const diffMs = target.getTime() - Date.now() + if (diffMs <= 0) { + return 'soon' + } + + const minutes = Math.ceil(diffMs / 60000) + if (minutes < 60) { + return formatRelative(minutes, 'minute') + } + + const hours = Math.ceil(minutes / 60) + if (hours < 36) { + return formatRelative(hours, 'hour') + } + + 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) + } + + const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => { + const card = node.closest('.notification-content') + if (!card) { + return + } + + const nextNode = card.querySelector('[data-next-airing]') + if (!(nextNode instanceof HTMLElement)) { + return + } + + const nextDate = nextAiringUTC(parsed) + if (!nextDate) { + nextNode.remove() + return + } + + nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})` + } + + const updateNode = (node: Element, localOffsetMinutes: number): void => { + const card = node.closest('.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) + if (!parsed) { + if (nextNode instanceof HTMLElement) { + nextNode.remove() + } + return + } + + const converted = convertToLocal(parsed, localOffsetMinutes) + if (!converted) { + if (nextNode instanceof HTMLElement) { + nextNode.remove() + } + return + } + + node.textContent = converted + updateNextAiring(node, parsed) + } + + const updateAll = (): void => { + const localOffsetMinutes = -new Date().getTimezoneOffset() + const nodes = document.querySelectorAll('[data-jst-text]') + nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes)) + } + + document.addEventListener('DOMContentLoaded', updateAll) + document.body.addEventListener('htmx:afterSwap', updateAll) +})() From dd6f49b1c98e01cc1eafad2e58238c31ab92a853 Mon Sep 17 00:00:00 2001 From: mkelvers Date: Tue, 14 Apr 2026 23:43:54 +0200 Subject: [PATCH 3/9] ui: start tailwind utility migration --- internal/shared/ui/empty_state.templ | 6 +-- internal/shared/ui/loading.templ | 8 ++-- internal/templates/auth.templ | 58 ++++++++++++++-------------- internal/templates/not_found.templ | 10 ++--- 4 files changed, 41 insertions(+), 41 deletions(-) diff --git a/internal/shared/ui/empty_state.templ b/internal/shared/ui/empty_state.templ index ab0dd4b..6aa9f82 100644 --- a/internal/shared/ui/empty_state.templ +++ b/internal/shared/ui/empty_state.templ @@ -1,9 +1,9 @@ package ui templ EmptyState(title string) { -
-
{ title }
-
+
+
{ title }
+
{ children... }
diff --git a/internal/shared/ui/loading.templ b/internal/shared/ui/loading.templ index 3dac5a5..2ac6439 100644 --- a/internal/shared/ui/loading.templ +++ b/internal/shared/ui/loading.templ @@ -1,10 +1,10 @@ package ui templ LoadingIndicator(text string) { -
-
-
-
+
+
+
+
{ text }
} diff --git a/internal/templates/auth.templ b/internal/templates/auth.templ index edd44ed..12086ef 100644 --- a/internal/templates/auth.templ +++ b/internal/templates/auth.templ @@ -2,28 +2,28 @@ package templates templ Login(formError string, username string) { @Layout("Login", false) { -
-