Add prettier

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

13
.prettierrc Normal file
View File

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

79
package-lock.json generated
View File

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

View File

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

View File

@@ -1,5 +1,5 @@
const { app, BrowserWindow } = require('electron');
const path = require('path');
const { app, BrowserWindow } = require("electron");
const path = require("path");
function createWindow() {
const url = "https://imgfloat.kruhlmann.dev/channels";
@@ -10,7 +10,7 @@ function createWindow() {
height: initialWindowHeightPx,
transparent: true,
frame: true,
backgroundColor: '#00000000',
backgroundColor: "#00000000",
alwaysOnTop: false,
icon: path.join(__dirname, "../resources/assets/icon/appicon.ico"),
webPreferences: { backgroundThrottling: false },
@@ -77,13 +77,13 @@ function createWindow() {
applicationWindow.loadURL(url);
applicationWindow.webContents.on('did-finish-load', () => {
applicationWindow.webContents.on("did-finish-load", () => {
handleNavigation(applicationWindow.webContents.getURL());
});
applicationWindow.webContents.on('did-navigate', (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.webContents.on('did-navigate-in-page', (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.on('closed', clearCanvasSizeInterval);
applicationWindow.webContents.on("did-navigate", (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.webContents.on("did-navigate-in-page", (_event, navigationUrl) => handleNavigation(navigationUrl));
applicationWindow.on("closed", clearCanvasSizeInterval);
}
app.whenReady().then(createWindow);

View File

@@ -20,9 +20,9 @@ body {
.landing-body {
min-height: 100vh;
background: radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.18), transparent 30%),
radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 25%),
#0f172a;
background:
radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.18), transparent 30%),
radial-gradient(circle at 80% 0%, rgba(59, 130, 246, 0.16), transparent 25%), #0f172a;
}
.landing {
@@ -59,25 +59,28 @@ body {
font-size: 13px;
}
.channels-body, .settings-body {
.channels-body,
.settings-body {
min-height: 100vh;
background: radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%),
#0f172a;
background:
radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%),
radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), #0f172a;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(24px, 4vw, 48px);
}
.channels-shell, .settings-shell {
.channels-shell,
.settings-shell {
width: min(760px, 100%);
display: flex;
flex-direction: column;
gap: 20px;
}
.channels-header, .settings-header {
.channels-header,
.settings-header {
display: flex;
align-items: center;
justify-content: space-between;
@@ -397,7 +400,8 @@ body {
gap: 14px;
}
.download-header h2, .download-header h3 {
.download-header h2,
.download-header h3 {
margin: 6px 0 8px;
}
@@ -420,7 +424,10 @@ body {
display: flex;
flex-direction: column;
gap: 10px;
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease,
transform 0.2s ease;
}
.download-card-header {
@@ -466,7 +473,8 @@ body {
margin: 16px 0 10px;
}
.button, button {
.button,
button {
background: #7c3aed;
color: white;
padding: 10px 16px;
@@ -482,7 +490,9 @@ body {
box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25);
}
.button:disabled, button:disabled, .button[aria-disabled="true"] {
.button:disabled,
button:disabled,
.button[aria-disabled="true"] {
background: #a78bfa;
color: #e5e7eb;
cursor: not-allowed;
@@ -567,7 +577,9 @@ button:disabled:hover {
background: #0f172a;
color: #e2e8f0;
font-size: 15px;
transition: border-color 0.2s ease, box-shadow 0.2s ease;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.text-input:focus {
@@ -576,7 +588,8 @@ button:disabled:hover {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25);
}
.text-input:disabled, .text-input[aria-disabled="true"] {
.text-input:disabled,
.text-input[aria-disabled="true"] {
background: #020617;
border-color: #334155;
color: #64748b;
@@ -706,9 +719,9 @@ button:disabled:hover {
}
.admin-body {
background: radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.12), transparent 32%),
radial-gradient(circle at 90% 5%, rgba(124, 58, 237, 0.1), transparent 30%),
#0f172a;
background:
radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.12), transparent 32%),
radial-gradient(circle at 90% 5%, rgba(124, 58, 237, 0.1), transparent 30%), #0f172a;
}
.admin-frame {
@@ -761,7 +774,9 @@ button:disabled:hover {
.admin-rail {
background: rgba(11, 18, 32, 0.92);
border-right: 1px solid #1f2937;
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.02), 0 18px 45px rgba(0, 0, 0, 0.4);
box-shadow:
inset 0 1px 0 rgba(255, 255, 255, 0.02),
0 18px 45px rgba(0, 0, 0, 0.4);
display: flex;
flex-direction: column;
height: calc(100vh - 81px);
@@ -822,9 +837,9 @@ button:disabled:hover {
.dashboard-body {
min-height: 100vh;
background: radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.14), transparent 30%),
radial-gradient(circle at 90% 10%, rgba(124, 58, 237, 0.12), transparent 28%),
#0f172a;
background:
radial-gradient(circle at 0% 30%, rgba(59, 130, 246, 0.14), transparent 30%),
radial-gradient(circle at 90% 10%, rgba(124, 58, 237, 0.12), transparent 28%), #0f172a;
padding: 36px 18px 64px;
}
@@ -1061,9 +1076,9 @@ button:disabled:hover {
flex: 1;
min-height: clamp(520px, 72vh, 980px);
height: 100%;
background: radial-gradient(circle at 18% 20%, rgba(59, 130, 246, 0.08), transparent 38%),
radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.08), transparent 40%),
#0b1220;
background:
radial-gradient(circle at 18% 20%, rgba(59, 130, 246, 0.08), transparent 38%),
radial-gradient(circle at 80% 0%, rgba(124, 58, 237, 0.08), transparent 40%), #0b1220;
overflow: hidden;
border: 1px solid #1f2937;
border-radius: 16px;
@@ -1083,7 +1098,9 @@ button:disabled:hover {
#admin-canvas {
border: 1px solid rgba(148, 163, 184, 0.35);
border-radius: 10px;
box-shadow: 0 0 0 1px rgba(15, 23, 42, 0.5), 0 16px 45px rgba(0, 0, 0, 0.55);
box-shadow:
0 0 0 1px rgba(15, 23, 42, 0.5),
0 16px 45px rgba(0, 0, 0, 0.55);
background-color: rgba(255, 0, 255, 0.1);
}
@@ -1154,7 +1171,8 @@ button:disabled:hover {
}
.canvas-boundary {
background-image: linear-gradient(to right, rgba(255,255,255,0.03) 1px, transparent 1px),
background-image:
linear-gradient(to right, rgba(255, 255, 255, 0.03) 1px, transparent 1px),
linear-gradient(to bottom, rgba(255, 255, 255, 0.03) 1px, transparent 1px);
background-size: 24px 24px;
position: relative;
@@ -1166,7 +1184,9 @@ button:disabled:hover {
position: absolute;
border-radius: 12px;
pointer-events: none;
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.02), 0 16px 40px rgba(0, 0, 0, 0.35);
box-shadow:
inset 0 0 0 1px rgba(255, 255, 255, 0.02),
0 16px 40px rgba(0, 0, 0, 0.35);
}
.canvas-guides {
@@ -1303,7 +1323,9 @@ button:disabled:hover {
padding: 10px 12px;
background: rgba(124, 58, 237, 0.08);
cursor: pointer;
transition: background 120ms ease, background 120ms ease;
transition:
background 120ms ease,
background 120ms ease;
}
.file-input-trigger:hover {
@@ -1826,7 +1848,10 @@ button:disabled:hover {
background: linear-gradient(180deg, #e2e8f0, #cbd5e1);
border: 1px solid #cbd5e1;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.18);
transition: background 160ms ease, border-color 160ms ease, box-shadow 160ms ease;
transition:
background 160ms ease,
border-color 160ms ease,
box-shadow 160ms ease;
}
.toggle-thumb {
@@ -1834,24 +1859,35 @@ button:disabled:hover {
height: 17px;
border-radius: 999px;
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.18), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
box-shadow:
0 2px 4px rgba(0, 0, 0, 0.18),
0 1px 0 rgba(255, 255, 255, 0.6) inset;
transform: translateX(0);
transition: transform 160ms ease, box-shadow 160ms ease, background 160ms ease;
transition:
transform 160ms ease,
box-shadow 160ms ease,
background 160ms ease;
}
.checkbox-inline input[type="checkbox"]:checked + .toggle-track {
background: linear-gradient(180deg, #7c3aed, #342366);
border-color: #7c3aed;
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12), 0 0 0 1px rgba(52, 199, 89, 0.35);
box-shadow:
inset 0 1px 2px rgba(0, 0, 0, 0.12),
0 0 0 1px rgba(52, 199, 89, 0.35);
}
.checkbox-inline input[type="checkbox"]:checked + .toggle-track .toggle-thumb {
transform: translateX(25px);
box-shadow: 0 2px 6px rgba(40, 183, 75, 0.35), 0 1px 0 rgba(255, 255, 255, 0.6) inset;
box-shadow:
0 2px 6px rgba(40, 183, 75, 0.35),
0 1px 0 rgba(255, 255, 255, 0.6) inset;
}
.checkbox-inline input[type="checkbox"]:focus-visible + .toggle-track {
box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.35), inset 0 1px 2px rgba(0, 0, 0, 0.18);
box-shadow:
0 0 0 3px rgba(124, 58, 237, 0.35),
inset 0 1px 2px rgba(0, 0, 0, 0.18);
}
.toggle-label {
@@ -1960,7 +1996,9 @@ button:disabled:hover {
background: #0b1221;
color: #e5e7eb;
cursor: pointer;
transition: transform 120ms ease, opacity 120ms ease;
transition:
transform 120ms ease,
opacity 120ms ease;
}
.toast:hover {

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,9 @@
const canvas = document.getElementById('broadcast-canvas');
const canvas = document.getElementById("broadcast-canvas");
const obsBrowser = !!window.obsstudio;
const supportsAnimatedDecode = typeof ImageDecoder !== 'undefined' && typeof createImageBitmap === 'function' && !obsBrowser;
const canPlayProbe = document.createElement('video');
const ctx = canvas.getContext('2d');
const supportsAnimatedDecode =
typeof ImageDecoder !== "undefined" && typeof createImageBitmap === "function" && !obsBrowser;
const canPlayProbe = document.createElement("video");
const ctx = canvas.getContext("2d");
let canvasSettings = { width: 1920, height: 1080 };
canvas.width = canvasSettings.width;
canvas.height = canvasSettings.height;
@@ -23,7 +24,7 @@ let frameScheduled = false;
let pendingDraw = false;
let renderIntervalId = null;
const pendingRemovals = new Set();
const audioUnlockEvents = ['pointerdown', 'keydown', 'touchstart'];
const audioUnlockEvents = ["pointerdown", "keydown", "touchstart"];
let layerOrder = [];
audioUnlockEvents.forEach((eventName) => {
@@ -34,19 +35,19 @@ audioUnlockEvents.forEach((eventName) => {
});
});
function ensureLayerPosition(assetId, placement = 'keep') {
function ensureLayerPosition(assetId, placement = "keep") {
const asset = assets.get(assetId);
if (asset && isAudioAsset(asset)) {
return;
}
const existingIndex = layerOrder.indexOf(assetId);
if (existingIndex !== -1 && placement === 'keep') {
if (existingIndex !== -1 && placement === "keep") {
return;
}
if (existingIndex !== -1) {
layerOrder.splice(existingIndex, 1);
}
if (placement === 'append') {
if (placement === "append") {
layerOrder.push(assetId);
} else {
layerOrder.unshift(assetId);
@@ -71,7 +72,10 @@ function getLayerOrder() {
}
function getRenderOrder() {
return [...getLayerOrder()].reverse().map((id) => assets.get(id)).filter(Boolean);
return [...getLayerOrder()]
.reverse()
.map((id) => assets.get(id))
.filter(Boolean);
}
function queueRemoval(assetId) {
@@ -95,7 +99,7 @@ function flushPendingRemovals() {
}
function connect() {
const socket = new SockJS('/ws');
const socket = new SockJS("/ws");
const stompClient = Stomp.over(socket);
stompClient.connect({}, () => {
stompClient.subscribe(`/topic/channel/${broadcaster}`, (payload) => {
@@ -105,22 +109,22 @@ function connect() {
fetch(`/api/channels/${broadcaster}/assets/visible`)
.then((r) => {
if (!r.ok) {
throw new Error('Failed to load assets');
throw new Error("Failed to load assets");
}
return r.json();
})
.then(renderAssets)
.catch(() => showToast('Unable to load overlay assets. Retrying may help.', 'error'));
.catch(() => showToast("Unable to load overlay assets. Retrying may help.", "error"));
});
}
function renderAssets(list) {
layerOrder = [];
list.forEach((asset) => storeAsset(asset, 'append'));
list.forEach((asset) => storeAsset(asset, "append"));
draw();
}
function storeAsset(asset, placement = 'keep') {
function storeAsset(asset, placement = "keep") {
if (!asset) return;
const wasExisting = assets.has(asset.id);
assets.set(asset.id, asset);
@@ -135,7 +139,7 @@ function fetchCanvasSettings() {
return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`)
.then((r) => {
if (!r.ok) {
throw new Error('Failed to load canvas');
throw new Error("Failed to load canvas");
}
return r.json();
})
@@ -145,7 +149,7 @@ function fetchCanvasSettings() {
})
.catch(() => {
resizeCanvas();
showToast('Using default canvas size. Unable to load saved settings.', 'warning');
showToast("Using default canvas size. Unable to load saved settings.", "warning");
});
}
@@ -164,11 +168,11 @@ function resizeCanvas() {
function handleEvent(event) {
const assetId = event.assetId || event?.patch?.id || event?.payload?.id;
if (event.type === 'VISIBILITY') {
if (event.type === "VISIBILITY") {
handleVisibilityEvent(event);
return;
}
if (event.type === 'DELETED') {
if (event.type === "DELETED") {
removeAsset(assetId);
} else if (event.patch) {
applyPatch(assetId, event.patch);
@@ -177,10 +181,10 @@ function handleEvent(event) {
if (payload.hidden) {
hideAssetWithTransition(payload);
} else if (!assets.has(payload.id)) {
upsertVisibleAsset(payload, 'append');
upsertVisibleAsset(payload, "append");
}
}
} else if (event.type === 'PLAY' && event.payload) {
} else if (event.type === "PLAY" && event.payload) {
const payload = normalizePayload(event.payload);
storeAsset(payload);
if (isAudioAsset(payload)) {
@@ -205,7 +209,13 @@ function hideAssetWithTransition(asset) {
return;
}
const existing = assets.get(payload.id);
if (!existing && (!Number.isFinite(payload.x) || !Number.isFinite(payload.y) || !Number.isFinite(payload.width) || !Number.isFinite(payload.height))) {
if (
!existing &&
(!Number.isFinite(payload.x) ||
!Number.isFinite(payload.y) ||
!Number.isFinite(payload.width) ||
!Number.isFinite(payload.height))
) {
return;
}
const merged = normalizePayload({ ...(existing || {}), ...payload, hidden: true });
@@ -213,12 +223,12 @@ function hideAssetWithTransition(asset) {
stopAudio(payload.id);
}
function upsertVisibleAsset(asset, placement = 'keep') {
function upsertVisibleAsset(asset, placement = "keep") {
const payload = asset ? normalizePayload(asset) : null;
if (!payload?.id) {
return;
}
const placementMode = assets.has(payload.id) ? 'keep' : placement;
const placementMode = assets.has(payload.id) ? "keep" : placement;
storeAsset(payload, placementMode);
ensureMedia(payload);
if (isAudioAsset(payload)) {
@@ -238,7 +248,7 @@ function handleVisibilityEvent(event) {
}
if (payload) {
const placement = assets.has(payload.id) ? 'keep' : 'append';
const placement = assets.has(payload.id) ? "keep" : "append";
upsertVisibleAsset(payload, placement);
}
@@ -254,7 +264,7 @@ function applyPatch(assetId, patch) {
return;
}
const sanitizedPatch = Object.fromEntries(
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined)
Object.entries(patch).filter(([, value]) => value !== null && value !== undefined),
);
const existing = assets.get(assetId);
if (!existing) {
@@ -266,9 +276,7 @@ function applyPatch(assetId, patch) {
hideAssetWithTransition(merged);
return;
}
const targetLayer = Number.isFinite(patch.layer)
? patch.layer
: (Number.isFinite(patch.zIndex) ? patch.zIndex : null);
const targetLayer = Number.isFinite(patch.layer) ? patch.layer : Number.isFinite(patch.zIndex) ? patch.zIndex : null;
if (!isAudio && Number.isFinite(targetLayer)) {
const currentOrder = getLayerOrder().filter((id) => id !== assetId);
const insertIndex = Math.max(0, currentOrder.length - Math.round(targetLayer));
@@ -321,7 +329,7 @@ function drawAsset(asset) {
ctx.save();
ctx.globalAlpha = Math.max(0, Math.min(1, visibility.alpha));
ctx.translate(renderState.x + halfWidth, renderState.y + halfHeight);
ctx.rotate(renderState.rotation * Math.PI / 180);
ctx.rotate((renderState.rotation * Math.PI) / 180);
if (isAudioAsset(asset)) {
if (!asset.hidden) {
@@ -360,7 +368,7 @@ function smoothState(asset) {
y: lerp(previous.y, asset.y, factor),
width: lerp(previous.width, asset.width, factor),
height: lerp(previous.height, asset.height, factor),
rotation: smoothAngle(previous.rotation, asset.rotation, factor)
rotation: smoothAngle(previous.rotation, asset.rotation, factor),
};
renderStates.set(asset.id, next);
return next;
@@ -404,19 +412,19 @@ function recordDuration(assetId, seconds) {
}
function isVideoAsset(asset) {
return asset?.mediaType?.startsWith('video/');
return asset?.mediaType?.startsWith("video/");
}
function isAudioAsset(asset) {
return asset?.mediaType?.startsWith('audio/');
return asset?.mediaType?.startsWith("audio/");
}
function isVideoElement(element) {
return element && element.tagName === 'VIDEO';
return element && element.tagName === "VIDEO";
}
function isGifAsset(asset) {
return asset?.mediaType?.toLowerCase() === 'image/gif';
return asset?.mediaType?.toLowerCase() === "image/gif";
}
function isDrawable(element) {
@@ -429,7 +437,7 @@ function isDrawable(element) {
if (isVideoElement(element)) {
return element.readyState >= 2;
}
if (typeof ImageBitmap !== 'undefined' && element instanceof ImageBitmap) {
if (typeof ImageBitmap !== "undefined" && element instanceof ImageBitmap) {
return true;
}
return !!element.complete;
@@ -438,7 +446,7 @@ function isDrawable(element) {
function clearMedia(assetId) {
const element = mediaCache.get(assetId);
if (isVideoElement(element)) {
element.src = '';
element.src = "";
element.remove();
}
mediaCache.delete(assetId);
@@ -463,7 +471,7 @@ function clearMedia(assetId) {
}
audio.element.pause();
audio.element.currentTime = 0;
audio.element.src = '';
audio.element.src = "";
audio.element.remove();
audioControllers.delete(assetId);
}
@@ -482,9 +490,9 @@ function ensureAudioController(asset) {
const element = new Audio(asset.url);
element.autoplay = true;
element.preload = 'auto';
element.preload = "auto";
element.controls = false;
element.addEventListener('loadedmetadata', () => recordDuration(asset.id, element.duration));
element.addEventListener("loadedmetadata", () => recordDuration(asset.id, element.duration));
const controller = {
id: asset.id,
src: asset.url,
@@ -493,7 +501,7 @@ function ensureAudioController(asset) {
loopEnabled: false,
loopActive: true,
delayMs: 0,
baseDelayMs: 0
baseDelayMs: 0,
};
element.onended = () => handleAudioEnded(asset.id);
audioControllers.set(asset.id, controller);
@@ -577,7 +585,7 @@ function playAudioImmediately(asset) {
function playOverlappingAudio(asset) {
const temp = new Audio(asset.url);
temp.autoplay = true;
temp.preload = 'auto';
temp.preload = "auto";
temp.controls = false;
applyAudioElementSettings(temp, asset);
const controller = { element: temp };
@@ -646,9 +654,9 @@ function ensureMedia(asset) {
}
}
const element = isVideoAsset(asset) ? document.createElement('video') : new Image();
const element = isVideoAsset(asset) ? document.createElement("video") : new Image();
element.dataset.sourceUrl = asset.url;
element.crossOrigin = 'anonymous';
element.crossOrigin = "anonymous";
if (isVideoElement(element)) {
if (!canPlayVideoType(asset.mediaType)) {
return null;
@@ -659,8 +667,8 @@ function ensureMedia(asset) {
element.controls = false;
element.onloadeddata = draw;
element.onloadedmetadata = () => recordDuration(asset.id, element.duration);
element.preload = 'auto';
element.addEventListener('error', () => clearMedia(asset.id));
element.preload = "auto";
element.addEventListener("error", () => clearMedia(asset.id));
applyMediaVolume(element, asset);
element.muted = true;
setVideoSource(element, asset);
@@ -696,11 +704,11 @@ function ensureAnimatedImage(asset) {
bitmap: null,
timeout: null,
cancelled: false,
isAnimated: true
isAnimated: true,
};
fetchAssetBlob(asset)
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || 'image/gif' }))
.then((blob) => new ImageDecoder({ data: blob, type: blob.type || "image/gif" }))
.then((decoder) => {
if (controller.cancelled) {
decoder.close?.();
@@ -753,13 +761,15 @@ function setVideoSource(element, asset) {
return;
}
fetchAssetBlob(asset).then(() => {
fetchAssetBlob(asset)
.then(() => {
const next = blobCache.get(asset.id);
if (next?.url !== asset.url || !next.objectUrl) {
return;
}
applyVideoSource(element, next.objectUrl, asset);
}).catch(() => { });
})
.catch(() => {});
}
function applyVideoSource(element, objectUrl, asset) {
@@ -776,7 +786,7 @@ function canPlayVideoType(mediaType) {
return true;
}
const support = canPlayProbe.canPlayType(mediaType);
return support === 'probably' || support === 'maybe';
return support === "probably" || support === "maybe";
}
function getCachedSource(element) {
@@ -787,7 +797,9 @@ function scheduleNextFrame(controller) {
if (controller.cancelled || !controller.decoder) {
return;
}
controller.decoder.decode().then(({ image, complete }) => {
controller.decoder
.decode()
.then(({ image, complete }) => {
if (controller.cancelled) {
image.close?.();
return;
@@ -814,7 +826,8 @@ function scheduleNextFrame(controller) {
scheduleNextFrame(controller);
}
}, delay);
}).catch(() => {
})
.catch(() => {
// If decoding fails, clear animated cache so static fallback is used next render
animatedCache.delete(controller.id);
animationFailures.set(controller.id, Date.now());
@@ -849,9 +862,13 @@ function startVideoPlayback(element, asset) {
if (!element.paused && element.readyState >= 2) {
element.muted = false;
} else {
element.addEventListener('playing', () => {
element.addEventListener(
"playing",
() => {
element.muted = false;
}, { once: true });
},
{ once: true },
);
}
}
}
@@ -865,7 +882,7 @@ function startRenderLoop() {
}, MIN_FRAME_TIME);
}
window.addEventListener('resize', () => {
window.addEventListener("resize", () => {
resizeCanvas();
});

View File

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

View File

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

View File

@@ -12,9 +12,7 @@ document.addEventListener("DOMContentLoaded", () => {
function updateSuggestions(term) {
const normalizedTerm = term.trim().toLowerCase();
const filtered = channels
.filter((name) => !normalizedTerm || name.includes(normalizedTerm))
.slice(0, 20);
const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
suggestions.innerHTML = "";
filtered.forEach((name) => {

View File

@@ -105,12 +105,16 @@ function submitSettingsForm() {
}
statusElement.textContent = "Saving…";
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", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(userSettings),
})
.then((r) => {
if (!r.ok) {
throw new Error('Failed to load canvas');
throw new Error("Failed to load canvas");
}
return r.json();
})
.then((newSettings) => {
currentSettings = { ...newSettings };
@@ -122,7 +126,7 @@ function submitSettingsForm() {
updateSubmitButtonDisabledState();
})
.catch((error) => {
showToast('Unable to save settings', 'error')
showToast("Unable to save settings", "error");
console.error(error);
statusElement.textContent = "Save failed. Try again.";
statusElement.classList.add("status-warning");

View File

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

View File

@@ -1,10 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta charset="UTF-8" />
<title>Imgfloat Admin</title>
<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/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
@@ -22,14 +28,22 @@
<i class="fa-solid fa-chevron-left" aria-hidden="true"></i>
<span class="sr-only">Back to dashboard</span>
</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>
</header>
<div class="admin-workspace">
<aside class="admin-rail">
<div class="upload-row">
<input id="asset-file" class="file-input-field" type="file" accept="image/*,video/*,audio/*" onchange="handleFileSelection(this)" />
<input
id="asset-file"
class="file-input-field"
type="file"
accept="image/*,video/*,audio/*"
onchange="handleFileSelection(this)"
/>
<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">
@@ -52,7 +66,9 @@
<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" 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>
@@ -70,7 +86,13 @@
</div>
<div class="property-row">
<span class="property-label">Height</span>
<input id="asset-height" class="number-input property-control" type="number" min="10" step="5" />
<input
id="asset-height"
class="number-input property-control"
type="number"
min="10"
step="5"
/>
</div>
<div class="property-row">
<span class="property-label">Maintain AR</span>
@@ -101,7 +123,15 @@
<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" />
<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>
@@ -115,7 +145,15 @@
<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" />
<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>
@@ -140,7 +178,15 @@
<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" />
<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">
@@ -148,7 +194,15 @@
<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" />
<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">
@@ -156,22 +210,58 @@
<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" />
<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">
<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">
<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>

View File

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

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta charset="UTF-8" />
<title>Browse channels - Imgfloat</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
@@ -24,7 +24,15 @@
<p class="muted">Type the channel name to jump straight to their overlay.</p>
<form id="channel-search-form" class="channel-form">
<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
id="channel-search"
name="channel"
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>

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta charset="UTF-8" />
<title>Imgfloat Dashboard</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
@@ -110,7 +110,10 @@
<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>
<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 class="download-grid">
@@ -120,7 +123,11 @@
<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>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
>Download .dmg</a
>
</div>
<div class="download-card" data-platform="windows">
<div class="download-card-header">
@@ -128,7 +135,11 @@
<span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<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>
<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="linux">
<div class="download-card-header">
@@ -136,7 +147,11 @@
<span class="badge soft recommended-badge hidden">Recommended</span>
</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>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
>Download AppImage</a
>
</div>
</div>
</section>
@@ -144,7 +159,7 @@
<script src="/js/toast.js"></script>
<script src="/js/downloads.js"></script>
<script th:inline="javascript">
const broadcaster = /*[[${channel}]]*/ '';
const broadcaster = /*[[${channel}]]*/ "";
</script>
<script src="/js/dashboard.js"></script>
</body>

View File

@@ -1,7 +1,7 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta charset="UTF-8" />
<title>Imgfloat - Twitch overlay</title>
<link rel="stylesheet" href="/css/styles.css" />
</head>
@@ -21,7 +21,9 @@
<div class="hero-text">
<p class="eyebrow">Overlay toolkit</p>
<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">
Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter.
</p>
<div class="cta-row">
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
<a class="button ghost" href="/channels" rel="prefetch">Browse channels</a>
@@ -33,7 +35,9 @@
<div class="download-header">
<p class="eyebrow">Desktop app</p>
<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 class="download-grid">
<div class="download-card" data-platform="mac">
@@ -42,7 +46,11 @@
<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>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '-arm64.dmg'"
>Download .dmg</a
>
</div>
<div class="download-card" data-platform="windows">
<div class="download-card-header">
@@ -50,7 +58,11 @@
<span class="badge soft recommended-badge hidden">Recommended</span>
</div>
<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>
<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="linux">
<div class="download-card-header">
@@ -58,7 +70,11 @@
<span class="badge soft recommended-badge hidden">Recommended</span>
</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>
<a
class="button block"
th:href="'https://github.com/Kruhlmann/imgfloat-j/releases/download/' + ${releaseVersion} + '/Imgfloat-' + ${releaseVersion} + '.AppImage'"
>Download AppImage</a
>
</div>
</div>
</section>

View File

@@ -1,10 +1,16 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta charset="UTF-8" />
<title>Imgfloat Admin</title>
<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/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
@@ -26,8 +32,8 @@
<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.
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>
@@ -74,10 +80,13 @@
<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>
<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
<label for="canvas-fps"
>Canvas FPS
<input
id="canvas-fps"
name="canvas-fps"
@@ -89,7 +98,8 @@
/>
</label>
<label for="canvas-size">Canvas max side length (pixels)
<label for="canvas-size"
>Canvas max side length (pixels)
<input
id="canvas-size"
name="canvas-size"
@@ -111,7 +121,8 @@
<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
<label for="min-playback-speed"
>Min playback speed
<input
id="min-playback-speed"
name="min-playback-speed"
@@ -123,7 +134,8 @@
/>
</label>
<label for="max-playback-speed">Max playback speed
<label for="max-playback-speed"
>Max playback speed
<input
id="max-playback-speed"
name="max-playback-speed"
@@ -135,7 +147,9 @@
/>
</label>
</div>
<p class="field-hint">Keep the maximum at 1.0 to avoid speeding overlays beyond their source frame rate.</p>
<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">
@@ -145,7 +159,8 @@
<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
<label for="min-audio-pitch"
>Min audio pitch
<input
id="min-audio-pitch"
name="min-audio-pitch"
@@ -157,7 +172,8 @@
/>
</label>
<label for="max-audio-pitch">Max audio pitch
<label for="max-audio-pitch"
>Max audio pitch
<input
id="max-audio-pitch"
name="max-audio-pitch"
@@ -170,7 +186,8 @@
</label>
</div>
<div class="control-grid split-row">
<label for="min-volume">Min volume
<label for="min-volume"
>Min volume
<input
id="min-volume"
name="min-volume"
@@ -182,7 +199,8 @@
/>
</label>
<label for="max-volume">Max volume
<label for="max-volume"
>Max volume
<input
id="max-volume"
name="max-volume"
@@ -219,7 +237,9 @@
<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>
<p class="muted tiny">
Changes here update every channel immediately. Save carefully and confirm with your team.
</p>
</section>
</aside>
</div>