Merge pull request #5 from mkelvers/nxl/ui-tailwind-ts-foundation
add tailwind v4 + typescript foundation for ui migration
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -5,6 +5,8 @@ node_modules
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
static/css/tailwind.css
|
||||
static/js/*.js
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
|
||||
@@ -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`)
|
||||
|
||||
11
Dockerfile
11
Dockerfile
@@ -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
|
||||
|
||||
|
||||
@@ -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
149
bun.lock
Normal 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=="],
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
16
package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1289
static/css/style.css
1289
static/css/style.css
File diff suppressed because it is too large
Load Diff
@@ -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
62
static/js/anime.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
})()
|
||||
@@ -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
25
static/js/auth.ts
Normal 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
|
||||
@@ -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
46
static/js/discover.ts
Normal 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)
|
||||
})
|
||||
})()
|
||||
@@ -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
127
static/js/search.ts
Normal 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
|
||||
}
|
||||
}
|
||||
})()
|
||||
@@ -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
17
tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user