diff --git a/frontend/src/lib/react-utils.ts b/frontend/src/lib/react.ts similarity index 100% rename from frontend/src/lib/react-utils.ts rename to frontend/src/lib/react.ts diff --git a/frontend/src/lib/time-utils.ts b/frontend/src/lib/time.ts similarity index 100% rename from frontend/src/lib/time-utils.ts rename to frontend/src/lib/time.ts diff --git a/frontend/src/lib/twitch.ts b/frontend/src/lib/twitch.ts new file mode 100644 index 0000000..2f870d2 --- /dev/null +++ b/frontend/src/lib/twitch.ts @@ -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 { + 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; +} + +/** + * 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 { + 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}`); + } +} diff --git a/frontend/src/lib/type-utils.ts b/frontend/src/lib/types.ts similarity index 100% rename from frontend/src/lib/type-utils.ts rename to frontend/src/lib/types.ts diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 698d9fd..e0c4ffd 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -95,7 +95,10 @@ "current-status": "Current status" }, "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": { "title": "Bot commands", @@ -259,9 +262,16 @@ }, "onboarding": { "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", - "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": { "title": "User interface settings", diff --git a/frontend/src/locale/it/translation.json b/frontend/src/locale/it/translation.json index 411433b..5359c10 100644 --- a/frontend/src/locale/it/translation.json +++ b/frontend/src/locale/it/translation.json @@ -258,7 +258,14 @@ "skip-button": "Salta procedura guidata", "welcome-continue-button": "Cominciamo", "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": { "credits-header": "Ringraziamenti", diff --git a/frontend/src/store/api/reducer.ts b/frontend/src/store/api/reducer.ts index 48ebb40..5530e65 100644 --- a/frontend/src/store/api/reducer.ts +++ b/frontend/src/store/api/reducer.ts @@ -12,7 +12,7 @@ import { import KilovoltWS from '@strimertul/kilovolt-client'; import type { kvError } from '@strimertul/kilovolt-client/types/messages'; import { AuthenticateKVClient, IsServerReady } from '@wailsapp/go/main/App'; -import { delay } from '~/lib/time-utils'; +import { delay } from '~/lib/time'; import { APIState, ConnectionStatus, diff --git a/frontend/src/store/api/types.ts b/frontend/src/store/api/types.ts index 87e1006..9bcc6ef 100644 --- a/frontend/src/store/api/types.ts +++ b/frontend/src/store/api/types.ts @@ -144,6 +144,7 @@ export interface LoyaltyRedeem { } export interface UISettings { + onboardingStatus: number; onboardingDone: boolean; language: string; } diff --git a/frontend/src/ui/components/DataTable.tsx b/frontend/src/ui/components/DataTable.tsx index e37fd03..698624b 100644 --- a/frontend/src/ui/components/DataTable.tsx +++ b/frontend/src/ui/components/DataTable.tsx @@ -1,6 +1,6 @@ import { ChevronDownIcon, ChevronUpIcon } from '@radix-ui/react-icons'; import React, { useState } from 'react'; -import { SortFunction } from '~/lib/type-utils'; +import { SortFunction } from '~/lib/types'; import { styled } from '../theme'; import { Table, TableHeader } from '../theme/table'; import PageList from './PageList'; diff --git a/frontend/src/ui/components/LogViewer.tsx b/frontend/src/ui/components/LogViewer.tsx index f31e3c3..1e2f82c 100644 --- a/frontend/src/ui/components/LogViewer.tsx +++ b/frontend/src/ui/components/LogViewer.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; import { RootState } from 'src/store'; 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 { Dialog, diff --git a/frontend/src/ui/components/PageList.tsx b/frontend/src/ui/components/PageList.tsx index 6fd305c..5d37972 100644 --- a/frontend/src/ui/components/PageList.tsx +++ b/frontend/src/ui/components/PageList.tsx @@ -38,21 +38,13 @@ function PageList({ }, }} > - + onPageChange(current - 1)} - css={{ - '@mobile': { flex: 1 }, - '@medium': { flex: 0 }, - }} + css={{ flex: 1, '@medium': { flex: 0 } }} > ‹ @@ -61,10 +53,7 @@ function PageList({ title={t('pagination.next')} disabled={current >= max} onClick={() => onPageChange(current + 1)} - css={{ - '@mobile': { flex: 1 }, - '@medium': { flex: 0 }, - }} + css={{ flex: 1, '@medium': { flex: 0 } }} > › @@ -75,7 +64,7 @@ function PageList({ onChange={(ev) => onSelectChange(Number(ev.target.value))} css={{ textAlign: 'center', - '@mobile': { flex: 1 }, + flex: 1, '@medium': { flex: 0 }, }} > diff --git a/frontend/src/ui/components/forms/Interval.tsx b/frontend/src/ui/components/forms/Interval.tsx index 4aad534..41bcf38 100644 --- a/frontend/src/ui/components/forms/Interval.tsx +++ b/frontend/src/ui/components/forms/Interval.tsx @@ -1,6 +1,6 @@ import React, { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { getInterval } from '~/lib/time-utils'; +import { getInterval } from '~/lib/time'; import { ComboBox, FlexRow, InputBox } from '../../theme'; export interface TimeUnit { diff --git a/frontend/src/ui/components/forms/PasswordField.tsx b/frontend/src/ui/components/forms/PasswordField.tsx index 7f56289..e94cc46 100644 --- a/frontend/src/ui/components/forms/PasswordField.tsx +++ b/frontend/src/ui/components/forms/PasswordField.tsx @@ -13,8 +13,10 @@ function PasswordField( > >, ) { + const subprops = { ...props }; + delete subprops.reveal; return ( - + {props.children} ); diff --git a/frontend/src/ui/pages/BotCommands.tsx b/frontend/src/ui/pages/BotCommands.tsx index 56cb783..69ce600 100644 --- a/frontend/src/ui/pages/BotCommands.tsx +++ b/frontend/src/ui/pages/BotCommands.tsx @@ -1,7 +1,7 @@ import { PlusIcon } from '@radix-ui/react-icons'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useModule } from '~/lib/react-utils'; +import { useModule } from '~/lib/react'; import { useAppDispatch } from '~/store'; import { modules } from '~/store/api/reducer'; import { diff --git a/frontend/src/ui/pages/BotTimers.tsx b/frontend/src/ui/pages/BotTimers.tsx index e3b6087..86321ca 100644 --- a/frontend/src/ui/pages/BotTimers.tsx +++ b/frontend/src/ui/pages/BotTimers.tsx @@ -2,7 +2,7 @@ import { PlusIcon } from '@radix-ui/react-icons'; import { TFunction } from 'i18next'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useModule } from '~/lib/react-utils'; +import { useModule } from '~/lib/react'; import { useAppDispatch } from '~/store'; import { modules } from '~/store/api/reducer'; import { TwitchBotTimer } from '~/store/api/types'; diff --git a/frontend/src/ui/pages/ChatAlerts.tsx b/frontend/src/ui/pages/ChatAlerts.tsx index 82afb73..183b2aa 100644 --- a/frontend/src/ui/pages/ChatAlerts.tsx +++ b/frontend/src/ui/pages/ChatAlerts.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; 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 { useAppDispatch } from '~/store'; import MultiInput from '../components/forms/MultiInput'; diff --git a/frontend/src/ui/pages/Dashboard.tsx b/frontend/src/ui/pages/Dashboard.tsx index 91fab4c..8bb9952 100644 --- a/frontend/src/ui/pages/Dashboard.tsx +++ b/frontend/src/ui/pages/Dashboard.tsx @@ -1,7 +1,7 @@ import { CircleIcon } from '@radix-ui/react-icons'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useLiveKey } from '~/lib/react-utils'; +import { useLiveKey } from '~/lib/react'; import { PageContainer, SectionHeader, styled } from '../theme'; import WIPNotice from '../components/utils/WIPNotice'; import BrowserLink from '../components/BrowserLink'; diff --git a/frontend/src/ui/pages/LoyaltyConfig.tsx b/frontend/src/ui/pages/LoyaltyConfig.tsx index ec75df3..a31ef3f 100644 --- a/frontend/src/ui/pages/LoyaltyConfig.tsx +++ b/frontend/src/ui/pages/LoyaltyConfig.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { CheckIcon } from '@radix-ui/react-icons'; 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 { useAppDispatch } from '~/store'; import { diff --git a/frontend/src/ui/pages/LoyaltyQueue.tsx b/frontend/src/ui/pages/LoyaltyQueue.tsx index d067a0d..a0d4b65 100644 --- a/frontend/src/ui/pages/LoyaltyQueue.tsx +++ b/frontend/src/ui/pages/LoyaltyQueue.tsx @@ -1,7 +1,7 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useModule, useUserPoints } from '~/lib/react-utils'; -import { SortFunction } from '~/lib/type-utils'; +import { useModule, useUserPoints } from '~/lib/react'; +import { SortFunction } from '~/lib/types'; import { useAppDispatch } from '~/store'; import { modules, removeRedeem, setUserPoints } from '~/store/api/reducer'; import { DataTable } from '../components/DataTable'; diff --git a/frontend/src/ui/pages/LoyaltyRewards.tsx b/frontend/src/ui/pages/LoyaltyRewards.tsx index 847bda5..9132695 100644 --- a/frontend/src/ui/pages/LoyaltyRewards.tsx +++ b/frontend/src/ui/pages/LoyaltyRewards.tsx @@ -1,7 +1,7 @@ import { CheckIcon, PlusIcon } from '@radix-ui/react-icons'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useModule } from '~/lib/react-utils'; +import { useModule } from '~/lib/react'; import { useAppDispatch } from '~/store'; import { modules } from '~/store/api/reducer'; import { LoyaltyGoal, LoyaltyReward } from '~/store/api/types'; diff --git a/frontend/src/ui/pages/Onboarding.tsx b/frontend/src/ui/pages/Onboarding.tsx index 82209a8..007dd38 100644 --- a/frontend/src/ui/pages/Onboarding.tsx +++ b/frontend/src/ui/pages/Onboarding.tsx @@ -1,3 +1,4 @@ +import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { keyframes } from '@stitches/react'; import { useEffect, useState } from 'react'; import { useTranslation } from 'react-i18next'; @@ -5,11 +6,30 @@ import { useNavigate } from 'react-router-dom'; // @ts-expect-error Asset import 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 { 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', { display: 'flex', @@ -56,6 +76,20 @@ const HeroContainer = styled('div', { 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', { bottom: '-50px', left: '50%', @@ -95,6 +129,10 @@ const Spinner = styled('img', { const StepContainer = styled(PageContainer, { display: 'flex', flexDirection: 'column', + paddingTop: '1rem', + '& p': { + margin: '1.5rem 0', + }, }); const ActionContainer = styled('div', { @@ -102,21 +140,81 @@ const ActionContainer = styled('div', { 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', +}); + +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 { 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() { - const { t } = useTranslation(); + const [t, i18n] = useTranslation(); const [animationItems, setAnimationItems] = useState([]); - const [currentStep, setStep] = useState(OnboardingSteps.Landing); const [uiConfig, setUiConfig] = useModule(modules.uiConfig); const dispatch = useAppDispatch(); const navigate = useNavigate(); + const currentStep = steps[uiConfig?.onboardingStatus || 0]; const landing = currentStep === OnboardingSteps.Landing; const onboardingDone = uiConfig?.onboardingDone; @@ -158,26 +256,10 @@ export default function OnboardingPage() { ); }, []); - return ( - - - {landing ? ( - - {animationItems} - {t('pages.onboarding.welcome-header')} - - {t('pages.onboarding.welcome-p1')} - - Heads up: if you're used to other platforms, this unfortunately - will require some more work on your end. - - - - ) : ( -
- )} -
- + let currentStepBody: JSX.Element = null; + switch (currentStep) { + case OnboardingSteps.Landing: + currentStepBody = ( - + ); + break; + } + + return ( + + + {landing ? ( + + + { + void dispatch( + setUiConfig({ ...uiConfig, language: newLang }), + ); + }} + > + {languages.map((lang) => ( + + {lang.name} + {lang.keys < maxKeys ? : null} + + ))} + + + {animationItems} + {t('pages.onboarding.welcome-header')} + + {t('pages.onboarding.welcome-p1')} + + {t('pages.onboarding.welcome-p2')} + + + + ) : ( + + {steps.map((step) => ( + { + // Can't skip ahead + if (step >= currentStep) { + return; + } + void dispatch( + setUiConfig({ + ...uiConfig, + onboardingStatus: + steps.findIndex((val) => val === step) ?? 0, + }), + ); + }} + > + {t(stepI18n[step])} + + ))} + + )} + + {currentStepBody} ); } diff --git a/frontend/src/ui/pages/ServerSettings.tsx b/frontend/src/ui/pages/ServerSettings.tsx index 8ed2b15..31337e6 100644 --- a/frontend/src/ui/pages/ServerSettings.tsx +++ b/frontend/src/ui/pages/ServerSettings.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useModule, useStatus } from '~/lib/react-utils'; +import { useModule, useStatus } from '~/lib/react'; import { useAppDispatch } from '~/store'; import apiReducer, { modules } from '~/store/api/reducer'; import AlertContent from '../components/AlertContent'; diff --git a/frontend/src/ui/pages/TwitchSettings.tsx b/frontend/src/ui/pages/TwitchSettings.tsx index 6cfa61d..b2e4f08 100644 --- a/frontend/src/ui/pages/TwitchSettings.tsx +++ b/frontend/src/ui/pages/TwitchSettings.tsx @@ -6,9 +6,10 @@ import React, { useEffect, useState } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { useSelector } from 'react-redux'; 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 apiReducer, { modules } from '~/store/api/reducer'; +import { checkTwitchKeys } from '~/lib/twitch'; import BrowserLink from '../components/BrowserLink'; import DefinitionTable from '../components/DefinitionTable'; import RevealLink from '../components/utils/RevealLink'; @@ -35,6 +36,8 @@ import { TabList, TextBlock, } from '../theme'; +import AlertContent from '../components/AlertContent'; +import { Alert } from '../theme/alert'; const StepList = styled('ul', { lineHeight: '1.5', @@ -195,6 +198,8 @@ function TwitchBotSettings() { ); } +type TestResult = { open: boolean; error?: Error }; + function TwitchAPISettings() { const { t } = useTranslation(); const [httpConfig] = useModule(modules.httpConfig); @@ -204,6 +209,27 @@ function TwitchAPISettings() { const status = useStatus(loadStatus.save); const dispatch = useAppDispatch(); const [revealClientSecret, setRevealClientSecret] = useState(false); + const [testing, setTesting] = useState(false); + const [testResult, setTestResult] = useState({ + 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 (
- + + + + + { + setTestResult({ ...testResult, open: val }); + }} + > + { + setTestResult({ ...testResult, open: false }); + }} + /> + ); } diff --git a/frontend/src/ui/pages/UISettingsPage.tsx b/frontend/src/ui/pages/UISettingsPage.tsx index 4dec393..0743a9e 100644 --- a/frontend/src/ui/pages/UISettingsPage.tsx +++ b/frontend/src/ui/pages/UISettingsPage.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useModule } from '~/lib/react-utils'; +import { useModule } from '~/lib/react'; import { languages } from '~/locale/languages'; import { useAppDispatch } from '~/store'; import { modules } from '~/store/api/reducer'; @@ -19,16 +19,16 @@ const PartialWarning = styled('small', { color: '$yellow11', }); +const maxKeys = languages.reduce( + (current, it) => Math.max(current, it.keys), + 0, +); + export default function UISettingsPage(): React.ReactElement { const [uiConfig, setUiConfig] = useModule(modules.uiConfig); const [t, i18n] = useTranslation(); const dispatch = useAppDispatch(); - const maxKeys = languages.reduce( - (current, it) => Math.max(current, it.keys), - 0, - ); - return ( @@ -63,7 +63,13 @@ export default function UISettingsPage(): React.ReactElement {