From 0b7f7fe06950676e883d7edf30ef3465b62d02f3 Mon Sep 17 00:00:00 2001 From: Ash Keel Date: Mon, 10 Jan 2022 11:19:54 +0100 Subject: [PATCH] Auth dialog, chat timers, some reusable components --- frontend/package-lock.json | 13 - frontend/package.json | 1 - frontend/src/locale/en/translation.json | 36 +- frontend/src/ui/App.tsx | 34 +- frontend/src/ui/components/Interval.tsx | 88 +++++ frontend/src/ui/components/MultiInput.tsx | 78 ++++ frontend/src/ui/components/Sidebar.tsx | 4 +- frontend/src/ui/pages/AuthDialog.tsx | 80 +++++ frontend/src/ui/pages/BotCommands.tsx | 105 +++--- frontend/src/ui/pages/BotTimers.tsx | 417 ++++++++++++++++++++++ frontend/src/ui/theme/forms.ts | 56 ++- frontend/src/ui/theme/pages.ts | 7 + frontend/src/ui/theme/theme.ts | 3 + frontend/src/ui/theme/utils.ts | 7 + 14 files changed, 843 insertions(+), 86 deletions(-) create mode 100644 frontend/src/ui/components/Interval.tsx create mode 100644 frontend/src/ui/components/MultiInput.tsx create mode 100644 frontend/src/ui/pages/AuthDialog.tsx create mode 100644 frontend/src/ui/pages/BotTimers.tsx diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 36f4dbf..02910c1 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -2708,11 +2708,6 @@ "error-ex": "^1.2.0" } }, - "parse-ms": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-2.1.0.tgz", - "integrity": "sha512-kHt7kzLoS9VBZfUsiKjv43mr91ea+U05EyKkEtqp7vNbHxmaVuEqN7XxeEVnGrMtYOAxGrDElSi96K7EgO1zCA==" - }, "path-exists": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", @@ -2812,14 +2807,6 @@ "fast-diff": "^1.1.2" } }, - "pretty-ms": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-7.0.1.tgz", - "integrity": "sha512-973driJZvxiGOQ5ONsFhOF/DtzPMOMtgC11kCpUrPGMTgqp2q/1gwzCquocrN33is0VZ5GFHXZYMM9l6h67v2Q==", - "requires": { - "parse-ms": "^2.1.0" - } - }, "progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", diff --git a/frontend/package.json b/frontend/package.json index 96c6cfa..57969bb 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -24,7 +24,6 @@ "overlayscrollbars": "^1.13.1", "overlayscrollbars-react": "^0.2.3", "postcss-import": "^14.0.2", - "pretty-ms": "^7.0.1", "react": "^17.0.2", "react-dom": "^17.0.2", "react-i18next": "^11.12.0", diff --git a/frontend/src/locale/en/translation.json b/frontend/src/locale/en/translation.json index 0197d46..4eab82c 100644 --- a/frontend/src/locale/en/translation.json +++ b/frontend/src/locale/en/translation.json @@ -125,6 +125,31 @@ "streamer": "Streamer only" }, "remove-command-title": "Remove command {{name}}?" + }, + "bottimers": { + "title": "Bot timers", + "desc": "Define reminders such as checking out your social media or ongoing events", + "add-button": "New timer", + "search-placeholder": "Search timer by name", + "timer-header-new": "New timer", + "timer-header-edit": "Edit timer", + "timer-name": "Timer name", + "timer-name-placeholder": "my-timer", + "timer-action-new": "Create", + "timer-action-edit": "Edit", + "remove-timer-title": "Remove timer {{name}}?", + "timer-parameters": "every {{time}}, ≥ {{messages}} messages in the last {{interval}}", + "timer-interval": "Minimul interval", + "timer-activity": "Minimul chat activity (0 to disable)", + "timer-activity-desc": "messages in the last 5 minutes", + "timer-messages": "Messages" + }, + "auth": { + "title": "Authentication required", + "desc": "The installation of strimertül you are trying to reach is protected with a password. Please write the password below to access the control panel.", + "no-pwd-note": " If the database has no password (for example, it was recently changed from having one to none), leave the field empty.", + "password": "Password", + "submit": "Authenticate" } }, "form-actions": { @@ -137,9 +162,18 @@ "disable": "Disable", "delete": "Delete", "cancel": "Cancel", - "ok": "OK" + "ok": "OK", + "add": "Add" }, "debug": { "dev-build": "Development build" + }, + "time": { + "x-hours": "{{time}} hrs", + "x-minutes": "{{time}} min", + "x-seconds": "{{time}} sec", + "hours": "hours", + "minutes": "minutes", + "seconds": "seconds" } } diff --git a/frontend/src/ui/App.tsx b/frontend/src/ui/App.tsx index f7607c4..14da8ab 100644 --- a/frontend/src/ui/App.tsx +++ b/frontend/src/ui/App.tsx @@ -1,4 +1,4 @@ -import React, { useEffect } from 'react'; +import React, { useEffect, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { ChatBubbleIcon, @@ -20,13 +20,27 @@ import ServerSettingsPage from './pages/ServerSettings'; import { RootState } from '../store'; import { createWSClient } from '../store/api/reducer'; import { ConnectionStatus } from '../store/api/types'; -import { styled } from './theme'; +import { + Button, + Dialog, + DialogContainer, + FlexRow, + InputBox, + PageHeader, + PageTitle, + styled, + TextBlock, +} from './theme'; // @ts-expect-error Asset import import spinner from '../assets/icon-loading.svg'; import BackendIntegrationPage from './pages/BackendIntegration'; import TwitchSettingsPage from './pages/TwitchSettings'; import TwitchBotCommandsPage from './pages/BotCommands'; +import TwitchBotTimersPage from './pages/BotTimers'; +import { useTranslation } from 'react-i18next'; +import DialogContent from './components/DialogContent'; +import AuthDialog from './pages/AuthDialog'; const LoadingDiv = styled('div', { display: 'flex', @@ -46,18 +60,6 @@ function Loading() { ); } - -function AuthDialog() { - const AuthWrapper = styled('div', { - display: 'flex', - justifyContent: 'center', - alignItems: 'center', - minHeight: '100vh', - }); - - return ; -} - const sections: RouteSection[] = [ { title: 'menu.sections.monitor', @@ -194,6 +196,10 @@ export default function App(): JSX.Element { path="/twitch/bot/commands" element={} /> + } + /> diff --git a/frontend/src/ui/components/Interval.tsx b/frontend/src/ui/components/Interval.tsx new file mode 100644 index 0000000..867f326 --- /dev/null +++ b/frontend/src/ui/components/Interval.tsx @@ -0,0 +1,88 @@ +import React, { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getInterval } from '../../lib/time-utils'; +import { ComboBox, FlexRow, InputBox } from '../theme'; + +export interface TimeUnit { + multiplier: number; + unit: string; +} + +export const seconds = { multiplier: 1, unit: 'time.seconds' }; +export const minutes = { multiplier: 60, unit: 'time.minutes' }; +export const hours = { multiplier: 3600, unit: 'time.hours' }; + +export interface IntervalProps { + active: boolean; + value: number; + id?: string; + min?: number; + units?: TimeUnit[]; + onChange?: (value: number) => void; +} + +function Interval({ id, active, value, min, units, onChange }: IntervalProps) { + const { t } = useTranslation(); + + const timeUnits = units ?? [seconds, minutes, hours]; + + const [numInitialValue, multInitialValue] = getInterval(value); + const [num, setNum] = useState(numInitialValue); + const [mult, setMult] = useState(multInitialValue); + + useEffect(() => { + const total = num * mult; + if (min && total < min) { + const [minNum, minMult] = getInterval(min); + setNum(minNum); + setMult(minMult); + } + onChange(Math.max(min ?? 0, total)); + }, [num, mult]); + + return ( + <> + + { + const intNum = parseInt(ev.target.value, 10); + if (Number.isNaN(intNum)) { + return; + } + setNum(intNum); + }} + placeholder="#" + /> + { + const intMult = parseInt(ev.target.value, 10); + if (Number.isNaN(intMult)) { + return; + } + setMult(intMult); + }} + > + {timeUnits.map((unit) => ( + + ))} + + + + ); +} + +export default React.memo(Interval); diff --git a/frontend/src/ui/components/MultiInput.tsx b/frontend/src/ui/components/MultiInput.tsx new file mode 100644 index 0000000..b1c5577 --- /dev/null +++ b/frontend/src/ui/components/MultiInput.tsx @@ -0,0 +1,78 @@ +import { Cross2Icon } from '@radix-ui/react-icons'; +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, FlexRow, InputBox, Textarea } from '../theme'; + +export interface MessageArrayProps { + placeholder?: string; + value: string[]; + onChange: (value: string[]) => void; +} + +function MessageArray({ value, placeholder, onChange }: MessageArrayProps) { + const { t } = useTranslation(); + + return ( + <> + {value.map((message, index) => ( + + + + {value.length > 1 && ( + + )} + + + ))} + + + + + ); +} + +export default React.memo(MessageArray); diff --git a/frontend/src/ui/components/Sidebar.tsx b/frontend/src/ui/components/Sidebar.tsx index 287187d..142f945 100644 --- a/frontend/src/ui/components/Sidebar.tsx +++ b/frontend/src/ui/components/Sidebar.tsx @@ -38,10 +38,12 @@ const Header = styled('div', { const AppName = styled('h1', { display: 'flex', alignItems: 'center', + justifyContent: 'center', gap: '0.2rem', fontSize: '1.4rem', margin: '0.5rem 0 0.5rem 0', fontWeight: 300, + paddingRight: '0.5rem', }); const VersionLabel = styled('div', { @@ -49,7 +51,7 @@ const VersionLabel = styled('div', { fontSize: '0.75rem', fontWeight: 'bold', color: '$teal8', - paddingLeft: '12px', + textAlign: 'center', }); const UpdateButton = styled('a', { diff --git a/frontend/src/ui/pages/AuthDialog.tsx b/frontend/src/ui/pages/AuthDialog.tsx new file mode 100644 index 0000000..e55bb72 --- /dev/null +++ b/frontend/src/ui/pages/AuthDialog.tsx @@ -0,0 +1,80 @@ +import { styled } from '@stitches/react'; +import React, { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Button, InputBox, TextBlock } from '../theme'; + +const AuthWrapper = styled('div', { + alignItems: 'center', + minHeight: '100vh', + maxWidth: '600px', + display: 'flex', + justifyContent: 'center', + width: '90vw', + margin: '0 auto', +}); +const AuthTitle = styled('div', { + fontWeight: 'bold', + color: '$teal12', + fontSize: '15pt', + borderBottom: '1px solid $teal6', + padding: '1rem 1.5rem', + margin: '0', + lineHeight: '1.25', +}); +const AuthDialogContainer = styled('form', { + display: 'flex', + flexDirection: 'column', + padding: '0', + backgroundColor: '$gray2', + borderRadius: '0.25rem', + boxShadow: + 'hsl(206 22% 7% / 35%) 0px 10px 38px -10px, hsl(206 22% 7% / 20%) 0px 10px 20px -15px', +}); +const Content = styled('div', { + display: 'flex', + flexDirection: 'column', + padding: '1.5rem', + gap: '1rem', +}); +const Actions = styled('div', { + display: 'flex', + gap: '0.5rem', + justifyContent: 'flex-end', + borderTop: '1px solid $gray6', + padding: '1rem 1.5rem', +}); + +export default function AuthDialog(): React.ReactElement { + const [password, setPassword] = useState(''); + const { t } = useTranslation(); + + return ( + + { + e.preventDefault(); + localStorage.setItem('password', password); + window.location.reload(); + }} + > + {t('pages.auth.title')} + + {t('pages.auth.desc')} + {t('pages.auth.no-pwd-note')} + + + setPassword(e.target.value)} + css={{ flex: 1 }} + placeholder={t('pages.auth.password')} + /> + + + + + ); +} diff --git a/frontend/src/ui/pages/BotCommands.tsx b/frontend/src/ui/pages/BotCommands.tsx index f3aafc1..9f61bae 100644 --- a/frontend/src/ui/pages/BotCommands.tsx +++ b/frontend/src/ui/pages/BotCommands.tsx @@ -22,6 +22,7 @@ import { FlexRow, InputBox, Label, + MultiButton, PageContainer, PageHeader, PageTitle, @@ -103,7 +104,7 @@ interface CommandItemProps { onDelete?: () => void; } -function CommandItemEl({ +function CommandItem({ name, item, onToggle, @@ -126,36 +127,38 @@ function CommandItemEl({ {item.access_level !== 'streamer' && '+'} )} - - - - - - - (onDelete ? onDelete() : null)} - /> - + + + + + + + + (onDelete ? onDelete() : null)} + /> + + {item.response} @@ -163,8 +166,6 @@ function CommandItemEl({ ); } -const CommandItem = React.memo(CommandItemEl); - type DialogPrompt = | { kind: 'new' } | { kind: 'edit'; name: string; item: TwitchBotCustomCommand }; @@ -269,20 +270,20 @@ function CommandDialog({ } export default function TwitchBotCommandsPage(): React.ReactElement { - const [botCommands, setBotCommands] = useModule(modules.twitchBotCommands); - const [commandFilter, setCommandFilter] = useState(''); + const [commands, setCommands] = useModule(modules.twitchBotCommands); + const [filter, setFilter] = useState(''); const [activeDialog, setActiveDialog] = useState(null); const { t } = useTranslation(); const dispatch = useDispatch(); - const commandFilterLC = commandFilter.toLowerCase(); + const filterLC = filter.toLowerCase(); const setCommand = (newName: string, data: TwitchBotCustomCommand): void => { switch (activeDialog.kind) { case 'new': dispatch( - setBotCommands({ - ...botCommands, + setCommands({ + ...commands, [newName]: { ...data, enabled: true, @@ -293,8 +294,8 @@ export default function TwitchBotCommandsPage(): React.ReactElement { case 'edit': { const oldName = activeDialog.name; dispatch( - setBotCommands({ - ...botCommands, + setCommands({ + ...commands, [oldName]: undefined, [newName]: data, }), @@ -307,8 +308,8 @@ export default function TwitchBotCommandsPage(): React.ReactElement { const deleteCommand = (cmd: string): void => { dispatch( - setBotCommands({ - ...botCommands, + setCommands({ + ...commands, [cmd]: undefined, }), ); @@ -316,11 +317,11 @@ export default function TwitchBotCommandsPage(): React.ReactElement { const toggleCommand = (cmd: string): void => { dispatch( - setBotCommands({ - ...botCommands, + setCommands({ + ...commands, [cmd]: { - ...botCommands[cmd], - enabled: !botCommands[cmd].enabled, + ...commands[cmd], + enabled: !commands[cmd].enabled, }, }), ); @@ -344,25 +345,25 @@ export default function TwitchBotCommandsPage(): React.ReactElement { setCommandFilter(e.target.value)} + value={filter} + onChange={(e) => setFilter(e.target.value)} /> - {Object.keys(botCommands ?? {}) - ?.filter((cmd) => cmd.toLowerCase().includes(commandFilterLC)) + {Object.keys(commands ?? {}) + ?.filter((cmd) => cmd.toLowerCase().includes(filterLC)) .sort() .map((cmd) => ( toggleCommand(cmd)} onEdit={() => setActiveDialog({ kind: 'edit', name: cmd, - item: botCommands[cmd], + item: commands[cmd], }) } onDelete={() => deleteCommand(cmd)} diff --git a/frontend/src/ui/pages/BotTimers.tsx b/frontend/src/ui/pages/BotTimers.tsx new file mode 100644 index 0000000..eb71620 --- /dev/null +++ b/frontend/src/ui/pages/BotTimers.tsx @@ -0,0 +1,417 @@ +import { PlusIcon } from '@radix-ui/react-icons'; +import React, { useState } from 'react'; +import { TFunction, useTranslation } from 'react-i18next'; +import { useDispatch } from 'react-redux'; +import { useModule } from '../../lib/react-utils'; +import { modules } from '../../store/api/reducer'; +import { TwitchBotTimer } from '../../store/api/types'; +import AlertContent from '../components/AlertContent'; +import DialogContent from '../components/DialogContent'; +import Interval, { hours, minutes } from '../components/Interval'; +import MultiInput from '../components/MultiInput'; +import { + Button, + Dialog, + DialogActions, + DialogClose, + Field, + FlexRow, + InputBox, + Label, + MultiButton, + PageContainer, + PageHeader, + PageTitle, + styled, + TextBlock, +} from '../theme'; +import { Alert, AlertTrigger } from '../theme/alert'; + +const TimerList = styled('div', { marginTop: '1rem' }); +const TimerItemContainer = styled('article', { + backgroundColor: '$gray2', + margin: '0.5rem 0', + padding: '0.5rem', + borderLeft: '5px solid $teal8', + borderRadius: '0.25rem', + borderBottom: '1px solid $gray4', + transition: 'all 50ms', + '&:hover': { + backgroundColor: '$gray3', + }, + variants: { + status: { + enabled: {}, + disabled: { + borderLeftColor: '$red7', + backgroundColor: '$gray3', + color: '$gray10', + }, + }, + }, +}); +const TimerHeader = styled('header', { + display: 'flex', + gap: '0.5rem', + alignItems: 'center', + marginBottom: '0.4rem', +}); +const TimerName = styled('span', { + color: '$teal10', + fontWeight: 'bold', + variants: { + status: { + enabled: {}, + disabled: { + color: '$gray10', + }, + }, + }, +}); +const TimerDescription = styled('span', { + flex: 1, +}); +const TimerActions = styled('div', { + display: 'flex', + alignItems: 'center', + gap: '0.25rem', +}); +const TimerText = styled('div', { + fontFamily: 'Space Mono', + fontSize: '10pt', + margin: '0 -0.5rem', + marginTop: '0', + marginBottom: '0.3rem', + padding: '0.5rem', + backgroundColor: '$gray4', + lineHeight: '1.2rem', + '&:last-child': { + marginBottom: '-0.5rem', + }, +}); + +function humanTime(t: TFunction<'translation'>, secs: number): string { + const mins = Math.floor(secs / 60); + const hrs = Math.floor(mins / 60); + + if (hrs > 0) { + return t('time.x-hours', { time: hrs }); + } + if (mins > 0) { + return t('time.x-minutes', { time: mins }); + } + return t('time.x-seconds', { time: secs }); +} + +interface TimerItemProps { + name: string; + item: TwitchBotTimer; + onToggle?: () => void; + onEdit?: () => void; + onDelete?: () => void; +} + +function TimerItem({ + name, + item, + onToggle, + onEdit, + onDelete, +}: TimerItemProps): React.ReactElement { + const { t } = useTranslation(); + + return ( + + + + {name} + + + ( + {t('pages.bottimers.timer-parameters', { + time: humanTime(t, item.minimum_delay), + messages: item.minimum_chat_activity, + interval: humanTime(t, 300), + })} + ) + + + + + + + + + + (onDelete ? onDelete() : null)} + /> + + + + + {item.messages?.map((message, index) => ( + {message} + ))} + + ); +} + +type DialogPrompt = + | { kind: 'new' } + | { kind: 'edit'; name: string; item: TwitchBotTimer }; + +function TimerDialog({ + kind, + name, + item, + onSubmit, +}: { + kind: 'new' | 'edit'; + name?: string; + item?: TwitchBotTimer; + onSubmit?: (name: string, item: TwitchBotTimer) => void; +}) { + const [timerName, setName] = useState(name ?? ''); + const [messages, setMessages] = useState(item?.messages ?? ['']); + const [minDelay, setMinDelay] = useState(item?.minimum_delay ?? 300); + const [minActivity, setMinActivity] = useState( + item?.minimum_chat_activity ?? 5, + ); + const { t } = useTranslation(); + + return ( + +
{ + e.preventDefault(); + if (onSubmit) { + onSubmit(timerName, { + ...item, + messages, + minimum_delay: minDelay, + minimum_chat_activity: minActivity, + }); + } + }} + > + + + setName(e.target.value)} + placeholder={t('pages.bottimers.timer-name-placeholder')} + /> + + + + + + + + + + + { + const intNum = parseInt(ev.target.value, 10); + if (Number.isNaN(intNum)) { + return; + } + setMinActivity(intNum); + }} + placeholder="#" + /> + {t('pages.bottimers.timer-activity-desc')} + + + + + + + + + + + + + + +
+
+ ); +} + +export default function TwitchBotTimersPage(): React.ReactElement { + const [timerConfig, setTimerConfig] = useModule(modules.twitchBotTimers); + const [filter, setFilter] = useState(''); + const [activeDialog, setActiveDialog] = useState(null); + const { t } = useTranslation(); + const dispatch = useDispatch(); + + const filterLC = filter.toLowerCase(); + + const setTimer = (newName: string, data: TwitchBotTimer): void => { + switch (activeDialog.kind) { + case 'new': + dispatch( + setTimerConfig({ + ...timerConfig, + timers: { + ...timerConfig.timers, + [newName]: { + ...data, + enabled: true, + }, + }, + }), + ); + break; + case 'edit': { + const oldName = activeDialog.name; + dispatch( + setTimerConfig({ + ...timerConfig, + timers: { + ...timerConfig.timers, + [oldName]: undefined, + [newName]: data, + }, + }), + ); + break; + } + } + setActiveDialog(null); + }; + + const deleteTimer = (cmd: string): void => { + dispatch( + setTimerConfig({ + ...timerConfig, + timers: { + ...timerConfig.timers, + [cmd]: undefined, + }, + }), + ); + }; + + const toggleTimer = (cmd: string): void => { + dispatch( + setTimerConfig({ + ...timerConfig, + timers: { + ...timerConfig.timers, + [cmd]: { + ...timerConfig.timers[cmd], + enabled: !timerConfig.timers[cmd].enabled, + }, + }, + }), + ); + }; + + return ( + + + {t('pages.bottimers.title')} + {t('pages.bottimers.desc')} + + + + + + setFilter(e.target.value)} + /> + + + {Object.keys(timerConfig?.timers ?? {}) + ?.filter((cmd) => cmd.toLowerCase().includes(filterLC)) + .sort() + .map((cmd) => ( + toggleTimer(cmd)} + onEdit={() => + setActiveDialog({ + kind: 'edit', + name: cmd, + item: timerConfig.timers[cmd], + }) + } + onDelete={() => deleteTimer(cmd)} + /> + ))} + + + { + if (!open) { + // Reset dialog status on dialog close + setActiveDialog(null); + } + }} + > + {activeDialog && ( + setTimer(name, data)} + /> + )} + + + ); +} diff --git a/frontend/src/ui/theme/forms.ts b/frontend/src/ui/theme/forms.ts index 1070e71..f1004ca 100644 --- a/frontend/src/ui/theme/forms.ts +++ b/frontend/src/ui/theme/forms.ts @@ -1,6 +1,7 @@ import * as UnstyledLabel from '@radix-ui/react-label'; import * as CheckboxPrimitive from '@radix-ui/react-checkbox'; import { styled } from './theme'; +import { theme } from '.'; export const Field = styled('fieldset', { all: 'unset', @@ -45,7 +46,7 @@ export const InputBox = styled('input', { fontWeight: '300', border: '1px solid $gray6', padding: '0.5rem', - borderRadius: '0.3rem', + borderRadius: theme.borderRadius.form, backgroundColor: '$gray2', '&:hover': { borderColor: '$teal7', @@ -59,6 +60,13 @@ export const InputBox = styled('input', { borderColor: '$gray5', color: '$gray8', }, + variants: { + border: { + none: { + borderWidth: '0', + }, + }, + }, }); export const Textarea = styled('textarea', { @@ -66,7 +74,7 @@ export const Textarea = styled('textarea', { fontWeight: '300', border: '1px solid $gray6', padding: '0.5rem', - borderRadius: '0.3rem', + borderRadius: theme.borderRadius.form, backgroundColor: '$gray2', '&:hover': { borderColor: '$teal7', @@ -80,6 +88,13 @@ export const Textarea = styled('textarea', { borderColor: '$gray5', color: '$gray8', }, + variants: { + border: { + none: { + borderWidth: '0', + }, + }, + }, }); export const ButtonGroup = styled('div', { @@ -87,12 +102,17 @@ export const ButtonGroup = styled('div', { gap: '0.5rem', }); +export const MultiButton = styled('div', { + display: 'flex', +}); + export const Button = styled('button', { all: 'unset', cursor: 'pointer', + color: '$gray12', fontWeight: '300', padding: '0.5rem 1rem', - borderRadius: '0.3rem', + borderRadius: theme.borderRadius.form, fontSize: '1.1rem', border: '1px solid $gray6', backgroundColor: '$gray4', @@ -108,13 +128,34 @@ export const Button = styled('button', { }, transition: 'all 0.2s', variants: { + border: { + none: { + borderWidth: '0', + }, + }, styling: { + form: { + padding: '0.65rem', + }, link: { backgroundColor: 'transparent', border: 'none', color: '$teal11', textDecoration: 'underline', }, + multi: { + borderRadius: '0', + margin: '0 -1px', + '&:first-child': { + borderRadius: `$borderRadius$form 0 0 $borderRadius$form`, + }, + '&:last-child': { + borderRadius: `0 $borderRadius$form $borderRadius$form 0`, + }, + '&:hover': { + zIndex: '1', + }, + }, }, size: { small: { @@ -177,7 +218,7 @@ export const ComboBox = styled('select', { fontWeight: '300', border: '1px solid $gray6', padding: '0.5rem', - borderRadius: '0.3rem', + borderRadius: theme.borderRadius.form, backgroundColor: '$gray2', '&:hover': { borderColor: '$teal7', @@ -191,6 +232,13 @@ export const ComboBox = styled('select', { borderColor: '$gray5', color: '$gray8', }, + variants: { + border: { + none: { + borderWidth: '0', + }, + }, + }, }); export const Checkbox = styled(CheckboxPrimitive.Root, { diff --git a/frontend/src/ui/theme/pages.ts b/frontend/src/ui/theme/pages.ts index 7f41d22..03cb26b 100644 --- a/frontend/src/ui/theme/pages.ts +++ b/frontend/src/ui/theme/pages.ts @@ -31,4 +31,11 @@ export const SectionHeader = styled('h2', { export const TextBlock = styled('p', { lineHeight: '1.5', + variants: { + spacing: { + none: { + margin: '0', + }, + }, + }, }); diff --git a/frontend/src/ui/theme/theme.ts b/frontend/src/ui/theme/theme.ts index e7e88df..8b642c4 100644 --- a/frontend/src/ui/theme/theme.ts +++ b/frontend/src/ui/theme/theme.ts @@ -37,5 +37,8 @@ export const { styled, theme } = createStitches({ ...redDark, ...blackA, }, + borderRadius: { + form: '0.3rem', + }, }, }); diff --git a/frontend/src/ui/theme/utils.ts b/frontend/src/ui/theme/utils.ts index d5090e8..7c2f796 100644 --- a/frontend/src/ui/theme/utils.ts +++ b/frontend/src/ui/theme/utils.ts @@ -1,3 +1,4 @@ +import { theme } from '.'; import { styled } from './theme'; export const FlexRow = styled('div', { @@ -6,6 +7,12 @@ export const FlexRow = styled('div', { alignItems: 'center', justifyContent: 'center', variants: { + border: { + form: { + border: '1px solid $gray6', + borderRadius: theme.borderRadius.form, + }, + }, spacing: { '1': { gap: '0.5rem',