diff --git a/apps/react/src/components/Button.tsx b/apps/react/src/components/Button.tsx deleted file mode 100644 index 53bb3fac..00000000 --- a/apps/react/src/components/Button.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import { Spinner } from './Spinner'; - -export type ButtonProps = React.ButtonHTMLAttributes & { - loading?: boolean; -}; - -export const Button = React.forwardRef( - ({ className = '', children, loading = false, disabled, ...props }, ref) => { - const isDisabled = loading || disabled; - const baseClasses = - 'flex w-full justify-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 transition-colors'; - const enabledPalette = - 'bg-blue-600 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600'; - const disabledPalette = 'bg-slate-300 text-slate-600 cursor-not-allowed shadow-none'; - return ( - - ); - }, -); -Button.displayName = 'Button'; diff --git a/apps/react/src/components/CardOptionsMenu.tsx b/apps/react/src/components/CardOptionsMenu.tsx index cadbf93b..738c030b 100644 --- a/apps/react/src/components/CardOptionsMenu.tsx +++ b/apps/react/src/components/CardOptionsMenu.tsx @@ -2,7 +2,7 @@ import { EllipsisVerticalIcon } from '@heroicons/react/24/outline'; import React from 'react'; import { Menu, MenuButton } from '@headlessui/react'; import { DropdownMenu, DropdownItem } from './DropdownMenu'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from './ui/CircleHover'; import { InputModal } from './modals/InputModal'; import { ConfirmModal } from './modals/ConfirmModal'; import { VisibilityModal } from './modals/VisibilityModal'; diff --git a/apps/react/src/components/ConsoleErrorsButton.tsx b/apps/react/src/components/ConsoleErrorsButton.tsx index 3a719cc1..1cb5c522 100644 --- a/apps/react/src/components/ConsoleErrorsButton.tsx +++ b/apps/react/src/components/ConsoleErrorsButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ExclamationTriangleIcon } from '@heroicons/react/24/outline'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from './ui/CircleHover'; import { Modal } from './modals/Modal'; import { useConsoleErrors } from '../utils/useConsoleErrors'; import { isIOSDebug } from '../utils/isIOSDebug'; diff --git a/apps/react/src/components/FlashCard.tsx b/apps/react/src/components/FlashCard.tsx index b4b92491..d085fa15 100644 --- a/apps/react/src/components/FlashCard.tsx +++ b/apps/react/src/components/FlashCard.tsx @@ -6,7 +6,7 @@ import React, { forwardRef } from 'react'; import { IS_TEST_ENV } from '../utils/constants'; import { FlashCardOptionsMenu } from './FlashCardOptionsMenu'; import { MultiSheetCardQuestion } from './FlashCards/MultiSheetCardQuestion'; -import { Pill } from './Pill'; +import { Pill } from './ui/Pill'; type Placement = 'cur' | 'scheduled' | 'answered'; diff --git a/apps/react/src/components/FlashCardDeleteButton.tsx b/apps/react/src/components/FlashCardDeleteButton.tsx index c3061a16..73e26966 100644 --- a/apps/react/src/components/FlashCardDeleteButton.tsx +++ b/apps/react/src/components/FlashCardDeleteButton.tsx @@ -4,7 +4,7 @@ import { deleteCard } from 'MemoryFlashCore/src/redux/actions/delete-card-action import { CardWithAttempts } from 'MemoryFlashCore/src/redux/selectors/currDeckCardsWithAttempts'; import { useAppDispatch } from 'MemoryFlashCore/src/redux/store'; import { useIsCardOwner } from '../utils/useIsCardOwner'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from './ui/CircleHover'; import { ConfirmModal } from './modals/ConfirmModal'; interface FlashCardDeleteButtonProps { diff --git a/apps/react/src/components/FlashCardEditButton.tsx b/apps/react/src/components/FlashCardEditButton.tsx index d61bae68..bc74b6a7 100644 --- a/apps/react/src/components/FlashCardEditButton.tsx +++ b/apps/react/src/components/FlashCardEditButton.tsx @@ -1,7 +1,7 @@ import { PencilSquareIcon } from '@heroicons/react/24/outline'; import { CardWithAttempts } from 'MemoryFlashCore/src/redux/selectors/currDeckCardsWithAttempts'; import { useIsCardOwner } from '../utils/useIsCardOwner'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from './ui/CircleHover'; interface FlashCardEditButtonProps { card: CardWithAttempts; diff --git a/apps/react/src/components/FlashCardOptionsMenu.tsx b/apps/react/src/components/FlashCardOptionsMenu.tsx index 1ff1bc3e..343ade7e 100644 --- a/apps/react/src/components/FlashCardOptionsMenu.tsx +++ b/apps/react/src/components/FlashCardOptionsMenu.tsx @@ -2,7 +2,7 @@ import { EllipsisVerticalIcon } from '@heroicons/react/24/outline'; import React from 'react'; import { Menu, MenuButton } from '@headlessui/react'; import { DropdownMenu, DropdownItem } from './DropdownMenu'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from './ui/CircleHover'; import { ConfirmModal } from './modals/ConfirmModal'; import { useNavigate } from 'react-router-dom'; import { useIsCardOwner } from '../utils/useIsCardOwner'; diff --git a/apps/react/src/components/FlashCards/RevealAnswerModal.tsx b/apps/react/src/components/FlashCards/RevealAnswerModal.tsx index d25e07fe..e5e28de0 100644 --- a/apps/react/src/components/FlashCards/RevealAnswerModal.tsx +++ b/apps/react/src/components/FlashCards/RevealAnswerModal.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { Modal } from '../modals/Modal'; import { MusicNotation } from '../MusicNotation'; -import { Button } from '../Button'; +import { Button } from '../ui/Button'; import { AnswerType, Card, CardTypeEnum, ChordMemoryAnswer } from 'MemoryFlashCore/src/types/Cards'; export const canRevealAnswer = (card: Card): boolean => { diff --git a/apps/react/src/components/OpenSettingsButton.tsx b/apps/react/src/components/OpenSettingsButton.tsx index 923737f4..62f1b838 100644 --- a/apps/react/src/components/OpenSettingsButton.tsx +++ b/apps/react/src/components/OpenSettingsButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { Cog6ToothIcon } from '@heroicons/react/24/outline'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from './ui/CircleHover'; import { isIOSDebug } from '../utils/isIOSDebug'; export const OpenSettingsButton: React.FC = () => { diff --git a/apps/react/src/components/SegmentButton.tsx b/apps/react/src/components/SegmentButton.tsx index 28a5902a..7230f558 100644 --- a/apps/react/src/components/SegmentButton.tsx +++ b/apps/react/src/components/SegmentButton.tsx @@ -1,20 +1,40 @@ +import clsx from 'clsx'; + interface SegmentedButtonProps { text: string; Icon?: (props: { color: string }) => JSX.Element; active: boolean; onClick: () => void; + variant?: 'default' | 'compact'; } -export const SegmentButton: React.FC = ({ text, Icon, active, onClick }) => { +export const SegmentButton: React.FC = ({ + text, + Icon, + active, + onClick, + variant = 'default', +}) => { return ( -
{Icon && } -
{text}
-
+ {text} + ); }; diff --git a/apps/react/src/components/feedback/EmptyState.tsx b/apps/react/src/components/feedback/EmptyState.tsx new file mode 100644 index 00000000..07ff4019 --- /dev/null +++ b/apps/react/src/components/feedback/EmptyState.tsx @@ -0,0 +1,15 @@ +import React from 'react'; +import clsx from 'clsx'; + +interface EmptyStateProps { + message: string; + className?: string; +} + +export const EmptyState: React.FC = ({ message, className }) => { + return ( +

+ {message} +

+ ); +}; diff --git a/apps/react/src/components/ErrorCard.tsx b/apps/react/src/components/feedback/ErrorCard.tsx similarity index 100% rename from apps/react/src/components/ErrorCard.tsx rename to apps/react/src/components/feedback/ErrorCard.tsx diff --git a/apps/react/src/components/Spinner.tsx b/apps/react/src/components/feedback/Spinner.tsx similarity index 100% rename from apps/react/src/components/Spinner.tsx rename to apps/react/src/components/feedback/Spinner.tsx diff --git a/apps/react/src/components/Toast.tsx b/apps/react/src/components/feedback/Toast.tsx similarity index 100% rename from apps/react/src/components/Toast.tsx rename to apps/react/src/components/feedback/Toast.tsx diff --git a/apps/react/src/components/feedback/index.ts b/apps/react/src/components/feedback/index.ts new file mode 100644 index 00000000..19cf1e5c --- /dev/null +++ b/apps/react/src/components/feedback/index.ts @@ -0,0 +1,4 @@ +export * from './Spinner'; +export * from './Toast'; +export * from './ErrorCard'; +export * from './EmptyState'; diff --git a/apps/react/src/components/index.ts b/apps/react/src/components/index.ts index 28fd11c8..466c0fa8 100644 --- a/apps/react/src/components/index.ts +++ b/apps/react/src/components/index.ts @@ -1,18 +1,23 @@ -export * from './Layout'; +// UI Components +export * from './ui'; + +// Layout Components +export * from './layout'; + +// Feedback Components +export * from './feedback'; + +// Form Inputs +export * from './inputs'; + +// Modals +export * from './modals'; + +// Other Components export * from './FlashCard'; export * from '../screens/StudyScreen/StudyScreen'; -export * from './SectionCard'; -export * from './SectionData'; -export * from './SectionHeader'; -export * from './SegmentHeader'; export * from './graphics'; export * from './AuthForm'; -export * from './inputs'; -export * from './Button'; -export * from './modals/Modal'; -export * from './modals/InputModal'; -export * from './modals/ConfirmModal'; -export * from './modals/VisibilityModal'; export * from './CardOptionsMenu'; export * from './ConsoleErrorsButton'; export * from './TranspositionSelector'; diff --git a/apps/react/src/components/inputs/SearchInput.tsx b/apps/react/src/components/inputs/SearchInput.tsx new file mode 100644 index 00000000..958f16fd --- /dev/null +++ b/apps/react/src/components/inputs/SearchInput.tsx @@ -0,0 +1,11 @@ +import React from 'react'; +import { BaseInput, BaseInputProps } from './BaseInput'; + +export type SearchInputProps = Omit; + +export const SearchInput = React.forwardRef( + ({ className = '', ...props }, ref) => ( + + ), +); +SearchInput.displayName = 'SearchInput'; diff --git a/apps/react/src/components/inputs/index.ts b/apps/react/src/components/inputs/index.ts index c99f1b46..b126b466 100644 --- a/apps/react/src/components/inputs/index.ts +++ b/apps/react/src/components/inputs/index.ts @@ -6,3 +6,4 @@ export * from './Select'; export * from './Checkbox'; export * from './DurationSelect'; export * from './NumberInput'; +export * from './SearchInput'; diff --git a/apps/react/src/components/Layout.tsx b/apps/react/src/components/layout/Layout.tsx similarity index 86% rename from apps/react/src/components/Layout.tsx rename to apps/react/src/components/layout/Layout.tsx index a2b09ab5..91a40654 100644 --- a/apps/react/src/components/Layout.tsx +++ b/apps/react/src/components/layout/Layout.tsx @@ -2,12 +2,12 @@ import { ChevronLeftIcon, ArrowPathIcon, GlobeAltIcon } from '@heroicons/react/2 import clsx from 'clsx'; import React from 'react'; import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { CircleHover } from './CircleHover'; -import { MidiInputsDropdown } from './MidiInputsDropdown'; -import { AccountNavButton } from './navigation/AccountNavButton'; -import { isIOSDebug } from '../utils/isIOSDebug'; -import { ConsoleErrorsButton } from './ConsoleErrorsButton'; -import { StreakChip } from './StreakChip'; +import { CircleHover } from '../ui/CircleHover'; +import { MidiInputsDropdown } from '../MidiInputsDropdown'; +import { AccountNavButton } from '../navigation/AccountNavButton'; +import { isIOSDebug } from '../../utils/isIOSDebug'; +import { ConsoleErrorsButton } from '../ConsoleErrorsButton'; +import { StreakChip } from '../StreakChip'; interface LayoutProps { children: React.ReactNode; diff --git a/apps/react/src/components/SectionCard.tsx b/apps/react/src/components/layout/SectionCard.tsx similarity index 97% rename from apps/react/src/components/SectionCard.tsx rename to apps/react/src/components/layout/SectionCard.tsx index 8f34ff60..b1af99e2 100644 --- a/apps/react/src/components/SectionCard.tsx +++ b/apps/react/src/components/layout/SectionCard.tsx @@ -1,5 +1,5 @@ import React, { useCallback } from 'react'; -import { Card } from './Card'; +import { Card } from '../ui/Card'; import { useNavigate } from 'react-router-dom'; interface SectionCardProps { diff --git a/apps/react/src/components/SectionData.tsx b/apps/react/src/components/layout/SectionData.tsx similarity index 100% rename from apps/react/src/components/SectionData.tsx rename to apps/react/src/components/layout/SectionData.tsx diff --git a/apps/react/src/components/SectionHeader.tsx b/apps/react/src/components/layout/SectionHeader.tsx similarity index 95% rename from apps/react/src/components/SectionHeader.tsx rename to apps/react/src/components/layout/SectionHeader.tsx index 60c8a21d..e550d0c0 100644 --- a/apps/react/src/components/SectionHeader.tsx +++ b/apps/react/src/components/layout/SectionHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { ChevronDownIcon } from '@heroicons/react/24/solid'; -import { CircleHover } from './CircleHover'; +import { CircleHover } from '../ui/CircleHover'; interface SectionHeaderProps { title: string; diff --git a/apps/react/src/components/SegmentHeader.tsx b/apps/react/src/components/layout/SegmentHeader.tsx similarity index 84% rename from apps/react/src/components/SegmentHeader.tsx rename to apps/react/src/components/layout/SegmentHeader.tsx index b9e21ea1..d37fc82d 100644 --- a/apps/react/src/components/SegmentHeader.tsx +++ b/apps/react/src/components/layout/SegmentHeader.tsx @@ -1,6 +1,6 @@ import React from 'react'; -import { iconDisplay } from '../mocks/icon'; -import { SegmentButton } from './SegmentButton'; +import { iconDisplay } from '../../mocks/icon'; +import { SegmentButton } from '../ui/SegmentButton'; interface SegmentHeaderProps { segments: string[]; diff --git a/apps/react/src/components/layout/index.ts b/apps/react/src/components/layout/index.ts new file mode 100644 index 00000000..d47beccc --- /dev/null +++ b/apps/react/src/components/layout/index.ts @@ -0,0 +1,5 @@ +export * from './Layout'; +export * from './SectionCard'; +export * from './SectionData'; +export * from './SectionHeader'; +export * from './SegmentHeader'; diff --git a/apps/react/src/components/modals/ConfirmModal.tsx b/apps/react/src/components/modals/ConfirmModal.tsx index 96314ce2..cc61d501 100644 --- a/apps/react/src/components/modals/ConfirmModal.tsx +++ b/apps/react/src/components/modals/ConfirmModal.tsx @@ -1,5 +1,6 @@ import React from 'react'; import { Modal, ModalProps } from './Modal'; +import { ModalButtons } from './ModalButtons'; export interface ConfirmModalProps extends Omit { message: string; @@ -20,25 +21,14 @@ export const ConfirmModal: React.FC = ({
{message}
-
- - -
+ { + onConfirm(); + onClose(); + }} + confirmText={confirmText} + confirmVariant="danger" + /> ); diff --git a/apps/react/src/components/modals/InputModal.tsx b/apps/react/src/components/modals/InputModal.tsx index d2f35da7..c659f3d7 100644 --- a/apps/react/src/components/modals/InputModal.tsx +++ b/apps/react/src/components/modals/InputModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useState } from 'react'; import { Modal, ModalProps } from './Modal'; import { InputField } from '../inputs'; +import { ModalButtons } from './ModalButtons'; export interface InputModalProps extends Omit { label: string; @@ -43,22 +44,7 @@ export const InputModal: React.FC = ({ /> -
- - -
+ ); }; diff --git a/apps/react/src/components/modals/ModalButtons.tsx b/apps/react/src/components/modals/ModalButtons.tsx new file mode 100644 index 00000000..4a7e194f --- /dev/null +++ b/apps/react/src/components/modals/ModalButtons.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { Button, ButtonVariant } from '../ui/Button'; + +export interface ModalButtonsProps { + onCancel: () => void; + onConfirm: () => void; + cancelText?: string; + confirmText?: string; + confirmVariant?: ButtonVariant; + confirmDisabled?: boolean; +} + +export const ModalButtons: React.FC = ({ + onCancel, + onConfirm, + cancelText = 'Cancel', + confirmText = 'Confirm', + confirmVariant = 'primary', + confirmDisabled = false, +}) => { + return ( +
+ + +
+ ); +}; diff --git a/apps/react/src/components/modals/VisibilityModal.tsx b/apps/react/src/components/modals/VisibilityModal.tsx index ac673a0f..7305a314 100644 --- a/apps/react/src/components/modals/VisibilityModal.tsx +++ b/apps/react/src/components/modals/VisibilityModal.tsx @@ -3,6 +3,7 @@ import { Modal, ModalProps } from './Modal'; import { Visibility, VISIBILITIES } from 'MemoryFlashCore/src/types/Deck'; import { RadioGroup, Radio, Label, Description, Field } from '@headlessui/react'; import clsx from 'clsx'; +import { ModalButtons } from './ModalButtons'; const VISIBILITY_INFO: Record = { private: { label: 'Private', description: 'Only you can see this' }, @@ -119,23 +120,12 @@ export const VisibilityModal: React.FC = ({ )} -
- - -
+ onSave(selected)} + confirmText={isSaving ? 'Saving...' : 'Save'} + confirmDisabled={isSaving} + /> ); }; diff --git a/apps/react/src/components/modals/index.ts b/apps/react/src/components/modals/index.ts new file mode 100644 index 00000000..4df9bcf9 --- /dev/null +++ b/apps/react/src/components/modals/index.ts @@ -0,0 +1,10 @@ +export { ConfirmModal } from './ConfirmModal'; +export type { ConfirmModalProps } from './ConfirmModal'; +export { InputModal } from './InputModal'; +export type { InputModalProps } from './InputModal'; +export { Modal } from './Modal'; +export type { ModalProps } from './Modal'; +export { ModalButtons } from './ModalButtons'; +export type { ModalButtonsProps } from './ModalButtons'; +export { VisibilityModal } from './VisibilityModal'; +export type { VisibilityModalProps } from './VisibilityModal'; diff --git a/apps/react/src/components/navigation/AccountNavButton.tsx b/apps/react/src/components/navigation/AccountNavButton.tsx index 5addd8fd..9acb490d 100644 --- a/apps/react/src/components/navigation/AccountNavButton.tsx +++ b/apps/react/src/components/navigation/AccountNavButton.tsx @@ -1,6 +1,6 @@ import React from 'react'; import { UserCircleIcon } from '@heroicons/react/24/outline'; -import { CircleHover } from '../CircleHover'; +import { CircleHover } from '../ui/CircleHover'; interface AccountNavButtonProps {} diff --git a/apps/react/src/components/notation/SettingsSection.tsx b/apps/react/src/components/notation/SettingsSection.tsx index 0863b933..eaf37f0a 100644 --- a/apps/react/src/components/notation/SettingsSection.tsx +++ b/apps/react/src/components/notation/SettingsSection.tsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; -import { SectionHeader } from '../SectionHeader'; -import { Card } from '../Card'; +import { SectionHeader } from '../layout/SectionHeader'; +import { Card } from '../ui/Card'; interface SettingsSectionProps { title: string; diff --git a/apps/react/src/components/ui/Button.tsx b/apps/react/src/components/ui/Button.tsx new file mode 100644 index 00000000..66b42fcb --- /dev/null +++ b/apps/react/src/components/ui/Button.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import clsx from 'clsx'; +import { Spinner } from '../feedback/Spinner'; + +export type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger'; + +export type ButtonProps = React.ButtonHTMLAttributes & { + loading?: boolean; + variant?: ButtonVariant; +}; + +const variantStyles: Record = { + primary: { + enabled: + 'bg-blue-600 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600', + disabled: 'bg-slate-300 text-slate-600 cursor-not-allowed shadow-none', + }, + secondary: { + enabled: + 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600', + disabled: + 'bg-gray-100 text-gray-400 cursor-not-allowed dark:bg-gray-800 dark:text-gray-500', + }, + outline: { + enabled: + 'bg-white text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-100 dark:ring-gray-600 dark:hover:bg-gray-700', + disabled: + 'bg-white text-gray-400 cursor-not-allowed ring-1 ring-inset ring-gray-200 dark:bg-gray-800 dark:text-gray-500 dark:ring-gray-700', + }, + danger: { + enabled: + 'bg-red-600 text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600', + disabled: 'bg-red-300 text-red-100 cursor-not-allowed shadow-none', + }, +}; + +export const Button = React.forwardRef( + ( + { className = '', children, loading = false, disabled, variant = 'primary', ...props }, + ref, + ) => { + const isDisabled = loading || disabled; + const baseClasses = + 'inline-flex justify-center items-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 transition-colors'; + const styles = variantStyles[variant]; + return ( + + ); + }, +); +Button.displayName = 'Button'; diff --git a/apps/react/src/components/Card.tsx b/apps/react/src/components/ui/Card.tsx similarity index 100% rename from apps/react/src/components/Card.tsx rename to apps/react/src/components/ui/Card.tsx diff --git a/apps/react/src/components/CircleHover.tsx b/apps/react/src/components/ui/CircleHover.tsx similarity index 100% rename from apps/react/src/components/CircleHover.tsx rename to apps/react/src/components/ui/CircleHover.tsx diff --git a/apps/react/src/components/ui/ContentCard.tsx b/apps/react/src/components/ui/ContentCard.tsx new file mode 100644 index 00000000..c2673af0 --- /dev/null +++ b/apps/react/src/components/ui/ContentCard.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import clsx from 'clsx'; + +export interface ContentCardProps { + children: React.ReactNode; + className?: string; + spacing?: 'sm' | 'md'; + centered?: boolean; +} + +export const ContentCard: React.FC = ({ + children, + className, + spacing = 'md', + centered = false, +}) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/react/src/components/ui/LinkButton.tsx b/apps/react/src/components/ui/LinkButton.tsx new file mode 100644 index 00000000..8fa6a8d6 --- /dev/null +++ b/apps/react/src/components/ui/LinkButton.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { Link, LinkProps } from 'react-router-dom'; +import clsx from 'clsx'; +import { ButtonVariant } from './Button'; + +export type LinkButtonProps = LinkProps & { + variant?: ButtonVariant; + className?: string; +}; + +const variantStyles: Record = { + primary: + 'bg-blue-600 text-white shadow-sm hover:bg-blue-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-blue-600', + secondary: + 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-100 dark:hover:bg-gray-600', + outline: + 'bg-white text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-100 dark:ring-gray-600 dark:hover:bg-gray-700', + danger: 'bg-red-600 text-white shadow-sm hover:bg-red-500 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-red-600', +}; + +export const LinkButton: React.FC = ({ + variant = 'primary', + className = '', + children, + ...props +}) => { + const baseClasses = + 'inline-flex justify-center items-center rounded-md px-3 py-1.5 text-sm font-semibold leading-6 transition-colors'; + + return ( + + {children} + + ); +}; diff --git a/apps/react/src/components/ui/ListContainer.tsx b/apps/react/src/components/ui/ListContainer.tsx new file mode 100644 index 00000000..0c86176e --- /dev/null +++ b/apps/react/src/components/ui/ListContainer.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import clsx from 'clsx'; + +export interface ListContainerProps { + children: React.ReactNode; + className?: string; +} + +export const ListContainer: React.FC = ({ children, className }) => { + return ( +
+ {children} +
+ ); +}; + +export interface ListItemProps { + children: React.ReactNode; + className?: string; + onClick?: () => void; +} + +export const ListItem: React.FC = ({ children, className, onClick }) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/react/src/components/ui/PageTitle.tsx b/apps/react/src/components/ui/PageTitle.tsx new file mode 100644 index 00000000..b603b6c6 --- /dev/null +++ b/apps/react/src/components/ui/PageTitle.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import clsx from 'clsx'; + +type HeadingLevel = 'h1' | 'h2' | 'h3'; + +interface PageTitleProps { + as?: HeadingLevel; + children: React.ReactNode; + className?: string; +} + +export const PageTitle: React.FC = ({ + as: Component = 'h1', + children, + className, +}) => { + return ( + + {children} + + ); +}; diff --git a/apps/react/src/components/Pill.tsx b/apps/react/src/components/ui/Pill.tsx similarity index 100% rename from apps/react/src/components/Pill.tsx rename to apps/react/src/components/ui/Pill.tsx diff --git a/apps/react/src/components/ui/SegmentButton.tsx b/apps/react/src/components/ui/SegmentButton.tsx new file mode 100644 index 00000000..7230f558 --- /dev/null +++ b/apps/react/src/components/ui/SegmentButton.tsx @@ -0,0 +1,40 @@ +import clsx from 'clsx'; + +interface SegmentedButtonProps { + text: string; + Icon?: (props: { color: string }) => JSX.Element; + active: boolean; + onClick: () => void; + variant?: 'default' | 'compact'; +} + +export const SegmentButton: React.FC = ({ + text, + Icon, + active, + onClick, + variant = 'default', +}) => { + return ( + + ); +}; diff --git a/apps/react/src/components/ui/SegmentedControl.tsx b/apps/react/src/components/ui/SegmentedControl.tsx new file mode 100644 index 00000000..d7186696 --- /dev/null +++ b/apps/react/src/components/ui/SegmentedControl.tsx @@ -0,0 +1,25 @@ +import React from 'react'; +import clsx from 'clsx'; + +interface SegmentedControlProps { + children: React.ReactNode; + variant?: 'default' | 'compact'; +} + +export const SegmentedControl: React.FC = ({ + children, + variant = 'default', +}) => { + return ( +
+ {children} +
+ ); +}; diff --git a/apps/react/src/components/ui/index.ts b/apps/react/src/components/ui/index.ts new file mode 100644 index 00000000..fceb15fb --- /dev/null +++ b/apps/react/src/components/ui/index.ts @@ -0,0 +1,10 @@ +export * from './Button'; +export * from './LinkButton'; +export * from './Card'; +export * from './ContentCard'; +export * from './ListContainer'; +export * from './Pill'; +export * from './CircleHover'; +export * from './SegmentButton'; +export * from './SegmentedControl'; +export * from './PageTitle'; diff --git a/apps/react/src/main.tsx b/apps/react/src/main.tsx index ae209955..acf0d17d 100644 --- a/apps/react/src/main.tsx +++ b/apps/react/src/main.tsx @@ -2,7 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import App from './App.tsx'; import './index.css'; -import { ToastProvider } from './components/Toast'; +import { ToastProvider } from './components/feedback/Toast'; ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/apps/react/src/screens/AllDeckCardsScreen.tsx b/apps/react/src/screens/AllDeckCardsScreen.tsx index 37f8fc6e..78d2c34f 100644 --- a/apps/react/src/screens/AllDeckCardsScreen.tsx +++ b/apps/react/src/screens/AllDeckCardsScreen.tsx @@ -1,7 +1,7 @@ import React, { useEffect } from 'react'; import { FlashCard, Layout } from '../components'; -import { BasicErrorCard } from '../components/ErrorCard'; -import { Spinner } from '../components/Spinner'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; +import { Spinner } from '../components/feedback/Spinner'; import { getDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-action'; import { currDeckAllWithCorrectAttemptsSortedArray } from 'MemoryFlashCore/src/redux/selectors/currDeckCardsWithAttempts'; import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; diff --git a/apps/react/src/screens/AttemptHistoryScreen/AttemptHistoryScreen.tsx b/apps/react/src/screens/AttemptHistoryScreen/AttemptHistoryScreen.tsx index 04ccfcce..ef93504c 100644 --- a/apps/react/src/screens/AttemptHistoryScreen/AttemptHistoryScreen.tsx +++ b/apps/react/src/screens/AttemptHistoryScreen/AttemptHistoryScreen.tsx @@ -1,8 +1,14 @@ import React, { useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router-dom'; -import { Layout } from '../../components'; -import { BasicErrorCard } from '../../components/ErrorCard'; -import { Spinner } from '../../components/Spinner'; +import { + Layout, + LinkButton, + SegmentedControl, + SegmentButton, + PageTitle, + EmptyState, +} from '../../components'; +import { BasicErrorCard } from '../../components/feedback/ErrorCard'; +import { Spinner } from '../../components/feedback/Spinner'; import { getDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-action'; import { currDeckAllWithAttemptsSelector } from 'MemoryFlashCore/src/redux/selectors/currDeckCardsWithAttempts'; import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; @@ -69,9 +75,7 @@ const AttemptsList: React.FC<{ deckName?: string; }> = ({ attempts, viewMode, deckName }) => attempts.length === 0 ? ( -
- No attempts yet. -
+ ) : (
{attempts.map(({ attempt, card }) => ( @@ -124,39 +128,26 @@ export const AttemptHistoryScreen: React.FC = () => {
-

Attempt history

+ Attempt history
-
- - -
+ variant="compact" + /> + {deckId && ( - + Back to stats - + )}
diff --git a/apps/react/src/screens/CommunityScreen.tsx b/apps/react/src/screens/CommunityScreen.tsx index 29731836..e2ae38e6 100644 --- a/apps/react/src/screens/CommunityScreen.tsx +++ b/apps/react/src/screens/CommunityScreen.tsx @@ -1,9 +1,16 @@ import React, { useEffect, useState, useCallback } from 'react'; import { Link } from 'react-router-dom'; -import { Layout, SectionHeader } from '../components'; -import { SegmentButton } from '../components/SegmentButton'; -import { BasicErrorCard } from '../components/ErrorCard'; -import { Spinner } from '../components/Spinner'; +import { + Layout, + SectionHeader, + ListContainer, + SegmentedControl, + SegmentButton, + EmptyState, +} from '../components'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; +import { Spinner } from '../components/feedback/Spinner'; +import { SearchInput } from '../components/inputs'; import { searchCommunityDecks, searchCommunityCourses, @@ -62,7 +69,7 @@ export const CommunityScreen: React.FC = () => { {leaderboard.length > 0 && (
-
+ {isLoadingLeaderboard ? ( ) : ( @@ -93,14 +100,14 @@ export const CommunityScreen: React.FC = () => { )) )} -
+
)}
-
+ { active={activeTab === 'courses'} onClick={() => handleTabChange('courses')} /> -
+ - setSearchQuery(e.target.value)} - className="block w-full rounded-md border-0 py-2 px-3 text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 placeholder:text-gray-400 focus:ring-2 focus:ring-inset focus:ring-blue-500 sm:text-sm" /> @@ -143,24 +148,26 @@ interface DeckResultsListProps { const DeckResultsList: React.FC = ({ decks }) => { if (decks.length === 0) { - return

No decks found

; + return ; } return ( -
+ {decks.map((deck) => (
-
{deck.name}
+
+ {deck.name} +
{deck.course &&
{deck.course}
}
))} -
+ ); }; @@ -170,24 +177,26 @@ interface CourseResultsListProps { const CourseResultsList: React.FC = ({ courses }) => { if (courses.length === 0) { - return

No courses found

; + return ; } return ( -
+ {courses.map((course) => ( -
{course.name}
+
+ {course.name} +
{course.deckCount} {course.deckCount === 1 ? 'deck' : 'decks'} •{' '} {course.totalCardCount} {course.totalCardCount === 1 ? 'card' : 'cards'} ))} -
+ ); }; diff --git a/apps/react/src/screens/CoursePreviewScreen.tsx b/apps/react/src/screens/CoursePreviewScreen.tsx index 73fd8efb..7a485088 100644 --- a/apps/react/src/screens/CoursePreviewScreen.tsx +++ b/apps/react/src/screens/CoursePreviewScreen.tsx @@ -1,8 +1,8 @@ import React, { useEffect } from 'react'; import { useParams, useNavigate, Link } from 'react-router-dom'; -import { Layout, Button } from '../components'; -import { BasicErrorCard } from '../components/ErrorCard'; -import { Spinner } from '../components/Spinner'; +import { Layout, Button, LinkButton, ContentCard, PageTitle } from '../components'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; +import { Spinner } from '../components/feedback/Spinner'; import { getCoursePreview, clearCoursePreview, @@ -54,18 +54,18 @@ export const CoursePreviewScreen: React.FC = () => { return (
-
-

{preview.course.name}

+ + {preview.course.name}

{preview.course.deckCount}{' '} {preview.course.deckCount === 1 ? 'deck' : 'decks'} •{' '} {preview.course.totalCardCount}{' '} {preview.course.totalCardCount === 1 ? 'card' : 'cards'}

-
+ {preview.decks.length > 0 && ( -
+

Decks

    {preview.decks.map((deck) => ( @@ -83,35 +83,27 @@ export const CoursePreviewScreen: React.FC = () => { ))}
-
+ )} {user ? ( -
+ -
+ ) : ( -
+

Sign in to import this course to your library

- - Log In - - + Log In + Sign Up - +
-
+ )}
diff --git a/apps/react/src/screens/CoursesScreen.tsx b/apps/react/src/screens/CoursesScreen.tsx index 46f4ce77..dd052d43 100644 --- a/apps/react/src/screens/CoursesScreen.tsx +++ b/apps/react/src/screens/CoursesScreen.tsx @@ -19,8 +19,8 @@ import { SectionData, SectionHeader, } from '../components'; -import { BasicErrorCard } from '../components/ErrorCard'; -import { Spinner } from '../components/Spinner'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; +import { Spinner } from '../components/feedback/Spinner'; const getVisibilityWarnings = ( currentVisibility: Visibility, diff --git a/apps/react/src/screens/DeckPreviewScreen.tsx b/apps/react/src/screens/DeckPreviewScreen.tsx index 7f121ca7..4298f3f6 100644 --- a/apps/react/src/screens/DeckPreviewScreen.tsx +++ b/apps/react/src/screens/DeckPreviewScreen.tsx @@ -1,9 +1,10 @@ import React, { useEffect, useState } from 'react'; -import { useParams, useNavigate, Link } from 'react-router-dom'; -import { Layout, Button } from '../components'; +import { useParams, useNavigate } from 'react-router-dom'; +import { Layout, Button, LinkButton, ContentCard, PageTitle } from '../components'; +import { Select } from '../components/inputs'; import { MusicNotation } from '../components/MusicNotation'; -import { BasicErrorCard } from '../components/ErrorCard'; -import { Spinner } from '../components/Spinner'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; +import { Spinner } from '../components/feedback/Spinner'; import { getDeckPreview, clearDeckPreview, @@ -67,31 +68,30 @@ export const DeckPreviewScreen: React.FC = () => { return (
-
-

{preview.deck.name}

+ + {preview.deck.name} {preview.course && (

Course: {preview.course.name}

)}

{preview.deck.cardCount} {preview.deck.cardCount === 1 ? 'card' : 'cards'}

-
+ {user ? ( -
+

Import to My Library

- +
-
+ ) : ( -
+

Sign in to import this deck to your library

- - Log In - - + Log In + Sign Up - +
-
+ )} {preview.cards && preview.cards.length > 0 && ( -
+

Cards Preview

{preview.cards.map((card) => ( ))}
-
+ )}
diff --git a/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx b/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx index 10d42b9d..71217708 100644 --- a/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx +++ b/apps/react/src/screens/DeckStatsScreen/DeckStatsScreen.tsx @@ -1,9 +1,8 @@ import React, { useEffect } from 'react'; import { CartesianGrid, Label, Line, LineChart, Tooltip, XAxis, YAxis } from 'recharts'; -import { Layout } from '../../components'; -import { BasicErrorCard } from '../../components/ErrorCard'; -import { Spinner } from '../../components/Spinner'; -import { Link } from 'react-router-dom'; +import { Layout, LinkButton } from '../../components'; +import { BasicErrorCard } from '../../components/feedback/ErrorCard'; +import { Spinner } from '../../components/feedback/Spinner'; import { getDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-action'; import { getStatsDeck } from 'MemoryFlashCore/src/redux/actions/get-deck-stats-action'; import { attemptsStatsSelector } from 'MemoryFlashCore/src/redux/selectors/attemptsStatsSelector'; @@ -70,18 +69,13 @@ export const DeckStatsScreen: React.FunctionComponent = ({
Number of cards: {numCards}
Median time to answer: {median.toFixed(1)}s
Total time studying: {(totalTimeSpent / 60).toFixed(1)} minutes
- {deckId && (
- + View attempt history - +
- )} - + )}{' '}
= ({ />
-
diff --git a/apps/react/src/screens/DecksScreen.tsx b/apps/react/src/screens/DecksScreen.tsx index 762773bb..2529be2b 100644 --- a/apps/react/src/screens/DecksScreen.tsx +++ b/apps/react/src/screens/DecksScreen.tsx @@ -9,8 +9,8 @@ import { InputModal, CardOptionsMenu, } from '../components'; -import { BasicErrorCard } from '../components/ErrorCard'; -import { Spinner } from '../components/Spinner'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; +import { Spinner } from '../components/feedback/Spinner'; import { getCourse } from 'MemoryFlashCore/src/redux/actions/get-course-action'; import { decksSelector } from 'MemoryFlashCore/src/redux/selectors/decksSelector'; import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; diff --git a/apps/react/src/screens/NotationInputScreen.tsx b/apps/react/src/screens/NotationInputScreen.tsx index c302c2f1..a5bb5c40 100644 --- a/apps/react/src/screens/NotationInputScreen.tsx +++ b/apps/react/src/screens/NotationInputScreen.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Layout, Button } from '../components'; -import { BasicErrorCard } from '../components/ErrorCard'; +import { BasicErrorCard } from '../components/feedback/ErrorCard'; import { useAppDispatch, useAppSelector } from 'MemoryFlashCore/src/redux/store'; import { questionsForAllMajorKeys } from 'MemoryFlashCore/src/lib/multiKeyTransposer'; import { majorKeys } from 'MemoryFlashCore/src/lib/notes'; diff --git a/apps/react/src/screens/StudyScreen/QuestionPresentationModePills.tsx b/apps/react/src/screens/StudyScreen/QuestionPresentationModePills.tsx index 06ba8987..278f3e9b 100644 --- a/apps/react/src/screens/StudyScreen/QuestionPresentationModePills.tsx +++ b/apps/react/src/screens/StudyScreen/QuestionPresentationModePills.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { Pill } from '../../components/Pill'; +import { Pill } from '../../components/ui/Pill'; import { setPresentationMode } from 'MemoryFlashCore/src/redux/actions/set-presentation-mode'; import { useAppDispatch, useAppSelector } from 'MemoryFlashCore/src/redux/store'; import { Card } from 'MemoryFlashCore/src/types/Cards'; diff --git a/apps/react/src/screens/StudyScreen/StudyScreen.tsx b/apps/react/src/screens/StudyScreen/StudyScreen.tsx index d3b34dec..a5904b64 100644 --- a/apps/react/src/screens/StudyScreen/StudyScreen.tsx +++ b/apps/react/src/screens/StudyScreen/StudyScreen.tsx @@ -1,9 +1,9 @@ import { ListBulletIcon, PresentationChartLineIcon, PlusIcon } from '@heroicons/react/24/outline'; import clsx from 'clsx'; import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { CircleHover } from '../../components/CircleHover'; +import { CircleHover } from '../../components/ui/CircleHover'; import { FlashCard } from '../../components/FlashCard'; -import { Layout } from '../../components/Layout'; +import { Layout } from '../../components/layout/Layout'; import { MidiInputsDropdown } from '../../components/MidiInputsDropdown'; import { StudyScreenEmptyState } from './StudyScreenEmptyState'; import { AnswerValidator } from '../../components/answer-validators/AnswerValidator'; diff --git a/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx b/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx index b58d5234..db3ff696 100644 --- a/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx +++ b/apps/react/src/screens/StudyScreen/StudyScreenEmptyState.tsx @@ -1,7 +1,7 @@ import { Link } from 'react-router-dom'; -import { Button } from '../../components/Button'; -import { BasicErrorCard } from '../../components/ErrorCard'; -import { Spinner } from '../../components/Spinner'; +import { Button } from '../../components/ui/Button'; +import { BasicErrorCard } from '../../components/feedback/ErrorCard'; +import { Spinner } from '../../components/feedback/Spinner'; import { useDeckIdPath } from '../useDeckIdPath'; import { useNetworkState } from 'MemoryFlashCore/src/redux/selectors/useNetworkState'; import { useAppSelector } from 'MemoryFlashCore/src/redux/store';