mirror of
https://git.sr.ht/~ashkeel/strimertul
synced 2024-09-18 01:50:50 +00:00
feat: add user lang/theme to fatal error screen
This commit is contained in:
parent
11e7741f83
commit
7e4d358f60
11 changed files with 130 additions and 78 deletions
|
@ -9,7 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Added an unfinished light mode, check the UI settings
|
- Added a light theme, can be set during onboarding or in the UI settings
|
||||||
|
- The fatal error screen now uses the user configured language and theme
|
||||||
- Added the ability to hide viewer count in the dashboard
|
- Added the ability to hide viewer count in the dashboard
|
||||||
- Custom chat commands can now be sent as replies, whispers and announcements. Due to some API shenanigans yet to be solved, the latter two will always be sent from your main account, not the bot account (if they are different)
|
- Custom chat commands can now be sent as replies, whispers and announcements. Due to some API shenanigans yet to be solved, the latter two will always be sent from your main account, not the bot account (if they are different)
|
||||||
- Added a structured RPC `twitch/bot/@send-message` for sending messages as replies, announcements and whispers.
|
- Added a structured RPC `twitch/bot/@send-message` for sending messages as replies, announcements and whispers.
|
||||||
|
|
2
app.go
2
app.go
|
@ -117,7 +117,7 @@ func (a *App) startup(ctx context.Context) {
|
||||||
go a.forwardLogs()
|
go a.forwardLogs()
|
||||||
|
|
||||||
// Run HTTP server
|
// Run HTTP server
|
||||||
if err := a.httpServer.Listen(); err != nil {
|
if err = a.httpServer.Listen(); err != nil {
|
||||||
a.showFatalError(err, "HTTP server stopped")
|
a.showFatalError(err, "HTTP server stopped")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
@ -98,7 +98,7 @@
|
||||||
"app-category": "Category",
|
"app-category": "Category",
|
||||||
"app-oauth-redirect-url": "OAuth Redirect URLs",
|
"app-oauth-redirect-url": "OAuth Redirect URLs",
|
||||||
"test-button": "Test connection",
|
"test-button": "Test connection",
|
||||||
"test-failed": "Test failed: \"{{0}}\". Check your app client IDs and secret!",
|
"test-failed": "Test failed: \"{{error}}\". Check your app client IDs and secret!",
|
||||||
"test-succeeded": "Test succeeded!",
|
"test-succeeded": "Test succeeded!",
|
||||||
"bot-chat-cooldown-tip": "Global chat cooldown for commands (in seconds)"
|
"bot-chat-cooldown-tip": "Global chat cooldown for commands (in seconds)"
|
||||||
},
|
},
|
||||||
|
|
|
@ -359,7 +359,7 @@
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"test-button": "Test connessione",
|
"test-button": "Test connessione",
|
||||||
"test-failed": "Test fallito: \"{{0}}\". \nControlla ID e segreto client dell'app!",
|
"test-failed": "Test fallito: \"{{error}}\". \nControlla ID e segreto client dell'app!",
|
||||||
"test-succeeded": "Test riuscito!",
|
"test-succeeded": "Test riuscito!",
|
||||||
"bot-chat-cooldown-tip": "Tempo minimo di attesa tra comandi (in secondi)"
|
"bot-chat-cooldown-tip": "Tempo minimo di attesa tra comandi (in secondi)"
|
||||||
},
|
},
|
||||||
|
|
|
@ -220,7 +220,11 @@ export default function App(): JSX.Element {
|
||||||
// Sync UI changes on key change
|
// Sync UI changes on key change
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (uiConfig?.language) {
|
if (uiConfig?.language) {
|
||||||
void i18n.changeLanguage(uiConfig?.language ?? 'en');
|
void i18n.changeLanguage(uiConfig.language ?? 'en');
|
||||||
|
localStorage.setItem('language', uiConfig.language);
|
||||||
|
}
|
||||||
|
if (uiConfig?.theme) {
|
||||||
|
localStorage.setItem('theme', uiConfig.theme);
|
||||||
}
|
}
|
||||||
if (!uiConfig?.onboardingDone) {
|
if (!uiConfig?.onboardingDone) {
|
||||||
navigate('/setup');
|
navigate('/setup');
|
||||||
|
|
|
@ -22,6 +22,7 @@ import {
|
||||||
DialogActions,
|
DialogActions,
|
||||||
Field,
|
Field,
|
||||||
FlexRow,
|
FlexRow,
|
||||||
|
getTheme,
|
||||||
InputBox,
|
InputBox,
|
||||||
Label,
|
Label,
|
||||||
MultiToggle,
|
MultiToggle,
|
||||||
|
@ -43,6 +44,8 @@ const Container = styled('div', {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
height: '100vh',
|
height: '100vh',
|
||||||
border: '2px solid $red10',
|
border: '2px solid $red10',
|
||||||
|
backgroundColor: '$gray1',
|
||||||
|
color: '$gray12',
|
||||||
});
|
});
|
||||||
|
|
||||||
const ErrorHeader = styled('h1', {
|
const ErrorHeader = styled('h1', {
|
||||||
|
@ -497,6 +500,7 @@ export default function ErrorWindow(): JSX.Element {
|
||||||
const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false);
|
const [recoveryDialogOpen, setRecoveryDialogOpen] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
void i18n.changeLanguage(localStorage.getItem('language') ?? 'en');
|
||||||
void GetLastLogs().then((appLogs) => {
|
void GetLastLogs().then((appLogs) => {
|
||||||
setLogs(appLogs.map(processEntry).reverse());
|
setLogs(appLogs.map(processEntry).reverse());
|
||||||
});
|
});
|
||||||
|
@ -509,9 +513,10 @@ export default function ErrorWindow(): JSX.Element {
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const fatal = logs.find((log) => log.level === 'error');
|
const fatal = logs.find((log) => log.level === 'error');
|
||||||
|
const theme = getTheme(localStorage.getItem('theme') ?? 'dark');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container>
|
<Container id="app-container" className={theme}>
|
||||||
<ReportDialog
|
<ReportDialog
|
||||||
open={reportDialogOpen}
|
open={reportDialogOpen}
|
||||||
onOpenChange={setReportDialogOpen}
|
onOpenChange={setReportDialogOpen}
|
||||||
|
@ -541,62 +546,64 @@ export default function ErrorWindow(): JSX.Element {
|
||||||
))}
|
))}
|
||||||
</MultiToggle>
|
</MultiToggle>
|
||||||
</LanguageSelector>
|
</LanguageSelector>
|
||||||
<PageContainer>
|
<Scrollbar vertical={true} viewport={{ flex: '1', maxHeight: '100vh' }}>
|
||||||
<Scrollbar vertical={true} viewport={{ maxHeight: '100vh' }}>
|
<div style={{ width: '100vw' }}>
|
||||||
<PageHeader>
|
<PageContainer>
|
||||||
<TextBlock>{t('pages.crash.fatal-message')}</TextBlock>
|
<PageHeader>
|
||||||
</PageHeader>
|
<TextBlock>{t('pages.crash.fatal-message')}</TextBlock>
|
||||||
{fatal ? (
|
</PageHeader>
|
||||||
<>
|
{fatal ? (
|
||||||
<ErrorHeader>{fatal.message}</ErrorHeader>
|
<>
|
||||||
<ErrorDetails>
|
<ErrorHeader>{fatal.message}</ErrorHeader>
|
||||||
{Object.keys(fatal.data)
|
<ErrorDetails>
|
||||||
.filter((key) => key.length > 1)
|
{Object.keys(fatal.data)
|
||||||
.map((key) => (
|
.filter((key) => key.length > 1)
|
||||||
<Fragment key={key}>
|
.map((key) => (
|
||||||
<ErrorDetailKey>{key}</ErrorDetailKey>
|
<Fragment key={key}>
|
||||||
<ErrorDetailValue>{fatal.data[key]}</ErrorDetailValue>
|
<ErrorDetailKey>{key}</ErrorDetailKey>
|
||||||
</Fragment>
|
<ErrorDetailValue>{fatal.data[key]}</ErrorDetailValue>
|
||||||
))}
|
</Fragment>
|
||||||
</ErrorDetails>
|
))}
|
||||||
</>
|
</ErrorDetails>
|
||||||
) : null}
|
</>
|
||||||
<SectionHeader>{t('pages.crash.action-header')}</SectionHeader>
|
) : null}
|
||||||
<TextBlock>{t('pages.crash.action-submit-line')}</TextBlock>
|
<SectionHeader>{t('pages.crash.action-header')}</SectionHeader>
|
||||||
<TextBlock>{t('pages.crash.action-recover-line')}</TextBlock>
|
<TextBlock>{t('pages.crash.action-submit-line')}</TextBlock>
|
||||||
<TextBlock>
|
<TextBlock>{t('pages.crash.action-recover-line')}</TextBlock>
|
||||||
<Trans
|
<TextBlock>
|
||||||
t={t}
|
<Trans
|
||||||
i18nKey="pages.crash.action-log-line"
|
t={t}
|
||||||
values={{
|
i18nKey="pages.crash.action-log-line"
|
||||||
A: 'strimertul.log',
|
values={{
|
||||||
B: 'strimertul-panic.log',
|
A: 'strimertul.log',
|
||||||
}}
|
B: 'strimertul-panic.log',
|
||||||
components={{
|
}}
|
||||||
m: <Mono />,
|
components={{
|
||||||
}}
|
m: <Mono />,
|
||||||
/>
|
}}
|
||||||
</TextBlock>
|
/>
|
||||||
<FlexRow align="left" spacing={1} css={{ paddingTop: '0.5rem' }}>
|
</TextBlock>
|
||||||
<Button
|
<FlexRow align="left" spacing={1} css={{ paddingTop: '0.5rem' }}>
|
||||||
variation={'danger'}
|
<Button
|
||||||
onClick={() => setReportDialogOpen(true)}
|
variation={'danger'}
|
||||||
>
|
onClick={() => setReportDialogOpen(true)}
|
||||||
{t('pages.crash.button-report')}
|
>
|
||||||
</Button>
|
{t('pages.crash.button-report')}
|
||||||
<Button onClick={() => setRecoveryDialogOpen(true)}>
|
</Button>
|
||||||
{t('pages.crash.button-recovery')}
|
<Button onClick={() => setRecoveryDialogOpen(true)}>
|
||||||
</Button>
|
{t('pages.crash.button-recovery')}
|
||||||
</FlexRow>
|
</Button>
|
||||||
|
</FlexRow>
|
||||||
|
|
||||||
<MiniHeader>{t('pages.crash.app-log-header')}</MiniHeader>
|
<MiniHeader>{t('pages.crash.app-log-header')}</MiniHeader>
|
||||||
<LogContainer>
|
<LogContainer>
|
||||||
{logs.map((log) => (
|
{logs.map((log) => (
|
||||||
<LogItem key={log.time.toString()} data={log} />
|
<LogItem key={log.time.toString()} data={log} />
|
||||||
))}
|
))}
|
||||||
</LogContainer>
|
</LogContainer>
|
||||||
</Scrollbar>
|
</PageContainer>
|
||||||
</PageContainer>
|
</div>
|
||||||
|
</Scrollbar>
|
||||||
</Container>
|
</Container>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -144,6 +144,14 @@ const MenuLink = styled(Link, {
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const AppLogo = styled('img', {
|
||||||
|
height: '28px',
|
||||||
|
marginBottom: '-2px',
|
||||||
|
[`.${lightMode} &`]: {
|
||||||
|
filter: 'invert(1)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function SidebarLink({ route: { title, url, icon } }: { route: Route }) {
|
function SidebarLink({ route: { title, url, icon } }: { route: Route }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const resolved = useResolvedPath(url);
|
const resolved = useResolvedPath(url);
|
||||||
|
@ -254,10 +262,7 @@ export default function Sidebar({
|
||||||
<Header>
|
<Header>
|
||||||
<AppLink to={'/about'} status={matchApp ? 'active' : 'default'}>
|
<AppLink to={'/about'} status={matchApp ? 'active' : 'default'}>
|
||||||
<AppName>
|
<AppName>
|
||||||
<img
|
<AppLogo src={logo as string} />
|
||||||
src={logo as string}
|
|
||||||
style={{ height: '28px', marginBottom: '-2px' }}
|
|
||||||
/>
|
|
||||||
{APPNAME}
|
{APPNAME}
|
||||||
</AppName>
|
</AppName>
|
||||||
<VersionLabel>
|
<VersionLabel>
|
||||||
|
|
|
@ -31,6 +31,7 @@ import {
|
||||||
Field,
|
Field,
|
||||||
InputBox,
|
InputBox,
|
||||||
Label,
|
Label,
|
||||||
|
lightMode,
|
||||||
MultiToggle,
|
MultiToggle,
|
||||||
MultiToggleItem,
|
MultiToggleItem,
|
||||||
PageContainer,
|
PageContainer,
|
||||||
|
@ -38,6 +39,7 @@ import {
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
styled,
|
styled,
|
||||||
TextBlock,
|
TextBlock,
|
||||||
|
themes,
|
||||||
} from '../theme';
|
} from '../theme';
|
||||||
import { Alert } from '../theme/alert';
|
import { Alert } from '../theme/alert';
|
||||||
|
|
||||||
|
@ -86,7 +88,7 @@ const HeroContainer = styled('div', {
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
const HeroLanguageSelector = styled('div', {
|
const HeroSelector = styled('div', {
|
||||||
top: '10px',
|
top: '10px',
|
||||||
left: '10px',
|
left: '10px',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
|
@ -95,7 +97,7 @@ const HeroLanguageSelector = styled('div', {
|
||||||
zIndex: '10',
|
zIndex: '10',
|
||||||
});
|
});
|
||||||
|
|
||||||
const LanguageItem = styled(MultiToggleItem, {
|
const HeroSelectorItem = styled(MultiToggleItem, {
|
||||||
fontSize: '1rem',
|
fontSize: '1rem',
|
||||||
padding: '5px 8px',
|
padding: '5px 8px',
|
||||||
});
|
});
|
||||||
|
@ -161,6 +163,10 @@ const StepList = styled('nav', {
|
||||||
flexWrap: 'wrap',
|
flexWrap: 'wrap',
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
justifyContent: 'flex-start',
|
justifyContent: 'flex-start',
|
||||||
|
[`.${lightMode} &`]: {
|
||||||
|
borderBottom: '1px solid $gray6',
|
||||||
|
backgroundColor: '$gray2',
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const StepName = styled('div', {
|
const StepName = styled('div', {
|
||||||
|
@ -180,6 +186,10 @@ const StepName = styled('div', {
|
||||||
active: {
|
active: {
|
||||||
color: '$gray12',
|
color: '$gray12',
|
||||||
display: 'inherit',
|
display: 'inherit',
|
||||||
|
|
||||||
|
[`.${lightMode} &`]: {
|
||||||
|
fontWeight: '500',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
interaction: {
|
interaction: {
|
||||||
|
@ -403,9 +413,9 @@ function TwitchIntegrationStep() {
|
||||||
variation={testResult.error ? 'danger' : 'default'}
|
variation={testResult.error ? 'danger' : 'default'}
|
||||||
description={
|
description={
|
||||||
testResult.error
|
testResult.error
|
||||||
? t('pages.twitch-settings.test-failed', [
|
? t('pages.twitch-settings.test-failed', {
|
||||||
testResult.error.message,
|
error: testResult.error.message,
|
||||||
])
|
})
|
||||||
: t('pages.twitch-settings.test-succeeded')
|
: t('pages.twitch-settings.test-succeeded')
|
||||||
}
|
}
|
||||||
actionText={t('form-actions.ok')}
|
actionText={t('form-actions.ok')}
|
||||||
|
@ -684,7 +694,7 @@ export default function OnboardingPage() {
|
||||||
<TopBanner>
|
<TopBanner>
|
||||||
{landing ? (
|
{landing ? (
|
||||||
<HeroContainer>
|
<HeroContainer>
|
||||||
<HeroLanguageSelector>
|
<HeroSelector>
|
||||||
<MultiToggle
|
<MultiToggle
|
||||||
value={uiConfig?.language ?? i18n.resolvedLanguage}
|
value={uiConfig?.language ?? i18n.resolvedLanguage}
|
||||||
type="single"
|
type="single"
|
||||||
|
@ -692,10 +702,11 @@ export default function OnboardingPage() {
|
||||||
void dispatch(
|
void dispatch(
|
||||||
setUiConfig({ ...uiConfig, language: newLang }),
|
setUiConfig({ ...uiConfig, language: newLang }),
|
||||||
);
|
);
|
||||||
|
localStorage.setItem('language', newLang);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{languages.map((lang) => (
|
{languages.map((lang) => (
|
||||||
<LanguageItem
|
<HeroSelectorItem
|
||||||
key={lang.code}
|
key={lang.code}
|
||||||
aria-label={lang.name}
|
aria-label={lang.name}
|
||||||
value={lang.code}
|
value={lang.code}
|
||||||
|
@ -707,10 +718,29 @@ export default function OnboardingPage() {
|
||||||
>
|
>
|
||||||
{lang.name}
|
{lang.name}
|
||||||
{lang.keys < maxKeys ? <ExclamationTriangleIcon /> : null}
|
{lang.keys < maxKeys ? <ExclamationTriangleIcon /> : null}
|
||||||
</LanguageItem>
|
</HeroSelectorItem>
|
||||||
))}
|
))}
|
||||||
</MultiToggle>
|
</MultiToggle>
|
||||||
</HeroLanguageSelector>
|
|
||||||
|
<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>
|
<HeroAnimation>{animationItems}</HeroAnimation>
|
||||||
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
|
<HeroTitle>{t('pages.onboarding.welcome-header')}</HeroTitle>
|
||||||
<HeroContent>
|
<HeroContent>
|
||||||
|
|
|
@ -357,9 +357,9 @@ function TwitchAPISettings() {
|
||||||
variation={testResult.error ? 'danger' : 'default'}
|
variation={testResult.error ? 'danger' : 'default'}
|
||||||
description={
|
description={
|
||||||
testResult.error
|
testResult.error
|
||||||
? t('pages.twitch-settings.test-failed', [
|
? t('pages.twitch-settings.test-failed', {
|
||||||
testResult.error.message,
|
error: testResult.error.message,
|
||||||
])
|
})
|
||||||
: t('pages.twitch-settings.test-succeeded')
|
: t('pages.twitch-settings.test-succeeded')
|
||||||
}
|
}
|
||||||
actionText={t('form-actions.ok')}
|
actionText={t('form-actions.ok')}
|
||||||
|
|
|
@ -13,6 +13,7 @@ import {
|
||||||
PageHeader,
|
PageHeader,
|
||||||
PageTitle,
|
PageTitle,
|
||||||
styled,
|
styled,
|
||||||
|
themes,
|
||||||
} from '../theme';
|
} from '../theme';
|
||||||
|
|
||||||
const PartialWarning = styled('small', {
|
const PartialWarning = styled('small', {
|
||||||
|
@ -42,6 +43,7 @@ export default function UISettingsPage(): React.ReactElement {
|
||||||
value={uiConfig?.language ?? i18n.resolvedLanguage}
|
value={uiConfig?.language ?? i18n.resolvedLanguage}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
void dispatch(setUiConfig({ ...uiConfig, language: value }));
|
void dispatch(setUiConfig({ ...uiConfig, language: value }));
|
||||||
|
localStorage.setItem('language', value);
|
||||||
}}
|
}}
|
||||||
values={languages.map((lang) => ({
|
values={languages.map((lang) => ({
|
||||||
id: lang.code,
|
id: lang.code,
|
||||||
|
@ -68,8 +70,9 @@ export default function UISettingsPage(): React.ReactElement {
|
||||||
value={uiConfig?.theme ?? 'dark'}
|
value={uiConfig?.theme ?? 'dark'}
|
||||||
onValueChange={(value) => {
|
onValueChange={(value) => {
|
||||||
void dispatch(setUiConfig({ ...uiConfig, theme: value }));
|
void dispatch(setUiConfig({ ...uiConfig, theme: value }));
|
||||||
|
localStorage.setItem('theme', value);
|
||||||
}}
|
}}
|
||||||
values={['dark', 'light'].map((theme) => ({
|
values={themes.map((theme) => ({
|
||||||
id: theme,
|
id: theme,
|
||||||
label: t(`pages.uiconfig.themes.${theme}`),
|
label: t(`pages.uiconfig.themes.${theme}`),
|
||||||
}))}
|
}))}
|
||||||
|
|
|
@ -76,6 +76,8 @@ export const lightMode = createTheme({
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const themes = ['dark', 'light'];
|
||||||
|
|
||||||
export function getTheme(themeName: string) {
|
export function getTheme(themeName: string) {
|
||||||
switch (themeName) {
|
switch (themeName) {
|
||||||
case 'light':
|
case 'light':
|
||||||
|
|
Loading…
Reference in a new issue