diff --git a/frontend/src/bundles/ai-avatars/components/avatar-card/avatar-card.tsx b/frontend/src/bundles/ai-avatars/components/avatar-card/avatar-card.tsx new file mode 100644 index 000000000..a3bebefbc --- /dev/null +++ b/frontend/src/bundles/ai-avatars/components/avatar-card/avatar-card.tsx @@ -0,0 +1,76 @@ +import { + Box, + Card, + CardBody, + Flex, + Icon, + IconButton, + Image, + Tag, + Text, +} from '~/bundles/common/components/components.js'; +import { useAppDispatch, useCallback } from '~/bundles/common/hooks/hooks.js'; +import { IconName } from '~/bundles/common/icons/icon-name.js'; +import { actions as studioActions } from '~/bundles/studio/store/slice.js'; + +type Properties = { + id: string; + image: string; + name: string; + tag: string; + isLiked: boolean | undefined; +}; + +const AvatarCard: React.FC = ({ + id, + image, + name, + tag, + isLiked, +}) => { + const dispatch = useAppDispatch(); + + const handleLike = useCallback(() => { + dispatch( + studioActions.avatarLikeToggle({ + avatarId: id, + image, + }), + ); + }, [dispatch, id, image]); + + return ( + + + + AI generated avatar image + } + color={isLiked ? 'brand.secondary.300' : 'white'} + variant="icon" + position="absolute" + top="0" + right="0" + onClick={handleLike} + /> + + + + {name} + + + + {tag} + + + 4K + + + + + + ); +}; + +export { AvatarCard }; diff --git a/frontend/src/bundles/ai-avatars/components/avatars-section/avatars-section.tsx b/frontend/src/bundles/ai-avatars/components/avatars-section/avatars-section.tsx new file mode 100644 index 000000000..884a1f52c --- /dev/null +++ b/frontend/src/bundles/ai-avatars/components/avatars-section/avatars-section.tsx @@ -0,0 +1,86 @@ +import { type FormikProps } from 'formik'; + +import { EMPTY_LENGTH } from '~/bundles/ai-avatars/constants/constants.js'; +import { type AvatarMapped } from '~/bundles/ai-avatars/types/types.js'; +import { + Badge, + Box, + Flex, + FormProvider, + Heading, + Select, + SimpleGrid, + Text, +} from '~/bundles/common/components/components.js'; + +import { AvatarCard } from '../components.js'; + +type Properties = { + subtitle: string; + avatars: AvatarMapped[]; + form?: FormikProps<{ style: string }>; +}; + +const AvatarsSection: React.FC = ({ subtitle, avatars, form }) => { + return ( + + + + + {subtitle} + + + {avatars.length} + + + {form && ( + + + + + + )} + + {avatars.length === EMPTY_LENGTH && !form ? ( + + Pick your favorites avatars to show them here! + + ) : ( + + {avatars.map(({ id, imgUrl, name, style, isLiked }) => ( + + ))} + + )} + + ); +}; + +export { AvatarsSection }; diff --git a/frontend/src/bundles/ai-avatars/components/avatars/avatars.tsx b/frontend/src/bundles/ai-avatars/components/avatars/avatars.tsx new file mode 100644 index 000000000..37c204347 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/components/avatars/avatars.tsx @@ -0,0 +1,74 @@ +import { EMPTY_LENGTH } from '~/bundles/ai-avatars/constants/constants.js'; +import { avatarsMapper } from '~/bundles/ai-avatars/helpers/helpers.js'; +import { useFilterAvatarStyle } from '~/bundles/ai-avatars/hooks/use-filter-avatar-style.hook.js'; +import { + Box, + Heading, + Loader, + Overlay, +} from '~/bundles/common/components/components.js'; +import { DataStatus } from '~/bundles/common/enums/data-status.enum.js'; +import { + useAppDispatch, + useAppForm, + useAppSelector, + useEffect, + useMemo, +} from '~/bundles/common/hooks/hooks.js'; +import { actions as studioActions } from '~/bundles/studio/store/studio.js'; + +import { AvatarsSection } from '../components.js'; + +const Avatars: React.FC = () => { + const dispatch = useAppDispatch(); + const { avatars, dataStatus } = useAppSelector(({ studio }) => studio); + + const form = useAppForm<{ style: string }>({ + initialValues: { style: '' }, + }); + const { + values: { style }, + } = form; + + const styledAvatars = useFilterAvatarStyle(style); + const mappedAvatars = useMemo(() => avatarsMapper(avatars), [avatars]); + const favoriteAvatars = useMemo( + () => mappedAvatars.filter(({ isLiked }) => isLiked), + [mappedAvatars], + ); + + useEffect(() => { + if (avatars.length === EMPTY_LENGTH) { + void dispatch(studioActions.loadAvatars()); + } + }, [dispatch, avatars.length]); + + return ( + + + + + + + AI Avatars + + + + + + ); +}; + +export { Avatars }; diff --git a/frontend/src/bundles/ai-avatars/components/components.ts b/frontend/src/bundles/ai-avatars/components/components.ts new file mode 100644 index 000000000..c38154090 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/components/components.ts @@ -0,0 +1,3 @@ +export { AvatarCard } from './avatar-card/avatar-card.js'; +export { Avatars } from './avatars/avatars.js'; +export { AvatarsSection } from './avatars-section/avatars-section.js'; diff --git a/frontend/src/bundles/ai-avatars/constants/constants.ts b/frontend/src/bundles/ai-avatars/constants/constants.ts new file mode 100644 index 000000000..3e9ba6bd7 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/constants/constants.ts @@ -0,0 +1,3 @@ +const EMPTY_LENGTH = 0; + +export { EMPTY_LENGTH }; diff --git a/frontend/src/bundles/ai-avatars/helpers/avatars-mapper.helper.ts b/frontend/src/bundles/ai-avatars/helpers/avatars-mapper.helper.ts new file mode 100644 index 000000000..efc4286e1 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/helpers/avatars-mapper.helper.ts @@ -0,0 +1,21 @@ +import { + type AvatarGetResponseDto, + type AvatarMapped, +} from '../types/types.js'; +import { capitalCase } from './helpers.js'; + +const avatarsMapper = (avatars: AvatarGetResponseDto[]): AvatarMapped[] => { + return avatars.flatMap(({ id, name, styles }) => { + return styles.map(({ imgUrl, style, isLiked }) => { + return { + id, + name: capitalCase(name), + style: style.split('-')[0] as string, + imgUrl, + isLiked, + }; + }); + }); +}; + +export { avatarsMapper }; diff --git a/frontend/src/bundles/ai-avatars/helpers/capital-case.helper.ts b/frontend/src/bundles/ai-avatars/helpers/capital-case.helper.ts new file mode 100644 index 000000000..f3971c304 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/helpers/capital-case.helper.ts @@ -0,0 +1,5 @@ +const capitalCase = (name: string): string => { + return name.charAt(0).toUpperCase() + name.slice(1); +}; + +export { capitalCase }; diff --git a/frontend/src/bundles/ai-avatars/helpers/helpers.ts b/frontend/src/bundles/ai-avatars/helpers/helpers.ts new file mode 100644 index 000000000..99e94d192 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/helpers/helpers.ts @@ -0,0 +1,2 @@ +export { avatarsMapper } from './avatars-mapper.helper.js'; +export { capitalCase } from './capital-case.helper.js'; diff --git a/frontend/src/bundles/ai-avatars/hooks/use-filter-avatar-style.hook.ts b/frontend/src/bundles/ai-avatars/hooks/use-filter-avatar-style.hook.ts new file mode 100644 index 000000000..7010635be --- /dev/null +++ b/frontend/src/bundles/ai-avatars/hooks/use-filter-avatar-style.hook.ts @@ -0,0 +1,30 @@ +import { + useAppSelector, + useEffect, + useState, +} from '~/bundles/common/hooks/hooks.js'; + +import { avatarsMapper } from '../helpers/helpers.js'; +import { type AvatarMapped } from '../types/types.js'; + +const useFilterAvatarStyle = (style: string): AvatarMapped[] => { + const { avatars } = useAppSelector(({ studio }) => studio); + const [avatarFiltered, setAvatarFiltered] = useState([]); + + useEffect(() => { + const avatarsMapped = avatarsMapper(avatars); + + const filterAvatars = (): AvatarMapped[] => + avatarsMapped.filter( + ({ style: avatarStyle }) => !style || avatarStyle === style, + ); + + const filteredAvatars = filterAvatars(); + + setAvatarFiltered(filteredAvatars); + }, [avatars, style]); + + return avatarFiltered; +}; + +export { useFilterAvatarStyle }; diff --git a/frontend/src/bundles/ai-avatars/pages/ai-avatars.tsx b/frontend/src/bundles/ai-avatars/pages/ai-avatars.tsx new file mode 100644 index 000000000..363bdebfc --- /dev/null +++ b/frontend/src/bundles/ai-avatars/pages/ai-avatars.tsx @@ -0,0 +1,16 @@ +import { Header, Sidebar } from '~/bundles/common/components/components.js'; + +import { Avatars } from '../components/components.js'; + +const AIAvatars: React.FC = () => { + return ( + <> +
+ + + + + ); +}; + +export { AIAvatars }; diff --git a/frontend/src/bundles/ai-avatars/types/avatar-mapped.ts b/frontend/src/bundles/ai-avatars/types/avatar-mapped.ts new file mode 100644 index 000000000..b5868d916 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/types/avatar-mapped.ts @@ -0,0 +1,9 @@ +type AvatarMapped = { + id: string; + name: string; + style: string; + imgUrl: string; + isLiked: boolean | undefined; +}; + +export { type AvatarMapped }; diff --git a/frontend/src/bundles/ai-avatars/types/types.ts b/frontend/src/bundles/ai-avatars/types/types.ts new file mode 100644 index 000000000..ee0907f40 --- /dev/null +++ b/frontend/src/bundles/ai-avatars/types/types.ts @@ -0,0 +1,2 @@ +export { type AvatarMapped } from './avatar-mapped.js'; +export { type AvatarGetResponseDto } from 'shared'; diff --git a/frontend/src/bundles/common/components/components.ts b/frontend/src/bundles/common/components/components.ts index 9d938499a..b63f6f056 100644 --- a/frontend/src/bundles/common/components/components.ts +++ b/frontend/src/bundles/common/components/components.ts @@ -82,6 +82,7 @@ export { TabPanel, TabPanels, Tabs, + Tag, Text, Tooltip, UnorderedList, diff --git a/frontend/src/bundles/common/components/select/select.tsx b/frontend/src/bundles/common/components/select/select.tsx index a0114ffdf..d1eb0b7fd 100644 --- a/frontend/src/bundles/common/components/select/select.tsx +++ b/frontend/src/bundles/common/components/select/select.tsx @@ -13,9 +13,9 @@ import { import { useFormField } from '~/bundles/common/hooks/hooks.js'; type Properties = { - label?: string; name: FieldInputProps['name']; children: React.ReactNode; + label?: string; isRequired?: boolean; placeholder?: string; }; diff --git a/frontend/src/bundles/common/components/sidebar/sidebar.tsx b/frontend/src/bundles/common/components/sidebar/sidebar.tsx index cef1e94df..f140dad58 100644 --- a/frontend/src/bundles/common/components/sidebar/sidebar.tsx +++ b/frontend/src/bundles/common/components/sidebar/sidebar.tsx @@ -144,6 +144,24 @@ const Sidebar: React.FC = ({ children }) => { /> + + + + + } + isCollapsed={isCollapsed} + label="AI Avatars" + /> + + + , + ) { + const { avatarId, image } = action.payload; + + const avatar = state.avatars.find(({ id }) => id === avatarId); + + if (!avatar) { + return; + } + + const style = avatar.styles.find(({ imgUrl }) => imgUrl === image); + + if (!style) { + return; + } + + style.isLiked = !style.isLiked; + }, loadTemplate(state, action: PayloadAction