Initial moderator view

This commit is contained in:
2025-12-10 19:56:39 +01:00
parent d99ecfb4aa
commit 5e0ef4fa74
7 changed files with 268 additions and 69 deletions

View File

@@ -94,6 +94,27 @@ public class ChannelApiController {
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
}
@GetMapping("/admins/suggestions")
public Collection<TwitchUserProfile> 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,

View File

@@ -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,12 +53,117 @@ public class TwitchUserLookupService {
.distinct()
.toList();
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
Map<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId);
return normalizedLogins.stream()
.map(login -> new TwitchUserProfile(login, login, null))
.map(login -> toProfile(login, byLogin.get(login)))
.toList();
}
public List<TwitchUserProfile> fetchModerators(String broadcasterLogin,
Collection<String> existingAdmins,
String accessToken,
String clientId) {
if (broadcasterLogin == null || broadcasterLogin.isBlank()) {
return List.of();
}
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
return List.of();
}
String normalizedBroadcaster = broadcasterLogin.toLowerCase(Locale.ROOT);
Map<String, TwitchUserData> 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<String> 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<String> 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<TwitchModeratorsResponse> 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<String, TwitchUserData> fetchUsers(Collection<String> logins, String accessToken, String clientId) {
if (logins == null || logins.isEmpty()) {
return Collections.emptyMap();
}
List<String> 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();
headers.setBearerAuth(accessToken);
headers.add("Client-ID", clientId);
@@ -70,41 +180,47 @@ public class TwitchUserLookupService {
entity,
TwitchUsersResponse.class);
Map<String, TwitchUserData> 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<TwitchUserData> 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<ModeratorData> 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) {
}
}

View File

@@ -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

View File

@@ -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;

View File

@@ -1,17 +1,4 @@
function renderAdmins(list) {
const adminList = document.getElementById('admin-list');
adminList.innerHTML = '';
if (!list || list.length === 0) {
const empty = document.createElement('li');
empty.textContent = 'No channel admins yet';
adminList.appendChild(empty);
return;
}
list.forEach((admin) => {
const li = document.createElement('li');
li.className = 'stacked-list-item';
function buildIdentity(admin) {
const identity = document.createElement('div');
identity.className = 'identity-row';
@@ -38,7 +25,25 @@ function renderAdmins(list) {
details.appendChild(subtitle);
identity.appendChild(avatar);
identity.appendChild(details);
li.appendChild(identity);
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');
empty.textContent = 'No channel admins yet';
adminList.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';
@@ -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');
}
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();

View File

@@ -76,8 +76,22 @@
<input id="new-admin" placeholder="Twitch username" />
<button type="button" onclick="addAdmin()">Add admin</button>
</div>
<div class="card-section">
<div class="section-header">
<p class="eyebrow subtle">Current</p>
<h4 class="list-title">Admins</h4>
</div>
<ul id="admin-list" class="stacked-list"></ul>
</div>
<div class="card-section">
<div class="section-header">
<p class="eyebrow subtle">Suggested</p>
<h4 class="list-title">Your Twitch moderators</h4>
<p class="muted">Add moderators who already help run your channel.</p>
</div>
<ul id="admin-suggestions" class="stacked-list"></ul>
</div>
</div>
</section>
<section th:if="${adminChannels != null}" class="card">

View File

@@ -22,28 +22,11 @@
<div class="hero-text">
<p class="eyebrow">Overlay toolkit</p>
<h1>Keep your Twitch overlays tidy.</h1>
<p class="lead">Upload artwork, drop it into a shared dashboard, and stay in sync with your mods.</p>
<p class="lead">Upload artwork, drop it into a shared dashboard, and stay in sync with your mods without clutter.</p>
<div class="cta-row">
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
<a class="button ghost" href="/channels">Browse channels</a>
</div>
<ul class="pill-list minimal">
<li>Instant overlay updates</li>
<li>Shared access for teammates</li>
</ul>
</div>
<div class="hero-panel hero-preview">
<div class="panel-header">
<div>
<p class="eyebrow subtle">Ready to go live</p>
<h3>Preview &amp; publish quickly</h3>
</div>
<span class="badge">Secure</span>
</div>
<div class="preview-summary">
<p class="muted">Spot check your canvas, push assets live, and keep everything aligned with your stream.</p>
</div>
<a class="button block" href="/oauth2/authorization/twitch">Open dashboard</a>
</div>
</main>