diff --git a/frontend/.storybook/main.ts b/frontend/.storybook/main.ts index bf3e37456..335e0e5b9 100644 --- a/frontend/.storybook/main.ts +++ b/frontend/.storybook/main.ts @@ -54,7 +54,30 @@ const config: StorybookConfig = { presets: [require.resolve('@emotion/babel-preset-css-prop')], }, }); - + if (config.module?.rules) { + config.module = config.module || {}; + config.module.rules = config.module.rules || []; + const imageRule = config.module.rules.find((rule) => + rule?.['test']?.test('.svg'), + ); + if (imageRule) { + imageRule['exclude'] = /\.svg$/; + } + config.module.rules.push({ + test: /\.svg$/i, + oneOf: [ + { + use: ['@svgr/webpack'], + issuer: /\.[jt]sx?$/, + resourceQuery: { not: [/url/] }, + }, + { + type: 'asset/resource', + resourceQuery: /url/, + }, + ], + }); + } return config; }, }; diff --git a/frontend/src/common/assets/crown.svg b/frontend/src/common/assets/crown.svg new file mode 100644 index 000000000..3cda192a2 --- /dev/null +++ b/frontend/src/common/assets/crown.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/src/common/assets/empty_profile.svg b/frontend/src/common/assets/empty_profile.svg new file mode 100644 index 000000000..ecb9cc7fa --- /dev/null +++ b/frontend/src/common/assets/empty_profile.svg @@ -0,0 +1,4 @@ + + + + diff --git a/frontend/src/components/Profile/ProfileCard.stories.tsx b/frontend/src/components/Profile/ProfileCard.stories.tsx new file mode 100644 index 000000000..0cc0aba4c --- /dev/null +++ b/frontend/src/components/Profile/ProfileCard.stories.tsx @@ -0,0 +1,46 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProfileCard from './ProfileCard'; +import EmptyProfile from '@_common/assets/empty_profile.svg'; +import Plus from '@_common/assets/tabler_plus.svg?url'; + +const meta = { + component: ProfileCard, + title: 'Components/ProfileCard', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + profile: { + nickname: '치코', + src: EmptyProfile, + role: 'moimee', + }, + }, + render: (args) => , +}; + +export const WithCustomImage: Story = { + args: { + profile: { + nickname: '치코', + src: Plus, + role: 'moimee', + }, + }, + render: (args) => , +}; + +export const WithErrorHandling: Story = { + args: { + profile: { + nickname: '치코', + src: 'invalid-url.jpg', // 오류를 발생시키기 위한 잘못된 URL + role: 'moimee', + }, + }, + render: (args) => , +}; diff --git a/frontend/src/components/Profile/ProfileCard.style.ts b/frontend/src/components/Profile/ProfileCard.style.ts new file mode 100644 index 000000000..a3a9ef677 --- /dev/null +++ b/frontend/src/components/Profile/ProfileCard.style.ts @@ -0,0 +1,16 @@ +import { css, Theme } from '@emotion/react'; + +export const ProfileCard = css` + display: flex; + flex-direction: column; + gap: 0.4rem; + align-items: center; + justify-content: flex-end; + + width: auto; + width: fit-content; +`; + +export const ProfileName = (props: { theme: Theme }) => css` + ${props.theme.typography.s2} +`; diff --git a/frontend/src/components/Profile/ProfileCard.tsx b/frontend/src/components/Profile/ProfileCard.tsx new file mode 100644 index 000000000..7eb69df7e --- /dev/null +++ b/frontend/src/components/Profile/ProfileCard.tsx @@ -0,0 +1,23 @@ +import ProfileFrame from './ProfileFrame'; +import * as S from './ProfileCard.style'; +import { useTheme } from '@emotion/react'; +import { Participation } from '@_types/index'; + +interface ProfileCardProps { + profile: Participation; +} +export default function ProfileBox(props: ProfileCardProps) { + const { profile } = props; + const theme = useTheme(); + return ( +
+ +
{profile.nickname}
+
+ ); +} diff --git a/frontend/src/components/Profile/ProfileFrame.stories.tsx b/frontend/src/components/Profile/ProfileFrame.stories.tsx new file mode 100644 index 000000000..6c3b7072a --- /dev/null +++ b/frontend/src/components/Profile/ProfileFrame.stories.tsx @@ -0,0 +1,50 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import ProfileFrame from './ProfileFrame'; +import EmptyProfile from '@_common/assets/empty_profile.svg'; +import Plus from '@_common/assets/tabler_plus.svg?url'; + +const meta = { + component: ProfileFrame, + title: 'Components/ProfileFrame', +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + width: 7, + height: 7, + src: EmptyProfile, + }, + render: (args) => , +}; + +export const WithCustomImageAndCrown: Story = { + args: { + width: 7, + height: 7, + src: Plus, + role: 'moimer', + }, + render: (args) => , +}; + +export const WithCustomImage: Story = { + args: { + width: 7, + height: 7, + src: Plus, + }, + render: (args) => , +}; + +export const WithErrorHandling: Story = { + args: { + width: 7, + height: 7, + src: 'invalid-url.jpg', // 오류를 발생시키기 위한 잘못된 URL + }, + render: (args) => , +}; diff --git a/frontend/src/components/Profile/ProfileFrame.style.ts b/frontend/src/components/Profile/ProfileFrame.style.ts new file mode 100644 index 000000000..298547bec --- /dev/null +++ b/frontend/src/components/Profile/ProfileFrame.style.ts @@ -0,0 +1,42 @@ +import { css } from '@emotion/react'; + +type Size = number; + +export const profileBox = () => { + return css` + width: fit-content; + `; +}; + +export const profileFrame = (width: Size, height: Size) => { + return css` + overflow: hidden; + display: flex; + align-items: center; + justify-content: center; + + width: ${width}rem; + height: ${height}rem; + + border: 0.5rem solid orange; + border-radius: 300rem; + `; +}; + +export const profileImage = () => { + return css` + width: 100%; + height: 100%; + object-fit: cover; + `; +}; + +// 크라운 이미지를 가운데 정렬하기 위한 css +export const profileCrown = (width: Size) => { + return css` + position: relative; + top: 1rem; + left: ${width / 2 - (3 * (width / 8)) / 2}rem; + width: ${3 * (width / 8)}rem; + `; +}; diff --git a/frontend/src/components/Profile/ProfileFrame.tsx b/frontend/src/components/Profile/ProfileFrame.tsx new file mode 100644 index 000000000..980f7a9c3 --- /dev/null +++ b/frontend/src/components/Profile/ProfileFrame.tsx @@ -0,0 +1,32 @@ +import { ImgHTMLAttributes } from 'react'; +import * as S from './ProfileFrame.style'; +import EmptyProfile from '@_common/assets/empty_profile.svg?url'; +import Crown from '@_common/assets/crown.svg?url'; +import { Role } from '@_types/index'; + +interface ProfileFrameProps extends ImgHTMLAttributes { + role?: Role; + width: number; + height: number; +} +export default function ProfileFrame(props: ProfileFrameProps) { + const { width, height, onError, role, ...args } = props; + + const handleError = ( + event: React.SyntheticEvent, + ) => { + if (onError) { + onError(event); + } + event.currentTarget.src = EmptyProfile; + }; + + return ( +
+ {role === 'moimer' ? : ''} +
+ +
+
+ ); +} diff --git a/frontend/src/components/ProfileList/ProfileList.stories.tsx b/frontend/src/components/ProfileList/ProfileList.stories.tsx new file mode 100644 index 000000000..6dce559ec --- /dev/null +++ b/frontend/src/components/ProfileList/ProfileList.stories.tsx @@ -0,0 +1,59 @@ +import { Meta, StoryObj } from '@storybook/react/*'; +import ProfileList from './ProfileList'; + +const meta = { + title: 'components/ProfileList', + component: ProfileList, +} satisfies Meta; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + participants: [ + { + nickname: '치코', + src: '', + role: 'moimer', + }, + { + nickname: '치코', + src: '', + role: 'moimee', + }, + { + nickname: '치코', + src: '', + role: 'moimer', + }, + { + nickname: '치코', + src: '', + role: 'moimer', + }, + { + nickname: '치코', + src: '', + role: 'moimer', + }, + { + nickname: '치코', + src: '', + role: 'moimer', + }, + { + nickname: '치코', + src: '', + role: 'moimer', + }, + { + nickname: '치코', + src: '', + role: 'moimer', + }, + ], + }, + render: (args) => , +}; diff --git a/frontend/src/components/ProfileList/ProfileList.style.ts b/frontend/src/components/ProfileList/ProfileList.style.ts new file mode 100644 index 000000000..1fe21844b --- /dev/null +++ b/frontend/src/components/ProfileList/ProfileList.style.ts @@ -0,0 +1,17 @@ +import { css } from '@emotion/react'; + +export const ProfileContanier = css` + scrollbar-width: none; /* TODO Firefox 현재 no-unsupported-browser-features 경고 발생 중 추후 확인 필요 */ + + overflow: auto hidden; + display: flex; + gap: 20px; + + width: auto; + + -ms-overflow-style: none; + + &::-webkit-scrollbar { + display: none; + } +`; diff --git a/frontend/src/components/ProfileList/ProfileList.tsx b/frontend/src/components/ProfileList/ProfileList.tsx new file mode 100644 index 000000000..ca53cd9d9 --- /dev/null +++ b/frontend/src/components/ProfileList/ProfileList.tsx @@ -0,0 +1,19 @@ +import ProfileCard from '@_components/Profile/ProfileCard'; +import * as S from './ProfileList.style'; +import { Participation } from '@_types/index'; + +interface ProfileListProps { + participants: Participation[]; +} + +export default function ProfileList(props: ProfileListProps) { + const { participants } = props; + + return ( +
+ {participants.map((participant) => { + return ; + })} +
+ ); +} diff --git a/frontend/src/custom.d.ts b/frontend/src/custom.d.ts index 48397087a..82ca1de9a 100644 --- a/frontend/src/custom.d.ts +++ b/frontend/src/custom.d.ts @@ -6,6 +6,11 @@ declare module '*.woff2' { const src: string; export default src; } + +declare module '*.svg?url' { + const content: string; + export default content; +} declare module '*.svg' { import React = require('react'); export const ReactComponent: React.FC>; diff --git a/frontend/src/types/index.d.ts b/frontend/src/types/index.d.ts index a2a33a06a..a034bad3e 100644 --- a/frontend/src/types/index.d.ts +++ b/frontend/src/types/index.d.ts @@ -12,6 +12,14 @@ export interface MoimInfo { description?: string; } +export interface Participation { + nickname: string; + src: string; + role: Role; +} + +export type Role = 'moimer' | 'moimee'; + export type MoimInputInfo = Omit< MoimInfo, 'moimId' | 'currentPeople' | 'participants' diff --git a/frontend/webpack.common.js b/frontend/webpack.common.js index 330a15a76..3964cb1c4 100644 --- a/frontend/webpack.common.js +++ b/frontend/webpack.common.js @@ -44,7 +44,17 @@ module.exports = { rules: [ { test: /\.svg$/i, - use: ['@svgr/webpack'], + oneOf: [ + { + use: ['@svgr/webpack'], + issuer: /\.[jt]sx?$/, + resourceQuery: { not: [/url/] }, + }, + { + type: 'asset/resource', + resourceQuery: /url/, + } + ] }, { test: /\.(png|jpe?g|gif|webp|woff2)$/i,