mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add logging and toasts
This commit is contained in:
@@ -10,6 +10,8 @@ import com.imgfloat.app.service.ChannelDirectoryService;
|
|||||||
import com.imgfloat.app.service.TwitchUserLookupService;
|
import com.imgfloat.app.service.TwitchUserLookupService;
|
||||||
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
|
||||||
import jakarta.validation.Valid;
|
import jakarta.validation.Valid;
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.http.MediaType;
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
import org.springframework.security.oauth2.client.OAuth2AuthorizedClient;
|
||||||
@@ -40,6 +42,7 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST;
|
|||||||
@RequestMapping("/api/channels/{broadcaster}")
|
@RequestMapping("/api/channels/{broadcaster}")
|
||||||
@SecurityRequirement(name = "twitchOAuth")
|
@SecurityRequirement(name = "twitchOAuth")
|
||||||
public class ChannelApiController {
|
public class ChannelApiController {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class);
|
||||||
private final ChannelDirectoryService channelDirectoryService;
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
private final OAuth2AuthorizedClientService authorizedClientService;
|
private final OAuth2AuthorizedClientService authorizedClientService;
|
||||||
private final TwitchUserLookupService twitchUserLookupService;
|
private final TwitchUserLookupService twitchUserLookupService;
|
||||||
@@ -58,7 +61,11 @@ public class ChannelApiController {
|
|||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
ensureBroadcaster(broadcaster, login);
|
||||||
|
LOG.info("User {} adding admin {} to {}", login, request.getUsername(), broadcaster);
|
||||||
boolean added = channelDirectoryService.addAdmin(broadcaster, request.getUsername());
|
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);
|
return ResponseEntity.ok().body(added);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,6 +74,7 @@ public class ChannelApiController {
|
|||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
ensureBroadcaster(broadcaster, login);
|
||||||
|
LOG.debug("Listing admins for {} by {}", broadcaster, login);
|
||||||
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
var channel = channelDirectoryService.getOrCreateChannel(broadcaster);
|
||||||
List<String> admins = channel.getAdmins().stream()
|
List<String> admins = channel.getAdmins().stream()
|
||||||
.sorted(Comparator.naturalOrder())
|
.sorted(Comparator.naturalOrder())
|
||||||
@@ -91,6 +99,7 @@ public class ChannelApiController {
|
|||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
ensureBroadcaster(broadcaster, login);
|
||||||
|
LOG.info("User {} removing admin {} from {}", login, username, broadcaster);
|
||||||
boolean removed = channelDirectoryService.removeAdmin(broadcaster, username);
|
boolean removed = channelDirectoryService.removeAdmin(broadcaster, username);
|
||||||
return ResponseEntity.ok().body(removed);
|
return ResponseEntity.ok().body(removed);
|
||||||
}
|
}
|
||||||
@@ -101,8 +110,10 @@ public class ChannelApiController {
|
|||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
||||||
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
|
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
|
||||||
|
LOG.warn("Unauthorized asset listing attempt for {} by {}", broadcaster, login);
|
||||||
throw new ResponseStatusException(FORBIDDEN, "Not authorized");
|
throw new ResponseStatusException(FORBIDDEN, "Not authorized");
|
||||||
}
|
}
|
||||||
|
LOG.info("Listing assets for {} requested by {}", broadcaster, login);
|
||||||
return channelDirectoryService.getAssetsForAdmin(broadcaster);
|
return channelDirectoryService.getAssetsForAdmin(broadcaster);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +124,7 @@ public class ChannelApiController {
|
|||||||
|
|
||||||
@GetMapping("/canvas")
|
@GetMapping("/canvas")
|
||||||
public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) {
|
public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) {
|
||||||
|
LOG.debug("Fetching canvas settings for {}", broadcaster);
|
||||||
return channelDirectoryService.getCanvasSettings(broadcaster);
|
return channelDirectoryService.getCanvasSettings(broadcaster);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,6 +134,7 @@ public class ChannelApiController {
|
|||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureBroadcaster(broadcaster, login);
|
ensureBroadcaster(broadcaster, login);
|
||||||
|
LOG.info("Updating canvas for {} by {}: {}x{}", broadcaster, login, request.getWidth(), request.getHeight());
|
||||||
return channelDirectoryService.updateCanvasSettings(broadcaster, request);
|
return channelDirectoryService.updateCanvasSettings(broadcaster, request);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,13 +145,16 @@ public class ChannelApiController {
|
|||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
ensureAuthorized(broadcaster, login);
|
||||||
if (file == null || file.isEmpty()) {
|
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");
|
throw new ResponseStatusException(BAD_REQUEST, "Asset file is required");
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
|
LOG.info("User {} uploading asset {} to {}", login, file.getOriginalFilename(), broadcaster);
|
||||||
return channelDirectoryService.createAsset(broadcaster, file)
|
return channelDirectoryService.createAsset(broadcaster, file)
|
||||||
.map(ResponseEntity::ok)
|
.map(ResponseEntity::ok)
|
||||||
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
.orElseThrow(() -> new ResponseStatusException(BAD_REQUEST, "Unable to read image"));
|
||||||
} catch (IOException e) {
|
} 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);
|
throw new ResponseStatusException(BAD_REQUEST, "Failed to process image", e);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -150,9 +166,13 @@ public class ChannelApiController {
|
|||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureAuthorized(broadcaster, login);
|
ensureAuthorized(broadcaster, login);
|
||||||
|
LOG.debug("Applying transform to asset {} on {} by {}", assetId, broadcaster, login);
|
||||||
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
|
return channelDirectoryService.updateTransform(broadcaster, assetId, request)
|
||||||
.map(ResponseEntity::ok)
|
.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")
|
@PutMapping("/assets/{assetId}/visibility")
|
||||||
@@ -162,9 +182,13 @@ public class ChannelApiController {
|
|||||||
OAuth2AuthenticationToken authentication) {
|
OAuth2AuthenticationToken authentication) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
ensureAuthorized(broadcaster, 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)
|
return channelDirectoryService.updateVisibility(broadcaster, assetId, request)
|
||||||
.map(ResponseEntity::ok)
|
.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")
|
@GetMapping("/assets/{assetId}/content")
|
||||||
@@ -179,6 +203,7 @@ public class ChannelApiController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (authorized) {
|
if (authorized) {
|
||||||
|
LOG.debug("Serving asset {} for broadcaster {} to authenticated user {}", assetId, broadcaster, authentication.getName());
|
||||||
return channelDirectoryService.getAssetContent(broadcaster, assetId)
|
return channelDirectoryService.getAssetContent(broadcaster, assetId)
|
||||||
.map(content -> ResponseEntity.ok()
|
.map(content -> ResponseEntity.ok()
|
||||||
.contentType(MediaType.parseMediaType(content.mediaType()))
|
.contentType(MediaType.parseMediaType(content.mediaType()))
|
||||||
@@ -201,13 +226,16 @@ public class ChannelApiController {
|
|||||||
ensureAuthorized(broadcaster, login);
|
ensureAuthorized(broadcaster, login);
|
||||||
boolean removed = channelDirectoryService.deleteAsset(broadcaster, assetId);
|
boolean removed = channelDirectoryService.deleteAsset(broadcaster, assetId);
|
||||||
if (!removed) {
|
if (!removed) {
|
||||||
|
LOG.warn("Attempt to delete missing asset {} on {} by {}", assetId, broadcaster, login);
|
||||||
throw new ResponseStatusException(NOT_FOUND, "Asset not found");
|
throw new ResponseStatusException(NOT_FOUND, "Asset not found");
|
||||||
}
|
}
|
||||||
|
LOG.info("Asset {} deleted on {} by {}", assetId, broadcaster, login);
|
||||||
return ResponseEntity.ok().build();
|
return ResponseEntity.ok().build();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensureBroadcaster(String broadcaster, String login) {
|
private void ensureBroadcaster(String broadcaster, String login) {
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, 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");
|
throw new ResponseStatusException(FORBIDDEN, "Only broadcasters can manage admins");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -215,6 +243,7 @@ public class ChannelApiController {
|
|||||||
private void ensureAuthorized(String broadcaster, String login) {
|
private void ensureAuthorized(String broadcaster, String login) {
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
||||||
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
|
&& !channelDirectoryService.isAdmin(broadcaster, login)) {
|
||||||
|
LOG.warn("Unauthorized access to channel {} by {}", broadcaster, login);
|
||||||
throw new ResponseStatusException(FORBIDDEN, "No permission for channel");
|
throw new ResponseStatusException(FORBIDDEN, "No permission for channel");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package com.imgfloat.app.controller;
|
package com.imgfloat.app.controller;
|
||||||
|
|
||||||
import com.imgfloat.app.service.ChannelDirectoryService;
|
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.security.oauth2.client.authentication.OAuth2AuthenticationToken;
|
||||||
import org.springframework.stereotype.Controller;
|
import org.springframework.stereotype.Controller;
|
||||||
import org.springframework.ui.Model;
|
import org.springframework.ui.Model;
|
||||||
@@ -10,6 +12,7 @@ import static org.springframework.http.HttpStatus.FORBIDDEN;
|
|||||||
|
|
||||||
@Controller
|
@Controller
|
||||||
public class ViewController {
|
public class ViewController {
|
||||||
|
private static final Logger LOG = LoggerFactory.getLogger(ViewController.class);
|
||||||
private final ChannelDirectoryService channelDirectoryService;
|
private final ChannelDirectoryService channelDirectoryService;
|
||||||
|
|
||||||
public ViewController(ChannelDirectoryService channelDirectoryService) {
|
public ViewController(ChannelDirectoryService channelDirectoryService) {
|
||||||
@@ -20,6 +23,7 @@ public class ViewController {
|
|||||||
public String home(OAuth2AuthenticationToken authentication, Model model) {
|
public String home(OAuth2AuthenticationToken authentication, Model model) {
|
||||||
if (authentication != null) {
|
if (authentication != null) {
|
||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
|
LOG.info("Rendering dashboard for {}", login);
|
||||||
model.addAttribute("username", login);
|
model.addAttribute("username", login);
|
||||||
model.addAttribute("channel", login);
|
model.addAttribute("channel", login);
|
||||||
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login));
|
model.addAttribute("adminChannels", channelDirectoryService.adminChannelsFor(login));
|
||||||
@@ -35,8 +39,10 @@ public class ViewController {
|
|||||||
String login = TwitchUser.from(authentication).login();
|
String login = TwitchUser.from(authentication).login();
|
||||||
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
if (!channelDirectoryService.isBroadcaster(broadcaster, login)
|
||||||
&& !channelDirectoryService.isAdmin(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");
|
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("broadcaster", broadcaster.toLowerCase());
|
||||||
model.addAttribute("username", login);
|
model.addAttribute("username", login);
|
||||||
return "admin";
|
return "admin";
|
||||||
@@ -45,6 +51,7 @@ public class ViewController {
|
|||||||
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
|
@org.springframework.web.bind.annotation.GetMapping("/view/{broadcaster}/broadcast")
|
||||||
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
public String broadcastView(@org.springframework.web.bind.annotation.PathVariable("broadcaster") String broadcaster,
|
||||||
Model model) {
|
Model model) {
|
||||||
|
LOG.debug("Rendering broadcast overlay for {}", broadcaster);
|
||||||
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
model.addAttribute("broadcaster", broadcaster.toLowerCase());
|
||||||
return "broadcast";
|
return "broadcast";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1058,3 +1058,92 @@ body {
|
|||||||
.avatar-fallback {
|
.avatar-fallback {
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -154,21 +154,48 @@ function connect() {
|
|||||||
handleEvent(body);
|
handleEvent(body);
|
||||||
});
|
});
|
||||||
fetchAssets();
|
fetchAssets();
|
||||||
|
}, (error) => {
|
||||||
|
console.warn('WebSocket connection issue', error);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Live updates connection interrupted. Retrying may be necessary.', 'warning');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchAssets() {
|
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() {
|
function fetchCanvasSettings() {
|
||||||
return fetch(`/api/channels/${broadcaster}/canvas`)
|
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) => {
|
.then((settings) => {
|
||||||
canvasSettings = settings;
|
canvasSettings = settings;
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
})
|
})
|
||||||
.catch(() => resizeCanvas());
|
.catch(() => {
|
||||||
|
resizeCanvas();
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Using default canvas size. Unable to load saved settings.', 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeCanvas() {
|
function resizeCanvas() {
|
||||||
@@ -1350,20 +1377,41 @@ function updateVisibility(asset, hidden) {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ hidden })
|
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);
|
storeAsset(updated);
|
||||||
if (updated.hidden) {
|
if (updated.hidden) {
|
||||||
stopAudio(updated.id);
|
stopAudio(updated.id);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Asset hidden from broadcast.', 'info');
|
||||||
|
}
|
||||||
} else if (isAudioAsset(updated)) {
|
} else if (isAudioAsset(updated)) {
|
||||||
playAudioFromCanvas(updated, true);
|
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);
|
updateRenderState(updated);
|
||||||
drawAndList();
|
drawAndList();
|
||||||
|
}).catch(() => {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Unable to change visibility right now.', 'error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteAsset(asset) {
|
function deleteAsset(asset) {
|
||||||
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' }).then(() => {
|
fetch(`/api/channels/${broadcaster}/assets/${asset.id}`, { method: 'DELETE' })
|
||||||
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to delete asset');
|
||||||
|
}
|
||||||
clearMedia(asset.id);
|
clearMedia(asset.id);
|
||||||
assets.delete(asset.id);
|
assets.delete(asset.id);
|
||||||
renderStates.delete(asset.id);
|
renderStates.delete(asset.id);
|
||||||
@@ -1372,6 +1420,14 @@ function deleteAsset(asset) {
|
|||||||
selectedAssetId = null;
|
selectedAssetId = null;
|
||||||
}
|
}
|
||||||
drawAndList();
|
drawAndList();
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Asset deleted.', 'info');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Unable to delete asset. Please try again.', 'error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1391,7 +1447,9 @@ function uploadAsset(file = null) {
|
|||||||
const fileInput = document.getElementById('asset-file');
|
const fileInput = document.getElementById('asset-file');
|
||||||
const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null);
|
const selectedFile = file || (fileInput?.files && fileInput.files.length ? fileInput.files[0] : null);
|
||||||
if (!selectedFile) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const data = new FormData();
|
const data = new FormData();
|
||||||
@@ -1402,15 +1460,24 @@ function uploadAsset(file = null) {
|
|||||||
fetch(`/api/channels/${broadcaster}/assets`, {
|
fetch(`/api/channels/${broadcaster}/assets`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: data
|
body: data
|
||||||
}).then(() => {
|
}).then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Upload failed');
|
||||||
|
}
|
||||||
if (fileInput) {
|
if (fileInput) {
|
||||||
fileInput.value = '';
|
fileInput.value = '';
|
||||||
handleFileSelection(fileInput);
|
handleFileSelection(fileInput);
|
||||||
}
|
}
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Asset uploaded successfully.', 'success');
|
||||||
|
}
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (fileNameLabel) {
|
if (fileNameLabel) {
|
||||||
fileNameLabel.textContent = 'Upload failed';
|
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,
|
audioPitch: asset.audioPitch,
|
||||||
audioVolume: asset.audioVolume
|
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);
|
storeAsset(updated);
|
||||||
updateRenderState(updated);
|
updateRenderState(updated);
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
drawAndList();
|
drawAndList();
|
||||||
}
|
}
|
||||||
|
}).catch(() => {
|
||||||
|
if (!silent && typeof showToast === 'function') {
|
||||||
|
showToast('Unable to save changes. Please retry.', 'error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,7 +36,19 @@ function connect() {
|
|||||||
const body = JSON.parse(payload.body);
|
const body = JSON.parse(payload.body);
|
||||||
handleEvent(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() {
|
function fetchCanvasSettings() {
|
||||||
return fetch(`/api/channels/${broadcaster}/canvas`)
|
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) => {
|
.then((settings) => {
|
||||||
canvasSettings = settings;
|
canvasSettings = settings;
|
||||||
resizeCanvas();
|
resizeCanvas();
|
||||||
})
|
})
|
||||||
.catch(() => resizeCanvas());
|
.catch(() => {
|
||||||
|
resizeCanvas();
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Using default canvas size. Unable to load saved settings.', 'warning');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function resizeCanvas() {
|
function resizeCanvas() {
|
||||||
|
|||||||
@@ -57,23 +57,44 @@ function renderAdmins(list) {
|
|||||||
|
|
||||||
function fetchAdmins() {
|
function fetchAdmins() {
|
||||||
fetch(`/api/channels/${broadcaster}/admins`)
|
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)
|
.then(renderAdmins)
|
||||||
.catch(() => renderAdmins([]));
|
.catch(() => {
|
||||||
|
renderAdmins([]);
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Unable to load admins right now. Please try again.', 'error');
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeAdmin(username) {
|
function removeAdmin(username) {
|
||||||
if (!username) return;
|
if (!username) return;
|
||||||
fetch(`/api/channels/${broadcaster}/admins/${encodeURIComponent(username)}`, {
|
fetch(`/api/channels/${broadcaster}/admins/${encodeURIComponent(username)}`, {
|
||||||
method: 'DELETE'
|
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() {
|
function addAdmin() {
|
||||||
const input = document.getElementById('new-admin');
|
const input = document.getElementById('new-admin');
|
||||||
const username = input.value.trim();
|
const username = input.value.trim();
|
||||||
if (!username) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,9 +103,20 @@ function addAdmin() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ username })
|
body: JSON.stringify({ username })
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then((response) => {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Add admin failed');
|
||||||
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(`Added @${username} as an admin.`, 'success');
|
||||||
|
}
|
||||||
fetchAdmins();
|
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() {
|
function fetchCanvasSettings() {
|
||||||
fetch(`/api/channels/${broadcaster}/canvas`)
|
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)
|
.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() {
|
function saveCanvasSettings() {
|
||||||
@@ -109,7 +151,9 @@ function saveCanvasSettings() {
|
|||||||
const width = parseFloat(widthInput?.value) || 0;
|
const width = parseFloat(widthInput?.value) || 0;
|
||||||
const height = parseFloat(heightInput?.value) || 0;
|
const height = parseFloat(heightInput?.value) || 0;
|
||||||
if (width <= 0 || height <= 0) {
|
if (width <= 0 || height <= 0) {
|
||||||
alert('Please enter a valid width and height.');
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Please enter a valid width and height.', 'info');
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (status) status.textContent = 'Saving...';
|
if (status) status.textContent = 'Saving...';
|
||||||
@@ -118,16 +162,27 @@ function saveCanvasSettings() {
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ width, height })
|
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) => {
|
.then((settings) => {
|
||||||
renderCanvasSettings(settings);
|
renderCanvasSettings(settings);
|
||||||
if (status) status.textContent = 'Saved.';
|
if (status) status.textContent = 'Saved.';
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Canvas size saved successfully.', 'success');
|
||||||
|
}
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (status) status.textContent = '';
|
if (status) status.textContent = '';
|
||||||
}, 2000);
|
}, 2000);
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (status) status.textContent = 'Unable to save right now.';
|
if (status) status.textContent = 'Unable to save right now.';
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Unable to save canvas size. Please retry.', 'error');
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
51
src/main/resources/static/js/toast.js
Normal file
51
src/main/resources/static/js/toast.js
Normal file
@@ -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));
|
||||||
|
};
|
||||||
|
})();
|
||||||
@@ -179,6 +179,7 @@
|
|||||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||||
const username = /*[[${username}]]*/ '';
|
const username = /*[[${username}]]*/ '';
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/toast.js"></script>
|
||||||
<script src="/js/admin.js"></script>
|
<script src="/js/admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
const broadcaster = /*[[${broadcaster}]]*/ '';
|
const broadcaster = /*[[${broadcaster}]]*/ '';
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/js/toast.js"></script>
|
||||||
<script src="/js/broadcast.js"></script>
|
<script src="/js/broadcast.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -88,6 +88,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
<script src="/js/toast.js"></script>
|
||||||
<script th:inline="javascript">
|
<script th:inline="javascript">
|
||||||
const broadcaster = /*[[${channel}]]*/ '';
|
const broadcaster = /*[[${channel}]]*/ '';
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
Reference in New Issue
Block a user