Skip to content

Commit

Permalink
Merge pull request #450 from BinaryStudioAcademy/task/OV-402-create-a…
Browse files Browse the repository at this point in the history
…vatars-page

OV-402: Create avatars page
  • Loading branch information
anton-otroshchenko authored Sep 27, 2024
2 parents 370d6c2 + 80f6b47 commit 431c83f
Show file tree
Hide file tree
Showing 22 changed files with 388 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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<Properties> = ({
id,
image,
name,
tag,
isLiked,
}) => {
const dispatch = useAppDispatch();

const handleLike = useCallback(() => {
dispatch(
studioActions.avatarLikeToggle({
avatarId: id,
image,
}),
);
}, [dispatch, id, image]);

return (
<Card size="sm" borderRadius="lg" boxShadow="none" maxW="500px">
<CardBody>
<Box bg="background.50" borderRadius="md" position="relative">
<Image src={image} alt="AI generated avatar image" />
<IconButton
aria-label="favorite button"
icon={<Icon as={IconName.HEART} boxSize={5} />}
color={isLiked ? 'brand.secondary.300' : 'white'}
variant="icon"
position="absolute"
top="0"
right="0"
onClick={handleLike}
/>
</Box>
<Box p="5px 0">
<Text color="typography.900" fontWeight="600">
{name}
</Text>
<Flex gap="5px">
<Tag bg="background.330" borderRadius="full">
{tag}
</Tag>
<Tag bg="background.330" borderRadius="full">
4K
</Tag>
</Flex>
</Box>
</CardBody>
</Card>
);
};

export { AvatarCard };
Original file line number Diff line number Diff line change
@@ -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<Properties> = ({ subtitle, avatars, form }) => {
return (
<Box p="10px 0">
<Flex justify="space-between" align="center" mb="10px">
<Flex>
<Heading
color="typography.900"
variant="H4"
marginRight="11px"
>
{subtitle}
</Heading>
<Badge
color="background.600"
bg="#D1D4DB"
fontWeight="400"
padding="2px 10px"
>
{avatars.length}
</Badge>
</Flex>
{form && (
<FormProvider value={form}>
<Box>
<Select name="style" placeholder="All styles">
<option value="business">Business</option>
<option value="casual">Casual</option>
<option value="formal">Formal</option>
<option value="youthful">Youthful</option>
<option value="graceful">Graceful</option>
<option value="technical">Technical</option>
</Select>
</Box>
</FormProvider>
)}
</Flex>
{avatars.length === EMPTY_LENGTH && !form ? (
<Text color="typography.600" variant="body1">
Pick your favorites avatars to show them here!
</Text>
) : (
<SimpleGrid
minChildWidth="300px"
justifyItems="center"
spacing="40px"
>
{avatars.map(({ id, imgUrl, name, style, isLiked }) => (
<AvatarCard
key={`${id}-${style}`}
id={id}
name={name}
tag={style}
image={imgUrl}
isLiked={isLiked}
/>
))}
</SimpleGrid>
)}
</Box>
);
};

export { AvatarsSection };
74 changes: 74 additions & 0 deletions frontend/src/bundles/ai-avatars/components/avatars/avatars.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<Box bg="background.900" pr="25px">
<Box
bg="background.50"
borderTopRadius="xl"
p="25px"
minH="calc(100vh - 75px)"
>
<Overlay isOpen={dataStatus === DataStatus.PENDING}>
<Loader />
</Overlay>
<Heading color="typography.900" variant="H3">
AI Avatars
</Heading>
<AvatarsSection
avatars={favoriteAvatars}
subtitle="My Avatars"
/>
<AvatarsSection
avatars={styledAvatars}
subtitle="OutreachVids Library"
form={form}
/>
</Box>
</Box>
);
};

export { Avatars };
3 changes: 3 additions & 0 deletions frontend/src/bundles/ai-avatars/components/components.ts
Original file line number Diff line number Diff line change
@@ -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';
3 changes: 3 additions & 0 deletions frontend/src/bundles/ai-avatars/constants/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
const EMPTY_LENGTH = 0;

export { EMPTY_LENGTH };
21 changes: 21 additions & 0 deletions frontend/src/bundles/ai-avatars/helpers/avatars-mapper.helper.ts
Original file line number Diff line number Diff line change
@@ -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 };
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const capitalCase = (name: string): string => {
return name.charAt(0).toUpperCase() + name.slice(1);
};

export { capitalCase };
2 changes: 2 additions & 0 deletions frontend/src/bundles/ai-avatars/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { avatarsMapper } from './avatars-mapper.helper.js';
export { capitalCase } from './capital-case.helper.js';
Original file line number Diff line number Diff line change
@@ -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<AvatarMapped[]>([]);

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 };
16 changes: 16 additions & 0 deletions frontend/src/bundles/ai-avatars/pages/ai-avatars.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { Header, Sidebar } from '~/bundles/common/components/components.js';

import { Avatars } from '../components/components.js';

const AIAvatars: React.FC = () => {
return (
<>
<Header />
<Sidebar>
<Avatars />
</Sidebar>
</>
);
};

export { AIAvatars };
9 changes: 9 additions & 0 deletions frontend/src/bundles/ai-avatars/types/avatar-mapped.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
type AvatarMapped = {
id: string;
name: string;
style: string;
imgUrl: string;
isLiked: boolean | undefined;
};

export { type AvatarMapped };
2 changes: 2 additions & 0 deletions frontend/src/bundles/ai-avatars/types/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { type AvatarMapped } from './avatar-mapped.js';
export { type AvatarGetResponseDto } from 'shared';
1 change: 1 addition & 0 deletions frontend/src/bundles/common/components/components.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ export {
TabPanel,
TabPanels,
Tabs,
Tag,
Text,
Tooltip,
UnorderedList,
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/bundles/common/components/select/select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,9 +13,9 @@ import {
import { useFormField } from '~/bundles/common/hooks/hooks.js';

type Properties<T extends FormValues> = {
label?: string;
name: FieldInputProps<T>['name'];
children: React.ReactNode;
label?: string;
isRequired?: boolean;
placeholder?: string;
};
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/bundles/common/components/sidebar/sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,24 @@ const Sidebar: React.FC<Properties> = ({ children }) => {
/>
</Link>
</Box>

<Box>
<Link to={AppRoute.AI_AVATARS}>
<SidebarItem
bg={activeButtonPage(AppRoute.AI_AVATARS)}
icon={
<Icon
as={IconName.AI_AVATARS}
boxSize={4}
color={activeIconPage(AppRoute.AI_AVATARS)}
/>
}
isCollapsed={isCollapsed}
label="AI Avatars"
/>
</Link>
</Box>

<Spacer />
<SidebarItem
color="brand.secondary.600"
Expand Down
1 change: 1 addition & 0 deletions frontend/src/bundles/common/enums/app-route.enum.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const AppRoute = {
MY_AVATAR: '/my-avatar',
ANY: '*',
CREATE_AVATAR: '/create-avatar',
AI_AVATARS: '/ai-avatars',
PREVIEW: '/preview',
VOICES: '/voices',
TEMPLATES: '/templates',
Expand Down
Loading

0 comments on commit 431c83f

Please sign in to comment.