From f20df13d0ddfdd22687300e7574173c7006272b3 Mon Sep 17 00:00:00 2001 From: Minha Kang <118591632+m2na7@users.noreply.github.com> Date: Sun, 8 Dec 2024 22:54:28 +0900 Subject: [PATCH] =?UTF-8?q?feat(community):=20=EB=A7=88=EC=9D=B4=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=B6=94=EA=B0=80=20(#38)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(community): add userCard component * feat(community): add userList component * feat(community): add user page * fix(community): rename getUser function * feat(design-system): add box-shadow tokens * refactor(community): rename MyPage and related components for clarity * Update apps/community/src/app/my/page.tsx * feat(community): add list card component and implement edit functionality * feat(design-system): add outline variant to button component * feat(community): update MyInfoCard styles and button variants * feat(community): refactor MyInfoCard and improve edit functionality * feat(design-system): enhance input component styles for disabled and focus states * refactor(community): update export method of MyInfoCardList component * feat(community): split MyInfoCardList into two components --------- Co-authored-by: JaeguJaegu <118053865+SunwooJaeho@users.noreply.github.com> Co-authored-by: Gwansik Kim --- apps/community/src/app/api/mock/my/data.ts | 10 ++ apps/community/src/app/api/mock/my/route.ts | 5 + apps/community/src/app/my/page.tsx | 34 +++++++ apps/community/src/app/my/remotes.ts | 17 ++++ .../src/components/my/my-info-card.css.ts | 87 ++++++++++++++++++ .../src/components/my/my-info-card.tsx | 91 +++++++++++++++++++ .../my/my-info-editable-profile-card.tsx | 32 +++++++ .../components/my/my-info-profile-card.tsx | 26 ++++++ apps/community/src/constants/api.ts | 1 + .../src/components/button/button.css.ts | 8 ++ .../src/components/button/button.stories.tsx | 8 ++ .../src/components/button/button.tsx | 2 +- .../src/components/input/input.css.ts | 9 +- .../design-system/src/styles/theme.css.ts | 2 + .../src/styles/tokens/box-shadow.ts | 7 ++ 15 files changed, 336 insertions(+), 3 deletions(-) create mode 100644 apps/community/src/app/api/mock/my/data.ts create mode 100644 apps/community/src/app/api/mock/my/route.ts create mode 100644 apps/community/src/app/my/page.tsx create mode 100644 apps/community/src/app/my/remotes.ts create mode 100644 apps/community/src/components/my/my-info-card.css.ts create mode 100644 apps/community/src/components/my/my-info-card.tsx create mode 100644 apps/community/src/components/my/my-info-editable-profile-card.tsx create mode 100644 apps/community/src/components/my/my-info-profile-card.tsx create mode 100644 packages/design-system/src/styles/tokens/box-shadow.ts diff --git a/apps/community/src/app/api/mock/my/data.ts b/apps/community/src/app/api/mock/my/data.ts new file mode 100644 index 0000000..a8dccf3 --- /dev/null +++ b/apps/community/src/app/api/mock/my/data.ts @@ -0,0 +1,10 @@ +const myProfile = { + name: '강민하', + phone: '010-0000-0000', + email: 'aics1204@kyonggi.ac.kr', + role: '학부생', + major: '컴퓨터공학부', + id: '201912000', +}; + +export { myProfile }; diff --git a/apps/community/src/app/api/mock/my/route.ts b/apps/community/src/app/api/mock/my/route.ts new file mode 100644 index 0000000..a7f6964 --- /dev/null +++ b/apps/community/src/app/api/mock/my/route.ts @@ -0,0 +1,5 @@ +import { myProfile } from './data'; + +export function GET() { + return Response.json({ data: myProfile }); +} diff --git a/apps/community/src/app/my/page.tsx b/apps/community/src/app/my/page.tsx new file mode 100644 index 0000000..e44e115 --- /dev/null +++ b/apps/community/src/app/my/page.tsx @@ -0,0 +1,34 @@ +import { MyInfoEditableProfileCard } from '~/components/my/my-info-editable-profile-card'; +import { MyInfoProfileCard } from '~/components/my/my-info-profile-card'; +import { PageHeader } from '~/components/page-header'; +import { getMyProfile } from './remotes'; + +// TODO: for mocking but will be replaced with a proper solution later +export const dynamic = 'force-dynamic'; + +export default async function MyPage() { + const { data } = await getMyProfile(); + + const userDetails = [ + { title: '이름', value: data.name }, + { title: '학번', value: data.id }, + { title: '구분', value: data.role }, + { title: '전공', value: data.major }, + ]; + + const userEditableDetails = [ + { title: '전화번호', value: data.phone }, + { title: '이메일', value: data.email }, + ]; + + return ( + <> + + + + + ); +} diff --git a/apps/community/src/app/my/remotes.ts b/apps/community/src/app/my/remotes.ts new file mode 100644 index 0000000..9c8a6a6 --- /dev/null +++ b/apps/community/src/app/my/remotes.ts @@ -0,0 +1,17 @@ +import { MOCK_END_POINT } from '~/constants/api'; +import { http } from '~/utils/http'; + +interface MyProfile { + id: string; + name: string; + phone: string; + email: string; + role: string; + major: string; +} + +function getMyProfile() { + return http.get(MOCK_END_POINT.MY_PROFILE); +} + +export { type MyProfile, getMyProfile }; diff --git a/apps/community/src/components/my/my-info-card.css.ts b/apps/community/src/components/my/my-info-card.css.ts new file mode 100644 index 0000000..7ae9380 --- /dev/null +++ b/apps/community/src/components/my/my-info-card.css.ts @@ -0,0 +1,87 @@ +import { screen, themeVars } from '@aics-client/design-system/styles'; +import { style, styleVariants } from '@vanilla-extract/css'; + +const cardWrapper = style({ + display: 'flex', + flexDirection: 'column', + gap: themeVars.spacing.lg, + marginBottom: themeVars.spacing.xl, + padding: themeVars.spacing.xl, + border: `1px solid ${themeVars.color.gray300}`, + borderRadius: themeVars.borderRadius.xl, + boxShadow: themeVars.boxShadow.md, +}); + +const cardTitle = style({ + marginBottom: themeVars.spacing.lg, + fontSize: themeVars.fontSize.xl, + fontWeight: themeVars.fontWeight.bold, +}); + +const cardContent = styleVariants({ + default: { + display: 'grid', + gridTemplateColumns: 'repeat(2, 1fr)', + gap: themeVars.spacing.lg, + }, + singleColumn: { + display: 'grid', + gridTemplateColumns: '1fr', + gap: themeVars.spacing.lg, + }, +}); + +const field = style({ + margin: '1.5rem 0', +}); + +const fieldTitle = style({ + width: '6rem', + fontSize: themeVars.fontSize.lg, + fontWeight: themeVars.fontWeight.semibold, +}); + +const editFieldWrapper = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', +}); + +const editFieldContent = style({ + display: 'flex', + flexDirection: 'column', + gap: themeVars.spacing.lg, + + ...screen.md({ + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + }), +}); + +const editField = style({ + width: '15rem', + + ...screen.md({ + width: '20rem', + }), +}); + +const buttonWrapper = style({ + display: 'flex', + margin: '1rem 0', + gap: themeVars.spacing.sm, +}); + +export { + cardWrapper, + cardTitle, + cardContent, + field, + fieldTitle, + editFieldWrapper, + editFieldContent, + editField, + buttonWrapper, +}; diff --git a/apps/community/src/components/my/my-info-card.tsx b/apps/community/src/components/my/my-info-card.tsx new file mode 100644 index 0000000..0fa9a2d --- /dev/null +++ b/apps/community/src/components/my/my-info-card.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { Button, Input } from '@aics-client/design-system'; +import { useState } from 'react'; +import * as styles from '~/components/my/my-info-card.css'; + +interface MyInfoCardProps { + title: string; + layout?: 'default' | 'singleColumn'; + children: React.ReactNode; +} + +interface MyInfoFieldProps { + title: string; + value: string; +} + +interface MyInfoEditableFieldProps extends MyInfoFieldProps { + onSave?: (value: string) => void; +} + +function MyInfoCard({ title, children, layout = 'default' }: MyInfoCardProps) { + return ( +
+

{title}

+
{children}
+
+ ); +} + +function MyInfoField({ title, value }: MyInfoFieldProps) { + return ( +
+

{title}

+

{value}

+
+ ); +} + +function MyInfoEditableField({ + title, + value, + onSave, +}: MyInfoEditableFieldProps) { + const [isEditing, setIsEditing] = useState(false); + const [currentValue, setCurrentValue] = useState(value); + + const handleEditToggle = () => setIsEditing((prev) => !prev); + const handleSave = () => { + setIsEditing(false); + onSave?.(currentValue); + }; + + return ( +
+
+

{title}

+ setCurrentValue(e.target.value)} + disabled={!isEditing} + /> +
+ +
+ {isEditing ? ( + <> + + + + ) : ( + + )} +
+
+ ); +} + +MyInfoCard.Field = MyInfoField; +MyInfoCard.EditableField = MyInfoEditableField; + +export { MyInfoCard }; diff --git a/apps/community/src/components/my/my-info-editable-profile-card.tsx b/apps/community/src/components/my/my-info-editable-profile-card.tsx new file mode 100644 index 0000000..f32c051 --- /dev/null +++ b/apps/community/src/components/my/my-info-editable-profile-card.tsx @@ -0,0 +1,32 @@ +'use client'; + +import { MyInfoCard } from '~/components/my/my-info-card'; + +interface Props { + data: { + title: string; + value: string; + }[]; +} + +function MyInfoEditableProfileCard({ data }: Props) { + const handleSave = (field: string, value: string) => { + // TODO: 필요한 서버 API 호출 로직 추가 + console.log(`${field} 저장: ${value}`); + }; + + return ( + + {data.map((detail) => ( + handleSave(detail.title, value)} + /> + ))} + + ); +} + +export { MyInfoEditableProfileCard }; diff --git a/apps/community/src/components/my/my-info-profile-card.tsx b/apps/community/src/components/my/my-info-profile-card.tsx new file mode 100644 index 0000000..092d9d5 --- /dev/null +++ b/apps/community/src/components/my/my-info-profile-card.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { MyInfoCard } from '~/components/my/my-info-card'; + +interface Props { + data: { + title: string; + value: string; + }[]; +} + +function MyInfoProfileCard({ data }: Props) { + return ( + + {data.map((detail) => ( + + ))} + + ); +} + +export { MyInfoProfileCard }; diff --git a/apps/community/src/constants/api.ts b/apps/community/src/constants/api.ts index a27e24d..1b454a3 100644 --- a/apps/community/src/constants/api.ts +++ b/apps/community/src/constants/api.ts @@ -6,6 +6,7 @@ const MOCK_END_POINT = { PROFESSORS: `${MOCK_BASE_URL}/professors`, CLUB: `${MOCK_BASE_URL}/about/club`, CONTACT: `${MOCK_BASE_URL}/about/contact`, + MY_PROFILE: `${MOCK_BASE_URL}/my`, BOARD: `${MOCK_BASE_URL}/board`, BOARD_DETAIL: `${MOCK_BASE_URL}/board/detail`, } as const; diff --git a/packages/design-system/src/components/button/button.css.ts b/packages/design-system/src/components/button/button.css.ts index b64a899..2a49462 100644 --- a/packages/design-system/src/components/button/button.css.ts +++ b/packages/design-system/src/components/button/button.css.ts @@ -21,6 +21,14 @@ const buttonVariants = recipe({ warning: { backgroundColor: themeVars.color.warning, }, + outline: { + backgroundColor: themeVars.color.white, + color: themeVars.color.black, + border: `1px solid ${themeVars.color.gray300}`, + ':hover': { + backgroundColor: themeVars.color.gray50, + }, + }, }, size: { sm: { diff --git a/packages/design-system/src/components/button/button.stories.tsx b/packages/design-system/src/components/button/button.stories.tsx index fe432cd..54e11dc 100644 --- a/packages/design-system/src/components/button/button.stories.tsx +++ b/packages/design-system/src/components/button/button.stories.tsx @@ -57,6 +57,14 @@ export const Warning: Story = { }, }; +export const Outline: Story = { + args: { + color: 'outline', + size: 'md', + children: 'Button', + }, +}; + export const Small: Story = { args: { size: 'sm', diff --git a/packages/design-system/src/components/button/button.tsx b/packages/design-system/src/components/button/button.tsx index 230eb71..30980b7 100644 --- a/packages/design-system/src/components/button/button.tsx +++ b/packages/design-system/src/components/button/button.tsx @@ -3,7 +3,7 @@ import Spinner from '../spinner/spinner'; import { buttonVariants } from './button.css'; interface Props extends React.ButtonHTMLAttributes { - color?: 'primary' | 'secondary' | 'danger' | 'warning'; + color?: 'primary' | 'secondary' | 'danger' | 'warning' | 'outline'; size?: 'sm' | 'md' | 'lg'; loading?: boolean; } diff --git a/packages/design-system/src/components/input/input.css.ts b/packages/design-system/src/components/input/input.css.ts index b5f347c..c14e2c1 100644 --- a/packages/design-system/src/components/input/input.css.ts +++ b/packages/design-system/src/components/input/input.css.ts @@ -29,15 +29,20 @@ export const input = recipe({ }, ':disabled': { - backgroundColor: themeVars.color.gray100, + opacity: themeVars.opacity[80], cursor: 'not-allowed', }, + + ':focus-visible': { + outline: 'none', + boxShadow: `0 0 0 2px ${themeVars.color.white}, 0 0 0 4px ${themeVars.color.gray400}`, + }, }, variants: { variant: { primary: { border: `1px solid ${themeVars.color.gray200}`, - boxShadow: '0 1px 2px rgba(0, 0, 0, 0.05)', + boxShadow: themeVars.boxShadow.sm, }, ghost: { border: 'none', diff --git a/packages/design-system/src/styles/theme.css.ts b/packages/design-system/src/styles/theme.css.ts index 3216e87..db48301 100644 --- a/packages/design-system/src/styles/theme.css.ts +++ b/packages/design-system/src/styles/theme.css.ts @@ -1,6 +1,7 @@ import { createTheme } from '@vanilla-extract/css'; import { createSprinkles, defineProperties } from '@vanilla-extract/sprinkles'; import border from './tokens/border'; +import boxShadow from './tokens/box-shadow'; import color from './tokens/color'; import container from './tokens/container'; import display from './tokens/display'; @@ -21,6 +22,7 @@ const tokens = { // presets container: container, spacing: spacing, + boxShadow: boxShadow, }; const properties = defineProperties({ diff --git a/packages/design-system/src/styles/tokens/box-shadow.ts b/packages/design-system/src/styles/tokens/box-shadow.ts new file mode 100644 index 0000000..1678191 --- /dev/null +++ b/packages/design-system/src/styles/tokens/box-shadow.ts @@ -0,0 +1,7 @@ +const boxShadow = { + sm: '0 1px 2px rgba(0, 0, 0, 0.05)', + md: '0 1px 4px rgba(0, 0, 0, 0.1)', + lg: '0 4px 6px rgba(0, 0, 0, 0.1)', +} as const; + +export default boxShadow;