diff --git a/deno.lock b/deno.lock index 3fea1e5..11ed555 100644 --- a/deno.lock +++ b/deno.lock @@ -11,6 +11,7 @@ "npm:@tauri-apps/plugin-os@2.2": "2.2.0", "npm:@types/lodash-es@^4.17.12": "4.17.12", "npm:@types/react-dom@19.0.4": "19.0.4_@types+react@19.0.10", + "npm:@types/react-transition-group@^4.4.12": "4.4.12_@types+react@19.0.10", "npm:@types/react@19.0.10": "19.0.10", "npm:@types/uuid@10": "10.0.0", "npm:@vitejs/plugin-react@^4.3.4": "4.3.4_vite@6.2.0__lightningcss@1.29.1_@babel+core@7.26.9_lightningcss@1.29.1", @@ -25,6 +26,7 @@ "npm:lodash-es@^4.17.21": "4.17.21", "npm:react-dom@19.0.0": "19.0.0_react@19.0.0", "npm:react-router-dom@^7.2.0": "7.2.0_react@19.0.0_react-dom@19.0.0__react@19.0.0", + "npm:react-transition-group@^4.4.5": "4.4.5_react@19.0.0_react-dom@19.0.0__react@19.0.0", "npm:react-use@^17.6.0": "17.6.0_react@19.0.0_react-dom@19.0.0__react@19.0.0_tslib@2.8.1", "npm:react@19.0.0": "19.0.0", "npm:sanitize.css@13": "13.0.0", @@ -561,6 +563,12 @@ "@types/react" ] }, + "@types/react-transition-group@4.4.12_@types+react@19.0.10": { + "integrity": "sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==", + "dependencies": [ + "@types/react" + ] + }, "@types/react@19.0.10": { "integrity": "sha512-JuRQ9KXLEjaUNjTWpzuR231Z2WpIwczOkBEIvbHNCzQefFIT0L8IqE6NV6ULLyC1SI/i234JnDoMkfg+RjQj2g==", "dependencies": [ @@ -803,6 +811,13 @@ "detect-libc@1.0.3": { "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==" }, + "dom-helpers@5.2.1": { + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "dependencies": [ + "@babel/runtime", + "csstype" + ] + }, "electron-to-chromium@1.5.104": { "integrity": "sha512-Us9M2L4cO/zMBqVkJtnj353nQhMju9slHm62NprKTmdF3HH8wYOtNvDFq/JB2+ZRoGLzdvYDiATlMHs98XBM1g==" }, @@ -1173,6 +1188,12 @@ "lodash.merge@4.6.2": { "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==" }, + "loose-envify@1.4.0": { + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": [ + "js-tokens" + ] + }, "lru-cache@5.1.1": { "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", "dependencies": [ @@ -1231,6 +1252,9 @@ "node-releases@2.0.19": { "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==" }, + "object-assign@4.1.1": { + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==" + }, "optionator@0.9.4": { "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", "dependencies": [ @@ -1283,6 +1307,14 @@ "prelude-ls@1.2.1": { "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==" }, + "prop-types@15.8.1": { + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": [ + "loose-envify", + "object-assign", + "react-is" + ] + }, "punycode@2.3.1": { "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==" }, @@ -1296,6 +1328,9 @@ "scheduler" ] }, + "react-is@16.13.1": { + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" + }, "react-refresh@0.14.2": { "integrity": "sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==" }, @@ -1318,6 +1353,17 @@ "turbo-stream" ] }, + "react-transition-group@4.4.5_react@19.0.0_react-dom@19.0.0__react@19.0.0": { + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "dependencies": [ + "@babel/runtime", + "dom-helpers", + "loose-envify", + "prop-types", + "react", + "react-dom" + ] + }, "react-universal-interface@0.6.2_react@19.0.0_tslib@2.8.1": { "integrity": "sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==", "dependencies": [ @@ -1576,6 +1622,7 @@ "npm:@tauri-apps/plugin-os@2.2", "npm:@types/lodash-es@^4.17.12", "npm:@types/react-dom@19.0.4", + "npm:@types/react-transition-group@^4.4.12", "npm:@types/react@19.0.10", "npm:@types/uuid@10", "npm:@vitejs/plugin-react@^4.3.4", @@ -1590,6 +1637,7 @@ "npm:lodash-es@^4.17.21", "npm:react-dom@19.0.0", "npm:react-router-dom@^7.2.0", + "npm:react-transition-group@^4.4.5", "npm:react-use@^17.6.0", "npm:react@19.0.0", "npm:sanitize.css@13", diff --git a/package.json b/package.json index 10730f2..9edb5a7 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "react": "19.0.0", "react-dom": "19.0.0", "react-router-dom": "^7.2.0", + "react-transition-group": "^4.4.5", "react-use": "^17.6.0", "sanitize.css": "^13.0.0", "@tauri-apps/api": "^2", @@ -31,6 +32,7 @@ "devDependencies": { "@eslint/js": "^9.19.0", "@types/lodash-es": "^4.17.12", + "@types/react-transition-group": "^4.4.12", "@types/uuid": "^10.0.0", "eslint": "^9.19.0", "eslint-plugin-react-hooks": "^5.2.0", diff --git a/src/components/Notifications.module.css b/src/components/Notifications.module.css new file mode 100644 index 0000000..efa285c --- /dev/null +++ b/src/components/Notifications.module.css @@ -0,0 +1,132 @@ +@layer components { + .notification_positioner { + position: absolute; + bottom: 20px; + right: 20px; + width: 25vw; + display: flex; + flex-direction: column-reverse; + justify-content: flex-start; + align-items: stretch; + gap: 10px; + z-index: 600; + } + .message_box_positioner { + position: absolute; + } + .toast_positioner { + position: absolute; + left: 50%; + transform: translateX(-50%); + bottom: 20vh; + z-index: 700; + } + .notification { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 10px; + padding: 10px 15px; + border-radius: 2px; + .notification_icon { + flex-shrink: 0; + font-size: 24px; + } + .notificaiton_content { + flex-grow: 1; + .notification_title { + font-size: 1.4em; + font-weight: bold; + } + } + .notification_close { + flex-shrink: 0; + font-size: 16px; + align-self: flex-start; + cursor: pointer; + } + } + .toast { + position: absolute; + bottom: 0; + left: 50%; + transform: translateX(-50%); + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 10px; + padding: 4px 8px; + border-radius: 2px; + width: 35vw; + .toast_icon { + flex-shrink: 0; + font-size: 18px; + } + .toast_content { + flex-grow: 1; + display: inline-block; + overflow-wrap: break-word; + } + .toast_close { + flex-shrink: 0; + font-size: 14px; + cursor: pointer; + } + } + .kind_info { + color: var(--color-on-info-container); + background-color: var(--color-info-container); + box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra); + } + .kind_promption { + color: var(--color-on-tertiary-container); + background-color: var(--color-tertiary-container); + box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra); + } + .kind_success { + color: var(--color-on-success-container); + background-color: var(--color-success-container); + box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra); + } + .kind_warning { + color: var(--color-on-warning-container); + background-color: var(--color-warning-container); + box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra); + } + .kind_error { + color: var(--color-on-error-container); + background-color: var(--color-error-container); + box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra); + } + .kind_custom { + color: var(--color-on-defensive-container); + background-color: var(--color-defensive-container); + box-shadow: var(--elevation-2-ambient) var(--elevation-2-umbra); + } + .slide_in_enter { + transform: translateX(100%); + opacity: 0; + } + .slide_in_enter_active { + transition: all 500ms ease-in-out; + transform: translateX(0); + opacity: 1; + } + .slide_out_exit_active { + transition: all 500ms ease-in-out; + opacity: 0; + } + .fade_in_enter { + opacity: 0; + } + .fade_in_enter_active { + transition: all 500ms ease-in-out; + opacity: 1; + } + .fade_out_exit_active { + transition: all 500ms ease-in-out; + opacity: 0; + } +} diff --git a/src/components/Notifications.tsx b/src/components/Notifications.tsx new file mode 100644 index 0000000..48ad2b3 --- /dev/null +++ b/src/components/Notifications.tsx @@ -0,0 +1,296 @@ +import { Icon, IconifyIconProps } from '@iconify/react/dist/iconify.js'; +import cx from 'clsx'; +import { + createContext, + createRef, + FC, + ReactNode, + RefObject, + useCallback, + useContext, + useEffect, + useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import { CSSTransition, TransitionGroup } from 'react-transition-group'; +import { v4 } from 'uuid'; +import styles from './Notifications.module.css'; + +export enum NotificationType { + INFO, + PROMPTION, + SUCCESS, + WARNING, + ERROR, + CUSTOM, +} + +export enum ToastDuration { + MANUAL = 0, + SHORT = 1500, + MEDIUM = 3000, + LONG = 5000, +} + +interface NotificationFunctions { + addNotification( + kind: NotificationType, + title?: string, + message?: ReactNode, + icon?: IconifyIconProps['icon'], + duration?: number, + ): string; + removeNotification(id: string): void; + showToast( + kind: NotificationType, + message: string, + icon?: IconifyIconProps['icon'], + duration?: ToastDuration, + ): string; +} + +const NotificationStyleMap = { + [NotificationType.INFO]: styles.kind_info, + [NotificationType.PROMPTION]: styles.kind_promption, + [NotificationType.SUCCESS]: styles.kind_success, + [NotificationType.WARNING]: styles.kind_warning, + [NotificationType.ERROR]: styles.kind_error, + [NotificationType.CUSTOM]: styles.kind_custom, +}; + +const NotificationHostContext = createContext({ + addNotification: () => '', + removeNotification: () => {}, + showToast: () => '', +}); + +type NotificationProps = { + kind?: NotificationType; + nid: string; + icon?: IconifyIconProps['icon']; + title?: string; + message?: ReactNode; + duration?: number; + ref: RefObject; + closeAction?: (id: string) => void; +}; + +const Notification: FC = ({ + kind = NotificationType.INFO, + nid, + icon, + title, + message, + duration = 3000, + ref, + closeAction, +}) => { + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + closeAction?.(nid); + }, duration); + return () => clearTimeout(timer); + } + }, [nid, duration, closeAction]); + + return ( +
+ {icon && } +
+ {title &&
{title}
} + {message &&
{message}
} +
+ {duration === 0 && ( + closeAction?.(nid)} + /> + )} +
+ ); +}; + +type ToastProps = { + kind: NotificationType; + tid: string; + message?: string; + icon?: string; + duration?: ToastDuration; + ref: RefObject; + closeAction: (tid?: string) => void; +}; + +const Toast: FC = ({ + kind, + tid, + message, + icon, + duration = ToastDuration.MEDIUM, + ref, + closeAction, +}) => { + useEffect(() => { + if (duration !== ToastDuration.MANUAL) { + const timer = setTimeout(() => { + closeAction?.(tid); + }, duration); + return () => clearTimeout(timer); + } + }, [tid, duration, closeAction]); + + return ( +
+ {icon && } +
{message}
+ {duration === ToastDuration.MANUAL && ( + closeAction?.(tid)} + /> + )} +
+ ); +}; + +export function useNotification() { + const functions = useContext(NotificationHostContext); + return functions; +} + +type NotificationElement = { + id: string; + element: ReactNode; + ref: RefObject; +}; + +type NotificationsProps = { + defaultDuration?: number; + maxNotifications?: number; + children?: ReactNode; +}; + +const Notifications: FC = ({ + defaultDuration = 3000, + maxNotifications = 5, + children, +}) => { + const [notifications, setNotifications] = useState([]); + const [toasts, setToasts] = useState([]); + + const removeNotification = useCallback((id: string) => { + setNotifications((prev) => prev.filter((n) => n.id !== id)); + }, []); + const addNotification = useCallback( + ( + kind: NotificationType, + title?: string, + message?: ReactNode, + icon?: IconifyIconProps['icon'], + duration?: number, + ) => { + const id = v4(); + const ref = createRef(); + const newNotify = ( + + ); + setNotifications((prev) => [...prev, { id, element: newNotify, ref } as NotificationElement]); + + return id; + }, + [removeNotification, defaultDuration], + ); + const removeToast = useCallback((id: string) => { + setToasts((prev) => prev.filter((n) => n.id !== id)); + }, []); + const showToast = useCallback( + (kind: NotificationType, message?: string, icon?: string, duration?: ToastDuration) => { + const id = v4(); + const ref = createRef(); + const newToast = ( + + ); + setToasts((prev) => [...prev, { id, element: newToast, ref } as NotificationElement]); + + return id; + }, + [removeToast], + ); + + return ( + + {children} + {createPortal( +
+ + {notifications.slice(0, maxNotifications).map(({ id, element, ref }) => ( + + {element} + + ))} + +
, + document.body, + )} + {createPortal( +
+ + {toasts.slice(0, 1).map(({ id, element, ref }) => ( + + {element} + + ))} + +
, + document.body, + )} +
+ ); +}; + +export default Notifications; diff --git a/src/main.tsx b/src/main.tsx index c002ffd..be4ab51 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -5,27 +5,36 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import Layout from './Layout'; +import Notifications from './components/Notifications'; import EstimWatchProvider from './context/EstimContext'; +import CreatePattern from './pages/CreatePattern'; import Device from './pages/Device'; import PatternEditor from './pages/PatternEditor'; import PatternLibrary from './pages/PatternLibrary'; +import PatternNavigator from './pages/PatternNavigator'; import PlayControl from './pages/Play'; import Settings from './pages/Settings'; ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( - - - - }> - } /> - } /> - } /> - } /> - } /> - - - - + + + + + }> + } /> + } /> + } /> + + } /> + } /> + } /> + + } /> + + + + + , );