mirror of
https://github.com/imgfloat/server.git
synced 2026-02-05 03:39:26 +00:00
Initial moderator view
This commit is contained in:
@@ -94,6 +94,27 @@ public class ChannelApiController {
|
|||||||
return twitchUserLookupService.fetchProfiles(admins, accessToken, clientId);
|
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}")
|
@DeleteMapping("/admins/{username}")
|
||||||
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
|
public ResponseEntity<?> removeAdmin(@PathVariable("broadcaster") String broadcaster,
|
||||||
@PathVariable("username") String username,
|
@PathVariable("username") String username,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ import java.util.Map;
|
|||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.function.Function;
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
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
|
@Service
|
||||||
public class TwitchUserLookupService {
|
public class TwitchUserLookupService {
|
||||||
@@ -48,12 +53,117 @@ public class TwitchUserLookupService {
|
|||||||
.distinct()
|
.distinct()
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (accessToken == null || accessToken.isBlank() || clientId == null || clientId.isBlank()) {
|
Map<String, TwitchUserData> byLogin = fetchUsers(normalizedLogins, accessToken, clientId);
|
||||||
|
|
||||||
return normalizedLogins.stream()
|
return normalizedLogins.stream()
|
||||||
.map(login -> new TwitchUserProfile(login, login, null))
|
.map(login -> toProfile(login, byLogin.get(login)))
|
||||||
.toList();
|
.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();
|
HttpHeaders headers = new HttpHeaders();
|
||||||
headers.setBearerAuth(accessToken);
|
headers.setBearerAuth(accessToken);
|
||||||
headers.add("Client-ID", clientId);
|
headers.add("Client-ID", clientId);
|
||||||
@@ -70,41 +180,47 @@ public class TwitchUserLookupService {
|
|||||||
entity,
|
entity,
|
||||||
TwitchUsersResponse.class);
|
TwitchUsersResponse.class);
|
||||||
|
|
||||||
Map<String, TwitchUserData> byLogin = response.getBody() == null
|
return response.getBody() == null
|
||||||
? Collections.emptyMap()
|
? Collections.emptyMap()
|
||||||
: response.getBody().data().stream()
|
: response.getBody().data().stream()
|
||||||
.filter(Objects::nonNull)
|
.filter(Objects::nonNull)
|
||||||
.collect(Collectors.toMap(
|
.collect(Collectors.toMap(
|
||||||
user -> user.login().toLowerCase(Locale.ROOT),
|
user -> user.login().toLowerCase(Locale.ROOT),
|
||||||
Function.identity(),
|
Function.identity(),
|
||||||
(a, b) -> a));
|
(a, b) -> a,
|
||||||
|
HashMap::new));
|
||||||
return normalizedLogins.stream()
|
|
||||||
.map(login -> toProfile(login, byLogin.get(login)))
|
|
||||||
.toList();
|
|
||||||
} catch (RestClientException ex) {
|
} catch (RestClientException ex) {
|
||||||
LOG.warn("Unable to fetch Twitch user profiles", ex);
|
LOG.warn("Unable to fetch Twitch user profiles", ex);
|
||||||
return normalizedLogins.stream()
|
return Collections.emptyMap();
|
||||||
.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)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
private record TwitchUsersResponse(List<TwitchUserData> data) {
|
private record TwitchUsersResponse(List<TwitchUserData> data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||||
private record TwitchUserData(
|
private record TwitchUserData(
|
||||||
|
String id,
|
||||||
String login,
|
String login,
|
||||||
@JsonProperty("display_name") String displayName,
|
@JsonProperty("display_name") String displayName,
|
||||||
@JsonProperty("profile_image_url") String profileImageUrl) {
|
@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
|
client-authentication-method: client_secret_post
|
||||||
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
|
redirect-uri: "${TWITCH_REDIRECT_URI:{baseUrl}/login/oauth2/code/twitch}"
|
||||||
authorization-grant-type: authorization_code
|
authorization-grant-type: authorization_code
|
||||||
scope: ["user:read:email"]
|
scope: ["user:read:email", "moderation:read"]
|
||||||
provider:
|
provider:
|
||||||
twitch:
|
twitch:
|
||||||
authorization-uri: https://id.twitch.tv/oauth2/authorize
|
authorization-uri: https://id.twitch.tv/oauth2/authorize
|
||||||
|
|||||||
@@ -556,6 +556,13 @@ body {
|
|||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-section {
|
||||||
|
margin-top: 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.container {
|
.container {
|
||||||
max-width: 960px;
|
max-width: 960px;
|
||||||
margin: 40px auto;
|
margin: 40px auto;
|
||||||
|
|||||||
@@ -1,17 +1,4 @@
|
|||||||
function renderAdmins(list) {
|
function buildIdentity(admin) {
|
||||||
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';
|
|
||||||
|
|
||||||
const identity = document.createElement('div');
|
const identity = document.createElement('div');
|
||||||
identity.className = 'identity-row';
|
identity.className = 'identity-row';
|
||||||
|
|
||||||
@@ -38,7 +25,25 @@ function renderAdmins(list) {
|
|||||||
details.appendChild(subtitle);
|
details.appendChild(subtitle);
|
||||||
identity.appendChild(avatar);
|
identity.appendChild(avatar);
|
||||||
identity.appendChild(details);
|
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');
|
const actions = document.createElement('div');
|
||||||
actions.className = 'actions';
|
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() {
|
function fetchAdmins() {
|
||||||
fetch(`/api/channels/${broadcaster}/admins`)
|
fetch(`/api/channels/${broadcaster}/admins`)
|
||||||
.then((r) => {
|
.then((r) => {
|
||||||
@@ -81,6 +134,7 @@ function removeAdmin(username) {
|
|||||||
showToast('Failed to remove admin. Please retry.', 'error');
|
showToast('Failed to remove admin. Please retry.', 'error');
|
||||||
}
|
}
|
||||||
fetchAdmins();
|
fetchAdmins();
|
||||||
|
fetchSuggestedAdmins();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast('Failed to remove admin. Please retry.', 'error');
|
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 input = document.getElementById('new-admin');
|
||||||
const username = input.value.trim();
|
const username = (usernameFromAction || input?.value || '').trim();
|
||||||
if (!username) {
|
if (!username) {
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast('Enter a Twitch username to add as an admin.', 'info');
|
showToast('Enter a Twitch username to add as an admin.', 'info');
|
||||||
@@ -107,11 +161,14 @@ function addAdmin() {
|
|||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error('Add admin failed');
|
throw new Error('Add admin failed');
|
||||||
}
|
}
|
||||||
|
if (input) {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
|
}
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast(`Added @${username} as an admin.`, 'success');
|
showToast(`Added @${username} as an admin.`, 'success');
|
||||||
}
|
}
|
||||||
fetchAdmins();
|
fetchAdmins();
|
||||||
|
fetchSuggestedAdmins();
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
@@ -187,4 +244,5 @@ function saveCanvasSettings() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fetchAdmins();
|
fetchAdmins();
|
||||||
|
fetchSuggestedAdmins();
|
||||||
fetchCanvasSettings();
|
fetchCanvasSettings();
|
||||||
|
|||||||
@@ -76,8 +76,22 @@
|
|||||||
<input id="new-admin" placeholder="Twitch username" />
|
<input id="new-admin" placeholder="Twitch username" />
|
||||||
<button type="button" onclick="addAdmin()">Add admin</button>
|
<button type="button" onclick="addAdmin()">Add admin</button>
|
||||||
</div>
|
</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>
|
<ul id="admin-list" class="stacked-list"></ul>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<section th:if="${adminChannels != null}" class="card">
|
<section th:if="${adminChannels != null}" class="card">
|
||||||
|
|||||||
@@ -22,28 +22,11 @@
|
|||||||
<div class="hero-text">
|
<div class="hero-text">
|
||||||
<p class="eyebrow">Overlay toolkit</p>
|
<p class="eyebrow">Overlay toolkit</p>
|
||||||
<h1>Keep your Twitch overlays tidy.</h1>
|
<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">
|
<div class="cta-row">
|
||||||
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
<a class="button" href="/oauth2/authorization/twitch">Login with Twitch</a>
|
||||||
<a class="button ghost" href="/channels">Browse channels</a>
|
<a class="button ghost" href="/channels">Browse channels</a>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user