diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx deleted file mode 100644 index 8dcfacac..00000000 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.spec.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import AddBackgroundEffect from './AddBackgroundEffect'; - -describe('AddBackgroundEffect', () => { - it('renders the add photo icon', () => { - render(); - const icon = screen.getByTestId('AddPhotoAlternateIcon'); - expect(icon).toBeInTheDocument(); - }); - - it('renders the tooltip with recommended text when enabled', async () => { - render(); - const option = screen.getByTestId('background-upload'); - expect(option).toBeInTheDocument(); - }); - - it('shows disabled tooltip when isDisabled is true', () => { - render(); - const option = screen.getByTestId('background-upload'); - expect(option).toHaveAttribute('aria-disabled', 'true'); - }); - - it('shows the tooltip when hovered', async () => { - render(); - const option = screen.getByTestId('background-upload'); - await userEvent.hover(option); - - const tooltip = await screen.findByRole('tooltip'); - expect(tooltip).toBeInTheDocument(); - - expect(tooltip).toHaveTextContent(/recommended/i); - }); -}); diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx deleted file mode 100644 index d0cb6f85..00000000 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect.tsx +++ /dev/null @@ -1,48 +0,0 @@ -import { ReactElement } from 'react'; -import AddPhotoAlternateIcon from '@mui/icons-material/AddPhotoAlternate'; -import { Tooltip } from '@mui/material'; -import SelectableOption from '../SelectableOption'; - -export type AddBackgroundEffectProps = { - isDisabled?: boolean; -}; - -/** - * Renders a button that allows user to upload background effects. - * - * This button is disabled if the user has reached the maximum limit of custom images. - * @param {AddBackgroundEffectProps} props - the props for the component. - * @property {boolean} isDisabled - Whether the button is disabled. - * @returns {ReactElement} A button for uploading background effects. - */ -const AddBackgroundEffect = ({ isDisabled = false }: AddBackgroundEffectProps): ReactElement => { - return ( - - Recommended: JPG/PNG img. at 1280x720 resolution. -
- Note: Images are stored only locally in the browser. - - ) - } - arrow - > - {} - // TODO: Implement upload functionality - } - icon={} - /> -
- ); -}; - -export default AddBackgroundEffect; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx new file mode 100644 index 00000000..d7ae5fe8 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.spec.tsx @@ -0,0 +1,63 @@ +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect } from 'vitest'; +import AddBackgroundEffectLayout from './AddBackgroundEffectLayout'; + +vi.mock('../../../../utils/useImageStorage/useImageStorage', () => ({ + __esModule: true, + default: () => ({ + storageError: '', + handleImageFromFile: vi.fn(async () => ({ + dataUrl: 'data:image/png;base64,MOCKED', + })), + handleImageFromLink: vi.fn(async () => ({ + dataUrl: 'data:image/png;base64,MOCKED_LINK', + })), + }), +})); + +describe('AddBackgroundEffectLayout', () => { + it('should render', () => { + render(); + expect(screen.getByText(/Drag and Drop, or click here to upload Image/i)).toBeInTheDocument(); + expect(screen.getByPlaceholderText(/Link from the web/i)).toBeInTheDocument(); + expect(screen.getByTestId('background-effect-link-submit-button')).toBeInTheDocument(); + }); + + it('shows error for invalid file type', async () => { + render(); + const input = screen.getByLabelText(/upload/i); + const file = new File(['dummy'], 'test.txt', { type: 'text/plain' }); + fireEvent.change(input, { target: { files: [file] } }); + expect( + await screen.findByText(/Only JPG, PNG, or WebP images are allowed/i) + ).toBeInTheDocument(); + }); + + it('shows error for file size too large', async () => { + render(); + const input = screen.getByLabelText(/upload/i); + const file = new File(['x'.repeat(3 * 1024 * 1024)], 'big.png', { type: 'image/png' }); + Object.defineProperty(file, 'size', { value: 3 * 1024 * 1024 }); + fireEvent.change(input, { target: { files: [file] } }); + expect(await screen.findByText(/Image must be less than 2MB/i)).toBeInTheDocument(); + }); + + it('handles valid image file upload', async () => { + const cb = vi.fn(); + render(); + const input = screen.getByLabelText(/upload/i); + const file = new File(['dummy'], 'test.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + await waitFor(() => expect(cb).toHaveBeenCalledWith('data:image/png;base64,MOCKED')); + }); + + it('handles valid link submit', async () => { + const cb = vi.fn(); + render(); + const input = screen.getByPlaceholderText(/Link from the web/i); + fireEvent.change(input, { target: { value: 'https://example.com/image.png' } }); + const button = screen.getByTestId('background-effect-link-submit-button'); + fireEvent.click(button); + await waitFor(() => expect(cb).toHaveBeenCalledWith('data:image/png;base64,MOCKED_LINK')); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx new file mode 100644 index 00000000..46ba3657 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout.tsx @@ -0,0 +1,134 @@ +import { + Box, + Button, + CircularProgress, + InputAdornment, + TextField, + Typography, +} from '@mui/material'; +import { ChangeEvent, ReactElement, useState } from 'react'; +import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; +import LinkIcon from '@mui/icons-material/Link'; +import FileUploader from '../../FileUploader/FileUploader'; +import { ALLOWED_TYPES, MAX_SIZE_MB } from '../../../../utils/constants'; +import useImageStorage from '../../../../utils/useImageStorage/useImageStorage'; + +export type AddBackgroundEffectLayoutProps = { + customBackgroundImageChange: (dataUrl: string) => void; +}; + +/** + * AddBackgroundEffectLayout Component + * + * This component manages the UI for adding background effects. + * @param {AddBackgroundEffectLayoutProps} props - The props for the component. + * @property {Function} customBackgroundImageChange - Callback function to handle background image change. + * @returns {ReactElement} The add background effect layout component. + */ +const AddBackgroundEffectLayout = ({ + customBackgroundImageChange, +}: AddBackgroundEffectLayoutProps): ReactElement => { + const [fileError, setFileError] = useState(''); + const [imageLink, setImageLink] = useState(''); + const [linkLoading, setLinkLoading] = useState(false); + const { storageError, handleImageFromFile, handleImageFromLink } = useImageStorage(); + + type HandleFileChangeType = ChangeEvent | { target: { files: FileList } }; + + const handleFileChange = async (e: HandleFileChangeType) => { + const { files } = e.target; + if (!files || files.length === 0) { + return; + } + + const file = files[0]; + if (!file) { + return; + } + + if (!ALLOWED_TYPES.includes(file.type)) { + setFileError('Only JPG, PNG, or WebP images are allowed.'); + return; + } + + if (file.size > MAX_SIZE_MB * 1024 * 1024) { + setFileError('Image must be less than 2MB.'); + return; + } + + try { + const newImage = await handleImageFromFile(file); + if (newImage) { + setFileError(''); + customBackgroundImageChange(newImage.dataUrl); + } + } catch { + setFileError('Failed to process uploaded image.'); + } + }; + + const handleLinkSubmit = async () => { + setFileError(''); + setLinkLoading(true); + try { + const newImage = await handleImageFromLink(imageLink); + if (newImage) { + setFileError(''); + customBackgroundImageChange(newImage.dataUrl); + } else { + setFileError('Failed to store image.'); + } + } catch { + // error handled in hook + } finally { + setLinkLoading(false); + } + }; + + return ( + + + + {(fileError || storageError) && ( + + {fileError || storageError} + + )} + + + setImageLink(e.target.value)} + InputProps={{ + startAdornment: ( + + {linkLoading ? : } + + ), + }} + /> + + + + + ); +}; + +export default AddBackgroundEffectLayout; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx new file mode 100644 index 00000000..ae8ccca1 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/AddBackgroundEffectLayout/Index.tsx @@ -0,0 +1,3 @@ +import AddBackgroundEffectLayout from './AddBackgroundEffectLayout'; + +export default AddBackgroundEffectLayout; diff --git a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx b/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx deleted file mode 100644 index ba0fb702..00000000 --- a/frontend/src/components/BackgroundEffects/AddBackgroundEffect/Index.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import AddBackgroundEffect from './AddBackgroundEffect'; - -export default AddBackgroundEffect; diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.spec.tsx new file mode 100644 index 00000000..82207346 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.spec.tsx @@ -0,0 +1,62 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { vi, describe, it, expect } from 'vitest'; +import BackgroundEffectTabs from './BackgroundEffectTabs'; + +describe('BackgroundEffectTabs', () => { + const setTabSelected = vi.fn(); + const setBackgroundSelected = vi.fn(); + const cleanBackgroundReplacementIfSelectedAndDeleted = vi.fn(); + const customBackgroundImageChange = vi.fn(); + + it('renders tabs and defaults to Backgrounds tab', () => { + render( + + ); + expect(screen.getByRole('tab', { name: /Backgrounds/i })).toBeInTheDocument(); + expect(screen.getByRole('tab', { name: /Add Background/i })).toBeInTheDocument(); + }); + + it('switches to Add Background tab when clicked', async () => { + render( + + ); + const addTab = screen.getByRole('tab', { name: /Add Background/i }); + await userEvent.click(addTab); + expect(setTabSelected).toHaveBeenCalledWith(1); + }); + + it('renders AddBackgroundEffectLayout when Add Background tab is selected', () => { + render( + + ); + expect(screen.getByText(/upload/i)).toBeInTheDocument(); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.tsx new file mode 100644 index 00000000..099a90d6 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs.tsx @@ -0,0 +1,103 @@ +import { Box, Tabs, Tab } from '@mui/material'; +import { Publisher } from '@vonage/client-sdk-video'; +import EffectOptionButtons from '../EffectOptionButtons/EffectOptionButtons'; +import BackgroundGallery from '../BackgroundGallery/BackgroundGallery'; +import AddBackgroundEffectLayout from '../AddBackgroundEffect/AddBackgroundEffectLayout/AddBackgroundEffectLayout'; +import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; +import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; + +type BackgroundEffectTabsProps = { + tabSelected: number; + setTabSelected: (value: number) => void; + backgroundSelected: string; + setBackgroundSelected: (value: string) => void; + cleanBackgroundReplacementIfSelectedAndDeletedFunction: (dataUrl: string) => void; + customBackgroundImageChange: (dataUrl: string) => void; +}; + +export const cleanBackgroundReplacementIfSelectedAndDeleted = ( + publisher: Publisher | null | undefined, + changeBackground: (bg: string) => void, + backgroundSelected: string, + dataUrl: string +) => { + const selectedBackgroundOption = getInitialBackgroundFilter(publisher); + if (dataUrl === selectedBackgroundOption) { + changeBackground(backgroundSelected); + } +}; + +/** + * BackgroundEffectTabs Component + * + * This component manages the tabs for background effects, including selecting existing backgrounds + * and adding new ones. + * @param {BackgroundEffectTabsProps} props - The props for the component. + * @property {number} tabSelected - The currently selected tab index. + * @property {Function} setTabSelected - Function to set the selected tab index. + * @property {string} backgroundSelected - The currently selected background option. + * @property {Function} setBackgroundSelected - Function to set the selected background option. + * @property {Function} cleanBackgroundReplacementIfSelectedAndDeletedFunction - Function to clean up background replacement if deleted. + * @property {Function} customBackgroundImageChange - Callback function to handle background image change. + * @returns {ReactElement} The background effect tabs component. + */ +const BackgroundEffectTabs = ({ + tabSelected, + setTabSelected, + backgroundSelected, + setBackgroundSelected, + cleanBackgroundReplacementIfSelectedAndDeletedFunction, + customBackgroundImageChange, +}: BackgroundEffectTabsProps) => { + const handleBackgroundSelect = (value: string) => { + setBackgroundSelected(value); + }; + + return ( + + setTabSelected(newValue)} + aria-label="backgrounds tabs" + > + + + + + + {tabSelected === 0 && ( + + + + + )} + {tabSelected === 1 && ( + + )} + + + ); +}; + +export default BackgroundEffectTabs; diff --git a/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/Index.tsx b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/Index.tsx new file mode 100644 index 00000000..2bad5f92 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/BackgroundEffectTabs/Index.tsx @@ -0,0 +1,3 @@ +import BackgroundEffectTabs from './BackgroundEffectTabs'; + +export default BackgroundEffectTabs; diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx index 799f10db..64df21be 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.spec.tsx @@ -1,34 +1,132 @@ -import { describe, expect, it, vi } from 'vitest'; +import { describe, expect, it, vi, beforeEach } from 'vitest'; import { render, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import BackgroundGallery, { backgrounds } from './BackgroundGallery'; +const customImages = [ + { id: 'custom1', dataUrl: 'data:image/png;base64,custom1' }, + { id: 'custom2', dataUrl: 'data:image/png;base64,custom2' }, +]; + +const mockDeleteImageFromStorage = vi.fn(); +const mockGetImagesFromStorage = vi.fn(() => customImages); + +vi.mock('../../../utils/useImageStorage/useImageStorage', () => ({ + __esModule: true, + default: () => ({ + getImagesFromStorage: mockGetImagesFromStorage, + deleteImageFromStorage: mockDeleteImageFromStorage, + }), +})); + describe('BackgroundGallery', () => { - const backgroundsFiles = backgrounds.map((bg) => bg.file); + beforeEach(() => { + vi.clearAllMocks(); + }); - it('renders all background images as selectable options', () => { - render( {}} />); - const imgs = screen.getAllByRole('img'); - backgroundsFiles.forEach((file) => { - expect(imgs.some((img) => (img as HTMLImageElement).src.includes(file))).toBe(true); + it('renders all built-in backgrounds as selectable options', () => { + render( + {}} + cleanPublisherBackgroundReplacementIfSelectedAndDeleted={() => {}} + /> + ); + backgrounds.forEach((bg) => { + expect(screen.getByTestId(`background-${bg.id}`)).toBeInTheDocument(); }); - const options = backgrounds.map((bg) => screen.getByTestId(`background-${bg.id}`)); - expect(options).toHaveLength(backgroundsFiles.length); }); - it('sets the selected background', async () => { + it('renders custom images as selectable options', () => { + render( + {}} + cleanPublisherBackgroundReplacementIfSelectedAndDeleted={() => {}} + /> + ); + customImages.forEach((img) => { + expect(screen.getByTestId(`background-${img.id}`)).toBeInTheDocument(); + }); + }); + + it('sets the selected built-in background', async () => { const setBackgroundSelected = vi.fn(); render( - + {}} + /> ); const duneViewOption = screen.getByTestId('background-bg3'); await userEvent.click(duneViewOption); expect(setBackgroundSelected).toHaveBeenCalledWith('dune-view.jpg'); }); - it('marks the background as selected', () => { - render( {}} />); + it('sets the selected custom image', async () => { + const setBackgroundSelected = vi.fn(); + render( + {}} + /> + ); + const customOption = screen.getByTestId('background-custom1'); + await userEvent.click(customOption); + expect(setBackgroundSelected).toHaveBeenCalledWith('data:image/png;base64,custom1'); + }); + + it('marks the built-in background as selected', () => { + render( + {}} + cleanPublisherBackgroundReplacementIfSelectedAndDeleted={() => {}} + /> + ); const planeOption = screen.getByTestId('background-bg7'); - expect(planeOption?.getAttribute('aria-pressed')).toBe('true'); + expect(planeOption.getAttribute('aria-pressed')).toBe('true'); + }); + + it('marks the custom image as selected', () => { + render( + {}} + cleanPublisherBackgroundReplacementIfSelectedAndDeleted={() => {}} + /> + ); + const customOption = screen.getByTestId('background-custom2'); + expect(customOption.getAttribute('aria-pressed')).toBe('true'); + }); + + it('calls deleteImageFromStorage and cleans publisher when deleting a custom image', async () => { + const cleanPublisher = vi.fn(); + render( + {}} + cleanPublisherBackgroundReplacementIfSelectedAndDeleted={cleanPublisher} + /> + ); + const deleteButtons = screen.getAllByLabelText('Delete custom background'); + await userEvent.click(deleteButtons[0]); + expect(mockDeleteImageFromStorage).toHaveBeenCalledWith('custom1'); + expect(cleanPublisher).toHaveBeenCalledWith('data:image/png;base64,custom1'); + }); + + it("doesn't delete custom image if it's selected", async () => { + render( + {}} + cleanPublisherBackgroundReplacementIfSelectedAndDeleted={() => {}} + /> + ); + const deleteButton = screen.getByTestId('background-delete-custom1'); + await userEvent.click(deleteButton); + expect(mockDeleteImageFromStorage).not.toHaveBeenCalled(); }); }); diff --git a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx index 50b6b17d..56c1f743 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundGallery/BackgroundGallery.tsx @@ -1,22 +1,25 @@ -import { ReactElement } from 'react'; -import { Box } from '@mui/material'; +import { ReactElement, useEffect, useState } from 'react'; +import { Box, IconButton, Tooltip } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; import { BACKGROUNDS_PATH } from '../../../utils/constants'; import SelectableOption from '../SelectableOption'; +import useImageStorage, { StoredImage } from '../../../utils/useImageStorage/useImageStorage'; export const backgrounds = [ - { id: 'bg1', file: 'bookshelf-room.jpg' }, - { id: 'bg2', file: 'busy-room.jpg' }, - { id: 'bg3', file: 'dune-view.jpg' }, - { id: 'bg4', file: 'hogwarts.jpg' }, - { id: 'bg5', file: 'library.jpg' }, - { id: 'bg6', file: 'new-york.jpg' }, - { id: 'bg7', file: 'plane.jpg' }, - { id: 'bg8', file: 'white-room.jpg' }, + { id: 'bg1', file: 'bookshelf-room.jpg', name: 'Bookshelf Room' }, + { id: 'bg2', file: 'busy-room.jpg', name: 'Busy Room' }, + { id: 'bg3', file: 'dune-view.jpg', name: 'Dune View' }, + { id: 'bg4', file: 'hogwarts.jpg', name: 'Hogwarts' }, + { id: 'bg5', file: 'library.jpg', name: 'Library' }, + { id: 'bg6', file: 'new-york.jpg', name: 'New York' }, + { id: 'bg7', file: 'plane.jpg', name: 'Plane' }, + { id: 'bg8', file: 'white-room.jpg', name: 'White Room' }, ]; export type BackgroundGalleryProps = { backgroundSelected: string; - setBackgroundSelected: (key: string) => void; + setBackgroundSelected: (dataUrl: string) => void; + cleanPublisherBackgroundReplacementIfSelectedAndDeleted: (dataUrl: string) => void; }; /** @@ -26,33 +29,91 @@ export type BackgroundGalleryProps = { * @param {BackgroundGalleryProps} props - The props for the component. * @property {string} backgroundSelected - The currently selected background image key. * @property {Function} setBackgroundSelected - Callback to update the selected background image key. + * @property {Function} cleanPublisherBackgroundReplacementIfSelectedAndDeleted - Callback to clean up background replacement if the selected background is deleted. * @returns {ReactElement} A horizontal stack of selectable option buttons. */ const BackgroundGallery = ({ backgroundSelected, setBackgroundSelected, + cleanPublisherBackgroundReplacementIfSelectedAndDeleted, }: BackgroundGalleryProps): ReactElement => { + const { getImagesFromStorage, deleteImageFromStorage } = useImageStorage(); + const [customImages, setCustomImages] = useState([]); + + useEffect(() => { + setCustomImages(getImagesFromStorage()); + }, [getImagesFromStorage]); + + const handleDelete = (id: string, dataUrl: string) => { + if (backgroundSelected === id) { + return; + } + deleteImageFromStorage(id); + setCustomImages((imgs) => imgs.filter((img) => img.id !== id)); + cleanPublisherBackgroundReplacementIfSelectedAndDeleted(dataUrl); + }; + return ( <> - {backgrounds.map((bg) => { - const path = `${BACKGROUNDS_PATH}/${bg.file}`; + {customImages.map(({ id, dataUrl }) => { + const isSelected = backgroundSelected === dataUrl; return ( setBackgroundSelected(bg.file)} - image={path} - /> + id={id} + title="Your Background" + isSelected={isSelected} + onClick={() => setBackgroundSelected(dataUrl)} + image={dataUrl} + > + + !isSelected && handleDelete(id, dataUrl)} + size="small" + sx={{ + color: 'white', + position: 'absolute', + top: -8, + right: -8, + zIndex: 10, + cursor: isSelected ? 'default' : 'pointer', + backgroundColor: isSelected ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.8)', + '&:hover': { + backgroundColor: isSelected ? 'rgba(0,0,0,0.4)' : 'rgba(0,0,0,0.8)', + }, + }} + > + + + + ); })} + + {backgrounds.map((bg) => { + const path = `${BACKGROUNDS_PATH}/${bg.file}`; + return ( + setBackgroundSelected(bg.file)} + image={path} + /> + ); + })} ); }; diff --git a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx index 46f4a4ca..b2cc6c6b 100644 --- a/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx +++ b/frontend/src/components/BackgroundEffects/BackgroundVideoContainer/BackgroundVideoContainer.tsx @@ -26,6 +26,7 @@ const BackgroundVideoContainer = ({ const isSMViewport = useMediaQuery(`(max-width:500px)`); const isMDViewport = useMediaQuery(`(max-width:768px)`); const isTabletViewport = useMediaQuery(`(max-width:899px)`); + const isLGViewport = useMediaQuery(`(max-width:1199px)`); useEffect(() => { if (publisherVideoElement && containerRef.current) { @@ -37,7 +38,11 @@ const BackgroundVideoContainer = ({ myVideoElement.style.maxHeight = isTabletViewport ? '80%' : '450px'; let width = '100%'; - if ((isFixedWidth && isTabletViewport) || (!isFixedWidth && isMDViewport)) { + if ( + (isFixedWidth && isTabletViewport) || + (!isFixedWidth && isMDViewport) || + (isLGViewport && isFixedWidth) + ) { width = '80%'; } myVideoElement.style.width = width; @@ -62,6 +67,7 @@ const BackgroundVideoContainer = ({ publisherVideoElement, isFixedWidth, isParentVideoEnabled, + isLGViewport, ]); let containerWidth = '100%'; @@ -72,7 +78,7 @@ const BackgroundVideoContainer = ({ } return ( -
+
{!isParentVideoEnabled && (
You have not enabled video diff --git a/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx index 7b747898..a9fb025a 100644 --- a/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx +++ b/frontend/src/components/BackgroundEffects/EffectOptionButtons/EffectOptionButtons.tsx @@ -4,9 +4,13 @@ import BlurOnIcon from '@mui/icons-material/BlurOn'; import SelectableOption from '../SelectableOption'; const options = [ - { key: 'none', icon: }, - { key: 'low-blur', icon: }, - { key: 'high-blur', icon: }, + { key: 'none', icon: , name: 'Remove background' }, + { key: 'low-blur', icon: , name: 'Slightly background blur' }, + { + key: 'high-blur', + icon: , + name: 'Strong background blur', + }, ]; export type EffectOptionButtonsProps = { @@ -29,10 +33,11 @@ const EffectOptionButtons = ({ }: EffectOptionButtonsProps): ReactElement => { return ( <> - {options.map(({ key, icon }) => ( + {options.map(({ key, icon, name }) => ( setBackgroundSelected(key)} icon={icon} diff --git a/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.spec.tsx b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.spec.tsx new file mode 100644 index 00000000..fb9fb47d --- /dev/null +++ b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.spec.tsx @@ -0,0 +1,43 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { it, vi, describe, expect } from 'vitest'; +import FileUploader from './FileUploader'; + +describe('FileUploader', () => { + it('renders upload UI', () => { + render(); + expect(screen.getByText(/Drag and Drop, or click here to upload Image/i)).toBeInTheDocument(); + expect(screen.getByTestId('file-upload-input')).toBeInTheDocument(); + expect(screen.getByTestId('file-upload-drop-area')).toBeInTheDocument(); + }); + + it('handles file input change', () => { + const handleFileChange = vi.fn(); + render(); + const input = screen.getByTestId('file-upload-input'); + const file = new File(['dummy'], 'test.png', { type: 'image/png' }); + fireEvent.change(input, { target: { files: [file] } }); + expect(handleFileChange).toHaveBeenCalled(); + }); + + it('handles file drop event', () => { + const handleFileChange = vi.fn(); + render(); + const box = screen.getByTestId('file-upload-drop-area'); + const file = new File(['dummy'], 'test.jpg', { type: 'image/jpeg' }); + const dataTransfer = { + files: [file], + clearData: vi.fn(), + }; + fireEvent.drop(box, { dataTransfer }); + expect(handleFileChange).toHaveBeenCalledWith({ target: { files: [file] } }); + }); + + it('shows drag over style when dragging', () => { + render(); + const box = screen.getByTestId('file-upload-drop-area'); + fireEvent.dragOver(box); + expect(box).toHaveStyle('border: 2px dashed #1976d2'); + fireEvent.dragLeave(box); + expect(box).toHaveStyle('border: 1px dashed #C1C1C1'); + }); +}); diff --git a/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.tsx b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.tsx new file mode 100644 index 00000000..6ea155d6 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/FileUploader/FileUploader.tsx @@ -0,0 +1,81 @@ +import { ChangeEvent, useState, DragEvent, ReactElement } from 'react'; +import { Box, Typography } from '@mui/material'; +import CloudUploadIcon from '@mui/icons-material/CloudUpload'; + +export type FileUploaderProps = { + handleFileChange: ( + event: ChangeEvent | { target: { files: FileList } } + ) => void; +}; + +/** + * FileUploader component allows users to upload image files via drag-and-drop or file selection. + * + * This component manages the UI for adding background effects. + * @param {FileUploaderProps} props - The props for the component. + * @property {Function} handleFileChange - Callback function to handle background image change. + * @returns {ReactElement} The add background effect layout component. + */ +const FileUploader = ({ handleFileChange }: FileUploaderProps): ReactElement => { + const [dragOver, setDragOver] = useState(false); + + const onDragOver = (e: DragEvent) => { + e.preventDefault(); + setDragOver(true); + }; + + const onDragLeave = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + }; + + const onDrop = (e: DragEvent) => { + e.preventDefault(); + setDragOver(false); + const { files } = e.dataTransfer; + if (files && files.length > 0) { + handleFileChange({ target: { files } }); + e.dataTransfer.clearData(); + } + }; + + return ( + + ); +}; + +export default FileUploader; diff --git a/frontend/src/components/BackgroundEffects/FileUploader/index.tsx b/frontend/src/components/BackgroundEffects/FileUploader/index.tsx new file mode 100644 index 00000000..9036fc89 --- /dev/null +++ b/frontend/src/components/BackgroundEffects/FileUploader/index.tsx @@ -0,0 +1,3 @@ +import FileUploader from './FileUploader'; + +export default FileUploader; diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx index 798ff130..f35a0dfa 100644 --- a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx +++ b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.spec.tsx @@ -60,4 +60,32 @@ describe('SelectableOption', () => { const option = screen.getByTestId('background-disabled'); expect(option).toHaveAttribute('aria-disabled', 'true'); }); + + it('shows the title in the tooltip', async () => { + render( + {}} + id="with-title" + icon={Icon} + title="My Tooltip Title" + /> + ); + await userEvent.hover(screen.getByTestId('background-with-title')); + expect(screen.getByLabelText('My Tooltip Title')).toBeInTheDocument(); + }); + + it('renders children inside the option', () => { + render( + {}} + id="with-children" + icon={Icon} + > + Child Content + + ); + expect(screen.getByTestId('child-content')).toBeInTheDocument(); + }); }); diff --git a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx index 231512ae..73581599 100644 --- a/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx +++ b/frontend/src/components/BackgroundEffects/SelectableOption/SelectableOption.tsx @@ -1,5 +1,5 @@ import { ReactElement, ReactNode } from 'react'; -import { Paper } from '@mui/material'; +import { Box, Paper, Tooltip } from '@mui/material'; import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; export type SelectableOptionProps = { @@ -7,9 +7,11 @@ export type SelectableOptionProps = { onClick: () => void; id: string; icon?: ReactNode; + title?: string; image?: string; size?: number; isDisabled?: boolean; + children?: ReactNode; }; /** @@ -21,9 +23,11 @@ export type SelectableOptionProps = { * @property {Function} onClick - Function to call when the option is clicked * @property {string} id - Unique identifier for the option * @property {ReactNode} icon - Icon to display in the option + * @property {string} title - Title to display in the option * @property {string} image - Image URL to display in the option * @property {number} size - Size of the option (default is DEFAULT_SELECTABLE_OPTION_WIDTH) * @property {boolean} isDisabled - Whether the option is disabled + * @property {ReactNode} children - Additional content to render inside the option * @returns {ReactElement} A selectable option element */ const SelectableOption = ({ @@ -31,44 +35,60 @@ const SelectableOption = ({ onClick, id, icon, + title, image, size = DEFAULT_SELECTABLE_OPTION_WIDTH, isDisabled = false, + children, ...otherProps // Used by MUI Tooltip }: SelectableOptionProps): ReactElement => { return ( - - {image ? ( - background - ) : ( - icon - )} - + + + {image ? ( + background + ) : ( + icon + )} + {children} + + + ); }; diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx index 7a1a70d2..17d6ffad 100644 --- a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx +++ b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx @@ -37,9 +37,9 @@ describe('BackgroundEffectsLayout', () => { expect(screen.getByTestId('right-panel-title')).toHaveTextContent('Background Effects'); expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); expect(screen.getByTestId('background-none')).toBeInTheDocument(); - expect(screen.getByTestId('background-upload')).toBeInTheDocument(); expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); - expect(screen.getAllByText(/Choose Background Effect/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Backgrounds/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Add background/i)[0]).toBeInTheDocument(); expect(screen.getByTestId('background-effect-cancel-button')).toBeInTheDocument(); expect(screen.getByTestId('background-effect-apply-button')).toBeInTheDocument(); }); diff --git a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx index 2184778c..0bba24dc 100644 --- a/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx +++ b/frontend/src/components/MeetingRoom/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -1,14 +1,13 @@ import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { Box, Button, Typography } from '@mui/material'; +import { Box, Button, useMediaQuery } from '@mui/material'; import usePublisherContext from '../../../hooks/usePublisherContext'; import RightPanelTitle from '../RightPanel/RightPanelTitle'; -import EffectOptionButtons from '../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; -import BackgroundGallery from '../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; import BackgroundVideoContainer from '../../BackgroundEffects/BackgroundVideoContainer'; import useBackgroundPublisherContext from '../../../hooks/useBackgroundPublisherContext'; -import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../utils/constants'; -import AddBackgroundEffect from '../../BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect'; import getInitialBackgroundFilter from '../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; +import BackgroundEffectTabs, { + cleanBackgroundReplacementIfSelectedAndDeleted, +} from '../../BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs'; export type BackgroundEffectsLayoutProps = { handleClose: () => void; @@ -18,7 +17,7 @@ export type BackgroundEffectsLayoutProps = { /** * BackgroundEffectsLayout Component * - * This component manages the UI for background effects (cancel background and blurs) in a room. + * This component manages the UI for background effects in the waiting room. * @param {BackgroundEffectsLayoutProps} props - The props for the component. * @property {boolean} isOpen - Whether the background effects panel is open. * @property {Function} handleClose - Function to close the panel. @@ -28,7 +27,9 @@ const BackgroundEffectsLayout = ({ handleClose, isOpen, }: BackgroundEffectsLayoutProps): ReactElement | false => { + const [tabSelected, setTabSelected] = useState(0); const [backgroundSelected, setBackgroundSelected] = useState('none'); + const isShortScreen = useMediaQuery('(max-height:825px)'); const { publisher, changeBackground, isVideoEnabled } = usePublisherContext(); const { publisherVideoElement, changeBackground: changeBackgroundPreview } = useBackgroundPublisherContext(); @@ -43,6 +44,11 @@ const BackgroundEffectsLayout = ({ handleClose(); }; + const customBackgroundImageChange = (dataUrl: string) => { + setTabSelected(0); + handleBackgroundSelect(dataUrl); + }; + const setInitialBackgroundReplacement = useCallback(() => { const selectedBackgroundOption = getInitialBackgroundFilter(publisher); setBackgroundSelected(selectedBackgroundOption); @@ -51,7 +57,6 @@ const BackgroundEffectsLayout = ({ const publisherVideoFilter = publisher?.getVideoFilter(); - // Reset background when closing the panel useEffect(() => { if (isOpen) { const currentOption = setInitialBackgroundReplacement(); @@ -59,67 +64,72 @@ const BackgroundEffectsLayout = ({ } }, [publisherVideoFilter, isOpen, changeBackgroundPreview, setInitialBackgroundReplacement]); - return ( - isOpen && ( - <> - + if (!isOpen) { + return false; + } - - - + return ( + + - - - Choose Background Effect - + + + - - - - {/* TODO: load custom images */} - - - + + cleanBackgroundReplacementIfSelectedAndDeleted( + publisher, + changeBackground, + backgroundSelected, + dataUrl + ) + } + customBackgroundImageChange={customBackgroundImageChange} + /> - - - - - - ) + + + + + ); }; diff --git a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx index a3eac284..0ca4d46c 100644 --- a/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx +++ b/frontend/src/components/MeetingRoom/DeviceSettingsMenu/DeviceSettingsMenu.tsx @@ -55,6 +55,11 @@ const DeviceSettingsMenu = ({ const theme = useTheme(); const customLightBlueColor = 'rgb(138, 180, 248)'; + const handleToggleBackgroundEffects = () => { + toggleBackgroundEffects(); + handleToggle(); + }; + useDropdownResizeObserver({ setIsOpen, dropDownRefElement: anchorRef.current }); const renderSettingsMenu = () => { @@ -74,7 +79,7 @@ const DeviceSettingsMenu = ({ {hasMediaProcessorSupport() && ( <> - + )} diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx index b19327c5..414cc50e 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsDialog/BackgroundEffectsDialog.tsx @@ -1,4 +1,5 @@ -import { Dialog, DialogContent } from '@mui/material'; +import { Dialog, DialogContent, DialogTitle, IconButton } from '@mui/material'; +import CloseIcon from '@mui/icons-material/Close'; import { ReactElement } from 'react'; import BackgroundEffectsLayout from '../BackgroundEffectsLayout/BackgroundEffectsLayout'; @@ -20,18 +21,29 @@ const BackgroundEffectsDialog = ({ isBackgroundEffectsOpen, setIsBackgroundEffectsOpen, }: BackgroundEffectsDialogProps): ReactElement | false => { + const handleClose = () => { + setIsBackgroundEffectsOpen(false); + }; + return ( - setIsBackgroundEffectsOpen(false)} - maxWidth="lg" - fullWidth - > + + + Background Effects + theme.palette.grey[500], + }} + > + + + - setIsBackgroundEffectsOpen(false)} - /> + ); diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx index 0d87ed88..5c39f7e2 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.spec.tsx @@ -34,12 +34,11 @@ describe('BackgroundEffects (Waiting Room)', () => { it('renders when open', () => { renderLayout(); - expect(screen.getByText('Background Effects')).toBeInTheDocument(); expect(screen.getByTestId('background-video-container')).toBeInTheDocument(); expect(screen.getByTestId('background-none')).toBeInTheDocument(); - expect(screen.getByTestId('background-upload')).toBeInTheDocument(); expect(screen.getByTestId('background-bg1')).toBeInTheDocument(); - expect(screen.getAllByText(/Choose Background Effect/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Backgrounds/i)[0]).toBeInTheDocument(); + expect(screen.getAllByText(/Add background/i)[0]).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Cancel/i })).toBeInTheDocument(); expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument(); }); diff --git a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx index be05d055..f0061531 100644 --- a/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx +++ b/frontend/src/components/WaitingRoom/BackgroundEffects/BackgroundEffectsLayout/BackgroundEffectsLayout.tsx @@ -1,13 +1,12 @@ import { ReactElement, useCallback, useEffect, useState } from 'react'; -import { Box, Button, Typography, useMediaQuery } from '@mui/material'; -import EffectOptionButtons from '../../../BackgroundEffects/EffectOptionButtons/EffectOptionButtons'; -import BackgroundGallery from '../../../BackgroundEffects/BackgroundGallery/BackgroundGallery'; +import { Box, Button, useMediaQuery } from '@mui/material'; import BackgroundVideoContainer from '../../../BackgroundEffects/BackgroundVideoContainer'; import usePreviewPublisherContext from '../../../../hooks/usePreviewPublisherContext'; import useBackgroundPublisherContext from '../../../../hooks/useBackgroundPublisherContext'; -import { DEFAULT_SELECTABLE_OPTION_WIDTH } from '../../../../utils/constants'; -import AddBackgroundEffect from '../../../BackgroundEffects/AddBackgroundEffect/AddBackgroundEffect'; import getInitialBackgroundFilter from '../../../../utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter'; +import BackgroundEffectTabs, { + cleanBackgroundReplacementIfSelectedAndDeleted, +} from '../../../BackgroundEffects/BackgroundEffectTabs/BackgroundEffectTabs'; export type BackgroundEffectsProps = { isOpen: boolean; @@ -27,7 +26,9 @@ const BackgroundEffectsLayout = ({ isOpen, handleClose, }: BackgroundEffectsProps): ReactElement | false => { + const [tabSelected, setTabSelected] = useState(0); const [backgroundSelected, setBackgroundSelected] = useState('none'); + const { publisher, changeBackground, isVideoEnabled } = usePreviewPublisherContext(); const { publisherVideoElement, changeBackground: changeBackgroundPreview } = useBackgroundPublisherContext(); @@ -43,6 +44,11 @@ const BackgroundEffectsLayout = ({ handleClose(); }; + const customBackgroundImageChange = (dataUrl: string) => { + setTabSelected(0); + handleBackgroundSelect(dataUrl); + }; + const setInitialBackgroundReplacement = useCallback(() => { const selectedBackgroundOption = getInitialBackgroundFilter(publisher); setBackgroundSelected(selectedBackgroundOption); @@ -84,49 +90,48 @@ const BackgroundEffectsLayout = ({ return ( isOpen && ( - <> - - Background Effects - - - - + + + - {!isTabletViewport && buttonGroup} - - - - Choose Background Effect - - - - - {/* TODO: load custom images */} - - - - {isTabletViewport && buttonGroup} + {!isTabletViewport && buttonGroup} - + + + cleanBackgroundReplacementIfSelectedAndDeleted( + publisher, + changeBackground, + backgroundSelected, + dataUrl + ) + } + customBackgroundImageChange={customBackgroundImageChange} + /> + + {isTabletViewport && buttonGroup} + ) ); }; diff --git a/frontend/src/css/App.css b/frontend/src/css/App.css index 4526b27b..0c1664af 100644 --- a/frontend/src/css/App.css +++ b/frontend/src/css/App.css @@ -73,10 +73,50 @@ .choose-background-effect-box { border: 1px solid #e0e0e0; border-radius: 12px; - padding: 1rem; + padding: 0.5rem; + margin: 0.5rem; padding-bottom: 0.5rem; background-color: #f0f4f9; } +.choose-background-effect-grid { + overflow-y: auto; + padding: 8px; + max-height: 35vh; +} +@media (min-width: 899px) and (min-height: 920px) { + .choose-background-effect-grid { + max-height: 55vh; + } +} +@media (max-height: 875px) { + .choose-background-effect-grid { + max-height: 30vh; + } +} +@media (max-height: 750px) { + .choose-background-effect-grid { + max-height: 25vh; + } +} +@media (max-height: 680px) { + .choose-background-effect-grid { + max-height: 20vh; + } +} +@media (max-width: 600px) { + .choose-background-effect-grid { + max-height: 25vh; + } +} +@media (max-width: 425px) { + .choose-background-effect-grid { + max-height: 32vh; + } +} + +.background-video-container { + margin-top: 16px; +} .background-video-container-disabled { display: flex; @@ -92,4 +132,30 @@ font-size: 1.25rem; margin: 0 auto 16px auto; aspect-ratio: 16 / 9; + padding: 1rem; +} + +.add-background-effect-input .MuiInputBase-root { + color: #5f6368; +} + +.add-background-effect-input .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input .MuiOutlinedInput-root:hover .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input .MuiOutlinedInput-root.Mui-focused .MuiOutlinedInput-notchedOutline { + border-color: #80868b; +} + +.add-background-effect-input input::placeholder { + color: #5f6368; + opacity: 1; +} + +.add-background-effect-input-icon { + color: #5f6368; } diff --git a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts index c09ddc05..1ac8c524 100644 --- a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts +++ b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.spec.ts @@ -126,4 +126,22 @@ describe('applyBackgroundFilter', () => { blurStrength: 'low', }); }); + + it('applies background replacement filter with a dataUrl', async () => { + const dataUrl = 'data:image/png;base64,somebase64data'; + await applyBackgroundFilter({ publisher: mockPublisher, backgroundSelected: dataUrl }); + + expect(mockPublisher.applyVideoFilter).toHaveBeenCalledWith({ + type: 'backgroundReplacement', + backgroundImgUrl: dataUrl, + }); + + expect(setStorageItem).toHaveBeenCalledWith( + STORAGE_KEYS.BACKGROUND_REPLACEMENT, + JSON.stringify({ + type: 'backgroundReplacement', + backgroundImgUrl: dataUrl, + }) + ); + }); }); diff --git a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts index 8437602d..c778ab05 100644 --- a/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts +++ b/frontend/src/utils/backgroundFilter/applyBackgroundFilter/applyBackgroundFilter.ts @@ -38,16 +38,21 @@ const applyBackgroundFilter = async ({ } let videoFilter: VideoFilter | undefined; + const isDataUrl = backgroundSelected.startsWith('data:image/'); + const isImageUrl = /^https?:\/\/.+\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected); + const isImageFileName = /\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected); + if (backgroundSelected === 'low-blur' || backgroundSelected === 'high-blur') { videoFilter = { type: 'backgroundBlur', blurStrength: backgroundSelected === 'low-blur' ? 'low' : 'high', }; await publisher.applyVideoFilter(videoFilter); - } else if (/\.(jpg|jpeg|png|gif|bmp)$/i.test(backgroundSelected)) { + } else if (isDataUrl || isImageUrl || isImageFileName) { videoFilter = { type: 'backgroundReplacement', - backgroundImgUrl: `${BACKGROUNDS_PATH}/${backgroundSelected}`, + backgroundImgUrl: + isDataUrl || isImageUrl ? backgroundSelected : `${BACKGROUNDS_PATH}/${backgroundSelected}`, }; await publisher.applyVideoFilter(videoFilter); } else { diff --git a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts index 7f0ec81b..e1cb9fae 100644 --- a/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts +++ b/frontend/src/utils/backgroundFilter/getInitialBackgroundFilter/getInitialBackgroundFilter.ts @@ -4,7 +4,7 @@ import { Publisher } from '@vonage/client-sdk-video'; * Returns the initial background replacement setting based on the publisher's video filter. * @param {Publisher} publisher - The Vonage Publisher instance. * @returns {string} - The initial background replacement setting. - * Possible values are 'none', 'low-blur', 'high-blur', or the filename of a background image. + * Possible values are 'none', 'low-blur', 'high-blur', a base64 image or the filename of a background image. * If no valid background is set, it returns 'none'. * @throws {Error} - Throws an error if the publisher is not provided. */ @@ -19,7 +19,17 @@ const getInitialBackgroundFilter = (publisher?: Publisher | null): string => { } } if (filter?.type === 'backgroundReplacement') { - return filter.backgroundImgUrl?.split('/').pop() || 'none'; + const url = filter.backgroundImgUrl; + + if (!url) { + return 'none'; + } + + if (url.startsWith('data:image/')) { + return url; + } + + return url.split('/').pop() || 'none'; } return 'none'; }; diff --git a/frontend/src/utils/constants.tsx b/frontend/src/utils/constants.tsx index 6fb310d5..e2a62dcc 100644 --- a/frontend/src/utils/constants.tsx +++ b/frontend/src/utils/constants.tsx @@ -137,6 +137,24 @@ export const SMALL_VIEWPORT = 768; */ export const BACKGROUNDS_PATH = '/background'; +/** + * @constant {number} MAX_SIZE_MB - The maximum file size (in megabytes) allowed for image uploads. + * Used to validate image uploads in components like AddBackgroundEffectLayout. + */ +export const MAX_SIZE_MB = 2; + +/** + * @constant {string[]} ALLOWED_TYPES - An array of allowed MIME types for image uploads. + * Used to validate image uploads in components like AddBackgroundEffectLayout. + */ +export const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']; + +/** + * @constant {number} MAX_LOCAL_STORAGE_BYTES - The maximum size (in bytes) for storing images in localStorage. + * This is set to approximately 4MB, which is a common limit for localStorage across browsers. + */ +export const MAX_LOCAL_STORAGE_BYTES = 4 * 1024 * 1024; + /** * @constant {number} DEFAULT_SELECTABLE_OPTION_WIDTH - The default size (in pixels) for selectable option elements. * Used to define the width of selectable options in UI components. diff --git a/frontend/src/utils/storage.ts b/frontend/src/utils/storage.ts index c6b93305..bd0c76f7 100644 --- a/frontend/src/utils/storage.ts +++ b/frontend/src/utils/storage.ts @@ -4,6 +4,7 @@ export const STORAGE_KEYS = { NOISE_SUPPRESSION: 'noiseSuppression', BACKGROUND_REPLACEMENT: 'backgroundReplacement', USERNAME: 'username', + BACKGROUND_IMAGE: 'userBackgroundImage', }; export const setStorageItem = (key: string, value: string) => { diff --git a/frontend/src/utils/useImageStorage/useImageStorage.spec.ts b/frontend/src/utils/useImageStorage/useImageStorage.spec.ts new file mode 100644 index 00000000..6ec1bbdf --- /dev/null +++ b/frontend/src/utils/useImageStorage/useImageStorage.spec.ts @@ -0,0 +1,102 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { vi, describe, it, expect, beforeEach } from 'vitest'; +import useImageStorage, { StoredImage } from './useImageStorage'; +import { STORAGE_KEYS } from '../storage'; + +const mockStorage: Record = {}; + +vi.mock('../storage', async () => { + const actual = await vi.importActual('../storage'); + return { + ...actual, + getStorageItem: vi.fn((key: string) => mockStorage[key] || null), + setStorageItem: vi.fn((key: string, value: string) => { + mockStorage[key] = value; + }), + STORAGE_KEYS: { + BACKGROUND_IMAGE: 'BACKGROUND_IMAGE', + }, + }; +}); + +const mockDataUrl = 'data:image/png;base64,mockdata'; +const mockImage: StoredImage = { id: '1', dataUrl: mockDataUrl }; + +describe('useImageStorage', () => { + beforeEach(() => { + Object.keys(mockStorage).forEach((key) => delete mockStorage[key]); + }); + + it('returns empty array if no data in storage', () => { + const { result } = renderHook(() => useImageStorage()); + expect(result.current.getImagesFromStorage()).toEqual([]); + }); + + it('returns parsed data if valid JSON in storage', () => { + mockStorage[STORAGE_KEYS.BACKGROUND_IMAGE] = JSON.stringify([mockImage]); + const { result } = renderHook(() => useImageStorage()); + expect(result.current.getImagesFromStorage()).toEqual([mockImage]); + }); + + it('deletes an image by id', () => { + mockStorage[STORAGE_KEYS.BACKGROUND_IMAGE] = JSON.stringify([ + { id: '1', dataUrl: 'abc' }, + { id: '2', dataUrl: 'def' }, + ]); + const { result } = renderHook(() => useImageStorage()); + act(() => { + result.current.deleteImageFromStorage('1'); + }); + + const newData = JSON.parse(mockStorage[STORAGE_KEYS.BACKGROUND_IMAGE]); + expect(newData).toHaveLength(1); + expect(newData[0].id).toBe('2'); + }); + + it('rejects invalid URL in handleImageFromLink', async () => { + const { result } = renderHook(() => useImageStorage()); + + await act(async () => { + await expect(result.current.handleImageFromLink('invalid-url')).rejects.toBeUndefined(); + }); + + await waitFor(() => expect(result.current.storageError).toBe('Invalid image URL.')); + }); + + it('rejects invalid extension in handleImageFromLink', async () => { + const { result } = renderHook(() => useImageStorage()); + + await act(async () => { + await expect( + result.current.handleImageFromLink('https://example.com/file.txt') + ).rejects.toBeUndefined(); + }); + + await waitFor(() => expect(result.current.storageError).toBe('Invalid image extension.')); + }); + + it('processes a valid file in handleImageFromFile', async () => { + const { result } = renderHook(() => useImageStorage()); + const file = new File(['file-content'], 'test.png', { type: 'image/png' }); + + await act(async () => { + const stored = await result.current.handleImageFromFile(file); + expect(stored?.dataUrl).toContain('data:image'); + }); + }); + + it('limits image size for localStorage (~4MB)', async () => { + const largeDataUrl = `data:image/png;base64,${'a'.repeat(5 * 1024 * 1024)}`; + const largeImage: StoredImage = { id: 'x', dataUrl: largeDataUrl }; + const { result } = renderHook(() => useImageStorage()); + + act(() => { + const success = result.current.saveImagesToStorage([largeImage]); + expect(success).toBe(false); + }); + + await waitFor(() => + expect(result.current.storageError).toBe('Images are too large to store (~4MB max).') + ); + }); +}); diff --git a/frontend/src/utils/useImageStorage/useImageStorage.ts b/frontend/src/utils/useImageStorage/useImageStorage.ts new file mode 100644 index 00000000..5ce75988 --- /dev/null +++ b/frontend/src/utils/useImageStorage/useImageStorage.ts @@ -0,0 +1,155 @@ +import { useCallback, useState } from 'react'; +import { getStorageItem, setStorageItem, STORAGE_KEYS } from '../storage'; +import { MAX_LOCAL_STORAGE_BYTES } from '../constants'; + +export type StoredImage = { + id: string; + dataUrl: string; +}; + +/** + * Custom hook for managing image storage in localStorage. + * @returns {object} - The image storage methods and error state. + */ +const useImageStorage = () => { + const [storageError, setStorageError] = useState(''); + + const estimateSizeInBytes = (str: string) => new Blob([str]).size; + + const getImagesFromStorage = useCallback((): StoredImage[] => { + const stored = getStorageItem(STORAGE_KEYS.BACKGROUND_IMAGE); + if (!stored) { + return []; + } + try { + return JSON.parse(stored) as StoredImage[]; + } catch { + return []; + } + }, []); + + const saveImagesToStorage = (images: StoredImage[]): boolean => { + try { + const totalSize = images.reduce((acc, img) => acc + estimateSizeInBytes(img.dataUrl), 0); + if (totalSize > MAX_LOCAL_STORAGE_BYTES) { + setStorageError('Images are too large to store (~4MB max).'); + return false; + } + setStorageItem(STORAGE_KEYS.BACKGROUND_IMAGE, JSON.stringify(images)); + setStorageError(''); + return true; + } catch { + setStorageError('Failed to store images in localStorage.'); + return false; + } + }; + + const addImageToStorage = (dataUrl: string): StoredImage | null => { + const images = getImagesFromStorage(); + + const isDuplicate = images.some((img) => img.dataUrl === dataUrl); + if (isDuplicate) { + setStorageError('This image is already added.'); + return null; + } + + const generateId = (): string => { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + + const array = new Uint32Array(4); + window.crypto.getRandomValues(array); + return Array.from(array, (n) => n.toString(16)).join(''); + }; + + const newImage: StoredImage = { + id: generateId(), + dataUrl, + }; + images.push(newImage); + const success = saveImagesToStorage(images); + return success ? newImage : null; + }; + + const deleteImageFromStorage = (id: string) => { + const images = getImagesFromStorage().filter((img) => img.id !== id); + saveImagesToStorage(images); + }; + + const handleImageFromFile = (file: File): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + const newImage = addImageToStorage(dataUrl); + if (newImage) { + resolve(newImage); + } else { + resolve(null); + } + }; + reader.onerror = () => { + setStorageError('Failed to read image file.'); + reject(); + }; + reader.readAsDataURL(file); + }); + }; + + const handleImageFromLink = (url: string): Promise => { + return new Promise((resolve, reject) => { + try { + const parsed = new URL(url); + const validExt = /\.(jpg|jpeg|png|webp)$/i.test(parsed.pathname); + if (!validExt) { + setStorageError('Invalid image extension.'); + reject(); + return; + } + } catch { + setStorageError('Invalid image URL.'); + reject(); + return; + } + + const img = new Image(); + img.crossOrigin = 'Anonymous'; + img.onload = () => { + try { + const canvas = document.createElement('canvas'); + canvas.width = img.width; + canvas.height = img.height; + const ctx = canvas.getContext('2d'); + ctx?.drawImage(img, 0, 0); + const dataUrl = canvas.toDataURL('image/png'); + const newImage = addImageToStorage(dataUrl); + if (newImage) { + resolve(newImage); + } else { + reject(); + } + } catch { + setStorageError('Could not convert image.'); + reject(); + } + }; + img.onerror = () => { + setStorageError('Could not load image.'); + reject(); + }; + img.src = url; + }); + }; + + return { + storageError, + handleImageFromFile, + handleImageFromLink, + getImagesFromStorage, + deleteImageFromStorage, + saveImagesToStorage, + }; +}; + +export default useImageStorage;