From 53410dc235e9fd56be0130afe1f7e4539ae0dd5c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 10 Dec 2025 14:01:59 +0100 Subject: [PATCH] Add logging and toasts --- .../app/controller/ChannelApiController.java | 33 +++++- .../app/controller/ViewController.java | 7 ++ src/main/resources/static/css/styles.css | 89 ++++++++++++++ src/main/resources/static/js/admin.js | 110 +++++++++++++++--- src/main/resources/static/js/broadcast.js | 28 ++++- src/main/resources/static/js/dashboard.js | 73 ++++++++++-- src/main/resources/static/js/toast.js | 51 ++++++++ src/main/resources/templates/admin.html | 1 + src/main/resources/templates/broadcast.html | 1 + src/main/resources/templates/dashboard.html | 1 + 10 files changed, 363 insertions(+), 31 deletions(-) create mode 100644 src/main/resources/static/js/toast.js diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index c73c088..c78dfd3 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -10,6 +10,8 @@ import com.imgfloat.app.service.ChannelDirectoryService; import com.imgfloat.app.service.TwitchUserLookupService; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.http.ResponseEntity; import org.springframework.http.MediaType; import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; @@ -40,6 +42,7 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST; @RequestMapping("/api/channels/{broadcaster}") @SecurityRequirement(name = "twitchOAuth") public class ChannelApiController { + private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private final ChannelDirectoryService channelDirectoryService; private final OAuth2AuthorizedClientService authorizedClientService; private final TwitchUserLookupService twitchUserLookupService; @@ -58,7 +61,11 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureBroadcaster(broadcaster, login); + LOG.info("User {} adding admin {} to {}", login, request.getUsername(), broadcaster); boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername()); + if (!added) { + LOG.info("User {} already admin for {} or could not be added", request.getUsername(), broadcaster); + } return ResponseEntity.ok().body(added); } @@ -67,6 +74,7 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureBroadcaster(broadcaster, login); + LOG.debug("Listing admins for {} by {}", broadcaster, login); var channel = channelDirectoryService.getOrCreateChannel(broadcaster); List admins = channel.getAdmins().stream() .sorted(Comparator.naturalOrder()) @@ -91,6 +99,7 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureBroadcaster(broadcaster, login); + LOG.info("User {} removing admin {} from {}", login, username, broadcaster); boolean removed = channelDirectoryService.removeAdmin(broadcaster, username); return ResponseEntity.ok().body(removed); } @@ -101,8 +110,10 @@ public class ChannelApiController { String login = TwitchUser.from(authentication).login(); if (!channelDirectoryService.isBroadcaster(broadcaster, login) && !channelDirectoryService.isAdmin(broadcaster, login)) { + LOG.warn("Unauthorized asset listing attempt for {} by {}", broadcaster, login); throw new ResponseStatusException(FORBIDDEN, "Not authorized"); } + LOG.info("Listing assets for {} requested by {}", broadcaster, login); return channelDirectoryService.getAssetsForAdmin(broadcaster); } @@ -113,6 +124,7 @@ public class ChannelApiController { @GetMapping("/canvas") public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) { + LOG.debug("Fetching canvas settings for {}", broadcaster); return channelDirectoryService.getCanvasSettings(broadcaster); } @@ -122,6 +134,7 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureBroadcaster(broadcaster, login); + LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, login, request.getWidth(), request.getHeight()); return channelDirectoryService.updateCanvasSettings(broadcaster, request); } @@ -132,13 +145,16 @@ public class ChannelApiController { String login = TwitchUser.from(authentication).login(); ensureAuthorized(broadcaster, login); if (file == null || file.isEmpty()) { + LOG.warn("User {} attempted to upload empty file to {}", login, broadcaster); throw new ResponseStatusException(BAD_REQUEST, "Asset file is required"); } try { + LOG.info("User {} uploading asset {} to {}", login, file.getOriginalFilename(), broadcaster); return channelDirectoryService.createAsset(broadcaster, file) .map(ResponseEntity::ok) .orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image")); } catch (IOException e) { + LOG.error("Failed to process asset upload for {} by {}", broadcaster, login, e); throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e); } } @@ -150,9 +166,13 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureAuthorized(broadcaster, login); + LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, login); return channelDirectoryService.updateTransform(broadcaster, assetId, request) .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + .orElseThrow(() -> { + LOG.warn("Transform request for missing asset {} on {} by {}", assetId, broadcaster, login); + return new ResponseStatusException(NOT_FOUND, "Asset not found"); + }); } @PutMapping("/assets/{assetId}/visibility") @@ -162,9 +182,13 @@ public class ChannelApiController { OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureAuthorized(broadcaster, login); + LOG.info("Updating visibility for asset {} on {} by {} to hidden={} ", assetId, broadcaster, login, request.isHidden()); return channelDirectoryService.updateVisibility(broadcaster, assetId, request) .map(ResponseEntity::ok) - .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Asset not found")); + .orElseThrow(() -> { + LOG.warn("Visibility update for missing asset {} on {} by {}", assetId, broadcaster, login); + return new ResponseStatusException(NOT_FOUND, "Asset not found"); + }); } @GetMapping("/assets/{assetId}/content") @@ -179,6 +203,7 @@ public class ChannelApiController { } if (authorized) { + LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName()); return channelDirectoryService.getAssetContent(broadcaster, assetId) .map(content -> ResponseEntity.ok() .contentType(MediaType.parseMediaType(content.mediaType())) @@ -201,13 +226,16 @@ public class ChannelApiController { ensureAuthorized(broadcaster, login); boolean removed = channelDirectoryService.deleteAsset(broadcaster, assetId); if (!removed) { + LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, login); throw new ResponseStatusException(NOT_FOUND, "Asset not found"); } + LOG.info("Asset {} deleted on {} by {}", assetId, broadcaster, login); return ResponseEntity.ok().build(); } private void ensureBroadcaster(String broadcaster, String login) { if (!channelDirectoryService.isBroadcaster(broadcaster, login)) { + LOG.warn("Access denied for broadcaster-only action on {} by {}", broadcaster, login); throw new ResponseStatusException(FORBIDDEN, "Only broadcasters can manage admins"); } } @@ -215,6 +243,7 @@ public class ChannelApiController { private void ensureAuthorized(String broadcaster, String login) { if (!channelDirectoryService.isBroadcaster(broadcaster, login) && !channelDirectoryService.isAdmin(broadcaster, login)) { + LOG.warn("Unauthorized access to channel {} by {}", broadcaster, login); throw new ResponseStatusException(FORBIDDEN, "No permission for channel"); } } diff --git a/src/main/java/com/imgfloat/app/controller/ViewController.java b/src/main/java/com/imgfloat/app/controller/ViewController.java index 386c837..deeafdf 100644 --- a/src/main/java/com/imgfloat/app/controller/ViewController.java +++ b/src/main/java/com/imgfloat/app/controller/ViewController.java @@ -1,6 +1,8 @@ package com.imgfloat.app.controller; import com.imgfloat.app.service.ChannelDirectoryService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -10,6 +12,7 @@ import static org.springframework.http.HttpStatus.FORBIDDEN; @Controller public class ViewController { + private static final Logger LOG = LoggerFactory.getLogger(ViewController.class); private final ChannelDirectoryService channelDirectoryService; public ViewController(ChannelDirectoryService channelDirectoryService) { @@ -20,6 +23,7 @@ public class ViewController { public String home(OAuth2AuthenticationToken authentication, Model model) { if (authentication != null) { String login = TwitchUser.from(authentication).login(); + LOG.info("Rendering dashboard for {}", login); model.addAttribute("username", login); model.addAttribute("channel", login); model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login)); @@ -35,8 +39,10 @@ public class ViewController { String login = TwitchUser.from(authentication).login(); if (!channelDirectoryService.isBroadcaster(broadcaster, login) && !channelDirectoryService.isAdmin(broadcaster, login)) { + LOG.warn("Unauthorized admin console access attempt for {} by {}", broadcaster, login); throw new ResponseStatusException(FORBIDDEN, "Not authorized for admin tools"); } + LOG.info("Rendering admin console for {} (requested by {})", broadcaster, login); model.addAttribute("broadcaster", broadcaster.toLowerCase()); model.addAttribute("username", login); return "admin"; @@ -45,6 +51,7 @@ public class ViewController { @org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast") public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster, Model model) { + LOG.debug("Rendering broadcast overlay for {}", broadcaster); model.addAttribute("broadcaster", broadcaster.toLowerCase()); return "broadcast"; } diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 71c9091..17e15d7 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -1058,3 +1058,92 @@ body { .avatar-fallback { border: 1px solid rgba(255, 255, 255, 0.08); } + +.toast-container { + position: fixed; + top: 16px; + right: 16px; + display: flex; + flex-direction: column; + gap: 12px; + z-index: 10000; + max-width: 360px; +} + +.toast { + display: grid; + grid-template-columns: auto 1fr; + gap: 12px; + align-items: center; + padding: 12px 14px; + border-radius: 12px; + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.08); + background: #0b1221; + color: #e5e7eb; + cursor: pointer; + transition: transform 120ms ease, opacity 120ms ease; +} + +.toast:hover { + transform: translateY(-2px); +} + +.toast-exit { + opacity: 0; + transform: translateY(-6px); +} + +.toast-indicator { + width: 12px; + height: 12px; + border-radius: 50%; + background: #a5b4fc; + box-shadow: 0 0 0 4px rgba(165, 180, 252, 0.16); +} + +.toast-message { + margin: 0; + font-size: 14px; + line-height: 1.4; +} + +.toast-success { + border-color: rgba(34, 197, 94, 0.35); + background: rgba(16, 185, 129, 0.12); +} + +.toast-success .toast-indicator { + background: #34d399; + box-shadow: 0 0 0 4px rgba(52, 211, 153, 0.2); +} + +.toast-error { + border-color: rgba(239, 68, 68, 0.35); + background: rgba(248, 113, 113, 0.12); +} + +.toast-error .toast-indicator { + background: #f87171; + box-shadow: 0 0 0 4px rgba(248, 113, 113, 0.2); +} + +.toast-warning { + border-color: rgba(251, 191, 36, 0.35); + background: rgba(251, 191, 36, 0.12); +} + +.toast-warning .toast-indicator { + background: #facc15; + box-shadow: 0 0 0 4px rgba(250, 204, 21, 0.2); +} + +.toast-info { + border-color: rgba(96, 165, 250, 0.35); + background: rgba(96, 165, 250, 0.12); +} + +.toast-info .toast-indicator { + background: #60a5fa; + box-shadow: 0 0 0 4px rgba(96, 165, 250, 0.2); +} diff --git a/src/main/resources/static/js/admin.js b/src/main/resources/static/js/admin.js index 6579618..defbcf1 100644 --- a/src/main/resources/static/js/admin.js +++ b/src/main/resources/static/js/admin.js @@ -154,21 +154,48 @@ function connect() { handleEvent(body); }); fetchAssets(); + }, (error) => { + console.warn('WebSocket connection issue', error); + if (typeof showToast === 'function') { + showToast('Live updates connection interrupted. Retrying may be necessary.', 'warning'); + } }); } function fetchAssets() { - fetch(`/api/channels/${broadcaster}/assets`).then((r) => r.json()).then(renderAssets); + fetch(`/api/channels/${broadcaster}/assets`) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load assets'); + } + return r.json(); + }) + .then(renderAssets) + .catch(() => { + if (typeof showToast === 'function') { + showToast('Unable to load assets. Please refresh.', 'error'); + } + }); } function fetchCanvasSettings() { return fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => r.json()) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load canvas'); + } + return r.json(); + }) .then((settings) => { canvasSettings = settings; resizeCanvas(); }) - .catch(() => resizeCanvas()); + .catch(() => { + resizeCanvas(); + if (typeof showToast === 'function') { + showToast('Using default canvas size. Unable to load saved settings.', 'warning'); + } + }); } function resizeCanvas() { @@ -1350,29 +1377,58 @@ function updateVisibility(asset, hidden) { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ hidden }) - }).then((r) => r.json()).then((updated) => { + }).then((r) => { + if (!r.ok) { + throw new Error('Failed to update visibility'); + } + return r.json(); + }).then((updated) => { storeAsset(updated); if (updated.hidden) { stopAudio(updated.id); + if (typeof showToast === 'function') { + showToast('Asset hidden from broadcast.', 'info'); + } } else if (isAudioAsset(updated)) { playAudioFromCanvas(updated, true); + if (typeof showToast === 'function') { + showToast('Asset is now visible and active.', 'success'); + } + } else if (typeof showToast === 'function') { + showToast('Asset is now visible.', 'success'); } updateRenderState(updated); drawAndList(); + }).catch(() => { + if (typeof showToast === 'function') { + showToast('Unable to change visibility right now.', 'error'); + } }); } function deleteAsset(asset) { - fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => { - clearMedia(asset.id); - assets.delete(asset.id); - renderStates.delete(asset.id); - zOrderDirty = true; - if (selectedAssetId === asset.id) { - selectedAssetId = null; - } - drawAndList(); - }); + fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }) + .then((response) => { + if (!response.ok) { + throw new Error('Failed to delete asset'); + } + clearMedia(asset.id); + assets.delete(asset.id); + renderStates.delete(asset.id); + zOrderDirty = true; + if (selectedAssetId === asset.id) { + selectedAssetId = null; + } + drawAndList(); + if (typeof showToast === 'function') { + showToast('Asset deleted.', 'info'); + } + }) + .catch(() => { + if (typeof showToast === 'function') { + showToast('Unable to delete asset. Please try again.', 'error'); + } + }); } function handleFileSelection(input) { @@ -1391,7 +1447,9 @@ function uploadAsset(file = null) { const fileInput = document.getElementById('asset-file'); const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null); if (!selectedFile) { - alert('Please choose an image, GIF, video, or audio file to upload.'); + if (typeof showToast === 'function') { + showToast('Choose an image, GIF, video, or audio file to upload.', 'info'); + } return; } const data = new FormData(); @@ -1402,15 +1460,24 @@ function uploadAsset(file = null) { fetch(`/api/channels/${broadcaster}/assets`, { method: 'POST', body: data - }).then(() => { + }).then((response) => { + if (!response.ok) { + throw new Error('Upload failed'); + } if (fileInput) { fileInput.value = ''; handleFileSelection(fileInput); } + if (typeof showToast === 'function') { + showToast('Asset uploaded successfully.', 'success'); + } }).catch(() => { if (fileNameLabel) { fileNameLabel.textContent = 'Upload failed'; } + if (typeof showToast === 'function') { + showToast('Upload failed. Please try again with a supported file.', 'error'); + } }); } @@ -1462,12 +1529,21 @@ function persistTransform(asset, silent = false) { audioPitch: asset.audioPitch, audioVolume: asset.audioVolume }) - }).then((r) => r.json()).then((updated) => { + }).then((r) => { + if (!r.ok) { + throw new Error('Transform failed'); + } + return r.json(); + }).then((updated) => { storeAsset(updated); updateRenderState(updated); if (!silent) { drawAndList(); } + }).catch(() => { + if (!silent && typeof showToast === 'function') { + showToast('Unable to save changes. Please retry.', 'error'); + } }); } diff --git a/src/main/resources/static/js/broadcast.js b/src/main/resources/static/js/broadcast.js index 8024fee..6be68b9 100644 --- a/src/main/resources/static/js/broadcast.js +++ b/src/main/resources/static/js/broadcast.js @@ -36,7 +36,19 @@ function connect() { const body = JSON.parse(payload.body); handleEvent(body); }); - fetch(`/api/channels/${broadcaster}/assets/visible`).then(r => r.json()).then(renderAssets); + fetch(`/api/channels/${broadcaster}/assets/visible`) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load assets'); + } + return r.json(); + }) + .then(renderAssets) + .catch(() => { + if (typeof showToast === 'function') { + showToast('Unable to load overlay assets. Retrying may help.', 'error'); + } + }); }); } @@ -51,12 +63,22 @@ function renderAssets(list) { function fetchCanvasSettings() { return fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => r.json()) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load canvas'); + } + return r.json(); + }) .then((settings) => { canvasSettings = settings; resizeCanvas(); }) - .catch(() => resizeCanvas()); + .catch(() => { + resizeCanvas(); + if (typeof showToast === 'function') { + showToast('Using default canvas size. Unable to load saved settings.', 'warning'); + } + }); } function resizeCanvas() { diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 21afd10..39ee7d3 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -57,23 +57,44 @@ function renderAdmins(list) { function fetchAdmins() { fetch(`/api/channels/${broadcaster}/admins`) - .then((r) => r.json()) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load admins'); + } + return r.json(); + }) .then(renderAdmins) - .catch(() => renderAdmins([])); + .catch(() => { + renderAdmins([]); + if (typeof showToast === 'function') { + showToast('Unable to load admins right now. Please try again.', 'error'); + } + }); } function removeAdmin(username) { if (!username) return; fetch(`/api/channels/${broadcaster}/admins/${encodeURIComponent(username)}`, { method: 'DELETE' - }).then(fetchAdmins); + }).then((response) => { + if (!response.ok && typeof showToast === 'function') { + showToast('Failed to remove admin. Please retry.', 'error'); + } + fetchAdmins(); + }).catch(() => { + if (typeof showToast === 'function') { + showToast('Failed to remove admin. Please retry.', 'error'); + } + }); } function addAdmin() { const input = document.getElementById('new-admin'); const username = input.value.trim(); if (!username) { - alert('Enter a Twitch username to add as an admin.'); + if (typeof showToast === 'function') { + showToast('Enter a Twitch username to add as an admin.', 'info'); + } return; } @@ -82,9 +103,20 @@ function addAdmin() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username }) }) - .then(() => { + .then((response) => { + if (!response.ok) { + throw new Error('Add admin failed'); + } input.value = ''; + if (typeof showToast === 'function') { + showToast(`Added @${username} as an admin.`, 'success'); + } fetchAdmins(); + }) + .catch(() => { + if (typeof showToast === 'function') { + showToast('Unable to add admin right now. Please try again.', 'error'); + } }); } @@ -97,9 +129,19 @@ function renderCanvasSettings(settings) { function fetchCanvasSettings() { fetch(`/api/channels/${broadcaster}/canvas`) - .then((r) => r.json()) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load canvas settings'); + } + return r.json(); + }) .then(renderCanvasSettings) - .catch(() => renderCanvasSettings({ width: 1920, height: 1080 })); + .catch(() => { + renderCanvasSettings({ width: 1920, height: 1080 }); + if (typeof showToast === 'function') { + showToast('Using default canvas size. Unable to load saved settings.', 'warning'); + } + }); } function saveCanvasSettings() { @@ -109,7 +151,9 @@ function saveCanvasSettings() { const width = parseFloat(widthInput?.value) || 0; const height = parseFloat(heightInput?.value) || 0; if (width <= 0 || height <= 0) { - alert('Please enter a valid width and height.'); + if (typeof showToast === 'function') { + showToast('Please enter a valid width and height.', 'info'); + } return; } if (status) status.textContent = 'Saving...'; @@ -118,16 +162,27 @@ function saveCanvasSettings() { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ width, height }) }) - .then((r) => r.json()) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to save canvas'); + } + return r.json(); + }) .then((settings) => { renderCanvasSettings(settings); if (status) status.textContent = 'Saved.'; + if (typeof showToast === 'function') { + 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'); + } }); } diff --git a/src/main/resources/static/js/toast.js b/src/main/resources/static/js/toast.js new file mode 100644 index 0000000..c9cdff7 --- /dev/null +++ b/src/main/resources/static/js/toast.js @@ -0,0 +1,51 @@ +(function () { + 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); + } + return container; + } + + 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 content = document.createElement('div'); + content.className = 'toast-message'; + content.textContent = message; + + toast.appendChild(indicator); + toast.appendChild(content); + return toast; + } + + 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)); + }; +})(); diff --git a/src/main/resources/templates/admin.html b/src/main/resources/templates/admin.html index 56e0d65..d4703c7 100644 --- a/src/main/resources/templates/admin.html +++ b/src/main/resources/templates/admin.html @@ -179,6 +179,7 @@ const broadcaster = /*[[${broadcaster}]]*/ ''; const username = /*[[${username}]]*/ ''; + diff --git a/src/main/resources/templates/broadcast.html b/src/main/resources/templates/broadcast.html index 18fab91..8c35216 100644 --- a/src/main/resources/templates/broadcast.html +++ b/src/main/resources/templates/broadcast.html @@ -12,6 +12,7 @@ + diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index d1e1503..ada9a19 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -88,6 +88,7 @@ +