mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Unify formatting
This commit is contained in:
@@ -1,65 +1,65 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8080}
|
||||
tomcat:
|
||||
max-swallow-size: 0
|
||||
ssl:
|
||||
enabled: ${SSL_ENABLED:false}
|
||||
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
|
||||
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
|
||||
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
|
||||
error:
|
||||
include-message: never
|
||||
include-stacktrace: never
|
||||
port: ${SERVER_PORT:8080}
|
||||
tomcat:
|
||||
max-swallow-size: 0
|
||||
ssl:
|
||||
enabled: ${SSL_ENABLED:false}
|
||||
key-store: ${SSL_KEYSTORE_PATH:classpath:keystore.p12}
|
||||
key-store-password: ${SSL_KEYSTORE_PASSWORD:changeit}
|
||||
key-store-type: ${SSL_KEYSTORE_TYPE:PKCS12}
|
||||
error:
|
||||
include-message: never
|
||||
include-stacktrace: never
|
||||
|
||||
spring:
|
||||
config:
|
||||
import: optional:file:.env[.properties]
|
||||
application:
|
||||
name: imgfloat
|
||||
devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
livereload:
|
||||
enabled: true
|
||||
thymeleaf:
|
||||
cache: false
|
||||
datasource:
|
||||
url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
hikari:
|
||||
connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"
|
||||
maximum-pool-size: 1
|
||||
minimum-idle: 1
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||
session:
|
||||
store-type: jdbc
|
||||
jdbc:
|
||||
initialize-schema: always
|
||||
platform: sqlite
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
twitch:
|
||||
client-id: ${TWITCH_CLIENT_ID}
|
||||
client-secret: ${TWITCH_CLIENT_SECRET}
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
|
||||
authorization-grant-type: authorization_code
|
||||
scope: ["user:read:email", "moderation:read"]
|
||||
provider:
|
||||
twitch:
|
||||
authorization-uri: https://id.twitch.tv/oauth2/authorize
|
||||
token-uri: https://id.twitch.tv/oauth2/token
|
||||
user-info-uri: https://api.twitch.tv/helix/users
|
||||
user-name-attribute: login
|
||||
config:
|
||||
import: optional:file:.env[.properties]
|
||||
application:
|
||||
name: imgfloat
|
||||
devtools:
|
||||
restart:
|
||||
enabled: true
|
||||
livereload:
|
||||
enabled: true
|
||||
thymeleaf:
|
||||
cache: false
|
||||
datasource:
|
||||
url: jdbc:sqlite:${IMGFLOAT_DB_PATH}?busy_timeout=5000&journal_mode=WAL
|
||||
driver-class-name: org.sqlite.JDBC
|
||||
hikari:
|
||||
connection-init-sql: "PRAGMA journal_mode=WAL; PRAGMA busy_timeout=5000;"
|
||||
maximum-pool-size: 1
|
||||
minimum-idle: 1
|
||||
jpa:
|
||||
open-in-view: false
|
||||
hibernate:
|
||||
ddl-auto: update
|
||||
database-platform: org.hibernate.community.dialect.SQLiteDialect
|
||||
session:
|
||||
store-type: jdbc
|
||||
jdbc:
|
||||
initialize-schema: always
|
||||
platform: sqlite
|
||||
security:
|
||||
oauth2:
|
||||
client:
|
||||
registration:
|
||||
twitch:
|
||||
client-id: ${TWITCH_CLIENT_ID}
|
||||
client-secret: ${TWITCH_CLIENT_SECRET}
|
||||
client-authentication-method: client_secret_post
|
||||
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
|
||||
authorization-grant-type: authorization_code
|
||||
scope: ["user:read:email", "moderation:read"]
|
||||
provider:
|
||||
twitch:
|
||||
authorization-uri: https://id.twitch.tv/oauth2/authorize
|
||||
token-uri: https://id.twitch.tv/oauth2/token
|
||||
user-info-uri: https://api.twitch.tv/helix/users
|
||||
user-name-attribute: login
|
||||
|
||||
management:
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
endpoints:
|
||||
web:
|
||||
exposure:
|
||||
include: health,info
|
||||
|
||||
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
@@ -12,7 +12,7 @@
|
||||
const persistDismissal = () => {
|
||||
try {
|
||||
window.localStorage.setItem(CONSENT_STORAGE_KEY, "true");
|
||||
} catch { }
|
||||
} catch {}
|
||||
document.cookie = `${CONSENT_STORAGE_KEY}=true; max-age=${COOKIE_MAX_AGE_SECONDS}; path=/; SameSite=Lax`;
|
||||
};
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
if (window.localStorage.getItem(CONSENT_STORAGE_KEY) === "true") {
|
||||
return true;
|
||||
}
|
||||
} catch { }
|
||||
} catch {}
|
||||
return readConsentCookie() === "true";
|
||||
};
|
||||
|
||||
|
||||
@@ -1,228 +1,228 @@
|
||||
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";
|
||||
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();
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
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";
|
||||
subtitle.textContent = `@${admin.login}`;
|
||||
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";
|
||||
subtitle.textContent = `@${admin.login}`;
|
||||
|
||||
details.appendChild(title);
|
||||
details.appendChild(subtitle);
|
||||
identity.appendChild(avatar);
|
||||
identity.appendChild(details);
|
||||
return identity;
|
||||
details.appendChild(title);
|
||||
details.appendChild(subtitle);
|
||||
identity.appendChild(avatar);
|
||||
identity.appendChild(details);
|
||||
return identity;
|
||||
}
|
||||
|
||||
function renderAdmins(list) {
|
||||
const adminList = document.getElementById("admin-list");
|
||||
if (!adminList) return;
|
||||
adminList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.textContent = "No channel admins yet";
|
||||
adminList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
const adminList = document.getElementById("admin-list");
|
||||
if (!adminList) return;
|
||||
adminList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
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";
|
||||
list.forEach((admin) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "stacked-list-item";
|
||||
|
||||
li.appendChild(buildIdentity(admin));
|
||||
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);
|
||||
adminList.appendChild(li);
|
||||
});
|
||||
actions.appendChild(removeBtn);
|
||||
li.appendChild(actions);
|
||||
adminList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function renderSuggestedAdmins(list) {
|
||||
const suggestionList = document.getElementById("admin-suggestions");
|
||||
if (!suggestionList) return;
|
||||
const suggestionList = document.getElementById("admin-suggestions");
|
||||
if (!suggestionList) return;
|
||||
|
||||
suggestionList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
const empty = document.createElement("li");
|
||||
empty.className = "stacked-list-item";
|
||||
empty.textContent = "No moderator suggestions right now";
|
||||
suggestionList.appendChild(empty);
|
||||
return;
|
||||
}
|
||||
suggestionList.innerHTML = "";
|
||||
if (!list || list.length === 0) {
|
||||
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";
|
||||
list.forEach((admin) => {
|
||||
const li = document.createElement("li");
|
||||
li.className = "stacked-list-item";
|
||||
|
||||
li.appendChild(buildIdentity(admin));
|
||||
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);
|
||||
suggestionList.appendChild(li);
|
||||
});
|
||||
actions.appendChild(addBtn);
|
||||
li.appendChild(actions);
|
||||
suggestionList.appendChild(li);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchSuggestedAdmins() {
|
||||
fetch(`/api/channels/${broadcaster}/admins/suggestions`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load admin suggestions");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderSuggestedAdmins)
|
||||
.catch(() => {
|
||||
renderSuggestedAdmins([]);
|
||||
});
|
||||
fetch(`/api/channels/${broadcaster}/admins/suggestions`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load admin suggestions");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then(renderSuggestedAdmins)
|
||||
.catch(() => {
|
||||
renderSuggestedAdmins([]);
|
||||
});
|
||||
}
|
||||
|
||||
function fetchAdmins() {
|
||||
fetch(`/api/channels/${broadcaster}/admins`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
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");
|
||||
});
|
||||
fetch(`/api/channels/${broadcaster}/admins`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
function removeAdmin(username) {
|
||||
if (!username) return;
|
||||
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
fetchAdmins();
|
||||
fetchSuggestedAdmins();
|
||||
if (!username) return;
|
||||
fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, {
|
||||
method: "DELETE",
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Failed to remove admin. Please retry.", "error");
|
||||
});
|
||||
.then((response) => {
|
||||
if (!response.ok) {
|
||||
throw new Error();
|
||||
}
|
||||
fetchAdmins();
|
||||
fetchSuggestedAdmins();
|
||||
})
|
||||
.catch(() => {
|
||||
showToast("Failed to remove admin. Please retry.", "error");
|
||||
});
|
||||
}
|
||||
|
||||
function addAdmin(usernameFromAction) {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
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");
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/channels/${broadcaster}/admins`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
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();
|
||||
fetch(`/api/channels/${broadcaster}/admins`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username }),
|
||||
})
|
||||
.catch(() => showToast("Unable to add admin right now. Please try again.", "error"));
|
||||
.then((response) => {
|
||||
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) {
|
||||
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);
|
||||
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);
|
||||
}
|
||||
|
||||
function fetchCanvasSettings() {
|
||||
fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
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");
|
||||
});
|
||||
fetch(`/api/channels/${broadcaster}/canvas`)
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
function saveCanvasSettings() {
|
||||
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");
|
||||
return;
|
||||
}
|
||||
if (status) status.textContent = "Saving...";
|
||||
fetch(`/api/channels/${broadcaster}/canvas`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width, height }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to save canvas");
|
||||
}
|
||||
return r.json();
|
||||
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");
|
||||
return;
|
||||
}
|
||||
if (status) status.textContent = "Saving...";
|
||||
fetch(`/api/channels/${broadcaster}/canvas`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ width, height }),
|
||||
})
|
||||
.then((settings) => {
|
||||
renderCanvasSettings(settings);
|
||||
if (status) status.textContent = "Saved.";
|
||||
showToast("Canvas size saved successfully.", "success");
|
||||
setTimeout(() => {
|
||||
if (status) status.textContent = "";
|
||||
}, 2000);
|
||||
})
|
||||
.catch(() => {
|
||||
if (status) status.textContent = "Unable to save right now.";
|
||||
showToast("Unable to save canvas size. Please retry.", "error");
|
||||
});
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
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");
|
||||
setTimeout(() => {
|
||||
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();
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
function detectPlatform() {
|
||||
const navigatorPlatform = (navigator.userAgentData?.platform || navigator.platform || "").toLowerCase();
|
||||
const userAgent = (navigator.userAgent || "").toLowerCase();
|
||||
const platformString = `${navigatorPlatform} ${userAgent}`;
|
||||
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("win")) {
|
||||
return "windows";
|
||||
}
|
||||
if (platformString.includes("linux")) {
|
||||
return "linux";
|
||||
}
|
||||
return null;
|
||||
if (platformString.includes("mac") || platformString.includes("darwin")) {
|
||||
return "mac";
|
||||
}
|
||||
if (platformString.includes("win")) {
|
||||
return "windows";
|
||||
}
|
||||
if (platformString.includes("linux")) {
|
||||
return "linux";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function markRecommendedDownload(section) {
|
||||
const cards = Array.from(section.querySelectorAll(".download-card"));
|
||||
if (!cards.length) {
|
||||
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 cards = Array.from(section.querySelectorAll(".download-card"));
|
||||
if (!cards.length) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const downloadSections = document.querySelectorAll(".download-section, .download-card-block");
|
||||
downloadSections.forEach(markRecommendedDownload);
|
||||
const downloadSections = document.querySelectorAll(".download-section, .download-card-block");
|
||||
downloadSections.forEach(markRecommendedDownload);
|
||||
});
|
||||
|
||||
@@ -1,54 +1,54 @@
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const searchForm = document.getElementById("channel-search-form");
|
||||
const searchInput = document.getElementById("channel-search");
|
||||
const suggestions = document.getElementById("channel-suggestions");
|
||||
const searchForm = document.getElementById("channel-search-form");
|
||||
const searchInput = document.getElementById("channel-search");
|
||||
const suggestions = document.getElementById("channel-suggestions");
|
||||
|
||||
if (!searchForm || !searchInput || !suggestions) {
|
||||
console.error("Required elements not found in the DOM");
|
||||
return;
|
||||
}
|
||||
if (!searchForm || !searchInput || !suggestions) {
|
||||
console.error("Required elements not found in the DOM");
|
||||
return;
|
||||
}
|
||||
|
||||
let channels = [];
|
||||
let channels = [];
|
||||
|
||||
function updateSuggestions(term) {
|
||||
const normalizedTerm = term.trim().toLowerCase();
|
||||
const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
|
||||
function updateSuggestions(term) {
|
||||
const normalizedTerm = term.trim().toLowerCase();
|
||||
const filtered = channels.filter((name) => !normalizedTerm || name.includes(normalizedTerm)).slice(0, 20);
|
||||
|
||||
suggestions.innerHTML = "";
|
||||
filtered.forEach((name) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
suggestions.appendChild(option);
|
||||
suggestions.innerHTML = "";
|
||||
filtered.forEach((name) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = name;
|
||||
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`;
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
loadChannels();
|
||||
});
|
||||
|
||||
@@ -19,130 +19,130 @@ const currentSettings = JSON.parse(serverRenderedSettings);
|
||||
let userSettings = { ...currentSettings };
|
||||
|
||||
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) {
|
||||
return false;
|
||||
}
|
||||
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
const keysA = Object.keys(a);
|
||||
const keysB = Object.keys(b);
|
||||
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
if (keysA.length !== keysB.length) return false;
|
||||
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false;
|
||||
if (!jsonEquals(a[key], b[key])) return false;
|
||||
}
|
||||
for (const key of keysA) {
|
||||
if (!keysB.includes(key)) return false;
|
||||
if (!jsonEquals(a[key], b[key])) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
return true;
|
||||
}
|
||||
|
||||
function setFormSettings(s) {
|
||||
canvasFpsElement.value = s.canvasFramesPerSecond;
|
||||
canvasSizeElement.value = s.maxCanvasSideLengthPixels;
|
||||
canvasFpsElement.value = s.canvasFramesPerSecond;
|
||||
canvasSizeElement.value = s.maxCanvasSideLengthPixels;
|
||||
|
||||
minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction;
|
||||
maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction;
|
||||
minPitchElement.value = s.minAssetAudioPitchFraction;
|
||||
maxPitchElement.value = s.maxAssetAudioPitchFraction;
|
||||
minVolumeElement.value = s.minAssetVolumeFraction;
|
||||
maxVolumeElement.value = s.maxAssetVolumeFraction;
|
||||
minPlaybackSpeedElement.value = s.minAssetPlaybackSpeedFraction;
|
||||
maxPlaybackSpeedElement.value = s.maxAssetPlaybackSpeedFraction;
|
||||
minPitchElement.value = s.minAssetAudioPitchFraction;
|
||||
maxPitchElement.value = s.maxAssetAudioPitchFraction;
|
||||
minVolumeElement.value = s.minAssetVolumeFraction;
|
||||
maxVolumeElement.value = s.maxAssetVolumeFraction;
|
||||
}
|
||||
|
||||
function updateStatCards(settings) {
|
||||
if (!settings) return;
|
||||
statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`;
|
||||
statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`;
|
||||
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
|
||||
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
|
||||
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`;
|
||||
if (!settings) return;
|
||||
statCanvasFpsElement.textContent = `${settings.canvasFramesPerSecond ?? "--"} fps`;
|
||||
statCanvasSizeElement.textContent = `${settings.maxCanvasSideLengthPixels ?? "--"} px`;
|
||||
statPlaybackRangeElement.textContent = `${settings.minAssetPlaybackSpeedFraction ?? "--"} – ${settings.maxAssetPlaybackSpeedFraction ?? "--"}x`;
|
||||
statAudioRangeElement.textContent = `${settings.minAssetAudioPitchFraction ?? "--"} – ${settings.maxAssetAudioPitchFraction ?? "--"}x`;
|
||||
statVolumeRangeElement.textContent = `${settings.minAssetVolumeFraction ?? "--"} – ${settings.maxAssetVolumeFraction ?? "--"}x`;
|
||||
}
|
||||
|
||||
function readInt(input) {
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
}
|
||||
|
||||
function readFloat(input) {
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
return input.checkValidity() ? Number(input.value) : null;
|
||||
}
|
||||
|
||||
function loadUserSettingsFromDom() {
|
||||
userSettings.canvasFramesPerSecond = readInt(canvasFpsElement);
|
||||
userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement);
|
||||
userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement);
|
||||
userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement);
|
||||
userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement);
|
||||
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
|
||||
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
|
||||
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
|
||||
userSettings.canvasFramesPerSecond = readInt(canvasFpsElement);
|
||||
userSettings.maxCanvasSideLengthPixels = readInt(canvasSizeElement);
|
||||
userSettings.minAssetPlaybackSpeedFraction = readFloat(minPlaybackSpeedElement);
|
||||
userSettings.maxAssetPlaybackSpeedFraction = readFloat(maxPlaybackSpeedElement);
|
||||
userSettings.minAssetAudioPitchFraction = readFloat(minPitchElement);
|
||||
userSettings.maxAssetAudioPitchFraction = readFloat(maxPitchElement);
|
||||
userSettings.minAssetVolumeFraction = readFloat(minVolumeElement);
|
||||
userSettings.maxAssetVolumeFraction = readFloat(maxVolumeElement);
|
||||
}
|
||||
|
||||
function updateSubmitButtonDisabledState() {
|
||||
if (jsonEquals(currentSettings, userSettings)) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "No changes yet.";
|
||||
statusElement.classList.remove("status-success", "status-warning");
|
||||
return;
|
||||
}
|
||||
if (!formElement.checkValidity()) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "Fix highlighted fields.";
|
||||
statusElement.classList.add("status-warning");
|
||||
statusElement.classList.remove("status-success");
|
||||
return;
|
||||
}
|
||||
submitButtonElement.disabled = null;
|
||||
statusElement.textContent = "Ready to save.";
|
||||
statusElement.classList.remove("status-warning");
|
||||
if (jsonEquals(currentSettings, userSettings)) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "No changes yet.";
|
||||
statusElement.classList.remove("status-success", "status-warning");
|
||||
return;
|
||||
}
|
||||
if (!formElement.checkValidity()) {
|
||||
submitButtonElement.disabled = "disabled";
|
||||
statusElement.textContent = "Fix highlighted fields.";
|
||||
statusElement.classList.add("status-warning");
|
||||
statusElement.classList.remove("status-success");
|
||||
return;
|
||||
}
|
||||
submitButtonElement.disabled = null;
|
||||
statusElement.textContent = "Ready to save.";
|
||||
statusElement.classList.remove("status-warning");
|
||||
}
|
||||
|
||||
function submitSettingsForm() {
|
||||
if (submitButtonElement.getAttribute("disabled") != null) {
|
||||
console.warn("Attempted to submit invalid form");
|
||||
showToast("Settings not valid", "warning");
|
||||
return;
|
||||
}
|
||||
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) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load canvas");
|
||||
}
|
||||
return r.json();
|
||||
if (submitButtonElement.getAttribute("disabled") != null) {
|
||||
console.warn("Attempted to submit invalid form");
|
||||
showToast("Settings not valid", "warning");
|
||||
return;
|
||||
}
|
||||
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((newSettings) => {
|
||||
currentSettings = { ...newSettings };
|
||||
userSettings = { ...newSettings };
|
||||
updateStatCards(newSettings);
|
||||
showToast("Settings saved", "success");
|
||||
statusElement.textContent = "Saved.";
|
||||
statusElement.classList.add("status-success");
|
||||
updateSubmitButtonDisabledState();
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast("Unable to save settings", "error");
|
||||
console.error(error);
|
||||
statusElement.textContent = "Save failed. Try again.";
|
||||
statusElement.classList.add("status-warning");
|
||||
});
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error("Failed to load canvas");
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((newSettings) => {
|
||||
currentSettings = { ...newSettings };
|
||||
userSettings = { ...newSettings };
|
||||
updateStatCards(newSettings);
|
||||
showToast("Settings saved", "success");
|
||||
statusElement.textContent = "Saved.";
|
||||
statusElement.classList.add("status-success");
|
||||
updateSubmitButtonDisabledState();
|
||||
})
|
||||
.catch((error) => {
|
||||
showToast("Unable to save settings", "error");
|
||||
console.error(error);
|
||||
statusElement.textContent = "Save failed. Try again.";
|
||||
statusElement.classList.add("status-warning");
|
||||
});
|
||||
}
|
||||
|
||||
formElement.querySelectorAll("input").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
loadUserSettingsFromDom();
|
||||
updateSubmitButtonDisabledState();
|
||||
});
|
||||
input.addEventListener("input", () => {
|
||||
loadUserSettingsFromDom();
|
||||
updateSubmitButtonDisabledState();
|
||||
});
|
||||
});
|
||||
|
||||
formElement.addEventListener("submit", (event) => {
|
||||
event.preventDefault();
|
||||
submitSettingsForm();
|
||||
event.preventDefault();
|
||||
submitSettingsForm();
|
||||
});
|
||||
|
||||
setFormSettings(currentSettings);
|
||||
|
||||
@@ -1,51 +1,51 @@
|
||||
(function () {
|
||||
const CONTAINER_ID = "toast-container";
|
||||
const DEFAULT_DURATION = 4200;
|
||||
const CONTAINER_ID = "toast-container";
|
||||
const DEFAULT_DURATION = 4200;
|
||||
|
||||
function ensureContainer() {
|
||||
let container = document.getElementById(CONTAINER_ID);
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = "toast-container";
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("aria-atomic", "true");
|
||||
document.body.appendChild(container);
|
||||
function ensureContainer() {
|
||||
let container = document.getElementById(CONTAINER_ID);
|
||||
if (!container) {
|
||||
container = document.createElement("div");
|
||||
container.id = CONTAINER_ID;
|
||||
container.className = "toast-container";
|
||||
container.setAttribute("aria-live", "polite");
|
||||
container.setAttribute("aria-atomic", "true");
|
||||
document.body.appendChild(container);
|
||||
}
|
||||
return container;
|
||||
}
|
||||
return container;
|
||||
}
|
||||
|
||||
function buildToast(message, type) {
|
||||
const toast = document.createElement("div");
|
||||
toast.className = `toast toast-${type}`;
|
||||
function buildToast(message, type) {
|
||||
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";
|
||||
content.textContent = message;
|
||||
const content = document.createElement("div");
|
||||
content.className = "toast-message";
|
||||
content.textContent = message;
|
||||
|
||||
toast.appendChild(indicator);
|
||||
toast.appendChild(content);
|
||||
return toast;
|
||||
}
|
||||
toast.appendChild(indicator);
|
||||
toast.appendChild(content);
|
||||
return toast;
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
if (!toast) return;
|
||||
toast.classList.add("toast-exit");
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}
|
||||
function removeToast(toast) {
|
||||
if (!toast) return;
|
||||
toast.classList.add("toast-exit");
|
||||
setTimeout(() => toast.remove(), 250);
|
||||
}
|
||||
|
||||
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 container = ensureContainer();
|
||||
const toast = buildToast(message, normalized);
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => removeToast(toast), Math.max(1200, duration));
|
||||
toast.addEventListener("click", () => removeToast(toast));
|
||||
};
|
||||
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 container = ensureContainer();
|
||||
const toast = buildToast(message, normalized);
|
||||
container.appendChild(toast);
|
||||
setTimeout(() => removeToast(toast), Math.max(1200, duration));
|
||||
toast.addEventListener("click", () => removeToast(toast));
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -1,308 +1,358 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<body class="admin-body">
|
||||
<div class="admin-frame">
|
||||
<header class="admin-topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="admin-identity">
|
||||
<p class="eyebrow subtle">CHANNEL ADMIN</p>
|
||||
<h1 th:text="${broadcaster}"></h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="header-actions horizontal">
|
||||
<a class="icon-button" th:href="@{/}" title="Back to dashboard">
|
||||
<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
|
||||
>
|
||||
</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)"
|
||||
/>
|
||||
<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 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 id="asset-controls-placeholder" class="asset-controls-placeholder">
|
||||
<div id="asset-controls" class="hidden asset-settings">
|
||||
<div class="panel-section" id="layout-section">
|
||||
<div class="section-header">
|
||||
<h5>Layout & order</h5>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<body class="admin-body">
|
||||
<div class="admin-frame">
|
||||
<header class="admin-topbar">
|
||||
<div class="topbar-left">
|
||||
<div class="admin-identity">
|
||||
<p class="eyebrow subtle">CHANNEL ADMIN</p>
|
||||
<h1 th:text="${broadcaster}"></h1>
|
||||
</div>
|
||||
<div class="property-list">
|
||||
<div class="property-row">
|
||||
<span class="property-label">Width</span>
|
||||
<input id="asset-width" class="number-input property-control" type="number" min="10" step="5" />
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Height</span>
|
||||
</div>
|
||||
<div class="header-actions horizontal">
|
||||
<a class="icon-button" th:href="@{/}" title="Back to dashboard">
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="admin-workspace">
|
||||
<aside class="admin-rail">
|
||||
<div class="upload-row">
|
||||
<input
|
||||
id="asset-height"
|
||||
class="number-input property-control"
|
||||
type="number"
|
||||
min="10"
|
||||
step="5"
|
||||
id="asset-file"
|
||||
class="file-input-field"
|
||||
type="file"
|
||||
accept="image/*,video/*,audio/*"
|
||||
onchange="handleFileSelection(this)"
|
||||
/>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Maintain AR</span>
|
||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||
<input id="maintain-aspect" type="checkbox" checked />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
<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="property-row">
|
||||
<span class="property-label">Layer</span>
|
||||
<div class="property-control">
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge">Layer <strong id="asset-z-level">1</strong></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rail-body">
|
||||
<div class="rail-scroll">
|
||||
<ul id="asset-list" class="asset-list"></ul>
|
||||
</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 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 id="asset-controls-placeholder" class="asset-controls-placeholder">
|
||||
<div id="asset-controls" class="hidden asset-settings">
|
||||
<div class="panel-section" id="layout-section">
|
||||
<div class="section-header">
|
||||
<h5>Layout & order</h5>
|
||||
</div>
|
||||
<div class="property-list">
|
||||
<div class="property-row">
|
||||
<span class="property-label">Width</span>
|
||||
<input
|
||||
id="asset-width"
|
||||
class="number-input property-control"
|
||||
type="number"
|
||||
min="10"
|
||||
step="5"
|
||||
/>
|
||||
</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"
|
||||
/>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Maintain AR</span>
|
||||
<label class="checkbox-inline toggle inline-toggle property-control">
|
||||
<input id="maintain-aspect" type="checkbox" checked />
|
||||
<span class="toggle-track" aria-hidden="true">
|
||||
<span class="toggle-thumb"></span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="property-row">
|
||||
<span class="property-label">Layer</span>
|
||||
<div class="property-control">
|
||||
<div class="badge-row stacked">
|
||||
<span class="badge"
|
||||
>Layer <strong id="asset-z-level">1</strong></span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</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" 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 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>
|
||||
</aside>
|
||||
<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>
|
||||
|
||||
<section class="canvas-stack">
|
||||
<div class="canvas-topbar">
|
||||
<div>
|
||||
<p class="eyebrow subtle">Canvas</p>
|
||||
<h3 class="panel-title">Live composition</h3>
|
||||
<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>
|
||||
</aside>
|
||||
|
||||
<section class="canvas-stack">
|
||||
<div class="canvas-topbar">
|
||||
<div>
|
||||
<p class="eyebrow subtle">Canvas</p>
|
||||
<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 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>
|
||||
</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 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>
|
||||
</div>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||
const username = /*[[${username}]]*/ '';
|
||||
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
|
||||
const SETTINGS = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</div>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||
const username = /*[[${username}]]*/ '';
|
||||
const UPLOAD_LIMIT_BYTES = /*[[${uploadLimitBytes}]]*/ 0;
|
||||
const SETTINGS = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Broadcast</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="broadcast-body">
|
||||
<canvas id="broadcast-canvas"></canvas>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/broadcast.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Broadcast</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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/stompjs@2.3.3/lib/stomp.min.js"></script>
|
||||
</head>
|
||||
<body class="broadcast-body">
|
||||
<canvas id="broadcast-canvas"></canvas>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${broadcaster}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/broadcast.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Browse channels - Imgfloat</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="channels-body">
|
||||
<div class="channels-shell">
|
||||
<header class="channels-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Browse channels - Imgfloat</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="channels-body">
|
||||
<div class="channels-shell">
|
||||
<header class="channels-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="channels-main">
|
||||
<section class="channel-card">
|
||||
<p class="eyebrow subtle">Broadcast overlay</p>
|
||||
<h1>Open a channel</h1>
|
||||
<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"
|
||||
/>
|
||||
<datalist id="channel-suggestions"></datalist>
|
||||
<button type="submit" class="button block">Open overlay</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/landing.js"></script>
|
||||
</body>
|
||||
<main class="channels-main">
|
||||
<section class="channel-card">
|
||||
<p class="eyebrow subtle">Broadcast overlay</p>
|
||||
<h1>Open a channel</h1>
|
||||
<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"
|
||||
/>
|
||||
<datalist id="channel-suggestions"></datalist>
|
||||
<button type="submit" class="button block">Open overlay</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/landing.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,119 +1,119 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Dashboard</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<div class="dashboard-shell">
|
||||
<header class="dashboard-topbar">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png"/>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-pill">
|
||||
<span class="eyebrow subtle">Signed in as</span>
|
||||
<span class="user-display" th:text="${username}">user</span>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Dashboard</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<div class="dashboard-shell">
|
||||
<header class="dashboard-topbar">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png" />
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-pill">
|
||||
<span class="eyebrow subtle">Signed in as</span>
|
||||
<span class="user-display" th:text="${username}">user</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">Navigation</p>
|
||||
<h3>Shortcuts</h3>
|
||||
<p class="muted">Jump into your overlay</p>
|
||||
<div class="panel-actions">
|
||||
<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" href="/channels">Browse channels</a>
|
||||
<form class="block" th:action="@{/logout}" method="post">
|
||||
<button class="secondary block" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<p class="eyebrow">Navigation</p>
|
||||
<h3>Shortcuts</h3>
|
||||
<p class="muted">Jump into your overlay</p>
|
||||
<div class="panel-actions">
|
||||
<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" href="/channels">Browse channels</a>
|
||||
<form class="block" th:action="@{/logout}" method="post">
|
||||
<button class="secondary block" type="submit">Logout</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h3>Overlay dimensions</h3>
|
||||
<p class="muted">Match these with your OBS resolution.</p>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Width
|
||||
<input id="canvas-width" type="number" min="100" step="10" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input id="canvas-height" type="number" min="100" step="10" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
|
||||
<span id="canvas-status" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
<section class="card">
|
||||
<p class="eyebrow">Settings</p>
|
||||
<h3>Overlay dimensions</h3>
|
||||
<p class="muted">Match these with your OBS resolution.</p>
|
||||
<div class="control-grid">
|
||||
<label>
|
||||
Width
|
||||
<input id="canvas-width" type="number" min="100" step="10" />
|
||||
</label>
|
||||
<label>
|
||||
Height
|
||||
<input id="canvas-height" type="number" min="100" step="10" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="control-actions">
|
||||
<button type="button" onclick="saveCanvasSettings()">Save canvas size</button>
|
||||
<span id="canvas-status" class="muted"></span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card-grid two-col">
|
||||
<div class="card">
|
||||
<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 class="card-grid two-col">
|
||||
<div class="card">
|
||||
<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>
|
||||
<p class="eyebrow">Your access</p>
|
||||
<h3>Channels you administer</h3>
|
||||
<p class="muted">Jump into a teammate's overlay console.</p>
|
||||
</div>
|
||||
</div>
|
||||
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
|
||||
<ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list">
|
||||
<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 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>
|
||||
<p th:if="${#lists.isEmpty(adminChannels)}">No admin invitations yet.</p>
|
||||
<ul th:if="${!#lists.isEmpty(adminChannels)}" class="stacked-list">
|
||||
<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" th:insert="fragments/downloads :: downloads"></section>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${channel}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
</body>
|
||||
<section class="card download-card-block" th:insert="fragments/downloads :: downloads"></section>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
<script th:inline="javascript">
|
||||
const broadcaster = /*[[${channel}]]*/ "";
|
||||
</script>
|
||||
<script src="/js/dashboard.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,44 +1,44 @@
|
||||
<th:block th:fragment="downloads">
|
||||
<div class="download-header">
|
||||
<p class="eyebrow">Desktop app</p>
|
||||
<h2>Download Imgfloat</h2>
|
||||
</div>
|
||||
<div class="download-grid">
|
||||
<div class="download-card" data-platform="mac">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">macOS</p>
|
||||
<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 class="download-header">
|
||||
<p class="eyebrow">Desktop app</p>
|
||||
<h2>Download Imgfloat</h2>
|
||||
</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>
|
||||
<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 class="download-grid">
|
||||
<div class="download-card" data-platform="mac">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">macOS</p>
|
||||
<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 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>
|
||||
<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="linux">
|
||||
<div class="download-card-header">
|
||||
<p class="eyebrow">Linux</p>
|
||||
<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
|
||||
>
|
||||
</div>
|
||||
</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>
|
||||
<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>
|
||||
</th:block>
|
||||
|
||||
@@ -1,48 +1,50 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat - Twitch overlay</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="landing-body">
|
||||
<div class="landing">
|
||||
<header class="landing-header">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png"/>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat - Twitch overlay</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<link rel="stylesheet" href="/css/styles.css" />
|
||||
</head>
|
||||
<body class="landing-body">
|
||||
<div class="landing">
|
||||
<header class="landing-header">
|
||||
<div class="brand">
|
||||
<img class="brand-mark" src="/img/brand.png" />
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="hero hero-compact">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Overlay toolkit</p>
|
||||
<h1>Collaborative real-time Twitch overlay</h1>
|
||||
<p class="lead">Customize your Twitch stream with audio, video and images updated by your mods in real-time</p>
|
||||
<div class="cta-row">
|
||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<main class="hero hero-compact">
|
||||
<div class="hero-text">
|
||||
<p class="eyebrow">Overlay toolkit</p>
|
||||
<h1>Collaborative real-time Twitch overlay</h1>
|
||||
<p class="lead">
|
||||
Customize your Twitch stream with audio, video and images updated by your mods in real-time
|
||||
</p>
|
||||
<div class="cta-row">
|
||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<section class="download-section" th:insert="fragments/downloads :: downloads"></section>
|
||||
<section class="download-section" th:insert="fragments/downloads :: downloads"></section>
|
||||
|
||||
<footer class="landing-meta">
|
||||
<div class="build-chip">
|
||||
<span class="muted">License</span>
|
||||
<span class="version-badge">MIT</span>
|
||||
<footer class="landing-meta">
|
||||
<div class="build-chip">
|
||||
<span class="muted">License</span>
|
||||
<span class="version-badge">MIT</span>
|
||||
</div>
|
||||
<div class="build-chip">
|
||||
<span class="muted">Build</span>
|
||||
<span class="version-badge" th:text="${version}">unknown</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<div class="build-chip">
|
||||
<span class="muted">Build</span>
|
||||
<span class="version-badge" th:text="${version}">unknown</span>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
</body>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/downloads.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,256 +1,269 @@
|
||||
<!doctype html>
|
||||
<html lang="en" xmlns:th="http://www.thymeleaf.org">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<body class="settings-body">
|
||||
<div class="settings-shell">
|
||||
<header class="settings-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Imgfloat Admin</title>
|
||||
<link rel="icon" href="/favicon.ico" />
|
||||
<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"
|
||||
/>
|
||||
<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>
|
||||
<body class="settings-body">
|
||||
<div class="settings-shell">
|
||||
<header class="settings-header">
|
||||
<div class="brand">
|
||||
<div class="brand-mark">IF</div>
|
||||
<div>
|
||||
<div class="brand-title">Imgfloat</div>
|
||||
<div class="brand-subtitle">Twitch overlay manager</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="settings-main">
|
||||
<section class="settings-card settings-hero">
|
||||
<div class="hero-copy">
|
||||
<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 30–60 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>
|
||||
</header>
|
||||
|
||||
<main class="settings-main">
|
||||
<section class="settings-card settings-hero">
|
||||
<div class="hero-copy">
|
||||
<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 30–60 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/cookie-consent.js"></script>
|
||||
<script src="/js/settings.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
</body>
|
||||
<script th:inline="javascript">
|
||||
const serverRenderedSettings = /*[[${settingsJson}]]*/;
|
||||
</script>
|
||||
<script src="/js/cookie-consent.js"></script>
|
||||
<script src="/js/settings.js"></script>
|
||||
<script src="/js/toast.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user