mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 11:49:25 +00:00
Initial moderator view
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId);
|
||||
|
||||
return normalizedLogins.stream()
|
||||
.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 normalizedLogins.stream()
|
||||
.map(login -> new TwitchUserProfile(login, login, null))
|
||||
.toList();
|
||||
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();
|
||||
@@ -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) {
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -76,7 +76,21 @@
|
||||
<input id="new-admin" placeholder="Twitch username" />
|
||||
<button type="button" onclick="addAdmin()">Add admin</button>
|
||||
</div>
|
||||
<ul id="admin-list" class="stacked-list"></ul>
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user