strimertul/frontend/src/ui/pages/Onboarding.tsx

705 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ExclamationTriangleIcon,
ExternalLinkIcon,
} from '@radix-ui/react-icons';
import { keyframes } from '@stitches/react';
import { GetTwitchLoggedUser, GetTwitchAuthURL } from '@wailsapp/go/main/App';
import { helix } from '@wailsapp/go/models';
import { BrowserOpenURL } from '@wailsapp/runtime/runtime';
import { useSelector } from 'react-redux';
import { useEffect, useState } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useModule } from '~/lib/react';
import {
checkTwitchKeys,
startAuthFlow,
TwitchCredentials,
} from '~/lib/twitch';
import { languages } from '~/locale/languages';
import { RootState, useAppDispatch } from '~/store';
import apiReducer, { modules } from '~/store/api/reducer';
// @ts-expect-error Asset import
import spinner from '~/assets/icon-logo.svg';
import AlertContent from '../components/AlertContent';
import BrowserLink from '../components/BrowserLink';
import DefinitionTable from '../components/DefinitionTable';
import RevealLink from '../components/utils/RevealLink';
import Channels from '../components/utils/Channels';
import {
Button,
ButtonGroup,
Field,
InputBox,
Label,
lightMode,
MultiToggle,
MultiToggleItem,
PageContainer,
PasswordInputBox,
SectionHeader,
styled,
TextBlock,
themes,
} from '../theme';
import { Alert } from '../theme/alert';
import TwitchUserBlock from '../components/TwitchUserBlock';
const Container = styled('div', {
display: 'flex',
flexDirection: 'column',
width: '100%',
});
const TopBanner = styled('div', {
backgroundColor: '$gray2',
display: 'flex',
width: '100%',
transition: 'all 100ms ease-out',
});
const appear = keyframes({
'0%': { opacity: 0, transform: 'translate(0, 30px)' },
'100%': { opacity: 1, transform: 'translate(0, 0)' },
});
const HeroTitle = styled('h1', {
fontSize: '35pt',
fontWeight: 200,
textAlign: 'center',
padding: 0,
margin: 0,
marginBottom: '1em',
'@media (prefers-reduced-motion: no-preference)': {
opacity: 0,
animation: `${appear()} 1s ease-in`,
animationDelay: '1s',
animationFillMode: 'forwards',
},
});
const HeroContainer = styled('div', {
height: 'calc(100vh - 110px)',
boxSizing: 'border-box',
justifyContent: 'center',
alignItems: 'center',
display: 'flex',
flexDirection: 'column',
width: '100%',
position: 'relative',
overflow: 'hidden',
});
const HeroSelector = styled('div', {
top: '10px',
left: '10px',
display: 'flex',
gap: '1rem',
position: 'absolute',
zIndex: '10',
});
const HeroSelectorItem = styled(MultiToggleItem, {
fontSize: '1rem',
padding: '5px 8px',
});
const HeroAnimation = styled('div', {
bottom: '-50px',
left: '50%',
position: 'absolute',
});
const HeroContent = styled('div', {
display: 'flex',
flexDirection: 'column',
gap: '1rem',
maxWidth: '1000px',
width: '100%',
padding: '0 3rem',
'@media (prefers-reduced-motion: no-preference)': {
opacity: 0,
animation: `${appear()} 1s ease-in`,
animationDelay: '1s',
animationFillMode: 'forwards',
},
'& p': { margin: 0, padding: 0 },
});
const fadeOut = keyframes({
'0%': { transform: 'translate(10px, 0px) rotate(-80deg)' },
'100%': { opacity: 0, transform: 'translate(-100px, -800px) rotate(30deg)' },
});
const Spinner = styled('img', {
width: '100px',
position: 'absolute',
'@media (prefers-reduced-motion: no-preference)': {
animation: `${fadeOut()} 2s ease-in`,
animationFillMode: 'forwards',
},
});
const StepContainer = styled(PageContainer, {
display: 'flex',
flexDirection: 'column',
paddingTop: '1rem',
'& p': {
margin: '1.5rem 0',
},
});
const ActionContainer = styled('div', {
flex: 1,
display: 'flex',
justifyContent: 'center',
gap: '1rem',
paddingTop: '1rem',
});
const StepList = styled('nav', {
flex: '1',
display: 'flex',
alignItems: 'center',
padding: '0 1rem',
flexWrap: 'wrap',
flexDirection: 'row',
justifyContent: 'flex-start',
[`.${lightMode} &`]: {
borderBottom: '1px solid $gray6',
backgroundColor: '$gray2',
},
});
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: '$gray12',
display: 'inherit',
[`.${lightMode} &`]: {
fontWeight: '500',
},
},
},
interaction: {
clickable: {
cursor: 'pointer',
},
},
},
});
enum OnboardingSteps {
Landing = 0,
TwitchIntegration = 1,
TwitchEvents = 2,
Done = 999,
}
const steps = [
OnboardingSteps.Landing,
OnboardingSteps.TwitchIntegration,
OnboardingSteps.TwitchEvents,
OnboardingSteps.Done,
];
const stepI18n = {
[OnboardingSteps.Landing]: 'pages.onboarding.sections.landing',
[OnboardingSteps.TwitchIntegration]:
'pages.onboarding.sections.twitch-config',
[OnboardingSteps.TwitchEvents]: 'pages.onboarding.sections.twitch-events',
[OnboardingSteps.Done]: 'pages.onboarding.sections.done',
};
const maxKeys = languages.reduce(
(current, it) => Math.max(current, it.keys),
0,
);
type TestResult = { open: boolean; error?: Error };
const TwitchStepList = styled('ul', {
lineHeight: '1.5',
listStyleType: 'none',
listStylePosition: 'outside',
});
const TwitchStep = styled('li', {
marginBottom: '0.5rem',
paddingLeft: '1rem',
'&::marker': {
color: '$teal11',
content: '▧',
display: 'inline-block',
marginLeft: '-0.5rem',
},
});
function TwitchIntegrationStep() {
const { t } = useTranslation();
const [httpConfig] = useModule(modules.httpConfig);
const [twitchConfig, setTwitchConfig] = useModule(modules.twitchConfig);
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const dispatch = useAppDispatch();
const [revealClientSecret, setRevealClientSecret] = useState(false);
const [testing, setTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult>({
open: false,
});
const checkCredentials = async () => {
setTesting(true);
if (twitchConfig) {
try {
await checkTwitchKeys(
twitchConfig.api_client_id,
twitchConfig.api_client_secret,
);
void dispatch(
setTwitchConfig({
...twitchConfig,
enabled: true,
}),
);
void dispatch(
setUiConfig({
...uiConfig,
onboardingStatus: uiConfig.onboardingStatus + 1,
}),
);
} catch (e: unknown) {
setTestResult({ open: true, error: e as Error });
}
}
setTesting(false);
};
function skipTwitch() {
void dispatch(
setUiConfig({
...uiConfig,
onboardingStatus:
steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1,
}),
);
}
const allFields =
(twitchConfig?.api_client_id?.length > 0 ?? false) &&
(twitchConfig?.api_client_secret?.length > 0 ?? false);
return (
<form
onSubmit={(ev) => {
void dispatch(setTwitchConfig(twitchConfig));
ev.preventDefault();
}}
>
<TextBlock>{t('pages.onboarding.twitch-p1')}</TextBlock>
<TwitchStepList>
<TwitchStep>
<Trans i18nKey="pages.twitch-settings.apiguide-2">
{' '}
<BrowserLink href="https://dev.twitch.tv/console/apps/create">
https://dev.twitch.tv/console/apps/create
</BrowserLink>
</Trans>
</TwitchStep>
<TwitchStep>
{t('pages.twitch-settings.apiguide-3')}
<DefinitionTable
entries={{
[t('pages.twitch-settings.app-oauth-redirect-url')]: `http://${
httpConfig?.bind.indexOf(':') > 0
? httpConfig.bind
: `localhost${httpConfig?.bind ?? ':4337'}`
}/twitch/callback`,
[t('pages.twitch-settings.app-category')]: 'Broadcasting Suite',
}}
/>
</TwitchStep>
<TwitchStep>
<Trans i18nKey="pages.twitch-settings.apiguide-4">
{'str1 '}
<b>str2</b>
</Trans>
</TwitchStep>
</TwitchStepList>
<Field size="fullWidth" css={{ marginTop: '2rem' }}>
<Label htmlFor="clientid">
{t('pages.twitch-settings.app-client-id')}
</Label>
<InputBox
type="text"
id="clientid"
placeholder={t('pages.twitch-settings.app-client-id')}
required={true}
value={twitchConfig?.api_client_id ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_id: ev.target.value,
}),
)
}
/>
</Field>
<Field size="fullWidth">
<Label htmlFor="clientsecret">
{t('pages.twitch-settings.app-client-secret')}
<RevealLink
value={revealClientSecret}
setter={setRevealClientSecret}
/>
</Label>
<PasswordInputBox
reveal={revealClientSecret}
id="clientsecret"
placeholder={t('pages.twitch-settings.app-client-secret')}
required={true}
value={twitchConfig?.api_client_secret ?? ''}
onChange={(ev) =>
dispatch(
apiReducer.actions.twitchConfigChanged({
...twitchConfig,
api_client_secret: ev.target.value,
}),
)
}
/>
</Field>
<TextBlock>{t('pages.onboarding.twitch-p2')}</TextBlock>
<ButtonGroup>
<Button
type="button"
variation={'primary'}
onClick={() => {
void checkCredentials();
}}
disabled={!allFields || testing}
>
{t('pages.twitch-settings.test-button')}
</Button>
<Button
type="button"
onClick={() => {
skipTwitch();
}}
>
{t('pages.onboarding.twitch-skip')}
</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', {
error: testResult.error.message,
})
: t('pages.twitch-settings.test-succeeded')
}
actionText={t('form-actions.ok')}
onAction={() => {
setTestResult({ ...testResult, open: false });
}}
/>
</Alert>
</form>
);
}
function TwitchEventsStep() {
const { t } = useTranslation();
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const dispatch = useAppDispatch();
const finishStep = async () => {
await dispatch(
setUiConfig({
...uiConfig,
onboardingStatus:
steps.findIndex((val) => val === OnboardingSteps.TwitchEvents) + 1,
}),
);
};
return (
<div>
<TextBlock>{t('pages.onboarding.twitch-ev-p1')}</TextBlock>
<TextBlock>{t('pages.twitch-settings.events.auth-message')}</TextBlock>
<ButtonGroup>
<Button
variation="primary"
onClick={() => {
void startAuthFlow('stream');
}}
>
<ExternalLinkIcon /> {t('pages.twitch-settings.events.auth-button')}
</Button>
</ButtonGroup>
<SectionHeader>
{t('pages.twitch-settings.events.current-status')}
</SectionHeader>
<TwitchUserBlock
authKey="twitch/auth-keys"
noUserMessage={t('pages.twitch-settings.events.err-no-user')}
/>
<TextBlock>{t('pages.onboarding.twitch-ev-p3')}</TextBlock>
<Button
variation={'primary'}
onClick={() => {
void finishStep();
}}
>
{t('pages.onboarding.twitch-complete')}
</Button>
</div>
);
}
function DoneStep() {
const { t } = useTranslation();
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const dispatch = useAppDispatch();
const done = () => {
void dispatch(
setUiConfig({
...uiConfig,
onboardingDone: true,
}),
);
};
return (
<div>
<SectionHeader>{t('pages.onboarding.done-header')}</SectionHeader>
<TextBlock>{t('pages.onboarding.done-p1')}</TextBlock>
<TextBlock>{t('pages.onboarding.done-p2')}</TextBlock>
{Channels}
<TextBlock>{t('pages.onboarding.done-p3')}</TextBlock>
<Button variation={'primary'} onClick={() => done()}>
{t('pages.onboarding.done-button')}
</Button>
</div>
);
}
export default function OnboardingPage() {
const [t, i18n] = useTranslation();
const [animationItems, setAnimationItems] = useState<JSX.Element[]>([]);
const [uiConfig, setUiConfig] = useModule(modules.uiConfig);
const dispatch = useAppDispatch();
const navigate = useNavigate();
const currentStep = steps[uiConfig?.onboardingStatus || 0];
const landing = currentStep === OnboardingSteps.Landing;
// Skip onboarding if we've already done it
const onboardingDone = uiConfig?.onboardingDone;
useEffect(() => {
if (onboardingDone) {
navigate('/');
}
}, [onboardingDone]);
const skip = () => {
void dispatch(
setUiConfig({
...uiConfig,
onboardingDone: true,
}),
);
};
useEffect(() => {
const spinners = new Array<string>(30).fill(spinner as string);
setAnimationItems(
spinners.map((url, i) => (
<Spinner
key={i}
src={url}
css={{
marginLeft: `${Math.trunc(Math.random() * 1000) - 500}px`,
animationDelay: `${(i / spinners.length) * 1000}ms`,
marginTop: `${Math.trunc(Math.random() * 200 - 50)}px`,
animationDuration: `${Math.trunc(Math.random() * 1000 + 1000)}ms`,
width: `${Math.trunc(100 + Math.random() * 100)}px`,
opacity: `${0.1 + Math.random() * 0.2}`,
filter: `sepia(100%) saturate(1300%) hue-rotate(${Math.trunc(
Math.random() * 180,
)}deg) brightness(120%) contrast(120%)`,
}}
/>
)),
);
}, []);
let currentStepBody: JSX.Element = null;
switch (currentStep) {
case OnboardingSteps.Landing:
currentStepBody = (
<ActionContainer>
<Button
css={{ width: '20vw', justifyContent: 'center' }}
onClick={() => skip()}
>
{t('pages.onboarding.skip-button')}
</Button>
<Button
css={{ width: '20vw', justifyContent: 'center' }}
variation="primary"
onClick={() => {
void dispatch(
setUiConfig({
...uiConfig,
onboardingStatus: (uiConfig?.onboardingStatus ?? 0) + 1,
}),
);
}}
>
{t('pages.onboarding.welcome-continue-button')}
</Button>
</ActionContainer>
);
break;
case OnboardingSteps.TwitchIntegration:
currentStepBody = <TwitchIntegrationStep />;
break;
case OnboardingSteps.TwitchEvents:
currentStepBody = <TwitchEventsStep />;
break;
case OnboardingSteps.Done:
currentStepBody = <DoneStep />;
break;
}
return (
<Container>
<TopBanner>
{landing ? (
<HeroContainer>
<HeroSelector>
<MultiToggle
value={uiConfig?.language ?? i18n.resolvedLanguage}
type="single"
onValueChange={(newLang) => {
void dispatch(
setUiConfig({ ...uiConfig, language: newLang }),
);
localStorage.setItem('language', newLang);
}}
>
{languages.map((lang) => (
<HeroSelectorItem
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}
</HeroSelectorItem>
))}
</MultiToggle>
<MultiToggle
value={uiConfig?.theme ?? 'dark'}
type="single"
onValueChange={(newTheme) => {
void dispatch(setUiConfig({ ...uiConfig, theme: newTheme }));
localStorage.setItem('theme', newTheme);
}}
>
{themes.map((theme) => (
<HeroSelectorItem
key={theme}
value={theme}
aria-label={t(`pages.uiconfig.themes.${theme}`)}
>
{t(`pages.uiconfig.themes.${theme}`)}
</HeroSelectorItem>
))}
</MultiToggle>
</HeroSelector>
<HeroAnimation>{animationItems}</HeroAnimation>
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
<HeroContent>
<TextBlock>{t('pages.onboarding.welcome-p1')}</TextBlock>
<TextBlock>
<Trans
t={t}
i18nKey={'pages.onboarding.welcome-guide'}
components={{
g: (
<BrowserLink href="https://strimertul.stream/guide/getting-started/first-time-setup/" />
),
}}
/>
</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>
);
}