diff --git a/src/client/webapp/elements/components/display.tsx b/src/client/webapp/elements/components/display.tsx index 76f9431..617eb7d 100644 --- a/src/client/webapp/elements/components/display.tsx +++ b/src/client/webapp/elements/components/display.tsx @@ -18,12 +18,14 @@ interface DisplayProps { saving: boolean; saveFailed: boolean; errorMessage: string | null; + infoMessage: string | null; } const Display: FC = (props: DisplayProps) => { - const { children, changes, resetChanges, saveChanges, saving, saveFailed, errorMessage } = props; + const { children, changes, resetChanges, saveChanges, saving, saveFailed, errorMessage, infoMessage } = props; const [ saveButtonShaking, setSaveButtonShaking ] = useState(false); + const [ dismissedInfoMessage, setDismissedInfoMessage ] = useState(null); useEffect(() => { (async () => { @@ -43,6 +45,10 @@ const Display: FC = (props: DisplayProps) => { } }, [ saving, saveFailed ]); + const dismissInfoMessage = () => { + setDismissedInfoMessage(infoMessage); + } + const popup = useMemo(() => { if (errorMessage) { return ( @@ -50,9 +56,15 @@ const Display: FC = (props: DisplayProps) => { ); + } else if (infoMessage && infoMessage !== dismissedInfoMessage) { + return ( + + + + ); } else if (changes) { return ( - + diff --git a/src/client/webapp/elements/components/input-image-edit.tsx b/src/client/webapp/elements/components/input-image-edit.tsx index 5a77327..46aa426 100644 --- a/src/client/webapp/elements/components/input-image-edit.tsx +++ b/src/client/webapp/elements/components/input-image-edit.tsx @@ -18,11 +18,12 @@ interface ImageEditInputProps { value: Buffer | null; setValue: React.Dispatch>; - setErrorMessage: React.Dispatch>; + setValid: React.Dispatch>; + setMessage: React.Dispatch>; } const ImageEditInput: FC = (props: ImageEditInputProps) => { - const { alt, maxSize, value, setValue, setErrorMessage } = props; + const { alt, maxSize, value, setValue, setValid, setMessage } = props; const acceptedExtTypes = [ 'png', 'jpg', 'jpeg' ]; @@ -36,13 +37,16 @@ const ImageEditInput: FC = (props: ImageEditInputProps) => try { const src = await ElementsUtil.getImageBufferSrc(value); setImgSrc(src); + setValid(true); } catch (e: unknown) { LOG.error('unable to get image buffer src', e); setImgSrc('./img/error.png'); - setErrorMessage('Unable to get image src'); + setValid(false); + setMessage('Unable to get image src'); } } else { setImgSrc('./img/loading.svg'); + setValid(false); } })(); }, [ value ]); @@ -63,17 +67,17 @@ const ImageEditInput: FC = (props: ImageEditInputProps) => if (file.size > maxSize) { e.target.value = ''; LOG.debug('image too large'); - setErrorMessage(`Image too large. (${ElementsUtil.humanSize(file.size)}>${ElementsUtil.humanSize(maxSize)})`); + setMessage(`Image too large. (${ElementsUtil.humanSize(file.size)}>${ElementsUtil.humanSize(maxSize)})`); return; } const buff = Buffer.from(await file.arrayBuffer()); const typeResult = await FileType.fromBuffer(buff); if (!typeResult || !acceptedExtTypes.includes(typeResult.ext)) { e.target.value = ''; - setErrorMessage(`Invalid image type. Accepted types: ${acceptedExtTypes.join(', ')}`); + setMessage(`Invalid image type. Accepted types: ${acceptedExtTypes.join(', ')}`); return; } - setErrorMessage(null); + setMessage(null); setValue(buff); } diff --git a/src/client/webapp/elements/components/input-text.tsx b/src/client/webapp/elements/components/input-text.tsx index 90aedad..5d47459 100644 --- a/src/client/webapp/elements/components/input-text.tsx +++ b/src/client/webapp/elements/components/input-text.tsx @@ -3,27 +3,51 @@ const electronConsole = electronRemote.getGlobal('console') as Console; import Logger from '../../../../logger/logger'; const LOG = Logger.create(__filename, electronConsole); -import React, { FC, useMemo, useState } from 'react'; +import React, { FC, useEffect, useMemo, useState } from 'react'; export interface TextInputProps { - label?: string; + label: string; placeholder?: string; + noLabel?: boolean; + + allowEmpty?: boolean; + maxLength: number; + value: string; setValue: React.Dispatch>; + setValid: React.Dispatch>; + setMessage: React.Dispatch>; + onEnterKeyDown?: () => void; } const TextInput: FC = (props: TextInputProps) => { - const { label, placeholder, value, setValue, onEnterKeyDown } = props; + const { label, placeholder, noLabel, allowEmpty, maxLength, value, setValue, setValid, setMessage, onEnterKeyDown } = props; + + useEffect(() => { + if (maxLength !== undefined && value.length > maxLength) { + setValid(false); + setMessage(`${label} is too long (${value.length}>${maxLength})`); + return; + } + if (!allowEmpty && value.length === 0) { + setValid(false); + setMessage(`${label} is empty`); + return; + } + setValid(true); + setMessage(null); + }, [ value ]); const labelElement = useMemo(() => { - return label &&
{label}
+ return label && !noLabel &&
{label}
}, [ label ]); const handleChange = (e: React.ChangeEvent) => { - setValue(e.target.value); + const value = e.target.value; + setValue(value); } const handleKeyDown = (e: React.KeyboardEvent) => { @@ -37,7 +61,7 @@ const TextInput: FC = (props: TextInputProps) => { {labelElement} - ) + ); } export default TextInput; diff --git a/src/client/webapp/elements/components/submit-overlay-lower.tsx b/src/client/webapp/elements/components/submit-overlay-lower.tsx new file mode 100644 index 0000000..0ea834a --- /dev/null +++ b/src/client/webapp/elements/components/submit-overlay-lower.tsx @@ -0,0 +1,57 @@ +import React, { FC, useMemo, useState } from 'react'; +import ElementsUtil from '../require/elements-util'; +import Button from './button'; + +// Includes a submit button and error message + +export interface SubmitOverlayLowerProps { + buttonMessage: string; + doSubmit: () => Promise; + errorMessage: string | null; +} + +const SubmitOverlayLower: FC = (props: SubmitOverlayLowerProps) => { + const { buttonMessage, doSubmit, errorMessage } = props; + + const [ shaking, setShaking ] = useState(false) + const [ submitting, setSubmitting ] = useState(false); + const [ submitFailed, setSubmitFailed ] = useState(false); + + const buttonText = useMemo(() => { + if (submitting) { + return 'Submitting...'; + } else if (submitFailed) { + return 'Try Again'; + } else { + return buttonMessage; + } + }, [ submitting, submitFailed ]); + + const onSubmit = async () => { + setSubmitting(true); + const succeeded = await doSubmit(); + setSubmitting(false); + + setSubmitFailed(!succeeded); + if (!succeeded) { + await ElementsUtil.delayToggleState(setShaking, 400); + } + + + } + + const isEmpty = useMemo(() => errorMessage === null, [ errorMessage ]); + + return ( +
+
+
{errorMessage}
+
+
+ +
+
+ ); +} + +export default SubmitOverlayLower; diff --git a/src/client/webapp/elements/displays/display-guild-overview.tsx b/src/client/webapp/elements/displays/display-guild-overview.tsx index 231d23e..f8c0f59 100644 --- a/src/client/webapp/elements/displays/display-guild-overview.tsx +++ b/src/client/webapp/elements/displays/display-guild-overview.tsx @@ -31,7 +31,11 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const [ iconFailed, setIconFailed ] = useState(false); const [ saveFailed, setSaveFailed ] = useState(false); - const [ imageInputErrorMessage, setImageInputErrorMessage ] = useState(null); + const [ nameInputValid, setNameInputValid ] = useState(false); + const [ nameInputMessage, setNameInputMessage ] = useState(null); + + const [ iconInputValid, setIconInputValid ] = useState(false); + const [ iconInputMessage, setIconInputMessage ] = useState(null); useEffect(() => { (async () => { @@ -53,21 +57,26 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie const errorMessage = useMemo(() => { if (iconFailed) { return 'Unable to load icon'; - } else if (imageInputErrorMessage) { - return imageInputErrorMessage; - } else if (iconBuff && iconBuff.length > Globals.MAX_GUILD_ICON_SIZE) { - return `Icon is too large. (${ElementsUtil.humanSize(iconBuff.length)}>${ElementsUtil.humanSize(Globals.MAX_GUILD_ICON_SIZE)}) Try a 512x512 icon.`; - } else if (name.length === 0) { - return 'Name is empty'; - } else if (name.length > Globals.MAX_GUILD_NAME_LENGTH) { - return `Name is too long. (${name.length}>${Globals.MAX_GUILD_NAME_LENGTH} characters)`; + } else if (!iconInputValid && iconInputMessage) { + return iconInputMessage; + } else if (!nameInputValid && nameInputMessage) { + return nameInputMessage; } else { return null; } - }, [ iconFailed, imageInputErrorMessage, iconBuff, name ]); + }, [ iconFailed, iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]); + + const infoMessage = useMemo(() => { + if (iconInputValid && iconInputMessage) { + return iconInputMessage; + } else if (nameInputValid && nameInputMessage) { + return nameInputMessage; + } else { + return null; + } + }, [ iconInputValid, iconInputMessage, nameInputValid, nameInputMessage ]); const resetChanges = () => { - setImageInputErrorMessage(null); setName(savedName); setIconBuff(savedIconBuff); } @@ -112,14 +121,23 @@ const GuildOverviewDisplay: FC = (props: GuildOvervie
- +
- +
diff --git a/src/client/webapp/elements/events-add-guild.ts b/src/client/webapp/elements/events-add-guild.tsx similarity index 86% rename from src/client/webapp/elements/events-add-guild.ts rename to src/client/webapp/elements/events-add-guild.tsx index ed16b9f..e851c49 100644 --- a/src/client/webapp/elements/events-add-guild.ts +++ b/src/client/webapp/elements/events-add-guild.tsx @@ -12,6 +12,8 @@ import Q from '../q-module'; import UI from '../ui'; import GuildsManager from '../guilds-manager'; import createErrorMessageOverlay from './overlay-error-message'; +import AddGuildOverlay from './overlays/overlay-add-guild'; +import React from 'react'; export default function bindAddGuildEvents(document: Document, q: Q, ui: UI, guildsManager: GuildsManager): void { let choosingFile = false; @@ -52,8 +54,9 @@ export default function bindAddGuildEvents(document: Document, q: Q, ui: UI, gui LOG.debug('bad guild data:', { addGuildData, fileText }) throw new Error('bad guild data'); } - const overlayElement = createAddGuildOverlay(document, q, ui, guildsManager, addGuildData as IAddGuildData); - document.body.appendChild(overlayElement); + ElementsUtil.presentReactOverlay(document, ) + // const overlayElement = createAddGuildOverlay(document, q, ui, guildsManager, addGuildData as IAddGuildData); + // document.body.appendChild(overlayElement); } catch (e: unknown) { LOG.error('Unable to parse guild data', e); const errorOverlayElement = createErrorMessageOverlay(document, 'Unable to parse guild file', (e as Error).message); diff --git a/src/client/webapp/elements/overlay-guild-settings.tsx b/src/client/webapp/elements/overlay-guild-settings.tsx deleted file mode 100644 index 63dcc42..0000000 --- a/src/client/webapp/elements/overlay-guild-settings.tsx +++ /dev/null @@ -1,204 +0,0 @@ -/* eslint-disable @typescript-eslint/no-empty-function */ -import * as electronRemote from '@electron/remote'; -const electronConsole = electronRemote.getGlobal('console') as Console; -import Logger from '../../../logger/logger'; -const LOG = Logger.create(__filename, electronConsole); - -import Globals from '../globals'; - -import BaseElements, { HTMLElementWithRemoveSelf } from './require/base-elements'; -import ElementsUtil from './require/elements-util'; - -import { GuildMetadata } from '../data-types'; -import Q from '../q-module'; -import CombinedGuild from '../guild-combined'; - -import React from 'react'; - -export default function createGuildSettingsOverlay(document: Document, q: Q, guild: CombinedGuild, guildMeta: GuildMetadata): HTMLElementWithRemoveSelf { - const element = BaseElements.createOverlay(document, ( -
-
-
{guildMeta.name}
-
Overview
-
Channels
-
Roles
-
Invites
-
-
-
-
- -
-
Guild Name
- -
-
-
-
-
-
You have unsaved changes
-
-
Reset
-
Save Changes
-
-
-
-
-
-
-
-
X
-
-
-
-
-
- )); - - let newIconBuff: Buffer | null = null; - let oldName = guildMeta.name; - let newName = guildMeta.name; - - async function updatePopups(errMsg: string | null = null) { - if (errMsg) { - q.$$$(element, '.popup.error .tip').innerText = errMsg; - q.$$$(element, '.popup.error').classList.add('enabled'); - q.$$$(element, '.popup.changes').classList.remove('enabled'); - await ElementsUtil.shakeElement(q.$$$(element, '.popup.error'), 400); - } else if (newIconBuff !== null || newName !== oldName) { - q.$$$(element, '.popup.changes .tip').innerText = 'You have unsaved changes'; // in case it had an error before - q.$$$(element, '.popup.changes .button.save-changes').innerText = 'Save Changes'; // in case it had try again before - q.$$$(element, '.popup.error').classList.remove('enabled'); - q.$$$(element, '.popup.changes').classList.add('enabled'); - } else { - q.$$$(element, '.popup.error').classList.remove('enabled'); - q.$$$(element, '.popup.changes').classList.remove('enabled'); - } - } - - function reset() { - q.$$$(element, '.image-input-upload').value = ''; - q.$$$(element, 'input.guild-name').value = oldName; - (async () => { - LOG.debug('resetting icon'); - q.$$$(element, '.icon img').src = await ElementsUtil.getImageBufferFromResourceFailSoftly(guild, guildMeta.iconResourceId); - })(); - - newIconBuff = null; - newName = oldName; - } - - reset(); // sets icon - - q.$$$(element, '.popup.error .button.close').addEventListener('click', () => { - updatePopups() - }); - - q.$$$(element, '.popup.changes .button.reset').addEventListener('click', () => { - reset(); - updatePopups(); - }); - - BaseElements.bindImageUploadEvents(q.$$$(element, '.image-input-upload') as HTMLInputElement, { - maxSize: Globals.MAX_GUILD_ICON_SIZE, - acceptedMimeTypes: [ 'image/png', 'image/jpeg', 'image/jpg' ], - onChangeStart: async () => await updatePopups(), - onCleared: () => {}, - onError: async (errMsg) => await updatePopups(errMsg), - onLoaded: async (buff, src) => { - LOG.debug('image loaded'); - newIconBuff = buff; - (q.$$$(element, 'img.guild-icon') as HTMLImageElement).src = src; - await updatePopups(); - } - }); - - q.$$$(element, 'input.guild-name').addEventListener('input', (e) => { - newName = q.$$$(element, 'input.guild-name').value; - updatePopups(); - }); - - q.$$$(element, 'input.guild-name').addEventListener('keydown', (e) => { - if (e.key === 'Enter') { - e.preventDefault(); - q.$$$(element, '.button.save-changes').click(); - } - }); - - let submitting = false; - q.$$$(element, '.button.save-changes').addEventListener('click', async () => { - if (submitting) return; - submitting = true; - q.$$$(element, '.button.save-changes').innerText = 'Saving...'; - - if (newName == oldName && newIconBuff == null) { - // nothing changed - updatePopups(); - submitting = false; - return; - } - - let success = false; - if (newName != oldName && newName.length == 0) { - LOG.warn('attempted to set empty guild name'); - q.$$$(element, '.button.save-changes').innerText = 'Try Again'; - q.$$$(element, '.popup.changes .tip').innerText = 'New name is empty'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.save-changes'), 400); - } else if (newName != oldName && newName.length > Globals.MAX_GUILD_NAME_LENGTH) { - LOG.warn('attempted to oversized guild name'); - q.$$$(element, '.button.save-changes').innerText = 'Try Again'; - q.$$$(element, '.popup.changes .tip').innerText = 'New name is too long. ' + newName.length + ' > ' + Globals.MAX_GUILD_NAME_LENGTH; - await ElementsUtil.shakeElement(q.$$$(element, '.button.save-changes'), 400); - } else { // client-size icon size checks are handled above - let failed = false; - // Set Name - if (newName != oldName) { - try { - await guild.requestSetGuildName(newName); - guildMeta = await guild.fetchMetadata(); - } catch (e) { - LOG.error('error setting new guild name', e); - q.$$$(element, '.button.save-changes').innerText = 'Try Again'; - q.$$$(element, '.popup.changes .tip').innerText = 'Error setting new guild name'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.save-changes'), 400); - failed = true; - } - } - - // Set Icon - if (!failed && newIconBuff != null) { - try { - await guild.requestSetGuildIcon(newIconBuff); - newIconBuff = null; // prevent resubmit - } catch (e) { - LOG.error('error setting new guild icon', e); - q.$$$(element, '.button.submit').innerText = 'Try Again'; - q.$$$(element, '.popup.changes .tip').innerText = 'Error setting new guild icon'; - await ElementsUtil.shakeElement(q.$$$(element, '.button.submit'), 400); - failed = true; - } - } - - success = !failed; - } - - if (success) { - q.$$$(element, '.options .title').innerText = newName; - q.$$$(element, '.guild-name').setAttribute('placeholder', newName); - oldName = newName; - newIconBuff = null; - updatePopups(); - } - - submitting = false; - }); - - return element; -} diff --git a/src/client/webapp/elements/overlays/overlay-add-guild.tsx b/src/client/webapp/elements/overlays/overlay-add-guild.tsx new file mode 100644 index 0000000..9dcd0aa --- /dev/null +++ b/src/client/webapp/elements/overlays/overlay-add-guild.tsx @@ -0,0 +1,149 @@ +import * as electronRemote from '@electron/remote'; +const electronConsole = electronRemote.getGlobal('console') as Console; +import Logger from '../../../../logger/logger'; +const LOG = Logger.create(__filename, electronConsole); + +import React, { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import GuildsManager from '../../guilds-manager'; +import { IAddGuildData } from '../overlay-add-guild'; +import moment from 'moment'; +import TextInput from '../components/input-text'; +import ImageEditInput from '../components/input-image-edit'; +import Globals from '../../globals'; +import SubmitOverlayLower from '../components/submit-overlay-lower'; +import path from 'path'; +import fs from 'fs/promises'; +import Util from '../../util'; + +function getExampleDisplayName(): string { + const names = [ + 'gamer69', + 'exdenoccp', + 'TiggerEliminator', + 'FreshDingus', + 'Wonky Experiment', + 'TheLegend27' + ]; + return names[Math.floor(Math.random() * names.length)] as string; +} + +function getExampleAvatarPath(): string { + const paths = [ // these are relative to the working directory + path.join(__dirname, '../../img/default-avatars/avatar-airstrip.png'), + path.join(__dirname, '../../img/default-avatars/avatar-building.png'), + path.join(__dirname, '../../img/default-avatars/avatar-frog.png'), + path.join(__dirname, '../../img/default-avatars/avatar-sun.png') + ]; + return paths[Math.floor(Math.random() * paths.length)] as string; +} + +export interface AddGuildOverlayProps { + guildsManager: GuildsManager; + addGuildData: IAddGuildData; +} + +const AddGuildOverlay: FC = (props: AddGuildOverlayProps) => { + const { guildsManager, addGuildData } = props; + + const expired = addGuildData.expires < new Date().getTime(); + const exampleDisplayName = useMemo(() => getExampleDisplayName(), []); + const exampleAvatarPath = useMemo(() => getExampleAvatarPath(), []); + + const [ displayName, setDisplayName ] = useState(exampleDisplayName); + const [ avatarBuff, setAvatarBuff ] = useState(null); + + const [ exampleAvatarFailed, setExampleAvatarFailed ] = useState(false); + const [ addGuildFailedMessage, setAddGuildFailedMessage ] = useState(null); + + const [ displayNameInputMessage, setDisplayNameInputMessage ] = useState(null); + const [ displayNameInputValid, setDisplayNameInputValid ] = useState(false); + + const [ avatarInputMessage, setAvatarInputMessage ] = useState(null); + const [ avatarInputValid, setAvatarInputValid ] = useState(false); + + useEffect(() => { + (async () => { + try { + const exampleAvatarBuff = await fs.readFile(exampleAvatarPath); + if (avatarBuff === null) { + setAvatarBuff(exampleAvatarBuff); + } + } catch (e: unknown) { + LOG.error('error setting example avatar', e); + setExampleAvatarFailed(true); + } + })(); + }, []) + + const errorMessage = useMemo(() => { + if (exampleAvatarFailed && !avatarBuff) { + return 'Unable to load example avatar'; + } else if (!avatarInputValid && avatarInputMessage) { + return avatarInputMessage; + } else if (!displayNameInputValid && displayNameInputMessage) { + return displayNameInputMessage; + } else if (addGuildFailedMessage !== null) { + return addGuildFailedMessage; + } else { + return null; + } + }, [ exampleAvatarFailed, avatarBuff, displayNameInputValid, displayNameInputMessage, avatarInputValid, avatarInputMessage, addGuildFailedMessage ]); + + const doSubmit = useCallback(async (): Promise => { + if (!displayNameInputValid || !avatarInputValid || avatarBuff === null) { + return false; + } + if (expired) { + setAddGuildFailedMessage('token expired'); + return false; + } + try { + await guildsManager.addNewGuild(addGuildData, displayName, avatarBuff); + setAddGuildFailedMessage(null); + return true; + } catch (e: unknown) { + LOG.error('error adding new guild', e); + if (e instanceof Error) { + setAddGuildFailedMessage(e.message); + } else { + setAddGuildFailedMessage('error adding new guild'); + } + return false; + } + + }, [ displayName, avatarBuff, displayNameInputValid, avatarInputValid ]); + + return ( +
+
+ icon +
+
{addGuildData.name}
+
{addGuildData.url}
+
{(expired ? 'Invite Expired ' : 'Invite Expires ') + moment(addGuildData.expires).fromNow()}
+
+
+
+
+
+ +
+
+ +
+
+ +
+ ); +} + +export default AddGuildOverlay; diff --git a/src/client/webapp/styles/components.scss b/src/client/webapp/styles/components.scss index 4c8bcc5..6809ef6 100644 --- a/src/client/webapp/styles/components.scss +++ b/src/client/webapp/styles/components.scss @@ -113,4 +113,38 @@ } } } +} + +.lower-react { + .error-container { + background-color: $background-secondary-alt; + overflow-y: hidden; + max-height: 0; + transition: max-height 0.2s ease-in-out; + + &:not(.empty) { + max-height: 48px; + } + + .error { + font-size: 16px; + line-height: 1; + padding: 16px 16px; + height: 16px; + color: $text-normal; + + &::first-letter { + text-transform: uppercase; + } + } + } + + .buttons { + padding: 16px; + display: flex; + justify-content: flex-end; + background-color: $background-tertiary; + border-bottom-left-radius: 8px; + border-bottom-right-radius: 8px; + } } \ No newline at end of file diff --git a/src/client/webapp/styles/overlays.scss b/src/client/webapp/styles/overlays.scss index ba4c6dc..c2267da 100644 --- a/src/client/webapp/styles/overlays.scss +++ b/src/client/webapp/styles/overlays.scss @@ -234,7 +234,7 @@ body > .overlay, width: 64px; height: 64px; margin-right: 16px; - border-radius: 16px; + border-radius: 8px; } .preview .name { @@ -253,52 +253,34 @@ body > .overlay, font-weight: 600; } - .message { - margin: 16px; - padding: 0; - } - - .avatar-input { + .personalization { margin: 16px; display: flex; - align-items: center; - } - .display-name-input { - margin: 16px; - color: $text-normal; - background-color: $channeltextarea-background; - border-radius: 8px; - max-height: 100px; - overflow-y: scroll; - padding: 14px 16px; - } - - .display-name-input:focus { - outline: none; + .display-name { + margin-left: 16px; + } } .lower { padding: 16px; border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; - display: flex; - justify-content: space-between; - align-items: center; background-color: $background-tertiary; - } - .error { - color: $text-normal; - } - - .error::first-letter { - text-transform: uppercase; - } - - .buttons { - margin-left: 16px; - display: flex; + .error { + background-color: $background-secondary-alt; + color: $text-normal; + } + + .error::first-letter { + text-transform: uppercase; + } + + .buttons { + margin-left: 16px; + display: flex; + } } }