From 016a2cc354c41e865f9fcdb2056a449dee2dafed Mon Sep 17 00:00:00 2001 From: Hunter Miller Date: Fri, 20 Aug 2021 16:58:26 -0500 Subject: [PATCH] system-prefs: implementing preferences menu --- pkg/grid/.eslintrc.js | 18 ++++- pkg/grid/package-lock.json | 28 +++++++ pkg/grid/package.json | 1 + pkg/grid/src/assets/notifications.svg | 28 +++++++ pkg/grid/src/assets/system-updates.svg | 26 ++++++ pkg/grid/src/components/DocketHeader.tsx | 3 +- pkg/grid/src/components/Setting.tsx | 36 +++++++++ pkg/grid/src/components/Toggle.tsx | 55 +++++++++++++ pkg/grid/src/logic/useAsyncCall.ts | 30 +++++++ pkg/grid/src/logic/useDebounce.ts | 30 +++++++ pkg/grid/src/logic/useIsMounted.ts | 11 +++ pkg/grid/src/logic/useTreaty.ts | 13 +-- pkg/grid/src/nav/Leap.tsx | 27 +++---- pkg/grid/src/nav/Nav.tsx | 9 ++- pkg/grid/src/nav/SystemPreferences.tsx | 68 ++++++++++++++-- .../src/nav/preferences/NotificationPrefs.tsx | 41 ++++++++++ .../src/nav/preferences/SystemUpdatePrefs.tsx | 81 +++++++++++++++++++ .../nav/preferences/usePreferencesStore.ts | 51 ++++++++++++ pkg/grid/src/nav/search/Home.tsx | 4 +- pkg/grid/src/state/docket.ts | 15 +--- pkg/grid/src/state/mock-data.ts | 8 ++ pkg/grid/src/styles/components.css | 8 ++ 22 files changed, 539 insertions(+), 52 deletions(-) create mode 100644 pkg/grid/src/assets/notifications.svg create mode 100644 pkg/grid/src/assets/system-updates.svg create mode 100644 pkg/grid/src/components/Setting.tsx create mode 100644 pkg/grid/src/components/Toggle.tsx create mode 100644 pkg/grid/src/logic/useAsyncCall.ts create mode 100644 pkg/grid/src/logic/useDebounce.ts create mode 100644 pkg/grid/src/logic/useIsMounted.ts create mode 100644 pkg/grid/src/nav/preferences/NotificationPrefs.tsx create mode 100644 pkg/grid/src/nav/preferences/SystemUpdatePrefs.tsx create mode 100644 pkg/grid/src/nav/preferences/usePreferencesStore.ts diff --git a/pkg/grid/.eslintrc.js b/pkg/grid/.eslintrc.js index 02ef1346da..19196a8a23 100644 --- a/pkg/grid/.eslintrc.js +++ b/pkg/grid/.eslintrc.js @@ -51,7 +51,23 @@ module.exports = { 'react/jsx-props-no-spreading': 'off', 'react/require-default-props': 'off', 'import/no-extraneous-dependencies': ['error'], - 'tailwind/class-order': 'off' + 'tailwind/class-order': 'off', + 'jsx-a11y/label-has-associated-control': [ + 'error', + { + required: { + some: ['nesting', 'id'] + } + } + ], + 'jsx-a11y/label-has-for': [ + 'error', + { + required: { + some: ['nesting', 'id'] + } + } + ] }, settings: { 'import/parsers': { diff --git a/pkg/grid/package-lock.json b/pkg/grid/package-lock.json index cbdd461635..a084e916eb 100644 --- a/pkg/grid/package-lock.json +++ b/pkg/grid/package-lock.json @@ -12,6 +12,7 @@ "@radix-ui/react-dropdown-menu": "^0.0.23", "@radix-ui/react-polymorphic": "^0.0.13", "@radix-ui/react-portal": "^0.0.15", + "@radix-ui/react-toggle": "^0.0.10", "@urbit/http-api": "^1.3.1", "classnames": "^2.3.1", "clipboard-copy": "^4.0.1", @@ -848,6 +849,21 @@ "react": "^16.8 || ^17.0" } }, + "node_modules/@radix-ui/react-toggle": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-0.0.10.tgz", + "integrity": "sha512-fr62h2p6tqayDBg3hkrdvVJFQQmzBNHolyOqF+MifeTAryBJ3AsQu9ZvuYxJzkIaPPAIGOohRdR48FSQGJWndA==", + "dependencies": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "0.0.5", + "@radix-ui/react-polymorphic": "0.0.13", + "@radix-ui/react-primitive": "0.0.15", + "@radix-ui/react-use-controllable-state": "0.0.6" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0" + } + }, "node_modules/@radix-ui/react-use-body-pointer-events": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.0.7.tgz", @@ -7917,6 +7933,18 @@ "@radix-ui/react-compose-refs": "0.0.5" } }, + "@radix-ui/react-toggle": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-toggle/-/react-toggle-0.0.10.tgz", + "integrity": "sha512-fr62h2p6tqayDBg3hkrdvVJFQQmzBNHolyOqF+MifeTAryBJ3AsQu9ZvuYxJzkIaPPAIGOohRdR48FSQGJWndA==", + "requires": { + "@babel/runtime": "^7.13.10", + "@radix-ui/primitive": "0.0.5", + "@radix-ui/react-polymorphic": "0.0.13", + "@radix-ui/react-primitive": "0.0.15", + "@radix-ui/react-use-controllable-state": "0.0.6" + } + }, "@radix-ui/react-use-body-pointer-events": { "version": "0.0.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-use-body-pointer-events/-/react-use-body-pointer-events-0.0.7.tgz", diff --git a/pkg/grid/package.json b/pkg/grid/package.json index d6d60e2eee..302bce7ecc 100644 --- a/pkg/grid/package.json +++ b/pkg/grid/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-dropdown-menu": "^0.0.23", "@radix-ui/react-polymorphic": "^0.0.13", "@radix-ui/react-portal": "^0.0.15", + "@radix-ui/react-toggle": "^0.0.10", "@urbit/http-api": "^1.3.1", "classnames": "^2.3.1", "clipboard-copy": "^4.0.1", diff --git a/pkg/grid/src/assets/notifications.svg b/pkg/grid/src/assets/notifications.svg new file mode 100644 index 0000000000..4e94769fb9 --- /dev/null +++ b/pkg/grid/src/assets/notifications.svg @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/grid/src/assets/system-updates.svg b/pkg/grid/src/assets/system-updates.svg new file mode 100644 index 0000000000..62fefa7051 --- /dev/null +++ b/pkg/grid/src/assets/system-updates.svg @@ -0,0 +1,26 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/pkg/grid/src/components/DocketHeader.tsx b/pkg/grid/src/components/DocketHeader.tsx index 1b70605e31..924d58f9c3 100644 --- a/pkg/grid/src/components/DocketHeader.tsx +++ b/pkg/grid/src/components/DocketHeader.tsx @@ -8,8 +8,7 @@ interface DocketHeaderProps { export function DocketHeader(props: DocketHeaderProps) { const { docket, children } = props; - const color = `#${docket.color.slice(2).replace('.', '')}`.toUpperCase(); - const { info, title, img } = docket; + const { info, title, img, color } = docket; return (
diff --git a/pkg/grid/src/components/Setting.tsx b/pkg/grid/src/components/Setting.tsx new file mode 100644 index 0000000000..64ae41db24 --- /dev/null +++ b/pkg/grid/src/components/Setting.tsx @@ -0,0 +1,36 @@ +import classNames from 'classnames'; +import React, { FC, HTMLAttributes } from 'react'; +import slugify from 'slugify'; +import { useAsyncCall } from '../logic/useAsyncCall'; +import { Spinner } from './Spinner'; +import { Toggle } from './Toggle'; + +type SettingsProps = { + name: string; + on: boolean; + toggle: (open: boolean) => Promise; +} & HTMLAttributes; + +export const Setting: FC = ({ name, on, toggle, className, children }) => { + const { status, call } = useAsyncCall(toggle); + const id = slugify(name); + + return ( +
+

+ {name} {status === 'loading' && } +

+
+
+ +
+
{children}
+
+
+ ); +}; diff --git a/pkg/grid/src/components/Toggle.tsx b/pkg/grid/src/components/Toggle.tsx new file mode 100644 index 0000000000..e6d3be2ff7 --- /dev/null +++ b/pkg/grid/src/components/Toggle.tsx @@ -0,0 +1,55 @@ +import classNames from 'classnames'; +import React, { useState } from 'react'; +import * as RadixToggle from '@radix-ui/react-toggle'; +import type * as Polymorphic from '@radix-ui/react-polymorphic'; + +type ToggleComponent = Polymorphic.ForwardRefComponent< + Polymorphic.IntrinsicElement, + Polymorphic.OwnProps & { + knobClass?: string; + } +>; + +export const Toggle = React.forwardRef( + ({ defaultPressed, pressed, onPressedChange, disabled, className }, ref) => { + const [on, setOn] = useState(defaultPressed); + const isControlled = !!onPressedChange; + const proxyPressed = isControlled ? pressed : on; + const proxyOnPressedChange = isControlled ? onPressedChange : setOn; + const knobPosition = proxyPressed ? 18 : 2; + + return ( + + + + + + + ); + } +) as ToggleComponent; diff --git a/pkg/grid/src/logic/useAsyncCall.ts b/pkg/grid/src/logic/useAsyncCall.ts new file mode 100644 index 0000000000..63ff02d7f3 --- /dev/null +++ b/pkg/grid/src/logic/useAsyncCall.ts @@ -0,0 +1,30 @@ +import { useCallback, useState } from 'react'; + +export type Status = 'initial' | 'loading' | 'success' | 'error'; + +export function useAsyncCall(cb: (...args: any[]) => Promise) { + const [status, setStatus] = useState('initial'); + const [error, setError] = useState(null); + + const call = useCallback( + (...args: any[]) => { + setStatus('loading'); + cb(...args) + .then((result) => { + setStatus('success'); + return result; + }) + .catch((err) => { + setError(err); + setStatus('error'); + }); + }, + [cb] + ); + + return { + call, + status, + error + }; +} diff --git a/pkg/grid/src/logic/useDebounce.ts b/pkg/grid/src/logic/useDebounce.ts new file mode 100644 index 0000000000..1caae7af01 --- /dev/null +++ b/pkg/grid/src/logic/useDebounce.ts @@ -0,0 +1,30 @@ +import { debounce, DebounceSettings } from 'lodash-es'; +import { useRef, useEffect, useCallback } from 'react'; +import { useIsMounted } from './useIsMounted'; + +export function useDebounce( + cb: (...args: any[]) => void, + delay: number, + options?: DebounceSettings +) { + const isMounted = useIsMounted(); + const inputsRef = useRef({ cb, delay }); // mutable ref like with useThrottle + + useEffect(() => { + inputsRef.current = { cb, delay }; + }); // also track cur. delay + + return useCallback( + debounce( + (...args) => { + // Debounce is an async callback. Cancel it, if in the meanwhile + // (1) component has been unmounted (see isMounted in snippet) + // (2) delay has changed + if (inputsRef.current.delay === delay && isMounted()) inputsRef.current.cb(...args); + }, + delay, + options + ), + [delay, debounce] + ); +} diff --git a/pkg/grid/src/logic/useIsMounted.ts b/pkg/grid/src/logic/useIsMounted.ts new file mode 100644 index 0000000000..a35b8472bd --- /dev/null +++ b/pkg/grid/src/logic/useIsMounted.ts @@ -0,0 +1,11 @@ +import { useRef, useEffect } from 'react'; + +export function useIsMounted() { + const isMountedRef = useRef(true); + useEffect(() => { + return () => { + isMountedRef.current = false; + }; + }, []); + return () => isMountedRef.current; +} diff --git a/pkg/grid/src/logic/useTreaty.ts b/pkg/grid/src/logic/useTreaty.ts index 123332713e..1d6dfe319a 100644 --- a/pkg/grid/src/logic/useTreaty.ts +++ b/pkg/grid/src/logic/useTreaty.ts @@ -4,8 +4,7 @@ import { useCallback, useEffect, useState } from 'react'; import { useParams } from 'react-router-dom'; import useDocketState from '../state/docket'; import { Treaty } from '../state/docket-types'; - -type Status = 'initial' | 'loading' | 'success' | 'error'; +import { useAsyncCall } from './useAsyncCall'; export function useTreaty() { const { ship, desk } = useParams<{ ship: string; desk: string }>(); @@ -13,7 +12,6 @@ export function useTreaty() { pick(s, ['requestTreaty', 'installDocket']) ); const [treaty, setTreaty] = useState(); - const [installStatus, setInstallStatus] = useState('initial'); useEffect(() => { async function getTreaty() { @@ -27,13 +25,8 @@ export function useTreaty() { clipboardCopy(`${ship}/${desk}`); }, [ship, desk]); - const installApp = useCallback(async () => { - setInstallStatus('loading'); - - installDocket(ship, desk) - .then(() => setInstallStatus('success')) - .catch(() => setInstallStatus('error')); - }, []); + const install = useCallback(() => installDocket(ship, desk), [ship, desk]); + const { status: installStatus, call: installApp } = useAsyncCall(install); return { ship, diff --git a/pkg/grid/src/nav/Leap.tsx b/pkg/grid/src/nav/Leap.tsx index d69b5886c4..489e376ace 100644 --- a/pkg/grid/src/nav/Leap.tsx +++ b/pkg/grid/src/nav/Leap.tsx @@ -1,5 +1,4 @@ import classNames from 'classnames'; -import { debounce } from 'lodash-es'; import React, { ChangeEvent, FocusEvent, @@ -13,6 +12,7 @@ import React, { import { Link, useHistory, useRouteMatch } from 'react-router-dom'; import slugify from 'slugify'; import { Cross } from '../components/icons/Cross'; +import { useDebounce } from '../logic/useDebounce'; import { MenuState, useLeapStore } from './Nav'; function normalizePathEnding(path: string) { @@ -83,22 +83,21 @@ export const Leap = React.forwardRef(({ menu, dropdown, showClose, className }: [menu] ); - const handleSearch = useCallback( - debounce( - (input: string) => { - if (!match || appsMatch) { - return; - } + const debouncedSearch = useDebounce( + (input: string) => { + if (!match || appsMatch) { + return; + } - useLeapStore.setState({ searchInput: input }); - navigateByInput(input); - }, - 300, - { leading: true } - ), - [menu, match] + useLeapStore.setState({ searchInput: input }); + navigateByInput(input); + }, + 300, + { leading: true } ); + const handleSearch = useCallback(debouncedSearch, [match]); + const onChange = useCallback( (e: ChangeEvent) => { const input = e.target as HTMLInputElement; diff --git a/pkg/grid/src/nav/Nav.tsx b/pkg/grid/src/nav/Nav.tsx index f2a661e35d..b9df721cb0 100644 --- a/pkg/grid/src/nav/Nav.tsx +++ b/pkg/grid/src/nav/Nav.tsx @@ -99,9 +99,10 @@ export const Nav: FunctionComponent = ({ menu }) => { return ( <> + {/* Using portal so that we can retain the same nav items both in the dialog and in the base header */} = ({ menu }) => { menu={menuState} dropdown="leap-items" showClose={isOpen} - className={!isOpen ? 'bg-gray-100' : ''} + className={classNames('flex-1 max-w-[600px]', !isOpen ? 'bg-gray-100' : '')} />
= ({ menu }) => { -
+
{ +export const SystemPreferences = ({ match }: RouteComponentProps<{ submenu: string }>) => { const select = useLeapStore((state) => state.select); + const subMatch = useRouteMatch<{ submenu: string }>(`${match.url}/:submenu`); useEffect(() => { select('System Preferences'); }, []); + const matchSub = useCallback( + (target: string) => { + if (!subMatch && target === 'notifications') { + return true; + } + + return subMatch?.params.submenu === target; + }, + [match, subMatch] + ); + return ( -
-

Recent Apps

-
-
-

Recent Developers

-
+
+ +
+ + + + +
); }; diff --git a/pkg/grid/src/nav/preferences/NotificationPrefs.tsx b/pkg/grid/src/nav/preferences/NotificationPrefs.tsx new file mode 100644 index 0000000000..c3ad8ebda9 --- /dev/null +++ b/pkg/grid/src/nav/preferences/NotificationPrefs.tsx @@ -0,0 +1,41 @@ +import React, { useEffect } from 'react'; +import { Setting } from '../../components/Setting'; +import { useLeapStore } from '../Nav'; +import { usePreferencesStore } from './usePreferencesStore'; + +export const NotificationPrefs = () => { + const select = useLeapStore((s) => s.select); + const { doNotDisturb, mentions, toggleDoNotDisturb, toggleMentions } = usePreferencesStore(); + + useEffect(() => { + select('System Preferences: Notifications'); + }, []); + + return ( + <> +

Notifications

+
+ +

+ Block visual desktop notifications whenever Urbit software produces an in-Landscape + notification badge. +

+

+ Turning this "off" will prompt your browser to ask if you'd like to + enable notifications +

+
+ +

+ [PLACEHOLDER] Block visual desktop notifications whenever Urbit software produces an + in-Landscape notification badge. +

+

+ Turning this "off" will prompt your browser to ask if you'd like to + enable notifications +

+
+
+ + ); +}; diff --git a/pkg/grid/src/nav/preferences/SystemUpdatePrefs.tsx b/pkg/grid/src/nav/preferences/SystemUpdatePrefs.tsx new file mode 100644 index 0000000000..2654ee7675 --- /dev/null +++ b/pkg/grid/src/nav/preferences/SystemUpdatePrefs.tsx @@ -0,0 +1,81 @@ +import React, { ChangeEvent, FormEvent, useCallback, useEffect, useState } from 'react'; +import { Button } from '../../components/Button'; +import { Setting } from '../../components/Setting'; +import { ShipName } from '../../components/ShipName'; +import { Spinner } from '../../components/Spinner'; +import { useAsyncCall } from '../../logic/useAsyncCall'; +import { useLeapStore } from '../Nav'; +import { usePreferencesStore } from './usePreferencesStore'; + +export const SystemUpdatePrefs = () => { + const select = useLeapStore((s) => s.select); + const { otasEnabled, otaSource, toggleOTAs, setOTASource } = usePreferencesStore(); + const [source, setSource] = useState(otaSource); + const sourceDirty = source !== otaSource; + const { status: sourceStatus, call: setOTA } = useAsyncCall(setOTASource); + + useEffect(() => { + select('System Preferences: Updates'); + }, []); + + useEffect(() => { + setSource(otaSource); + }, [otaSource]); + + const handleSourceChange = useCallback((e: ChangeEvent) => { + const { target } = e; + const value = target.value.trim(); + setSource(value.startsWith('~') ? value : `~${value}`); + }, []); + + const onSubmit = useCallback( + (e: FormEvent) => { + e.preventDefault(); + setOTA(source); + }, + [source] + ); + + return ( + <> +

System Updates

+
+ +

Automatically download and apply system updates to keep your Urbit up to date.

+

+ OTA Source: +

+
+
+ +

+ Enter a valid urbit name into this form to change who you receive OTA updates from. Be + sure to select a reliable urbit! +

+
+ + {sourceDirty && ( + + )} +
+
+
+ + ); +}; diff --git a/pkg/grid/src/nav/preferences/usePreferencesStore.ts b/pkg/grid/src/nav/preferences/usePreferencesStore.ts new file mode 100644 index 0000000000..3c342f941a --- /dev/null +++ b/pkg/grid/src/nav/preferences/usePreferencesStore.ts @@ -0,0 +1,51 @@ +import create from 'zustand'; +import { fakeRequest } from '../../state/mock-data'; + +const useMockData = import.meta.env.MODE === 'mock'; + +interface PreferencesStore { + otasEnabled: boolean; + otaSource: string; + doNotDisturb: boolean; + mentions: boolean; + setOTASource: (source: string) => Promise; + toggleOTAs: () => Promise; + toggleDoNotDisturb: () => Promise; + toggleMentions: () => Promise; +} + +export const usePreferencesStore = create((set) => ({ + otasEnabled: true, + otaSource: useMockData ? '~sabbus' : '', + doNotDisturb: false, + mentions: true, + /** + * a lot of these are repetitive, we may do better with a map of settings + * and some generic way to update them through pokes. That way, we could + * just have toggleSetting(key) and run a similar op for all + */ + toggleOTAs: async () => { + if (useMockData) { + await fakeRequest(); + set((state) => ({ otasEnabled: !state.otasEnabled })); + } + }, + setOTASource: async (source: string) => { + if (useMockData) { + await fakeRequest(); + set({ otaSource: source }); + } + }, + toggleDoNotDisturb: async () => { + if (useMockData) { + await fakeRequest(); + set((state) => ({ doNotDisturb: !state.doNotDisturb })); + } + }, + toggleMentions: async () => { + if (useMockData) { + await fakeRequest(); + set((state) => ({ mentions: !state.mentions })); + } + } +})); diff --git a/pkg/grid/src/nav/search/Home.tsx b/pkg/grid/src/nav/search/Home.tsx index 3a305f2a06..c2880532c1 100644 --- a/pkg/grid/src/nav/search/Home.tsx +++ b/pkg/grid/src/nav/search/Home.tsx @@ -4,10 +4,10 @@ export const Home = () => { return (

Recent Apps

-
+

Recent Developers

-
+
); }; diff --git a/pkg/grid/src/state/docket.ts b/pkg/grid/src/state/docket.ts index 3fec1f89bc..2f15fc8932 100644 --- a/pkg/grid/src/state/docket.ts +++ b/pkg/grid/src/state/docket.ts @@ -5,7 +5,7 @@ import { useCallback } from 'react'; import { omit } from 'lodash-es'; import api from './api'; import { Treaty, Dockets, Docket, Provider, Treaties, Providers } from './docket-types'; -import { mockProviders, mockTreaties } from './mock-data'; +import { fakeRequest, mockProviders, mockTreaties } from './mock-data'; const useMockData = import.meta.env.MODE === 'mock'; @@ -26,14 +26,6 @@ interface DocketState { uninstallDocket: (desk: string) => Promise; } -async function fakeRequest(data: T, time = 300): Promise { - return new Promise((resolve) => { - setTimeout(() => { - resolve(data); - }, time); - }); -} - const stableTreatyMap = new Map(); const useDocketState = create((set, get) => ({ @@ -107,7 +99,8 @@ const useDocketState = create((set, get) => ({ }, installDocket: async (ship: string, desk: string) => { if (useMockData) { - const docket = normalizeDocket(await get().requestTreaty(ship, desk)); + const treaties = await fakeRequest(mockTreaties); + const docket = treaties[desk]; set((state) => addCharge(state, { desk, docket })); } @@ -176,7 +169,7 @@ interface DelDockEvent { type DocketEvent = AddDockEvent | DelDockEvent; function addCharge(state: DocketState, { desk, docket }: AddDockEvent['add-dock']) { - return { charges: { ...state.charges, [desk]: docket } }; + return { charges: { ...state.charges, [desk]: normalizeDocket(docket) } }; } function delCharge(state: DocketState, desk: DelDockEvent['del-dock']) { diff --git a/pkg/grid/src/state/mock-data.ts b/pkg/grid/src/state/mock-data.ts index da46c30541..e066a82917 100644 --- a/pkg/grid/src/state/mock-data.ts +++ b/pkg/grid/src/state/mock-data.ts @@ -2,6 +2,14 @@ import systemUrl from '../assets/system.png'; import goUrl from '../assets/go.png'; import { Providers, Treaties, Treaty } from './docket-types'; +export async function fakeRequest(data?: any, time = 300): Promise { + return new Promise((resolve) => { + setTimeout(() => { + resolve(data); + }, time); + }); +} + export const mockProviders: Providers = { '~zod': { shipName: '~zod', diff --git a/pkg/grid/src/styles/components.css b/pkg/grid/src/styles/components.css index 7ad7a9e3f8..e44691951a 100644 --- a/pkg/grid/src/styles/components.css +++ b/pkg/grid/src/styles/components.css @@ -22,6 +22,14 @@ @apply min-w-52 p-4 rounded-xl; } +.inner-section { + @apply p-3 bg-gray-100 rounded-xl; +} + +.input { + @apply px-4 py-2 w-full bg-white rounded-xl; +} + .spinner { @apply inline-flex items-center w-6 h-6 animate-spin; }