Add prettier

This commit is contained in:
2026-01-05 11:34:53 +01:00
parent c0ca04a349
commit 65488ae59e
18 changed files with 4981 additions and 4662 deletions

13
.prettierrc Normal file
View File

@@ -0,0 +1,13 @@
{
"printWidth":120,
"tabWidth":2,
"useTabs":false,
"semi":true,
"singleQuote":false,
"trailingComma":"all",
"bracketSpacing":true,
"arrowParens":"always",
"requirePragma":false,
"insertPragma":false,
"proseWrap":"always"
}

79
package-lock.json generated
View File

@@ -9,7 +9,8 @@
"version": "0.0.1", "version": "0.0.1",
"devDependencies": { "devDependencies": {
"electron": "^35.7.5", "electron": "^35.7.5",
"electron-builder": "^24.13.3" "electron-builder": "^24.13.3",
"prettier": "^3.7.4"
} }
}, },
"node_modules/@develar/schema-utils": { "node_modules/@develar/schema-utils": {
@@ -675,7 +676,6 @@
"integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.1", "fast-deep-equal": "^3.1.1",
"fast-json-stable-stringify": "^2.0.0", "fast-json-stable-stringify": "^2.0.0",
@@ -830,6 +830,7 @@
"integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==", "integrity": "sha512-+25nxyyznAXF7Nef3y0EbBeqmGZgeN/BxHX29Rs39djAfaFalmQ89SE6CWyDCHzGL0yt/ycBtNOmGTW0FyGWNw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^2.1.0", "archiver-utils": "^2.1.0",
"async": "^3.2.4", "async": "^3.2.4",
@@ -849,6 +850,7 @@
"integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==", "integrity": "sha512-bEL/yUb/fNNiNTuUz979Z0Yg5L+LzLxGJz8x79lYmR54fmTIb6ob/hNQgkQnIUDWIFjZVQwl9Xs356I6BAMHfw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.1.4", "glob": "^7.1.4",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
@@ -871,6 +873,7 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -886,7 +889,8 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/archiver-utils/node_modules/string_decoder": { "node_modules/archiver-utils/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
@@ -894,6 +898,7 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -995,6 +1000,7 @@
"integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer": "^5.5.0", "buffer": "^5.5.0",
"inherits": "^2.0.4", "inherits": "^2.0.4",
@@ -1367,6 +1373,7 @@
"integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==", "integrity": "sha512-D3uMHtGc/fcO1Gt1/L7i1e33VOvD4A9hfQLP+6ewd+BvG/gQ84Yh4oftEhAdjSMgBgwGL+jsppT7JYNpo6MHHg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"buffer-crc32": "^0.2.13", "buffer-crc32": "^0.2.13",
"crc32-stream": "^4.0.2", "crc32-stream": "^4.0.2",
@@ -1466,6 +1473,7 @@
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==", "integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"crc32": "bin/crc32.njs" "crc32": "bin/crc32.njs"
}, },
@@ -1479,6 +1487,7 @@
"integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==", "integrity": "sha512-NT7w2JVU7DFroFdYkeq8cywxrgjPHWkdX1wjpRQXPX5Asews3tA+Ght6lddQO5Mkumffp3X7GEqku3epj2toIw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"crc-32": "^1.2.0", "crc-32": "^1.2.0",
"readable-stream": "^3.4.0" "readable-stream": "^3.4.0"
@@ -1656,7 +1665,6 @@
"integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==", "integrity": "sha512-rcJUkMfnJpfCboZoOOPf4L29TRtEieHNOeAbYPWPxlaBw/Z1RKrRA86dOI9rwaI4tQSc/RD82zTNHprfUHXsoQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"builder-util": "24.13.1", "builder-util": "24.13.1",
@@ -1841,6 +1849,7 @@
"integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==", "integrity": "sha512-oHkV0iogWfyK+ah9ZIvMDpei1m9ZRpdXcvde1wTpra2U8AFDNNpqJdnin5z+PM1GbQ5BoaKCWas2HSjtR0HwMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"app-builder-lib": "24.13.3", "app-builder-lib": "24.13.3",
"archiver": "^5.3.1", "archiver": "^5.3.1",
@@ -1854,6 +1863,7 @@
"integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==", "integrity": "sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1", "jsonfile": "^6.0.1",
@@ -1869,6 +1879,7 @@
"integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==", "integrity": "sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"universalify": "^2.0.0" "universalify": "^2.0.0"
}, },
@@ -1882,6 +1893,7 @@
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">= 10.0.0" "node": ">= 10.0.0"
} }
@@ -2198,7 +2210,8 @@
"resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
"integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/fs-extra": { "node_modules/fs-extra": {
"version": "8.1.0", "version": "8.1.0",
@@ -2694,7 +2707,8 @@
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/isbinaryfile": { "node_modules/isbinaryfile": {
"version": "5.0.7", "version": "5.0.7",
@@ -2831,6 +2845,7 @@
"integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==", "integrity": "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"readable-stream": "^2.0.5" "readable-stream": "^2.0.5"
}, },
@@ -2844,6 +2859,7 @@
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"core-util-is": "~1.0.0", "core-util-is": "~1.0.0",
"inherits": "~2.0.3", "inherits": "~2.0.3",
@@ -2859,7 +2875,8 @@
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lazystream/node_modules/string_decoder": { "node_modules/lazystream/node_modules/string_decoder": {
"version": "1.1.1", "version": "1.1.1",
@@ -2867,6 +2884,7 @@
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.1.0" "safe-buffer": "~5.1.0"
} }
@@ -2883,35 +2901,40 @@
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==", "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.difference": { "node_modules/lodash.difference": {
"version": "4.5.0", "version": "4.5.0",
"resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz", "resolved": "https://registry.npmjs.org/lodash.difference/-/lodash.difference-4.5.0.tgz",
"integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==", "integrity": "sha512-dS2j+W26TQ7taQBGN8Lbbq04ssV3emRw4NY58WErlTO29pIqS0HmoT5aJ9+TUQ1N3G+JOZSji4eugsWwGp9yPA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.flatten": { "node_modules/lodash.flatten": {
"version": "4.4.0", "version": "4.4.0",
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz", "resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
"integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==", "integrity": "sha512-C5N2Z3DgnnKr0LOpv/hKCgKdb7ZZwafIrsesve6lmzvZIRZRGaZ/l6Q8+2W7NaT+ZwO3fFlSCzCzrDCFdJfZ4g==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.isplainobject": { "node_modules/lodash.isplainobject": {
"version": "4.0.6", "version": "4.0.6",
"resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
"integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lodash.union": { "node_modules/lodash.union": {
"version": "4.6.0", "version": "4.6.0",
"resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz", "resolved": "https://registry.npmjs.org/lodash.union/-/lodash.union-4.6.0.tgz",
"integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==", "integrity": "sha512-c4pB2CdGrGdjMKYLA+XiRDO7Y0PRQbm/Gzg8qMj+QH+pFVAoTp5sBpO0odL3FjoPCGjK96p6qsP+yQoiLoOBcw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/lowercase-keys": { "node_modules/lowercase-keys": {
"version": "2.0.0", "version": "2.0.0",
@@ -3100,6 +3123,7 @@
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -3228,12 +3252,29 @@
"node": ">=10.4.0" "node": ">=10.4.0"
} }
}, },
"node_modules/prettier": {
"version": "3.7.4",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.4.tgz",
"integrity": "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/process-nextick-args": { "node_modules/process-nextick-args": {
"version": "2.0.1", "version": "2.0.1",
"resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/progress": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
@@ -3317,6 +3358,7 @@
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"inherits": "^2.0.3", "inherits": "^2.0.3",
"string_decoder": "^1.1.1", "string_decoder": "^1.1.1",
@@ -3332,6 +3374,7 @@
"integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==", "integrity": "sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"minimatch": "^5.1.0" "minimatch": "^5.1.0"
} }
@@ -3414,7 +3457,8 @@
"url": "https://feross.org/support" "url": "https://feross.org/support"
} }
], ],
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/safer-buffer": { "node_modules/safer-buffer": {
"version": "2.1.2", "version": "2.1.2",
@@ -3610,6 +3654,7 @@
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"safe-buffer": "~5.2.0" "safe-buffer": "~5.2.0"
} }
@@ -3722,6 +3767,7 @@
"integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"bl": "^4.0.3", "bl": "^4.0.3",
"end-of-stream": "^1.4.1", "end-of-stream": "^1.4.1",
@@ -3879,7 +3925,8 @@
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"dev": true, "dev": true,
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/verror": { "node_modules/verror": {
"version": "1.10.1", "version": "1.10.1",
@@ -4030,6 +4077,7 @@
"integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==", "integrity": "sha512-9qv4rlDiopXg4E69k+vMHjNN63YFMe9sZMrdlvKnCjlCRWeCBswPPMPUfx+ipsAWq1LXHe70RcbaHdJJpS6hyQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"archiver-utils": "^3.0.4", "archiver-utils": "^3.0.4",
"compress-commons": "^4.1.2", "compress-commons": "^4.1.2",
@@ -4045,6 +4093,7 @@
"integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==", "integrity": "sha512-KVgf4XQVrTjhyWmx6cte4RxonPLR9onExufI1jhvw/MQ4BB6IsZD5gT8Lq+u/+pRkWna/6JoHpiQioaqFP5Rzw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"glob": "^7.2.3", "glob": "^7.2.3",
"graceful-fs": "^4.2.0", "graceful-fs": "^4.2.0",

View File

@@ -6,16 +6,24 @@
"build": { "build": {
"appId": "dev.kruhlmann.imgfloat", "appId": "dev.kruhlmann.imgfloat",
"productName": "Imgfloat", "productName": "Imgfloat",
"files": [ "src/main/node/app.js" ], "files": [
"src/main/node/app.js"
],
"asar": false, "asar": false,
"directories": { "output": "dist" }, "directories": {
"output": "dist"
},
"linux": { "linux": {
"target": ["AppImage"], "target": [
"AppImage"
],
"category": "Utility", "category": "Utility",
"icon": "src/main/resources/assets/icon/raw_icon.png" "icon": "src/main/resources/assets/icon/raw_icon.png"
}, },
"win": { "win": {
"target": ["nsis"], "target": [
"nsis"
],
"icon": "src/main/resources/assets/icon/appicon.ico" "icon": "src/main/resources/assets/icon/appicon.ico"
}, },
"nsis": { "nsis": {
@@ -34,6 +42,7 @@
}, },
"devDependencies": { "devDependencies": {
"electron": "^35.7.5", "electron": "^35.7.5",
"electron-builder": "^24.13.3" "electron-builder": "^24.13.3",
"prettier": "^3.7.4"
} }
} }

View File

@@ -1,36 +1,36 @@
const { app, BrowserWindow } = require('electron'); const { app, BrowserWindow } = require("electron");
const path = require('path'); const path = require("path");
function createWindow() { function createWindow() {
const url = "https://imgfloat.kruhlmann.dev/channels"; const url = "https://imgfloat.kruhlmann.dev/channels";
const initialWindowWidthPx = 960; const initialWindowWidthPx = 960;
const initialWindowHeightPx = 640; const initialWindowHeightPx = 640;
const applicationWindow = new BrowserWindow({ const applicationWindow = new BrowserWindow({
width: initialWindowWidthPx, width: initialWindowWidthPx,
height: initialWindowHeightPx, height: initialWindowHeightPx,
transparent: true, transparent: true,
frame: true, frame: true,
backgroundColor: '#00000000', backgroundColor: "#00000000",
alwaysOnTop: false, alwaysOnTop: false,
icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"), icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"),
webPreferences: { backgroundThrottling: false }, webPreferences: { backgroundThrottling: false },
}); });
applicationWindow.setMenu(null); applicationWindow.setMenu(null);
let canvasSizeInterval; let canvasSizeInterval;
const clearCanvasSizeInterval = () => { const clearCanvasSizeInterval = () => {
if (canvasSizeInterval) { if (canvasSizeInterval) {
clearInterval(canvasSizeInterval); clearInterval(canvasSizeInterval);
canvasSizeInterval = undefined; canvasSizeInterval = undefined;
} }
}; };
const lockWindowToCanvas = async () => { const lockWindowToCanvas = async () => {
if (applicationWindow.isDestroyed()) { if (applicationWindow.isDestroyed()) {
return false; return false;
} }
try { try {
const size = await applicationWindow.webContents.executeJavaScript(`(() => { const size = await applicationWindow.webContents.executeJavaScript(`(() => {
const canvas = document.getElementById('broadcast-canvas'); const canvas = document.getElementById('broadcast-canvas');
if (!canvas || !canvas.width || !canvas.height) { if (!canvas || !canvas.width || !canvas.height) {
return null; return null;
@@ -38,52 +38,52 @@ function createWindow() {
return { width: Math.round(canvas.width), height: Math.round(canvas.height) }; return { width: Math.round(canvas.width), height: Math.round(canvas.height) };
})();`); })();`);
if (size?.width && size?.height) { if (size?.width && size?.height) {
const [currentWidth, currentHeight] = applicationWindow.getSize(); const [currentWidth, currentHeight] = applicationWindow.getSize();
if (currentWidth !== size.width || currentHeight !== size.height) { if (currentWidth !== size.width || currentHeight !== size.height) {
applicationWindow.setSize(size.width, size.height, false); applicationWindow.setSize(size.width, size.height, false);
}
applicationWindow.setMinimumSize(size.width, size.height);
applicationWindow.setMaximumSize(size.width, size.height);
applicationWindow.setResizable(false);
return true;
}
} catch (error) {
// Best-effort sizing; ignore errors from early navigation states.
} }
return false; applicationWindow.setMinimumSize(size.width, size.height);
}; applicationWindow.setMaximumSize(size.width, size.height);
applicationWindow.setResizable(false);
return true;
}
} catch (error) {
// Best-effort sizing; ignore errors from early navigation states.
}
return false;
};
const handleNavigation = (navigationUrl) => { const handleNavigation = (navigationUrl) => {
try { try {
const { pathname } = new URL(navigationUrl); const { pathname } = new URL(navigationUrl);
const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname); const isBroadcast = /\/view\/[^/]+\/broadcast\/?$/.test(pathname);
if (isBroadcast) { if (isBroadcast) {
clearCanvasSizeInterval(); clearCanvasSizeInterval();
canvasSizeInterval = setInterval(lockWindowToCanvas, 750); canvasSizeInterval = setInterval(lockWindowToCanvas, 750);
lockWindowToCanvas(); lockWindowToCanvas();
} else { } else {
clearCanvasSizeInterval(); clearCanvasSizeInterval();
applicationWindow.setResizable(true); applicationWindow.setResizable(true);
applicationWindow.setMinimumSize(320, 240); applicationWindow.setMinimumSize(320, 240);
applicationWindow.setMaximumSize(10000, 10000); applicationWindow.setMaximumSize(10000, 10000);
applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false); applicationWindow.setSize(initialWindowWidthPx, initialWindowHeightPx, false);
} }
} catch { } catch {
// Ignore malformed URLs while navigating. // Ignore malformed URLs while navigating.
} }
}; };
applicationWindow.loadURL(url); applicationWindow.loadURL(url);
applicationWindow.webContents.on('did-finish-load', () => { applicationWindow.webContents.on("did-finish-load", () => {
handleNavigation(applicationWindow.webContents.getURL()); handleNavigation(applicationWindow.webContents.getURL());
}); });
applicationWindow.webContents.on('did-navigate', (_event, navigationUrl) => handleNavigation(navigationUrl)); applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.webContents.on('did-navigate-in-page', (_event, navigationUrl) => handleNavigation(navigationUrl)); applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.on('closed', clearCanvasSizeInterval); applicationWindow.on("closed", clearCanvasSizeInterval);
} }
app.whenReady().then(createWindow); app.whenReady().then(createWindow);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,226 +1,228 @@
function buildIdentity(admin) { function buildIdentity(admin) {
const identity = document.createElement('div'); const identity = document.createElement("div");
identity.className = 'identity-row'; identity.className = "identity-row";
const avatar = document.createElement(admin.avatarUrl ? 'img' : 'div'); const avatar = document.createElement(admin.avatarUrl ? "img" : "div");
avatar.className = 'avatar'; avatar.className = "avatar";
if (admin.avatarUrl) { if (admin.avatarUrl) {
avatar.src = admin.avatarUrl; avatar.src = admin.avatarUrl;
avatar.alt = `${admin.displayName || admin.login} avatar`; avatar.alt = `${admin.displayName || admin.login} avatar`;
} else { } else {
avatar.classList.add('avatar-fallback'); avatar.classList.add("avatar-fallback");
avatar.textContent = (admin.displayName || admin.login || '?').charAt(0).toUpperCase(); avatar.textContent = (admin.displayName || admin.login || "?").charAt(0).toUpperCase();
} }
const details = document.createElement('div'); const details = document.createElement("div");
details.className = 'identity-text'; details.className = "identity-text";
const title = document.createElement('p'); const title = document.createElement("p");
title.className = 'list-title'; title.className = "list-title";
title.textContent = admin.displayName || admin.login; title.textContent = admin.displayName || admin.login;
const subtitle = document.createElement('p'); const subtitle = document.createElement("p");
subtitle.className = 'muted'; subtitle.className = "muted";
subtitle.textContent = `@${admin.login}`; subtitle.textContent = `@${admin.login}`;
details.appendChild(title); details.appendChild(title);
details.appendChild(subtitle); details.appendChild(subtitle);
identity.appendChild(avatar); identity.appendChild(avatar);
identity.appendChild(details); identity.appendChild(details);
return identity; return identity;
} }
function renderAdmins(list) { function renderAdmins(list) {
const adminList = document.getElementById('admin-list'); const adminList = document.getElementById("admin-list");
if (!adminList) return; if (!adminList) return;
adminList.innerHTML = ''; adminList.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement('li'); const empty = document.createElement("li");
empty.textContent = 'No channel admins yet'; empty.textContent = "No channel admins yet";
adminList.appendChild(empty); adminList.appendChild(empty);
return; return;
} }
list.forEach((admin) => { list.forEach((admin) => {
const li = document.createElement('li'); const li = document.createElement("li");
li.className = 'stacked-list-item'; li.className = "stacked-list-item";
li.appendChild(buildIdentity(admin)); li.appendChild(buildIdentity(admin));
const actions = document.createElement('div'); const actions = document.createElement("div");
actions.className = 'actions'; actions.className = "actions";
const removeBtn = document.createElement('button'); const removeBtn = document.createElement("button");
removeBtn.type = 'button'; removeBtn.type = "button";
removeBtn.className = 'secondary'; removeBtn.className = "secondary";
removeBtn.textContent = 'Remove'; removeBtn.textContent = "Remove";
removeBtn.addEventListener('click', () => removeAdmin(admin.login)); removeBtn.addEventListener("click", () => removeAdmin(admin.login));
actions.appendChild(removeBtn); actions.appendChild(removeBtn);
li.appendChild(actions); li.appendChild(actions);
adminList.appendChild(li); adminList.appendChild(li);
}); });
} }
function renderSuggestedAdmins(list) { function renderSuggestedAdmins(list) {
const suggestionList = document.getElementById('admin-suggestions'); const suggestionList = document.getElementById("admin-suggestions");
if (!suggestionList) return; if (!suggestionList) return;
suggestionList.innerHTML = ''; suggestionList.innerHTML = "";
if (!list || list.length === 0) { if (!list || list.length === 0) {
const empty = document.createElement('li'); const empty = document.createElement("li");
empty.className = 'stacked-list-item'; empty.className = "stacked-list-item";
empty.textContent = 'No moderator suggestions right now'; empty.textContent = "No moderator suggestions right now";
suggestionList.appendChild(empty); suggestionList.appendChild(empty);
return; return;
} }
list.forEach((admin) => { list.forEach((admin) => {
const li = document.createElement('li'); const li = document.createElement("li");
li.className = 'stacked-list-item'; li.className = "stacked-list-item";
li.appendChild(buildIdentity(admin)); li.appendChild(buildIdentity(admin));
const actions = document.createElement('div'); const actions = document.createElement("div");
actions.className = 'actions'; actions.className = "actions";
const addBtn = document.createElement('button'); const addBtn = document.createElement("button");
addBtn.type = 'button'; addBtn.type = "button";
addBtn.className = 'ghost'; addBtn.className = "ghost";
addBtn.textContent = 'Add as admin'; addBtn.textContent = "Add as admin";
addBtn.addEventListener('click', () => addAdmin(admin.login)); addBtn.addEventListener("click", () => addAdmin(admin.login));
actions.appendChild(addBtn); actions.appendChild(addBtn);
li.appendChild(actions); li.appendChild(actions);
suggestionList.appendChild(li); suggestionList.appendChild(li);
}); });
} }
function fetchSuggestedAdmins() { function fetchSuggestedAdmins() {
fetch(`/api/channels/${broadcaster}/admins/suggestions`) fetch(`/api/channels/${broadcaster}/admins/suggestions`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
throw new Error('Failed to load admin suggestions'); throw new Error("Failed to load admin suggestions");
} }
return r.json(); return r.json();
}) })
.then(renderSuggestedAdmins) .then(renderSuggestedAdmins)
.catch(() => { .catch(() => {
renderSuggestedAdmins([]); renderSuggestedAdmins([]);
}); });
} }
function fetchAdmins() { function fetchAdmins() {
fetch(`/api/channels/${broadcaster}/admins`) fetch(`/api/channels/${broadcaster}/admins`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
throw new Error('Failed to load admins'); throw new Error("Failed to load admins");
} }
return r.json(); return r.json();
}) })
.then(renderAdmins) .then(renderAdmins)
.catch(() => { .catch(() => {
renderAdmins([]); renderAdmins([]);
showToast('Unable to load admins right now. Please try again.', 'error'); showToast("Unable to load admins right now. Please try again.", "error");
}); });
} }
function removeAdmin(username) { function removeAdmin(username) {
if (!username) return; if (!username) return;
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
method: 'DELETE' method: "DELETE",
}).then((response) => { })
if (!response.ok) { .then((response) => {
throw new Error(); if (!response.ok) {
} throw new Error();
fetchAdmins(); }
fetchSuggestedAdmins(); fetchAdmins();
}).catch(() => { fetchSuggestedAdmins();
showToast('Failed to remove admin. Please retry.', 'error'); })
.catch(() => {
showToast("Failed to remove admin. Please retry.", "error");
}); });
} }
function addAdmin(usernameFromAction) { function addAdmin(usernameFromAction) {
const input = document.getElementById('new-admin'); const input = document.getElementById("new-admin");
const username = (usernameFromAction || input?.value || '').trim(); const username = (usernameFromAction || input?.value || "").trim();
if (!username) { if (!username) {
showToast('Enter a Twitch username to add as an admin.', 'info'); showToast("Enter a Twitch username to add as an admin.", "info");
return; return;
} }
fetch(`/api/channels/${broadcaster}/admins`, { fetch(`/api/channels/${broadcaster}/admins`, {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username }) body: JSON.stringify({ username }),
})
.then((response) => {
if (!response.ok) {
throw new Error("Add admin failed");
}
if (input) {
input.value = "";
}
showToast(`Added @${username} as an admin.`, "success");
fetchAdmins();
fetchSuggestedAdmins();
}) })
.then((response) => { .catch(() => showToast("Unable to add admin right now. Please try again.", "error"));
if (!response.ok) {
throw new Error('Add admin failed');
}
if (input) {
input.value = '';
}
showToast(`Added @${username} as an admin.`, 'success');
fetchAdmins();
fetchSuggestedAdmins();
})
.catch(() => showToast('Unable to add admin right now. Please try again.', 'error'));
} }
function renderCanvasSettings(settings) { function renderCanvasSettings(settings) {
const widthInput = document.getElementById('canvas-width'); const widthInput = document.getElementById("canvas-width");
const heightInput = document.getElementById('canvas-height'); const heightInput = document.getElementById("canvas-height");
if (widthInput) widthInput.value = Math.round(settings.width); if (widthInput) widthInput.value = Math.round(settings.width);
if (heightInput) heightInput.value = Math.round(settings.height); if (heightInput) heightInput.value = Math.round(settings.height);
} }
function fetchCanvasSettings() { function fetchCanvasSettings() {
fetch(`/api/channels/${broadcaster}/canvas`) fetch(`/api/channels/${broadcaster}/canvas`)
.then((r) => { .then((r) => {
if (!r.ok) { if (!r.ok) {
throw new Error('Failed to load canvas settings'); throw new Error("Failed to load canvas settings");
} }
return r.json(); return r.json();
}) })
.then(renderCanvasSettings) .then(renderCanvasSettings)
.catch(() => { .catch(() => {
renderCanvasSettings({ width: 1920, height: 1080 }); renderCanvasSettings({ width: 1920, height: 1080 });
showToast('Using default canvas size. Unable to load saved settings.', 'warning'); showToast("Using default canvas size. Unable to load saved settings.", "warning");
}); });
} }
function saveCanvasSettings() { function saveCanvasSettings() {
const widthInput = document.getElementById('canvas-width'); const widthInput = document.getElementById("canvas-width");
const heightInput = document.getElementById('canvas-height'); const heightInput = document.getElementById("canvas-height");
const status = document.getElementById('canvas-status'); const status = document.getElementById("canvas-status");
const width = parseFloat(widthInput?.value) || 0; const width = parseFloat(widthInput?.value) || 0;
const height = parseFloat(heightInput?.value) || 0; const height = parseFloat(heightInput?.value) || 0;
if (width <= 0 || height <= 0) { if (width <= 0 || height <= 0) {
showToast('Please enter a valid width and height.', 'info'); showToast("Please enter a valid width and height.", "info");
return; return;
} }
if (status) status.textContent = 'Saving...'; if (status) status.textContent = "Saving...";
fetch(`/api/channels/${broadcaster}/canvas`, { fetch(`/api/channels/${broadcaster}/canvas`, {
method: 'PUT', method: "PUT",
headers: { 'Content-Type': 'application/json' }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ width, height }) body: JSON.stringify({ width, height }),
})
.then((r) => {
if (!r.ok) {
throw new Error("Failed to save canvas");
}
return r.json();
}) })
.then((r) => { .then((settings) => {
if (!r.ok) { renderCanvasSettings(settings);
throw new Error('Failed to save canvas'); if (status) status.textContent = "Saved.";
} showToast("Canvas size saved successfully.", "success");
return r.json(); setTimeout(() => {
}) if (status) status.textContent = "";
.then((settings) => { }, 2000);
renderCanvasSettings(settings); })
if (status) status.textContent = 'Saved.'; .catch(() => {
showToast('Canvas size saved successfully.', 'success'); if (status) status.textContent = "Unable to save right now.";
setTimeout(() => { showToast("Unable to save canvas size. Please retry.", "error");
if (status) status.textContent = ''; });
}, 2000);
})
.catch(() => {
if (status) status.textContent = 'Unable to save right now.';
showToast('Unable to save canvas size. Please retry.', 'error');
});
} }
fetchAdmins(); fetchAdmins();

View File

@@ -1,40 +1,40 @@
function detectPlatform() { function detectPlatform() {
const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || '').toLowerCase(); const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase();
const userAgent = (navigator.userAgent || '').toLowerCase(); const userAgent = (navigator.userAgent || "").toLowerCase();
const platformString = `${navigatorPlatform} ${userAgent}`; const platformString = `${navigatorPlatform} ${userAgent}`;
if (platformString.includes('mac') || platformString.includes('darwin')) { if (platformString.includes("mac") || platformString.includes("darwin")) {
return 'mac'; return "mac";
} }
if (platformString.includes('win')) { if (platformString.includes("win")) {
return 'windows'; return "windows";
} }
if (platformString.includes('linux')) { if (platformString.includes("linux")) {
return 'linux'; return "linux";
} }
return null; return null;
} }
function markRecommendedDownload(section) { function markRecommendedDownload(section) {
const cards = Array.from(section.querySelectorAll('.download-card')); const cards = Array.from(section.querySelectorAll(".download-card"));
if (!cards.length) { if (!cards.length) {
return; return;
}
const platform = detectPlatform();
const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0];
cards.forEach((card) => {
const isPreferred = card === preferredCard;
card.classList.toggle("download-card--active", isPreferred);
const badge = card.querySelector(".recommended-badge");
if (badge) {
badge.classList.toggle("hidden", !isPreferred);
} }
});
const platform = detectPlatform();
const preferredCard = cards.find((card) => card.dataset.platform === platform) || cards[0];
cards.forEach((card) => {
const isPreferred = card === preferredCard;
card.classList.toggle('download-card--active', isPreferred);
const badge = card.querySelector('.recommended-badge');
if (badge) {
badge.classList.toggle('hidden', !isPreferred);
}
});
} }
document.addEventListener('DOMContentLoaded', () => { document.addEventListener("DOMContentLoaded", () => {
const downloadSections = document.querySelectorAll('.download-section, .download-card-block'); const downloadSections = document.querySelectorAll(".download-section, .download-card-block");
downloadSections.forEach(markRecommendedDownload); downloadSections.forEach(markRecommendedDownload);
}); });

View File

@@ -1,56 +1,54 @@
document.addEventListener("DOMContentLoaded", () => { document.addEventListener("DOMContentLoaded", () => {
const searchForm = document.getElementById("channel-search-form"); const searchForm = document.getElementById("channel-search-form");
const searchInput = document.getElementById("channel-search"); const searchInput = document.getElementById("channel-search");
const suggestions = document.getElementById("channel-suggestions"); const suggestions = document.getElementById("channel-suggestions");
if (!searchForm || !searchInput || !suggestions) { if (!searchForm || !searchInput || !suggestions) {
console.error("Required elements not found in the DOM"); console.error("Required elements not found in the DOM");
return; return;
} }
let channels = []; let channels = [];
function updateSuggestions(term) { function updateSuggestions(term) {
const normalizedTerm = term.trim().toLowerCase(); const normalizedTerm = term.trim().toLowerCase();
const filtered = channels const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
.filter((name) => !normalizedTerm || name.includes(normalizedTerm))
.slice(0, 20);
suggestions.innerHTML = ""; suggestions.innerHTML = "";
filtered.forEach((name) => { filtered.forEach((name) => {
const option = document.createElement("option"); const option = document.createElement("option");
option.value = name; option.value = name;
suggestions.appendChild(option); suggestions.appendChild(option);
});
}
async function loadChannels() {
try {
const response = await fetch("/api/channels");
if (!response.ok) {
throw new Error(`Failed to load channels: ${response.status}`);
}
channels = await response.json();
updateSuggestions(searchInput.value || "");
} catch (error) {
console.error("Could not load channel directory", error);
}
}
searchInput.focus({ preventScroll: true });
searchInput.select();
searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || ""));
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
const broadcaster = (searchInput.value || "").trim().toLowerCase();
if (!broadcaster) {
searchInput.focus();
return;
}
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
}); });
}
loadChannels(); async function loadChannels() {
try {
const response = await fetch("/api/channels");
if (!response.ok) {
throw new Error(`Failed to load channels: ${response.status}`);
}
channels = await response.json();
updateSuggestions(searchInput.value || "");
} catch (error) {
console.error("Could not load channel directory", error);
}
}
searchInput.focus({ preventScroll: true });
searchInput.select();
searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || ""));
searchForm.addEventListener("submit", (event) => {
event.preventDefault();
const broadcaster = (searchInput.value || "").trim().toLowerCase();
if (!broadcaster) {
searchInput.focus();
return;
}
window.location.href = `/view/${encodeURIComponent(broadcaster)}/broadcast`;
});
loadChannels();
}); });

View File

@@ -19,126 +19,130 @@ const currentSettings = JSON.parse(serverRenderedSettings);
let userSettings = { ...currentSettings }; let userSettings = { ...currentSettings };
function jsonEquals(a, b) { function jsonEquals(a, b) {
if (a === b) return true; if (a === b) return true;
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
return false; return false;
} }
const keysA = Object.keys(a); const keysA = Object.keys(a);
const keysB = Object.keys(b); const keysB = Object.keys(b);
if (keysA.length !== keysB.length) return false; if (keysA.length !== keysB.length) return false;
for (const key of keysA) { for (const key of keysA) {
if (!keysB.includes(key)) return false; if (!keysB.includes(key)) return false;
if (!jsonEquals(a[key], b[key])) return false; if (!jsonEquals(a[key], b[key])) return false;
} }
return true; return true;
} }
function setFormSettings(s) { function setFormSettings(s) {
canvasFpsElement.value = s.canvasFramesPerSecond; canvasFpsElement.value = s.canvasFramesPerSecond;
canvasSizeElement.value = s.maxCanvasSideLengthPixels; canvasSizeElement.value = s.maxCanvasSideLengthPixels;
minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction; minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction;
maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction; maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction;
minPitchElement.value = s.minAssetAudioPitchFraction; minPitchElement.value = s.minAssetAudioPitchFraction;
maxPitchElement.value = s.maxAssetAudioPitchFraction; maxPitchElement.value = s.maxAssetAudioPitchFraction;
minVolumeElement.value = s.minAssetVolumeFraction; minVolumeElement.value = s.minAssetVolumeFraction;
maxVolumeElement.value = s.maxAssetVolumeFraction; maxVolumeElement.value = s.maxAssetVolumeFraction;
} }
function updateStatCards(settings) { function updateStatCards(settings) {
if (!settings) return; if (!settings) return;
statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`; statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`;
statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`; statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`;
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`; statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} ${settings.maxAssetAudioPitchFraction ?? "--"}x`; statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} ${settings.maxAssetVolumeFraction ?? "--"}x`; statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} ${settings.maxAssetVolumeFraction ?? "--"}x`;
} }
function readInt(input) { function readInt(input) {
return input.checkValidity() ? Number(input.value) : null; return input.checkValidity() ? Number(input.value) : null;
} }
function readFloat(input) { function readFloat(input) {
return input.checkValidity() ? Number(input.value) : null; return input.checkValidity() ? Number(input.value) : null;
} }
function loadUserSettingsFromDom() { function loadUserSettingsFromDom() {
userSettings.canvasFramesPerSecond = readInt(canvasFpsElement); userSettings.canvasFramesPerSecond = readInt(canvasFpsElement);
userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement); userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement);
userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement); userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement);
userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement); userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement);
userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement); userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement);
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement); userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement); userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement); userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
} }
function updateSubmitButtonDisabledState() { function updateSubmitButtonDisabledState() {
if (jsonEquals(currentSettings, userSettings)) { if (jsonEquals(currentSettings, userSettings)) {
submitButtonElement.disabled = "disabled"; submitButtonElement.disabled = "disabled";
statusElement.textContent = "No changes yet."; statusElement.textContent = "No changes yet.";
statusElement.classList.remove("status-success", "status-warning"); statusElement.classList.remove("status-success", "status-warning");
return; return;
} }
if (!formElement.checkValidity()) { if (!formElement.checkValidity()) {
submitButtonElement.disabled = "disabled"; submitButtonElement.disabled = "disabled";
statusElement.textContent = "Fix highlighted fields."; statusElement.textContent = "Fix highlighted fields.";
statusElement.classList.add("status-warning"); statusElement.classList.add("status-warning");
statusElement.classList.remove("status-success"); statusElement.classList.remove("status-success");
return; return;
} }
submitButtonElement.disabled = null; submitButtonElement.disabled = null;
statusElement.textContent = "Ready to save."; statusElement.textContent = "Ready to save.";
statusElement.classList.remove("status-warning"); statusElement.classList.remove("status-warning");
} }
function submitSettingsForm() { function submitSettingsForm() {
if (submitButtonElement.getAttribute("disabled") != null) { if (submitButtonElement.getAttribute("disabled") != null) {
console.warn("Attempted to submit invalid form"); console.warn("Attempted to submit invalid form");
showToast("Settings not valid", "warning"); showToast("Settings not valid", "warning");
return; return;
} }
statusElement.textContent = "Saving…"; statusElement.textContent = "Saving…";
statusElement.classList.remove("status-success", "status-warning"); statusElement.classList.remove("status-success", "status-warning");
fetch("/api/settings/set", { method: "PUT", headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(userSettings) }).then((r) => { fetch("/api/settings/set", {
if (!r.ok) { method: "PUT",
throw new Error('Failed to load canvas'); headers: { "Content-Type": "application/json" },
} body: JSON.stringify(userSettings),
return r.json(); })
.then((r) => {
if (!r.ok) {
throw new Error("Failed to load canvas");
}
return r.json();
}) })
.then((newSettings) => { .then((newSettings) => {
currentSettings = { ...newSettings }; currentSettings = { ...newSettings };
userSettings = { ...newSettings }; userSettings = { ...newSettings };
updateStatCards(newSettings); updateStatCards(newSettings);
showToast("Settings saved", "success"); showToast("Settings saved", "success");
statusElement.textContent = "Saved."; statusElement.textContent = "Saved.";
statusElement.classList.add("status-success"); statusElement.classList.add("status-success");
updateSubmitButtonDisabledState(); updateSubmitButtonDisabledState();
}) })
.catch((error) => { .catch((error) => {
showToast('Unable to save settings', 'error') showToast("Unable to save settings", "error");
console.error(error); console.error(error);
statusElement.textContent = "Save failed. Try again."; statusElement.textContent = "Save failed. Try again.";
statusElement.classList.add("status-warning"); statusElement.classList.add("status-warning");
}); });
} }
formElement.querySelectorAll("input").forEach((input) => { formElement.querySelectorAll("input").forEach((input) => {
input.addEventListener("input", () => { input.addEventListener("input", () => {
loadUserSettingsFromDom(); loadUserSettingsFromDom();
updateSubmitButtonDisabledState(); updateSubmitButtonDisabledState();
}); });
}); });
formElement.addEventListener("submit", (event) => { formElement.addEventListener("submit", (event) => {
event.preventDefault(); event.preventDefault();
submitSettingsForm(); submitSettingsForm();
}); });
setFormSettings(currentSettings); setFormSettings(currentSettings);

View File

@@ -1,51 +1,51 @@
(function () { (function () {
const CONTAINER_ID = 'toast-container'; const CONTAINER_ID = "toast-container";
const DEFAULT_DURATION = 4200; const DEFAULT_DURATION = 4200;
function ensureContainer() { function ensureContainer() {
let container = document.getElementById(CONTAINER_ID); let container = document.getElementById(CONTAINER_ID);
if (!container) { if (!container) {
container = document.createElement('div'); container = document.createElement("div");
container.id = CONTAINER_ID; container.id = CONTAINER_ID;
container.className = 'toast-container'; container.className = "toast-container";
container.setAttribute('aria-live', 'polite'); container.setAttribute("aria-live", "polite");
container.setAttribute('aria-atomic', 'true'); container.setAttribute("aria-atomic", "true");
document.body.appendChild(container); document.body.appendChild(container);
}
return container;
} }
return container;
}
function buildToast(message, type) { function buildToast(message, type) {
const toast = document.createElement('div'); const toast = document.createElement("div");
toast.className = `toast toast-${type}`; toast.className = `toast toast-${type}`;
const indicator = document.createElement('span'); const indicator = document.createElement("span");
indicator.className = 'toast-indicator'; indicator.className = "toast-indicator";
indicator.setAttribute('aria-hidden', 'true'); indicator.setAttribute("aria-hidden", "true");
const content = document.createElement('div'); const content = document.createElement("div");
content.className = 'toast-message'; content.className = "toast-message";
content.textContent = message; content.textContent = message;
toast.appendChild(indicator); toast.appendChild(indicator);
toast.appendChild(content); toast.appendChild(content);
return toast; return toast;
} }
function removeToast(toast) { function removeToast(toast) {
if (!toast) return; if (!toast) return;
toast.classList.add('toast-exit'); toast.classList.add("toast-exit");
setTimeout(() => toast.remove(), 250); setTimeout(() => toast.remove(), 250);
} }
window.showToast = function showToast(message, type = 'info', options = {}) { window.showToast = function showToast(message, type = "info", options = {}) {
if (!message) return; if (!message) return;
const normalized = ['success', 'error', 'warning', 'info'].includes(type) ? type : 'info'; const normalized = ["success", "error", "warning", "info"].includes(type) ? type : "info";
const duration = typeof options.duration === 'number' ? options.duration : DEFAULT_DURATION; const duration = typeof options.duration === "number" ? options.duration : DEFAULT_DURATION;
const container = ensureContainer(); const container = ensureContainer();
const toast = buildToast(message, normalized); const toast = buildToast(message, normalized);
container.appendChild(toast); container.appendChild(toast);
setTimeout(() => removeToast(toast), Math.max(1200, duration)); setTimeout(() => removeToast(toast), Math.max(1200, duration));
toast.addEventListener('click', () => removeToast(toast)); toast.addEventListener("click", () => removeToast(toast));
}; };
})(); })();

View File

@@ -1,216 +1,306 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Imgfloat Admin</title> <title>Imgfloat Admin</title>
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
/>
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head> </head>
<body class="admin-body"> <body class="admin-body">
<div class="admin-frame"> <div class="admin-frame">
<header class="admin-topbar"> <header class="admin-topbar">
<div class="topbar-left"> <div class="topbar-left">
<div class="admin-identity"> <div class="admin-identity">
<p class="eyebrow subtle">CHANNEL ADMIN</p> <p class="eyebrow subtle">CHANNEL ADMIN</p>
<h1 th:text="${broadcaster}"></h1> <h1 th:text="${broadcaster}"></h1>
</div> </div>
</div> </div>
<div class="header-actions horizontal"> <div class="header-actions horizontal">
<a class="icon-button" th:href="@{/}" title="Back to dashboard"> <a class="icon-button" th:href="@{/}" title="Back to dashboard">
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i> <i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span class="sr-only">Back to dashboard</span> <span class="sr-only">Back to dashboard</span>
</a> </a>
<a class="button ghost" th:href="${'/view/' + broadcaster + '/broadcast'}" target="_blank" rel="noopener">Broadcaster view</a> <a class="button ghost" th:href="${'/view/' + broadcaster + '/broadcast'}" target="_blank" rel="noopener"
>Broadcaster view</a
>
</div> </div>
</header> </header>
<div class="admin-workspace"> <div class="admin-workspace">
<aside class="admin-rail"> <aside class="admin-rail">
<div class="upload-row"> <div class="upload-row">
<input id="asset-file" class="file-input-field" type="file" accept="image/*,video/*,audio/*" onchange="handleFileSelection(this)" /> <input
<label for="asset-file" class="file-input-trigger"> id="asset-file"
<span class="file-input-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span> class="file-input-field"
<span class="file-input-copy"> type="file"
<strong>Upload asset</strong> accept="image/*,video/*,audio/*"
<small id="asset-file-name">No file chosen</small> onchange="handleFileSelection(this)"
</span> />
</label> <label for="asset-file" class="file-input-trigger">
<span class="file-input-icon"><i class="fa-solid fa-cloud-arrow-up"></i></span>
<span class="file-input-copy">
<strong>Upload asset</strong>
<small id="asset-file-name">No file chosen</small>
</span>
</label>
</div>
<div class="rail-body">
<div class="rail-scroll">
<ul id="asset-list" class="asset-list"></ul>
</div> </div>
<div class="rail-body"> </div>
<div class="rail-scroll">
<ul id="asset-list" class="asset-list"></ul> <div id="asset-inspector" class="rail-inspector hidden">
<div class="asset-inspector">
<div class="selected-asset-banner">
<div class="selected-asset-main">
<div class="title-row">
<strong id="selected-asset-name">Choose an asset</strong>
<span id="selected-asset-resolution" class="asset-resolution subtle-text hidden"></span>
</div>
<p class="meta-text" id="selected-asset-meta">
Pick an asset in the list to adjust its placement and playback.
</p>
<p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
<div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
</div> </div>
</div> </div>
<div id="asset-controls-placeholder" class="asset-controls-placeholder">
<div id="asset-inspector" class="rail-inspector hidden"> <div id="asset-controls" class="hidden asset-settings">
<div class="asset-inspector"> <div class="panel-section" id="layout-section">
<div class="selected-asset-banner"> <div class="section-header">
<div class="selected-asset-main"> <h5>Layout & order</h5>
<div class="title-row">
<strong id="selected-asset-name">Choose an asset</strong>
<span id="selected-asset-resolution" class="asset-resolution subtle-text hidden"></span>
</div>
<p class="meta-text" id="selected-asset-meta">Pick an asset in the list to adjust its placement and playback.</p>
<p class="meta-text subtle-text hidden" id="selected-asset-id"></p>
<div class="badge-row asset-meta-badges" id="selected-asset-badges"></div>
</div>
</div> </div>
<div id="asset-controls-placeholder" class="asset-controls-placeholder"> <div class="property-list">
<div id="asset-controls" class="hidden asset-settings"> <div class="property-row">
<div class="panel-section" id="layout-section"> <span class="property-label">Width</span>
<div class="section-header"> <input id="asset-width" class="number-input property-control" type="number" min="10" step="5" />
<h5>Layout & order</h5> </div>
</div> <div class="property-row">
<div class="property-list"> <span class="property-label">Height</span>
<div class="property-row"> <input
<span class="property-label">Width</span> id="asset-height"
<input id="asset-width" class="number-input property-control" type="number" min="10" step="5" /> class="number-input property-control"
</div> type="number"
<div class="property-row"> min="10"
<span class="property-label">Height</span> step="5"
<input id="asset-height" class="number-input property-control" type="number" min="10" step="5" /> />
</div> </div>
<div class="property-row"> <div class="property-row">
<span class="property-label">Maintain AR</span> <span class="property-label">Maintain AR</span>
<label class="checkbox-inline toggle inline-toggle property-control"> <label class="checkbox-inline toggle inline-toggle property-control">
<input id="maintain-aspect" type="checkbox" checked /> <input id="maintain-aspect" type="checkbox" checked />
<span class="toggle-track" aria-hidden="true"> <span class="toggle-track" aria-hidden="true">
<span class="toggle-thumb"></span> <span class="toggle-thumb"></span>
</span> </span>
</label> </label>
</div> </div>
<div class="property-row"> <div class="property-row">
<span class="property-label">Layer</span> <span class="property-label">Layer</span>
<div class="property-control"> <div class="property-control">
<div class="badge-row stacked"> <div class="badge-row stacked">
<span class="badge">Layer <strong id="asset-z-level">1</strong></span> <span class="badge">Layer <strong id="asset-z-level">1</strong></span>
</div> </div>
</div>
</div>
</div>
</div>
<div class="panel-section" id="playback-section">
<div class="section-header">
<h5>Playback</h5>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback speed</span>
<span class="value-hint" id="asset-speed-label">100%</span>
</div>
<input id="asset-speed" class="range-input" type="range" min="0" max="1000" step="10" value="100" />
<div class="range-meta"><span>0%</span><span>1000%</span></div>
</div>
</div>
<div class="panel-section" id="volume-section">
<div class="section-header">
<h5>Volume</h5>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback volume</span>
<span class="value-hint" id="asset-volume-label">100%</span>
</div>
<input id="asset-volume" class="range-input" type="range" min="0" max="200" step="1" value="100" />
<div class="range-meta"><span>0%</span><span>200%</span></div>
</div>
</div>
<div class="panel-section hidden" id="audio-section">
<div class="section-header">
<h5>Audio</h5>
</div>
<div class="property-list">
<div class="property-row">
<span class="property-label">Loop</span>
<label class="checkbox-inline toggle inline-toggle property-control">
<input id="asset-audio-loop" type="checkbox" />
<span class="toggle-track" aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Delay</span>
<span class="value-hint" id="asset-audio-delay-label">0ms</span>
</div>
<input id="asset-audio-delay" class="range-input property-control" type="range" min="0" max="30000" step="100" value="0" />
<div class="range-meta"><span>0ms</span><span>30s</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback speed</span>
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
</div>
<input id="asset-audio-speed" class="range-input" type="range" min="25" max="400" step="5" value="100" />
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Pitch</span>
<span class="value-hint" id="asset-audio-pitch-label">100%</span>
</div>
<input id="asset-audio-pitch" class="range-input property-control" type="range" min="50" max="200" step="5" value="100" />
<div class="range-meta"><span>50%</span><span>200%</span></div>
</div>
</div>
<div class="control-actions compact unified-actions" id="asset-actions">
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back"><i class="fa-solid fa-angles-down"></i></button>
<button type="button" onclick="bringBackward()" class="secondary" title="Move backward"><i class="fa-solid fa-arrow-down"></i></button>
<button type="button" onclick="bringForward()" class="secondary" title="Move forward"><i class="fa-solid fa-arrow-up"></i></button>
<button type="button" onclick="bringToFront()" class="secondary" title="Bring to front"><i class="fa-solid fa-angles-up"></i></button>
<button type="button" onclick="recenterSelectedAsset()" class="secondary" title="Center on canvas"><i class="fa-solid fa-bullseye"></i></button>
<button type="button" onclick="nudgeRotation(-5)" class="secondary" title="Rotate left"><i class="fa-solid fa-rotate-left"></i></button>
<button type="button" onclick="nudgeRotation(5)" class="secondary" title="Rotate right"><i class="fa-solid fa-rotate-right"></i></button>
<button id="selected-asset-visibility" class="secondary" type="button" title="Hide asset" disabled data-audio-enabled="true">
<i class="fa-solid fa-eye-slash"></i>
</button>
<button id="selected-asset-delete" class="secondary danger" type="button" title="Delete asset" disabled data-audio-enabled="true">
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div> </div>
</div>
</div> </div>
</div>
<div class="panel-section" id="playback-section">
<div class="section-header">
<h5>Playback</h5>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback speed</span>
<span class="value-hint" id="asset-speed-label">100%</span>
</div>
<input
id="asset-speed"
class="range-input"
type="range"
min="0"
max="1000"
step="10"
value="100"
/>
<div class="range-meta"><span>0%</span><span>1000%</span></div>
</div>
</div>
<div class="panel-section" id="volume-section">
<div class="section-header">
<h5>Volume</h5>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback volume</span>
<span class="value-hint" id="asset-volume-label">100%</span>
</div>
<input
id="asset-volume"
class="range-input"
type="range"
min="0"
max="200"
step="1"
value="100"
/>
<div class="range-meta"><span>0%</span><span>200%</span></div>
</div>
</div>
<div class="panel-section hidden" id="audio-section">
<div class="section-header">
<h5>Audio</h5>
</div>
<div class="property-list">
<div class="property-row">
<span class="property-label">Loop</span>
<label class="checkbox-inline toggle inline-toggle property-control">
<input id="asset-audio-loop" type="checkbox" />
<span class="toggle-track" aria-hidden="true">
<span class="toggle-thumb"></span>
</span>
</label>
</div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Delay</span>
<span class="value-hint" id="asset-audio-delay-label">0ms</span>
</div>
<input
id="asset-audio-delay"
class="range-input property-control"
type="range"
min="0"
max="30000"
step="100"
value="0"
/>
<div class="range-meta"><span>0ms</span><span>30s</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Playback speed</span>
<span class="value-hint" id="asset-audio-speed-label">1.0x</span>
</div>
<input
id="asset-audio-speed"
class="range-input"
type="range"
min="25"
max="400"
step="5"
value="100"
/>
<div class="range-meta"><span>0.25x</span><span>4x</span></div>
</div>
<div class="stacked-field">
<div class="label-row">
<span>Pitch</span>
<span class="value-hint" id="asset-audio-pitch-label">100%</span>
</div>
<input
id="asset-audio-pitch"
class="range-input property-control"
type="range"
min="50"
max="200"
step="5"
value="100"
/>
<div class="range-meta"><span>50%</span><span>200%</span></div>
</div>
</div>
<div class="control-actions compact unified-actions" id="asset-actions">
<button type="button" onclick="sendToBack()" class="secondary" title="Send to back">
<i class="fa-solid fa-angles-down"></i>
</button>
<button type="button" onclick="bringBackward()" class="secondary" title="Move backward">
<i class="fa-solid fa-arrow-down"></i>
</button>
<button type="button" onclick="bringForward()" class="secondary" title="Move forward">
<i class="fa-solid fa-arrow-up"></i>
</button>
<button type="button" onclick="bringToFront()" class="secondary" title="Bring to front">
<i class="fa-solid fa-angles-up"></i>
</button>
<button type="button" onclick="recenterSelectedAsset()" class="secondary" title="Center on canvas">
<i class="fa-solid fa-bullseye"></i>
</button>
<button type="button" onclick="nudgeRotation(-5)" class="secondary" title="Rotate left">
<i class="fa-solid fa-rotate-left"></i>
</button>
<button type="button" onclick="nudgeRotation(5)" class="secondary" title="Rotate right">
<i class="fa-solid fa-rotate-right"></i>
</button>
<button
id="selected-asset-visibility"
class="secondary"
type="button"
title="Hide asset"
disabled
data-audio-enabled="true"
>
<i class="fa-solid fa-eye-slash"></i>
</button>
<button
id="selected-asset-delete"
class="secondary danger"
type="button"
title="Delete asset"
disabled
data-audio-enabled="true"
>
<i class="fa-solid fa-trash"></i>
</button>
</div>
</div> </div>
</div>
</div> </div>
</div>
</aside> </aside>
<section class="canvas-stack"> <section class="canvas-stack">
<div class="canvas-topbar"> <div class="canvas-topbar">
<div> <div>
<p class="eyebrow subtle">Canvas</p> <p class="eyebrow subtle">Canvas</p>
<h3 class="panel-title">Live composition</h3> <h3 class="panel-title">Live composition</h3>
</div>
<div class="canvas-meta">
<span class="badge soft" id="canvas-resolution">1920 x 1080</span>
<span class="badge outline" id="canvas-scale">100%</span>
</div>
</div> </div>
<div class="canvas-surface"> <div class="canvas-meta">
<div class="overlay canvas-boundary" id="admin-overlay"> <span class="badge soft" id="canvas-resolution">1920 x 1080</span>
<div class="canvas-guides"></div> <span class="badge outline" id="canvas-scale">100%</span>
<canvas id="admin-canvas"></canvas>
</div>
<div class="canvas-footnote">
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
</div>
</div> </div>
</div>
<div class="canvas-surface">
<div class="overlay canvas-boundary" id="admin-overlay">
<div class="canvas-guides"></div>
<canvas id="admin-canvas"></canvas>
</div>
<div class="canvas-footnote">
<p>Edges of the canvas are outlined to match the aspect ratio of the stream.</p>
</div>
</div>
</section> </section>
</div>
</div> </div>
</div> <script th:inline="javascript">
<script th:inline="javascript"> const broadcaster = /*[[${broadcaster}]]*/ '';
const broadcaster = /*[[${broadcaster}]]*/ ''; const username = /*[[${username}]]*/ '';
const username = /*[[${username}]]*/ ''; const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0; const SETTINGS = /*[[${settingsJson}]]*/;
const SETTINGS = /*[[${settingsJson}]]*/; </script>
</script> <script src="/js/toast.js"></script>
<script src="/js/toast.js"></script> <script src="/js/admin.js"></script>
<script src="/js/admin.js"></script> </body>
</body>
</html> </html>

View File

@@ -1,18 +1,18 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Imgfloat Broadcast</title> <title>Imgfloat Broadcast</title>
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head> </head>
<body class="broadcast-body"> <body class="broadcast-body">
<canvas id="broadcast-canvas"></canvas> <canvas id="broadcast-canvas"></canvas>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${broadcaster}]]*/ ''; const broadcaster = /*[[${broadcaster}]]*/ "";
</script> </script>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>
<script src="/js/broadcast.js"></script> <script src="/js/broadcast.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,36 +1,44 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Browse channels - Imgfloat</title> <title>Browse channels - Imgfloat</title>
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="channels-body"> <body class="channels-body">
<div class="channels-shell"> <div class="channels-shell">
<header class="channels-header"> <header class="channels-header">
<div class="brand"> <div class="brand">
<div class="brand-mark">IF</div> <div class="brand-mark">IF</div>
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div> </div>
</header> </header>
<main class="channels-main"> <main class="channels-main">
<section class="channel-card"> <section class="channel-card">
<p class="eyebrow subtle">Broadcast overlay</p> <p class="eyebrow subtle">Broadcast overlay</p>
<h1>Open a channel</h1> <h1>Open a channel</h1>
<p class="muted">Type the channel name to jump straight to their overlay.</p> <p class="muted">Type the channel name to jump straight to their overlay.</p>
<form id="channel-search-form" class="channel-form"> <form id="channel-search-form" class="channel-form">
<label class="sr-only" for="channel-search">Channel name</label> <label class="sr-only" for="channel-search">Channel name</label>
<input id="channel-search" name="channel" class="text-input" type="text" list="channel-suggestions" placeholder="Type a channel name" autocomplete="off" /> <input
<datalist id="channel-suggestions"></datalist> id="channel-search"
<button type="submit" class="button block">Open overlay</button> name="channel"
</form> class="text-input"
type="text"
list="channel-suggestions"
placeholder="Type a channel name"
autocomplete="off"
/>
<datalist id="channel-suggestions"></datalist>
<button type="submit" class="button block">Open overlay</button>
</form>
</section> </section>
</main> </main>
</div> </div>
<script src="/js/landing.js"></script> <script src="/js/landing.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,151 +1,166 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Imgfloat Dashboard</title> <title>Imgfloat Dashboard</title>
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="dashboard-body"> <body class="dashboard-body">
<div class="dashboard-shell"> <div class="dashboard-shell">
<header class="dashboard-topbar"> <header class="dashboard-topbar">
<div class="brand"> <div class="brand">
<div class="brand-mark">IF</div> <div class="brand-mark">IF</div>
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div> </div>
<div class="user-pill"> <div class="user-pill">
<span class="eyebrow subtle">Signed in as</span> <span class="eyebrow subtle">Signed in as</span>
<span class="user-display" th:text="${username}">user</span> <span class="user-display" th:text="${username}">user</span>
</div> </div>
</header> </header>
<section class="card"> <section class="card">
<p class="eyebrow">Navigation</p> <p class="eyebrow">Navigation</p>
<h3>Shortcuts</h3> <h3>Shortcuts</h3>
<p class="muted">Jump into your overlay</p> <p class="muted">Jump into your overlay</p>
<div class="panel-actions"> <div class="panel-actions">
<a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a> <a class="button block" th:href="@{'/view/' + ${channel} + '/broadcast'}">Open broadcast overlay</a>
<a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a> <a class="button ghost block" th:href="@{'/view/' + ${channel} + '/admin'}">Open admin console</a>
<a class="button ghost block" href="/channels">Browse channels</a> <a class="button ghost block" href="/channels">Browse channels</a>
<form class="block" th:action="@{/logout}" method="post"> <form class="block" th:action="@{/logout}" method="post">
<button class="secondary block" type="submit">Logout</button> <button class="secondary block" type="submit">Logout</button>
</form> </form>
</div> </div>
</section> </section>
<section class="card"> <section class="card">
<p class="eyebrow">Settings</p> <p class="eyebrow">Settings</p>
<h3>Overlay dimensions</h3> <h3>Overlay dimensions</h3>
<p class="muted">Match these with your OBS resolution.</p> <p class="muted">Match these with your OBS resolution.</p>
<div class="control-grid"> <div class="control-grid">
<label> <label>
Width Width
<input id="canvas-width" type="number" min="100" step="10" /> <input id="canvas-width" type="number" min="100" step="10" />
</label> </label>
<label> <label>
Height Height
<input id="canvas-height" type="number" min="100" step="10" /> <input id="canvas-height" type="number" min="100" step="10" />
</label> </label>
</div> </div>
<div class="control-actions"> <div class="control-actions">
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button> <button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
<span id="canvas-status" class="muted"></span> <span id="canvas-status" class="muted"></span>
</div> </div>
</section> </section>
<section class="card-grid two-col"> <section class="card-grid two-col">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<div>
<p class="eyebrow">Collaboration</p>
<h3>Channel admins</h3>
<p class="muted">Invite moderators to help manage assets.</p>
</div>
</div>
<div class="inline-form">
<input id="new-admin" placeholder="Twitch username" />
<button type="button" onclick="addAdmin()">Add admin</button>
</div>
<div class="card-section">
<div class="section-header">
<h4 class="list-title">Channel Admins</h4>
<p class="muted">Users who can currently modify your overlay.</p>
</div>
<ul id="admin-list" class="stacked-list"></ul>
</div>
<div class="card-section">
<div class="section-header">
<h4 class="list-title">Your Twitch moderators</h4>
<p class="muted">Add moderators who already help run your channel.</p>
</div>
<ul id="admin-suggestions" class="stacked-list"></ul>
</div>
</div>
</section>
<section th:if="${adminChannels != null}" class="card">
<div class="card-header">
<div> <div>
<p class="eyebrow">Your access</p> <p class="eyebrow">Collaboration</p>
<h3>Channels you administer</h3> <h3>Channel admins</h3>
<p class="muted">Jump into a teammate's overlay console.</p> <p class="muted">Invite moderators to help manage assets.</p>
</div> </div>
</div>
<div class="inline-form">
<input id="new-admin" placeholder="Twitch username" />
<button type="button" onclick="addAdmin()">Add admin</button>
</div>
<div class="card-section">
<div class="section-header">
<h4 class="list-title">Channel Admins</h4>
<p class="muted">Users who can currently modify your overlay.</p>
</div>
<ul id="admin-list" class="stacked-list"></ul>
</div>
<div class="card-section">
<div class="section-header">
<h4 class="list-title">Your Twitch moderators</h4>
<p class="muted">Add moderators who already help run your channel.</p>
</div>
<ul id="admin-suggestions" class="stacked-list"></ul>
</div>
</div>
</section>
<section th:if="${adminChannels != null}" class="card">
<div class="card-header">
<div>
<p class="eyebrow">Your access</p>
<h3>Channels you administer</h3>
<p class="muted">Jump into a teammate's overlay console.</p>
</div>
</div> </div>
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p> <p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
<ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list"> <ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list">
<li th:each="channelName : ${adminChannels}" class="stacked-list-item"> <li th:each="channelName : ${adminChannels}" class="stacked-list-item">
<div>
<p class="list-title" th:text="${channelName}">channel</p>
<p class="muted">Channel admin access</p>
</div>
<a class="button ghost" th:href="@{'/view/' + ${channelName} + '/admin'}">Open</a>
</li>
</ul>
</section>
<section class="card download-card-block">
<div class="download-header">
<div> <div>
<p class="eyebrow">Desktop app</p> <p class="list-title" th:text="${channelName}">channel</p>
<h3>Download Imgfloat</h3> <p class="muted">Channel admin access</p>
<p class="muted">Version <span class="version-inline" th:text="${releaseVersion}">0.0.1</span> · build <span class="version-inline" th:text="${version}">unknown</span></p>
</div> </div>
<a class="button ghost" th:href="@{'/view/' + ${channelName} + '/admin'}">Open</a>
</li>
</ul>
</section>
<section class="card download-card-block">
<div class="download-header">
<div>
<p class="eyebrow">Desktop app</p>
<h3>Download Imgfloat</h3>
<p class="muted">
Version <span class="version-inline" th:text="${releaseVersion}">0.0.1</span> · build
<span class="version-inline" th:text="${version}">unknown</span>
</p>
</div>
</div> </div>
<div class="download-grid"> <div class="download-grid">
<div class="download-card" data-platform="mac"> <div class="download-card" data-platform="mac">
<div class="download-card-header"> <div class="download-card-header">
<p class="eyebrow">macOS</p> <p class="eyebrow">macOS</p>
<span class="badge soft recommended-badge hidden">Recommended</span> <span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<p class="muted">Apple Silicon build (ARM64)</p>
<a class="button block" th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'">Download .dmg</a>
</div> </div>
<div class="download-card" data-platform="windows"> <p class="muted">Apple Silicon build (ARM64)</p>
<div class="download-card-header"> <a
<p class="eyebrow">Windows</p> class="button block"
<span class="badge soft recommended-badge hidden">Recommended</span> th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
</div> >Download .dmg</a
<p class="muted">Installer for Windows 10 and 11</p> >
<a class="button block" th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'">Download .exe</a> </div>
<div class="download-card" data-platform="windows">
<div class="download-card-header">
<p class="eyebrow">Windows</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div> </div>
<div class="download-card" data-platform="linux"> <p class="muted">Installer for Windows 10 and 11</p>
<div class="download-card-header"> <a
<p class="eyebrow">Linux</p> class="button block"
<span class="badge soft recommended-badge hidden">Recommended</span> th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'"
</div> >Download .exe</a
<p class="muted">AppImage for most distributions</p> >
<a class="button block" th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'">Download AppImage</a> </div>
<div class="download-card" data-platform="linux">
<div class="download-card-header">
<p class="eyebrow">Linux</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div> </div>
<p class="muted">AppImage for most distributions</p>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
>Download AppImage</a
>
</div>
</div> </div>
</section> </section>
</div> </div>
<script src="/js/toast.js"></script> <script src="/js/toast.js"></script>
<script src="/js/downloads.js"></script> <script src="/js/downloads.js"></script>
<script th:inline="javascript"> <script th:inline="javascript">
const broadcaster = /*[[${channel}]]*/ ''; const broadcaster = /*[[${channel}]]*/ "";
</script> </script>
<script src="/js/dashboard.js"></script> <script src="/js/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,79 +1,95 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Imgfloat - Twitch overlay</title> <title>Imgfloat - Twitch overlay</title>
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
</head> </head>
<body class="landing-body"> <body class="landing-body">
<div class="landing"> <div class="landing">
<header class="landing-header"> <header class="landing-header">
<div class="brand"> <div class="brand">
<div class="brand-mark">IF</div> <div class="brand-mark">IF</div>
<div> <div>
<div class="brand-title">Imgfloat</div> <div class="brand-title">Imgfloat</div>
<div class="brand-subtitle">Twitch overlay manager</div> <div class="brand-subtitle">Twitch overlay manager</div>
</div> </div>
</div> </div>
</header> </header>
<main class="hero hero-compact"> <main class="hero hero-compact">
<div class="hero-text"> <div class="hero-text">
<p class="eyebrow">Overlay toolkit</p> <p class="eyebrow">Overlay toolkit</p>
<h1>Keep your Twitch overlays tidy.</h1> <h1>Keep your Twitch overlays tidy.</h1>
<p class="lead">Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter.</p> <p class="lead">
<div class="cta-row"> Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter.
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a> </p>
<a class="button ghost" href="/channels" rel="prefetch">Browse channels</a> <div class="cta-row">
</div> <a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
<a class="button ghost" href="/channels" rel="prefetch">Browse channels</a>
</div>
</div> </div>
</main> </main>
<section class="download-section"> <section class="download-section">
<div class="download-header"> <div class="download-header">
<p class="eyebrow">Desktop app</p> <p class="eyebrow">Desktop app</p>
<h2>Download Imgfloat</h2> <h2>Download Imgfloat</h2>
<p class="muted">Version <span class="version-inline" th:text="${releaseVersion}">0.0.1</span> for Windows, macOS, and Linux.</p> <p class="muted">
Version <span class="version-inline" th:text="${releaseVersion}">0.0.1</span> for Windows, macOS, and Linux.
</p>
</div> </div>
<div class="download-grid"> <div class="download-grid">
<div class="download-card" data-platform="mac"> <div class="download-card" data-platform="mac">
<div class="download-card-header"> <div class="download-card-header">
<p class="eyebrow">macOS</p> <p class="eyebrow">macOS</p>
<span class="badge soft recommended-badge hidden">Recommended</span> <span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<p class="muted">Apple Silicon build (ARM64)</p>
<a class="button block" th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'">Download .dmg</a>
</div> </div>
<div class="download-card" data-platform="windows"> <p class="muted">Apple Silicon build (ARM64)</p>
<div class="download-card-header"> <a
<p class="eyebrow">Windows</p> class="button block"
<span class="badge soft recommended-badge hidden">Recommended</span> th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
</div> >Download .dmg</a
<p class="muted">Installer for Windows 10 and 11</p> >
<a class="button block" th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'">Download .exe</a> </div>
<div class="download-card" data-platform="windows">
<div class="download-card-header">
<p class="eyebrow">Windows</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div> </div>
<div class="download-card" data-platform="linux"> <p class="muted">Installer for Windows 10 and 11</p>
<div class="download-card-header"> <a
<p class="eyebrow">Linux</p> class="button block"
<span class="badge soft recommended-badge hidden">Recommended</span> th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat.Setup.' + ${releaseVersion} + '.exe'"
</div> >Download .exe</a
<p class="muted">AppImage for most distributions</p> >
<a class="button block" th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'">Download AppImage</a> </div>
<div class="download-card" data-platform="linux">
<div class="download-card-header">
<p class="eyebrow">Linux</p>
<span class="badge soft recommended-badge hidden">Recommended</span>
</div> </div>
<p class="muted">AppImage for most distributions</p>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
>Download AppImage</a
>
</div>
</div> </div>
</section> </section>
<footer class="landing-meta"> <footer class="landing-meta">
<div class="build-chip"> <div class="build-chip">
<span class="muted">License</span> <span class="muted">License</span>
<span class="version-badge">MIT</span> <span class="version-badge">MIT</span>
</div> </div>
<div class="build-chip"> <div class="build-chip">
<span class="muted">Build</span> <span class="muted">Build</span>
<span class="version-badge" th:text="${version}">unknown</span> <span class="version-badge" th:text="${version}">unknown</span>
</div> </div>
</footer> </footer>
</div> </div>
<script src="/js/downloads.js"></script> <script src="/js/downloads.js"></script>
</body> </body>
</html> </html>

View File

@@ -1,234 +1,254 @@
<!DOCTYPE html> <!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org"> <html lang="en" xmlns:th="http://www.thymeleaf.org">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<title>Imgfloat Admin</title> <title>Imgfloat Admin</title>
<link rel="stylesheet" href="/css/styles.css" /> <link rel="stylesheet" href="/css/styles.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css" integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A==" crossorigin="anonymous" referrerpolicy="no-referrer" /> <link
<script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script> rel="stylesheet"
<script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script> href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.2/css/all.min.css"
</head> integrity="sha512-SnH5WK+bZxgPHs44uWIX+LLJAJ9/2PkPKZ5QiAj6Ta86w+fsb2TkcmfRyVX3pBnMFcV7oQPJkl9QevSCWr3W6A=="
<body class="settings-body"> crossorigin="anonymous"
<div class="settings-shell"> referrerpolicy="no-referrer"
<header class="settings-header"> />
<div class="brand"> <script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script>
<div class="brand-mark">IF</div> <script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
<div> </head>
<div class="brand-title">Imgfloat</div> <body class="settings-body">
<div class="brand-subtitle">Twitch overlay manager</div> <div class="settings-shell">
</div> <header class="settings-header">
</div> <div class="brand">
</header> <div class="brand-mark">IF</div>
<div>
<main class="settings-main"> <div class="brand-title">Imgfloat</div>
<section class="settings-card settings-hero"> <div class="brand-subtitle">Twitch overlay manager</div>
<div class="hero-copy"> </div>
<p class="eyebrow subtle">System administrator settings</p>
<h1>Application defaults</h1>
<p class="muted">
Configure overlay performance and audio guardrails for every channel using Imgfloat.
These settings are applied globally.
</p>
<div class="badge-row">
<span class="badge soft"><i class="fa-solid fa-gauge-high"></i> Performance tuned</span>
<span class="badge"><i class="fa-solid fa-cloud"></i> Server-wide</span>
<span class="badge subtle"><i class="fa-solid fa-gear"></i> Admin only</span>
</div>
</div>
<div class="stat-grid compact">
<div class="stat">
<p class="stat-label">Canvas FPS</p>
<p class="stat-value" id="stat-canvas-fps">--</p>
<p class="stat-subtitle">Longest side <span id="stat-canvas-size">--</span></p>
</div>
<div class="stat">
<p class="stat-label">Playback speed</p>
<p class="stat-value" id="stat-playback-range">--</p>
<p class="stat-subtitle">Applies to all animations</p>
</div>
<div class="stat">
<p class="stat-label">Audio pitch</p>
<p class="stat-value" id="stat-audio-range">--</p>
<p class="stat-subtitle">Fraction of original clip</p>
</div>
<div class="stat">
<p class="stat-label">Volume limits</p>
<p class="stat-value" id="stat-volume-range">--</p>
<p class="stat-subtitle">Keeps alerts comfortable</p>
</div>
</div>
</section>
<div class="settings-layout">
<section class="settings-card settings-panel">
<div class="section-heading">
<div>
<p class="eyebrow subtle">Overlay defaults</p>
<h2>Performance & audio budget</h2>
<p class="muted tiny">Tune the canvas and audio guardrails to keep overlays smooth and balanced.</p>
</div>
</div>
<form novalidate id="settings-form" class="settings-form">
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Canvas</p>
<h3>Rendering budget</h3>
<p class="muted tiny">Match FPS and max dimensions to your streaming canvas for consistent overlays.</p>
</div>
<div class="control-grid split-row">
<label for="canvas-fps">Canvas FPS
<input
id="canvas-fps"
name="canvas-fps"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="60"
/>
</label>
<label for="canvas-size">Canvas max side length (pixels)
<input
id="canvas-size"
name="canvas-size"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="1920"
/>
</label>
</div>
<p class="field-hint">Use the longest edge of your OBS browser source to prevent stretching.</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Playback</p>
<h3>Animation speed limits</h3>
<p class="muted tiny">Bound default speeds between 0 and 1 so clips run predictably.</p>
</div>
<div class="control-grid split-row">
<label for="min-playback-speed">Min playback speed
<input
id="min-playback-speed"
name="min-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.5"
/>
</label>
<label for="max-playback-speed">Max playback speed
<input
id="max-playback-speed"
name="max-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Audio</p>
<h3>Pitch & volume guardrails</h3>
<p class="muted tiny">Prevent harsh audio by bounding pitch and volume as fractions of the source.</p>
</div>
<div class="control-grid split-row">
<label for="min-audio-pitch">Min audio pitch
<input
id="min-audio-pitch"
name="min-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.8"
/>
</label>
<label for="max-audio-pitch">Max audio pitch
<input
id="max-audio-pitch"
name="max-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<div class="control-grid split-row">
<label for="min-volume">Min volume
<input
id="min-volume"
name="min-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.2"
/>
</label>
<label for="max-volume">Max volume
<input
id="max-volume"
name="max-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">Volume and pitch values are percentages of the original clip between 0 and 1.</p>
</div>
<div class="form-footer">
<p id="settings-status" class="status-chip">No changes yet.</p>
<button id="settings-submit-button" type="submit" class="button" disabled>Save settings</button>
</div>
</form>
</section>
<aside class="settings-sidebar">
<section class="settings-card info-card">
<p class="eyebrow subtle">Checklist</p>
<h3>Before you save</h3>
<ul class="hint-list">
<li>Match canvas dimensions to the OBS browser source you embed.</li>
<li>Use 3060 FPS for smoother overlays without overwhelming viewers.</li>
<li>Keep playback and pitch bounds between 0 and 1 to avoid distortion.</li>
<li>Lower the minimum volume if alerts feel too loud on stream.</li>
</ul>
</section>
<section class="settings-card info-card subtle">
<p class="eyebrow subtle">Heads up</p>
<h3>Global impact</h3>
<p class="muted tiny">Changes here update every channel immediately. Save carefully and confirm with your team.</p>
</section>
</aside>
</div>
</main>
</div> </div>
<script th:inline="javascript"> </header>
const serverRenderedSettings = /*[[${settingsJson}]]*/;
</script> <main class="settings-main">
<script src="/js/settings.js"></script> <section class="settings-card settings-hero">
<script src="/js/toast.js"></script> <div class="hero-copy">
</body> <p class="eyebrow subtle">System administrator settings</p>
<h1>Application defaults</h1>
<p class="muted">
Configure overlay performance and audio guardrails for every channel using Imgfloat. These settings are
applied globally.
</p>
<div class="badge-row">
<span class="badge soft"><i class="fa-solid fa-gauge-high"></i> Performance tuned</span>
<span class="badge"><i class="fa-solid fa-cloud"></i> Server-wide</span>
<span class="badge subtle"><i class="fa-solid fa-gear"></i> Admin only</span>
</div>
</div>
<div class="stat-grid compact">
<div class="stat">
<p class="stat-label">Canvas FPS</p>
<p class="stat-value" id="stat-canvas-fps">--</p>
<p class="stat-subtitle">Longest side <span id="stat-canvas-size">--</span></p>
</div>
<div class="stat">
<p class="stat-label">Playback speed</p>
<p class="stat-value" id="stat-playback-range">--</p>
<p class="stat-subtitle">Applies to all animations</p>
</div>
<div class="stat">
<p class="stat-label">Audio pitch</p>
<p class="stat-value" id="stat-audio-range">--</p>
<p class="stat-subtitle">Fraction of original clip</p>
</div>
<div class="stat">
<p class="stat-label">Volume limits</p>
<p class="stat-value" id="stat-volume-range">--</p>
<p class="stat-subtitle">Keeps alerts comfortable</p>
</div>
</div>
</section>
<div class="settings-layout">
<section class="settings-card settings-panel">
<div class="section-heading">
<div>
<p class="eyebrow subtle">Overlay defaults</p>
<h2>Performance & audio budget</h2>
<p class="muted tiny">Tune the canvas and audio guardrails to keep overlays smooth and balanced.</p>
</div>
</div>
<form novalidate id="settings-form" class="settings-form">
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Canvas</p>
<h3>Rendering budget</h3>
<p class="muted tiny">
Match FPS and max dimensions to your streaming canvas for consistent overlays.
</p>
</div>
<div class="control-grid split-row">
<label for="canvas-fps"
>Canvas FPS
<input
id="canvas-fps"
name="canvas-fps"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="60"
/>
</label>
<label for="canvas-size"
>Canvas max side length (pixels)
<input
id="canvas-size"
name="canvas-size"
class="text-input"
type="text"
inputmode="numeric"
pattern="^[1-9]\d*$"
placeholder="1920"
/>
</label>
</div>
<p class="field-hint">Use the longest edge of your OBS browser source to prevent stretching.</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Playback</p>
<h3>Animation speed limits</h3>
<p class="muted tiny">Bound default speeds between 0 and 1 so clips run predictably.</p>
</div>
<div class="control-grid split-row">
<label for="min-playback-speed"
>Min playback speed
<input
id="min-playback-speed"
name="min-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.5"
/>
</label>
<label for="max-playback-speed"
>Max playback speed
<input
id="max-playback-speed"
name="max-playback-speed"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">
Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.
</p>
</div>
<div class="form-section">
<div class="form-heading">
<p class="eyebrow subtle">Audio</p>
<h3>Pitch & volume guardrails</h3>
<p class="muted tiny">Prevent harsh audio by bounding pitch and volume as fractions of the source.</p>
</div>
<div class="control-grid split-row">
<label for="min-audio-pitch"
>Min audio pitch
<input
id="min-audio-pitch"
name="min-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.8"
/>
</label>
<label for="max-audio-pitch"
>Max audio pitch
<input
id="max-audio-pitch"
name="max-audio-pitch"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<div class="control-grid split-row">
<label for="min-volume"
>Min volume
<input
id="min-volume"
name="min-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="0.2"
/>
</label>
<label for="max-volume"
>Max volume
<input
id="max-volume"
name="max-volume"
class="text-input"
type="text"
inputmode="decimal"
pattern="^(0(\.\d+)?|1(\.0+)?)$"
placeholder="1.0"
/>
</label>
</div>
<p class="field-hint">Volume and pitch values are percentages of the original clip between 0 and 1.</p>
</div>
<div class="form-footer">
<p id="settings-status" class="status-chip">No changes yet.</p>
<button id="settings-submit-button" type="submit" class="button" disabled>Save settings</button>
</div>
</form>
</section>
<aside class="settings-sidebar">
<section class="settings-card info-card">
<p class="eyebrow subtle">Checklist</p>
<h3>Before you save</h3>
<ul class="hint-list">
<li>Match canvas dimensions to the OBS browser source you embed.</li>
<li>Use 3060 FPS for smoother overlays without overwhelming viewers.</li>
<li>Keep playback and pitch bounds between 0 and 1 to avoid distortion.</li>
<li>Lower the minimum volume if alerts feel too loud on stream.</li>
</ul>
</section>
<section class="settings-card info-card subtle">
<p class="eyebrow subtle">Heads up</p>
<h3>Global impact</h3>
<p class="muted tiny">
Changes here update every channel immediately. Save carefully and confirm with your team.
</p>
</section>
</aside>
</div>
</main>
</div>
<script th:inline="javascript">
const serverRenderedSettings = /*[[${settingsJson}]]*/;
</script>
<script src="/js/settings.js"></script>
<script src="/js/toast.js"></script>
</body>
</html> </html>