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
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"
}
+64 -15
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",
+14 -5
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"
} }
} }
+69 -69
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
+179 -177
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();
+31 -31
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);
}); });
+46 -48
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();
}); });
+90 -86
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);
+41 -41
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));
}; };
})(); })();
+280 -190
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>
+12 -12
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>
+35 -27
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>
+129 -114
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>
+73 -57
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>
+251 -231
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>