From 5e0ef4fa74dea01bd1305e3db4e367c2ddbbb89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andreas=20Kr=C3=BChlmann?= Date: Wed, 10 Dec 2025 19:56:39 +0100 Subject: [PATCH] Initial moderator view --- .../app/controller/ChannelApiController.java | 21 +++ .../app/service/TwitchUserLookupService.java | 154 +++++++++++++++--- src/main/resources/application.yml | 2 +- src/main/resources/static/css/styles.css | 7 + src/main/resources/static/js/dashboard.js | 118 ++++++++++---- src/main/resources/templates/dashboard.html | 16 +- src/main/resources/templates/index.html | 19 +-- 7 files changed, 268 insertions(+), 69 deletions(-) diff --git a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java index f9e3015..d80bd31 100644 --- a/src/main/java/com/imgfloat/app/controller/ChannelApiController.java +++ b/src/main/java/com/imgfloat/app/controller/ChannelApiController.java @@ -94,6 +94,27 @@ public class ChannelApiController { return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId); } + @GetMapping("/admins/suggestions") + public Collection listAdminSuggestions(@PathVariable("broadcaster") String broadcaster, + OAuth2AuthenticationToken authentication) { + String login = TwitchUser.from(authentication).login(); + ensureBroadcaster(broadcaster, login); + LOG.debug("Listing admin suggestions for {} by {}", broadcaster, login); + var channel = channelDirectoryService.getOrCreateChannel(broadcaster); + 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.fetchModerators(broadcaster, channel.getAdmins(), accessToken, clientId); + } + @DeleteMapping("/admins/{username}") public ResponseEntity removeAdmin(@PathVariable("broadcaster") String broadcaster, @PathVariable("username") String username, diff --git a/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java b/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java index 90314eb..7ee331e 100644 --- a/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java +++ b/src/main/java/com/imgfloat/app/service/TwitchUserLookupService.java @@ -24,6 +24,11 @@ import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.Set; +import java.util.Optional; @Service public class TwitchUserLookupService { @@ -48,10 +53,115 @@ public class TwitchUserLookupService { .distinct() .toList(); + Map byLogin = fetchUsers(normalizedLogins, accessToken, clientId); + + return normalizedLogins.stream() + .map(login -> toProfile(login, byLogin.get(login))) + .toList(); + } + + public List fetchModerators(String broadcasterLogin, + Collection existingAdmins, + String accessToken, + String clientId) { + if (broadcasterLogin == null || broadcasterLogin.isBlank()) { + return List.of(); + } + if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) { - return normalizedLogins.stream() - .map(login -> new TwitchUserProfile(login, login, null)) - .toList(); + return List.of(); + } + + String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT); + Map broadcasterData = fetchUsers(List.of(normalizedBroadcaster), accessToken, clientId); + String broadcasterId = Optional.ofNullable(broadcasterData.get(normalizedBroadcaster)) + .map(TwitchUserData::id) + .orElse(null); + + if (broadcasterId == null || broadcasterId.isBlank()) { + LOG.warn("No broadcaster id found for {} when fetching moderators", broadcasterLogin); + return List.of(); + } + + Set skipLogins = new HashSet<>(); + if (existingAdmins != null) { + existingAdmins.stream() + .filter(Objects::nonNull) + .map(login -> login.toLowerCase(Locale.ROOT)) + .forEach(skipLogins::add); + } + skipLogins.add(normalizedBroadcaster); + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + headers.add("Client-ID", clientId); + + List moderatorLogins = new ArrayList<>(); + String cursor = null; + + do { + UriComponentsBuilder builder = UriComponentsBuilder + .fromHttpUrl("https://api.twitch.tv/helix/moderation/moderators") + .queryParam("broadcaster_id", broadcasterId) + .queryParam("first", 100); + if (cursor != null && !cursor.isBlank()) { + builder.queryParam("after", cursor); + } + + try { + ResponseEntity response = restTemplate.exchange( + builder.build(true).toUri(), + HttpMethod.GET, + new HttpEntity<>(headers), + TwitchModeratorsResponse.class); + + TwitchModeratorsResponse body = response.getBody(); + if (body != null && body.data() != null) { + body.data().stream() + .filter(Objects::nonNull) + .map(ModeratorData::userLogin) + .filter(Objects::nonNull) + .map(login -> login.toLowerCase(Locale.ROOT)) + .filter(login -> !skipLogins.contains(login)) + .forEach(moderatorLogins::add); + } + + cursor = body != null && body.pagination() != null + ? body.pagination().cursor() + : null; + } catch (RestClientException ex) { + LOG.warn("Unable to fetch Twitch moderators for {}", broadcasterLogin, ex); + return List.of(); + } + } while (cursor != null && !cursor.isBlank()); + + if (moderatorLogins.isEmpty()) { + return List.of(); + } + + return fetchProfiles(moderatorLogins, accessToken, clientId); + } + + private TwitchUserProfile toProfile(String login, TwitchUserData data) { + if (data == null) { + return new TwitchUserProfile(login, login, null); + } + return new TwitchUserProfile(login, data.displayName(), data.profileImageUrl()); + } + + private Map fetchUsers(Collection logins, String accessToken, String clientId) { + if (logins == null || logins.isEmpty()) { + return Collections.emptyMap(); + } + + 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 Collections.emptyMap(); } HttpHeaders headers = new HttpHeaders(); @@ -70,41 +180,47 @@ public class TwitchUserLookupService { entity, TwitchUsersResponse.class); - Map byLogin = response.getBody() == null + return 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(); + (a, b) -> a, + HashMap::new)); } catch (RestClientException ex) { LOG.warn("Unable to fetch Twitch user profiles", ex); - return normalizedLogins.stream() - .map(login -> new TwitchUserProfile(login, login, null)) - .toList(); + return Collections.emptyMap(); } } - 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 id, String login, @JsonProperty("display_name") String displayName, @JsonProperty("profile_image_url") String profileImageUrl) { } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TwitchModeratorsResponse( + List data, + Pagination pagination) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record ModeratorData( + @JsonProperty("user_id") String userId, + @JsonProperty("user_login") String userLogin, + @JsonProperty("user_name") String userName) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record Pagination(String cursor) { + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 88c48f6..b0313d7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -46,7 +46,7 @@ spring: client-authentication-method: client_secret_post redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}" authorization-grant-type: authorization_code - scope: ["user:read:email"] + scope: ["user:read:email", "moderation:read"] provider: twitch: authorization-uri: https://id.twitch.tv/oauth2/authorize diff --git a/src/main/resources/static/css/styles.css b/src/main/resources/static/css/styles.css index 079ab58..54e8848 100644 --- a/src/main/resources/static/css/styles.css +++ b/src/main/resources/static/css/styles.css @@ -556,6 +556,13 @@ body { color: #e2e8f0; } +.card-section { + margin-top: 16px; + display: flex; + flex-direction: column; + gap: 8px; +} + .container { max-width: 960px; margin: 40px auto; diff --git a/src/main/resources/static/js/dashboard.js b/src/main/resources/static/js/dashboard.js index 39ee7d3..f6a591c 100644 --- a/src/main/resources/static/js/dashboard.js +++ b/src/main/resources/static/js/dashboard.js @@ -1,5 +1,36 @@ +function buildIdentity(admin) { + 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); + return identity; +} + function renderAdmins(list) { const adminList = document.getElementById('admin-list'); + if (!adminList) return; adminList.innerHTML = ''; if (!list || list.length === 0) { const empty = document.createElement('li'); @@ -12,33 +43,7 @@ function renderAdmins(list) { const li = document.createElement('li'); 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); + li.appendChild(buildIdentity(admin)); const actions = document.createElement('div'); actions.className = 'actions'; @@ -55,6 +60,54 @@ function renderAdmins(list) { }); } +function renderSuggestedAdmins(list) { + const suggestionList = document.getElementById('admin-suggestions'); + if (!suggestionList) return; + + suggestionList.innerHTML = ''; + if (!list || list.length === 0) { + const empty = document.createElement('li'); + empty.className = 'stacked-list-item'; + empty.textContent = 'No moderator suggestions right now'; + suggestionList.appendChild(empty); + return; + } + + list.forEach((admin) => { + const li = document.createElement('li'); + li.className = 'stacked-list-item'; + + li.appendChild(buildIdentity(admin)); + + const actions = document.createElement('div'); + actions.className = 'actions'; + + const addBtn = document.createElement('button'); + addBtn.type = 'button'; + addBtn.className = 'ghost'; + addBtn.textContent = 'Add as admin'; + addBtn.addEventListener('click', () => addAdmin(admin.login)); + + actions.appendChild(addBtn); + li.appendChild(actions); + suggestionList.appendChild(li); + }); +} + +function fetchSuggestedAdmins() { + fetch(`/api/channels/${broadcaster}/admins/suggestions`) + .then((r) => { + if (!r.ok) { + throw new Error('Failed to load admin suggestions'); + } + return r.json(); + }) + .then(renderSuggestedAdmins) + .catch(() => { + renderSuggestedAdmins([]); + }); +} + function fetchAdmins() { fetch(`/api/channels/${broadcaster}/admins`) .then((r) => { @@ -81,6 +134,7 @@ function removeAdmin(username) { showToast('Failed to remove admin. Please retry.', 'error'); } fetchAdmins(); + fetchSuggestedAdmins(); }).catch(() => { if (typeof showToast === 'function') { showToast('Failed to remove admin. Please retry.', 'error'); @@ -88,9 +142,9 @@ function removeAdmin(username) { }); } -function addAdmin() { +function addAdmin(usernameFromAction) { const input = document.getElementById('new-admin'); - const username = input.value.trim(); + const username = (usernameFromAction || input?.value || '').trim(); if (!username) { if (typeof showToast === 'function') { showToast('Enter a Twitch username to add as an admin.', 'info'); @@ -107,11 +161,14 @@ function addAdmin() { if (!response.ok) { throw new Error('Add admin failed'); } - input.value = ''; + if (input) { + input.value = ''; + } if (typeof showToast === 'function') { showToast(`Added @${username} as an admin.`, 'success'); } fetchAdmins(); + fetchSuggestedAdmins(); }) .catch(() => { if (typeof showToast === 'function') { @@ -187,4 +244,5 @@ function saveCanvasSettings() { } fetchAdmins(); +fetchSuggestedAdmins(); fetchCanvasSettings(); diff --git a/src/main/resources/templates/dashboard.html b/src/main/resources/templates/dashboard.html index b40ff69..9096690 100644 --- a/src/main/resources/templates/dashboard.html +++ b/src/main/resources/templates/dashboard.html @@ -76,7 +76,21 @@ -
    +
    +
    +

    Current

    +

    Admins

    +
    +
      +
      +
      +
      +

      Suggested

      +

      Your Twitch moderators

      +

      Add moderators who already help run your channel.

      +
      +
        +
        diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html index d44685f..e8aa3b1 100644 --- a/src/main/resources/templates/index.html +++ b/src/main/resources/templates/index.html @@ -22,28 +22,11 @@

        Overlay toolkit

        Keep your Twitch overlays tidy.

        -

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

        +

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

        -
          -
        • Instant overlay updates
        • -
        • Shared access for teammates
        • -
        -
        -
        -
        -
        -

        Ready to go live

        -

        Preview & publish quickly

        -
        - Secure -
        -
        -

        Spot check your canvas, push assets live, and keep everything aligned with your stream.

        -
        - Open dashboard