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
dist
*.tgz
static/css/tailwind.css
static/js/*.js
# code coverage
coverage

View File

@@ -14,9 +14,15 @@ Thanks for your interest in improving MAL.
# install templ CLI
go install github.com/a-h/templ/cmd/templ@latest
# install frontend tooling
bun install
# generate templates
templ generate
# build frontend assets (tailwind + typescript)
bun run build:assets
# run tests
go test ./...
@@ -24,6 +30,9 @@ go test ./...
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
- Follow existing folder boundaries (`internal/features/*`, `internal/jikan`, `internal/templates`)

View File

@@ -8,11 +8,22 @@ ENV CGO_ENABLED=1
# Install templ
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 ./
RUN go mod download
COPY package.json bun.lock ./
RUN bun install --frozen-lockfile
COPY . .
# Build frontend assets (tailwind + ts)
RUN bun run build:assets
# Generate templ files
RUN templ generate

View File

@@ -73,14 +73,18 @@ There are still honest limits. Metadata quality still depends partly on external
## Getting started
For local development, install Go `1.24+`, SQLite, and the `templ` CLI, then generate templates and run the server.
For local development, install Go `1.24+`, SQLite, Bun, and the `templ` CLI, then generate templates, build frontend assets, and run the server.
```bash
go install github.com/a-h/templ/cmd/templ@latest
bun install
templ generate
bun run build:assets
go run ./cmd/server
```
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`.
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) {
if props.CurrentNode {
<div class={ defaultString(props.Class, "catalog-item") }>
<div class={ defaultString(props.Class, "min-w-0") }>
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 {
<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 }
</div>
{ children... }
</div>
} 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" {
<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 != "" {
<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 {
<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>
} else {
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 {
<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" {
<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 }
</div>
}

View File

@@ -7,12 +7,12 @@ import (
templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, containerID string) {
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)
</div>
}
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 }>
(function() {
@@ -20,7 +20,7 @@ templ InfiniteAnimeList(animes []jikan.Anime, hasNext bool, nextURL string, cont
const currentScript = scripts[scripts.length - 1];
const actualID = currentScript.getAttribute('data-container');
const container = document.getElementById(actualID) || document;
const items = container.querySelectorAll('.catalog-item[data-id]');
const items = container.querySelectorAll('[data-id]');
const seen = new Set();
items.forEach(item => {
const id = item.getAttribute('data-id');

View File

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

View File

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

View File

@@ -8,23 +8,23 @@ type SortFilterOptions struct {
}
templ SortFilter(opts SortFilterOptions) {
<div class="sort-filter">
<div class="sort-filter-group">
<label for="sort-select">Sort by</label>
<select id="sort-select" class="sort-filter-select" onchange="document.getElementById('sort-input').value = this.value; document.getElementById('sort-form').submit()">
<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="flex items-center gap-2">
<label for="sort-select" class="text-[0.78rem] text-[var(--text-muted)]">Sort by</label>
<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="title" selected?={ opts.Sort == "title" }>Title</option>
</select>
</div>
<div class="sort-filter-group">
<label for="order-select">Order</label>
<select id="order-select" class="sort-filter-select" onchange="document.getElementById('order-input').value = this.value; document.getElementById('sort-form').submit()">
<div class="flex items-center gap-2">
<label for="order-select" class="text-[0.78rem] text-[var(--text-muted)]">Order</label>
<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="asc" selected?={ opts.Order == "asc" }>Ascending</option>
</select>
</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="order" id="order-input" value={ opts.Order }/>
if opts.View != "" {

View File

@@ -7,150 +7,150 @@ import "strings"
templ AnimeDetails(anime jikan.Anime, currentStatus string) {
@Layout("mal - " + anime.DisplayTitle(), true) {
<div class="anime-page">
<div class="anime-main">
<div class="anime-hero anime-surface">
<div class="anime-poster">
<div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]">
<div class="grid min-w-0 gap-8">
<div class="grid grid-cols-[220px_minmax(0,1fr)] gap-5 max-[860px]:grid-cols-[minmax(0,1fr)]">
<div class="w-[220px] max-[860px]:w-[min(230px,58vw)]">
if anime.ImageURL() != "" {
<img src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
<img class="w-full" src={ anime.ImageURL() } alt={ anime.DisplayTitle() }/>
} 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 class="anime-info">
<div>
<h1>{ anime.DisplayTitle() }</h1>
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() != "" {
<span class="info-tag">{ anime.ShortRating() }</span>
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortRating() }</span>
}
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 {
<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() != "" {
<span class="info-tag">{ anime.ShortDuration() }</span>
<span class="text-[0.67rem] text-[var(--text-faint)]">{ anime.ShortDuration() }</span>
}
</div>
<div class="anime-actions">
<div class="mt-3">
@WatchlistDropdown(anime.MalID, anime.Title, anime.TitleEnglish, anime.TitleJapanese, anime.ImageURL(), currentStatus, anime.Airing)
</div>
<section class="anime-synopsis anime-section">
<section class="mt-4 max-w-[100ch]">
if anime.Synopsis != "" {
<p>{ anime.Synopsis }</p>
} else {
<p class="no-synopsis">No synopsis available.</p>
<p class="text-[var(--text-faint)]">No synopsis available.</p>
}
</section>
</div>
</div>
<section class="anime-relations anime-surface anime-section">
<section>
<h3>Related</h3>
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/relations", anime.MalID))) } hx-trigger="load">
@ui.LoadingIndicator("Loading relations")
</div>
</section>
<section class="anime-recommendations anime-surface anime-section">
<section>
<h3>Recommendations</h3>
<div hx-get={ string(templ.URL(fmt.Sprintf("/api/anime/%d/recommendations", anime.MalID))) } hx-trigger="load">
@ui.LoadingIndicator("Loading recommendations")
</div>
</section>
</div>
<aside class="anime-sidebar anime-surface">
<div class="anime-side-section">
<h3>Details</h3>
<aside class="sticky top-[74px] grid gap-4 bg-[var(--panel)] p-3 max-[1040px]:static">
<div class="grid gap-3">
<h3 class="mb-2 text-[0.78rem] text-[var(--text-faint)]">Details</h3>
if anime.Aired.String != "" {
<div class="sidebar-row">
<span class="sidebar-label">Aired</span>
<span class="sidebar-value">{ anime.Aired.String }</span>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Aired</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Aired.String }</span>
</div>
}
if anime.Premiered() != "" {
<div class="sidebar-row">
<span class="sidebar-label">Premiered</span>
<span class="sidebar-value">{ anime.Premiered() }</span>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Premiered</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Premiered() }</span>
</div>
}
if anime.Status != "" {
<div class="sidebar-row">
<span class="sidebar-label">Status</span>
<span class="sidebar-value">{ anime.Status }</span>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Status</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Status }</span>
</div>
}
if anime.Duration != "" {
<div class="sidebar-row">
<span class="sidebar-label">Duration</span>
<span class="sidebar-value">{ anime.Duration }</span>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Duration</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Duration }</span>
</div>
}
if len(anime.Genres) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Genres</span>
<span class="sidebar-value">{ joinNames(anime.Genres) }</span>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Genres</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Genres) }</span>
</div>
}
</div>
if hasExtraSidebarDetails(anime) {
<details class="anime-side-section side-details-more">
<summary>More metadata</summary>
<details class="grid gap-3">
<summary class="cursor-pointer text-[0.82rem] text-[var(--text-muted)]">More metadata</summary>
if anime.TitleJapanese != "" {
<div class="sidebar-row">
<span class="sidebar-label">Japanese</span>
<span class="sidebar-value">{ anime.TitleJapanese }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Japanese</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.TitleJapanese }</span>
</div>
}
if len(anime.TitleSynonyms) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Synonyms</span>
<span class="sidebar-value">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Synonyms</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ strings.Join(anime.TitleSynonyms, ", ") }</span>
</div>
}
if len(anime.Studios) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Studios</span>
<span class="sidebar-value">{ joinNames(anime.Studios) }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Studios</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Studios) }</span>
</div>
}
if len(anime.Producers) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Producers</span>
<span class="sidebar-value">{ joinNames(anime.Producers) }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Producers</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Producers) }</span>
</div>
}
if anime.Source != "" {
<div class="sidebar-row">
<span class="sidebar-label">Source</span>
<span class="sidebar-value">{ anime.Source }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Source</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ anime.Source }</span>
</div>
}
if len(anime.Demographics) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Demographics</span>
<span class="sidebar-value">{ joinNames(anime.Demographics) }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Demographics</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Demographics) }</span>
</div>
}
if len(anime.Themes) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Themes</span>
<span class="sidebar-value">{ joinNames(anime.Themes) }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Themes</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinNames(anime.Themes) }</span>
</div>
}
if anime.Broadcast.String != "" {
<div class="sidebar-row">
<span class="sidebar-label">Broadcast</span>
<span class="sidebar-value" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Broadcast</span>
<span class="text-[0.84rem] text-[var(--text-muted)]" data-jst-text={ anime.Broadcast.String }>{ anime.Broadcast.String }</span>
</div>
}
if len(anime.Streaming) > 0 {
<div class="sidebar-row">
<span class="sidebar-label">Streaming</span>
<span class="sidebar-value">{ joinStreamingNames(anime) }</span>
</div>
<div class="mt-1 grid gap-1">
<span class="mt-[2px] text-[0.84rem] text-[var(--text-faint)]">Streaming</span>
<span class="text-[0.84rem] text-[var(--text-muted)]">{ joinStreamingNames(anime) }</span>
</div>
}
</details>
}
@@ -161,12 +161,12 @@ templ AnimeDetails(anime jikan.Anime, currentStatus string) {
templ AnimePending(id int) {
@Layout("mal - anime pending", true) {
<div class="anime-page">
<div class="anime-main">
<section class="anime-surface anime-section">
<div class="grid grid-cols-[minmax(0,1fr)_300px] items-start gap-5 max-[1040px]:grid-cols-[minmax(0,1fr)]">
<div class="grid min-w-0 gap-8">
<section>
<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="empty-inline-note">Refresh this page in a few seconds.</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="text-[0.9rem] text-[var(--text-muted)]">Refresh this page in a few seconds.</p>
</section>
</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) {
<div class="dropdown" id="watchlist-dropdown">
<button class="dropdown-trigger" onclick="toggleDropdown()">
<div class="relative inline-block" id="watchlist-dropdown">
<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 != "" {
{ formatStatus(currentStatus) }
} else {
Add to watchlist
}
<span class="dropdown-arrow">▾</span>
<span class="text-[0.64rem]">▾</span>
</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, "completed", 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, "plan_to_watch", currentStatus, airing)
if currentStatus != "" {
<div class="dropdown-divider"></div>
<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-target="#watchlist-dropdown"
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) {
<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-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"
@@ -254,7 +256,7 @@ func formatStatus(status string) string {
templ AnimeRelationsList(relations []jikan.RelationEntry) {
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 {
@ui.AnimeCard(ui.AnimeCardProps{
ID: rel.Anime.MalID,
@@ -266,13 +268,13 @@ templ AnimeRelationsList(relations []jikan.RelationEntry) {
CurrentNode: rel.IsCurrent,
}) {
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>
} 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) {
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 {
@ui.AnimeCard(ui.AnimeCardProps{
ID: anime.MalID,
@@ -299,7 +301,7 @@ templ AnimeRecommendations(recs []jikan.Anime) {
}
</div>
} 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) {
@Layout("Login", false) {
<div class="auth-shell">
<div class="login-container">
<h2>Sign in</h2>
<p class="login-subtitle">Enter your credentials to continue.</p>
<form action="/login" method="POST" class="login-form">
<div class="form-group">
<div class="w-full max-w-[560px]">
<div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2 class="m-0 text-[1.4rem]">Sign in</h2>
<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="grid gap-4">
<div class="grid gap-1">
<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 class="form-group">
<div class="grid gap-1">
<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>
<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 != "" {
<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>
<p class="auth-switch-row">
Don't have an account? <a href="/register">Register</a>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Don't have an account? <a class="text-[var(--accent)]" href="/register">Register</a>
</p>
<p class="auth-switch-row">
Lost access? <a href="/recover">Recover account</a>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Lost access? <a class="text-[var(--accent)]" href="/recover">Recover account</a>
</p>
</div>
</div>
@@ -33,29 +33,29 @@ templ Login(formError string, username string) {
templ Register(formError string, username string) {
@Layout("Register", false) {
<div class="auth-shell">
<div class="login-container">
<h2>Register</h2>
<p class="login-subtitle">Create a new account to track anime.</p>
<form action="/register" method="POST" class="login-form">
<div class="form-group">
<div class="w-full max-w-[560px]">
<div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2 class="m-0 text-[1.4rem]">Register</h2>
<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="grid gap-4">
<div class="grid gap-1">
<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 class="form-group">
<div class="grid gap-1">
<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>
<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.
</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 != "" {
<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>
<p class="auth-switch-row">
Already have an account? <a href="/login">Sign in</a>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Already have an account? <a class="text-[var(--accent)]" href="/login">Sign in</a>
</p>
</div>
</div>
@@ -64,18 +64,18 @@ templ Register(formError string, username string) {
templ RegistrationRecoveryKey(recoveryKey string) {
@Layout("Save recovery key", false) {
<div class="auth-shell">
<div class="login-container">
<div class="w-full max-w-[560px]">
<div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Save your recovery key</h2>
<p class="login-subtitle">Store this key somewhere safe. It is shown only once.</p>
<div class="recovery-key-row">
<p class="recovery-key-box" id="registration-recovery-key">{ recoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('registration-recovery-key', 'registration-copy-feedback')">Copy key</button>
<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="grid gap-2">
<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="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>
<p class="auth-password-note recovery-copy-feedback" 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="auth-switch-row">
<a href="/" class="auth-primary-link">I saved it, continue</a>
<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="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="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
<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>
</div>
</div>
@@ -84,30 +84,30 @@ templ RegistrationRecoveryKey(recoveryKey string) {
templ Recover(formError string, username string, recoveryKey string) {
@Layout("Recover account", false) {
<div class="auth-shell">
<div class="login-container">
<h2>Recover account</h2>
<p class="login-subtitle">Enter your username, recovery key, and a new password.</p>
<form action="/recover" method="POST" class="login-form">
<div class="form-group">
<div class="w-full max-w-[560px]">
<div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2 class="m-0 text-[1.4rem]">Recover account</h2>
<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="grid gap-4">
<div class="grid gap-1">
<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 class="form-group">
<div class="grid gap-1">
<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 class="form-group">
<div class="grid gap-1">
<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>
<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 != "" {
<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>
<p class="auth-switch-row">
Remembered your password? <a href="/login">Sign in</a>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
Remembered your password? <a class="text-[var(--accent)]" href="/login">Sign in</a>
</p>
</div>
</div>
@@ -116,18 +116,18 @@ templ Recover(formError string, username string, recoveryKey string) {
templ RecoveryComplete(newRecoveryKey string) {
@Layout("Recovery complete", false) {
<div class="auth-shell">
<div class="login-container">
<div class="w-full max-w-[560px]">
<div class="mx-auto w-full bg-[var(--panel)] p-6">
<h2>Account recovered</h2>
<p class="login-subtitle">Your password was reset and your recovery key was rotated.</p>
<div class="recovery-key-row">
<p class="recovery-key-box" id="recovery-complete-key">{ newRecoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('recovery-complete-key', 'recovery-complete-feedback')">Copy key</button>
<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="grid gap-2">
<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="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>
<p class="auth-password-note recovery-copy-feedback" id="recovery-complete-feedback" aria-live="polite"></p>
<p class="auth-password-note">Replace your old recovery key with this one.</p>
<p class="auth-switch-row">
<a href="/login" class="auth-primary-link">Go to login</a>
<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="m-0 text-[0.75rem] leading-[1.4] text-[var(--text-faint)]">Replace your old recovery key with this one.</p>
<p class="mt-5 mb-0 text-center text-[0.9rem] text-[var(--text-muted)]">
<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>
</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) {
@Layout("Account", true) {
<div class="account-page">
<section class="account-card">
<div class="mx-auto grid w-[min(720px,100%)] gap-4">
<section class="grid gap-3 bg-[var(--panel)] p-5">
<h2>Account</h2>
<div class="account-meta">
<div class="account-meta-row">
<span class="account-meta-label">Email / Username</span>
<span class="account-meta-value">{ username }</span>
<div class="grid gap-2">
<div class="grid gap-1">
<span class="text-[0.78rem] text-[var(--text-faint)]">Email / Username</span>
<span class="text-[0.95rem] text-[var(--text)]">{ username }</span>
</div>
<div class="account-meta-row">
<span class="account-meta-label">Created</span>
<span class="account-meta-value">{ createdAt }</span>
<div class="grid gap-1">
<span class="text-[0.78rem] text-[var(--text-faint)]">Created</span>
<span class="text-[0.95rem] text-[var(--text)]">{ createdAt }</span>
</div>
</div>
</section>
<section class="account-card">
<section class="grid gap-3 bg-[var(--panel)] p-5">
<h3>Change password</h3>
<form action="/account/password" method="POST" class="account-form" onsubmit="return confirmDangerAction('Change your password now?')">
<div class="form-group">
<form action="/account/password" method="POST" class="grid gap-3" onsubmit="return confirmDangerAction('Change your password now?')">
<div class="grid gap-1">
<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 class="form-group">
<div class="grid gap-1">
<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 class="form-group">
<div class="grid gap-1">
<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>
<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 != "" {
<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 != "" {
<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>
</section>
<section class="account-card">
<section class="grid gap-3 bg-[var(--panel)] p-5">
<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>
<form action="/account/recovery-key" method="POST" class="account-form" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')">
<div class="form-group">
<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="grid gap-3" onsubmit="return confirmDangerAction('Rotate recovery key now? Your old key will stop working.')">
<div class="grid gap-1">
<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>
<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 != "" {
<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 != "" {
<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>
if recoveryKey != "" {
<div class="recovery-key-row">
<p class="recovery-key-box" id="account-recovery-key">{ recoveryKey }</p>
<button type="button" class="recovery-copy-btn" onclick="copyRecoveryKey('account-recovery-key', 'account-copy-feedback')">Copy key</button>
<div class="grid gap-2">
<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="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>
<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 class="account-card">
<section class="grid gap-3 bg-[var(--panel)] p-5">
<h3>Danger zone</h3>
<form action="/logout" method="POST" class="account-form-inline" onsubmit="return confirmDangerAction('Log out of this account now?')">
<button type="submit" class="account-logout-btn">Log out</button>
<form action="/logout" method="POST" class="inline-flex" onsubmit="return confirmDangerAction('Log out of this account now?')">
<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>
</section>
</div>

View File

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

View File

@@ -6,35 +6,39 @@ import "fmt"
templ Discover() {
@Layout("mal - discover", true) {
<div class="discover-container">
<div class="discover-header">
<div class="grid gap-4">
<div class="grid gap-4">
<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 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
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"
hx-get="/api/discover/airing?page=1"
hx-target="#discover-content"
hx-trigger="click"
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
</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"
hx-get="/api/discover/upcoming?page=1"
hx-target="#discover-content"
hx-trigger="click"
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
</button>
</div>
<div class="catalog-grid" id="discover-content" hx-get="/api/discover/airing?page=1" hx-trigger="load">
<div class="grid-full-width">
<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="col-span-full">
@ui.LoadingIndicator("Loading discover")
</div>
</div>

View File

@@ -10,7 +10,7 @@ import (
templ Search(q string) {
@Layout("mal - search", true) {
if q != "" {
<div id="loading" class="htmx-indicator">
<div id="loading" class="hidden htmx-request:inline-flex">
@ui.LoadingIndicator("Searching...")
</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.
}
} 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)
</div>
}

View File

@@ -10,39 +10,42 @@ templ Layout(title string, showHeader bool) {
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>{ title }</title>
<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="/static/js/discover.js" defer></script>
<script src="/static/js/anime.js" defer></script>
<script src="/static/js/timezone.js" defer></script>
<script src="/static/js/auth.js" defer></script>
</head>
<body>
<body class="min-h-screen bg-[var(--bg)] text-[var(--text)] font-[var(--font)] text-[14px] leading-[1.45]">
if showHeader {
<header>
<div class="header-top">
<div class="header-left">
<a href="/" class="logo" aria-label="mal logo">
@icons.LogoIcon("logo-svg")
<header class="sticky top-0 z-[100] bg-[var(--header)]">
<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="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="inline-flex items-center text-[var(--accent)]" aria-label="mal logo">
@icons.LogoIcon("h-7 w-7")
</a>
<div class="nav">
<a href="/">Catalog</a>
<a href="/discover">Discover</a>
<a href="/notifications">Notifications</a>
<a href="/watchlist">Watchlist</a>
<a href="/account">Account</a>
<div class="flex flex-wrap gap-3 text-[0.85rem] max-[860px]:w-full max-[860px]:gap-2">
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/">Catalog</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/discover">Discover</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/notifications">Notifications</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/watchlist">Watchlist</a>
<a class="text-[var(--text-muted)] no-underline hover:text-[var(--text)] hover:no-underline" href="/account">Account</a>
</div>
</div>
<div class="header-search-wrapper">
<form action="/search" method="GET" class="header-search" id="search-form">
<input type="text" id="search-input" name="q" class="search-input" placeholder="Search anime..." autocomplete="off"/>
<div id="search-dropdown" class="search-dropdown"></div>
<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="w-full" id="search-form">
<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="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>
</div>
</div>
</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... }
</main>
<script src="/static/js/search.js"></script>

View File

@@ -2,11 +2,11 @@ package templates
templ NotFoundPage() {
@Layout("mal - not found", false) {
<section class="not-found-page anime-surface">
<p class="not-found-code">404</p>
<h1>Page not found</h1>
<p class="empty-inline-note">The page you requested does not exist, or it was moved.</p>
<p><a href="/" class="not-found-link">Back to catalog</a></p>
<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="m-0 text-[clamp(4rem,15vw,10rem)] tracking-[0.04em] leading-[0.9] text-[var(--text-muted)]">404</p>
<h1 class="m-0 text-[clamp(2rem,4vw,3rem)]">Page not found</h1>
<p class="text-[var(--text-muted)]">The page you requested does not exist, or it was moved.</p>
<p><a href="/" class="text-[1.05rem] text-[var(--accent)] no-underline hover:underline">Back to catalog</a></p>
</section>
}
}

View File

@@ -13,11 +13,11 @@ type WatchingAnimeWithDetails struct {
templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@Layout("mal - notifications", true) {
<div class="notifications-page">
<div class="grid gap-4">
<h1>Notifications</h1>
<div class="status-tabs">
<a href="/notifications?tab=tracking" class={ activeClass(activeTab == "tracking") }>Tracking</a>
<a href="/notifications?tab=sequels" class={ activeClass(activeTab == "sequels") }>Sequels</a>
<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={ statusTabClass(activeTab == "tracking") }>Tracking</a>
<a href="/notifications?tab=sequels" class={ statusTabClass(activeTab == "sequels") }>Sequels</a>
</div>
if activeTab == "sequels" {
@@ -25,13 +25,13 @@ templ Notifications(watching []WatchingAnimeWithDetails, activeTab string) {
@ui.LoadingIndicator("Syncing sequel graphs...")
</div>
} 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 {
@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 {
<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 {
@NotificationCard(item)
}
@@ -56,7 +56,7 @@ func splitUpcomingSeasons(items []database.GetUpcomingSeasonsRow) (airing []data
templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if len(upcomingSeasons) == 0 {
@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 {
@renderSplitSeasons(upcomingSeasons)
@@ -66,10 +66,10 @@ templ UpcomingSeasonsList(upcomingSeasons []database.GetUpcomingSeasonsRow) {
templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
if airing, upcoming := splitUpcomingSeasons(upcomingSeasons); true {
if len(airing) > 0 {
<section class="notifications-group notifications-list-spaced">
<h2 class="notifications-group-title">Airing now</h2>
<p class="notifications-group-note">These are the currently airing anime, but you're not tracking any of these.</p>
<div class="notifications-list">
<section class="mb-4 grid gap-3">
<h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">Airing now</h2>
<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="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, item := range airing {
@UpcomingSeasonCard(item)
}
@@ -78,10 +78,10 @@ templ renderSplitSeasons(upcomingSeasons []database.GetUpcomingSeasonsRow) {
}
if len(upcoming) > 0 {
<section class="notifications-group">
<h2 class="notifications-group-title">Announced & upcoming</h2>
<p class="notifications-group-note">Newly announced or upcoming seasons related to anime you've watched.</p>
<div class="notifications-list">
<section class="grid gap-3">
<h2 class="m-0 text-[1.25rem] font-semibold leading-[1.2]">Announced & upcoming</h2>
<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="grid grid-cols-[repeat(auto-fill,minmax(190px,1fr))] gap-4 max-[680px]:grid-cols-2 max-[680px]:gap-3">
for _, item := range upcoming {
@UpcomingSeasonCard(item)
}
@@ -96,19 +96,19 @@ templ UpcomingSeasonCard(item database.GetUpcomingSeasonsRow) {
ID: int(item.ID),
Title: displaySeasonTitle(item),
ImageURL: item.ImageUrl,
Class: "notification-card",
ImgClass: "notification-image",
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
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="notification-title">
<div class="mt-2 grid gap-1 p-0" data-notification-content>
<div class="line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]">
{ displaySeasonTitle(item) }
</div>
<div class="notification-meta">
<div class="flex flex-wrap gap-2">
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) != "" {
<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>
@@ -124,20 +124,20 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
ID: int(item.Entry.AnimeID),
Title: displayTitle(item.Entry),
ImageURL: item.Entry.ImageUrl,
Class: "notification-card",
ImgClass: "notification-image",
Class: "notification-card min-w-0 flex flex-col bg-transparent text-inherit no-underline",
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="notification-title">
<div class="mt-2 grid gap-1 p-0" data-notification-content>
<div class="line-clamp-2 text-[0.86rem] leading-[1.3] text-[var(--text)]">
{ displayTitle(item.Entry) }
</div>
<div class="notification-meta">
<div class="flex flex-wrap gap-2">
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="notification-next-airing" data-next-airing="pending">Calculating next episode time...</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="text-[0.67rem] text-[var(--text-faint)]" data-next-airing="pending">Calculating next episode time...</span>
}
if item.Anime.Episodes > 0 {
<span class="notification-progress">
<span class="text-[0.67rem] text-[var(--text-faint)]">
if item.Entry.CurrentEpisode.Valid {
{ fmt.Sprintf("%d / %d eps", item.Entry.CurrentEpisode.Int64, item.Anime.Episodes) }
} else {
@@ -145,7 +145,7 @@ templ NotificationCard(item WatchingAnimeWithDetails) {
}
</span>
} 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) }
</span>
}
@@ -174,3 +174,11 @@ func seasonStatusLabel(status string) string {
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) {
@Layout("My Watchlist", true) {
<div class="watchlist-header">
<div class="watchlist-heading">
<div class="mb-4 flex items-end justify-between gap-4 max-[860px]:flex-col max-[860px]:items-start">
<div class="grid gap-1">
<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 class="watchlist-controls">
<a href="/api/watchlist/export" class="text-link">Export</a>
<button class="text-link" 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">
<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="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="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="hidden">
<input type="file" id="import-file" name="file" accept=".json" onchange="htmx.trigger('#import-form', 'submit')"/>
</form>
<div class="view-toggle">
<a href={ templ.URL(watchlistURL("grid", currentStatus, sortBy, sortOrder)) } class={ activeClass(layout == "grid") }>Grid</a>
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ activeClass(layout == "table") }>Table</a>
<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={ tabClass(layout == "grid") }>Grid</a>
<a href={ templ.URL(watchlistURL("table", currentStatus, sortBy, sortOrder)) } class={ tabClass(layout == "table") }>Table</a>
</div>
</div>
</div>
<div class="status-tabs">
<a href={ templ.URL(watchlistURL(layout, "all", sortBy, sortOrder)) } class={ activeClass(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, "continuing", sortBy, sortOrder)) } class={ activeClass(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, "plan_to_watch", sortBy, sortOrder)) } class={ activeClass(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, "completed", sortBy, sortOrder)) } class={ activeClass(currentStatus == "completed") }>Completed</a>
<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={ tabClass(currentStatus == "all") }>All</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={ tabClass(currentStatus == "continuing") }>Continuing</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={ tabClass(currentStatus == "plan_to_watch") }>Plan to watch</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={ tabClass(currentStatus == "completed") }>Completed</a>
</div>
@ui.SortFilter(ui.SortFilterOptions{Sort: sortBy, Order: sortOrder, View: layout, Status: currentStatus})
if len(entries) == 0 {
@@ -47,16 +47,16 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
}
} else {
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 {
<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{
ID: int(entry.AnimeID),
Title: entry.DisplayTitle(),
ImageURL: entry.ImageUrl,
})
<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-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
hx-swap="delete"
@@ -65,30 +65,30 @@ templ Watchlist(entries []database.GetUserWatchListRow, layout string, currentSt
}
</div>
} 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>
<tr>
<th></th>
<th>Title</th>
<th></th>
<th class="p-[0.6rem]"></th>
<th class="p-[0.6rem] text-left text-[0.67rem] text-[var(--text-faint)]">Title</th>
<th class="p-[0.6rem]"></th>
</tr>
</thead>
<tbody>
for _, entry := range entries {
<tr id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
<td>
<tr class="hover:bg-[var(--panel-soft)]" id={ fmt.Sprintf("watchlist-entry-%d", entry.AnimeID) }>
<td class="p-[0.6rem]">
<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>
</td>
<td class="title-cell">
<td class="p-[0.6rem] font-medium">
<a href={ templ.SafeURL(fmt.Sprintf("/anime/%d", entry.AnimeID)) }>
{ entry.DisplayTitle() }
</a>
</td>
<td class="actions-cell">
<td class="w-[90px] p-[0.6rem]">
<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-target={ fmt.Sprintf("#watchlist-entry-%d", entry.AnimeID) }
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 {
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 {

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

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"
]
}