Merge pull request #5 from mkelvers/nxl/ui-tailwind-ts-foundation

add tailwind v4 + typescript foundation for ui migration
This commit is contained in:
2026-04-15 00:14:22 +02:00
committed by GitHub
31 changed files with 847 additions and 1817 deletions

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ node_modules
out out
dist dist
*.tgz *.tgz
static/css/tailwind.css
static/js/*.js
# code coverage # code coverage
coverage coverage

View File

@@ -14,9 +14,15 @@ Thanks for your interest in improving MAL.
# install templ CLI # install templ CLI
go install github.com/a-h/templ/cmd/templ@latest go install github.com/a-h/templ/cmd/templ@latest
# install frontend tooling
bun install
# generate templates # generate templates
templ generate templ generate
# build frontend assets (tailwind + typescript)
bun run build:assets
# run tests # run tests
go test ./... go test ./...
@@ -24,6 +30,9 @@ go test ./...
go run ./cmd/server go run ./cmd/server
``` ```
TypeScript source files live in `static/js/*.ts` and are bundled to matching `static/js/*.js` files for runtime.
Generated `static/js/*.js` and `static/css/tailwind.css` files are ignored by git.
## Development guidelines ## Development guidelines
- Follow existing folder boundaries (`internal/features/*`, `internal/jikan`, `internal/templates`) - Follow existing folder boundaries (`internal/features/*`, `internal/jikan`, `internal/templates`)

View File

@@ -8,11 +8,22 @@ ENV CGO_ENABLED=1
# Install templ # Install templ
RUN go install github.com/a-h/templ/cmd/templ@latest RUN go install github.com/a-h/templ/cmd/templ@latest
# Install bun for frontend asset builds
RUN apt-get update && apt-get install -y curl unzip && rm -rf /var/lib/apt/lists/*
RUN curl -fsSL https://bun.sh/install | bash
ENV PATH="/root/.bun/bin:${PATH}"
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . . COPY . .
# Build frontend assets (tailwind + ts)
RUN bun run build:assets
# Generate templ files # Generate templ files
RUN templ generate RUN templ generate

View File

@@ -73,14 +73,18 @@ There are still honest limits. Metadata quality still depends partly on external
## Getting started ## 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 ```bash
go install github.com/a-h/templ/cmd/templ@latest go install github.com/a-h/templ/cmd/templ@latest
bun install
templ generate templ generate
bun run build:assets
go run ./cmd/server go run ./cmd/server
``` ```
The frontend pipeline uses a single source stylesheet (`static/css/style.css`) and TypeScript sources in `static/js/*.ts`, then emits build artifacts (`static/css/tailwind.css` and `static/js/*.js`) for serving.
When the server starts, the app is available at `http://localhost:3000`. When the server starts, the app is available at `http://localhost:3000`.
For containerized usage, the included `Dockerfile` uses a multi-stage build that generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support. For containerized usage, the included `Dockerfile` uses a multi-stage build that generates templates, compiles `cmd/server`, and ships a slim runtime image with SQLite support.

149
bun.lock Normal file
View File

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

View File

@@ -15,37 +15,37 @@ type AnimeCardProps struct {
templ AnimeCard(props AnimeCardProps) { templ AnimeCard(props AnimeCardProps) {
if props.CurrentNode { if props.CurrentNode {
<div class={ defaultString(props.Class, "catalog-item") }> <div class={ defaultString(props.Class, "min-w-0") }>
if props.ImageURL != "" { if props.ImageURL != "" {
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "catalog-thumb") } loading="lazy"/> <img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "block aspect-[2/3] max-h-[var(--poster-max-height)] w-full object-cover object-center") } loading="lazy"/>
} else { } else {
<div class="no-image">No image</div> <div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
} }
<div class={ defaultString(props.TitleClass, "catalog-title") }> <div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]") }>
{ props.Title } { props.Title }
</div> </div>
{ children... } { children... }
</div> </div>
} else { } else {
<a href={ templ.URL(fmt.Sprintf("/anime/%d", props.ID)) } class={ props.Class }> <a href={ templ.URL(fmt.Sprintf("/anime/%d", props.ID)) } class={ defaultString(props.Class, "flex flex-col bg-transparent text-inherit no-underline") }>
if props.Class == "notification-card" || props.Class == "schedule-card" { if props.Class == "notification-card" || props.Class == "schedule-card" {
<div class={ defaultString(props.ImgClass, "schedule-card-image") }> <div class={ defaultString(props.ImgClass, "flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden") }>
if props.ImageURL != "" { if props.ImageURL != "" {
<img src={ props.ImageURL } alt={ props.Title } loading="lazy"/> <img src={ props.ImageURL } alt={ props.Title } class="block h-full w-full object-cover object-center" loading="lazy"/>
} else { } else {
<div class="no-image">No image</div> <div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
} }
</div> </div>
} else { } else {
if props.ImageURL != "" { if props.ImageURL != "" {
<img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "catalog-thumb") } loading="lazy"/> <img src={ props.ImageURL } alt={ props.Title } class={ defaultString(props.ImgClass, "block aspect-[2/3] max-h-[var(--poster-max-height)] w-full object-cover object-center") } loading="lazy"/>
} else { } else {
<div class="no-image">No image</div> <div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
} }
} }
if props.Class != "notification-card" && props.Class != "schedule-card" { if props.Class != "notification-card" && props.Class != "schedule-card" {
<div class={ defaultString(props.TitleClass, "catalog-title") }> <div class={ defaultString(props.TitleClass, "mt-2 line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]") }>
{ props.Title } { props.Title }
</div> </div>
} }

View File

@@ -7,12 +7,12 @@ import (
templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, containerID string) { templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, containerID string) {
for _, anime := range animes { for _, anime := range animes {
<div class="catalog-item" data-id={ fmt.Sprintf("%d", anime.MalID) }> <div class="min-w-0" data-id={ fmt.Sprintf("%d", anime.MalID) }>
@CatalogItem(anime) @CatalogItem(anime)
</div> </div>
} }
if hasNext { if hasNext {
<div class="scroll-trigger full-span-trigger" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div> <div class="col-span-full h-px w-full" hx-get={ nextURL } hx-trigger="revealed" hx-swap="outerHTML"></div>
} }
<script data-container={ containerID }> <script data-container={ containerID }>
(function() { (function() {
@@ -20,7 +20,7 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
const currentScript = scripts[scripts.length - 1]; const currentScript = scripts[scripts.length - 1];
const actualID = currentScript.getAttribute('data-container'); const actualID = currentScript.getAttribute('data-container');
const container = document.getElementById(actualID) || document; const container = document.getElementById(actualID) || document;
const items = container.querySelectorAll('.catalog-item[data-id]'); const items = container.querySelectorAll('[data-id]');
const seen = new Set(); const seen = new Set();
items.forEach(item => { items.forEach(item => {
const id = item.getAttribute('data-id'); const id = item.getAttribute('data-id');

View File

@@ -1,9 +1,9 @@
package ui package ui
templ EmptyState(title string) { templ EmptyState(title string) {
<div class="empty-state"> <div class="py-4">
<div class="empty-state-title">{ title }</div> <div class="mb-2 text-base">{ title }</div>
<div class="empty-state-text"> <div class="text-sm text-[var(--text-muted)]">
{ children... } { children... }
</div> </div>
</div> </div>

View File

@@ -1,10 +1,10 @@
package ui package ui
templ LoadingIndicator(text string) { templ LoadingIndicator(text string) {
<div class="loading-indicator"> <div class="inline-flex items-center gap-2 text-sm text-[var(--text-muted)]">
<div class="loading-dot"></div> <div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div>
<div class="loading-dot"></div> <div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div>
<div class="loading-dot"></div> <div class="h-1.5 w-1.5 rounded-full bg-[var(--text-faint)]"></div>
<span>{ text }</span> <span>{ text }</span>
</div> </div>
} }

View File

@@ -8,23 +8,23 @@ type SortFilterOptions struct {
} }
templ SortFilter(opts SortFilterOptions) { templ SortFilter(opts SortFilterOptions) {
<div class="sort-filter"> <div class="mb-4 flex flex-wrap items-center gap-3 bg-[var(--panel)] p-3 max-[860px]:flex-col max-[860px]:items-start max-[860px]:gap-2">
<div class="sort-filter-group"> <div class="flex items-center gap-2">
<label for="sort-select">Sort by</label> <label for="sort-select" class="text-[0.78rem] text-[var(--text-muted)]">Sort by</label>
<select id="sort-select" class="sort-filter-select" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()"> <select id="sort-select" class="h-[30px] bg-[var(--surface-select)] px-2 text-[0.78rem] text-[var(--text)]" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()">
<option value="date" selected?={ opts.Sort == "date" }>Date added</option> <option value="date" selected?={ opts.Sort == "date" }>Date added</option>
<option value="title" selected?={ opts.Sort == "title" }>Title</option> <option value="title" selected?={ opts.Sort == "title" }>Title</option>
</select> </select>
</div> </div>
<div class="sort-filter-group"> <div class="flex items-center gap-2">
<label for="order-select">Order</label> <label for="order-select" class="text-[0.78rem] text-[var(--text-muted)]">Order</label>
<select id="order-select" class="sort-filter-select" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()"> <select id="order-select" class="h-[30px] bg-[var(--surface-select)] px-2 text-[0.78rem] text-[var(--text)]" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()">
<option value="desc" selected?={ opts.Order == "desc" }>Descending</option> <option value="desc" selected?={ opts.Order == "desc" }>Descending</option>
<option value="asc" selected?={ opts.Order == "asc" }>Ascending</option> <option value="asc" selected?={ opts.Order == "asc" }>Ascending</option>
</select> </select>
</div> </div>
</div> </div>
<form id="sort-form" method="get" class="is-hidden"> <form id="sort-form" method="get" class="hidden">
<input type="hidden" name="sort" id="sort-input" value={ opts.Sort }/> <input type="hidden" name="sort" id="sort-input" value={ opts.Sort }/>
<input type="hidden" name="order" id="order-input" value={ opts.Order }/> <input type="hidden" name="order" id="order-input" value={ opts.Order }/>
if opts.View != "" { if opts.View != "" {

View File

@@ -7,150 +7,150 @@ import "strings"
templ AnimeDetails(anime jikan.Anime, currentStatus string) { templ AnimeDetails(anime jikan.Anime, currentStatus string) {
@Layout("mal - " + anime.DisplayTitle(), true) { @Layout("mal - " + anime.DisplayTitle(), true) {
<div class="anime-page"> <div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]">
<div class="anime-main"> <div class="grid min-w-0 gap-8">
<div class="anime-hero anime-surface"> <div class="grid grid-cols-[220px_minmax(0,1fr)] gap-5 max-[860px]:grid-cols-[minmax(0,1fr)]">
<div class="anime-poster"> <div class="w-[220px] max-[860px]:w-[min(230px,58vw)]">
if anime.ImageURL() != "" { if anime.ImageURL() != "" {
<img src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/> <img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
} else { } else {
<div class="no-image">No image</div> <div class="flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden text-[0] text-transparent">No image</div>
} }
</div> </div>
<div class="anime-info"> <div>
<h1>{ anime.DisplayTitle() }</h1> <h1>{ anime.DisplayTitle() }</h1>
if anime.TitleJapanese != "" { if anime.TitleJapanese != "" {
<p class="anime-alt-title">{ anime.TitleJapanese }</p> <p class="my-2 mb-3 text-[0.9rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</p>
} }
<div class="anime-quick-info"> <div class="flex flex-wrap gap-2">
if anime.ShortRating() != "" { if anime.ShortRating() != "" {
<span class="info-tag">{ anime.ShortRating() }</span> <span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortRating() }</span>
} }
if anime.Type != "" { if anime.Type != "" {
<span class="info-tag">{ anime.Type }</span> <span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.Type }</span>
} }
if anime.Episodes > 0 { if anime.Episodes > 0 {
<span class="info-tag">{ fmt.Sprintf("%d ep", anime.Episodes) }</span> <span class="text-[0.67rem] text-[var(--text-faint)]">{ fmt.Sprintf("%d ep", anime.Episodes) }</span>
} }
if anime.ShortDuration() != "" { if anime.ShortDuration() != "" {
<span class="info-tag">{ anime.ShortDuration() }</span> <span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortDuration() }</span>
} }
</div> </div>
<div class="anime-actions"> <div class="mt-3">
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing) @WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
</div> </div>
<section class="anime-synopsis anime-section"> <section class="mt-4 max-w-[100ch]">
if anime.Synopsis != "" { if anime.Synopsis != "" {
<p>{ anime.Synopsis }</p> <p>{ anime.Synopsis }</p>
} else { } else {
<p class="no-synopsis">No synopsis available.</p> <p class="text-[var(--text-faint)]">No synopsis available.</p>
} }
</section> </section>
</div> </div>
</div> </div>
<section class="anime-relations anime-surface anime-section"> <section>
<h3>Related</h3> <h3>Related</h3>
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load"> <div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
@ui.LoadingIndicator("Loading relations") @ui.LoadingIndicator("Loading relations")
</div> </div>
</section> </section>
<section class="anime-recommendations anime-surface anime-section"> <section>
<h3>Recommendations</h3> <h3>Recommendations</h3>
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load"> <div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load">
@ui.LoadingIndicator("Loading recommendations") @ui.LoadingIndicator("Loading recommendations")
</div> </div>
</section> </section>
</div> </div>
<aside class="anime-sidebar anime-surface"> <aside class="sticky top-[74px] grid gap-4 bg-[var(--panel)] p-3 max-[1040px]:static">
<div class="anime-side-section"> <div class="grid gap-3">
<h3>Details</h3> <h3 class="mb-2 text-[0.78rem] text-[var(--text-faint)]">Details</h3>
if anime.Aired.String != "" { if anime.Aired.String != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Aired</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Aired</span>
<span class="sidebar-value">{ anime.Aired.String }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Aired.String }</span>
</div> </div>
} }
if anime.Premiered() != "" { if anime.Premiered() != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Premiered</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Premiered</span>
<span class="sidebar-value">{ anime.Premiered() }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Premiered() }</span>
</div> </div>
} }
if anime.Status != "" { if anime.Status != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Status</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Status</span>
<span class="sidebar-value">{ anime.Status }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Status }</span>
</div> </div>
} }
if anime.Duration != "" { if anime.Duration != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Duration</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Duration</span>
<span class="sidebar-value">{ anime.Duration }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Duration }</span>
</div> </div>
} }
if len(anime.Genres) > 0 { if len(anime.Genres) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Genres</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Genres</span>
<span class="sidebar-value">{ joinNames(anime.Genres) }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Genres) }</span>
</div> </div>
} }
</div> </div>
if hasExtraSidebarDetails(anime) { if hasExtraSidebarDetails(anime) {
<details class="anime-side-section side-details-more"> <details class="grid gap-3">
<summary>More metadata</summary> <summary class="cursor-pointer text-[0.82rem] text-[var(--text-muted)]">More metadata</summary>
if anime.TitleJapanese != "" { if anime.TitleJapanese != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Japanese</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Japanese</span>
<span class="sidebar-value">{ anime.TitleJapanese }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</span>
</div> </div>
} }
if len(anime.TitleSynonyms) > 0 { if len(anime.TitleSynonyms) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Synonyms</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Synonyms</span>
<span class="sidebar-value">{ strings.Join(anime.TitleSynonyms, ", ") }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
</div> </div>
} }
if len(anime.Studios) > 0 { if len(anime.Studios) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Studios</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Studios</span>
<span class="sidebar-value">{ joinNames(anime.Studios) }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Studios) }</span>
</div> </div>
} }
if len(anime.Producers) > 0 { if len(anime.Producers) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Producers</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Producers</span>
<span class="sidebar-value">{ joinNames(anime.Producers) }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Producers) }</span>
</div> </div>
} }
if anime.Source != "" { if anime.Source != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Source</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Source</span>
<span class="sidebar-value">{ anime.Source }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Source }</span>
</div> </div>
} }
if len(anime.Demographics) > 0 { if len(anime.Demographics) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Demographics</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Demographics</span>
<span class="sidebar-value">{ joinNames(anime.Demographics) }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Demographics) }</span>
</div> </div>
} }
if len(anime.Themes) > 0 { if len(anime.Themes) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Themes</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Themes</span>
<span class="sidebar-value">{ joinNames(anime.Themes) }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Themes) }</span>
</div> </div>
} }
if anime.Broadcast.String != "" { if anime.Broadcast.String != "" {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Broadcast</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Broadcast</span>
<span class="sidebar-value" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span> <span class="text-[0.84rem] text-[var(--text-muted)]" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
</div> </div>
} }
if len(anime.Streaming) > 0 { if len(anime.Streaming) > 0 {
<div class="sidebar-row"> <div class="mt-1 grid gap-1">
<span class="sidebar-label">Streaming</span> <span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Streaming</span>
<span class="sidebar-value">{ joinStreamingNames(anime) }</span> <span class="text-[0.84rem] text-[var(--text-muted)]">{ joinStreamingNames(anime) }</span>
</div> </div>
} }
</details> </details>
} }
@@ -161,12 +161,12 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
templ AnimePending(id int) { templ AnimePending(id int) {
@Layout("mal - anime pending", true) { @Layout("mal - anime pending", true) {
<div class="anime-page"> <div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]">
<div class="anime-main"> <div class="grid min-w-0 gap-8">
<section class="anime-surface anime-section"> <section>
<h1>Anime data is being fetched</h1> <h1>Anime data is being fetched</h1>
<p class="empty-inline-note">We could not load this anime right now. A background worker is retrying data fetch for anime #{ fmt.Sprintf("%d", id) }.</p> <p class="text-[0.9rem] text-[var(--text-muted)]">We could not load this anime right now. A background worker is retrying data fetch for anime #{ fmt.Sprintf("%d", id) }.</p>
<p class="empty-inline-note">Refresh this page in a few seconds.</p> <p class="text-[0.9rem] text-[var(--text-muted)]">Refresh this page in a few seconds.</p>
</section> </section>
</div> </div>
</div> </div>
@@ -195,25 +195,24 @@ func joinStreamingNames(anime jikan.Anime) string {
} }
templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) { templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, currentStatus string, airing bool) {
<div class="dropdown" id="watchlist-dropdown"> <div class="relative inline-block" id="watchlist-dropdown">
<button class="dropdown-trigger" onclick="toggleDropdown()"> <button class="inline-flex h-8 cursor-pointer items-center gap-2 bg-[var(--panel-soft)] px-2 text-[0.8rem] text-[var(--text)]" onclick="toggleDropdown()" data-dropdown-trigger>
if currentStatus != "" { if currentStatus != "" {
{ formatStatus(currentStatus) } { formatStatus(currentStatus) }
} else { } else {
Add to watchlist Add to watchlist
} }
<span class="dropdown-arrow">▾</span> <span class="text-[0.64rem]">▾</span>
</button> </button>
<div class="dropdown-menu"> <div class="invisible absolute left-0 top-[calc(100%+2px)] z-[110] min-w-[210px] bg-[var(--panel)] opacity-0 transition-opacity duration-150" data-dropdown-menu data-dropdown-open-classes="visible opacity-100" data-dropdown-closed-classes="invisible opacity-0">
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "watching", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "completed", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "on_hold", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "dropped", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "dropped", currentStatus, airing)
@dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing) @dropdownStatusOption(animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, "plan_to_watch", currentStatus, airing)
if currentStatus != "" { if currentStatus != "" {
<div class="dropdown-divider"></div>
<button <button
class="dropdown-item remove" class="flex w-full cursor-pointer items-center justify-between bg-transparent px-[0.62rem] py-2 text-left text-[0.78rem] text-[var(--text-muted)] hover:bg-[var(--panel-soft)] hover:text-[var(--danger)]"
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))) } hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d", animeID))) }
hx-target="#watchlist-dropdown" hx-target="#watchlist-dropdown"
hx-swap="outerHTML swap:150ms" hx-swap="outerHTML swap:150ms"
@@ -225,7 +224,10 @@ templ WatchlistDropdown(animeID int, animeTitle string, animeTitleEnglish string
templ dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, status string, currentStatus string, airing bool) { templ dropdownStatusOption(animeID int, animeTitle string, animeTitleEnglish string, animeTitleJapanese string, animeImage string, status string, currentStatus string, airing bool) {
<button <button
class={ "dropdown-item", templ.KV("active", status == currentStatus) } class={
"flex w-full cursor-pointer items-center justify-between bg-transparent px-[0.62rem] py-2 text-left text-[0.78rem] text-[var(--text-muted)] hover:bg-[var(--panel-soft)] hover:text-[var(--text)]",
templ.KV("bg-[var(--panel-soft)] text-[var(--text)]", status == currentStatus),
}
hx-post="/api/watchlist" hx-post="/api/watchlist"
hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) } hx-vals={ fmt.Sprintf(`{"anime_id": "%d", "anime_title": "%s", "anime_title_english": "%s", "anime_title_japanese": "%s", "anime_image": "%s", "status": "%s", "airing": "%v"}`, animeID, animeTitle, animeTitleEnglish, animeTitleJapanese, animeImage, status, airing) }
hx-target="#watchlist-dropdown" hx-target="#watchlist-dropdown"
@@ -254,7 +256,7 @@ func formatStatus(status string) string {
templ AnimeRelationsList(relations []jikan.RelationEntry) { templ AnimeRelationsList(relations []jikan.RelationEntry) {
if len(relations) > 1 { if len(relations) > 1 {
<div class="relations-grid" id="relations-grid"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3" id="relations-grid">
for _, rel := range relations { for _, rel := range relations {
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: rel.Anime.MalID, ID: rel.Anime.MalID,
@@ -266,13 +268,13 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
CurrentNode: rel.IsCurrent, CurrentNode: rel.IsCurrent,
}) { }) {
if rel.Relation != "" && rel.Relation != "Current" { if rel.Relation != "" && rel.Relation != "Current" {
<div class="relation-type">{ rel.Relation }</div> <div class="mt-1 text-[0.76rem] text-[var(--text-faint)]">{ rel.Relation }</div>
} }
} }
} }
</div> </div>
} else { } else {
<p class="empty-inline-note">No related anime found.</p> <p class="text-[0.9rem] text-[var(--text-muted)]">No related anime found.</p>
} }
} }
@@ -286,7 +288,7 @@ func relationCardClass(rel jikan.RelationEntry) string {
templ AnimeRecommendations(recs []jikan.Anime) { templ AnimeRecommendations(recs []jikan.Anime) {
if len(recs) > 0 { if len(recs) > 0 {
<div class="relations-grid"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, anime := range recs { for _, anime := range recs {
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: anime.MalID, ID: anime.MalID,
@@ -299,7 +301,7 @@ templ AnimeRecommendations(recs []jikan.Anime) {
} }
</div> </div>
} else { } else {
<p class="empty-inline-note">No recommendations available.</p> <p class="text-[0.9rem] text-[var(--text-muted)]">No recommendations available.</p>
} }
} }

View File

@@ -2,29 +2,29 @@ package templates
templ Login(formError string, username string) { templ Login(formError string, username string) {
@Layout("Login", false) { @Layout("Login", false) {
<div class="auth-shell"> <div class="w-full max-w-[560px]">
<div class="login-container"> <div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Sign in</h2> <h2 class="m-0 text-[1.4rem]">Sign in</h2>
<p class="login-subtitle">Enter your credentials to continue.</p> <p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Enter your credentials to continue.</p>
<form action="/login" method="POST" class="login-form"> <form action="/login" method="POST" class="grid gap-4">
<div class="form-group"> <div class="grid gap-1">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
</div> </div>
<div class="form-group"> <div class="grid gap-1">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required placeholder="Your password"/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="password" name="password" required placeholder="Your password"/>
</div> </div>
<button type="submit" class="login-button">Sign in</button> <button type="submit" class="h-10 cursor-pointer border-0 bg-[var(--accent)] text-[0.9rem] font-semibold text-[var(--text-on-accent)] hover:brightness-95">Sign in</button>
if formError != "" { if formError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p> <p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p>
} }
</form> </form>
<p class="auth-switch-row"> <p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Don't have an account? <a href="/register">Register</a> Don't have an account? <a class="text-[var(--accent)]" href="/register">Register</a>
</p> </p>
<p class="auth-switch-row"> <p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Lost access? <a href="/recover">Recover account</a> Lost access? <a class="text-[var(--accent)]" href="/recover">Recover account</a>
</p> </p>
</div> </div>
</div> </div>
@@ -33,29 +33,29 @@ templ Login(formError string, username string) {
templ Register(formError string, username string) { templ Register(formError string, username string) {
@Layout("Register", false) { @Layout("Register", false) {
<div class="auth-shell"> <div class="w-full max-w-[560px]">
<div class="login-container"> <div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Register</h2> <h2 class="m-0 text-[1.4rem]">Register</h2>
<p class="login-subtitle">Create a new account to track anime.</p> <p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Create a new account to track anime.</p>
<form action="/register" method="POST" class="login-form"> <form action="/register" method="POST" class="grid gap-4">
<div class="form-group"> <div class="grid gap-1">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
</div> </div>
<div class="form-group"> <div class="grid gap-1">
<label for="password">Password</label> <label for="password">Password</label>
<input type="password" id="password" name="password" required placeholder="Minimum 12 chars"/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="password" name="password" required placeholder="Minimum 12 chars"/>
</div> </div>
<p class="auth-password-note"> <p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">
Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character. Password must be at least 12 characters and include an uppercase letter, lowercase letter, number, and special character.
</p> </p>
<button type="submit" class="login-button">Create account</button> <button type="submit" class="h-10 cursor-pointer border-0 bg-[var(--accent)] text-[0.9rem] font-semibold text-[var(--text-on-accent)] hover:brightness-95">Create account</button>
if formError != "" { if formError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p> <p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p>
} }
</form> </form>
<p class="auth-switch-row"> <p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Already have an account? <a href="/login">Sign in</a> Already have an account? <a class="text-[var(--accent)]" href="/login">Sign in</a>
</p> </p>
</div> </div>
</div> </div>
@@ -64,18 +64,18 @@ templ Register(formError string, username string) {
templ RegistrationRecoveryKey(recoveryKey string) { templ RegistrationRecoveryKey(recoveryKey string) {
@Layout("Save recovery key", false) { @Layout("Save recovery key", false) {
<div class="auth-shell"> <div class="w-full max-w-[560px]">
<div class="login-container"> <div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Save your recovery key</h2> <h2>Save your recovery key</h2>
<p class="login-subtitle">Store this key somewhere safe. It is shown only once.</p> <p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Store this key somewhere safe. It is shown only once.</p>
<div class="recovery-key-row"> <div class="grid gap-2">
<p class="recovery-key-box" id="registration-recovery-key">{ recoveryKey }</p> <p class="m-0 break-all border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] p-3 text-[0.86rem] text-[var(--text)]" id="registration-recovery-key">{ recoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button> <button type="button" class="min-w-0 justify-self-start border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-[0.72rem] py-[0.42rem] text-[0.8rem] leading-none text-[var(--text)] hover:bg-[var(--surface-input-focus)]" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button>
</div> </div>
<p class="auth-password-note recovery-copy-feedback" id="registration-copy-feedback" aria-live="polite"></p> <p class="mt-2 min-h-[1.1rem] text-[0.75rem] leading-[1.4] text-[var(--text-faint)]" id="registration-copy-feedback" aria-live="polite"></p>
<p class="auth-password-note">If you lose your password, this key is the only way to recover your account without email.</p> <p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">If you lose your password, this key is the only way to recover your account without email.</p>
<p class="auth-switch-row"> <p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
<a href="/" class="auth-primary-link">I saved it, continue</a> <a href="/" class="inline-flex min-h-10 items-center justify-center border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] no-underline hover:bg-[var(--panel-soft)] hover:no-underline">I saved it, continue</a>
</p> </p>
</div> </div>
</div> </div>
@@ -84,30 +84,30 @@ templ RegistrationRecoveryKey(recoveryKey string) {
templ Recover(formError string, username string, recoveryKey string) { templ Recover(formError string, username string, recoveryKey string) {
@Layout("Recover account", false) { @Layout("Recover account", false) {
<div class="auth-shell"> <div class="w-full max-w-[560px]">
<div class="login-container"> <div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Recover account</h2> <h2 class="m-0 text-[1.4rem]">Recover account</h2>
<p class="login-subtitle">Enter your username, recovery key, and a new password.</p> <p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Enter your username, recovery key, and a new password.</p>
<form action="/recover" method="POST" class="login-form"> <form action="/recover" method="POST" class="grid gap-4">
<div class="form-group"> <div class="grid gap-1">
<label for="username">Username / Email</label> <label for="username">Username / Email</label>
<input type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="username" name="username" required placeholder="you@example.com" value={ username }/>
</div> </div>
<div class="form-group"> <div class="grid gap-1">
<label for="recovery_key">Recovery key</label> <label for="recovery_key">Recovery key</label>
<input type="text" id="recovery_key" name="recovery_key" required value={ recoveryKey }/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="text" id="recovery_key" name="recovery_key" required value={ recoveryKey }/>
</div> </div>
<div class="form-group"> <div class="grid gap-1">
<label for="new_password">New password</label> <label for="new_password">New password</label>
<input type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/> <input class="h-10 border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
</div> </div>
<button type="submit" class="login-button">Reset password</button> <button type="submit" class="h-10 cursor-pointer border-0 bg-[var(--accent)] text-[0.9rem] font-semibold text-[var(--text-on-accent)] hover:brightness-95">Reset password</button>
if formError != "" { if formError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ formError }</p> <p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ formError }</p>
} }
</form> </form>
<p class="auth-switch-row"> <p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Remembered your password? <a href="/login">Sign in</a> Remembered your password? <a class="text-[var(--accent)]" href="/login">Sign in</a>
</p> </p>
</div> </div>
</div> </div>
@@ -116,18 +116,18 @@ templ Recover(formError string, username string, recoveryKey string) {
templ RecoveryComplete(newRecoveryKey string) { templ RecoveryComplete(newRecoveryKey string) {
@Layout("Recovery complete", false) { @Layout("Recovery complete", false) {
<div class="auth-shell"> <div class="w-full max-w-[560px]">
<div class="login-container"> <div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Account recovered</h2> <h2>Account recovered</h2>
<p class="login-subtitle">Your password was reset and your recovery key was rotated.</p> <p class="my-3 mb-5 text-[0.95rem] text-[var(--text-muted)]">Your password was reset and your recovery key was rotated.</p>
<div class="recovery-key-row"> <div class="grid gap-2">
<p class="recovery-key-box" id="recovery-complete-key">{ newRecoveryKey }</p> <p class="m-0 break-all border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] p-3 text-[0.86rem] text-[var(--text)]" id="recovery-complete-key">{ newRecoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button> <button type="button" class="min-w-0 justify-self-start border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-[0.72rem] py-[0.42rem] text-[0.8rem] leading-none text-[var(--text)] hover:bg-[var(--surface-input-focus)]" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button>
</div> </div>
<p class="auth-password-note recovery-copy-feedback" id="recovery-complete-feedback" aria-live="polite"></p> <p class="mt-2 min-h-[1.1rem] text-[0.75rem] leading-[1.4] text-[var(--text-faint)]" id="recovery-complete-feedback" aria-live="polite"></p>
<p class="auth-password-note">Replace your old recovery key with this one.</p> <p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">Replace your old recovery key with this one.</p>
<p class="auth-switch-row"> <p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
<a href="/login" class="auth-primary-link">Go to login</a> <a href="/login" class="inline-flex min-h-10 items-center justify-center border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] no-underline hover:bg-[var(--panel-soft)] hover:no-underline">Go to login</a>
</p> </p>
</div> </div>
</div> </div>
@@ -136,75 +136,75 @@ templ RecoveryComplete(newRecoveryKey string) {
templ Account(username string, createdAt string, passwordError string, passwordSuccess string, recoveryError string, recoverySuccess string, recoveryKey string) { templ Account(username string, createdAt string, passwordError string, passwordSuccess string, recoveryError string, recoverySuccess string, recoveryKey string) {
@Layout("Account", true) { @Layout("Account", true) {
<div class="account-page"> <div class="mx-auto grid w-[min(720px,100%)] gap-4">
<section class="account-card"> <section class="grid gap-3 bg-[var(--panel)] p-5">
<h2>Account</h2> <h2>Account</h2>
<div class="account-meta"> <div class="grid gap-2">
<div class="account-meta-row"> <div class="grid gap-1">
<span class="account-meta-label">Email / Username</span> <span class="text-[0.78rem] text-[var(--text-faint)]">Email / Username</span>
<span class="account-meta-value">{ username }</span> <span class="text-[0.95rem] text-[var(--text)]">{ username }</span>
</div> </div>
<div class="account-meta-row"> <div class="grid gap-1">
<span class="account-meta-label">Created</span> <span class="text-[0.78rem] text-[var(--text-faint)]">Created</span>
<span class="account-meta-value">{ createdAt }</span> <span class="text-[0.95rem] text-[var(--text)]">{ createdAt }</span>
</div> </div>
</div> </div>
</section> </section>
<section class="account-card"> <section class="grid gap-3 bg-[var(--panel)] p-5">
<h3>Change password</h3> <h3>Change password</h3>
<form action="/account/password" method="POST" class="account-form" onsubmit="return confirmDangerAction('Change your password now?')"> <form action="/account/password" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Change your password now?')">
<div class="form-group"> <div class="grid gap-1">
<label for="current_password">Current password</label> <label for="current_password">Current password</label>
<input type="password" id="current_password" name="current_password" required/> <input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="current_password" name="current_password" required/>
</div> </div>
<div class="form-group"> <div class="grid gap-1">
<label for="new_password">New password</label> <label for="new_password">New password</label>
<input type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/> <input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="new_password" name="new_password" required placeholder="Minimum 12 chars"/>
</div> </div>
<div class="form-group"> <div class="grid gap-1">
<label for="confirm_new_password">Confirm new password</label> <label for="confirm_new_password">Confirm new password</label>
<input type="password" id="confirm_new_password" name="confirm_new_password" required/> <input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="confirm_new_password" name="confirm_new_password" required/>
</div> </div>
<button type="submit" class="account-submit-btn">Update password</button> <button type="submit" class="h-10 cursor-pointer border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] hover:bg-[var(--panel-soft)]">Update password</button>
if passwordError != "" { if passwordError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ passwordError }</p> <p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ passwordError }</p>
} }
if passwordSuccess != "" { if passwordSuccess != "" {
<p class="account-success" role="status" aria-live="polite">{ passwordSuccess }</p> <p class="m-0 text-[0.82rem] text-[var(--accent)]" role="status" aria-live="polite">{ passwordSuccess }</p>
} }
</form> </form>
</section> </section>
<section class="account-card"> <section class="grid gap-3 bg-[var(--panel)] p-5">
<h3>Recovery key</h3> <h3>Recovery key</h3>
<p class="auth-password-note">To view a new recovery key, confirm your current password. This rotates your old key.</p> <p class="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">To view a new recovery key, confirm your current password. This rotates your old key.</p>
<form action="/account/recovery-key" method="POST" class="account-form" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')"> <form action="/account/recovery-key" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')">
<div class="form-group"> <div class="grid gap-1">
<label for="recovery_password">Current password</label> <label for="recovery_password">Current password</label>
<input type="password" id="recovery_password" name="password" required/> <input class="h-10 w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 focus:border-[var(--surface-search-focus-border)] focus:outline-none" type="password" id="recovery_password" name="password" required/>
</div> </div>
<button type="submit" class="account-submit-btn">Show new recovery key</button> <button type="submit" class="h-10 cursor-pointer border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] hover:bg-[var(--panel-soft)]">Show new recovery key</button>
if recoveryError != "" { if recoveryError != "" {
<p class="auth-form-error" role="alert" aria-live="polite">{ recoveryError }</p> <p class="mt-2 text-[0.82rem] text-[var(--danger)]" role="alert" aria-live="polite">{ recoveryError }</p>
} }
if recoverySuccess != "" { if recoverySuccess != "" {
<p class="account-success" role="status" aria-live="polite">{ recoverySuccess }</p> <p class="m-0 text-[0.82rem] text-[var(--accent)]" role="status" aria-live="polite">{ recoverySuccess }</p>
} }
</form> </form>
if recoveryKey != "" { if recoveryKey != "" {
<div class="recovery-key-row"> <div class="grid gap-2">
<p class="recovery-key-box" id="account-recovery-key">{ recoveryKey }</p> <p class="m-0 break-all border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] p-3 text-[0.86rem] text-[var(--text)]" id="account-recovery-key">{ recoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button> <button type="button" class="min-w-0 justify-self-start border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-[0.72rem] py-[0.42rem] text-[0.8rem] leading-none text-[var(--text)] hover:bg-[var(--surface-input-focus)]" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button>
</div> </div>
<p class="auth-password-note recovery-copy-feedback" id="account-copy-feedback" aria-live="polite"></p> <p class="mt-2 min-h-[1.1rem] text-[0.75rem] leading-[1.4] text-[var(--text-faint)]" id="account-copy-feedback" aria-live="polite"></p>
} }
</section> </section>
<section class="account-card"> <section class="grid gap-3 bg-[var(--panel)] p-5">
<h3>Danger zone</h3> <h3>Danger zone</h3>
<form action="/logout" method="POST" class="account-form-inline" onsubmit="return confirmDangerAction('Log out of this account now?')"> <form action="/logout" method="POST" class="inline-flex" onsubmit="return confirmDangerAction('Log out of this account now?')">
<button type="submit" class="account-logout-btn">Log out</button> <button type="submit" class="h-10 cursor-pointer border border-[var(--surface-search-focus-border)] bg-[var(--surface-search)] px-4 text-[var(--text)] hover:bg-[var(--panel-soft)]">Log out</button>
</form> </form>
</section> </section>
</div> </div>

View File

@@ -6,8 +6,8 @@ import "fmt"
templ Catalog() { templ Catalog() {
@Layout("mal - catalog", true) { @Layout("mal - catalog", true) {
<div class="catalog-grid" id="catalog-content"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3" id="catalog-content">
<div class="grid-full-width" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML"> <div class="col-span-full" hx-get="/api/catalog?page=1" hx-trigger="load" hx-swap="outerHTML">
@ui.LoadingIndicator("Loading catalog") @ui.LoadingIndicator("Loading catalog")
</div> </div>
</div> </div>
@@ -20,9 +20,9 @@ templ CatalogItems(animes []jikan.Anime, nextPage int, hasNext bool) {
templ CatalogPlaceholderItems(count int) { templ CatalogPlaceholderItems(count int) {
for i := 0; i < count; i++ { for i := 0; i < count; i++ {
<div class="catalog-item catalog-placeholder" aria-hidden="true"> <div class="pointer-events-none min-w-0" aria-hidden="true">
<div class="catalog-placeholder-thumb"></div> <div class="aspect-[2/3] max-h-[var(--poster-max-height)] w-full animate-pulse bg-[var(--surface-search)]"></div>
<div class="catalog-placeholder-title"></div> <div class="mt-2 h-[0.9rem] w-4/5 animate-pulse bg-[var(--surface-search)]"></div>
</div> </div>
} }
} }

View File

@@ -6,35 +6,39 @@ import "fmt"
templ Discover() { templ Discover() {
@Layout("mal - discover", true) { @Layout("mal - discover", true) {
<div class="discover-container"> <div class="grid gap-4">
<div class="discover-header"> <div class="grid gap-4">
<h1>Discover</h1> <h1>Discover</h1>
<p class="discover-subtitle">Browse what's airing now and what is coming soon.</p> <p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Browse what's airing now and what is coming soon.</p>
</div> </div>
<div class="tabs discover-tabs" data-tab-group="discover"> <div class="flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1" data-tab-group="discover">
<button <button
class="tab active" class="tab-trigger shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)]"
type="button" type="button"
hx-get="/api/discover/airing?page=1" hx-get="/api/discover/airing?page=1"
hx-target="#discover-content" hx-target="#discover-content"
hx-trigger="click" hx-trigger="click"
data-tab-trigger data-tab-trigger
data-tab-active-classes="bg-[var(--surface-tab-active)] text-[var(--accent)]"
data-tab-inactive-classes="bg-[var(--panel-soft)] text-[var(--text-muted)]"
> >
airing now airing now
</button> </button>
<button <button
class="tab" class="tab-trigger shrink-0 whitespace-nowrap bg-[var(--panel-soft)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--text-muted)] hover:bg-[var(--surface-tab-hover)] hover:text-[var(--text)]"
type="button" type="button"
hx-get="/api/discover/upcoming?page=1" hx-get="/api/discover/upcoming?page=1"
hx-target="#discover-content" hx-target="#discover-content"
hx-trigger="click" hx-trigger="click"
data-tab-trigger data-tab-trigger
data-tab-active-classes="bg-[var(--surface-tab-active)] text-[var(--accent)]"
data-tab-inactive-classes="bg-[var(--panel-soft)] text-[var(--text-muted)]"
> >
upcoming upcoming
</button> </button>
</div> </div>
<div class="catalog-grid" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
<div class="grid-full-width"> <div class="col-span-full">
@ui.LoadingIndicator("Loading discover") @ui.LoadingIndicator("Loading discover")
</div> </div>
</div> </div>

View File

@@ -10,7 +10,7 @@ import (
templ Search(q string) { templ Search(q string) {
@Layout("mal - search", true) { @Layout("mal - search", true) {
if q != "" { if q != "" {
<div id="loading" class="htmx-indicator"> <div id="loading" class="hidden htmx-request:inline-flex">
@ui.LoadingIndicator("Searching...") @ui.LoadingIndicator("Searching...")
</div> </div>
<div id="results" hx-get={ string(templ.URL("/search?q=" + url.QueryEscape(q))) } hx-trigger="load" hx-indicator="#loading"></div> <div id="results" hx-get={ string(templ.URL("/search?q=" + url.QueryEscape(q))) } hx-trigger="load" hx-indicator="#loading"></div>
@@ -28,7 +28,7 @@ templ SearchResultsWrapper(query string, animes []jikan.Anime, nextPage int, has
Try a different search term. Try a different search term.
} }
} else { } else {
<div class="catalog-grid"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
@SearchItems(query, animes, nextPage, hasNext) @SearchItems(query, animes, nextPage, hasNext)
</div> </div>
} }

View File

@@ -10,39 +10,42 @@ templ Layout(title string, showHeader bool) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title> <title>{ title }</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/> <link rel="icon" type="image/svg+xml" href="/static/favicon.svg"/>
<link rel="stylesheet" href="/static/css/style.css"/> <link rel="stylesheet" href="/static/css/tailwind.css"/>
<script src="https://unpkg.com/htmx.org@1.9.11"></script> <script src="https://unpkg.com/htmx.org@1.9.11"></script>
<script src="/static/js/discover.js" defer></script> <script src="/static/js/discover.js" defer></script>
<script src="/static/js/anime.js" defer></script> <script src="/static/js/anime.js" defer></script>
<script src="/static/js/timezone.js" defer></script> <script src="/static/js/timezone.js" defer></script>
<script src="/static/js/auth.js" defer></script> <script src="/static/js/auth.js" defer></script>
</head> </head>
<body> <body class="min-h-screen bg-[var(--bg)] text-[var(--text)] font-[var(--font)] text-[14px] leading-[1.45]">
if showHeader { if showHeader {
<header> <header class="sticky top-0 z-[100] bg-[var(--header)]">
<div class="header-top"> <div class="mx-auto flex w-full max-w-[1580px] items-center gap-4 px-4 py-3 max-[860px]:flex-wrap max-[860px]:gap-3">
<div class="header-left"> <div class="flex min-w-0 items-center gap-5 max-[860px]:w-full max-[860px]:flex-wrap max-[860px]:gap-3" data-search-root>
<a href="/" class="logo" aria-label="mal logo"> <a href="/" class="inline-flex items-center text-[var(--accent)]" aria-label="mal logo">
@icons.LogoIcon("logo-svg") @icons.LogoIcon("h-7 w-7")
</a> </a>
<div class="nav"> <div class="flex flex-wrap gap-3 text-[0.85rem] max-[860px]:w-full max-[860px]:gap-2">
<a href="/">Catalog</a> <a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/">Catalog</a>
<a href="/discover">Discover</a> <a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/discover">Discover</a>
<a href="/notifications">Notifications</a> <a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/notifications">Notifications</a>
<a href="/watchlist">Watchlist</a> <a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/watchlist">Watchlist</a>
<a href="/account">Account</a> <a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/account">Account</a>
</div> </div>
</div> </div>
<div class="header-search-wrapper"> <div class="relative ml-auto min-w-[240px] w-[min(420px,45vw)] max-[860px]:ml-0 max-[860px]:w-full" data-search-root>
<form action="/search" method="GET" class="header-search" id="search-form"> <form action="/search" method="GET" class="w-full" id="search-form">
<input type="text" id="search-input" name="q" class="search-input" placeholder="Search anime..." autocomplete="off"/> <input type="text" id="search-input" name="q" class="h-[34px] w-full border border-transparent bg-[var(--surface-search)] px-3 text-[var(--text)] transition-colors duration-120 placeholder:text-[var(--text-faint)] focus:border-[var(--surface-search-focus-border)] focus:outline-none" placeholder="Search anime..." autocomplete="off"/>
<div id="search-dropdown" class="search-dropdown"></div> <div id="search-dropdown" class="absolute inset-x-0 top-[calc(100%+2px)] z-[120] max-h-[min(70vh,560px)] overflow-y-auto bg-[var(--panel)]" data-search-results-container></div>
</form> </form>
</div> </div>
</div> </div>
</header> </header>
} }
<main class={ "main-content", templ.KV("auth-main", !showHeader) }> <main class={
"mx-auto w-full max-w-[1580px] px-4 pt-5 pb-8 max-[860px]:px-3 max-[860px]:pb-6",
templ.KV("flex min-h-screen items-center justify-center px-4 py-0", !showHeader),
}>
{ children... } { children... }
</main> </main>
<script src="/static/js/search.js"></script> <script src="/static/js/search.js"></script>

View File

@@ -2,11 +2,11 @@ package templates
templ NotFoundPage() { templ NotFoundPage() {
@Layout("mal - not found", false) { @Layout("mal - not found", false) {
<section class="not-found-page anime-surface"> <section class="w-[min(780px,calc(100vw-(1.5rem*2)))] min-h-[72vh] mx-auto py-8 px-7 grid content-center justify-items-center gap-3 text-center">
<p class="not-found-code">404</p> <p class="m-0 text-[clamp(4rem,15vw,10rem)] tracking-[0.04em] leading-[0.9] text-[var(--text-muted)]">404</p>
<h1>Page not found</h1> <h1 class="m-0 text-[clamp(2rem,4vw,3rem)]">Page not found</h1>
<p class="empty-inline-note">The page you requested does not exist, or it was moved.</p> <p class="text-[var(--text-muted)]">The page you requested does not exist, or it was moved.</p>
<p><a href="/" class="not-found-link">Back to catalog</a></p> <p><a href="/" class="text-[1.05rem] text-[var(--accent)] no-underline hover:underline">Back to catalog</a></p>
</section> </section>
} }
} }

View File

@@ -13,11 +13,11 @@ type WatchingAnimeWithDetails struct {
templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) { templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@Layout("mal - notifications", true) { @Layout("mal - notifications", true) {
<div class="notifications-page"> <div class="grid gap-4">
<h1>Notifications</h1> <h1>Notifications</h1>
<div class="status-tabs"> <div class="mb-3 flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1">
<a href="/notifications?tab=tracking" class={ activeClass(activeTab == "tracking") }>Tracking</a> <a href="/notifications?tab=tracking" class={ statusTabClass(activeTab == "tracking") }>Tracking</a>
<a href="/notifications?tab=sequels" class={ activeClass(activeTab == "sequels") }>Sequels</a> <a href="/notifications?tab=sequels" class={ statusTabClass(activeTab == "sequels") }>Sequels</a>
</div> </div>
if activeTab == "sequels" { if activeTab == "sequels" {
@@ -25,13 +25,13 @@ templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@ui.LoadingIndicator("Syncing sequel graphs...") @ui.LoadingIndicator("Syncing sequel graphs...")
</div> </div>
} else { } else {
<p class="notifications-subtitle">Shows you're currently watching or planning to watch.</p> <p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Shows you're currently watching or planning to watch.</p>
if len(watching) == 0 { if len(watching) == 0 {
@ui.EmptyState("No airing anime in your watching list.") { @ui.EmptyState("No airing anime in your watching list.") {
<span class="empty-state-hint">Add currently airing shows to your watching list to see upcoming episodes here.</span> <span class="text-[0.9rem] text-[var(--text-muted)]">Add currently airing shows to your watching list to see upcoming episodes here.</span>
} }
} else { } else {
<div class="notifications-list"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, item := range watching { for _, item := range watching {
@NotificationCard(item) @NotificationCard(item)
} }
@@ -56,7 +56,7 @@ func splitUpcomingSeasons(items []database.GetUpcomingSeasonsRow) (airing []data
templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) { templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if len(upcomingSeasons) == 0 { if len(upcomingSeasons) == 0 {
@ui.EmptyState("No upcoming seasons for anime you've watched.") { @ui.EmptyState("No upcoming seasons for anime you've watched.") {
<span class="empty-state-hint">As you watch more shows, new seasons will appear here.</span> <span class="text-[0.9rem] text-[var(--text-muted)]">As you watch more shows, new seasons will appear here.</span>
} }
} else { } else {
@renderSplitSeasons(upcomingSeasons) @renderSplitSeasons(upcomingSeasons)
@@ -66,10 +66,10 @@ templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) { templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true { if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true {
if len(airing) > 0 { if len(airing) > 0 {
<section class="notifications-group notifications-list-spaced"> <section class="mb-4 grid gap-3">
<h2 class="notifications-group-title">Airing now</h2> <h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">Airing now</h2>
<p class="notifications-group-note">These are the currently airing anime, but you're not tracking any of these.</p> <p class="m-0 text-[0.88rem] text-[var(--text-muted)]">These are the currently airing anime, but you're not tracking any of these.</p>
<div class="notifications-list"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, item := range airing { for _, item := range airing {
@UpcomingSeasonCard(item) @UpcomingSeasonCard(item)
} }
@@ -78,10 +78,10 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
} }
if len(upcoming) > 0 { if len(upcoming) > 0 {
<section class="notifications-group"> <section class="grid gap-3">
<h2 class="notifications-group-title">Announced & upcoming</h2> <h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">Announced & upcoming</h2>
<p class="notifications-group-note">Newly announced or upcoming seasons related to anime you've watched.</p> <p class="m-0 text-[0.88rem] text-[var(--text-muted)]">Newly announced or upcoming seasons related to anime you've watched.</p>
<div class="notifications-list"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, item := range upcoming { for _, item := range upcoming {
@UpcomingSeasonCard(item) @UpcomingSeasonCard(item)
} }
@@ -96,19 +96,19 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
ID: int(item.ID), ID: int(item.ID),
Title: displaySeasonTitle(item), Title: displaySeasonTitle(item),
ImageURL: item.ImageUrl, ImageURL: item.ImageUrl,
Class: "notification-card", Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
ImgClass: "notification-image", ImgClass: "flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden",
}) { }) {
<div class="notification-content"> <div class="mt-2 grid gap-1 p-0" data-notification-content>
<div class="notification-title"> <div class="line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]">
{ displaySeasonTitle(item) } { displaySeasonTitle(item) }
</div> </div>
<div class="notification-meta"> <div class="flex flex-wrap gap-2">
if item.Status.Valid { if item.Status.Valid {
<span class="notification-muted">{ seasonStatusLabel(item.Status.String) }</span> <span class="text-[0.67rem] text-[var(--text-faint)]">{ seasonStatusLabel(item.Status.String) }</span>
} }
if strings.TrimSpace(item.PrequelTitle) != "" { if strings.TrimSpace(item.PrequelTitle) != "" {
<span class="notification-muted">{ fmt.Sprintf("Sequel to %s", item.PrequelTitle) }</span> <span class="text-[0.67rem] text-[var(--text-faint)]">{ fmt.Sprintf("Sequel to %s", item.PrequelTitle) }</span>
} }
</div> </div>
</div> </div>
@@ -124,20 +124,20 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
ID: int(item.Entry.AnimeID), ID: int(item.Entry.AnimeID),
Title: displayTitle(item.Entry), Title: displayTitle(item.Entry),
ImageURL: item.Entry.ImageUrl, ImageURL: item.Entry.ImageUrl,
Class: "notification-card", Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
ImgClass: "notification-image", ImgClass: "flex aspect-[2/3] max-h-[var(--poster-max-height)] w-full items-end justify-center overflow-hidden",
}) { }) {
<div class="notification-content"> <div class="mt-2 grid gap-1 p-0" data-notification-content>
<div class="notification-title"> <div class="line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]">
{ displayTitle(item.Entry) } { displayTitle(item.Entry) }
</div> </div>
<div class="notification-meta"> <div class="flex flex-wrap gap-2">
if item.Anime.Broadcast.String != "" { if item.Anime.Broadcast.String != "" {
<span class="notification-broadcast" data-jst-text={ item.Anime.Broadcast.String } data-broadcast-day={ item.Anime.Broadcast.Day } data-broadcast-time={ item.Anime.Broadcast.Time } data-broadcast-timezone={ item.Anime.Broadcast.Timezone }>{ item.Anime.Broadcast.String }</span> <span class="text-[0.67rem] text-[var(--text-faint)]" data-jst-text={ item.Anime.Broadcast.String } data-broadcast-day={ item.Anime.Broadcast.Day } data-broadcast-time={ item.Anime.Broadcast.Time } data-broadcast-timezone={ item.Anime.Broadcast.Timezone }>{ item.Anime.Broadcast.String }</span>
<span class="notification-next-airing" data-next-airing="pending">Calculating next episode time...</span> <span class="text-[0.67rem] text-[var(--text-faint)]" data-next-airing="pending">Calculating next episode time...</span>
} }
if item.Anime.Episodes > 0 { if item.Anime.Episodes > 0 {
<span class="notification-progress"> <span class="text-[0.67rem] text-[var(--text-faint)]">
if item.Entry.CurrentEpisode.Valid { if item.Entry.CurrentEpisode.Valid {
{ fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) } { fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) }
} else { } else {
@@ -145,7 +145,7 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
} }
</span> </span>
} else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 { } else if item.Entry.CurrentEpisode.Valid && item.Entry.CurrentEpisode.Int64 > 0 {
<span class="notification-progress"> <span class="text-[0.67rem] text-[var(--text-faint)]">
{ fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64) } { fmt.Sprintf("%d eps watched", item.Entry.CurrentEpisode.Int64) }
</span> </span>
} }
@@ -174,3 +174,11 @@ func seasonStatusLabel(status string) string {
return statusText return statusText
} }
func statusTabClass(active bool) string {
base := "shrink-0 whitespace-nowrap bg-[var(--panel-soft)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--text-muted)] no-underline hover:bg-[var(--surface-tab-hover)] hover:text-[var(--text)] hover:no-underline"
if active {
return "shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)] no-underline hover:no-underline"
}
return base
}

View File

@@ -8,31 +8,31 @@ import (
templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) { templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentStatus string, sortBy string, sortOrder string) {
@Layout("My Watchlist", true) { @Layout("My Watchlist", true) {
<div class="watchlist-header"> <div class="mb-4 flex items-end justify-between gap-4 max-[860px]:flex-col max-[860px]:items-start">
<div class="watchlist-heading"> <div class="grid gap-1">
<h2>Watchlist</h2> <h2>Watchlist</h2>
<p class="watchlist-subtitle">Track what you're watching with less noise.</p> <p class="m-0 text-[0.86rem] text-[var(--text-muted)]">Track what you're watching with less noise.</p>
</div> </div>
<div class="watchlist-controls"> <div class="flex flex-wrap items-center justify-end gap-2 max-[860px]:w-full max-[860px]:justify-start">
<a href="/api/watchlist/export" class="text-link">Export</a> <a href="/api/watchlist/export" class="inline-flex min-w-16 items-center justify-center px-[0.45rem] py-[0.24rem] text-center text-[0.8rem] leading-[1.2] text-[var(--text-muted)] no-underline hover:text-[var(--accent)] hover:no-underline">Export</a>
<button class="text-link" type="button" onclick="document.getElementById('import-file').click()">Import</button> <button class="inline-flex min-w-16 cursor-pointer items-center justify-center border-0 bg-transparent px-[0.45rem] py-[0.24rem] text-center text-[0.8rem] leading-[1.2] text-[var(--text-muted)] hover:text-[var(--accent)]" type="button" onclick="document.getElementById('import-file').click()">Import</button>
<form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="is-hidden"> <form id="import-form" hx-post="/api/watchlist/import" hx-encoding="multipart/form-data" class="hidden">
<input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/> <input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/>
</form> </form>
<div class="view-toggle"> <div class="flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1">
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ activeClass(layout == "grid") }>Grid</a> <a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "grid") }>Grid</a>
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ activeClass(layout == "table") }>Table</a> <a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a>
</div> </div>
</div> </div>
</div> </div>
<div class="status-tabs"> <div class="mb-3 flex flex-wrap gap-2 max-[680px]:flex-nowrap max-[680px]:overflow-x-auto max-[680px]:pb-1">
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ activeClass(currentStatus == "all") }>All</a> <a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ tabClass(currentStatus == "all") }>All</a>
<a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ activeClass(currentStatus == "watching") }>Watching</a> <a href={ templ.URL(watchlistURL(layout, "watching", sortBy, sortOrder)) } class={ tabClass(currentStatus == "watching") }>Watching</a>
<a href={ templ.URL(watchlistURL(layout, "continuing", sortBy, sortOrder)) } class={ activeClass(currentStatus == "continuing") }>Continuing</a> <a href={ templ.URL(watchlistURL(layout, "continuing", sortBy, sortOrder)) } class={ tabClass(currentStatus == "continuing") }>Continuing</a>
<a href={ templ.URL(watchlistURL(layout, "on_hold", sortBy, sortOrder)) } class={ activeClass(currentStatus == "on_hold") }>On hold</a> <a href={ templ.URL(watchlistURL(layout, "on_hold", sortBy, sortOrder)) } class={ tabClass(currentStatus == "on_hold") }>On hold</a>
<a href={ templ.URL(watchlistURL(layout, "plan_to_watch", sortBy, sortOrder)) } class={ activeClass(currentStatus == "plan_to_watch") }>Plan to watch</a> <a href={ templ.URL(watchlistURL(layout, "plan_to_watch", sortBy, sortOrder)) } class={ tabClass(currentStatus == "plan_to_watch") }>Plan to watch</a>
<a href={ templ.URL(watchlistURL(layout, "dropped", sortBy, sortOrder)) } class={ activeClass(currentStatus == "dropped") }>Dropped</a> <a href={ templ.URL(watchlistURL(layout, "dropped", sortBy, sortOrder)) } class={ tabClass(currentStatus == "dropped") }>Dropped</a>
<a href={ templ.URL(watchlistURL(layout, "completed", sortBy, sortOrder)) } class={ activeClass(currentStatus == "completed") }>Completed</a> <a href={ templ.URL(watchlistURL(layout, "completed", sortBy, sortOrder)) } class={ tabClass(currentStatus == "completed") }>Completed</a>
</div> </div>
@ui.SortFilter(ui.SortFilterOptions{Sort: sortBy, Order: sortOrder, View: layout, Status: currentStatus}) @ui.SortFilter(ui.SortFilterOptions{Sort: sortBy, Order: sortOrder, View: layout, Status: currentStatus})
if len(entries) == 0 { if len(entries) == 0 {
@@ -47,16 +47,16 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
} }
} else { } else {
if layout == "grid" { if layout == "grid" {
<div class="catalog-grid"> <div class="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, entry := range entries { for _, entry := range entries {
<div class="catalog-item watchlist-item" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }> <div class="group relative min-w-0" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
@ui.AnimeCard(ui.AnimeCardProps{ @ui.AnimeCard(ui.AnimeCardProps{
ID: int(entry.AnimeID), ID: int(entry.AnimeID),
Title: entry.DisplayTitle(), Title: entry.DisplayTitle(),
ImageURL: entry.ImageUrl, ImageURL: entry.ImageUrl,
}) })
<button <button
class="remove-btn" class="absolute right-2 top-2 h-[22px] w-[22px] cursor-pointer border-0 bg-[var(--overlay-subtle)] text-[var(--text-muted)] opacity-0 transition-opacity duration-150 group-hover:opacity-100 hover:text-[var(--danger)]"
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) } hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) } hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
hx-swap="delete" hx-swap="delete"
@@ -65,30 +65,30 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
} }
</div> </div>
} else { } else {
<table class="watchlist-table"> <table class="block w-full overflow-x-auto whitespace-nowrap bg-[var(--panel)] md:table md:overflow-visible md:whitespace-normal">
<thead> <thead>
<tr> <tr>
<th></th> <th class="p-[0.6rem]"></th>
<th>Title</th> <th class="p-[0.6rem] text-left text-[0.67rem] text-[var(--text-faint)]">Title</th>
<th></th> <th class="p-[0.6rem]"></th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
for _, entry := range entries { for _, entry := range entries {
<tr id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }> <tr class="hover:bg-[var(--panel-soft)]" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
<td> <td class="p-[0.6rem]">
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }> <a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
<img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="thumb" loading="lazy"/> <img src={ entry.ImageUrl } alt={ entry.DisplayTitle() } class="aspect-[2/3] w-9 object-cover" loading="lazy"/>
</a> </a>
</td> </td>
<td class="title-cell"> <td class="p-[0.6rem] font-medium">
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }> <a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
{ entry.DisplayTitle() } { entry.DisplayTitle() }
</a> </a>
</td> </td>
<td class="actions-cell"> <td class="w-[90px] p-[0.6rem]">
<button <button
class="remove-link" class="cursor-pointer border-0 bg-transparent p-0 text-[0.8rem] text-[var(--text-muted)] hover:text-[var(--danger)]"
hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) } hx-delete={ string(templ.URL(fmt.Sprintf("/api/watchlist/%d?from=watchlist", entry.AnimeID))) }
hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) } hx-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
hx-swap="delete" hx-swap="delete"
@@ -103,11 +103,12 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
} }
} }
func activeClass(active bool) string { func tabClass(active bool) string {
base := "shrink-0 whitespace-nowrap bg-[var(--panel-soft)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--text-muted)] no-underline hover:bg-[var(--surface-tab-hover)] hover:text-[var(--text)] hover:no-underline"
if active { if active {
return "active" return "shrink-0 whitespace-nowrap bg-[var(--surface-tab-active)] px-[0.45rem] py-[0.24rem] text-[0.76rem] text-[var(--accent)] no-underline hover:no-underline"
} }
return "" return base
} }
func watchlistURL(view string, status string, sortBy string, sortOrder string) string { func watchlistURL(view string, status string, sortBy string, sortOrder string) string {

16
package.json Normal file
View File

@@ -0,0 +1,16 @@
{
"name": "myanimelist-ui",
"private": true,
"scripts": {
"build:css": "bunx @tailwindcss/cli -i ./static/css/style.css -o ./static/css/tailwind.css",
"watch:css": "bunx @tailwindcss/cli -i ./static/css/style.css -o ./static/css/tailwind.css --watch",
"build:ts": "bun build ./static/js/*.ts --outdir ./static/js --target browser",
"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"
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,28 +0,0 @@
;(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')
}
})
})()

62
static/js/anime.ts Normal file
View File

@@ -0,0 +1,62 @@
((): void => {
const parseClassList = (value: string | null): string[] => {
if (!value) {
return []
}
return value
.split(' ')
.map((entry: string): string => entry.trim())
.filter((entry: string): boolean => entry.length > 0)
}
const setMenuState = (menu: HTMLElement, isOpen: boolean): void => {
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(...openClasses)
menu.classList.add(...closedClasses)
}
const toggleDropdown = (): void => {
const dropdown = document.getElementById('watchlist-dropdown')
if (!dropdown) {
return
}
const isOpen = !dropdown.classList.contains('open')
dropdown.classList.toggle('open', isOpen)
const menu = dropdown.querySelector('[data-dropdown-menu]')
if (menu instanceof HTMLElement) {
setMenuState(menu, isOpen)
}
}
;(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')
const menu = dropdown.querySelector('[data-dropdown-menu]')
if (menu instanceof HTMLElement) {
setMenuState(menu, false)
}
}
})
})()

View File

@@ -1,19 +0,0 @@
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.'
})
}
function confirmDangerAction(message) {
return window.confirm(message)
}

25
static/js/auth.ts Normal file
View File

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

View File

@@ -1,26 +0,0 @@
;(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)
})
})()

46
static/js/discover.ts Normal file
View File

@@ -0,0 +1,46 @@
((): void => {
const parseClassList = (value: string | null): string[] => {
if (!value) {
return []
}
return value
.split(' ')
.map((entry: string): string => entry.trim())
.filter((entry: string): boolean => entry.length > 0)
}
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 => {
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)
}
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)
})
})()

View File

@@ -1,108 +0,0 @@
(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)
})
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)
})
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
}
try {
const parsed = new URL(rawUrl, window.location.origin)
return parsed.protocol === 'https:' || parsed.protocol === 'http:'
} catch {
return false
}
}
})()

127
static/js/search.ts Normal file
View File

@@ -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.querySelector('[data-search-results-container]') as HTMLElement | null
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 = 'grid'
const title = document.createElement('div')
title.className = 'px-3 py-2 text-[0.68rem] text-[var(--text-faint)]'
title.textContent = 'Anime'
searchResults.appendChild(title)
results.forEach((result): void => {
const item = document.createElement('a')
item.className = 'flex items-start gap-3 px-3 py-2 text-inherit no-underline hover:bg-[var(--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-[var(--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-[var(--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 itemTitle = document.createElement('div')
itemTitle.className = 'line-clamp-1 text-[0.86rem] leading-[1.3] text-[var(--text)]'
itemTitle.textContent = String(result.title || '')
info.appendChild(itemTitle)
const itemType = document.createElement('div')
itemType.className = 'text-[0.67rem] text-[var(--text-faint)]'
itemType.textContent = String(result.type || '')
info.appendChild(itemType)
item.appendChild(info)
searchResults.appendChild(item)
})
const viewAll = document.createElement('a')
viewAll.className = 'bg-[var(--surface-search-view-all)] px-3 py-2 text-center text-[0.8rem] text-[var(--text-muted)] no-underline hover:bg-[var(--panel-soft)] hover:text-[var(--text)] hover:no-underline'
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('[data-search-root]')) {
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
}
}
})()

View File

@@ -1,7 +1,13 @@
;(function () { ((): void => {
const jstOffsetMinutes = 9 * 60 const jstOffsetMinutes = 9 * 60
const parseBroadcastTime = (value) => { type ParsedBroadcast = {
day: string
hour: number
minute: number
}
const parseBroadcastTime = (value: string | null): { hour: number; minute: number } | null => {
if (!value || typeof value !== 'string') { if (!value || typeof value !== 'string') {
return null return null
} }
@@ -20,7 +26,7 @@
return { hour, minute } return { hour, minute }
} }
const isJstTimezone = (timezone) => { const isJstTimezone = (timezone: string | null): boolean => {
if (!timezone) { if (!timezone) {
return true return true
} }
@@ -29,7 +35,7 @@
return normalized === 'asia/tokyo' || normalized === 'jst' return normalized === 'asia/tokyo' || normalized === 'jst'
} }
const parseFromStructuredAttrs = (node) => { const parseFromStructuredAttrs = (node: Element): ParsedBroadcast | null => {
const day = node.getAttribute('data-broadcast-day') const day = node.getAttribute('data-broadcast-day')
const time = node.getAttribute('data-broadcast-time') const time = node.getAttribute('data-broadcast-time')
const timezone = node.getAttribute('data-broadcast-timezone') const timezone = node.getAttribute('data-broadcast-timezone')
@@ -46,7 +52,7 @@
return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute } return { day: day.trim(), hour: parsedTime.hour, minute: parsedTime.minute }
} }
const parseBroadcast = (text) => { const parseBroadcast = (text: string | null): ParsedBroadcast | null => {
if (!text || typeof text !== 'string') { if (!text || typeof text !== 'string') {
return null return null
} }
@@ -71,9 +77,9 @@
return { day, hour, minute } return { day, hour, minute }
} }
const normalizeDay = (day) => { const normalizeDay = (day: string): number | null => {
const key = day.trim().toLowerCase().replace(/s$/, '') const key = day.trim().toLowerCase().replace(/s$/, '')
const days = { const days: Record<string, number> = {
mon: 1, mon: 1,
monday: 1, monday: 1,
tue: 2, tue: 2,
@@ -100,7 +106,7 @@
return days[key] return days[key]
} }
const convertToLocal = (parsed, localOffsetMinutes) => { const convertToLocal = (parsed: ParsedBroadcast, localOffsetMinutes: number): string | null => {
const sourceMinutes = parsed.hour * 60 + parsed.minute const sourceMinutes = parsed.hour * 60 + parsed.minute
const diff = jstOffsetMinutes - localOffsetMinutes const diff = jstOffsetMinutes - localOffsetMinutes
const localTotal = sourceMinutes - diff const localTotal = sourceMinutes - diff
@@ -122,7 +128,7 @@
return `${localDay} at ${time} (Local)` return `${localDay} at ${time} (Local)`
} }
const nextAiringUTC = (parsed) => { const nextAiringUTC = (parsed: ParsedBroadcast): Date | null => {
const targetDay = normalizeDay(parsed.day) const targetDay = normalizeDay(parsed.day)
if (targetDay === null) { if (targetDay === null) {
return null return null
@@ -144,7 +150,17 @@
return new Date(now.getTime() + minuteDelta * 60 * 1000) return new Date(now.getTime() + minuteDelta * 60 * 1000)
} }
const relativeText = (target) => { 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() const diffMs = target.getTime() - Date.now()
if (diffMs <= 0) { if (diffMs <= 0) {
return 'soon' return 'soon'
@@ -164,17 +180,7 @@
return formatRelative(days, 'day') return formatRelative(days, 'day')
} }
const formatRelative = (value, unit) => { const localDateTimeText = (date: Date): 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 localDateTimeText = (date) => {
const formatter = new Intl.DateTimeFormat(undefined, { const formatter = new Intl.DateTimeFormat(undefined, {
weekday: 'short', weekday: 'short',
hour: '2-digit', hour: '2-digit',
@@ -183,8 +189,8 @@
return formatter.format(date) return formatter.format(date)
} }
const updateNextAiring = (node, parsed) => { const updateNextAiring = (node: Element, parsed: ParsedBroadcast): void => {
const card = node.closest('.notification-content') const card = node.closest('[data-notification-content]')
if (!card) { if (!card) {
return return
} }
@@ -203,8 +209,8 @@
nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})` nextNode.textContent = `Next episode ${relativeText(nextDate)} (${localDateTimeText(nextDate)})`
} }
const updateNode = (node, localOffsetMinutes) => { const updateNode = (node: Element, localOffsetMinutes: number): void => {
const card = node.closest('.notification-content') const card = node.closest('[data-notification-content]')
const nextNode = card ? card.querySelector('[data-next-airing]') : null const nextNode = card ? card.querySelector('[data-next-airing]') : null
const structured = parseFromStructuredAttrs(node) const structured = parseFromStructuredAttrs(node)
@@ -229,10 +235,10 @@
updateNextAiring(node, parsed) updateNextAiring(node, parsed)
} }
const updateAll = () => { const updateAll = (): void => {
const localOffsetMinutes = -new Date().getTimezoneOffset() const localOffsetMinutes = -new Date().getTimezoneOffset()
const nodes = document.querySelectorAll('[data-jst-text]') const nodes = document.querySelectorAll('[data-jst-text]')
nodes.forEach((node) => updateNode(node, localOffsetMinutes)) nodes.forEach((node: Element): void => updateNode(node, localOffsetMinutes))
} }
document.addEventListener('DOMContentLoaded', updateAll) document.addEventListener('DOMContentLoaded', updateAll)

17
tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ES2020",
"moduleResolution": "Bundler",
"strict": true,
"noEmitOnError": true,
"allowJs": false,
"noEmit": true,
"sourceMap": false,
"removeComments": false,
"skipLibCheck": true
},
"include": [
"static/js/**/*.ts"
]
}