mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-20 02:00:49 +00:00
feat: add test button for Twitch keys
This commit is contained in:
parent
9f8e9e40ab
commit
56f4619a08
25 changed files with 365 additions and 71 deletions
59
frontend/src/lib/twitch.ts
Normal file
59
frontend/src/lib/twitch.ts
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
export interface TwitchCredentials {
|
||||||
|
access_token: string;
|
||||||
|
expires_in: number;
|
||||||
|
token_type: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TwitchError {
|
||||||
|
status: number;
|
||||||
|
message: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieve OAuth2 client credentials for Twitch app
|
||||||
|
* @param clientId App Client ID
|
||||||
|
* @param clientSecret App Client secret
|
||||||
|
* @returns Twitch credentials object
|
||||||
|
* @throws Credentials are not valid or request failed
|
||||||
|
*/
|
||||||
|
export async function twitchAuth(
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<TwitchCredentials> {
|
||||||
|
const url = `https://id.twitch.tv/oauth2/token?client_id=${clientId}&client_secret=${clientSecret}&grant_type=client_credentials`;
|
||||||
|
|
||||||
|
const req = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
});
|
||||||
|
if (!req.ok) {
|
||||||
|
const err = (await req.json()) as TwitchError;
|
||||||
|
throw new Error(`authentication failed: ${err.message} (${err.status})'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return req.json() as Promise<TwitchCredentials>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if provided Twitch app credentials are fine by making a simple request
|
||||||
|
* @param clientId App Client ID
|
||||||
|
* @param clientSecret App Client secret
|
||||||
|
* @throws Credentials are not valid or request failed
|
||||||
|
*/
|
||||||
|
export async function checkTwitchKeys(
|
||||||
|
clientId: string,
|
||||||
|
clientSecret: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const creds = await twitchAuth(clientId, clientSecret);
|
||||||
|
console.log(creds);
|
||||||
|
const req = await fetch('https://api.twitch.tv/helix/streams?first=1', {
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${creds.access_token}`,
|
||||||
|
'Client-Id': clientId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!req.ok) {
|
||||||
|
const err = (await req.json()) as TwitchError;
|
||||||
|
throw new Error(`API test call failed: ${err.message}`);
|
||||||
|
}
|
||||||
|
}
|
|
@ -95,7 +95,10 @@
|
||||||
"current-status": "Current status"
|
"current-status": "Current status"
|
||||||
},
|
},
|
||||||
"app-category": "Category",
|
"app-category": "Category",
|
||||||
"app-oauth-redirect-url": "OAuth Redirect URLs"
|
"app-oauth-redirect-url": "OAuth Redirect URLs",
|
||||||
|
"test-button": "Test",
|
||||||
|
"test-failed": "Test failed: \"{{0}}\". Check your app client IDs and secret!",
|
||||||
|
"test-succeeded": "Test succeeded!"
|
||||||
},
|
},
|
||||||
"botcommands": {
|
"botcommands": {
|
||||||
"title": "Bot commands",
|
"title": "Bot commands",
|
||||||
|
@ -259,9 +262,16 @@
|
||||||
},
|
},
|
||||||
"onboarding": {
|
"onboarding": {
|
||||||
"welcome-header": "Welcome to {{APPNAME}}",
|
"welcome-header": "Welcome to {{APPNAME}}",
|
||||||
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
|
|
||||||
"welcome-continue-button": "Get started",
|
"welcome-continue-button": "Get started",
|
||||||
"skip-button": "Skip onboarding"
|
"skip-button": "Skip onboarding",
|
||||||
|
"welcome-p1": "It looks like this is the first time you started {{APPNAME}}. You can click the \"Get started\" button at the bottom to set things up one by one, or just skip everything and configure things manually later.",
|
||||||
|
"welcome-p2": "Heads up: if you're used to other platforms, this unfortunately will require some more work on your end.",
|
||||||
|
"sections": {
|
||||||
|
"landing": "Welcome",
|
||||||
|
"twitch-config": "Twitch integration",
|
||||||
|
"twitch-bot": "Twitch bot",
|
||||||
|
"done": "All done!"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"uiconfig": {
|
"uiconfig": {
|
||||||
"title": "User interface settings",
|
"title": "User interface settings",
|
||||||
|
|
|
@ -258,7 +258,14 @@
|
||||||
"skip-button": "Salta procedura guidata",
|
"skip-button": "Salta procedura guidata",
|
||||||
"welcome-continue-button": "Cominciamo",
|
"welcome-continue-button": "Cominciamo",
|
||||||
"welcome-header": "Benvenuto su {{APPNAME}}",
|
"welcome-header": "Benvenuto su {{APPNAME}}",
|
||||||
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento."
|
"welcome-p1": "Sembra che questa sia la prima volta che avvii {{APPNAME}}. \nPuoi fare clic sul pulsante \"Cominciamo\" in basso per impostare tutto con una procedura guidata oppure semplicemente saltare tutto e configurare le cose manualmente in un secondo momento.",
|
||||||
|
"welcome-p2": "Giusto una cosa: se sei abituato ad altre piattaforme, stavolta toccherà un po' più lavoro da parte tua!",
|
||||||
|
"sections": {
|
||||||
|
"done": "Pronti a partire!",
|
||||||
|
"landing": "Benvenuto",
|
||||||
|
"twitch-bot": "Bot per Twitch",
|
||||||
|
"twitch-config": "Integrazione Twitch"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"strimertul": {
|
"strimertul": {
|
||||||
"credits-header": "Ringraziamenti",
|
"credits-header": "Ringraziamenti",
|
||||||
|
|
|
@ -12,7 +12,7 @@ import {
|
||||||
import KilovoltWS from '@strimertul/kilovolt-client';
|
import KilovoltWS from '@strimertul/kilovolt-client';
|
||||||
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
import type { kvError } from '@strimertul/kilovolt-client/types/messages';
|
||||||
import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App';
|
import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App';
|
||||||
import { delay } from '~/lib/time-utils';
|
import { delay } from '~/lib/time';
|
||||||
import {
|
import {
|
||||||
APIState,
|
APIState,
|
||||||
ConnectionStatus,
|
ConnectionStatus,
|
||||||
|
|
|
@ -144,6 +144,7 @@ export interface LoyaltyRedeem {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UISettings {
|
export interface UISettings {
|
||||||
|
onboardingStatus: number;
|
||||||
onboardingDone: boolean;
|
onboardingDone: boolean;
|
||||||
language: string;
|
language: string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { SortFunction } from '~/lib/type-utils';
|
import { SortFunction } from '~/lib/types';
|
||||||
import { styled } from '../theme';
|
import { styled } from '../theme';
|
||||||
import { Table, TableHeader } from '../theme/table';
|
import { Table, TableHeader } from '../theme/table';
|
||||||
import PageList from './PageList';
|
import PageList from './PageList';
|
||||||
|
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import { RootState } from 'src/store';
|
import { RootState } from 'src/store';
|
||||||
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
import * as DialogPrimitive from '@radix-ui/react-dialog';
|
||||||
import { delay } from '~/lib/time-utils';
|
import { delay } from '~/lib/time';
|
||||||
import { ProcessedLogEntry } from '~/store/logging/reducer';
|
import { ProcessedLogEntry } from '~/store/logging/reducer';
|
||||||
import {
|
import {
|
||||||
Dialog,
|
Dialog,
|
||||||
|
|
|
@ -38,21 +38,13 @@ function PageList({
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ToolbarSection
|
<ToolbarSection css={{ flex: 1, '@medium': { flex: 0 } }}>
|
||||||
css={{
|
|
||||||
'@mobile': { flex: 1 },
|
|
||||||
'@medium': { flex: 0 },
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
aria-label={t('pagination.previous')}
|
aria-label={t('pagination.previous')}
|
||||||
title={t('pagination.previous')}
|
title={t('pagination.previous')}
|
||||||
disabled={current <= min}
|
disabled={current <= min}
|
||||||
onClick={() => onPageChange(current - 1)}
|
onClick={() => onPageChange(current - 1)}
|
||||||
css={{
|
css={{ flex: 1, '@medium': { flex: 0 } }}
|
||||||
'@mobile': { flex: 1 },
|
|
||||||
'@medium': { flex: 0 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
@ -61,10 +53,7 @@ function PageList({
|
||||||
title={t('pagination.next')}
|
title={t('pagination.next')}
|
||||||
disabled={current >= max}
|
disabled={current >= max}
|
||||||
onClick={() => onPageChange(current + 1)}
|
onClick={() => onPageChange(current + 1)}
|
||||||
css={{
|
css={{ flex: 1, '@medium': { flex: 0 } }}
|
||||||
'@mobile': { flex: 1 },
|
|
||||||
'@medium': { flex: 0 },
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
</ToolbarButton>
|
</ToolbarButton>
|
||||||
|
@ -75,7 +64,7 @@ function PageList({
|
||||||
onChange={(ev) => onSelectChange(Number(ev.target.value))}
|
onChange={(ev) => onSelectChange(Number(ev.target.value))}
|
||||||
css={{
|
css={{
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
'@mobile': { flex: 1 },
|
flex: 1,
|
||||||
'@medium': { flex: 0 },
|
'@medium': { flex: 0 },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { getInterval } from '~/lib/time-utils';
|
import { getInterval } from '~/lib/time';
|
||||||
import { ComboBox, FlexRow, InputBox } from '../../theme';
|
import { ComboBox, FlexRow, InputBox } from '../../theme';
|
||||||
|
|
||||||
export interface TimeUnit {
|
export interface TimeUnit {
|
||||||
|
|
|
@ -13,8 +13,10 @@ function PasswordField(
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
) {
|
) {
|
||||||
|
const subprops = { ...props };
|
||||||
|
delete subprops.reveal;
|
||||||
return (
|
return (
|
||||||
<input type={props.reveal ? 'text' : 'password'} {...props}>
|
<input type={props.reveal ? 'text' : 'password'} {...subprops}>
|
||||||
{props.children}
|
{props.children}
|
||||||
</input>
|
</input>
|
||||||
);
|
);
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { PlusIcon } from '@radix-ui/react-icons';
|
import { PlusIcon } from '@radix-ui/react-icons';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule } from '~/lib/react-utils';
|
import { useModule } from '~/lib/react';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import { modules } from '~/store/api/reducer';
|
import { modules } from '~/store/api/reducer';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -2,7 +2,7 @@ import { PlusIcon } from '@radix-ui/react-icons';
|
||||||
import { TFunction } from 'i18next';
|
import { TFunction } from 'i18next';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule } from '~/lib/react-utils';
|
import { useModule } from '~/lib/react';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import { modules } from '~/store/api/reducer';
|
import { modules } from '~/store/api/reducer';
|
||||||
import { TwitchBotTimer } from '~/store/api/types';
|
import { TwitchBotTimer } from '~/store/api/types';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { CheckIcon } from '@radix-ui/react-icons';
|
import { CheckIcon } from '@radix-ui/react-icons';
|
||||||
import { useModule, useStatus } from '~/lib/react-utils';
|
import { useModule, useStatus } from '~/lib/react';
|
||||||
import apiReducer, { modules } from '~/store/api/reducer';
|
import apiReducer, { modules } from '~/store/api/reducer';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import MultiInput from '../components/forms/MultiInput';
|
import MultiInput from '../components/forms/MultiInput';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { CircleIcon } from '@radix-ui/react-icons';
|
import { CircleIcon } from '@radix-ui/react-icons';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useLiveKey } from '~/lib/react-utils';
|
import { useLiveKey } from '~/lib/react';
|
||||||
import { PageContainer, SectionHeader, styled } from '../theme';
|
import { PageContainer, SectionHeader, styled } from '../theme';
|
||||||
import WIPNotice from '../components/utils/WIPNotice';
|
import WIPNotice from '../components/utils/WIPNotice';
|
||||||
import BrowserLink from '../components/BrowserLink';
|
import BrowserLink from '../components/BrowserLink';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { CheckIcon } from '@radix-ui/react-icons';
|
import { CheckIcon } from '@radix-ui/react-icons';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule, useStatus } from '~/lib/react-utils';
|
import { useModule, useStatus } from '~/lib/react';
|
||||||
import apiReducer, { modules } from '~/store/api/reducer';
|
import apiReducer, { modules } from '~/store/api/reducer';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import {
|
import {
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule, useUserPoints } from '~/lib/react-utils';
|
import { useModule, useUserPoints } from '~/lib/react';
|
||||||
import { SortFunction } from '~/lib/type-utils';
|
import { SortFunction } from '~/lib/types';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer';
|
import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer';
|
||||||
import { DataTable } from '../components/DataTable';
|
import { DataTable } from '../components/DataTable';
|
||||||
|
|
|
@ -1,7 +1,7 @@
|
||||||
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
|
import { CheckIcon, PlusIcon } from '@radix-ui/react-icons';
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule } from '~/lib/react-utils';
|
import { useModule } from '~/lib/react';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import { modules } from '~/store/api/reducer';
|
import { modules } from '~/store/api/reducer';
|
||||||
import { LoyaltyGoal, LoyaltyReward } from '~/store/api/types';
|
import { LoyaltyGoal, LoyaltyReward } from '~/store/api/types';
|
||||||
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||||
import { keyframes } from '@stitches/react';
|
import { keyframes } from '@stitches/react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
@ -5,11 +6,30 @@ import { useNavigate } from 'react-router-dom';
|
||||||
|
|
||||||
// @ts-expect-error Asset import
|
// @ts-expect-error Asset import
|
||||||
import spinner from '~/assets/icon-logo.svg';
|
import spinner from '~/assets/icon-logo.svg';
|
||||||
import { useModule } from '~/lib/react-utils';
|
import { useModule, useStatus } from '~/lib/react';
|
||||||
|
import { languages } from '~/locale/languages';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import { modules } from '~/store/api/reducer';
|
import apiReducer, { modules } from '~/store/api/reducer';
|
||||||
|
import AlertContent from '../components/AlertContent';
|
||||||
|
import SaveButton from '../components/forms/SaveButton';
|
||||||
|
import RevealLink from '../components/utils/RevealLink';
|
||||||
|
|
||||||
import { Button, PageContainer, styled, TextBlock } from '../theme';
|
import {
|
||||||
|
Button,
|
||||||
|
Field,
|
||||||
|
FieldNote,
|
||||||
|
InputBox,
|
||||||
|
Label,
|
||||||
|
MultiToggle,
|
||||||
|
MultiToggleItem,
|
||||||
|
PageContainer,
|
||||||
|
PageHeader,
|
||||||
|
PageTitle,
|
||||||
|
PasswordInputBox,
|
||||||
|
styled,
|
||||||
|
TextBlock,
|
||||||
|
} from '../theme';
|
||||||
|
import { Alert } from '../theme/alert';
|
||||||
|
|
||||||
const Container = styled('div', {
|
const Container = styled('div', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -56,6 +76,20 @@ const HeroContainer = styled('div', {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const HeroLanguageSelector = styled('div', {
|
||||||
|
top: '10px',
|
||||||
|
left: '10px',
|
||||||
|
display: 'flex',
|
||||||
|
gap: '1rem',
|
||||||
|
position: 'absolute',
|
||||||
|
zIndex: '10',
|
||||||
|
});
|
||||||
|
|
||||||
|
const LanguageItem = styled(MultiToggleItem, {
|
||||||
|
fontSize: '1rem',
|
||||||
|
padding: '5px 8px',
|
||||||
|
});
|
||||||
|
|
||||||
const HeroAnimation = styled('div', {
|
const HeroAnimation = styled('div', {
|
||||||
bottom: '-50px',
|
bottom: '-50px',
|
||||||
left: '50%',
|
left: '50%',
|
||||||
|
@ -95,6 +129,10 @@ const Spinner = styled('img', {
|
||||||
const StepContainer = styled(PageContainer, {
|
const StepContainer = styled(PageContainer, {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
'& p': {
|
||||||
|
margin: '1.5rem 0',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const ActionContainer = styled('div', {
|
const ActionContainer = styled('div', {
|
||||||
|
@ -102,21 +140,81 @@ const ActionContainer = styled('div', {
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
gap: '1rem',
|
gap: '1rem',
|
||||||
|
paddingTop: '1rem',
|
||||||
|
});
|
||||||
|
|
||||||
|
const StepList = styled('nav', {
|
||||||
|
flex: '1',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: '0 1rem',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
flexDirection: 'row',
|
||||||
|
justifyContent: 'flex-start',
|
||||||
|
});
|
||||||
|
|
||||||
|
const StepName = styled('div', {
|
||||||
|
padding: '0.5rem',
|
||||||
|
color: '$gray10',
|
||||||
|
'&:not(:last-child)::after': {
|
||||||
|
color: '$gray10',
|
||||||
|
content: '›',
|
||||||
|
margin: '0 0 0 1rem',
|
||||||
|
},
|
||||||
|
display: 'none',
|
||||||
|
'@thin': {
|
||||||
|
display: 'inherit',
|
||||||
|
},
|
||||||
|
variants: {
|
||||||
|
status: {
|
||||||
|
active: {
|
||||||
|
color: '$teal12',
|
||||||
|
display: 'inherit',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
interaction: {
|
||||||
|
clickable: {
|
||||||
|
cursor: 'pointer',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
enum OnboardingSteps {
|
enum OnboardingSteps {
|
||||||
Landing = 0,
|
Landing = 0,
|
||||||
ServerConfig = 1,
|
TwitchIntegration = 1,
|
||||||
|
TwitchBot = 2,
|
||||||
|
Done = 999,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const steps = [
|
||||||
|
OnboardingSteps.Landing,
|
||||||
|
OnboardingSteps.TwitchIntegration,
|
||||||
|
OnboardingSteps.TwitchBot,
|
||||||
|
OnboardingSteps.Done,
|
||||||
|
];
|
||||||
|
|
||||||
|
const stepI18n = {
|
||||||
|
[OnboardingSteps.Landing]: 'pages.onboarding.sections.landing',
|
||||||
|
[OnboardingSteps.TwitchIntegration]:
|
||||||
|
'pages.onboarding.sections.twitch-config',
|
||||||
|
[OnboardingSteps.TwitchBot]: 'pages.onboarding.sections.twitch-bot',
|
||||||
|
[OnboardingSteps.Done]: 'pages.onboarding.sections.done',
|
||||||
|
};
|
||||||
|
|
||||||
|
const maxKeys = languages.reduce(
|
||||||
|
(current, it) => Math.max(current, it.keys),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
export default function OnboardingPage() {
|
export default function OnboardingPage() {
|
||||||
const { t } = useTranslation();
|
const [t, i18n] = useTranslation();
|
||||||
const [animationItems, setAnimationItems] = useState<JSX.Element[]>([]);
|
const [animationItems, setAnimationItems] = useState<JSX.Element[]>([]);
|
||||||
const [currentStep, setStep] = useState(OnboardingSteps.Landing);
|
|
||||||
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
|
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
const currentStep = steps[uiConfig?.onboardingStatus || 0];
|
||||||
const landing = currentStep === OnboardingSteps.Landing;
|
const landing = currentStep === OnboardingSteps.Landing;
|
||||||
|
|
||||||
const onboardingDone = uiConfig?.onboardingDone;
|
const onboardingDone = uiConfig?.onboardingDone;
|
||||||
|
@ -158,26 +256,10 @@ export default function OnboardingPage() {
|
||||||
);
|
);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
let currentStepBody: JSX.Element = null;
|
||||||
<Container>
|
switch (currentStep) {
|
||||||
<TopBanner>
|
case OnboardingSteps.Landing:
|
||||||
{landing ? (
|
currentStepBody = (
|
||||||
<HeroContainer>
|
|
||||||
<HeroAnimation>{animationItems}</HeroAnimation>
|
|
||||||
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
|
|
||||||
<HeroContent>
|
|
||||||
<TextBlock>{t('pages.onboarding.welcome-p1')}</TextBlock>
|
|
||||||
<TextBlock css={{ color: '$gray11' }}>
|
|
||||||
Heads up: if you're used to other platforms, this unfortunately
|
|
||||||
will require some more work on your end.
|
|
||||||
</TextBlock>
|
|
||||||
</HeroContent>
|
|
||||||
</HeroContainer>
|
|
||||||
) : (
|
|
||||||
<div></div>
|
|
||||||
)}
|
|
||||||
</TopBanner>
|
|
||||||
<StepContainer>
|
|
||||||
<ActionContainer>
|
<ActionContainer>
|
||||||
<Button
|
<Button
|
||||||
css={{ width: '20vw', justifyContent: 'center' }}
|
css={{ width: '20vw', justifyContent: 'center' }}
|
||||||
|
@ -188,12 +270,91 @@ export default function OnboardingPage() {
|
||||||
<Button
|
<Button
|
||||||
css={{ width: '20vw', justifyContent: 'center' }}
|
css={{ width: '20vw', justifyContent: 'center' }}
|
||||||
variation="primary"
|
variation="primary"
|
||||||
onClick={() => setStep(OnboardingSteps.ServerConfig)}
|
onClick={() => {
|
||||||
|
void dispatch(
|
||||||
|
setUiConfig({
|
||||||
|
...uiConfig,
|
||||||
|
onboardingStatus: (uiConfig?.onboardingStatus ?? 0) + 1,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{t('pages.onboarding.welcome-continue-button')}
|
{t('pages.onboarding.welcome-continue-button')}
|
||||||
</Button>
|
</Button>
|
||||||
</ActionContainer>
|
</ActionContainer>
|
||||||
</StepContainer>
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container>
|
||||||
|
<TopBanner>
|
||||||
|
{landing ? (
|
||||||
|
<HeroContainer>
|
||||||
|
<HeroLanguageSelector>
|
||||||
|
<MultiToggle
|
||||||
|
value={uiConfig?.language ?? i18n.resolvedLanguage}
|
||||||
|
type="single"
|
||||||
|
onValueChange={(newLang) => {
|
||||||
|
void dispatch(
|
||||||
|
setUiConfig({ ...uiConfig, language: newLang }),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{languages.map((lang) => (
|
||||||
|
<LanguageItem
|
||||||
|
key={lang.code}
|
||||||
|
aria-label={lang.name}
|
||||||
|
value={lang.code}
|
||||||
|
title={`${lang.name} ${
|
||||||
|
lang.keys < maxKeys
|
||||||
|
? `(${t('pages.uiconfig.partial-translation')})`
|
||||||
|
: ''
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{lang.name}
|
||||||
|
{lang.keys < maxKeys ? <ExclamationTriangleIcon /> : null}
|
||||||
|
</LanguageItem>
|
||||||
|
))}
|
||||||
|
</MultiToggle>
|
||||||
|
</HeroLanguageSelector>
|
||||||
|
<HeroAnimation>{animationItems}</HeroAnimation>
|
||||||
|
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
|
||||||
|
<HeroContent>
|
||||||
|
<TextBlock>{t('pages.onboarding.welcome-p1')}</TextBlock>
|
||||||
|
<TextBlock css={{ color: '$gray11' }}>
|
||||||
|
{t('pages.onboarding.welcome-p2')}
|
||||||
|
</TextBlock>
|
||||||
|
</HeroContent>
|
||||||
|
</HeroContainer>
|
||||||
|
) : (
|
||||||
|
<StepList>
|
||||||
|
{steps.map((step) => (
|
||||||
|
<StepName
|
||||||
|
key={step}
|
||||||
|
interaction={step < currentStep ? 'clickable' : undefined}
|
||||||
|
status={step === currentStep ? 'active' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
// Can't skip ahead
|
||||||
|
if (step >= currentStep) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void dispatch(
|
||||||
|
setUiConfig({
|
||||||
|
...uiConfig,
|
||||||
|
onboardingStatus:
|
||||||
|
steps.findIndex((val) => val === step) ?? 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t(stepI18n[step])}
|
||||||
|
</StepName>
|
||||||
|
))}
|
||||||
|
</StepList>
|
||||||
|
)}
|
||||||
|
</TopBanner>
|
||||||
|
<StepContainer>{currentStepBody}</StepContainer>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule, useStatus } from '~/lib/react-utils';
|
import { useModule, useStatus } from '~/lib/react';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import apiReducer, { modules } from '~/store/api/reducer';
|
import apiReducer, { modules } from '~/store/api/reducer';
|
||||||
import AlertContent from '../components/AlertContent';
|
import AlertContent from '../components/AlertContent';
|
||||||
|
|
|
@ -6,9 +6,10 @@ import React, { useEffect, useState } from 'react';
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import eventsubTests from '~/data/eventsub-tests';
|
import eventsubTests from '~/data/eventsub-tests';
|
||||||
import { useModule, useStatus } from '~/lib/react-utils';
|
import { useModule, useStatus } from '~/lib/react';
|
||||||
import { RootState, useAppDispatch } from '~/store';
|
import { RootState, useAppDispatch } from '~/store';
|
||||||
import apiReducer, { modules } from '~/store/api/reducer';
|
import apiReducer, { modules } from '~/store/api/reducer';
|
||||||
|
import { checkTwitchKeys } from '~/lib/twitch';
|
||||||
import BrowserLink from '../components/BrowserLink';
|
import BrowserLink from '../components/BrowserLink';
|
||||||
import DefinitionTable from '../components/DefinitionTable';
|
import DefinitionTable from '../components/DefinitionTable';
|
||||||
import RevealLink from '../components/utils/RevealLink';
|
import RevealLink from '../components/utils/RevealLink';
|
||||||
|
@ -35,6 +36,8 @@ import {
|
||||||
TabList,
|
TabList,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
} from '../theme';
|
} from '../theme';
|
||||||
|
import AlertContent from '../components/AlertContent';
|
||||||
|
import { Alert } from '../theme/alert';
|
||||||
|
|
||||||
const StepList = styled('ul', {
|
const StepList = styled('ul', {
|
||||||
lineHeight: '1.5',
|
lineHeight: '1.5',
|
||||||
|
@ -195,6 +198,8 @@ function TwitchBotSettings() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type TestResult = { open: boolean; error?: Error };
|
||||||
|
|
||||||
function TwitchAPISettings() {
|
function TwitchAPISettings() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [httpConfig] = useModule(modules.httpConfig);
|
const [httpConfig] = useModule(modules.httpConfig);
|
||||||
|
@ -204,6 +209,27 @@ function TwitchAPISettings() {
|
||||||
const status = useStatus(loadStatus.save);
|
const status = useStatus(loadStatus.save);
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
const [revealClientSecret, setRevealClientSecret] = useState(false);
|
const [revealClientSecret, setRevealClientSecret] = useState(false);
|
||||||
|
const [testing, setTesting] = useState(false);
|
||||||
|
const [testResult, setTestResult] = useState<TestResult>({
|
||||||
|
open: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function checkCredentials() {
|
||||||
|
setTesting(true);
|
||||||
|
if (twitchConfig) {
|
||||||
|
try {
|
||||||
|
await checkTwitchKeys(
|
||||||
|
twitchConfig.api_client_id,
|
||||||
|
twitchConfig.api_client_secret,
|
||||||
|
);
|
||||||
|
setTestResult({ open: true });
|
||||||
|
} catch (e: unknown) {
|
||||||
|
console.log(e);
|
||||||
|
setTestResult({ open: true, error: e as Error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setTesting(false);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
|
@ -291,7 +317,40 @@ function TwitchAPISettings() {
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
<SaveButton status={status} />
|
<ButtonGroup>
|
||||||
|
<SaveButton status={status} />
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
void checkCredentials();
|
||||||
|
}}
|
||||||
|
disabled={testing}
|
||||||
|
>
|
||||||
|
{t('pages.twitch-settings.test-button')}
|
||||||
|
</Button>
|
||||||
|
</ButtonGroup>
|
||||||
|
<Alert
|
||||||
|
defaultOpen={false}
|
||||||
|
open={testResult.open}
|
||||||
|
onOpenChange={(val: boolean) => {
|
||||||
|
setTestResult({ ...testResult, open: val });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AlertContent
|
||||||
|
variation={testResult.error ? 'danger' : 'default'}
|
||||||
|
description={
|
||||||
|
testResult.error
|
||||||
|
? t('pages.twitch-settings.test-failed', [
|
||||||
|
testResult.error.message,
|
||||||
|
])
|
||||||
|
: t('pages.twitch-settings.test-succeeded')
|
||||||
|
}
|
||||||
|
actionText={t('form-actions.ok')}
|
||||||
|
onAction={() => {
|
||||||
|
setTestResult({ ...testResult, open: false });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Alert>
|
||||||
</form>
|
</form>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useModule } from '~/lib/react-utils';
|
import { useModule } from '~/lib/react';
|
||||||
import { languages } from '~/locale/languages';
|
import { languages } from '~/locale/languages';
|
||||||
import { useAppDispatch } from '~/store';
|
import { useAppDispatch } from '~/store';
|
||||||
import { modules } from '~/store/api/reducer';
|
import { modules } from '~/store/api/reducer';
|
||||||
|
@ -19,16 +19,16 @@ const PartialWarning = styled('small', {
|
||||||
color: '$yellow11',
|
color: '$yellow11',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const maxKeys = languages.reduce(
|
||||||
|
(current, it) => Math.max(current, it.keys),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
export default function UISettingsPage(): React.ReactElement {
|
export default function UISettingsPage(): React.ReactElement {
|
||||||
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
|
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
|
||||||
const [t, i18n] = useTranslation();
|
const [t, i18n] = useTranslation();
|
||||||
const dispatch = useAppDispatch();
|
const dispatch = useAppDispatch();
|
||||||
|
|
||||||
const maxKeys = languages.reduce(
|
|
||||||
(current, it) => Math.max(current, it.keys),
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageContainer>
|
<PageContainer>
|
||||||
<PageHeader css={{ paddingBottom: '1rem' }}>
|
<PageHeader css={{ paddingBottom: '1rem' }}>
|
||||||
|
@ -63,7 +63,13 @@ export default function UISettingsPage(): React.ReactElement {
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void dispatch(setUiConfig({ ...uiConfig, onboardingDone: false }));
|
void dispatch(
|
||||||
|
setUiConfig({
|
||||||
|
...uiConfig,
|
||||||
|
onboardingDone: false,
|
||||||
|
onboardingStatus: 0,
|
||||||
|
}),
|
||||||
|
);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('pages.uiconfig.repeat-onboarding')}
|
{t('pages.uiconfig.repeat-onboarding')}
|
||||||
|
|
|
@ -46,7 +46,7 @@ export const { styled, theme } = createStitches({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
media: {
|
media: {
|
||||||
mobile: '(min-width: 640px)',
|
thin: '(min-width: 480px)',
|
||||||
medium: '(min-width: 768px)',
|
medium: '(min-width: 768px)',
|
||||||
wide: '(min-width: 1024px)',
|
wide: '(min-width: 1024px)',
|
||||||
},
|
},
|
||||||
|
|
Loading…
Reference in a new issue