diff --git a/.storybook/main.ts b/.storybook/main.ts index 5698dc21..49a0e80f 100644 --- a/.storybook/main.ts +++ b/.storybook/main.ts @@ -1,3 +1,4 @@ +/* eslint-disable unicorn/prefer-module */ import type {StorybookConfig} from "@storybook/react-vite" import path from "node:path" @@ -17,7 +18,7 @@ const config: StorybookConfig = { config.resolve.alias = { ...config.resolve.alias, - "@": path.resolve(import.meta.dirname, "../src"), + "@": path.resolve(__dirname, "../src"), } return config diff --git a/.storybook/preview.ts b/.storybook/preview.ts index 23c1fd9b..e05dbd8c 100644 --- a/.storybook/preview.ts +++ b/.storybook/preview.ts @@ -1,5 +1,6 @@ import type {Preview} from "@storybook/react" -import "../src/styles/tailwind.css" +import "@/styles/tailwind.css" +import "@/styles/storybook.css" const preview: Preview = { parameters: { diff --git a/components.json b/components.json index 8dbf3489..c6445b4d 100644 --- a/components.json +++ b/components.json @@ -12,6 +12,6 @@ }, "aliases": { "components": "@/components", - "utils": " @/utils" + "utils": "@/utils" } } diff --git a/src/components/AlertDialog.tsx b/src/components/AlertDialog.tsx deleted file mode 100644 index a4c1f92a..00000000 --- a/src/components/AlertDialog.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import {type FC} from "react" -import Typography, {TypographyVariant} from "./Typography" -import {IoCloseCircle} from "react-icons/io5" -import useTranslation from "@/hooks/util/useTranslation" -import {LangKey} from "@/lang/allKeys" -import {Button} from "./ui/button" - -export type AlertDialogProps = { - title: string - message: string - onClose: () => void - onAccept: () => void -} - -const AlertDialog: FC = ({ - title, - message, - onClose, - onAccept, -}) => { - const {t} = useTranslation() - - return ( -
-
- - {title} - - - -
- -
- {message} -
- -
- -
-
- ) -} - -export default AlertDialog diff --git a/src/components/AppLogo.tsx b/src/components/AppLogo.tsx index 208bb173..3d736a35 100644 --- a/src/components/AppLogo.tsx +++ b/src/components/AppLogo.tsx @@ -1,19 +1,20 @@ import {StaticAssetPath} from "@/utils/util" import {type FC} from "react" import {ReactSVG} from "react-svg" -import Typography, {TypographyVariant} from "./Typography" +import {Heading} from "./ui/typography" const AppLogo: FC = () => { return (
- - MIRAGE - + Mirage +
) } diff --git a/src/components/AsyncValueHandler.tsx b/src/components/AsyncValueHandler.tsx deleted file mode 100644 index fcf3bc14..00000000 --- a/src/components/AsyncValueHandler.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import {type AsyncState} from "@/hooks/util/useAsyncValue" -import useTranslation from "@/hooks/util/useTranslation" -import {LangKey} from "@/lang/allKeys" - -type AsyncValueHandlerProps = { - value: AsyncState - children: (value: T) => React.ReactNode - loading?: React.ReactNode - error?: (error: Error) => React.ReactNode - idle?: React.ReactNode -} - -function AsyncValueHandler({ - value, - error, - loading, - idle, - children, -}: AsyncValueHandlerProps): React.ReactNode { - const {t} = useTranslation() - - switch (value.status) { - case "loading": { - return loading ??

{t(LangKey.Loading)}

- } - - case "success": { - return children(value.data) - } - - case "error": { - return error?.(value.error) ??

Error: {value.error.message}

- } - - case "idle": { - return idle ?? <> - } - - default: { - return <> - } - } -} - -export default AsyncValueHandler diff --git a/src/components/AudioFilePreview.tsx b/src/components/AudioFilePreview.tsx index 96037ff2..720259a1 100644 --- a/src/components/AudioFilePreview.tsx +++ b/src/components/AudioFilePreview.tsx @@ -1,7 +1,6 @@ import {useRef, type FC} from "react" -import Typography, {TypographyVariant} from "./Typography" import {IoCloseCircle, IoPause, IoPlay} from "react-icons/io5" -import {fileSizeToString, getFileExtension} from "./FileMessage" +import {getFileExtension} from "./FileMessage" import {ReactSVG} from "react-svg" import {StaticAssetPath} from "@/utils/util" import {useWavesurfer} from "@wavesurfer/react" @@ -19,14 +18,13 @@ export type AudioFilePreviewProps = { const AudioFilePreview: FC = ({ fileName, - fileSize, audioUrl, onClose, onSend, }) => { const {t} = useTranslation() - const fileExtension = getFileExtension(fileName).toUpperCase() + const _fileExtension = getFileExtension(fileName).toUpperCase() const waveformRef = useRef(null) @@ -46,11 +44,12 @@ const AudioFilePreview: FC = ({ return (
- {t(LangKey.UploadAudio)} - + */}
@@ -58,11 +57,13 @@ const AudioFilePreview: FC = ({
- {fileName} - + */}
@@ -92,7 +93,9 @@ const AudioFilePreview: FC = ({
- {fileExtension} @@ -102,7 +105,7 @@ const AudioFilePreview: FC = ({ className="min-w-20 text-right font-semibold text-gray-400" variant={TypographyVariant.BodySmall}> {fileSizeToString(fileSize)} - + */}
diff --git a/src/components/AudioMessage.tsx b/src/components/AudioMessage.tsx index dbc1a958..b464d8f5 100644 --- a/src/components/AudioMessage.tsx +++ b/src/components/AudioMessage.tsx @@ -1,7 +1,7 @@ import {useEffect, useRef, useState, type FC} from "react" import {IoAlertCircle, IoPause, IoPlay} from "react-icons/io5" import AvatarImage, {AvatarType} from "./AvatarImage" -import {assert, CommonAssertion, formatTime, validateUrl} from "@/utils/util" +import {assert, formatTime, validateUrl} from "@/utils/util" import {useWavesurfer} from "@wavesurfer/react" import useAudioPlayerStore from "@/hooks/util/useAudioPlayerStore" import {type MessageBaseData, type MessageBaseProps} from "./MessageContainer" @@ -27,8 +27,6 @@ const AudioMessage: FC = ({ messageId, userId, }) => { - assert(messageId.length > 0, CommonAssertion.MessageIdEmpty) - if (audioUrl !== undefined) { assert(validateUrl(audioUrl), "The audio url should be valid.") } diff --git a/src/components/CallModal.tsx b/src/components/CallModal.tsx deleted file mode 100644 index 8be01caa..00000000 --- a/src/components/CallModal.tsx +++ /dev/null @@ -1,160 +0,0 @@ -import {useState, type FC} from "react" -import Typography, {TypographyVariant} from "./Typography" -import {assert, validateUrl} from "@/utils/util" -import {IoMdMic, IoMdMicOff} from "react-icons/io" -import {IoPause, IoPlay, IoVolumeHigh} from "react-icons/io5" -import {MdCall, MdCallEnd} from "react-icons/md" -import {twMerge} from "tailwind-merge" -import AvatarImage, {AvatarSize, AvatarType} from "./AvatarImage" -import {LangKey} from "@/lang/allKeys" -import useTranslation from "@/hooks/util/useTranslation" -import {IconButton} from "./ui/button" - -export enum VariantCall { - CallInProgress, - IncomingCall, - Calling, -} - -export type CallModalProps = { - name: string - variant: VariantCall - avatarUrl?: string -} - -const callAction: {[key in VariantCall]: LangKey} = { - [VariantCall.CallInProgress]: LangKey.CallInProgress, - [VariantCall.IncomingCall]: LangKey.IncomingCall, - [VariantCall.Calling]: LangKey.Connecting, -} - -const CallModal: FC = ({name, avatarUrl, variant}) => { - const [action, setAction] = useState(variant) - const [isMicEnabled, setIsMicEnabled] = useState(true) - const [isSpeakerMode, setIsSpeakerMode] = useState(false) - const [isCallPaused, setIsCallPaused] = useState(false) - const {t} = useTranslation() - - if (avatarUrl !== undefined) { - assert(validateUrl(avatarUrl), "avatar URL should be valid if defined") - } - - return ( -
-
- - -
- - {name} - - - - {t(callAction[action])} - -
-
- -
- {action === VariantCall.IncomingCall ? ( - <> - { - setAction(VariantCall.CallInProgress) - - // TODO: Handle here incoming calls acceptance. - }}> - - - - { - throw new Error("Call end not handled.") - }} - /> - - ) : ( - <> - { - setIsSpeakerMode(!isSpeakerMode) - - // TODO: Speaker mode not handled. - }}> - - - - { - setIsMicEnabled(!isMicEnabled) - - // TODO: Mic enabled/disabled not handled. - }}> - {isMicEnabled ? : } - - - { - setIsCallPaused(!isCallPaused) - - // TODO: Call paused not handled. - }}> - {isCallPaused ? : } - - - { - throw new Error("Call end not handled.") - }} - /> - - )} -
-
- ) -} - -const CallDeclineButton: FC<{onCallEnd: () => void}> = ({onCallEnd}) => { - const {t} = useTranslation() - - return ( - - - - ) -} - -export default CallModal diff --git a/src/components/ContextMenu.tsx b/src/components/ContextMenu.tsx deleted file mode 100644 index 903d63ac..00000000 --- a/src/components/ContextMenu.tsx +++ /dev/null @@ -1,137 +0,0 @@ -import {type CSSProperties, type FC} from "react" -import Typography, {TypographyVariant} from "./Typography" -import {type IconType} from "react-icons" -import React from "react" -import {create} from "zustand" -import useClickOutside from "@/hooks/util/useClickOutside" -import {createPortal} from "react-dom" -import {twMerge} from "tailwind-merge" -import {assert} from "@/utils/util" -import {motion} from "framer-motion" - -export type ContextMenuItem = { - text: string - icon: IconType - color?: CSSProperties["color"] - onClick: () => void -} - -export enum ClickActions { - RightClick, - LeftClick, - Hold, -} - -export type ContextMenuProps = { - id: string - children: React.ReactNode - elements: ContextMenuItem[] - actionType?: ClickActions - className?: string -} - -export type Points = { - x: number - y: number -} - -type ContextMenuState = { - activeMenuId: string | null - points: Points | null - showMenu: (id: string, e: React.MouseEvent) => void - hideMenu: () => void -} - -export const useContextMenuStore = create(set => ({ - activeMenuId: null, - points: null, - showMenu: (id, e) => { - set({activeMenuId: id, points: {x: e.clientX, y: e.clientY}}) - - e.preventDefault() - }, - hideMenu: () => { - set({activeMenuId: null, points: null}) - }, -})) - -const ContextMenu: FC = ({ - id, - elements, - children, - className, - actionType = ClickActions.RightClick, -}) => { - const {activeMenuId, hideMenu, points, showMenu} = useContextMenuStore() - const {elementRef} = useClickOutside(hideMenu) - const isActive = activeMenuId === id - const isRightClick = actionType === ClickActions.RightClick - const isLeftClick = actionType === ClickActions.LeftClick - - const onShowMenu = (event: React.MouseEvent): void => { - showMenu(id, event) - } - - assert(id.length > 0, "The context menu id should not be empty.") - - return ( - <> -
- {children} -
- - {isActive && - points !== null && - elements.length > 0 && - createPortal( - - {elements.map((element, index) => ( -
{ - element.onClick() - hideMenu() - }} - role="button" - aria-hidden - key={index}> -
- -
- - - {element.text} - -
- ))} -
, - document.body - )} - - ) -} - -export default ContextMenu diff --git a/src/components/Detail.tsx b/src/components/Detail.tsx deleted file mode 100644 index abdd19b9..00000000 --- a/src/components/Detail.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import React, {type FC} from "react" -import ContextMenu, {ClickActions, type ContextMenuItem} from "./ContextMenu" -import Typography, {TypographyVariant} from "./Typography" -import {IoEllipsisHorizontal} from "react-icons/io5" -import {twMerge} from "tailwind-merge" -import {assert} from "@/utils/util" -import {motion} from "framer-motion" - -export type DetailProps = { - title: string - id: string - isInitiallyOpen?: boolean - children?: React.ReactNode - menuElements?: ContextMenuItem[] - className?: string -} - -const Detail: FC = ({ - title, - id, - menuElements, - children, - isInitiallyOpen = false, - className, -}) => { - assert(id.length > 0, "Detail id should not be empty.") - - if (menuElements !== undefined) { - assert( - menuElements.length > 0, - "If menu elements is defined should not be empty." - ) - } - - return ( -
- - - {title.toUpperCase()} - - - {menuElements !== undefined && menuElements.length > 0 && ( -
- - - -
- )} -
- - {children} -
- ) -} - -export default Detail diff --git a/src/components/DropdownActions.tsx b/src/components/DropdownActions.tsx index 84652923..3ebea0cc 100644 --- a/src/components/DropdownActions.tsx +++ b/src/components/DropdownActions.tsx @@ -2,8 +2,8 @@ import {type FC, useState} from "react" import {type IconType} from "react-icons" import {IoMdCheckmark} from "react-icons/io" import Dropdown from "./Dropdown" -import Typography from "./Typography" import {assert} from "@/utils/util" +import {Text} from "./ui/typography" export type DropdownOption = { text: string @@ -37,7 +37,7 @@ const DropdownActions: FC = ({
- {optionSelected.text} + {optionSelected.text} }> <> @@ -78,7 +78,7 @@ const DropdownItem: FC = ({
- {text} + {text} {isSelected && } diff --git a/src/components/EventGroupMessage.tsx b/src/components/EventGroupMessage.tsx index 8e657044..5e06c365 100644 --- a/src/components/EventGroupMessage.tsx +++ b/src/components/EventGroupMessage.tsx @@ -42,13 +42,11 @@ export type EventGroupMessageData = { export interface EventGroupMessageProps extends EventGroupMessageData { onShowMember: () => void - onFindUser: () => void } const EventGroupMessage: FC = ({ eventGroupMainBody, eventMessages, - onFindUser, onShowMember, }) => { const {sender, shortenerType} = eventGroupMainBody @@ -61,7 +59,6 @@ const EventGroupMessage: FC = ({ = ({ /> - +
{eventMessages.map(eventMessageData => ( void - onFindUser: () => void className?: string } @@ -43,9 +42,7 @@ const EventMessage: FC = ({ timestamp, body, sender, - eventId, icon, - onFindUser, onShowMember, className, }) => { @@ -59,7 +56,7 @@ const EventMessage: FC = ({
- +
@@ -74,10 +71,7 @@ const EventMessage: FC = ({ - { - event.preventDefault() - }}> + e.preventDefault()}> { e.stopPropagation() @@ -90,19 +84,6 @@ const EventMessage: FC = ({ {t(LangKey.ViewMember)} - - { - e.stopPropagation() - - setIsDropdownOpen(false) - - onFindUser() - }}> - - - {t(LangKey.FindUser)} - diff --git a/src/components/FilePreview.tsx b/src/components/FilePreview.tsx index 26fb5279..fd51cd3d 100644 --- a/src/components/FilePreview.tsx +++ b/src/components/FilePreview.tsx @@ -1,89 +1,100 @@ -import {type FC} from "react" -import Typography, {TypographyVariant} from "./Typography" -import {IoCloseCircle} from "react-icons/io5" +import {useState, type FC} from "react" import {fileSizeToString, getFileExtension, IconFile} from "./FileMessage" -import {ReactSVG} from "react-svg" -import {StaticAssetPath} from "@/utils/util" import useTranslation from "@/hooks/util/useTranslation" import {LangKey} from "@/lang/allKeys" -import {Button, IconButton} from "./ui/button" +import { + Modal, + ModalAction, + ModalCancel, + ModalContent, + ModalDescription, + ModalFooter, + ModalHeader, + ModalTitle, +} from "./ui/modal" +import {Text} from "./ui/typography" export type FilePreviewProps = { fileName: string fileSize: number - onClose: () => void - onSend: () => void + open: boolean + onOpenChange: (isOpen: boolean) => void + onSend: () => Promise } const FilePreview: FC = ({ fileName, fileSize, - onClose, + open, + onOpenChange, onSend, }) => { const fileExtension = getFileExtension(fileName).toUpperCase() const {t} = useTranslation() + const [isSending, setIsSending] = useState(false) - return ( -
-
- - {t(LangKey.UploadFile)} - + const handleSendFile = (): void => { + if (isSending) { + return + } + + setIsSending(true) + + void onSend().finally(() => { + setIsSending(false) - - - -
+ onOpenChange(false) + }) + } + + return ( + <> + + + + {t(LangKey.UploadFile)} + -
-
-
- + +
+
+ - - {fileName} - -
+ {fileName} +
-
- - {fileExtension} - +
+ + {fileExtension} + - - {fileSizeToString(fileSize)} - -
-
+ + {fileSizeToString(fileSize)} + +
+
+ -
- + + {t(LangKey.Cancel)} - -
-
-
- + { + e.preventDefault() - -
-
+ handleSendFile() + }}> + {t(LangKey.Send)} + + + + + ) } diff --git a/src/components/ImagePreview.tsx b/src/components/ImagePreview.tsx deleted file mode 100644 index 7d4dc197..00000000 --- a/src/components/ImagePreview.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import {type FC} from "react" -import Typography, {TypographyVariant} from "./Typography" -import {IoAdd, IoCloseCircle} from "react-icons/io5" -import useTranslation from "@/hooks/util/useTranslation" -import {LangKey} from "@/lang/allKeys" -import {Button, IconButton} from "./ui/button" - -export type ImagePreviewProps = { - imageUrl: string - imageName: string - onClear: () => void - onSendImage: () => void -} - -const ImagePreview: FC = ({ - imageUrl, - onClear, - onSendImage, -}) => { - const {t} = useTranslation() - - return ( -
-
- - {t(LangKey.UploadImage)} - - - -
- -
- {t(LangKey.Preview)} -
- -
- {t(LangKey.UploadImage)} - - - - -
- -
- - - -
-
- ) -} - -export default ImagePreview diff --git a/src/components/ImageZoom.tsx b/src/components/ImageZoom.tsx deleted file mode 100644 index 585ddf07..00000000 --- a/src/components/ImageZoom.tsx +++ /dev/null @@ -1,94 +0,0 @@ -import useTranslation from "@/hooks/util/useTranslation" -import {LangKey} from "@/lang/allKeys" -import {assert, validateUrl} from "@/utils/util" -import React, {type FC, useState} from "react" - -export type ImageZoomProps = { - className?: string - src?: string -} - -type ImagePoints = { - x: number - y: number -} - -const DEFAULT_ZOOM_LEVEL = 100 -const ZOOM_ACTIVE_LEVEL = 150 -const ZOOM_LEVEL_MAX = 300 - -const COORDINATE_TO_PERCENT = 100 - -const ImageZoom: FC = ({className, src}) => { - const [zoom, setZoom] = useState(false) - const [zoomLevel, setZoomLevel] = useState(DEFAULT_ZOOM_LEVEL) - const [position, setPosition] = useState({x: 0, y: 0}) - const {t} = useTranslation() - - if (src !== undefined) { - assert(validateUrl(src), "The src url should should be valid if defined.") - } - - // When in zoom state, move the zoom to where the cursor is. - const handleMouseMove = (event: React.MouseEvent): void => { - if (!zoom) { - return - } - - const {left, top, width, height} = - event.currentTarget.getBoundingClientRect() - - const x = ((event.clientX - left) / width) * COORDINATE_TO_PERCENT - const y = ((event.clientY - top) / height) * COORDINATE_TO_PERCENT - - setPosition({x, y}) - } - - const toggleZoom = (): void => { - if (zoom) { - setZoomLevel(DEFAULT_ZOOM_LEVEL) - } else { - setZoomLevel(ZOOM_ACTIVE_LEVEL) - } - - setZoom(!zoom) - } - - // Zoom in and out with the mouse wheel. - const handleWheel = (event: React.WheelEvent): void => { - if (!zoom) { - setZoom(true) - } - - event.preventDefault() - - const delta = event.deltaY * -0.01 - - setZoomLevel(prevZoomLevel => - Math.max( - DEFAULT_ZOOM_LEVEL, - Math.min(ZOOM_LEVEL_MAX, prevZoomLevel + delta * 10) - ) - ) - } - - return ( -
- {t(LangKey.ImgMessageZoom)} -
- ) -} - -export default ImageZoom diff --git a/src/components/InputArea.tsx b/src/components/InputArea.tsx deleted file mode 100644 index 55508e4e..00000000 --- a/src/components/InputArea.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import {useEffect, useRef, useState, type FC} from "react" -import React from "react" -import {twMerge} from "tailwind-merge" - -export type InputAreaProps = { - initialValue?: string - initiallyRows?: number - className?: string - placeholder?: string - onValueChange: (value: string) => void -} - -const InputArea: FC = ({ - onValueChange, - initialValue, - className, - placeholder, - initiallyRows = 1, -}) => { - const [value, setValue] = useState(initialValue ?? "") - const textareaReference = useRef(null) - - const handleKeyDown = (event: React.KeyboardEvent): void => { - if (event.key === "Enter") - if (event.ctrlKey) { - onValueChange(value + "\n") - setValue(value + "\n") - } else { - event.preventDefault() - } - } - - // Move scroll to last message. - useEffect(() => { - const textarea = textareaReference.current - - if (textarea !== null) { - textarea.style.height = "auto" - textarea.style.height = `${textarea.scrollHeight}px` - } - }, [value]) - - return ( -
-