Basic functionality is here with a crappy ai generated frontend.

This commit is contained in:
Jurn Wubben 2026-02-01 17:07:39 +01:00
commit e9fdc05c2d
14 changed files with 1600 additions and 0 deletions

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
dist/* linguist-vendored

144
.gitignore vendored Normal file
View file

@ -0,0 +1,144 @@
# Created by https://www.toptal.com/developers/gitignore/api/node
# Edit at https://www.toptal.com/developers/gitignore?templates=node
### Node ###
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*
### Node Patch ###
# Serverless Webpack directories
.webpack/
# Optional stylelint cache
# SvelteKit build / generate output
.svelte-kit
# End of https://www.toptal.com/developers/gitignore/api/node

14
.helix/languages.toml Normal file
View file

@ -0,0 +1,14 @@
[language-server.deno-language-server]
command = "deno"
args = ["lsp"]
required-root-patterns = ["deno.json", "deno.jsonc"]
[[language]]
name = "typescript"
file-types = ["ts", "tsx"]
language-servers = ["deno-language-server"]
[[language]]
name = "javascript"
file-types = ["js", "jsx"]
language-servers = ["deno-language-server"]

10
deno.json Normal file
View file

@ -0,0 +1,10 @@
{
"tasks": {
"dev": "deno run --watch -A src/main.ts"
},
"imports": {
"@std/assert": "jsr:@std/assert@1",
"@std/http": "jsr:@std/http@^1.0.24",
"typia": "npm:typia@^11.0.3"
}
}

406
deno.lock generated Normal file
View file

@ -0,0 +1,406 @@
{
"version": "5",
"specifiers": {
"jsr:@std/assert@1": "1.0.18",
"jsr:@std/cli@^1.0.27": "1.0.27",
"jsr:@std/encoding@^1.0.10": "1.0.10",
"jsr:@std/fmt@^1.0.9": "1.0.9",
"jsr:@std/fs@^1.0.22": "1.0.22",
"jsr:@std/html@^1.0.5": "1.0.5",
"jsr:@std/http@^1.0.24": "1.0.24",
"jsr:@std/internal@^1.0.12": "1.0.12",
"jsr:@std/media-types@^1.1.0": "1.1.0",
"jsr:@std/net@^1.0.6": "1.0.6",
"jsr:@std/path@^1.1.4": "1.1.4",
"jsr:@std/streams@^1.0.17": "1.0.17",
"npm:typia@^11.0.3": "11.0.3_typescript@5.9.3"
},
"jsr": {
"@std/assert@1.0.18": {
"integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/cli@1.0.27": {
"integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de"
},
"@std/encoding@1.0.10": {
"integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1"
},
"@std/fmt@1.0.9": {
"integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0"
},
"@std/fs@1.0.22": {
"integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308"
},
"@std/html@1.0.5": {
"integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e"
},
"@std/http@1.0.24": {
"integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4",
"dependencies": [
"jsr:@std/cli",
"jsr:@std/encoding",
"jsr:@std/fmt",
"jsr:@std/fs",
"jsr:@std/html",
"jsr:@std/media-types",
"jsr:@std/net",
"jsr:@std/path",
"jsr:@std/streams"
]
},
"@std/internal@1.0.12": {
"integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027"
},
"@std/media-types@1.1.0": {
"integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4"
},
"@std/net@1.0.6": {
"integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c"
},
"@std/path@1.1.4": {
"integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5",
"dependencies": [
"jsr:@std/internal"
]
},
"@std/streams@1.0.17": {
"integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140"
}
},
"npm": {
"@inquirer/external-editor@1.0.3": {
"integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==",
"dependencies": [
"chardet",
"iconv-lite"
]
},
"@samchon/openapi@6.0.1": {
"integrity": "sha512-+nkznmCf/6YavoVkvWg60YoC0UbXY/oK9uMZReyrFcIcXecf+YoWmOLUg+TlgHi+h+6DPgRy6zRkZfiRd3uRnA=="
},
"@standard-schema/spec@1.1.0": {
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="
},
"ansi-escapes@4.3.2": {
"integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==",
"dependencies": [
"type-fest"
]
},
"ansi-regex@5.0.1": {
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="
},
"ansi-styles@4.3.0": {
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"dependencies": [
"color-convert"
]
},
"array-timsort@1.0.3": {
"integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ=="
},
"base64-js@1.5.1": {
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"bl@4.1.0": {
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dependencies": [
"buffer",
"inherits",
"readable-stream"
]
},
"buffer@5.7.1": {
"integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"dependencies": [
"base64-js",
"ieee754"
]
},
"chalk@4.1.2": {
"integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==",
"dependencies": [
"ansi-styles",
"supports-color"
]
},
"chardet@2.1.1": {
"integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ=="
},
"cli-cursor@3.1.0": {
"integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==",
"dependencies": [
"restore-cursor"
]
},
"cli-spinners@2.9.2": {
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg=="
},
"cli-width@3.0.0": {
"integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw=="
},
"clone@1.0.4": {
"integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg=="
},
"color-convert@2.0.1": {
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"dependencies": [
"color-name"
]
},
"color-name@1.1.4": {
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="
},
"commander@10.0.1": {
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="
},
"comment-json@4.5.1": {
"integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==",
"dependencies": [
"array-timsort",
"core-util-is",
"esprima"
]
},
"core-util-is@1.0.3": {
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="
},
"defaults@1.0.4": {
"integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==",
"dependencies": [
"clone"
]
},
"drange@1.1.1": {
"integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA=="
},
"emoji-regex@8.0.0": {
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="
},
"escape-string-regexp@1.0.5": {
"integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg=="
},
"esprima@4.0.1": {
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"bin": true
},
"figures@3.2.0": {
"integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==",
"dependencies": [
"escape-string-regexp"
]
},
"has-flag@4.0.0": {
"integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="
},
"iconv-lite@0.7.2": {
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"dependencies": [
"safer-buffer"
]
},
"ieee754@1.2.1": {
"integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"inherits@2.0.4": {
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"inquirer@8.2.7": {
"integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==",
"dependencies": [
"@inquirer/external-editor",
"ansi-escapes",
"chalk",
"cli-cursor",
"cli-width",
"figures",
"lodash",
"mute-stream",
"ora",
"run-async",
"rxjs",
"string-width",
"strip-ansi",
"through",
"wrap-ansi"
]
},
"is-fullwidth-code-point@3.0.0": {
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="
},
"is-interactive@1.0.0": {
"integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w=="
},
"is-unicode-supported@0.1.0": {
"integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw=="
},
"lodash@4.17.23": {
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
},
"log-symbols@4.1.0": {
"integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==",
"dependencies": [
"chalk",
"is-unicode-supported"
]
},
"mimic-fn@2.1.0": {
"integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg=="
},
"mute-stream@0.0.8": {
"integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA=="
},
"onetime@5.1.2": {
"integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==",
"dependencies": [
"mimic-fn"
]
},
"ora@5.4.1": {
"integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==",
"dependencies": [
"bl",
"chalk",
"cli-cursor",
"cli-spinners",
"is-interactive",
"is-unicode-supported",
"log-symbols",
"strip-ansi",
"wcwidth"
]
},
"package-manager-detector@0.2.11": {
"integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==",
"dependencies": [
"quansync"
]
},
"quansync@0.2.11": {
"integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA=="
},
"randexp@0.5.3": {
"integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==",
"dependencies": [
"drange",
"ret"
]
},
"readable-stream@3.6.2": {
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dependencies": [
"inherits",
"string_decoder",
"util-deprecate"
]
},
"restore-cursor@3.1.0": {
"integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==",
"dependencies": [
"onetime",
"signal-exit"
]
},
"ret@0.2.2": {
"integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ=="
},
"run-async@2.4.1": {
"integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ=="
},
"rxjs@7.8.2": {
"integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==",
"dependencies": [
"tslib"
]
},
"safe-buffer@5.2.1": {
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
},
"safer-buffer@2.1.2": {
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"signal-exit@3.0.7": {
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="
},
"string-width@4.2.3": {
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"dependencies": [
"emoji-regex",
"is-fullwidth-code-point",
"strip-ansi"
]
},
"string_decoder@1.3.0": {
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dependencies": [
"safe-buffer"
]
},
"strip-ansi@6.0.1": {
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"dependencies": [
"ansi-regex"
]
},
"supports-color@7.2.0": {
"integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==",
"dependencies": [
"has-flag"
]
},
"through@2.3.8": {
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
},
"tslib@2.8.1": {
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"type-fest@0.21.3": {
"integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="
},
"typescript@5.9.3": {
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"bin": true
},
"typia@11.0.3_typescript@5.9.3": {
"integrity": "sha512-L7x7WzOCpFyNCauWl6VYJVEG9EHZi5EPNBRzxTO1luaLCd6WEDf+xrJNT+hMZ8U+0X7hCsR1EUpi29LdHhvCvA==",
"dependencies": [
"@samchon/openapi",
"@standard-schema/spec",
"commander",
"comment-json",
"inquirer",
"package-manager-detector",
"randexp",
"typescript"
],
"bin": true
},
"util-deprecate@1.0.2": {
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"wcwidth@1.0.1": {
"integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==",
"dependencies": [
"defaults"
]
},
"wrap-ansi@6.2.0": {
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
"dependencies": [
"ansi-styles",
"string-width",
"strip-ansi"
]
}
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1",
"jsr:@std/http@^1.0.24",
"npm:typia@^11.0.3"
]
}
}

255
extension/spotiqueue.js Normal file
View file

@ -0,0 +1,255 @@
const DEFAULT_WS_URL = "ws://localhost:8000/ws/spotify";
const DEFAULT_RECONNECT_MS = 1000;
class SpotiQueue {
constructor(wsUrl = DEFAULT_WS_URL, reconnectMs = DEFAULT_RECONNECT_MS) {
this.wsUrl = wsUrl;
this.reconnectMs = reconnectMs;
this.socket = null;
this.reconnectInterval = null;
this.closing = false;
this.startedPlaying = false;
this.button = null;
this._onOpen = this._onOpen.bind(this);
this._onMessage = this._onMessage.bind(this);
this._onError = this._onError.bind(this);
this._onClose = this._onClose.bind(this);
}
parseCommand(commandStr) {
try {
const parsed = JSON.parse(commandStr);
if (
typeof parsed === "object" &&
parsed !== null &&
typeof parsed.c === "string" &&
(!("d" in parsed) || typeof parsed.d === "object")
) {
return parsed;
}
return null;
} catch {
return null;
}
}
connect() {
console.log("[SpotiQueue] Trying to connect to server...");
if (
this.socket &&
(this.socket.readyState === WebSocket.OPEN ||
this.socket.readyState === WebSocket.CONNECTING)
) {
return;
}
this.socket = new WebSocket(this.wsUrl);
this.socket.addEventListener("open", this._onOpen);
this.socket.addEventListener("message", this._onMessage);
this.socket.addEventListener("error", this._onError);
this.socket.addEventListener("close", this._onClose);
}
stop() {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
}
if (this.socket) {
this.closing = true;
try {
this.socket.close();
} catch (_e) {
// empty
}
this.socket = null;
}
console.log("[SpotiQueue] Disconnecting from server, Bye!");
}
send(objOrString) {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return;
const payload = (typeof objOrString === "string")
? objOrString
: JSON.stringify(objOrString);
try {
this.socket.send(payload);
} catch (err) {
console.error("[SpotiQueue] Failed to send:", err);
}
}
_onOpen() {
if (this.reconnectInterval) {
clearInterval(this.reconnectInterval);
this.reconnectInterval = null;
}
if (this.button) this.button.style.color = "#1DB954";
console.log("[SpotiQueue] Connected to server!");
}
async _onMessage(event) {
let data;
try {
data = JSON.parse(event.data);
} catch {
console.warn("[SpotiQueue] Received non-JSON message");
return;
}
if (data.c === "ping") {
this.send({ c: "pong" });
return;
}
if (data.c === "next_song" && data.d && data.d.song) {
try {
Spicetify.Player.playUri(data.d.song);
Spicetify.Player.setRepeat(false);
this.startedPlaying = true;
console.log("[SpotiQueue] New song received!");
} catch (err) {
console.error("[SpotiQueue] Error playing received song:", err);
}
}
if (data.c === "search" && data.d && data.d.query && data.d.id) {
const { query, id } = data.d;
let songs = [];
console.log("[SpotiQueue] Searching for", query);
try {
const { searchDesktop } = Spicetify.GraphQL.Definitions;
const { data } = await Spicetify.GraphQL.Request(searchDesktop, {
searchTerm: query,
limit: 10,
offset: 0,
numberOfTopResults: 10,
includeAudiobooks: false,
includePreReleases: false,
IncludeArtistHasConcertsField: false,
includeAuthors: true,
});
console.log(data)
songs = data.searchV2.tracksV2.items.reduce((o, c) => {
const item = c.item.data;
if (!item.playability.playable) return o;
const album = item.albumOfTrack;
o.push({
name: item.name,
uri: item.uri,
artists: item.artists.items.map(v => v.profile?.name),
album: {
name: album.name,
coverUrl: album.coverArt.sources.sort((a, b) =>
b.height - a.height
).map((v) =>
v.url
)[0],
},
});
return o;
}, []);
} catch (e) {
console.log("[SpotiQueue] Search error", e);
// _
}
console.log("[SpotiQueue] Found", songs);
this.send({
"c": "search",
"d": {
"id": id,
"results": songs,
},
});
}
}
_onError() {
try {
if (this.socket) this.socket.close();
} catch {
// ignore
}
}
_onClose() {
console.log(
"[SpotiQueue] closed connection",
this.reconnectInterval,
this.closing,
);
if (!this.reconnectInterval && !this.closing) {
this.reconnectInterval = setInterval(
() => this.connect(),
this.reconnectMs,
);
}
if (this.button) this.button.style.color = "";
this.closing = false;
}
initUi() {
const btn = new Spicetify.Topbar.Button(
"SpotiQueue",
`<p>hi</p>`, // SVG icon or markup
() => {
if (!this.socket) {
if (Spicetify.GraphQL.Definitions.searchDesktop === undefined) {
Spicetify.showNotification("Please search something (e.g a song) before trying to use SpotiQueue. This will load required dependencies.")
return
}
this.connect();
this.button.innerText = "bye";
} else {
this.stop();
this.button.innerText = "hi";
}
},
);
this.button = btn.button;
Spicetify.Player.addEventListener("songchange", (info) => {
console.log(info);
if (
this.socket && this.socket.readyState === WebSocket.OPEN &&
!this.startedPlaying
) {
console.log("[SpotiQueue] Requesting new song...");
this.send({ c: "next_song" });
Spicetify.Player.pause();
}
this.startedPlaying = false;
});
}
}
(function init() {
if (
!Spicetify.Player || !Spicetify.Platform || !Spicetify.GraphQL ||
!Spicetify.GraphQL.Request || !Spicetify.GraphQL.Definitions
) {
setTimeout(init, 100);
console.log("[SpotiQueue] loading extension... ");
return;
}
const client = new SpotiQueue();
client.initUi();
})();

30
src/commandBuilder.ts Normal file
View file

@ -0,0 +1,30 @@
export type BaseCommand = {
c: string,
// deno-lint-ignore no-explicit-any
d: any | undefined
}
export type Command<D> = {
c: string
d: D
}
// deno-lint-ignore no-explicit-any
export function buildCommand(command: string, data?: any): string {
return JSON.stringify({c: command, d: data})
}
export function buildError(error: number, info?: string): string {
return JSON.stringify({e: error, info})
}
export function parseCommand(command: string): BaseCommand | null {
let parsed: BaseCommand = {"c": "parse_error", "d": undefined};
try {
parsed = JSON.parse(command);
if (!("c" in parsed) || !(typeof(parsed.d) === "object" || typeof(parsed.d) === "undefined"))
return null
} catch {
return null;
}
return parsed as BaseCommand
}

53
src/heartbeat.ts Normal file
View file

@ -0,0 +1,53 @@
import { buildError, parseCommand } from "./commandBuilder.ts";
const HEARTBEAT_INTERVAL = 30_000;
const HEARTBEAT_TIMEOUT = 5_000;
const S_PING = JSON.stringify({ "c": "ping" });
export class HeartbeatWS {
heartbeatTimer: number | undefined;
pongTimer: number | undefined;
ws: WebSocket;
constructor(socket: WebSocket) {
this.ws = socket;
socket.addEventListener("open", (_) => this.setHeartBeat());
socket.addEventListener("close", _ => this.clearTimers());
socket.addEventListener("error", _ => this.clearTimers());
socket.addEventListener("message", (msg) => this.onMessage(msg));
}
private setPong(state: boolean) {
if (state) {
clearTimeout(this.pongTimer);
return;
}
this.ws.send(S_PING);
this.pongTimer = setTimeout(() => {
this.ws.send(buildError(1, "Pong missed."));
this.ws.close();
}, HEARTBEAT_TIMEOUT);
}
private setHeartBeat() {
this.setPong(false);
this.heartbeatTimer = setInterval(() => {
this.setPong(false);
}, HEARTBEAT_INTERVAL);
}
private clearTimers() {
clearTimeout(this.pongTimer);
clearTimeout(this.heartbeatTimer);
}
private onMessage(msg: MessageEvent<string>) {
const text = msg.data;
const parsed = parseCommand(text);
if (parsed === null || parsed.c !== "pong") return;
this.setPong(true);
}
}

43
src/main.ts Normal file
View file

@ -0,0 +1,43 @@
import { SpotifyWS } from "./spotify.ts"
import { HeartbeatWS } from "./heartbeat.ts";
import { PlayerManager } from "./playerManager.ts";
import { serveFile } from "@std/http/file-server";
import { UserWS } from "./user.ts";
const USER_PATTERN = new URLPattern({pathname: "/ws/user"})
const SPOTIFY_PATTERN = new URLPattern({pathname: "/ws/spotify"})
const song = "spotify:track:2VxJVGiTsK6UyrxPJJ2lR9";
function commonWs(req: Request) {
const { socket, response } = Deno.upgradeWebSocket(req);
return { socket, response };
}
declare global {
var spotify: SpotifyWS | undefined;
var playerManager: PlayerManager
}
globalThis.spotify = undefined;
globalThis.playerManager = new PlayerManager()
Deno.serve((req) => {
if (USER_PATTERN.exec(req.url)) {
const { socket, response } = commonWs(req);
new HeartbeatWS(socket);
new UserWS(socket)
return response
} else if (SPOTIFY_PATTERN.exec(req.url)) {
const { socket, response } = commonWs(req);
const spState = spotify?.ws.readyState;
if (spState !== undefined && spState !== WebSocket.CLOSED) spotify?.ws.close();
spotify = new SpotifyWS(socket, () => song);
new HeartbeatWS(socket);
return response
}
return serveFile(req, "./static/index.html")
})

94
src/playerManager.ts Normal file
View file

@ -0,0 +1,94 @@
import { Song } from "./spotify.ts";
import { UserWS } from "./user.ts";
export type UserQueue = {
queue: Song[];
userWS: UserWS;
};
export type MergedQueue = { user: string; song: Song }[];
export class PlayerManager {
private userQueue: { [x: string]: UserQueue } = {};
private nextUserIndex = 0;
public mergedQueue: MergedQueue = [];
public login(name: string, userWS: UserWS): UserQueue {
if (name in this.userQueue) {
const user = this.userQueue[name];
user.userWS.disconnect();
user.userWS = userWS;
return user;
}
this.userQueue[name] = {
queue: [],
userWS,
};
return this.userQueue[name];
}
public logout(name: string) {
if (!(name in this.userQueue)) return;
const userQueue = this.userQueue[name];
userQueue.userWS.disconnect();
delete this.userQueue[name];
}
public getNext(): Song | null {
const song = this.mergedQueue.shift();
if (song === undefined) return null;
if (song.user in this.userQueue) {
const user = this.userQueue[song.user];
const users = Object.keys(this.userQueue);
this.nextUserIndex = (this.nextUserIndex + 1) % users.length;
user.queue.shift();
user.userWS.updateQueue();
}
this.broadcastMergedQueue();
return song.song;
}
public updateMergedQueue() {
this.generateMergedQueue();
this.broadcastMergedQueue();
}
private broadcastMergedQueue() {
for (const user of Object.values(this.userQueue)) {
user.userWS.broadcastQueue(this.mergedQueue);
}
}
private generateMergedQueue() {
const users = Object.keys(this.userQueue);
if (!users.length) {
this.mergedQueue = [];
this.nextUserIndex = 0;
return;
}
this.nextUserIndex = this.nextUserIndex % users.length;
const out: MergedQueue = [];
let idx = 0;
while (true) {
let added = 0;
for (let i = 0; i < users.length; i++) {
const u = users[(this.nextUserIndex + i) % users.length];
const list = this.userQueue[u].queue;
if (idx < list.length) {
out.push({ user: u, song: list[idx] });
added++;
}
}
if (!added) break;
idx++;
}
this.mergedQueue = out;
}}

3
src/queue.ts Normal file
View file

@ -0,0 +1,3 @@
export class Queue {
private userCount = 0;
}

82
src/spotify.ts Normal file
View file

@ -0,0 +1,82 @@
import { buildCommand, type Command, type BaseCommand, parseCommand } from "./commandBuilder.ts";
type GetSong = () => string | null;
export type Song = {
name: string;
uri: string;
artists: string[];
album: {
name: string;
coverUrl: string | undefined;
};
};
export class SpotifyWS {
public ws: WebSocket;
private queryList: {
[x: string]: (value: Song[] | PromiseLike<Song[]>) => void;
} = {};
constructor(ws: WebSocket) {
this.ws = ws;
this.onMessage = this.onMessage.bind(this);
this.onClose = this.onClose.bind(this);
this.ws.addEventListener("message", this.onMessage);
this.ws.addEventListener("close", this.onClose);
}
public search(query: string): Promise<Song[]> {
return new Promise<Song[]>((resolve, reject) => {
if (this.ws.readyState != this.ws.OPEN) reject("WS Isn't connected.");
const id = crypto.randomUUID();
this.send(buildCommand("search", { query, id }));
this.queryList[id] = resolve;
setTimeout(() => {
reject("Timeout.");
delete this.queryList[id];
}, 3000);
});
}
public sendSong(uri?: string) {
const song = uri ?? playerManager.getNext()?.uri;
if (!song) return;
this.send(buildCommand("next_song", { song }));
}
private send(data: string) {
if (this.ws.readyState != this.ws.OPEN) return;
this.ws.send(data);
}
private onClose() {
globalThis.spotify = undefined;
}
private onMessage(msg: MessageEvent<string>) {
const text = msg.data;
const parsed = parseCommand(text);
if (parsed === null) return;
switch (parsed.c) {
case "next_song":
this.sendSong();
break;
case "search": {
if (!parsed.d || !parsed.d.id || !parsed.d.results) break;
const cmd = parsed as Command<{id: string, results: Song[]}>
const { id, results } = cmd.d;
if (!(id in this.queryList)) break;
this.queryList[id](results);
break;
}
}
}
}

113
src/user.ts Normal file
View file

@ -0,0 +1,113 @@
import { buildCommand, buildError, Command, parseCommand } from "./commandBuilder.ts";
import { MergedQueue, UserQueue } from "./playerManager.ts";
import { Song } from "./spotify.ts";
export class UserWS {
private ws: WebSocket;
private loggedIn: boolean = false;
private name: string = "";
private playerQueue: UserQueue | undefined;
constructor(ws: WebSocket) {
this.ws = ws;
this.onMessage = this.onMessage.bind(this);
this.onClose = this.onClose.bind(this);
ws.addEventListener("message", this.onMessage);
ws.addEventListener("close", this.onClose);
}
public disconnect() {
this.loggedIn = false;
this.ws.send(buildCommand("logout"))
this.ws.close();
}
public broadcastQueue(queue: MergedQueue) {
this.ws.send(buildCommand("updatemergedqueue", {queue}))
}
public updateQueue() {
const queue = this.playerQueue?.queue;
if (!queue) return;
this.ws.send(buildCommand("getqueue", {queue}))
}
private spotifyConnected(): boolean {
return spotify === undefined;
}
private login(name: string) {
this.name = name;
this.loggedIn = true;
this.playerQueue = playerManager.login(name, this);
}
private async onMessage(msg: MessageEvent<string>) {
const command = parseCommand(msg.data);
if (command === null || (command.c != "login" && !this.loggedIn)) {
console.log("User not logged in but sent", command)
return
};
switch (command.c) {
case "login": {
if (command.d === undefined || typeof(command.d.name) !== "string") {
this.ws.send(buildError(0))
return
};
const cmd = command as Command<{name: string}>;
const name = cmd.d.name.trim();
if (name === "") {
this.ws.send(buildError(0))
return
}
this.login(command.d.name);
break;
};
case "search": {
if (command.d === undefined || typeof(command.d.query) !== "string") return
const cmd = command as Command<{query: string}>;
const songs = await spotify?.search(cmd.d.query)
if (songs === undefined) {
this.ws.send(buildCommand("search", {songs: [], connected: false}))
} else {
this.ws.send(buildCommand("search", {songs, connected: true}))
}
break
}
case "getqueue": {
if (!this.playerQueue) return;
const queue = this.playerQueue.queue;
this.ws.send(buildCommand("getqueue", {queue}))
break
}
case "setqueue": {
if (command.d === undefined || typeof(command.d.queue) !== "object" || !this.playerQueue) return
const cmd = command as Command<{queue: Song[]}>
this.playerQueue.queue = cmd.d.queue;
playerManager.updateMergedQueue();
break
}
case "queuesong": {
if (command.d === undefined || typeof(command.d.song) !== "object" || !this.playerQueue) return
const cmd = command as Command<{song: Song}>
this.playerQueue.queue.push(cmd.d.song);
playerManager.updateMergedQueue();
}
}
}
private onClose() {
this.loggedIn = false;
}
}

352
static/index.html Normal file
View file

@ -0,0 +1,352 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Spotify Queue</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
* {
box-sizing: border-box;
margin: 0;
padding: 0;
font-family: system-ui, sans-serif;
}
body {
background: #121212;
color: #fff;
display: flex;
flex-direction: column;
height: 100vh;
}
header {
background: #1f1f1f;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
header h1 {
font-size: 1.2rem;
}
main {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
footer {
background: #1f1f1f;
padding: 0.75rem 1rem;
display: flex;
gap: 0.5rem;
}
input[type=text],
button {
padding: 0.5rem 0.75rem;
border: none;
border-radius: 4px;
font-size: 1rem;
}
input[type=text] {
flex: 1;
background: #2a2a2a;
color: #fff;
}
button {
background: #1db954;
color: #000;
cursor: pointer;
}
button:disabled {
background: #555;
cursor: not-allowed;
}
.song {
display: flex;
align-items: center;
gap: 0.75rem;
padding: 0.5rem;
border-radius: 6px;
background: #1a1a1a;
margin-bottom: 0.5rem;
cursor: grab;
touch-action: none;
}
.song.dragging {
opacity: 0.5;
}
.song img {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
}
.song div {
flex: 1;
}
.song .title {
font-weight: 600;
}
.song .artist {
font-size: 0.85rem;
opacity: 0.7;
}
.song .user {
font-size: 0.75rem;
opacity: 0.6;
margin-top: 0.2rem;
}
.tabs {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.tabs button {
background: #2a2a2a;
color: #fff;
}
.tabs button.active {
background: #1db954;
color: #000;
}
.hidden {
display: none;
}
.login {
display: flex;
flex-direction: column;
gap: 1rem;
max-width: 300px;
margin: auto;
}
</style>
</head>
<body>
<div id="loginScreen" class="login">
<h2>Enter your name</h2>
<input id="usernameInput" type="text" placeholder="Username" />
<button onclick="login()">Join</button>
</div>
<div id="app" class="hidden">
<header>
<h1>Spotify Queue</h1>
<button onclick="logout()">Logout</button>
</header>
<div class="tabs">
<button id="tabMy" class="active" onclick="switchTab('my')">My Queue</button>
<button id="tabMerged" onclick="switchTab('merged')">Merged Queue</button>
</div>
<main>
<div id="myView">
<div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
<input id="searchInput" type="text" placeholder="Search for songs…" />
<button onclick="search()">Search</button>
</div>
<div id="searchResults"></div>
<h3 style="margin-top:1rem;">My Queue</h3>
<div id="myQueue"></div>
</div>
<div id="mergedView" class="hidden">
<h3>Merged Queue</h3>
<div id="mergedQueue"></div>
</div>
</main>
<!-- <footer> -->
<!-- <input id="searchFooter" type="text" placeholder="Search…" /> -->
<!-- <button onclick="searchFooter()">Search</button> -->
<!-- </footer> -->
</div>
<script>
let ws;
let username = '';
let myQueue = [];
let mergedQueue = [];
let searchResults = [];
let dragged = null;
function connect() {
ws = new WebSocket(`ws://${location.host}/ws/user`);
ws.onopen = () => {
ws.send(JSON.stringify({c: 'login', d: {name: username}}));
ws.send(JSON.stringify({c: 'getqueue'}));
};
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.c === 'ping') {ws.send(JSON.stringify({c: 'pong'})); return;}
if (msg.c === 'search') {
searchResults = msg.d.songs || [];
renderSearch();
}
if (msg.c === 'getqueue') {
myQueue = msg.d.queue || [];
renderMyQueue();
}
if (msg.c === 'updatemergedqueue') {
console.log(msg)
mergedQueue = msg.d.queue || [];
if (document.getElementById('tabMerged').classList.contains('active')) renderMerged();
}
if (msg.c === "logout") {
location.reload();
}
};
ws.onclose = () => setTimeout(connect, 3000);
}
function login() {
const name = document.getElementById('usernameInput').value.trim();
if (!name) return;
username = name;
document.getElementById('loginScreen').style.display = 'none';
document.getElementById('app').classList.remove('hidden');
connect();
}
function logout() {
username = '';
if (ws) ws.close();
document.getElementById('app').classList.add('hidden');
document.getElementById('loginScreen').style.display = 'flex';
}
function switchTab(tab) {
document.getElementById('tabMy').classList.toggle('active', tab === 'my');
document.getElementById('tabMerged').classList.toggle('active', tab === 'merged');
document.getElementById('myView').classList.toggle('hidden', tab !== 'my');
document.getElementById('mergedView').classList.toggle('hidden', tab !== 'merged');
if (tab === 'merged') renderMerged();
}
function search() {
const q = document.getElementById('searchInput').value.trim();
if (!q) return;
ws.send(JSON.stringify({c: 'search', d: {query: q}}));
}
function searchFooter() {
const q = document.getElementById('searchFooter').value.trim();
if (!q) return;
document.getElementById('searchInput').value = q;
search();
switchTab('my');
}
function addSong(song) {
myQueue.push(song);
ws.send(JSON.stringify({c: 'queuesong', d: {song}}));
ws.send(JSON.stringify({c: 'getqueue'}));
}
function removeSong(idx) {
myQueue.splice(idx, 1);
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
renderMyQueue();
}
function renderSearch() {
const el = document.getElementById('searchResults');
el.innerHTML = '';
searchResults.forEach(s => {
const div = document.createElement('div');
div.className = 'song';
div.innerHTML = `
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${s.name}</div>
<div class="artist">${s.artists.join(', ')}</div>
</div>
<button onclick='addSong(${JSON.stringify(s)})'>+</button>
`;
el.appendChild(div);
});
}
function renderMyQueue() {
const el = document.getElementById('myQueue');
el.innerHTML = '';
myQueue.forEach((s, i) => {
const div = document.createElement('div');
div.className = 'song';
div.draggable = true;
div.dataset.idx = i;
div.innerHTML = `
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${s.name}</div>
<div class="artist">${s.artists.join(', ')}</div>
</div>
<button onclick="removeSong(${i})"></button>
`;
div.ondragstart = e => {dragged = +e.target.dataset.idx; e.target.classList.add('dragging');};
div.ondragend = e => e.target.classList.remove('dragging');
div.ondragover = e => e.preventDefault();
div.ondrop = e => {
e.preventDefault();
const to = +e.currentTarget.dataset.idx;
if (dragged === to) return;
const temp = myQueue[dragged];
myQueue.splice(dragged, 1);
myQueue.splice(to, 0, temp);
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
renderMyQueue();
};
el.appendChild(div);
});
}
function renderMerged() {
const el = document.getElementById('mergedQueue');
el.innerHTML = '';
mergedQueue.forEach(item => {
const div = document.createElement('div');
div.className = 'song';
div.innerHTML = `
<img src="${item.song.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
<div>
<div class="title">${item.song.name}</div>
<div class="artist">${item.song.artists.join(', ')}</div>
<div class="user">Added by ${item.user}</div>
</div>
`;
el.appendChild(div);
});
}
function escSong(s) {
return encodeURIComponent(JSON.stringify(s).replace(/'/g, "\\'"));
}
</script>
</body>
</html>