diff --git a/front/src/index.tsx b/front/src/index.tsx
index ed254ed2c..261b9e2f6 100644
--- a/front/src/index.tsx
+++ b/front/src/index.tsx
@@ -11,6 +11,7 @@ import '@emotion/react';
import { ApolloProvider } from './providers/apollo/ApolloProvider';
import { ClientConfigProvider } from './providers/client-config/ClientConfigProvider';
+import { SnackBarProvider } from './providers/snack-bar/SnackBarProvider';
import { AppThemeProvider } from './providers/theme/AppThemeProvider';
import { UserProvider } from './providers/user/UserProvider';
import { App } from './App';
@@ -29,11 +30,15 @@ root.render(
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/front/src/modules/snack-bar/components/SnackBar.tsx b/front/src/modules/snack-bar/components/SnackBar.tsx
new file mode 100644
index 000000000..a685e6d81
--- /dev/null
+++ b/front/src/modules/snack-bar/components/SnackBar.tsx
@@ -0,0 +1,182 @@
+import { useCallback, useMemo, useRef } from 'react';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+
+import { usePausableTimeout } from '@/snack-bar/hooks/usePausableTimeout';
+import { IconAlertTriangle, IconX } from '@/ui/icons';
+import { rgba } from '@/ui/themes/colors';
+
+import {
+ ProgressBar,
+ ProgressBarControls,
+} from '../../ui/components/progress-bar/ProgressBar';
+
+const StyledMotionContainer = styled.div>`
+ align-items: center;
+ background-color: ${({ theme, variant }) => {
+ switch (variant) {
+ case 'error':
+ return theme.snackBar.error.background;
+ case 'success':
+ return theme.snackBar.success.background;
+ case 'info':
+ default:
+ return theme.color.gray80;
+ }
+ }};
+ border-radius: ${({ theme }) => theme.border.radius.sm};
+ box-shadow: ${({ theme }) => theme.boxShadow.strong};
+ color: ${({ theme, variant }) => {
+ switch (variant) {
+ case 'error':
+ return theme.snackBar.error.color;
+ case 'success':
+ return theme.snackBar.success.color;
+ case 'info':
+ default:
+ return theme.color.gray0;
+ }
+ }};
+ cursor: pointer;
+ display: flex;
+ height: 40px;
+ overflow: hidden;
+ padding: ${({ theme }) => theme.spacing(2)};
+ pointer-events: auto;
+ position: relative;
+`;
+
+const StyledIconContainer = styled.div`
+ display: flex;
+ margin-right: ${({ theme }) => theme.spacing(2)};
+`;
+
+const ProgressBarContainer = styled.div`
+ height: 5px;
+ left: 0;
+ position: absolute;
+ right: 0;
+ top: 0;
+`;
+
+const CloseButton = styled.button>`
+ align-items: center;
+ background-color: transparent;
+ border: none;
+ border-radius: 12px;
+ color: ${({ theme, variant }) => {
+ switch (variant) {
+ case 'error':
+ return theme.color.red20;
+ case 'success':
+ return theme.color.turquoise20;
+ case 'info':
+ default:
+ return theme.color.gray0;
+ }
+ }};
+ cursor: pointer;
+ display: flex;
+ height: 24px;
+ justify-content: center;
+ margin-left: ${({ theme }) => theme.spacing(6)};
+ padding-left: ${({ theme }) => theme.spacing(1)};
+ padding-right: ${({ theme }) => theme.spacing(1)};
+ width: 24px;
+
+ &:hover {
+ background-color: ${({ theme }) => rgba(theme.color.gray0, 0.1)};
+ }
+`;
+
+export type SnackbarVariant = 'info' | 'error' | 'success';
+
+export interface SnackbarProps extends React.ComponentPropsWithoutRef<'div'> {
+ role?: 'alert' | 'status';
+ icon?: React.ReactNode;
+ message?: string;
+ allowDismiss?: boolean;
+ duration?: number;
+ variant?: SnackbarVariant;
+ children?: React.ReactNode;
+ onClose?: () => void;
+}
+
+export function SnackBar({
+ role = 'status',
+ icon: iconComponent,
+ message,
+ allowDismiss = true,
+ duration = 6000,
+ variant = 'info',
+ children,
+ onClose,
+ ...rootProps
+}: SnackbarProps) {
+ const theme = useTheme();
+
+ const progressBarRef = useRef(null);
+
+ const closeSnackbar = useCallback(() => {
+ onClose && onClose();
+ }, [onClose]);
+
+ const { pauseTimeout, resumeTimeout } = usePausableTimeout(
+ closeSnackbar,
+ duration,
+ );
+
+ const icon = useMemo(() => {
+ if (iconComponent) {
+ return iconComponent;
+ }
+
+ switch (variant) {
+ case 'error':
+ return (
+
+ );
+ case 'success':
+ case 'info':
+ default:
+ return null;
+ }
+ }, [iconComponent, theme.icon.size.md, variant]);
+
+ const onMouseEnter = () => {
+ progressBarRef.current?.pause();
+ pauseTimeout();
+ };
+
+ const onMouseLeave = () => {
+ progressBarRef.current?.start();
+ resumeTimeout();
+ };
+
+ return (
+
+
+
+
+ {icon && {icon}}
+ {children ? children : message}
+ {allowDismiss && (
+
+
+
+ )}
+
+ );
+}
diff --git a/front/src/modules/snack-bar/hooks/usePausableTimeout.ts b/front/src/modules/snack-bar/hooks/usePausableTimeout.ts
new file mode 100644
index 000000000..1429bfe45
--- /dev/null
+++ b/front/src/modules/snack-bar/hooks/usePausableTimeout.ts
@@ -0,0 +1,50 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+export function usePausableTimeout(callback: () => void, delay: number) {
+ const savedCallback = useRef<() => void>(callback);
+ const remainingTime = useRef(delay);
+ const startTime = useRef(Date.now());
+ const timeoutId = useRef | null>(null);
+
+ const tick = () => {
+ if (savedCallback.current) {
+ savedCallback.current();
+ }
+ };
+
+ const startTimeout = useCallback(() => {
+ startTime.current = Date.now();
+ timeoutId.current = setTimeout(tick, remainingTime.current);
+ }, []);
+
+ // Remember the latest callback
+ useEffect(() => {
+ savedCallback.current = callback;
+ }, [callback]);
+
+ // Set up the timeout loop
+ useEffect(() => {
+ if (delay !== null) {
+ startTimeout();
+ return () => {
+ if (timeoutId.current) {
+ clearTimeout(timeoutId.current);
+ }
+ };
+ }
+ }, [delay, startTimeout]);
+
+ const pauseTimeout = () => {
+ if (timeoutId.current) {
+ clearTimeout(timeoutId.current);
+ }
+ const elapsedTime = Date.now() - startTime.current;
+ remainingTime.current = remainingTime.current - elapsedTime;
+ };
+
+ const resumeTimeout = () => {
+ startTimeout();
+ };
+
+ return { pauseTimeout, resumeTimeout };
+}
diff --git a/front/src/modules/snack-bar/hooks/useSnackBar.ts b/front/src/modules/snack-bar/hooks/useSnackBar.ts
new file mode 100644
index 000000000..2db7474ab
--- /dev/null
+++ b/front/src/modules/snack-bar/hooks/useSnackBar.ts
@@ -0,0 +1,24 @@
+import { useSetRecoilState } from 'recoil';
+import { v4 as uuidv4 } from 'uuid';
+
+import {
+ SnackBarOptions,
+ snackBarSetQueueState,
+} from '../states/snackBarState';
+
+export function useSnackBar() {
+ const setSnackBarQueue = useSetRecoilState(snackBarSetQueueState);
+
+ const enqueueSnackBar = (
+ message: string,
+ options?: Omit,
+ ) => {
+ setSnackBarQueue({
+ id: uuidv4(),
+ message,
+ ...options,
+ });
+ };
+
+ return { enqueueSnackBar };
+}
diff --git a/front/src/modules/snack-bar/states/snackBarState.ts b/front/src/modules/snack-bar/states/snackBarState.ts
new file mode 100644
index 000000000..c6626d0d5
--- /dev/null
+++ b/front/src/modules/snack-bar/states/snackBarState.ts
@@ -0,0 +1,39 @@
+import { atom, selector } from 'recoil';
+
+import { SnackbarProps } from '@/snack-bar/components/SnackBar';
+
+export type SnackBarOptions = SnackbarProps & {
+ id: string;
+};
+
+export type SnackBarState = {
+ maxQueue: number;
+ queue: SnackBarOptions[];
+};
+
+export const snackBarInternalState = atom({
+ key: 'snackBarState',
+ default: {
+ maxQueue: 3,
+ queue: [],
+ },
+});
+
+export const snackBarSetQueueState = selector({
+ key: 'snackBarQueueState',
+ get: ({ get }) => null, // We don't care about getting the value
+ set: ({ set }, newValue) =>
+ set(snackBarInternalState, (prev) => {
+ if (prev.queue.length >= prev.maxQueue) {
+ return {
+ ...prev,
+ queue: [...prev.queue.slice(1), newValue] as SnackBarOptions[],
+ };
+ }
+
+ return {
+ ...prev,
+ queue: [...prev.queue, newValue] as SnackBarOptions[],
+ };
+ }),
+});
diff --git a/front/src/modules/ui/components/progress-bar/ProgressBar.tsx b/front/src/modules/ui/components/progress-bar/ProgressBar.tsx
new file mode 100644
index 000000000..48118e16a
--- /dev/null
+++ b/front/src/modules/ui/components/progress-bar/ProgressBar.tsx
@@ -0,0 +1,101 @@
+import {
+ forwardRef,
+ useCallback,
+ useEffect,
+ useImperativeHandle,
+ useRef,
+} from 'react';
+import { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import { AnimationControls, motion, useAnimation } from 'framer-motion';
+
+const Bar = styled.div>`
+ height: ${({ barHeight }) => barHeight}px;
+ overflow: hidden;
+ width: 100%;
+`;
+
+const BarFilling = styled(motion.div)`
+ height: 100%;
+ width: 100%;
+`;
+
+export type ProgressBarProps = {
+ duration?: number;
+ delay?: number;
+ easing?: string;
+ barHeight?: number;
+ barColor?: string;
+ autoStart?: boolean;
+};
+
+export type ProgressBarControls = AnimationControls & {
+ start: () => Promise;
+ pause: () => Promise;
+};
+
+export const ProgressBar = forwardRef(
+ (
+ {
+ duration = 3,
+ delay = 0,
+ easing = 'easeInOut',
+ barHeight = 24,
+ barColor,
+ autoStart = true,
+ },
+ ref,
+ ) => {
+ const theme = useTheme();
+
+ const controls = useAnimation();
+ const startTimestamp = useRef(0);
+ const remainingTime = useRef(duration);
+
+ const start = useCallback(async () => {
+ startTimestamp.current = Date.now();
+ return controls.start({
+ scaleX: 0,
+ transition: {
+ duration: remainingTime.current / 1000, // convert ms to s for framer-motion
+ delay: delay / 1000, // likewise
+ ease: easing,
+ },
+ });
+ }, [controls, delay, easing]);
+
+ useImperativeHandle(ref, () => ({
+ ...controls,
+ start: async () => {
+ return start();
+ },
+ pause: async () => {
+ const elapsed = Date.now() - startTimestamp.current;
+
+ remainingTime.current = remainingTime.current - elapsed;
+ return controls.stop();
+ },
+ }));
+
+ useEffect(() => {
+ if (autoStart) {
+ start();
+ }
+ }, [controls, delay, duration, easing, autoStart, start]);
+
+ return (
+
+
+
+ );
+ },
+);
diff --git a/front/src/modules/ui/icons/index.ts b/front/src/modules/ui/icons/index.ts
index c1feebb6c..072250a56 100644
--- a/front/src/modules/ui/icons/index.ts
+++ b/front/src/modules/ui/icons/index.ts
@@ -38,3 +38,5 @@ export { IconTimelineEvent } from '@tabler/icons-react';
export { IconAlertCircle } from '@tabler/icons-react';
export { IconEye } from '@tabler/icons-react';
export { IconEyeOff } from '@tabler/icons-react';
+export { IconAlertTriangle } from '@tabler/icons-react';
+export { IconCopy } from '@tabler/icons-react';
diff --git a/front/src/modules/ui/themes/themes.ts b/front/src/modules/ui/themes/themes.ts
index 49fa50fd9..85bff0f12 100644
--- a/front/src/modules/ui/themes/themes.ts
+++ b/front/src/modules/ui/themes/themes.ts
@@ -13,6 +13,20 @@ const common = {
icon: icon,
text: text,
blur: blur,
+ snackBar: {
+ success: {
+ background: '#16A26B',
+ color: '#D0F8E9',
+ },
+ error: {
+ background: '#B43232',
+ color: '#FED8D8',
+ },
+ info: {
+ background: color.gray80,
+ color: color.gray0,
+ },
+ },
spacing: (multiplicator: number) => `${multiplicator * 4}px`,
table: {
horizontalCellMargin: '8px',
diff --git a/front/src/modules/workspace/components/WorkspaceInviteLink.tsx b/front/src/modules/workspace/components/WorkspaceInviteLink.tsx
index dc094b354..2d63e2c8f 100644
--- a/front/src/modules/workspace/components/WorkspaceInviteLink.tsx
+++ b/front/src/modules/workspace/components/WorkspaceInviteLink.tsx
@@ -1,10 +1,10 @@
-import { useState } from 'react';
import { useTheme } from '@emotion/react';
import styled from '@emotion/styled';
+import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
import { Button } from '@/ui/components/buttons/Button';
import { TextInput } from '@/ui/components/inputs/TextInput';
-import { IconCheck, IconLink } from '@/ui/icons';
+import { IconCopy, IconLink } from '@/ui/icons';
const StyledContainer = styled.div`
align-items: center;
@@ -23,24 +23,24 @@ type OwnProps = {
export function WorkspaceInviteLink({ inviteLink }: OwnProps) {
const theme = useTheme();
- const [isCopied, setIsCopied] = useState(false);
+
+ const { enqueueSnackBar } = useSnackBar();
+
return (
- ) : (
-
- )
- }
+ icon={}
variant="primary"
- title={isCopied ? 'Copied' : 'Copy link'}
+ title="Copy link"
onClick={() => {
- setIsCopied(true);
+ enqueueSnackBar('Link copied to clipboard', {
+ variant: 'success',
+ icon: ,
+ duration: 2000,
+ });
navigator.clipboard.writeText(inviteLink);
}}
/>
diff --git a/front/src/pages/auth/CreateProfile.tsx b/front/src/pages/auth/CreateProfile.tsx
index 8f0984118..712337b15 100644
--- a/front/src/pages/auth/CreateProfile.tsx
+++ b/front/src/pages/auth/CreateProfile.tsx
@@ -16,6 +16,7 @@ import { OnboardingStatus } from '@/auth/utils/getOnboardingStatus';
import { useScopedHotkeys } from '@/hotkeys/hooks/useScopedHotkeys';
import { InternalHotkeysScope } from '@/hotkeys/types/internal/InternalHotkeysScope';
import { ProfilePictureUploader } from '@/settings/profile/components/ProfilePictureUploader';
+import { useSnackBar } from '@/snack-bar/hooks/useSnackBar';
import { MainButton } from '@/ui/components/buttons/MainButton';
import { TextInput } from '@/ui/components/inputs/TextInput';
import { SubSectionTitle } from '@/ui/components/section-titles/SubSectionTitle';
@@ -47,10 +48,6 @@ const StyledComboInputContainer = styled.div`
}
`;
-const StyledErrorContainer = styled.div`
- color: ${({ theme }) => theme.color.red};
-`;
-
const validationSchema = Yup.object()
.shape({
firstName: Yup.string().required('First name can not be empty'),
@@ -64,6 +61,8 @@ export function CreateProfile() {
const navigate = useNavigate();
const onboardingStatus = useOnboardingStatus();
+ const { enqueueSnackBar } = useSnackBar();
+
const [currentUser] = useRecoilState(currentUserState);
const [updateUser] = useUpdateUserMutation();
@@ -73,7 +72,6 @@ export function CreateProfile() {
control,
handleSubmit,
formState: { isValid, isSubmitting },
- setError,
getValues,
} = useForm