diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java index c59fade..6f927e5 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SchemaMigration.java @@ -29,13 +29,13 @@ public class SchemaMigration implements ApplicationRunner { } private void cleanupSpringSessionTables() { - try { - jdbcTemplate.execute("DELETE FROM SPRING_SESSION_ATTRIBUTES"); - jdbcTemplate.execute("DELETE FROM SPRING_SESSION"); - logger.info("Cleared persisted Spring Session tables on startup to avoid stale session conflicts"); - } catch (DataAccessException ex) { - logger.debug("Spring Session tables not available for cleanup", ex); - } + // try { + // jdbcTemplate.execute("DELETE FROM SPRING_SESSION_ATTRIBUTES"); + // jdbcTemplate.execute("DELETE FROM SPRING_SESSION"); + // logger.info("Cleared persisted Spring Session tables on startup to avoid stale session conflicts"); + // } catch (DataAccessException ex) { + // logger.debug("Spring Session tables not available for cleanup", ex); + // } } private void ensureChannelCanvasColumns() { diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java index ca0de07..0d3205c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SecurityConfig.java @@ -20,32 +20,31 @@ public class SecurityConfig { @Bean SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .authorizeHttpRequests(auth -> auth - .requestMatchers( - "/", - "/css/**", - "/js/**", - "/webjars/**", - "/actuator/health", - "/v3/api-docs/**", - "/swagger-ui.html", - "/swagger-ui/**", - "/channels" - ).permitAll() - .requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll() - .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() - .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll() - .requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll() - .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll() - .requestMatchers("/ws/**").permitAll() - .anyRequest().authenticated() - ) - .oauth2Login(oauth -> oauth - .tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) - .userInfoEndpoint(user -> user.userService(twitchOAuth2UserService())) - ) - .logout(logout -> logout.logoutSuccessUrl("/").permitAll()) - .csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/", + "/css/**", + "/js/**", + "/webjars/**", + "/actuator/health", + "/v3/api-docs/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/channels" + ).permitAll() + .requestMatchers(HttpMethod.GET, "/view/*/broadcast").permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels").permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/visible").permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels/*/canvas").permitAll() + .requestMatchers(HttpMethod.GET, "/api/channels/*/assets/*/content").permitAll() + .requestMatchers("/ws/**").permitAll() + .anyRequest().authenticated() + ) + .oauth2Login(oauth -> oauth + .tokenEndpoint(token -> token.accessTokenResponseClient(twitchAccessTokenResponseClient())) + .userInfoEndpoint(user -> user.userService(twitchOAuth2UserService()))) + .logout(logout -> logout.logoutSuccessUrl("/").permitAll()) + .csrf(csrf -> csrf.ignoringRequestMatchers("/ws/**", "/api/**")); return http.build(); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java index ebb39c1..4791c0e 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/SystemEnvironmentValidator.java @@ -55,14 +55,11 @@ public class SystemEnvironmentValidator { ); } - log.info("Environment validation successful."); - log.info("Configuration:"); + log.info("Environment validation successful:"); log.info(" - TWITCH_CLIENT_ID: {}", redact(twitchClientId)); log.info(" - TWITCH_CLIENT_SECRET: {}", redact(twitchClientSecret)); - log.info(" - SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {} ({} bytes)", - springMaxFileSize, maxUploadBytes); - log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)", - springMaxRequestSize, maxRequestBytes); + log.info(" - SPRING_SERVLET_MULTIPART_MAX_FILE_SIZE: {} ({} bytes)", springMaxFileSize, maxUploadBytes); + log.info(" - SPRING_SERVLET_MULTIPART_MAX_REQUEST_SIZE: {} ({} bytes)", springMaxRequestSize, maxRequestBytes); log.info(" - IMGFLOAT_DB_PATH: {}", dbPath); log.info(" - IMGFLOAT_INITIAL_TWITCH_USERNAME_SYSADMIN: {}", initialSysadmin); log.info(" - IMGFLOAT_ASSETS_PATH: {}", assetsPath); @@ -70,20 +67,23 @@ public class SystemEnvironmentValidator { } private void checkString(String value, String name, StringBuilder missing) { - if (!StringUtils.hasText(value) || "changeme".equalsIgnoreCase(value.trim())) { - missing.append(" - ").append(name).append("\n"); + if (value != null && StringUtils.hasText(value)) { + return } + missing.append(" - ").append(name).append("\n"); } private void checkUnsignedNumeric(T value, String name, StringBuilder missing) { - if (value == null || value.doubleValue() <= 0) { - missing.append(" - ").append(name).append('\n'); + if (value !== null && value.doubleValue() >= 0) { + return; } + missing.append(" - ").append(name).append('\n'); } private String redact(String value) { - if (!StringUtils.hasText(value)) return "(missing)"; - if (value.length() <= 6) return "******"; - return value.substring(0, 2) + "****" + value.substring(value.length() - 2); + if (value != null && StringUtils.hasText(value)) { + return "**************"; + }; + return ""; } } diff --git a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java index b8f8f37..38d0163 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java +++ b/src/main/java/dev/kruhlmann/imgfloat/config/TwitchAuthorizationCodeGrantRequestEntityConverter.java @@ -41,10 +41,11 @@ final class TwitchAuthorizationCodeGrantRequestEntityConverter implements } return new RequestEntity<>( - body, - entity.getHeaders(), - entity.getMethod() == null ? HttpMethod.POST : entity.getMethod(), - entity.getUrl() == null ? URI.create(registration.getProviderDetails().getTokenUri()) : entity.getUrl()); + body, + entity.getHeaders(), + entity.getMethod() == null ? HttpMethod.POST : entity.getMethod(), + entity.getUrl() == null ? URI.create(registration.getProviderDetails().getTokenUri()) : entity.getUrl() + ); } private MultiValueMap cloneBody(MultiValueMap existingBody) { diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java index 0f0ca2c..6515904 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ViewController.java @@ -68,6 +68,21 @@ public class ViewController { return "channels"; } + @org.springframework.web.bind.annotation.GetMapping("/settings") + public String settingsView(OAuth2AuthenticationToken oauthToken, Model model) { + String sessionUsername = OauthSessionUser.from(oauthToken).login(); + authorizationService.userIsSystemAdministratorOrThrowHttpError(sessionUsername); + LOG.info("Rendering settings for {}", sessionUsername); + Settings settings = settingsService.get(); + try { + model.addAttribute("settingsJson", objectMapper.writeValueAsString(settings)); + } catch (JsonProcessingException e) { + LOG.error("Failed to serialize settings for settings view", e); + throw new ResponseStatusException(INTERNAL_SERVER_ERROR, "Failed to serialize settings"); + } + return "settings"; + } + @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/admin") public String adminView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, OAuth2AuthenticationToken oauthToken, diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java b/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java index 63b6a43..483846d 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/Asset.java @@ -16,15 +16,16 @@ import java.util.UUID; public class Asset { @Id private String id; - @Column(nullable = false) private String broadcaster; - @Column(nullable = false) private String name; - - @Column(columnDefinition = "TEXT") + @Column(columnDefinition = "TEXT", nullable = false) private String url; + @Column(columnDefinition = "TEXT", nullable = false) + private String preview; + @Column(nullable = false) + private Instant createdAt; private double x; private double y; private double width; @@ -34,8 +35,6 @@ public class Asset { private Boolean muted; private String mediaType; private String originalMediaType; - @Column(columnDefinition = "TEXT") - private String preview; private Integer zIndex; private Boolean audioLoop; private Integer audioDelayMillis; @@ -43,7 +42,6 @@ public class Asset { private Double audioPitch; private Double audioVolume; private boolean hidden; - private Instant createdAt; public Asset() { } @@ -61,7 +59,7 @@ public class Asset { this.speed = 1.0; this.muted = false; this.zIndex = 1; - this.hidden = false; + this.hidden = true; this.createdAt = Instant.now(); } diff --git a/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java b/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java index 10ef66d..fbbbeab 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java +++ b/src/main/java/dev/kruhlmann/imgfloat/model/AssetEvent.java @@ -44,12 +44,13 @@ public class AssetEvent { return event; } - public static AssetEvent visibility(String channel, AssetPatch patch) { + public static AssetEvent visibility(String channel, AssetPatch patch, AssetView asset) { AssetEvent event = new AssetEvent(); event.type = Type.VISIBILITY; event.channel = channel; event.patch = patch; event.assetId = patch.id(); + event.payload = asset; return event; } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 5ac9e08..6f35fd6 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -59,7 +59,7 @@ body { font-size: 13px; } -.channels-body { +.channels-body, .settings-body { min-height: 100vh; background: radial-gradient(circle at 10% 20%, rgba(124, 58, 237, 0.16), transparent 30%), radial-gradient(circle at 85% 10%, rgba(59, 130, 246, 0.18), transparent 28%), @@ -70,14 +70,14 @@ body { padding: clamp(24px, 4vw, 48px); } -.channels-shell { +.channels-shell, .settings-shell { width: min(760px, 100%); display: flex; flex-direction: column; gap: 20px; } -.channels-header { +.channels-header, .settings-header { display: flex; align-items: center; justify-content: space-between; @@ -88,6 +88,23 @@ body { margin: 0; } +.settings-main { + display: flex; + justify-content: center; +} + +.settings-card { + width: 100%; + background: rgba(11, 18, 32, 0.95); + border: 1px solid #1f2937; + border-radius: 16px; + padding: clamp(20px, 3vw, 32px); + box-shadow: 0 25px 60px rgba(0, 0, 0, 0.45); + display: flex; + flex-direction: column; + gap: 10px; +} + .channels-main { display: flex; justify-content: center; @@ -116,6 +133,13 @@ body { margin-top: 6px; } +.settings-form { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 6px; +} + .channels-footer { display: flex; align-items: center; @@ -252,6 +276,19 @@ body { box-shadow: 0 10px 30px rgba(124, 58, 237, 0.25); } +.button:disabled, button:disabled, .button[aria-disabled="true"] { + background: #a78bfa; + color: #e5e7eb; + cursor: not-allowed; + box-shadow: none; + opacity: 0.7; +} + +.button:disabled:hover, +button:disabled:hover { + transform: none; +} + .button.block { width: 100%; } @@ -333,6 +370,18 @@ body { box-shadow: 0 0 0 3px rgba(124, 58, 237, 0.25); } +.text-input:disabled, .text-input[aria-disabled="true"] { + background: #020617; + border-color: #334155; + color: #64748b; + cursor: not-allowed; + box-shadow: none; +} + +.text-input:disabled::placeholder { + color: #475569; +} + .search-form { display: flex; flex-direction: column; diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 1432c8a..161bb45 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -87,11 +87,7 @@ function connect() { return r.json(); }) .then(renderAssets) - .catch(() => { - if (typeof showToast === 'function') { - showToast('Unable to load overlay assets. Retrying may help.', 'error'); - } - }); + .catch(() => showToast('Unable to load overlay assets. Retrying may help.', 'error')); }); } @@ -108,7 +104,7 @@ function storeAsset(asset, placement = 'keep') { } function fetchCanvasSettings() { - return fetch(`/api/channels/${broadcaster}/canvas`) + return fetch(`/api/channels/${encodeURIComponent(broadcaster)}/canvas`) .then((r) => { if (!r.ok) { throw new Error('Failed to load canvas'); @@ -121,9 +117,7 @@ function fetchCanvasSettings() { }) .catch(() => { resizeCanvas(); - if (typeof showToast === 'function') { - showToast('Using default canvas size. Unable to load saved settings.', 'warning'); - } + showToast('Using default canvas size. Unable to load saved settings.', 'warning'); }); } @@ -676,7 +670,7 @@ function setVideoSource(element, asset) { return; } applyVideoSource(element, next.objectUrl, asset); - }).catch(() => {}); + }).catch(() => { }); } function applyVideoSource(element, objectUrl, asset) { diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index f6a591c..cde8d88 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -119,26 +119,22 @@ function fetchAdmins() { .then(renderAdmins) .catch(() => { renderAdmins([]); - if (typeof showToast === 'function') { - showToast('Unable to load admins right now. Please try again.', 'error'); - } + showToast('Unable to load admins right now. Please try again.', 'error'); }); } function removeAdmin(username) { if (!username) return; - fetch(`/api/channels/${broadcaster}/admins/${encodeURIComponent(username)}`, { + fetch(`/api/channels/${encodeURIComponent(broadcaster)}/admins/${encodeURIComponent(username)}`, { method: 'DELETE' }).then((response) => { - if (!response.ok && typeof showToast === 'function') { - showToast('Failed to remove admin. Please retry.', 'error'); + if (!response.ok) { + throw new Error(); } fetchAdmins(); fetchSuggestedAdmins(); }).catch(() => { - if (typeof showToast === 'function') { - showToast('Failed to remove admin. Please retry.', 'error'); - } + showToast('Failed to remove admin. Please retry.', 'error'); }); } @@ -146,9 +142,7 @@ function addAdmin(usernameFromAction) { const input = document.getElementById('new-admin'); const username = (usernameFromAction || input?.value || '').trim(); if (!username) { - if (typeof showToast === 'function') { - showToast('Enter a Twitch username to add as an admin.', 'info'); - } + showToast('Enter a Twitch username to add as an admin.', 'info'); return; } @@ -164,17 +158,11 @@ function addAdmin(usernameFromAction) { if (input) { input.value = ''; } - if (typeof showToast === 'function') { - showToast(`Added @${username} as an admin.`, 'success'); - } + showToast(`Added @${username} as an admin.`, 'success'); fetchAdmins(); fetchSuggestedAdmins(); }) - .catch(() => { - if (typeof showToast === 'function') { - showToast('Unable to add admin right now. Please try again.', 'error'); - } - }); + .catch(() => showToast('Unable to add admin right now. Please try again.', 'error')); } function renderCanvasSettings(settings) { @@ -195,9 +183,7 @@ function fetchCanvasSettings() { .then(renderCanvasSettings) .catch(() => { renderCanvasSettings({ width: 1920, height: 1080 }); - if (typeof showToast === 'function') { - showToast('Using default canvas size. Unable to load saved settings.', 'warning'); - } + showToast('Using default canvas size. Unable to load saved settings.', 'warning'); }); } @@ -208,9 +194,7 @@ function saveCanvasSettings() { const width = parseFloat(widthInput?.value) || 0; const height = parseFloat(heightInput?.value) || 0; if (width <= 0 || height <= 0) { - if (typeof showToast === 'function') { - showToast('Please enter a valid width and height.', 'info'); - } + showToast('Please enter a valid width and height.', 'info'); return; } if (status) status.textContent = 'Saving...'; @@ -228,18 +212,14 @@ function saveCanvasSettings() { .then((settings) => { renderCanvasSettings(settings); if (status) status.textContent = 'Saved.'; - if (typeof showToast === 'function') { - showToast('Canvas size saved successfully.', 'success'); - } + showToast('Canvas size saved successfully.', 'success'); setTimeout(() => { if (status) status.textContent = ''; }, 2000); }) .catch(() => { if (status) status.textContent = 'Unable to save right now.'; - if (typeof showToast === 'function') { - showToast('Unable to save canvas size. Please retry.', 'error'); - } + showToast('Unable to save canvas size. Please retry.', 'error'); }); } diff --git a/src/main/resources/static/js/landing.js b/src/main/resources/static/js/landing.js index 236aa45..d119de2 100644 --- a/src/main/resources/static/js/landing.js +++ b/src/main/resources/static/js/landing.js @@ -4,6 +4,7 @@ document.addEventListener("DOMContentLoaded", () => { const suggestions = document.getElementById("channel-suggestions"); if (!searchForm || !searchInput || !suggestions) { + console.error("Required elements not found in the DOM"); return; } @@ -36,9 +37,7 @@ document.addEventListener("DOMContentLoaded", () => { } } - searchInput.addEventListener("input", (event) => { - updateSuggestions(event.target.value || ""); - }); + searchInput.addEventListener("input", (event) => updateSuggestions(event.target.value || "")); searchForm.addEventListener("submit", (event) => { event.preventDefault(); diff --git a/src/main/resources/static/js/settings.js b/src/main/resources/static/js/settings.js new file mode 100644 index 0000000..df36794 --- /dev/null +++ b/src/main/resources/static/js/settings.js @@ -0,0 +1,108 @@ +const formElement = document.getElementById("settings-form"); +const submitButtonElement = document.getElementById("settings-submit-button"); +const canvasFpsElement = document.getElementById("canvas-fps"); +const canvasSizeElement = document.getElementById("canvas-size"); +const minPlaybackSpeedElement = document.getElementById("min-playback-speed"); +const maxPlaybackSpeedElement = document.getElementById("max-playback-speed"); +const minPitchElement = document.getElementById("min-audio-pitch"); +const maxPitchElement = document.getElementById("max-audio-pitch"); +const minVolumeElement = document.getElementById("min-volume"); +const maxVolumeElement = document.getElementById("max-volume"); + +const currentSettings = JSON.parse(serverRenderedSettings); +let userSettings = { ...currentSettings }; + +function jsonEquals(a, b) { + if (a === b) return true; + + if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) { + return false; + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + 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; + } + + return true; +} + +function setFormSettings(s) { + 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; +} + +function readInt(input) { + return input.checkValidity() ? Number(input.value) : null; +} + +function readFloat(input) { + 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); +} + +function updateSubmitButtonDisabledState() { + if (jsonEquals(currentSettings, userSettings)) { + submitButtonElement.disabled = "disabled"; + return; + } + if (!formElement.checkValidity()) { + submitButtonElement.disabled = "disabled"; + return; + } + submitButtonElement.disabled = null; +} + +function submitSettingsForm() { + if (submitButtonElement.getAttribute("disabled") != null) { + console.warn("Attempted to submit invalid form"); + showToast("Settings not valid", "warning"); + return; + } + fetch("/api/settings/set", { method: "PUT", headers: { 'Content-Type': 'application/json' }, body: userSettings }).then((r) => { + if (!r.ok) { + throw new Error('Failed to load canvas'); + } + return r.json(); + + }) + .then((newSettings) => { + currentSettings = { ...newSettings }; + userSettings = { ...newSettings }; + }) + .catch((error) => { + showToast('Unable to save settings', 'error') + console.error(error); + }); +} + +formElement.querySelectorAll("input").forEach((input) => { + input.addEventListener("input", () => { + loadUserSettingsFromDom(); + updateSubmitButtonDisabledState(); + }); +}); + +setFormSettings(currentSettings); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index 85211e2..0ed17b0 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -28,6 +28,7 @@
Open broadcast overlay Open admin console + Browse channels
diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index b46b099..08e5109 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -24,7 +24,7 @@

Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter.

diff --git a/src/main/resources/templates/settings.html b/src/main/resources/templates/settings.html new file mode 100644 index 0000000..032c33f --- /dev/null +++ b/src/main/resources/templates/settings.html @@ -0,0 +1,118 @@ + + + + + Imgfloat Admin + + + + + + +
+
+
+
IF
+
+
Imgfloat
+
Twitch overlay manager
+
+
+
+ +
+
+

System administrator settings

+
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + + +