diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index 12b7291..853a83a 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -4,11 +4,15 @@ import com.imgfloat.app.model.AdminRequest; import com.imgfloat.app.model.Asset; import com.imgfloat.app.model.CanvasSettingsRequest; import com.imgfloat.app.model.TransformRequest; +import com.imgfloat.app.model.TwitchUserProfile; import com.imgfloat.app.model.VisibilityRequest; import com.imgfloat.app.service.ChannelDirectoryService; +import com.imgfloat.app.service.TwitchUserLookupService; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.http.MediaType; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClient; +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService; import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -23,6 +27,9 @@ import org.springframework.web.multipart.MultipartFile; import java.util.Collection; import java.io.IOException; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; import static org.springframework.http.HttpStatus.FORBIDDEN; import static org.springframework.http.HttpStatus.NOT_FOUND; @@ -32,9 +39,15 @@ import static org.springframework.http.HttpStatus.BAD_REQUEST; @RequestMapping("/api/channels/{broadcaster}") public class ChannelApiController { private final ChannelDirectoryService channelDirectoryService; + private final OAuth2AuthorizedClientService authorizedClientService; + private final TwitchUserLookupService twitchUserLookupService; - public ChannelApiController(ChannelDirectoryService channelDirectoryService) { + public ChannelApiController(ChannelDirectoryService channelDirectoryService, + OAuth2AuthorizedClientService authorizedClientService, + TwitchUserLookupService twitchUserLookupService) { this.channelDirectoryService = channelDirectoryService; + this.authorizedClientService = authorizedClientService; + this.twitchUserLookupService = twitchUserLookupService; } @PostMapping("/admins") @@ -48,11 +61,26 @@ public class ChannelApiController { } @GetMapping("/admins") - public Collection listAdmins(@PathVariable("broadcaster") String broadcaster, - OAuth2AuthenticationToken authentication) { + public Collection listAdmins(@PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken authentication) { String login = TwitchUser.from(authentication).login(); ensureBroadcaster(broadcaster, login); - return channelDirectoryService.getOrCreateChannel(broadcaster).getAdmins(); + var channel = channelDirectoryService.getOrCreateChannel(broadcaster); + List admins = channel.getAdmins().stream() + .sorted(Comparator.naturalOrder()) + .toList(); + OAuth2AuthorizedClient authorizedClient = authorizedClientService.loadAuthorizedClient( + authentication.getAuthorizedClientRegistrationId(), + authentication.getName()); + String accessToken = Optional.ofNullable(authorizedClient) + .map(OAuth2AuthorizedClient::getAccessToken) + .map(token -> token.getTokenValue()) + .orElse(null); + String clientId = Optional.ofNullable(authorizedClient) + .map(OAuth2AuthorizedClient::getClientRegistration) + .map(registration -> registration.getClientId()) + .orElse(null); + return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); } @DeleteMapping("/admins/{username}") diff --git a/src/main/java/com/imgfloat/app/model/TwitchUserProfile.java b/src/main/java/com/imgfloat/app/model/TwitchUserProfile.java new file mode 100644 index 0000000..72fe233 --- /dev/null +++ b/src/main/java/com/imgfloat/app/model/TwitchUserProfile.java @@ -0,0 +1,12 @@ +package com.imgfloat.app.model; + +/** + * Minimal Twitch user details used for rendering avatars and display names. + */ +public record TwitchUserProfile(String login, String displayName, String avatarUrl) { + public TwitchUserProfile { + if (displayName == null || displayName.isBlank()) { + displayName = login; + } + } +} diff --git a/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java b/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java new file mode 100644 index 0000000..90314eb --- /dev/null +++ b/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java @@ -0,0 +1,110 @@ +package com.imgfloat.app.service; + +import com.imgfloat.app.model.TwitchUserProfile; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; +import org.springframework.web.util.UriComponentsBuilder; + +import java.time.Duration; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +public class TwitchUserLookupService { + private static final Logger LOG = LoggerFactory.getLogger(TwitchUserLookupService.class); + private final RestTemplate restTemplate; + + public TwitchUserLookupService(RestTemplateBuilder builder) { + this.restTemplate = builder + .setConnectTimeout(Duration.ofSeconds(15)) + .setReadTimeout(Duration.ofSeconds(15)) + .build(); + } + + public List fetchProfiles(Collection logins, String accessToken, String clientId) { + if (logins == null || logins.isEmpty()) { + return List.of(); + } + + List normalizedLogins = logins.stream() + .filter(Objects::nonNull) + .map(login -> login.toLowerCase(Locale.ROOT)) + .distinct() + .toList(); + + if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { + return normalizedLogins.stream() + .map(login -> new TwitchUserProfile(login, login, null)) + .toList(); + } + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.add("Client-ID", clientId); + + UriComponentsBuilder uriBuilder = UriComponentsBuilder + .fromHttpUrl("https://api.twitch.tv/helix/users"); + normalizedLogins.forEach(login -> uriBuilder.queryParam("login", login)); + + HttpEntity entity = new HttpEntity<>(headers); + try { + ResponseEntity response = restTemplate.exchange( + uriBuilder.build(true).toUri(), + HttpMethod.GET, + entity, + TwitchUsersResponse.class); + + Map byLogin = response.getBody() == null + ? Collections.emptyMap() + : response.getBody().data().stream() + .filter(Objects::nonNull) + .collect(Collectors.toMap( + user -> user.login().toLowerCase(Locale.ROOT), + Function.identity(), + (a, b) -> a)); + + return normalizedLogins.stream() + .map(login -> toProfile(login, byLogin.get(login))) + .toList(); + } catch (RestClientException ex) { + LOG.warn("Unable to fetch Twitch user profiles", ex); + return normalizedLogins.stream() + .map(login -> new TwitchUserProfile(login, login, null)) + .toList(); + } + } + + private TwitchUserProfile toProfile(String login, TwitchUserData data) { + if (data == null) { + return new TwitchUserProfile(login, login, null); + } + return new TwitchUserProfile(login, data.displayName(), data.profileImageUrl()); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TwitchUsersResponse(List data) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TwitchUserData( + String login, + @JsonProperty("display_name") String displayName, + @JsonProperty("profile_image_url") String profileImageUrl) { + } +} diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 6795554..2f4792c 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -574,3 +574,32 @@ body { margin: 0; font-weight: 700; } + +.identity-row { + display: flex; + align-items: center; + gap: 12px; +} + +.identity-text { + display: flex; + flex-direction: column; + gap: 2px; +} + +.avatar { + width: 40px; + height: 40px; + border-radius: 50%; + object-fit: cover; + background: linear-gradient(135deg, #7c3aed, #4f46e5); + display: grid; + place-items: center; + font-weight: 700; + color: #e0e7ff; + text-transform: uppercase; +} + +.avatar-fallback { + border: 1px solid rgba(255, 255, 255, 0.08); +} diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 6f51295..a5d8695 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -10,7 +10,35 @@ function renderAdmins(list) { list.forEach((admin) => { const li = document.createElement('li'); - li.textContent = admin; + li.className = 'stacked-list-item'; + + 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 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); + li.appendChild(identity); adminList.appendChild(li); }); } diff --git a/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java b/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java index 129cdf8..15badc8 100644 --- a/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java +++ b/src/test/java/com/imgfloat/app/ChannelApiIntegrationTest.java @@ -48,6 +48,12 @@ class ChannelApiIntegrationTest { .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) .andExpect(status().isOk()); + mockMvc.perform(get("/api/channels/{broadcaster}/admins", broadcaster) + .with(oauth2Login().attributes(attrs -> attrs.put("preferred_username", broadcaster)))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].login").value("helper")) + .andExpect(jsonPath("$[0].displayName").value("helper")); + MockMultipartFile file = new MockMultipartFile("file", "image.png", "image/png", samplePng()); String assetId = objectMapper.readTree(mockMvc.perform(multipart("/api/channels/{broadcaster}/assets", broadcaster)