corpus

Log | Files | Refs | README | LICENSE

commit 6833d816e33b882c8fd3fac87b8167e3bab864db
parent c30098bccb7d0e7ba0a8b67a7577e9ca1d863058
Author: mtmn <miro@haravara.org>
Date:   Fri, 17 Apr 2026 13:18:54 +0200

feat: use elm in frontend instead of halogen

Diffstat:
M.gitignore | 1+
Mdocs/architecture.md | 9++++-----
Aelm.json | 25+++++++++++++++++++++++++
Mflake.nix | 42++++++++++++++++++++++++++++++++++++++++--
Mpackage-lock.json | 481+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mpackage.json | 5+++--
Mspago.lock | 330+------------------------------------------------------------------------------
Mspago.yaml | 10++--------
Asrc/Client.elm | 1007+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Dsrc/Client.js | 8--------
Dsrc/Client.purs | 583-------------------------------------------------------------------------------
Msrc/Main.purs | 9+++++++++
12 files changed, 1575 insertions(+), 935 deletions(-)

diff --git a/.gitignore b/.gitignore @@ -1,5 +1,6 @@ bower_components/ node_modules/ +elm-stuff/ .pulp-cache/ output/ output-es/ diff --git a/docs/architecture.md b/docs/architecture.md @@ -13,7 +13,7 @@ The server is built with PureScript running on Node.js. It handles several core - **Cover Art Proxy**: A specialized endpoint that fetches, caches, and serves cover art, utilizing a multi-source fallback strategy (CAA → Last.fm → Discogs). ### Frontend -A Single Page Application (SPA) built with PureScript and the [Halogen](https://github.com/purescript-halogen/purescript-halogen) framework. +A Single Page Application (SPA) built with [Elm](https://elm-lang.org). - **Real-time Updates**: Periodically refreshes the scrobble list. - **Filtering & Search**: Supports deep filtering by genre, label, or release year. - **Responsive UI**: Designed for both desktop and mobile viewing with a "retro-modern" aesthetic. @@ -64,11 +64,10 @@ When a cover is requested: ## Tech Stack -- **Language**: [PureScript](https://purescript.org) -- **Frontend Framework**: [Halogen](https://github.com/purescript-halogen/purescript-halogen) +- **Language**: [PureScript](https://purescript.org) (server), [Elm](https://elm-lang.org) (frontend) - **Runtime**: [Node.js](https://nodejs.org) - **Database**: [DuckDB](https://duckdb.org) -- **Bundling**: [spago](https://github.com/purescript/spago) and [esbuild](https://esbuild.github.io/) +- **Bundling**: [spago](https://github.com/purescript/spago) + [esbuild](https://esbuild.github.io/) (server), [elm make](https://guide.elm-lang.org/install/elm.html) (frontend) - **Environment**: [Nix](https://nixos.org) for reproducible development shells and container builds ## Foreign Function Interface (FFI) @@ -105,7 +104,7 @@ graph TD end subgraph Frontend - UI[Halogen SPA] + UI[Elm SPA] end %% Scrobble Sync Flow diff --git a/elm.json b/elm.json @@ -0,0 +1,25 @@ +{ + "type": "application", + "source-directories": ["src"], + "elm-version": "0.19.1", + "dependencies": { + "direct": { + "elm/browser": "1.0.2", + "elm/core": "1.0.5", + "elm/html": "1.0.1", + "elm/http": "2.0.0", + "elm/json": "1.1.4", + "elm/time": "1.0.0", + "elm/url": "1.0.0" + }, + "indirect": { + "elm/bytes": "1.0.8", + "elm/file": "1.0.5", + "elm/virtual-dom": "1.0.5" + } + }, + "test-dependencies": { + "direct": {}, + "indirect": {} + } +} diff --git a/flake.nix b/flake.nix @@ -33,6 +33,37 @@ hash = "sha256-ez73ecGmzf5d7EkamNZ9KT8u7e4yR7jSvdiGu4bGAHs="; }; + elmDeps = pkgs.stdenv.mkDerivation { + name = "corpus-elm-deps"; + + src = pkgs.lib.cleanSourceWith { + src = self; + filter = name: _type: let + baseName = baseNameOf (toString name); + in + pkgs.lib.elem baseName ["elm.json" "src" "Client.elm"]; + }; + + nativeBuildInputs = with pkgs; [elmPackages.elm cacert]; + + outputHashAlgo = "sha256"; + outputHashMode = "recursive"; + outputHash = "sha256-7Qx/xkTOh4bPQuztHe8s82CrLmrFGNjhOqYqpW8Jbec="; + + buildPhase = '' + export HOME=$TMPDIR + export SSL_CERT_FILE="${pkgs.cacert}/etc/ssl/certs/ca-bundle.crt" + export ELM_HOME=$HOME/.elm + + elm make src/Client.elm --output=/dev/null + ''; + + installPhase = '' + mkdir -p $out + cp -r $HOME/.elm/0.19.1/packages $out/ + ''; + }; + spagoDeps = pkgs.stdenv.mkDerivation { name = "corpus-spago-deps"; @@ -48,7 +79,7 @@ outputHashAlgo = "sha256"; outputHashMode = "recursive"; - outputHash = "sha256-aJPjClixXOdbAZrAyb63Asvt3EyN6ov0Vv+DawvOSag="; + outputHash = "sha256-hlMN1T0s8otmQcj/Hinf/FpLWSjAIesE3n3krGBS/WU="; buildPhase = '' export HOME=$TMPDIR @@ -100,7 +131,7 @@ version = "1.0.0"; inherit src; - npmDepsHash = "sha256-q7+quD9rN0eTyPOLxjDvjQgFDnQtJanvSXzJEvMQfFk="; + npmDepsHash = "sha256-vxwqSgi9mIE/1skBkgk9CbcZa5In4mme1YUJoJs/7Lo="; npmRebuildFlags = ["--ignore-scripts"]; nativeBuildInputs = with pkgs; [ @@ -108,6 +139,7 @@ nodejs git purescript + elmPackages.elm ]; buildPhase = '' @@ -128,6 +160,11 @@ cp -r ${spagoRegistryIndex} $HOME/.cache/spago-nodejs/registry-index chmod -R u+w $HOME/.cache/spago-nodejs + # Restore elm packages + mkdir -p $HOME/.elm/0.19.1 + cp -r ${elmDeps}/packages $HOME/.elm/0.19.1/ + chmod -R u+w $HOME/.elm + npm run build ''; @@ -178,6 +215,7 @@ buildInputs = with pkgs; [ nodejs purescript + elmPackages.elm awscli2 duckdb esbuild diff --git a/package-lock.json b/package-lock.json @@ -13,6 +13,7 @@ "duckdb": "^1.4.4" }, "devDependencies": { + "elm": "^0.19.1", "esbuild": "^0.28.0", "purescript": "^0.15.16", "purescript-language-server": "^0.18.0", @@ -2208,6 +2209,23 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -2283,6 +2301,16 @@ "safer-buffer": "~2.1.0" } }, + "node_modules/assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha512-NfJ4UzBCcQGLDlQq7nHxH+tv3kyZ0hHQqF5BO6J7tNJeP5do1llPr8dZ8zHonfhAu0PHAdMkSo+8o0wxg9lZWw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, "node_modules/astral-regex": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/astral-regex/-/astral-regex-2.0.0.tgz", @@ -2293,6 +2321,30 @@ "node": ">=8" } }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha512-08kcGqnYf/YmjoRhfxyu+CLxBjUtHLXLXX/vUfx9l2LYzG3c1m61nrpyFUZI6zeS+Li/wWMMidD9KgrqtGq3mA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/aws4": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.13.2.tgz", + "integrity": "sha512-lHe62zvbTB5eEABUVi/AwVh0ZKY9rMMDhmm+eeyuuUQbQ3+J+fONVQOZyj+DdrvD4BY33uYniyRJ4UJIaSKAfw==", + "dev": true, + "license": "MIT" + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -2485,6 +2537,13 @@ "node": ">=8" } }, + "node_modules/caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha512-4tYFyifaFfGacoiObjJegolkwSU4xQNGbVgUiNYVUxbQ2x2lUsFvY4hVgVzGiIe6WLOPqycWXA40l+PWsxthUw==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/chalk": { "version": "2.4.2", "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz", @@ -2557,6 +2616,19 @@ "color-support": "bin.js" } }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -2721,6 +2793,19 @@ "dev": true, "license": "MIT" }, + "node_modules/dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha512-jRFi8UDGo6j+odZiEpjazZaWqEal3w/basFjQHQEwVtZJGDpxbH1MeYluwCS8Xq5wmLJooDlMgvVarmWfGM44g==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -2781,6 +2866,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/delegates": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", @@ -2866,6 +2961,35 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha512-eh9O+hwRHNbG4BLTjEl3nw044CkGm5X6LoaCf7LPp7UU8Qrt47JYNi6nPX8xjW97TKGKm1ouctg0QSpZe9qrnw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "node_modules/elm": { + "version": "0.19.1", + "resolved": "https://registry.npmjs.org/elm/-/elm-0.19.1.tgz", + "integrity": "sha512-rehOtJKZvoYDddlrd7AX5NAf0H+LUllnBg3AHaeaIOKWzw4W316d7Bkhlbo7aSG+hVUVWP2ihKwyYkDi589TfA==", + "deprecated": "package.json was changed upon upload, breaking things on Mac and Linux", + "dev": true, + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "request": "^2.88.0" + }, + "bin": { + "elm": "bin/elm" + }, + "engines": { + "node": ">=7.0.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", @@ -2999,6 +3123,37 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true, + "license": "MIT" + }, + "node_modules/extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT" + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-xml-builder": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", @@ -3149,6 +3304,31 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha512-j0KLYPhm6zeac4lz3oJ3o65qvgQCcPubiyotZrXqEaG4hNagNYO8qdlUrX5vwqv9ohqeT/Z3j6+yW067yWWdUw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, + "node_modules/form-data": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.3.tgz", + "integrity": "sha512-1lLKB2Mu3aGP1Q/2eCOx0fNbRMe7XdwktwOruhfqqd0rIJWwN4Dh+E3hrPSlDCXnSR7UtZ1N38rVXm+6+MEhJQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.6", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 0.12" + } + }, "node_modules/from2": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/from2/-/from2-2.3.0.tgz", @@ -3323,6 +3503,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha512-0fzj9JxOLfJ+XGLhR8ze3unN0KZCgZwiSSDz168VERjK8Wl8kVSdcu2kspd4s4wtAa1y/qrVRiAA0WclVsu0ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0" + } + }, "node_modules/glob": { "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", @@ -3350,6 +3540,31 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha512-Oqluz6zhGX8cyRaTQlFMPw80bSJVG2x/cFb8ZPhUILGgHka9SsokCCOQgpveePerqidZOrT14ipqfJb7ILcW5Q==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=4" + } + }, + "node_modules/har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "deprecated": "this library is no longer supported", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -3398,6 +3613,22 @@ "node": ">= 6.0.0" } }, + "node_modules/http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha512-CAbnr6Rz4CYQkLYUtSNXxQPUH2gK8f3iWexVlsnMeD+GjlsQ0Xsy1cOX+mN3dtxYomRy21CiOzU8Uhw6OwncEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + }, + "engines": { + "node": ">=0.8", + "npm": ">=1.3.7" + } + }, "node_modules/https-proxy-agent": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", @@ -3573,6 +3804,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==", + "dev": true, + "license": "MIT" + }, "node_modules/is-wsl": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", @@ -3602,6 +3840,13 @@ "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", "license": "ISC" }, + "node_modules/isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha512-Yljz7ffyPbrLpLngrMtZ7NduUgVvi6wG9RJ9IUcyCd59YQ911PBJphODUcbOVbqYfxe1wuYf/LJ8PauMRwsM/g==", + "dev": true, + "license": "MIT" + }, "node_modules/jackspeak": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-4.2.3.tgz", @@ -3618,6 +3863,34 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha512-UVU9dibq2JcFWxQPA6KCqj5O42VOmAY3zQUfEKxU0KpTGXwNoCjkX1e13eHNvw/xPynt6pU0rZ1htjWTNTSXsg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "dev": true, + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, "node_modules/jsonfile": { "version": "6.2.0", "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.0.tgz", @@ -3631,6 +3904,22 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/jsprim": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.2.tgz", + "integrity": "sha512-P2bSOMAc/ciLz6DzgjVlGJP9+BrJWu5UDGK70C2iweC5QBIeFf0ZXRvGjEj2uYgrY2MkAAhsSWHDWlFtEroZWw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.4.0", + "verror": "1.10.0" + }, + "engines": { + "node": ">=0.6.0" + } + }, "node_modules/linkify-it": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", @@ -3793,6 +4082,29 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-fn": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz", @@ -4165,6 +4477,16 @@ "node": "^12.13.0 || ^14.15.0 || >=16.0.0" } }, + "node_modules/oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "*" + } + }, "node_modules/once": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", @@ -4357,6 +4679,13 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "dev": true, + "license": "MIT" + }, "node_modules/picomatch": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", @@ -4396,6 +4725,19 @@ "node": ">=10" } }, + "node_modules/psl": { + "version": "1.15.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.15.0.tgz", + "integrity": "sha512-JZd3gMVBAVQkSs6HdNZo9Sdo0LNcQeMNP3CozBJb3JYC/QUYZTnKxP+f8oWRX4rHP5EurWxqAHTSwUCjlNKa1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "funding": { + "url": "https://github.com/sponsors/lupomontero" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", @@ -4685,6 +5027,16 @@ "purs-tidy": "bin/index.js" } }, + "node_modules/qs": { + "version": "6.5.5", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.5.tgz", + "integrity": "sha512-mzR4sElr1bfCaPJe7m8ilJ6ZXdDaGoObcYR0ZHSsktM/Lt21MVHj5De30GQH2eiZ1qGRTO7LCAzQsUeXTNexWQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.6" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4730,6 +5082,50 @@ "node": ">= 0.8.0" } }, + "node_modules/request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "deprecated": "request has been deprecated, see https://github.com/request/request/issues/3142", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/request/node_modules/uuid": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.4.0.tgz", + "integrity": "sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==", + "deprecated": "Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.", + "dev": true, + "license": "MIT", + "bin": { + "uuid": "bin/uuid" + } + }, "node_modules/restore-cursor": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-3.1.0.tgz", @@ -5202,6 +5598,32 @@ "nan": "^2.23.0" } }, + "node_modules/sshpk": { + "version": "1.18.0", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.18.0.tgz", + "integrity": "sha512-2p2KJZTSqQ/I3+HX42EpYOa2l3f8Erv8MWKsy2I9uf4wA7yFIkXRffYdsx86y6z4vHtV8u7g+pPlr8/4ouAxsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + }, + "bin": { + "sshpk-conv": "bin/sshpk-conv", + "sshpk-sign": "bin/sshpk-sign", + "sshpk-verify": "bin/sshpk-verify" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ssri": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", @@ -5424,6 +5846,20 @@ "node": ">=8.0" } }, + "node_modules/tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + }, + "engines": { + "node": ">=0.8" + } + }, "node_modules/tr46": { "version": "0.0.3", "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", @@ -5436,6 +5872,19 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -5504,6 +5953,16 @@ "node": ">= 10.0.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5524,6 +5983,28 @@ "uuid": "dist/bin/uuid" } }, + "node_modules/verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha512-ZZKSmDAEFOijERBLkmYfJ+vmk3w+7hOLYDNkRCuRuMJGEmqYNCNLyBBFwWKVMhfwaEF3WOd0Zlw86U/WC/+nYw==", + "dev": true, + "engines": [ + "node >=0.6.0" + ], + "license": "MIT", + "dependencies": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "node_modules/verror/node_modules/core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==", + "dev": true, + "license": "MIT" + }, "node_modules/vscode-jsonrpc": { "version": "8.2.1", "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-8.2.1.tgz", diff --git a/package.json b/package.json @@ -1,15 +1,16 @@ { "name": "corpus", "version": "1.0.0", - "description": "ListenBrainz frontend in PureScript", + "description": "ListenBrainz scrobble viewer", "main": "index.js", "type": "module", "scripts": { - "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 && npx spago bundle --module Client --outfile client.js --platform browser", + "build": "npx spago build && npx esbuild output/Main/index.js --bundle --platform=node --format=esm --footer:js='main();' --outfile=server.js --external:http --external:https --external:dotenv --external:url --external:duckdb --external:@aws-sdk/client-s3 && elm make src/Client.elm --output=client.js", "test": "npx spago test", "tidy": "purs-tidy format-in-place src/**/*.purs" }, "devDependencies": { + "elm": "^0.19.1", "esbuild": "^0.28.0", "purescript": "^0.15.16", "purescript-language-server": "^0.18.0", diff --git a/spago.lock b/spago.lock @@ -7,8 +7,6 @@ "dependencies": [ "aff", "aff-retry", - "affjax", - "affjax-web", "argonaut", "argonaut-core", "arrays", @@ -21,7 +19,6 @@ "fetch-argonaut", "foreign-object", "formatters", - "halogen", "http-methods", "integers", "js-uri", @@ -36,13 +33,10 @@ "node-streams", "node-url", "now", - "ordered-collections", "prelude", "strings", "tailrec", "unsafe-coerce", - "web-dom", - "web-html", "web-url" ] }, @@ -64,7 +58,7 @@ }, "package_set": { "address": { - "registry": "75.9.0" + "registry": "76.0.0" }, "compiler": ">=0.15.15 <0.16.0", "content": { @@ -549,7 +543,7 @@ "systemd-journald": "0.3.0", "tagged": "4.0.2", "tailrec": "6.1.0", - "tanstack-query": "2.0.1", + "tanstack-query": "3.0.0", "tecton": "0.2.1", "tecton-halogen": "0.2.0", "temporal": "0.1.0", @@ -691,7 +685,7 @@ "yoga-redis": "0.1.0", "yoga-shadcn": "1.0.5", "yoga-sql-types": "0.1.0", - "yoga-sqlite": "0.3.0", + "yoga-sqlite": "0.3.2", "yoga-sse": "0.1.1", "yoga-subtlecrypto": "0.1.0", "yoga-test-docker": "0.1.2", @@ -747,48 +741,6 @@ "transformers" ] }, - "affjax": { - "type": "registry", - "version": "13.0.0", - "integrity": "sha256-dpOV8ELNqUVBwxE6eKFdnFky+sLwPFANrFUzIxQablk=", - "dependencies": [ - "aff", - "argonaut-core", - "arraybuffer-types", - "arrays", - "control", - "datetime", - "either", - "exceptions", - "foldable-traversable", - "foreign", - "form-urlencoded", - "functions", - "http-methods", - "lists", - "maybe", - "media-types", - "newtype", - "nullable", - "prelude", - "transformers", - "web-dom", - "web-file", - "web-xhr" - ] - }, - "affjax-web": { - "type": "registry", - "version": "1.0.0", - "integrity": "sha256-sHg+G50LDoZYG6UHvUiH1ZLT/UHIXaEF5DUBaPXxtxM=", - "dependencies": [ - "aff", - "affjax", - "either", - "maybe", - "prelude" - ] - }, "ansi": { "type": "registry", "version": "7.0.0", @@ -1016,23 +968,6 @@ "type-equality" ] }, - "dom-indexed": { - "type": "registry", - "version": "13.0.0", - "integrity": "sha256-Tr9uDQdmKN4TUGMg1/4KzsVu5zPa8OqKB4N6/S2a8vU=", - "dependencies": [ - "datetime", - "media-types", - "prelude", - "strings", - "web-clipboard", - "web-events", - "web-html", - "web-pointerevents", - "web-touchevents", - "web-uievents" - ] - }, "effect": { "type": "registry", "version": "4.0.0", @@ -1202,20 +1137,6 @@ "transformers" ] }, - "form-urlencoded": { - "type": "registry", - "version": "7.0.0", - "integrity": "sha256-+8F0S6thSLsC1GfQiTdTt6jyTMuhsgKLciBUzde9aFU=", - "dependencies": [ - "foldable-traversable", - "js-uri", - "maybe", - "newtype", - "prelude", - "strings", - "tuples" - ] - }, "formatters": { "type": "registry", "version": "7.0.0", @@ -1263,23 +1184,6 @@ "unsafe-coerce" ] }, - "freeap": { - "type": "registry", - "version": "7.0.0", - "integrity": "sha256-e7SiV9rg+rVARsU9cxA/02sps7ubogvVgYwj8nWosX4=", - "dependencies": [ - "const", - "either", - "gen", - "lists", - "newtype", - "nonempty", - "prelude", - "tailrec", - "tuples", - "unsafe-coerce" - ] - }, "functions": { "type": "registry", "version": "6.0.0", @@ -1325,92 +1229,6 @@ "unfoldable" ] }, - "halogen": { - "type": "registry", - "version": "7.0.0", - "integrity": "sha256-YrUm27QEiE1DyGaGJ5MNLVg4H/P3gnX/NVv1gRtWjB4=", - "dependencies": [ - "aff", - "bifunctors", - "console", - "control", - "dom-indexed", - "effect", - "either", - "exceptions", - "foldable-traversable", - "foreign", - "fork", - "free", - "freeap", - "functions", - "halogen-subscriptions", - "halogen-vdom", - "lazy", - "lists", - "maybe", - "media-types", - "newtype", - "ordered-collections", - "parallel", - "prelude", - "profunctor", - "refs", - "strings", - "tailrec", - "transformers", - "tuples", - "unfoldable", - "unsafe-coerce", - "unsafe-reference", - "web-clipboard", - "web-dom", - "web-events", - "web-file", - "web-html", - "web-touchevents", - "web-uievents" - ] - }, - "halogen-subscriptions": { - "type": "registry", - "version": "2.0.0", - "integrity": "sha256-1eBtVZENgGtKuOY9H0iuYD3dO1CSqmOIyhYe4OhypOU=", - "dependencies": [ - "arrays", - "contravariant", - "control", - "effect", - "foldable-traversable", - "maybe", - "prelude", - "refs", - "safe-coerce", - "unsafe-reference" - ] - }, - "halogen-vdom": { - "type": "registry", - "version": "8.0.0", - "integrity": "sha256-jk6aj/tH630skFVk6mB5Q+0g2zb897KX72T63fJhJ2Y=", - "dependencies": [ - "arrays", - "bifunctors", - "effect", - "foreign", - "foreign-object", - "functions", - "maybe", - "newtype", - "nullable", - "prelude", - "refs", - "tuples", - "unsafe-coerce", - "web-dom", - "web-events" - ] - }, "http-methods": { "type": "registry", "version": "6.0.0", @@ -2289,45 +2107,6 @@ "integrity": "sha256-0L1QsaY20OILjfU5TV72d3U/tSjhmL9hJ32WMp857Lk=", "dependencies": [] }, - "unsafe-reference": { - "type": "registry", - "version": "5.0.0", - "integrity": "sha256-ttSJTQUnK8AK2eGOsfRxLUJEDPL6pnmExjbiOqfwC6I=", - "dependencies": [ - "prelude" - ] - }, - "web-clipboard": { - "type": "registry", - "version": "6.0.0", - "integrity": "sha256-3CV0txwKddLoj8qBrRjUWbiX0M8+rnOpi1iApr5PEb0=", - "dependencies": [ - "effect", - "functions", - "js-promise", - "maybe", - "nullable", - "prelude", - "unsafe-coerce", - "web-events", - "web-html" - ] - }, - "web-dom": { - "type": "registry", - "version": "6.0.0", - "integrity": "sha256-rVeqkxChkjqisDvOujy9BMEgGqJkwX3t6g21bXfiG5o=", - "dependencies": [ - "effect", - "enums", - "maybe", - "newtype", - "nullable", - "prelude", - "unsafe-coerce", - "web-events" - ] - }, "web-events": { "type": "registry", "version": "4.0.0", @@ -2367,56 +2146,6 @@ "web-events" ] }, - "web-html": { - "type": "registry", - "version": "4.1.1", - "integrity": "sha256-yOuZJGUxFrivHKpo/jKuKzFZaq6PPPE64ftSysXFssk=", - "dependencies": [ - "effect", - "enums", - "foreign", - "functions", - "js-date", - "maybe", - "media-types", - "newtype", - "nullable", - "prelude", - "unsafe-coerce", - "web-dom", - "web-events", - "web-file", - "web-storage" - ] - }, - "web-pointerevents": { - "type": "registry", - "version": "2.0.0", - "integrity": "sha256-mQbm1V36u3It2WdYMAXtzLBJl3uRAhN4BjeZTw3LWN8=", - "dependencies": [ - "effect", - "maybe", - "prelude", - "unsafe-coerce", - "web-dom", - "web-events", - "web-html", - "web-uievents" - ] - }, - "web-storage": { - "type": "registry", - "version": "5.0.0", - "integrity": "sha256-YJfos55oeKtBStNfoWfeflSy8j+4IDym5eaZtIY7YM4=", - "dependencies": [ - "effect", - "maybe", - "nullable", - "prelude", - "unsafe-coerce", - "web-events" - ] - }, "web-streams": { "type": "registry", "version": "4.0.0", @@ -2432,35 +2161,6 @@ "tuples" ] }, - "web-touchevents": { - "type": "registry", - "version": "4.0.0", - "integrity": "sha256-H02c53BJlHDlAgogj/x8bM7PagJjhMMrMPYy9I7Rf0s=", - "dependencies": [ - "functions", - "maybe", - "nullable", - "prelude", - "unsafe-coerce", - "web-events", - "web-uievents" - ] - }, - "web-uievents": { - "type": "registry", - "version": "5.0.0", - "integrity": "sha256-yh9uVLIGu8wE1+jwLehtfsRfR1eyiShhIMMhBPqKybE=", - "dependencies": [ - "effect", - "enums", - "maybe", - "nullable", - "prelude", - "unsafe-coerce", - "web-events", - "web-html" - ] - }, "web-url": { "type": "registry", "version": "2.0.0", @@ -2471,30 +2171,6 @@ "prelude", "tuples" ] - }, - "web-xhr": { - "type": "registry", - "version": "5.0.1", - "integrity": "sha256-gxQgeGwvF/NyGdw0FJ9IzXI3gqIMNb8oQO8X+p5jDyw=", - "dependencies": [ - "arraybuffer-types", - "datetime", - "effect", - "either", - "enums", - "foreign", - "http-methods", - "maybe", - "media-types", - "newtype", - "nullable", - "prelude", - "unsafe-coerce", - "web-dom", - "web-events", - "web-file", - "web-html" - ] } } } diff --git a/spago.yaml b/spago.yaml @@ -2,8 +2,7 @@ package: name: corpus dependencies: - aff - - affjax - - affjax-web + - aff-retry - argonaut - argonaut-core - arrays @@ -16,7 +15,6 @@ package: - fetch-argonaut - foreign-object - formatters - - halogen - http-methods - integers - js-uri @@ -31,14 +29,10 @@ package: - node-streams - node-url - now - - ordered-collections - - aff-retry - prelude - strings - tailrec - unsafe-coerce - - web-dom - - web-html - web-url test: main: Test.Main @@ -55,5 +49,5 @@ package: - node-process workspace: packageSet: - registry: 75.9.0 + registry: 76.0.0 extraPackages: {} diff --git a/src/Client.elm b/src/Client.elm @@ -0,0 +1,1007 @@ +port module Client exposing (main) + +import Browser +import Html exposing (..) +import Html.Attributes as Attr exposing (class, disabled, href, placeholder, src, style, target, type_, value) +import Html.Events exposing (onClick, on, onInput) +import Http +import Json.Decode as D exposing (Decoder) +import List +import Maybe +import Set exposing (Set) +import String +import Task +import Time +import Url + + +port pushUrl : String -> Cmd msg + + +main : Program String Model Msg +main = + Browser.element + { init = init + , update = update + , view = view + , subscriptions = subscriptions + } + + +-- TYPES + + +type Tab + = ListensTab + | StatsTab + + +type Period + = AllTime + | LastDays Int + | CustomRange String String + + +type alias ActiveFilter = + { field : String + , value : String + } + + +type alias Listen = + { trackName : Maybe String + , artistName : Maybe String + , releaseName : Maybe String + , listenedAt : Maybe Int + , genre : Maybe String + , releaseMbid : Maybe String + , caaReleaseMbid : Maybe String + } + + +type alias StatsEntry = + { name : String + , count : Int + } + + +type alias Stats = + { genres : List StatsEntry + , labels : List StatsEntry + , years : List StatsEntry + , artists : List StatsEntry + , tracks : List StatsEntry + } + + +-- MODEL + + +type alias Model = + { listens : List Listen + , stats : Maybe Stats + , lastCheck : Maybe Time.Posix + , error : Maybe String + , loading : Bool + , currentTime : Maybe Time.Posix + , failedCovers : Set String + , hoveredCover : Maybe Int + , expandedSections : Set String + , loadedSections : Set String + , offset : Int + , limit : Int + , activeTab : Tab + , activeFilter : Maybe ActiveFilter + , statsPeriod : Period + , customInput : String + , showCustomInput : Bool + , customError : Maybe String + } + + +init : String -> ( Model, Cmd Msg ) +init searchString = + let + page = + parsePageParam searchString + + offset = + max 0 ((page - 1) * 25) + in + ( { listens = [] + , stats = Nothing + , lastCheck = Nothing + , error = Nothing + , loading = True + , currentTime = Nothing + , failedCovers = Set.empty + , hoveredCover = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , offset = offset + , limit = 25 + , activeTab = ListensTab + , activeFilter = Nothing + , statsPeriod = AllTime + , customInput = "" + , showCustomInput = False + , customError = Nothing + } + , Cmd.batch + [ fetchListens 25 offset Nothing + , Task.perform GotTime Time.now + ] + ) + + +parsePageParam : String -> Int +parsePageParam search = + let + s = + if String.startsWith "?" search then + String.dropLeft 1 search + + else + search + in + s + |> String.split "&" + |> List.filterMap + (\pair -> + case String.split "=" pair of + [ "page", v ] -> + String.toInt v + + _ -> + Nothing + ) + |> List.head + |> Maybe.withDefault 1 + + +-- MSG + + +type Msg + = GotListens (Result Http.Error (List Listen)) + | GotStats (Result Http.Error Stats) + | GotSectionData String (Result Http.Error (List StatsEntry)) + | Tick Time.Posix + | GotTime Time.Posix + | ImageError String + | NextPage + | PrevPage + | SwitchTab Tab + | SetStatsPeriod Period + | OpenCustomInput + | UpdateCustomInput String + | ApplyCustomPeriod + | FilterBy String String + | ClearFilter + | HoverCover Int + | UnhoverCover + | ExpandSection String + | ShowAllSection String + | CollapseSection String + + +-- UPDATE + + +update : Msg -> Model -> ( Model, Cmd Msg ) +update msg model = + case msg of + Tick time -> + if not model.loading then + ( { model | currentTime = Just time, loading = True } + , fetchListens model.limit model.offset model.activeFilter + ) + + else + ( { model | currentTime = Just time }, Cmd.none ) + + GotTime time -> + ( { model | lastCheck = Just time, currentTime = Just time }, Cmd.none ) + + GotListens result -> + let + newModel = + case result of + Err err -> + { model | loading = False, error = Just (httpErrorToString err) } + + Ok listens -> + { model | loading = False, listens = listens, error = Nothing } + in + ( newModel, Task.perform GotTime Time.now ) + + GotStats result -> + case result of + Err err -> + ( { model | error = Just (httpErrorToString err) }, Cmd.none ) + + Ok stats -> + ( { model | stats = Just stats }, Cmd.none ) + + GotSectionData section result -> + case result of + Err err -> + ( { model | error = Just (httpErrorToString err) }, Cmd.none ) + + Ok entries -> + ( { model + | expandedSections = Set.insert section model.expandedSections + , loadedSections = Set.insert section model.loadedSections + , stats = Maybe.map (patchStatSection section entries) model.stats + } + , Cmd.none + ) + + ImageError url -> + ( { model | failedCovers = Set.insert url model.failedCovers }, Cmd.none ) + + NextPage -> + let + newOffset = + model.offset + model.limit + + page = + newOffset // model.limit + 1 + in + ( { model | offset = newOffset, loading = True } + , Cmd.batch + [ fetchListens model.limit newOffset model.activeFilter + , pushUrl ("?page=" ++ String.fromInt page) + ] + ) + + PrevPage -> + let + newOffset = + max 0 (model.offset - model.limit) + + page = + newOffset // model.limit + 1 + in + ( { model | offset = newOffset, loading = True } + , Cmd.batch + [ fetchListens model.limit newOffset model.activeFilter + , pushUrl ("?page=" ++ String.fromInt page) + ] + ) + + SwitchTab tab -> + ( { model | activeTab = tab } + , case tab of + StatsTab -> + if model.stats == Nothing then + fetchStats model.statsPeriod + + else + Cmd.none + + ListensTab -> + Cmd.none + ) + + SetStatsPeriod period -> + ( { model + | statsPeriod = period + , stats = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , showCustomInput = False + } + , fetchStats period + ) + + OpenCustomInput -> + let + prefill = + case model.statsPeriod of + CustomRange from to -> + from ++ " " ++ to + + _ -> + model.customInput + in + ( { model | showCustomInput = True, customInput = prefill, customError = Nothing } + , Cmd.none + ) + + UpdateCustomInput str -> + ( { model | customInput = str, customError = Nothing }, Cmd.none ) + + ApplyCustomPeriod -> + let + parts = + model.customInput + |> String.split " " + |> List.filter (not << String.isEmpty) + in + case parts of + [ from, to ] -> + if String.length from /= 10 || String.length to /= 10 then + ( { model | customError = Just "Dates must be in YYYY-MM-DD format" }, Cmd.none ) + + else if from > to then + ( { model | customError = Just "'from' must be before 'to'" }, Cmd.none ) + + else + let + period = + CustomRange from to + in + ( { model + | statsPeriod = period + , stats = Nothing + , expandedSections = Set.empty + , loadedSections = Set.empty + , showCustomInput = False + , customError = Nothing + } + , fetchStats period + ) + + _ -> + ( { model | customError = Just "Enter two dates separated by a space" }, Cmd.none ) + + FilterBy field value -> + let + filter = + Just { field = field, value = value } + in + ( { model | activeFilter = filter, offset = 0, activeTab = ListensTab, loading = True } + , Cmd.batch + [ fetchListens model.limit 0 filter + , pushUrl "?page=1" + ] + ) + + ClearFilter -> + ( { model | activeFilter = Nothing, offset = 0, loading = True } + , Cmd.batch + [ fetchListens model.limit 0 Nothing + , pushUrl "?page=1" + ] + ) + + HoverCover idx -> + ( { model | hoveredCover = Just idx }, Cmd.none ) + + UnhoverCover -> + ( { model | hoveredCover = Nothing }, Cmd.none ) + + ExpandSection section -> + ( { model | expandedSections = Set.insert section model.expandedSections }, Cmd.none ) + + ShowAllSection section -> + ( model, fetchSectionData model.statsPeriod section ) + + CollapseSection section -> + ( { model + | expandedSections = Set.remove section model.expandedSections + , loadedSections = Set.remove section model.loadedSections + } + , Cmd.none + ) + + +patchStatSection : String -> List StatsEntry -> Stats -> Stats +patchStatSection section entries stats = + case section of + "artist" -> + { stats | artists = entries } + + "track" -> + { stats | tracks = entries } + + "genre" -> + { stats | genres = entries } + + "label" -> + { stats | labels = entries } + + "year" -> + { stats | years = entries } + + _ -> + stats + + +-- SUBSCRIPTIONS + + +subscriptions : Model -> Sub Msg +subscriptions _ = + Time.every 30000 Tick + + +-- HTTP + + +fetchListens : Int -> Int -> Maybe ActiveFilter -> Cmd Msg +fetchListens limit offset mFilter = + let + filterParams = + case mFilter of + Nothing -> + "" + + Just { field, value } -> + "&filterField=" ++ field ++ "&filterValue=" ++ Url.percentEncode value + + url = + "/proxy?limit=" ++ String.fromInt limit ++ "&offset=" ++ String.fromInt offset ++ filterParams + in + Http.get + { url = url + , expect = Http.expectJson GotListens listensDecoder + } + + +fetchStats : Period -> Cmd Msg +fetchStats period = + Http.get + { url = statsUrl period Nothing + , expect = Http.expectJson GotStats statsDecoder + } + + +fetchSectionData : Period -> String -> Cmd Msg +fetchSectionData period section = + Http.get + { url = statsUrl period (Just section) + , expect = Http.expectJson (GotSectionData section) (sectionEntriesDecoder section) + } + + +statsUrl : Period -> Maybe String -> String +statsUrl period mSection = + let + periodPart = + case period of + AllTime -> + "" + + LastDays n -> + "period=" ++ String.fromInt n + + CustomRange from to -> + "from=" ++ from ++ "&to=" ++ to + + sectionPart = + case mSection of + Nothing -> + "" + + Just sec -> + (if String.isEmpty periodPart then "" else "&") ++ "section=" ++ sec + + query = + periodPart ++ sectionPart + in + "/stats" ++ (if String.isEmpty query then "" else "?" ++ query) + + +httpErrorToString : Http.Error -> String +httpErrorToString err = + case err of + Http.BadUrl url -> + "Bad URL: " ++ url + + Http.Timeout -> + "Request timed out" + + Http.NetworkError -> + "Network error" + + Http.BadStatus status -> + "Server error: " ++ String.fromInt status + + Http.BadBody body -> + "JSON decode error: " ++ body + + +-- DECODERS + + +listensDecoder : Decoder (List Listen) +listensDecoder = + D.at [ "payload", "listens" ] (D.list listenDecoder) + + +listenDecoder : Decoder Listen +listenDecoder = + D.map2 + (\meta listenedAt -> + { trackName = meta.trackName + , artistName = meta.artistName + , releaseName = meta.releaseName + , listenedAt = listenedAt + , genre = meta.genre + , releaseMbid = meta.releaseMbid + , caaReleaseMbid = meta.caaReleaseMbid + } + ) + (D.field "track_metadata" trackMetaDecoder) + (D.maybe (D.field "listened_at" D.int)) + + +type alias TrackMeta = + { trackName : Maybe String + , artistName : Maybe String + , releaseName : Maybe String + , genre : Maybe String + , releaseMbid : Maybe String + , caaReleaseMbid : Maybe String + } + + +trackMetaDecoder : Decoder TrackMeta +trackMetaDecoder = + D.map6 TrackMeta + (D.maybe (D.field "track_name" D.string)) + (D.maybe (D.field "artist_name" D.string)) + (D.maybe (D.field "release_name" D.string)) + (D.maybe (D.field "genre" D.string)) + (D.maybe (D.at [ "mbid_mapping", "release_mbid" ] D.string)) + (D.maybe (D.at [ "mbid_mapping", "caa_release_mbid" ] D.string)) + + +statsDecoder : Decoder Stats +statsDecoder = + D.map5 Stats + (D.field "genres" (D.list entryDecoder)) + (D.field "labels" (D.list entryDecoder)) + (D.field "years" (D.list entryDecoder)) + (D.field "artists" (D.list entryDecoder)) + (D.field "tracks" (D.list entryDecoder)) + + +entryDecoder : Decoder StatsEntry +entryDecoder = + D.map2 StatsEntry + (D.field "name" D.string) + (D.field "count" D.int) + + +sectionEntriesDecoder : String -> Decoder (List StatsEntry) +sectionEntriesDecoder section = + D.map + (\stats -> + case section of + "artist" -> + stats.artists + + "track" -> + stats.tracks + + "genre" -> + stats.genres + + "label" -> + stats.labels + + "year" -> + stats.years + + _ -> + [] + ) + statsDecoder + + +-- VIEW + + +view : Model -> Html Msg +view model = + div [ class "container" ] + [ h1 [] [ text "scrobbler" ] + , div [ class "tabs" ] + [ a + [ class ("tab-btn" ++ (if model.activeTab == ListensTab then " active" else "")) + , href "/" + ] + [ text "listens" ] + , button + [ class ("tab-btn" ++ (if model.activeTab == StatsTab then " active" else "")) + , onClick (SwitchTab StatsTab) + ] + [ text "stats" ] + ] + , case model.activeTab of + ListensTab -> + div [] + [ case model.activeFilter of + Nothing -> + text "" + + Just { field, value } -> + div [ class "filter-banner" ] + [ span [ class "filter-label" ] + [ text (field ++ ": ") + , strong [] [ text value ] + ] + , button [ class "filter-clear", onClick ClearFilter ] + [ text "✕ clear" ] + ] + , renderContent model + , div [ class "pagination" ] + [ button + [ class "page-btn" + , disabled (model.offset == 0 || model.loading) + , onClick PrevPage + ] + [ text "Previous" ] + , div [ class "page-indicator" ] + [ text ("Page " ++ String.fromInt (model.offset // model.limit + 1)) ] + , button + [ class "page-btn" + , disabled (List.length model.listens < model.limit || model.loading) + , onClick NextPage + ] + [ text "Next" ] + ] + ] + + StatsTab -> + div [] + [ renderPeriodSelector model.statsPeriod model.showCustomInput model.customInput model.customError + , renderStatsView model.expandedSections model.loadedSections model.stats + ] + , div [ Attr.id "footer", class "small" ] + [ span [] [ text (lastCheckText model.currentTime model.lastCheck) ] ] + ] + + +renderContent : Model -> Html Msg +renderContent model = + if model.loading && List.isEmpty model.listens then + ul [] [ li [ class "loading" ] [ text "Loading recent tracks..." ] ] + + else + case model.error of + Just err -> + ul [] [ li [ class "error" ] [ text err ] ] + + Nothing -> + ul [ Attr.id "tracks-container" ] + (List.indexedMap + (\idx listen -> + renderListen model.currentTime model.failedCovers model.hoveredCover idx listen + ) + model.listens + ) + + +renderListen : Maybe Time.Posix -> Set String -> Maybe Int -> Int -> Listen -> Html Msg +renderListen currentTime failedCovers hoveredCover idx listen = + let + artist = + Maybe.withDefault "" listen.artistName + + release = + Maybe.withDefault "" listen.releaseName + + mbid = + case listen.caaReleaseMbid of + Just m -> + Just m + + Nothing -> + listen.releaseMbid + + coverUrl = + "/cover?artist=" + ++ Url.percentEncode artist + ++ "&release=" + ++ Url.percentEncode release + ++ (case mbid of + Just m -> + "&mbid=" ++ m + + Nothing -> + "" + ) + + isZoomed = + hoveredCover == Just idx + in + li [ class "success" ] + [ div [ class "track-info" ] + [ div [ class "track-name" ] + [ text (Maybe.withDefault "Unknown Track" listen.trackName) ] + , div [ class "track-artist" ] [ text artist ] + , div [ class "track-time" ] + [ span [] + [ a + [ href ("https://www.discogs.com/search/?q=" ++ Url.percentEncode (artist ++ " " ++ release) ++ "&type=release") + , target "_blank" + , class "album-link" + ] + [ text release ] + , text (" • " ++ timeAgo currentTime listen.listenedAt) + ] + ] + ] + , div [ class "cover-wrapper" ] + [ if Set.member coverUrl failedCovers then + text "" + + else + img + [ class ("track-cover" ++ (if isZoomed then " zoomed" else "")) + , src coverUrl + , Attr.alt release + , on "error" (D.succeed (ImageError coverUrl)) + , onClick + (if isZoomed then + UnhoverCover + + else + HoverCover idx + ) + ] + [] + , case listen.genre of + Just g -> + div [ class "genre-tag" ] [ text g ] + + Nothing -> + text "" + ] + ] + + +renderStatsView : Set String -> Set String -> Maybe Stats -> Html Msg +renderStatsView expandedSections loadedSections mStats = + case mStats of + Nothing -> + div [ class "loading" ] [ text "Loading stats..." ] + + Just stats -> + div [] + [ renderStatSection expandedSections loadedSections (Just "artist") "top artists" stats.artists + , renderStatSection expandedSections loadedSections Nothing "top tracks" stats.tracks + , renderStatSection expandedSections loadedSections (Just "genre") "genres" stats.genres + , renderStatSection expandedSections loadedSections (Just "label") "labels" stats.labels + , renderStatSection expandedSections loadedSections (Just "year") "years" stats.years + ] + + +renderStatSection : Set String -> Set String -> Maybe String -> String -> List StatsEntry -> Html Msg +renderStatSection expandedSections loadedSections mField title entries = + let + sectionKey = + Maybe.withDefault title mField + + expanded = + Set.member sectionKey expandedSections + + loaded = + Set.member sectionKey loadedSections + + visible = + if loaded then + entries + + else if expanded then + List.take 50 entries + + else + List.take 10 entries + + maxCount = + entries + |> List.map .count + |> List.maximum + |> Maybe.withDefault 1 + + n = + List.length entries + + footer = + if loaded then + if n > 10 then + button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] + + else + text "" + + else if expanded then + if n >= 50 then + span [] + [ button [ class "show-all-btn", onClick (ShowAllSection sectionKey) ] [ text "show all" ] + , text " · " + , button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] + ] + + else + button [ class "show-all-btn", onClick (CollapseSection sectionKey) ] [ text "show less" ] + + else if n > 10 then + button [ class "show-all-btn", onClick (ExpandSection sectionKey) ] [ text "show more" ] + + else + text "" + in + div [ class "stats-section" ] + [ h2 [] [ text title ] + , if List.isEmpty entries then + div [ class "stats-empty" ] [ text "no data yet — enrichment in progress" ] + + else + ul [] (List.map (renderStatEntry maxCount mField) visible) + , footer + ] + + +renderStatEntry : Int -> Maybe String -> StatsEntry -> Html Msg +renderStatEntry maxCount mField entry = + let + barPct = + entry.count * 100 // maxCount + + rowAttrs = + case mField of + Just field -> + [ class "stat-row clickable", onClick (FilterBy field entry.name) ] + + Nothing -> + [ class "stat-row" ] + in + li rowAttrs + [ div [ class "stat-bar", style "width" (String.fromInt barPct ++ "%") ] [] + , span [ class "stat-name" ] [ text entry.name ] + , span [ class "stat-count" ] [ text (String.fromInt entry.count) ] + ] + + +renderPeriodSelector : Period -> Bool -> String -> Maybe String -> Html Msg +renderPeriodSelector current showInput customVal mError = + let + isCustom = + case current of + CustomRange _ _ -> + True + + _ -> + False + + namedBtn target label = + button + [ class ("period-btn" ++ (if current == target then " active" else "")) + , onClick (SetStatsPeriod target) + ] + [ text label ] + + customBtn = + button + [ class ("period-btn" ++ (if isCustom then " active" else "")) + , onClick OpenCustomInput + ] + [ text "custom" ] + + daysBtn n = + let + label = + case n of + 7 -> + "1w" + + 14 -> + "2w" + + 30 -> + "1m" + + 90 -> + "3m" + + 180 -> + "6m" + + 365 -> + "1y" + + _ -> + String.fromInt n ++ "d" + in + button + [ class ("period-btn" ++ (if current == LastDays n then " active" else "")) + , onClick (SetStatsPeriod (LastDays n)) + ] + [ text label ] + in + div [] + [ div [ class "period-selector" ] + ([ namedBtn AllTime "all time", customBtn ] + ++ List.map daysBtn [ 7, 14, 30, 90, 180, 365 ] + ) + , if showInput || isCustom then + div [] + [ div [ class "custom-range" ] + [ input + [ type_ "text" + , class ("custom-range-input" ++ (if mError /= Nothing then " error" else "")) + , placeholder "2023-01-01 2026-01-01" + , value customVal + , onInput UpdateCustomInput + ] + [] + , button [ class "period-btn", onClick ApplyCustomPeriod ] + [ text "apply" ] + ] + , case mError of + Just err -> + div [ class "custom-range-error" ] [ text err ] + + Nothing -> + text "" + ] + + else + text "" + ] + + +-- HELPERS + + +timeAgo : Maybe Time.Posix -> Maybe Int -> String +timeAgo mNow mTimestamp = + case ( mNow, mTimestamp ) of + ( Just now, Just ts ) -> + let + nowSecs = + Time.posixToMillis now // 1000 + + diff = + nowSecs - ts + in + if diff < 60 then + "just now" + + else if diff < 3600 then + let + mins = + diff // 60 + in + String.fromInt mins ++ " minute" ++ (if mins > 1 then "s" else "") ++ " ago" + + else if diff < 86400 then + let + hours = + diff // 3600 + in + String.fromInt hours ++ " hour" ++ (if hours > 1 then "s" else "") ++ " ago" + + else + let + days = + diff // 86400 + in + String.fromInt days ++ " day" ++ (if days > 1 then "s" else "") ++ " ago" + + _ -> + "unknown time" + + +lastCheckText : Maybe Time.Posix -> Maybe Time.Posix -> String +lastCheckText mCurrent mLastCheck = + case ( mCurrent, mLastCheck ) of + ( Just current, Just lastCheck ) -> + let + diff = + (Time.posixToMillis current - Time.posixToMillis lastCheck) // 1000 + in + if diff < 5 then + "just updated" + + else + "last checked " ++ String.fromInt diff ++ "s ago" + + _ -> + "" diff --git a/src/Client.js b/src/Client.js @@ -1,8 +0,0 @@ -export const extractParam = (key) => (search) => { - const params = new URLSearchParams(search); - return params.get(key); -}; - -export const formatRFC3339 = (instant) => { - return new Date(instant).toISOString(); -}; diff --git a/src/Client.purs b/src/Client.purs @@ -1,583 +0,0 @@ -module Client where - -import Prelude - -import Affjax.Web as AX -import Affjax.ResponseFormat as ResponseFormat -import Data.Argonaut (decodeJson) -import Data.Array (mapWithIndex, length, take, filter) -import Data.String (Pattern(..)) -import Data.String as Str -import Data.Either (Either(..)) -import Data.Int (floor, fromString, toNumber) -import Data.Foldable (maximum) -import Data.Maybe (Maybe(..), fromMaybe) -import Effect (Effect) -import Effect.Aff (Aff, delay) -import Effect.Aff.Class (class MonadAff) -import Effect.Class (liftEffect) -import Effect.Now (now) -import Control.Monad.Rec.Class (forever) -import Halogen as H -import Halogen.Aff as HA -import Halogen.HTML as HH -import Halogen.HTML.Events as HE -import Halogen.HTML.Properties as HP -import Halogen.VDom.Driver (runUI) -import JSURI (encodeURIComponent) -import Web.DOM.ParentNode (QuerySelector(..)) -import Types (Listen(..), ListenBrainzResponse(..), TrackMetadata(..), Payload(..), MbidMapping(..), Stats(..), StatsEntry(..)) -import Data.Time.Duration (Milliseconds(..)) -import Data.DateTime.Instant (Instant, unInstant) -import Data.Set (Set) -import Data.Set as Set -import Web.HTML (window) -import Web.HTML.Window (location, history) -import Web.HTML.Location (search) -import Web.HTML.History (pushState, DocumentTitle(..), URL(..)) -import Foreign (unsafeToForeign) -import Data.Nullable (Nullable, toMaybe) - -data Tab = ListensTab | StatsTab - -derive instance eqTab :: Eq Tab - -data Period = AllTime | LastDays Int | CustomRange String String - -derive instance eqPeriod :: Eq Period - -type ActiveFilter = { field :: String, value :: String } - -type State = - { listens :: Array Listen - , stats :: Maybe Stats - , lastCheck :: Maybe String - , error :: Maybe String - , loading :: Boolean - , currentTime :: Maybe Milliseconds - , failedCovers :: Set String - , hoveredCover :: Maybe Int - , expandedSections :: Set String - , loadedSections :: Set String - , offset :: Int - , limit :: Int - , activeTab :: Tab - , activeFilter :: Maybe ActiveFilter - , statsPeriod :: Period - , customInput :: String - , showCustomInput :: Boolean - , customError :: Maybe String - } - -data Action - = Initialize - | Refresh - | ReceiveResponse (Either String (Array Listen)) - | ReceiveStats (Either String Stats) - | ImageError String - | NextPage - | PrevPage - | SwitchTab Tab - | SetStatsPeriod Period - | OpenCustomInput - | UpdateCustomInput String - | ApplyCustomPeriod - | FilterBy String String - | ClearFilter - | HoverCover Int - | UnhoverCover - | ExpandSection String - | ShowAllSection String - | CollapseSection String - | ReceiveSectionData String (Either String (Array StatsEntry)) - -component :: forall query input output m. MonadAff m => H.Component query input output m -component = - H.mkComponent - { initialState - , render - , eval: H.mkEval $ H.defaultEval - { handleAction = handleAction - , initialize = Just Initialize - } - } - where - initialState _ = - { listens: [] - , stats: Nothing - , lastCheck: Nothing - , error: Nothing - , loading: true - , currentTime: Nothing - , failedCovers: Set.empty - , hoveredCover: Nothing - , expandedSections: Set.empty - , loadedSections: Set.empty - , offset: 0 - , limit: 25 - , activeTab: ListensTab - , activeFilter: Nothing - , statsPeriod: AllTime - , customInput: "" - , showCustomInput: false - , customError: Nothing - } - - render state = - HH.div - [ HP.class_ (H.ClassName "container") ] - [ HH.h1_ [ HH.text "scrobbler" ] - , HH.div - [ HP.class_ (H.ClassName "tabs") ] - [ HH.a - [ HP.class_ (H.ClassName $ "tab-btn" <> if state.activeTab == ListensTab then " active" else "") - , HP.href "/" - ] - [ HH.text "listens" ] - , HH.button - [ HP.class_ (H.ClassName $ "tab-btn" <> if state.activeTab == StatsTab then " active" else "") - , HE.onClick \_ -> SwitchTab StatsTab - ] - [ HH.text "stats" ] - ] - , case state.activeTab of - ListensTab -> - HH.div_ - [ case state.activeFilter of - Nothing -> HH.text "" - Just { field, value } -> - HH.div [ HP.class_ (H.ClassName "filter-banner") ] - [ HH.span [ HP.class_ (H.ClassName "filter-label") ] - [ HH.text $ field <> ": " - , HH.strong_ [ HH.text value ] - ] - , HH.button - [ HP.class_ (H.ClassName "filter-clear") - , HE.onClick \_ -> ClearFilter - ] - [ HH.text "✕ clear" ] - ] - , renderContent state - , HH.div - [ HP.class_ (H.ClassName "pagination") ] - [ HH.button - [ HP.class_ (H.ClassName "page-btn") - , HP.disabled (state.offset == 0 || state.loading) - , HE.onClick \_ -> PrevPage - ] - [ HH.text "Previous" ] - , HH.div [ HP.class_ (H.ClassName "page-indicator") ] [ HH.text $ "Page " <> show (state.offset / state.limit + 1) ] - , HH.button - [ HP.class_ (H.ClassName "page-btn") - , HP.disabled (length state.listens < state.limit || state.loading) - , HE.onClick \_ -> NextPage - ] - [ HH.text "Next" ] - ] - ] - StatsTab -> - HH.div_ - [ renderPeriodSelector state.statsPeriod state.showCustomInput state.customInput state.customError - , renderStatsView state.expandedSections state.loadedSections state.stats - ] - , HH.div - [ HP.id "footer" - , HP.class_ (H.ClassName "small") - ] - [ HH.span_ [ HH.text $ fromMaybe "" state.lastCheck ] - ] - ] - - renderStatsView _ _ Nothing = - HH.div [ HP.class_ (H.ClassName "loading") ] [ HH.text "Loading stats..." ] - renderStatsView expandedSections loadedSections (Just (Stats { genres, labels, years, artists, tracks })) = - HH.div_ - [ renderStatSection expandedSections loadedSections (Just "artist") "top artists" artists - , renderStatSection expandedSections loadedSections Nothing "top tracks" tracks - , renderStatSection expandedSections loadedSections (Just "genre") "genres" genres - , renderStatSection expandedSections loadedSections (Just "label") "labels" labels - , renderStatSection expandedSections loadedSections (Just "year") "years" years - ] - - renderStatSection expandedSections loadedSections mField title entries = - let - sectionKey = fromMaybe title mField - maxCount = fromMaybe 1 (maximum (map (\(StatsEntry e) -> e.count) entries)) - expanded = Set.member sectionKey expandedSections - loaded = Set.member sectionKey loadedSections - visible = if loaded then entries else if expanded then take 50 entries else take 10 entries - btn action label = - HH.button - [ HP.class_ (H.ClassName "show-all-btn"), HE.onClick \_ -> action ] - [ HH.text label ] - footer = - if loaded then - if length entries > 10 then btn (CollapseSection sectionKey) "show less" else HH.text "" - else if expanded then - -- we have up to 50; show "show all" only if there might be more - if length entries >= 50 then HH.span_ [ btn (ShowAllSection sectionKey) "show all", HH.text " · ", btn (CollapseSection sectionKey) "show less" ] - else btn (CollapseSection sectionKey) "show less" - else if length entries > 10 then btn (ExpandSection sectionKey) "show more" - else HH.text "" - in - HH.div [ HP.class_ (H.ClassName "stats-section") ] - [ HH.h2_ [ HH.text title ] - , if entries == [] then - HH.div [ HP.class_ (H.ClassName "stats-empty") ] [ HH.text "no data yet — enrichment in progress" ] - else - HH.ul_ (map (renderStatEntry maxCount mField) visible) - , footer - ] - - renderStatEntry maxCount mField (StatsEntry { name, count }) = - let - barPct = floor (toNumber count * 100.0 / toNumber maxCount) - clickProps = case mField of - Just field -> [ HE.onClick \_ -> FilterBy field name ] - Nothing -> [] - in - HH.li - ([ HP.class_ (H.ClassName $ "stat-row" <> if mField /= Nothing then " clickable" else "") ] <> clickProps) - [ HH.div - [ HP.class_ (H.ClassName "stat-bar") - , HP.style $ "width: " <> show barPct <> "%" - ] - [] - , HH.span [ HP.class_ (H.ClassName "stat-name") ] [ HH.text name ] - , HH.span [ HP.class_ (H.ClassName "stat-count") ] [ HH.text $ show count ] - ] - - renderPeriodSelector current showInput customVal mError = - HH.div_ - [ HH.div - [ HP.class_ (H.ClassName "period-selector") ] - ( [ namedBtn AllTime "all time", customBtn ] - <> map daysBtn [ 7, 14, 30, 90, 180, 365 ] - ) - , if showInput || isCustom then - HH.div_ - [ HH.div - [ HP.class_ (H.ClassName "custom-range") ] - [ HH.input - [ HP.type_ HP.InputText - , HP.class_ (H.ClassName $ "custom-range-input" <> if mError /= Nothing then " error" else "") - , HP.placeholder "2023-01-01 2026-01-01" - , HP.value customVal - , HE.onValueInput UpdateCustomInput - ] - , HH.button - [ HP.class_ (H.ClassName "period-btn") - , HE.onClick \_ -> ApplyCustomPeriod - ] - [ HH.text "apply" ] - ] - , case mError of - Just err -> HH.div [ HP.class_ (H.ClassName "custom-range-error") ] [ HH.text err ] - Nothing -> HH.text "" - ] - else HH.text "" - ] - where - isCustom = case current of - CustomRange _ _ -> true - _ -> false - namedBtn target label = - HH.button - [ HP.class_ (H.ClassName $ "period-btn" <> if current == target then " active" else "") - , HE.onClick \_ -> SetStatsPeriod target - ] - [ HH.text label ] - customBtn = - HH.button - [ HP.class_ (H.ClassName $ "period-btn" <> if isCustom then " active" else "") - , HE.onClick \_ -> OpenCustomInput - ] - [ HH.text "custom" ] - daysBtn n = - HH.button - [ HP.class_ (H.ClassName $ "period-btn" <> if current == LastDays n then " active" else "") - , HE.onClick \_ -> SetStatsPeriod (LastDays n) - ] - [ HH.text $ case n of - 7 -> "1w" - 14 -> "2w" - 30 -> "1m" - 90 -> "3m" - 180 -> "6m" - 365 -> "1y" - _ -> show n <> "d" - ] - - renderContent state - | state.loading && state.listens == [] = - HH.ul_ [ HH.li [ HP.class_ (H.ClassName "loading") ] [ HH.text "Loading recent tracks..." ] ] - | Just err <- state.error = - HH.ul_ [ HH.li [ HP.class_ (H.ClassName "error") ] [ HH.text err ] ] - | otherwise = - HH.ul [ HP.id "tracks-container" ] - (mapWithIndex (renderListen state.currentTime state.failedCovers state.hoveredCover) state.listens) - - renderListen currentTime failedCovers hoveredCover idx (Listen { trackMetadata: TrackMetadata track, listenedAt }) = - let - release = fromMaybe "" track.releaseName - artist = fromMaybe "" track.artistName - - mbid = case track.mbidMapping of - Just (MbidMapping { caaReleaseMbid: Just m }) -> Just m - Just (MbidMapping { releaseMbid: Just m }) -> Just m - _ -> Nothing - - coverUrl = "/cover?artist=" <> (fromMaybe "" $ encodeURIComponent artist) - <> "&release=" - <> (fromMaybe "" $ encodeURIComponent release) - <> - ( case mbid of - Just m -> "&mbid=" <> m - Nothing -> "" - ) - in - HH.li - [ HP.class_ (H.ClassName "success") ] - [ HH.div - [ HP.class_ (H.ClassName "track-info") ] - [ HH.div - [ HP.class_ (H.ClassName "track-name") ] - [ HH.text $ fromMaybe "Unknown Track" track.trackName ] - , HH.div - [ HP.class_ (H.ClassName "track-artist") ] - [ HH.text artist ] - , HH.div - [ HP.class_ (H.ClassName "track-time") ] - [ let - query = fromMaybe "" $ encodeURIComponent (artist <> " " <> release) - in - HH.span_ - [ HH.a - [ HP.href $ "https://www.discogs.com/search/?q=" <> query <> "&type=release" - , HP.target "_blank" - , HP.class_ (H.ClassName "album-link") - ] - [ HH.text release ] - , HH.text $ " • " <> (fromMaybe "unknown time" $ formatTimeAgo currentTime listenedAt) - ] - ] - ] - , HH.div [ HP.class_ (H.ClassName "cover-wrapper") ] - [ if Set.member coverUrl failedCovers then HH.text "" - else HH.img - [ HP.class_ (H.ClassName $ "track-cover" <> if hoveredCover == Just idx then " zoomed" else "") - , HP.src coverUrl - , HP.alt release - , HE.onError \_ -> ImageError coverUrl - , HE.onClick \_ -> if hoveredCover == Just idx then UnhoverCover else HoverCover idx - ] - , case track.genre of - Just g -> HH.div [ HP.class_ (H.ClassName "genre-tag") ] [ HH.text g ] - Nothing -> HH.text "" - ] - ] - - handleAction = case _ of - Initialize -> do - w <- liftEffect window - loc <- liftEffect $ location w - qs <- liftEffect $ search loc - let pageParam = toMaybe $ extractParam "page" qs - let initialPage = fromMaybe 1 (pageParam >>= fromString) - let initialOffset = max 0 ((initialPage - 1) * 25) - - H.modify_ _ { offset = initialOffset } - - void $ H.fork $ forever (H.liftAff (delay (Milliseconds 30000.0)) *> handleAction Refresh) - handleAction Refresh - Refresh -> do - state <- H.get - H.modify_ _ { loading = true, error = Nothing } - response <- H.liftAff $ fetchListens state.limit state.offset state.activeFilter - handleAction (ReceiveResponse response) - ReceiveResponse result -> do - nowInstant <- liftEffect now - let nowMs = unInstant nowInstant - let nowStr = formatRFC3339 nowInstant - case result of - Left err -> H.modify_ _ { loading = false, error = Just err, lastCheck = Just nowStr, currentTime = Just nowMs } - Right listens -> H.modify_ _ { loading = false, listens = listens, lastCheck = Just nowStr, currentTime = Just nowMs } - ReceiveStats result -> case result of - Left err -> H.modify_ _ { error = Just err } - Right stats -> H.modify_ _ { stats = Just stats } - FilterBy field value -> do - H.modify_ _ { activeFilter = Just { field, value }, offset = 0, activeTab = ListensTab } - updateUrl - handleAction Refresh - ClearFilter -> do - H.modify_ _ { activeFilter = Nothing, offset = 0 } - updateUrl - handleAction Refresh - SwitchTab tab -> do - H.modify_ _ { activeTab = tab } - case tab of - StatsTab -> do - state <- H.get - when (state.stats == Nothing) do - response <- H.liftAff $ fetchStats state.statsPeriod - handleAction (ReceiveStats response) - ListensTab -> pure unit - SetStatsPeriod period -> do - H.modify_ _ { statsPeriod = period, stats = Nothing, expandedSections = Set.empty, loadedSections = Set.empty, showCustomInput = false } - response <- H.liftAff $ fetchStats period - handleAction (ReceiveStats response) - OpenCustomInput -> do - state <- H.get - let - prefill = case state.statsPeriod of - CustomRange from to -> from <> " " <> to - _ -> state.customInput - H.modify_ _ { showCustomInput = true, customInput = prefill, customError = Nothing } - UpdateCustomInput str -> H.modify_ _ { customInput = str, customError = Nothing } - ApplyCustomPeriod -> do - state <- H.get - let parts = filter (_ /= "") (Str.split (Pattern " ") state.customInput) - case parts of - [ from, to ] - | Str.length from /= 10 || Str.length to /= 10 -> - H.modify_ _ { customError = Just "Dates must be in YYYY-MM-DD format" } - | from > to -> - H.modify_ _ { customError = Just "'from' must be before 'to'" } - | otherwise -> do - let period = CustomRange from to - H.modify_ _ { statsPeriod = period, stats = Nothing, expandedSections = Set.empty, loadedSections = Set.empty, showCustomInput = false, customError = Nothing } - response <- H.liftAff $ fetchStats period - handleAction (ReceiveStats response) - _ -> H.modify_ _ { customError = Just "Enter two dates separated by a space" } - ImageError url -> do - H.modify_ \state -> state { failedCovers = Set.insert url state.failedCovers } - NextPage -> do - H.modify_ \state -> state { offset = state.offset + state.limit } - updateUrl - handleAction Refresh - PrevPage -> do - H.modify_ \state -> state { offset = max 0 (state.offset - state.limit) } - updateUrl - handleAction Refresh - HoverCover url -> do - H.modify_ _ { hoveredCover = Just url } - UnhoverCover -> do - H.modify_ _ { hoveredCover = Nothing } - ExpandSection section -> - H.modify_ \s -> s { expandedSections = Set.insert section s.expandedSections } - ShowAllSection section -> do - state <- H.get - response <- H.liftAff $ fetchSectionData state.statsPeriod section - handleAction (ReceiveSectionData section response) - - CollapseSection section -> - H.modify_ \s -> s - { expandedSections = Set.delete section s.expandedSections - , loadedSections = Set.delete section s.loadedSections - } - ReceiveSectionData section result -> case result of - Left err -> H.modify_ _ { error = Just err } - Right entries -> H.modify_ \s -> s - { expandedSections = Set.insert section s.expandedSections - , loadedSections = Set.insert section s.loadedSections - , stats = map (updateStatSection section entries) s.stats - } - - updateUrl = do - state <- H.get - let page = (state.offset / state.limit) + 1 - liftEffect do - w <- window - h <- history w - pushState (unsafeToForeign {}) (DocumentTitle "") (URL $ "?page=" <> show page) h - - fetchListens :: Int -> Int -> Maybe ActiveFilter -> Aff (Either String (Array Listen)) - fetchListens limit offset mFilter = do - let - filterParams = case mFilter of - Nothing -> "" - Just { field, value } -> "&filterField=" <> field <> "&filterValue=" <> (fromMaybe value (encodeURIComponent value)) - let url = "/proxy?limit=" <> show limit <> "&offset=" <> show offset <> filterParams - res <- AX.get ResponseFormat.json url - case res of - Left err -> pure $ Left $ "Network error: " <> AX.printError err - Right response -> - case decodeJson response.body of - Left err -> pure $ Left $ "JSON decode error: " <> show err - Right (ListenBrainzResponse { payload: Payload { listens } }) -> pure $ Right listens - - updateStatSection section entries (Stats s) = Stats $ case section of - "artist" -> s { artists = entries } - "track" -> s { tracks = entries } - "genre" -> s { genres = entries } - "label" -> s { labels = entries } - "year" -> s { years = entries } - _ -> s - - statsUrl :: Period -> Maybe String -> String - statsUrl period mSection = - "/stats" <> case query of - "" -> "" - q -> "?" <> q - where - periodPart = case period of - AllTime -> "" - LastDays n -> "period=" <> show n - CustomRange from to -> "from=" <> from <> "&to=" <> to - sectionPart = case mSection of - Nothing -> "" - Just sec -> (if periodPart /= "" then "&" else "") <> "section=" <> sec - query = periodPart <> sectionPart - - fetchSectionData :: Period -> String -> Aff (Either String (Array StatsEntry)) - fetchSectionData period section = do - res <- AX.get ResponseFormat.json (statsUrl period (Just section)) - case res of - Left err -> pure $ Left $ "Network error: " <> AX.printError err - Right response -> - case decodeJson response.body of - Left err -> pure $ Left $ "JSON decode error: " <> show err - Right (Stats s) -> pure $ Right $ case section of - "artist" -> s.artists - "track" -> s.tracks - "genre" -> s.genres - "label" -> s.labels - "year" -> s.years - _ -> [] - - fetchStats :: Period -> Aff (Either String Stats) - fetchStats period = do - res <- AX.get ResponseFormat.json (statsUrl period Nothing) - case res of - Left err -> pure $ Left $ "Network error: " <> AX.printError err - Right response -> - case decodeJson response.body of - Left err -> pure $ Left $ "JSON decode error: " <> show err - Right stats -> pure $ Right stats - - formatTimeAgo :: Maybe Milliseconds -> Maybe Int -> Maybe String - formatTimeAgo Nothing _ = Nothing - formatTimeAgo _ Nothing = Nothing - formatTimeAgo (Just (Milliseconds nowMs)) (Just timestamp) = - let - nowSecs = floor (nowMs / 1000.0) - diff = nowSecs - timestamp - in - Just $ - if diff < 60 then "just now" - else if diff < 3600 then - let mins = diff / 60 in show mins <> " minute" <> (if mins > 1 then "s" else "") <> " ago" - else if diff < 86400 then - let hours = diff / 3600 in show hours <> " hour" <> (if hours > 1 then "s" else "") <> " ago" - else - let days = diff / 86400 in show days <> " day" <> (if days > 1 then "s" else "") <> " ago" - -main :: Effect Unit -main = HA.runHalogenAff do - maybeApp <- HA.selectElement (QuerySelector "#app") - case maybeApp of - Nothing -> HA.awaitBody >>= runUI component unit - Just app -> runUI component unit app - -foreign import extractParam :: String -> String -> Nullable String -foreign import formatRFC3339 :: Instant -> String diff --git a/src/Main.purs b/src/Main.purs @@ -760,6 +760,15 @@ indexHtml = <body> <div id="app"></div> <script src="/client.js"></script> + <script> + var app = Elm.Client.init({ + node: document.getElementById('app'), + flags: window.location.search + }); + app.ports.pushUrl.subscribe(function(url) { + history.pushState({}, '', url); + }); + </script> </body> </html>"""