mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Add signup block display
This commit is contained in:
@@ -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<String> listAdmins(@PathVariable("broadcaster") String broadcaster,
|
||||
OAuth2AuthenticationToken authentication) {
|
||||
public Collection<TwitchUserProfile> 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<String> 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}")
|
||||
|
||||
12
src/main/java/com/imgfloat/app/model/TwitchUserProfile.java
Normal file
12
src/main/java/com/imgfloat/app/model/TwitchUserProfile.java
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TwitchUserProfile> fetchProfiles(Collection<String> logins, String accessToken, String clientId) {
|
||||
if (logins == null || logins.isEmpty()) {
|
||||
return List.of();
|
||||
}
|
||||
|
||||
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 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<Void> entity = new HttpEntity<>(headers);
|
||||
try {
|
||||
ResponseEntity<TwitchUsersResponse> response = restTemplate.exchange(
|
||||
uriBuilder.build(true).toUri(),
|
||||
HttpMethod.GET,
|
||||
entity,
|
||||
TwitchUsersResponse.class);
|
||||
|
||||
Map<String, TwitchUserData> 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<TwitchUserData> data) {
|
||||
}
|
||||
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
private record TwitchUserData(
|
||||
String login,
|
||||
@JsonProperty("display_name") String displayName,
|
||||
@JsonProperty("profile_image_url") String profileImageUrl) {
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user