From ee7641cff667b14c98a32cf85c7027351324c565 Mon Sep 17 00:00:00 2001 From: apple Date: Sat, 31 Jan 2026 15:00:14 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile-img/ProfileImage.module.css | 182 ++++++++ src/components/profile-img/ProfileImage.tsx | 400 ++++++++++++++++++ src/components/profile-img/index.ts | 7 + 3 files changed, 589 insertions(+) create mode 100644 src/components/profile-img/ProfileImage.module.css create mode 100644 src/components/profile-img/ProfileImage.tsx create mode 100644 src/components/profile-img/index.ts diff --git a/src/components/profile-img/ProfileImage.module.css b/src/components/profile-img/ProfileImage.module.css new file mode 100644 index 0000000..2ecf349 --- /dev/null +++ b/src/components/profile-img/ProfileImage.module.css @@ -0,0 +1,182 @@ +.frame { + display: inline-block; +} + +.outer { + position: relative; + overflow: visible; + display: inline-block; +} + +.box { + width: var(--pi-box-base); + height: var(--pi-box-base); + + display: flex; + align-items: center; + justify-content: center; + + position: relative; + + border: 0 !important; + outline: 0 !important; + box-shadow: none !important; + + overflow: visible; +} + +.mask { + width: 100%; + height: 100%; + + display: flex; + align-items: center; + justify-content: center; + + overflow: hidden; + border-radius: inherit; +} + +.teamMask { + background: var(--color-background-tertiary); +} + +/* avatar */ +.avatar { + width: var(--pi-img-base); + height: var(--pi-img-base); + + position: relative; + overflow: hidden; + + background: inherit; + border-radius: inherit; +} + +.clickable { + cursor: pointer; +} + +.avatarBorder { + border: 2px solid #e2e8f0; + box-sizing: border-box; +} + +.img { + display: block; + border-radius: inherit; +} + +.cover { + object-fit: cover; +} + +.contain { + object-fit: contain; +} + +/* radius */ +.r8 { + border-radius: 8px; +} +.r12 { + border-radius: 12px; +} +.r20 { + border-radius: 20px; +} +.r32 { + border-radius: 32px; +} + +/* edit 버튼 */ +.editButton { + position: absolute; + right: 3px; + bottom: 6px; + + background: var(--color-background-secondary); + border-radius: 50%; + border: none; + padding: 0; + cursor: pointer; + + width: 18px; + height: 18px; + + display: flex; + align-items: center; + justify-content: center; +} + +/* 아이콘 표시 제어 */ +.pencilSmall { + display: inline-flex; +} +.pencilLarge { + display: none; +} + +/* breakpoint: sm */ +@media (min-width: 640px) { + .box { + width: var(--pi-box-sm); + height: var(--pi-box-sm); + } + .avatar { + width: var(--pi-img-sm); + height: var(--pi-img-sm); + } +} + +@media (max-width: 767px) { + .r32 { + border-radius: 20px; + } +} + +@media (min-width: 768px) { + .box { + width: var(--pi-box-md); + height: var(--pi-box-md); + } + .avatar { + width: var(--pi-img-md); + height: var(--pi-img-md); + } + + .editButton { + right: 0; + width: 32px; + height: 32px; + } + + .pencilSmall { + display: none; + } + .pencilLarge { + display: inline-flex; + } +} + +@media (min-width: 1024px) { + .box { + width: var(--pi-box-lg); + height: var(--pi-box-lg); + } + .avatar { + width: var(--pi-img-lg); + height: var(--pi-img-lg); + } +} + +@media (min-width: 1280px) { + .box { + width: var(--pi-box-xl); + height: var(--pi-box-xl); + } + .avatar { + width: var(--pi-img-xl); + height: var(--pi-img-xl); + } +} diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx new file mode 100644 index 0000000..854f9e4 --- /dev/null +++ b/src/components/profile-img/ProfileImage.tsx @@ -0,0 +1,400 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { ChangeEvent, CSSProperties } from 'react'; +import Image from 'next/image'; +import styles from './ProfileImage.module.css'; + +import HumanBig from '@/assets/buttons/human/humanBig.svg'; +import HumanSmall from '@/assets/buttons/human/humanSmall.svg'; + +import PencilLarge from '@/assets/buttons/edit/editButtonLarge.svg'; +import PencilSmall from '@/assets/buttons/edit/editButtonSmall.svg'; + +import TeamDefault from '@/assets/icons/img/img.svg'; + +import { fetchApi } from '@/shared/apis/fetchApi'; +import { TEAM_ID } from '@/shared/apis/config'; + +const BASE_URL: string = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; + +export type ProfileImageSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; +export type ProfileImageVariant = 'profile' | 'team'; +export type ProfileImageRadius = 'r8' | 'r12' | 'r20' | 'r32'; + +type Breakpoint = 'base' | 'sm' | 'md' | 'lg' | 'xl'; +type SizeSpec = { box: number; image: number }; +type ResponsiveSize = Partial>; +type ResponsiveSpec = Partial>; + +export type ProfileImageProps = { + src?: string | null; + variant?: ProfileImageVariant; + size?: ProfileImageSize; + responsiveSize?: ResponsiveSize; + responsiveSpec?: ResponsiveSpec; + radius?: ProfileImageRadius; + + /** edit on/off */ + editable?: boolean; + + /** edit 버튼 표시 여부 (기본 true) */ + showEditButton?: boolean; + + /** 버튼 숨길 때, 아바타 클릭으로 업로드 열기 */ + clickToEdit?: boolean; + + /** + * 보더 제어(오버라이드) + * - undefined: 기본(xl/lg만 2px) + * - true: 모든 사이즈 2px + * - false: 보더 없음 + */ + showBorder?: boolean; + + /** 업로드 + PATCH까지 실행할지 (기본 true) */ + enableApi?: boolean; + + /** 업로드 요청에 추가로 붙일 헤더 (ex. Authorization) */ + uploadHeaders?: HeadersInit; + + /** LCP 경고 방지: above-the-fold에서만 true */ + priority?: boolean; + + alt?: string; + className?: string; +}; + +const SIZE_PRESET: Record = { + xl: { box: 112, image: 100 }, + lg: { box: 78, image: 64 }, + md: { box: 40, image: 32 }, + sm: { box: 32, image: 24 }, + xs: { box: 24, image: 16 }, +}; + +const BP_ORDER: Breakpoint[] = ['base', 'sm', 'md', 'lg', 'xl']; + +const BORDER_BY_SIZE: Record = { + xl: 2, + lg: 2, + md: 0, + sm: 0, + xs: 0, +}; + +function getDefaultResponsiveSize( + baseSize: ProfileImageSize, +): Record { + switch (baseSize) { + case 'xl': + return { base: 'lg', sm: 'lg', md: 'xl', lg: 'xl', xl: 'xl' }; + default: + return { base: baseSize, sm: baseSize, md: baseSize, lg: baseSize, xl: baseSize }; + } +} + +function resolveResponsiveSpec( + baseSize: ProfileImageSize, + responsiveSize?: ResponsiveSize, + responsiveSpec?: ResponsiveSpec, +): { spec: Record; sizeByBp: Record } { + const defaults = getDefaultResponsiveSize(baseSize); + + const sizeByBp: Record = { + base: responsiveSize?.base ?? defaults.base, + sm: responsiveSize?.sm ?? defaults.sm, + md: responsiveSize?.md ?? defaults.md, + lg: responsiveSize?.lg ?? defaults.lg, + xl: responsiveSize?.xl ?? defaults.xl, + }; + + const spec: Record = { + base: responsiveSpec?.base ?? SIZE_PRESET[sizeByBp.base], + sm: responsiveSpec?.sm ?? SIZE_PRESET[sizeByBp.sm], + md: responsiveSpec?.md ?? SIZE_PRESET[sizeByBp.md], + lg: responsiveSpec?.lg ?? SIZE_PRESET[sizeByBp.lg], + xl: responsiveSpec?.xl ?? SIZE_PRESET[sizeByBp.xl], + }; + + return { spec, sizeByBp }; +} + +function resolveResponsiveBorder( + sizeByBp: Record, + responsiveSpec: ResponsiveSpec | undefined, + showBorder: boolean | undefined, +): Record { + const result: Partial> = {}; + + if (showBorder === false) { + for (const bp of BP_ORDER) result[bp] = 0; + return result as Record; + } + + if (showBorder === true) { + for (const bp of BP_ORDER) result[bp] = 2; + return result as Record; + } + + for (const bp of BP_ORDER) { + if (responsiveSpec?.[bp]) { + result[bp] = bp === 'lg' || bp === 'xl' ? 2 : 0; + continue; + } + result[bp] = BORDER_BY_SIZE[sizeByBp[bp]]; + } + + return result as Record; +} + +function getFallback(variant: ProfileImageVariant, baseSize: ProfileImageSize) { + if (variant === 'team') return TeamDefault; + return baseSize === 'xs' || baseSize === 'sm' ? HumanSmall : HumanBig; +} + +function buildSizesAttr(spec: Record) { + return [ + `(min-width: 1280px) ${spec.xl.image}px`, + `(min-width: 1024px) ${spec.lg.image}px`, + `(min-width: 768px) ${spec.md.image}px`, + `(min-width: 640px) ${spec.sm.image}px`, + `${spec.base.image}px`, + ].join(', '); +} + +export default function ProfileImage({ + src, + variant = 'profile', + size = 'md', + responsiveSize, + responsiveSpec, + radius = 'r12', + editable = false, + showEditButton = true, + clickToEdit, + showBorder, + enableApi = true, + uploadHeaders, + priority = false, + alt = 'profile image', + className, +}: ProfileImageProps) { + const inputRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [erroredSrc, setErroredSrc] = useState(null); + + useEffect(() => { + return () => { + if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); + }; + }, [previewUrl]); + + const { spec: resolvedSpec, sizeByBp } = useMemo( + () => resolveResponsiveSpec(size, responsiveSize, responsiveSpec), + [size, responsiveSize, responsiveSpec], + ); + + const resolvedBorder = useMemo( + () => resolveResponsiveBorder(sizeByBp, responsiveSpec, showBorder), + [sizeByBp, responsiveSpec, showBorder], + ); + + const sizesAttr = useMemo(() => buildSizesAttr(resolvedSpec), [resolvedSpec]); + const fallback = useMemo(() => getFallback(variant, size), [variant, size]); + + const imageSrc = previewUrl ?? src ?? null; + const currentSrcKey = typeof imageSrc === 'string' ? imageSrc : null; + const isErrored = !!currentSrcKey && erroredSrc === currentSrcKey; + + const effectiveSrc = isErrored ? fallback : imageSrc || fallback; + const usingFallback = isErrored || !imageSrc; + + const shouldClickToEdit = clickToEdit ?? (editable && !showEditButton); + + const handleEditClick = useCallback(() => { + inputRef.current?.click(); + }, []); + + const handleAvatarClick = useCallback(() => { + if (editable && shouldClickToEdit) handleEditClick(); + }, [editable, shouldClickToEdit, handleEditClick]); + + /** + * 이미지 업로드 + * @param file 업로드할 이미지 파일 + * @returns 업로드 후 반환된 이미지 URL + */ + const uploadImage = useCallback( + async (file: File) => { + if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); + + const formData = new FormData(); + formData.append('image', file); + + const res = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { + method: 'POST', + body: formData, + // ✅ 토큰 기반: Authorization 등은 상위에서 주입 + // ⚠️ FormData라 Content-Type을 직접 넣으면 안 됨 + headers: { + ...(uploadHeaders ?? {}), + }, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`upload failed ${res.status}: ${text}`); + } + + const data = (await res.json()) as { url: string }; + return data.url; + }, + [uploadHeaders], + ); + + /** + * 유저 프로필 이미지 PATCH + * @param url 업로드된 이미지 URL + */ + const patchUserImage = useCallback(async (url: string) => { + const res = await fetchApi(`/${TEAM_ID}/user`, { + method: 'PATCH', + body: JSON.stringify({ image: url }), + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`patch failed ${res.status}: ${text}`); + } + }, []); + + const handleFileChange = useCallback( + async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + setErroredSrc(null); + + // local preview + const localUrl = URL.createObjectURL(file); + setPreviewUrl((prev) => { + if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); + return localUrl; + }); + + if (enableApi) { + try { + const url = await uploadImage(file); + await patchUserImage(url); + + // preview를 서버 url로 교체 + setPreviewUrl((prev) => { + if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); + return url; + }); + } catch (err) { + console.error(err); + } + } + + e.target.value = ''; + }, + [enableApi, uploadImage, patchUserImage], + ); + + const styleVars = useMemo(() => { + const s = resolvedSpec; + const b = resolvedBorder; + + const vars: CSSProperties & Record = { + '--pi-box-base': `${s.base.box}px`, + '--pi-img-base': `${s.base.image}px`, + '--pi-border-base': `${b.base}px`, + + '--pi-box-sm': `${s.sm.box}px`, + '--pi-img-sm': `${s.sm.image}px`, + '--pi-border-sm': `${b.sm}px`, + + '--pi-box-md': `${s.md.box}px`, + '--pi-img-md': `${s.md.image}px`, + '--pi-border-md': `${b.md}px`, + + '--pi-box-lg': `${s.lg.box}px`, + '--pi-img-lg': `${s.lg.image}px`, + '--pi-border-lg': `${b.lg}px`, + + '--pi-box-xl': `${s.xl.box}px`, + '--pi-img-xl': `${s.xl.image}px`, + '--pi-border-xl': `${b.xl}px`, + }; + + return vars; + }, [resolvedSpec, resolvedBorder]); + + const hasBorder = + resolvedBorder.base > 0 || + resolvedBorder.sm > 0 || + resolvedBorder.md > 0 || + resolvedBorder.lg > 0 || + resolvedBorder.xl > 0; + + return ( +
+
+
+
+
+ {alt} { + if (typeof imageSrc === 'string') setErroredSrc(imageSrc); + }} + /> +
+
+ + {editable && showEditButton && ( + + )} + + {editable && ( + + )} +
+
+
+ ); +} diff --git a/src/components/profile-img/index.ts b/src/components/profile-img/index.ts new file mode 100644 index 0000000..d2ba68e --- /dev/null +++ b/src/components/profile-img/index.ts @@ -0,0 +1,7 @@ +export { default as ProfileImage } from './ProfileImage'; +export type { + ProfileImageProps, + ProfileImageSize, + ProfileImageVariant, + ProfileImageRadius, +} from './ProfileImage'; From 8e4877f2899f0b9dcdcb6d960e8335f3fbdf0612 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Sat, 31 Jan 2026 16:39:32 +0900 Subject: [PATCH 2/8] =?UTF-8?q?chore:=20profile=20image=20props=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/profile-img/ProfileImage.tsx | 128 ++++++++++++-------- 1 file changed, 77 insertions(+), 51 deletions(-) diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx index 854f9e4..179dcae 100644 --- a/src/components/profile-img/ProfileImage.tsx +++ b/src/components/profile-img/ProfileImage.tsx @@ -1,5 +1,36 @@ 'use client'; +/** + * ProfileImage Component + * + * + * Props 요약 + * @param src 표시할 이미지 URL (없으면 fallback) + * @param variant 'profile' | 'team' (마스크 배경색/기본 fallback 분기) + * @param size 'xl' | 'lg' | 'md' | 'sm' | 'xs' (기본 스펙 프리셋) + * @param responsiveSize 브레이크포인트별 size 오버라이드 (선택) + * @param responsiveSpec 브레이크포인트별 box/image px 직접 지정 (선택) + * @param radius 'r8' | 'r12' | 'r20' | 'r32' (컨테이너/마스크/이미지 동일 적용) + * + * @param editable true면 input(file) 활성화 + edit 버튼 표시 가능 + * @param showEditButton edit 버튼 자체 표시 여부 (default true) + * @param clickToEdit showEditButton=false일 때 avatar 클릭으로 업로드 열기 (선택) + * + * @param showBorder 보더 표시 제어 (undefined=기본: xl/lg만 2px, true=항상 2px, false=없음) + * + * @param enableApi true면 업로드 -> PATCH까지 실행 (default true) + * @param authHeaders 토큰 기반 인증 헤더를 상위에서 주입 (upload + patch 둘 다 사용) + * 예) { Authorization: `Bearer ${token}` } + * @param uploadHeaders (하위호환) 업로드에만 붙일 헤더. authHeaders와 병합됨. + * + * @param priority above-the-fold일 때 true로 주면 LCP warning 줄어듦 (next/image) + * @param alt 이미지 alt + * @param className wrapper 클래스 추가 + * + + * - fetchApi는 JSON 전용(Content-Type 강제)이라 업로드(multipart)는 fetch로 유지 + */ + import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import type { ChangeEvent, CSSProperties } from 'react'; import Image from 'next/image'; @@ -14,9 +45,7 @@ import PencilSmall from '@/assets/buttons/edit/editButtonSmall.svg'; import TeamDefault from '@/assets/icons/img/img.svg'; import { fetchApi } from '@/shared/apis/fetchApi'; -import { TEAM_ID } from '@/shared/apis/config'; - -const BASE_URL: string = process.env.NEXT_PUBLIC_API_BASE_URL ?? ''; +import { BASE_URL, TEAM_ID } from '@/shared/apis/config'; export type ProfileImageSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type ProfileImageVariant = 'profile' | 'team'; @@ -35,32 +64,21 @@ export type ProfileImageProps = { responsiveSpec?: ResponsiveSpec; radius?: ProfileImageRadius; - /** edit on/off */ editable?: boolean; - - /** edit 버튼 표시 여부 (기본 true) */ showEditButton?: boolean; - - /** 버튼 숨길 때, 아바타 클릭으로 업로드 열기 */ clickToEdit?: boolean; - /** - * 보더 제어(오버라이드) - * - undefined: 기본(xl/lg만 2px) - * - true: 모든 사이즈 2px - * - false: 보더 없음 - */ showBorder?: boolean; - /** 업로드 + PATCH까지 실행할지 (기본 true) */ enableApi?: boolean; - /** 업로드 요청에 추가로 붙일 헤더 (ex. Authorization) */ + /** 업로드 + PATCH 공용 인증 헤더(권장) */ + authHeaders?: HeadersInit; + + /** (하위 호환) 업로드 전용 헤더 */ uploadHeaders?: HeadersInit; - /** LCP 경고 방지: above-the-fold에서만 true */ priority?: boolean; - alt?: string; className?: string; }; @@ -86,12 +104,8 @@ const BORDER_BY_SIZE: Record = { function getDefaultResponsiveSize( baseSize: ProfileImageSize, ): Record { - switch (baseSize) { - case 'xl': - return { base: 'lg', sm: 'lg', md: 'xl', lg: 'xl', xl: 'xl' }; - default: - return { base: baseSize, sm: baseSize, md: baseSize, lg: baseSize, xl: baseSize }; - } + if (baseSize === 'xl') return { base: 'lg', sm: 'lg', md: 'xl', lg: 'xl', xl: 'xl' }; + return { base: baseSize, sm: baseSize, md: baseSize, lg: baseSize, xl: baseSize }; } function resolveResponsiveSpec( @@ -175,15 +189,18 @@ export default function ProfileImage({ clickToEdit, showBorder, enableApi = true, + authHeaders, uploadHeaders, priority = false, alt = 'profile image', className, }: ProfileImageProps) { const inputRef = useRef(null); + const [previewUrl, setPreviewUrl] = useState(null); const [erroredSrc, setErroredSrc] = useState(null); + // blob url 정리 useEffect(() => { return () => { if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); @@ -220,11 +237,23 @@ export default function ProfileImage({ if (editable && shouldClickToEdit) handleEditClick(); }, [editable, shouldClickToEdit, handleEditClick]); - /** - * 이미지 업로드 - * @param file 업로드할 이미지 파일 - * @returns 업로드 후 반환된 이미지 URL - */ + const mergedUploadHeaders = useMemo(() => { + if (!authHeaders && !uploadHeaders) return undefined; + + const h = new Headers(); + + if (authHeaders) { + const a = new Headers(authHeaders); + a.forEach((v, k) => h.set(k, v)); + } + if (uploadHeaders) { + const u = new Headers(uploadHeaders); + u.forEach((v, k) => h.set(k, v)); + } + + return h; + }, [authHeaders, uploadHeaders]); + const uploadImage = useCallback( async (file: File) => { if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); @@ -235,11 +264,8 @@ export default function ProfileImage({ const res = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { method: 'POST', body: formData, - // ✅ 토큰 기반: Authorization 등은 상위에서 주입 - // ⚠️ FormData라 Content-Type을 직접 넣으면 안 됨 - headers: { - ...(uploadHeaders ?? {}), - }, + + headers: mergedUploadHeaders, }); if (!res.ok) { @@ -250,24 +276,24 @@ export default function ProfileImage({ const data = (await res.json()) as { url: string }; return data.url; }, - [uploadHeaders], + [mergedUploadHeaders], ); - /** - * 유저 프로필 이미지 PATCH - * @param url 업로드된 이미지 URL - */ - const patchUserImage = useCallback(async (url: string) => { - const res = await fetchApi(`/${TEAM_ID}/user`, { - method: 'PATCH', - body: JSON.stringify({ image: url }), - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`patch failed ${res.status}: ${text}`); - } - }, []); + const patchUserImage = useCallback( + async (url: string) => { + const res = await fetchApi('/user', { + method: 'PATCH', + body: JSON.stringify({ image: url }), + headers: authHeaders, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`patch failed ${res.status}: ${text}`); + } + }, + [authHeaders], + ); const handleFileChange = useCallback( async (e: ChangeEvent) => { @@ -288,7 +314,7 @@ export default function ProfileImage({ const url = await uploadImage(file); await patchUserImage(url); - // preview를 서버 url로 교체 + // 서버 url로 교체 setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return url; From a03850206222ebdcc020ae9f2f6ef4762186dfd3 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Sat, 31 Jan 2026 17:23:48 +0900 Subject: [PATCH 3/8] =?UTF-8?q?refactor:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=BB=B4=ED=8F=AC=EB=84=8C?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/page.tsx | 5 ++ .../profile-img/ProfileImage.module.css | 21 ++++---- src/components/profile-img/ProfileImage.tsx | 54 +++++++++++-------- 3 files changed, 46 insertions(+), 34 deletions(-) diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index 2649ff8..226ec7c 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -1,6 +1,11 @@ +'use client'; + +import ProfileImage from '@/components/profile-img/ProfileImage'; + export default function Home() { return (
+

프로젝트 준비

); diff --git a/src/components/profile-img/ProfileImage.module.css b/src/components/profile-img/ProfileImage.module.css index 2ecf349..c57ea3f 100644 --- a/src/components/profile-img/ProfileImage.module.css +++ b/src/components/profile-img/ProfileImage.module.css @@ -8,6 +8,7 @@ display: inline-block; } +/* box: 레이아웃 + edit 버튼 기준점 */ .box { width: var(--pi-box-base); height: var(--pi-box-base); @@ -17,14 +18,16 @@ justify-content: center; position: relative; - - border: 0 !important; - outline: 0 !important; - box-shadow: none !important; - overflow: visible; } +.outer .box { + border: 0; + outline: 0; + box-shadow: none; +} + +/* 라운드/클리핑은 mask에서만 처리 */ .mask { width: 100%; height: 100%; @@ -37,11 +40,7 @@ border-radius: inherit; } -.teamMask { - background: var(--color-background-tertiary); -} - -/* avatar */ +/* avatar = 이미지 영역 */ .avatar { width: var(--pi-img-base); height: var(--pi-img-base); @@ -89,7 +88,7 @@ border-radius: 32px; } -/* edit 버튼 */ +/* edit 버튼 (base~767: lg 취급) */ .editButton { position: absolute; right: 3px; diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx index 179dcae..4566136 100644 --- a/src/components/profile-img/ProfileImage.tsx +++ b/src/components/profile-img/ProfileImage.tsx @@ -3,10 +3,9 @@ /** * ProfileImage Component * - * * Props 요약 * @param src 표시할 이미지 URL (없으면 fallback) - * @param variant 'profile' | 'team' (마스크 배경색/기본 fallback 분기) + * @param variant 'profile' | 'team' (기본 fallback 분기 / (선택) teamMask 배경) * @param size 'xl' | 'lg' | 'md' | 'sm' | 'xs' (기본 스펙 프리셋) * @param responsiveSize 브레이크포인트별 size 오버라이드 (선택) * @param responsiveSpec 브레이크포인트별 box/image px 직접 지정 (선택) @@ -23,11 +22,13 @@ * 예) { Authorization: `Bearer ${token}` } * @param uploadHeaders (하위호환) 업로드에만 붙일 헤더. authHeaders와 병합됨. * + * @param onUploadedUrl 업로드 성공 후 URL 전달 (상위에서 variant별 PATCH 분기 가능) + * @param onError 업로드/패치 실패 시 상위로 에러 전달(토스트 등) + * * @param priority above-the-fold일 때 true로 주면 LCP warning 줄어듦 (next/image) * @param alt 이미지 alt * @param className wrapper 클래스 추가 * - * - fetchApi는 JSON 전용(Content-Type 강제)이라 업로드(multipart)는 fetch로 유지 */ @@ -78,11 +79,19 @@ export type ProfileImageProps = { /** (하위 호환) 업로드 전용 헤더 */ uploadHeaders?: HeadersInit; + /** 업로드 성공 시 URL 콜백 */ + onUploadedUrl?: (url: string) => void; + + /** 에러 콜백 */ + onError?: (error: unknown) => void; + priority?: boolean; alt?: string; className?: string; }; +const BORDER_WIDTH = 2; + const SIZE_PRESET: Record = { xl: { box: 112, image: 100 }, lg: { box: 78, image: 64 }, @@ -94,8 +103,8 @@ const SIZE_PRESET: Record = { const BP_ORDER: Breakpoint[] = ['base', 'sm', 'md', 'lg', 'xl']; const BORDER_BY_SIZE: Record = { - xl: 2, - lg: 2, + xl: BORDER_WIDTH, + lg: BORDER_WIDTH, md: 0, sm: 0, xs: 0, @@ -147,13 +156,13 @@ function resolveResponsiveBorder( } if (showBorder === true) { - for (const bp of BP_ORDER) result[bp] = 2; + for (const bp of BP_ORDER) result[bp] = BORDER_WIDTH; return result as Record; } for (const bp of BP_ORDER) { if (responsiveSpec?.[bp]) { - result[bp] = bp === 'lg' || bp === 'xl' ? 2 : 0; + result[bp] = bp === 'lg' || bp === 'xl' ? BORDER_WIDTH : 0; continue; } result[bp] = BORDER_BY_SIZE[sizeByBp[bp]]; @@ -191,6 +200,8 @@ export default function ProfileImage({ enableApi = true, authHeaders, uploadHeaders, + onUploadedUrl, + onError, priority = false, alt = 'profile image', className, @@ -200,7 +211,6 @@ export default function ProfileImage({ const [previewUrl, setPreviewUrl] = useState(null); const [erroredSrc, setErroredSrc] = useState(null); - // blob url 정리 useEffect(() => { return () => { if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); @@ -256,15 +266,12 @@ export default function ProfileImage({ const uploadImage = useCallback( async (file: File) => { - if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); - const formData = new FormData(); formData.append('image', file); const res = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { method: 'POST', body: formData, - headers: mergedUploadHeaders, }); @@ -281,6 +288,7 @@ export default function ProfileImage({ const patchUserImage = useCallback( async (url: string) => { + // fetchApi는 BASE_URL+TEAM_ID를 이미 붙여줌. path는 /user 로만. const res = await fetchApi('/user', { method: 'PATCH', body: JSON.stringify({ image: url }), @@ -302,7 +310,6 @@ export default function ProfileImage({ setErroredSrc(null); - // local preview const localUrl = URL.createObjectURL(file); setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); @@ -312,21 +319,26 @@ export default function ProfileImage({ if (enableApi) { try { const url = await uploadImage(file); + + // 상위에서 team/profile PATCH 분기하고 싶으면 여기서 처리 가능 + onUploadedUrl?.(url); + + // 기존 동작 유지: 기본은 user PATCH await patchUserImage(url); - // 서버 url로 교체 setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return url; }); } catch (err) { console.error(err); + onError?.(err); } } e.target.value = ''; }, - [enableApi, uploadImage, patchUserImage], + [enableApi, uploadImage, patchUserImage, onUploadedUrl, onError], ); const styleVars = useMemo(() => { @@ -370,14 +382,12 @@ export default function ProfileImage({
{ From 00286c1303c0daa9b19af037f1735438f997a15d Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Sat, 31 Jan 2026 17:42:32 +0900 Subject: [PATCH 4/8] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../profile-img/ProfileImage.module.css | 28 +++-- src/components/profile-img/ProfileImage.tsx | 114 +++++++++++++----- 2 files changed, 107 insertions(+), 35 deletions(-) diff --git a/src/components/profile-img/ProfileImage.module.css b/src/components/profile-img/ProfileImage.module.css index c57ea3f..870e7b7 100644 --- a/src/components/profile-img/ProfileImage.module.css +++ b/src/components/profile-img/ProfileImage.module.css @@ -8,7 +8,6 @@ display: inline-block; } -/* box: 레이아웃 + edit 버튼 기준점 */ .box { width: var(--pi-box-base); height: var(--pi-box-base); @@ -18,16 +17,14 @@ justify-content: center; position: relative; - overflow: visible; -} -.outer .box { border: 0; outline: 0; box-shadow: none; + + overflow: visible; } -/* 라운드/클리핑은 mask에서만 처리 */ .mask { width: 100%; height: 100%; @@ -40,7 +37,7 @@ border-radius: inherit; } -/* avatar = 이미지 영역 */ +/* avatar */ .avatar { width: var(--pi-img-base); height: var(--pi-img-base); @@ -56,8 +53,11 @@ cursor: pointer; } +/* border: breakpoint별 var로 대응 */ .avatarBorder { - border: 2px solid #e2e8f0; + border-style: solid; + border-color: #e2e8f0; + border-width: var(--pi-border-base); box-sizing: border-box; } @@ -88,7 +88,7 @@ border-radius: 32px; } -/* edit 버튼 (base~767: lg 취급) */ +/* edit 버튼 */ .editButton { position: absolute; right: 3px; @@ -126,6 +126,9 @@ width: var(--pi-img-sm); height: var(--pi-img-sm); } + .avatarBorder { + border-width: var(--pi-border-sm); + } } @media (max-width: 767px) { @@ -143,6 +146,9 @@ width: var(--pi-img-md); height: var(--pi-img-md); } + .avatarBorder { + border-width: var(--pi-border-md); + } .editButton { right: 0; @@ -167,6 +173,9 @@ width: var(--pi-img-lg); height: var(--pi-img-lg); } + .avatarBorder { + border-width: var(--pi-border-lg); + } } @media (min-width: 1280px) { @@ -178,4 +187,7 @@ width: var(--pi-img-xl); height: var(--pi-img-xl); } + .avatarBorder { + border-width: var(--pi-border-xl); + } } diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx index 4566136..f824bd9 100644 --- a/src/components/profile-img/ProfileImage.tsx +++ b/src/components/profile-img/ProfileImage.tsx @@ -3,9 +3,10 @@ /** * ProfileImage Component * + * * Props 요약 * @param src 표시할 이미지 URL (없으면 fallback) - * @param variant 'profile' | 'team' (기본 fallback 분기 / (선택) teamMask 배경) + * @param variant 'profile' | 'team' (마스크 배경색/기본 fallback 분기) * @param size 'xl' | 'lg' | 'md' | 'sm' | 'xs' (기본 스펙 프리셋) * @param responsiveSize 브레이크포인트별 size 오버라이드 (선택) * @param responsiveSpec 브레이크포인트별 box/image px 직접 지정 (선택) @@ -22,18 +23,19 @@ * 예) { Authorization: `Bearer ${token}` } * @param uploadHeaders (하위호환) 업로드에만 붙일 헤더. authHeaders와 병합됨. * - * @param onUploadedUrl 업로드 성공 후 URL 전달 (상위에서 variant별 PATCH 분기 가능) - * @param onError 업로드/패치 실패 시 상위로 에러 전달(토스트 등) - * * @param priority above-the-fold일 때 true로 주면 LCP warning 줄어듦 (next/image) * @param alt 이미지 alt * @param className wrapper 클래스 추가 * + * 추가: + * @param teamGroupId variant='team'일 때 PATCH /groups/{id} 호출에 필요한 그룹 id + * @param onError 업로드/패치 실패 시 상위에서 토스트 등 처리할 수 있게 콜백 제공 + * * - fetchApi는 JSON 전용(Content-Type 강제)이라 업로드(multipart)는 fetch로 유지 */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import type { ChangeEvent, CSSProperties } from 'react'; +import type { ChangeEvent, CSSProperties, KeyboardEvent } from 'react'; import Image from 'next/image'; import styles from './ProfileImage.module.css'; @@ -57,6 +59,8 @@ type SizeSpec = { box: number; image: number }; type ResponsiveSize = Partial>; type ResponsiveSpec = Partial>; +type ErrorStage = 'upload' | 'patch-profile' | 'patch-team'; + export type ProfileImageProps = { src?: string | null; variant?: ProfileImageVariant; @@ -73,18 +77,18 @@ export type ProfileImageProps = { enableApi?: boolean; + /** variant='team'에서 그룹 이미지 PATCH에 필요한 groupId */ + teamGroupId?: number; + + /** 업로드/패치 실패 시 상위에서 처리 (토스트 등) */ + onError?: (error: unknown, ctx: { stage: ErrorStage }) => void; + /** 업로드 + PATCH 공용 인증 헤더(권장) */ authHeaders?: HeadersInit; /** (하위 호환) 업로드 전용 헤더 */ uploadHeaders?: HeadersInit; - /** 업로드 성공 시 URL 콜백 */ - onUploadedUrl?: (url: string) => void; - - /** 에러 콜백 */ - onError?: (error: unknown) => void; - priority?: boolean; alt?: string; className?: string; @@ -198,10 +202,10 @@ export default function ProfileImage({ clickToEdit, showBorder, enableApi = true, + teamGroupId, + onError, authHeaders, uploadHeaders, - onUploadedUrl, - onError, priority = false, alt = 'profile image', className, @@ -211,6 +215,7 @@ export default function ProfileImage({ const [previewUrl, setPreviewUrl] = useState(null); const [erroredSrc, setErroredSrc] = useState(null); + // blob url 정리 useEffect(() => { return () => { if (previewUrl?.startsWith('blob:')) URL.revokeObjectURL(previewUrl); @@ -247,6 +252,17 @@ export default function ProfileImage({ if (editable && shouldClickToEdit) handleEditClick(); }, [editable, shouldClickToEdit, handleEditClick]); + const handleKeyDown = useCallback( + (e: KeyboardEvent) => { + if (!editable || !shouldClickToEdit) return; + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + handleEditClick(); + } + }, + [editable, shouldClickToEdit, handleEditClick], + ); + const mergedUploadHeaders = useMemo(() => { if (!authHeaders && !uploadHeaders) return undefined; @@ -266,6 +282,8 @@ export default function ProfileImage({ const uploadImage = useCallback( async (file: File) => { + if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); + const formData = new FormData(); formData.append('image', file); @@ -286,9 +304,8 @@ export default function ProfileImage({ [mergedUploadHeaders], ); - const patchUserImage = useCallback( + const patchProfileImage = useCallback( async (url: string) => { - // fetchApi는 BASE_URL+TEAM_ID를 이미 붙여줌. path는 /user 로만. const res = await fetchApi('/user', { method: 'PATCH', body: JSON.stringify({ image: url }), @@ -297,7 +314,23 @@ export default function ProfileImage({ if (!res.ok) { const text = await res.text(); - throw new Error(`patch failed ${res.status}: ${text}`); + throw new Error(`patch profile failed ${res.status}: ${text}`); + } + }, + [authHeaders], + ); + + const patchTeamImage = useCallback( + async (url: string, groupId: number) => { + const res = await fetchApi(`/groups/${groupId}`, { + method: 'PATCH', + body: JSON.stringify({ image: url }), + headers: authHeaders, + }); + + if (!res.ok) { + const text = await res.text(); + throw new Error(`patch team failed ${res.status}: ${text}`); } }, [authHeaders], @@ -310,6 +343,9 @@ export default function ProfileImage({ setErroredSrc(null); + const prevPreview = previewUrl; + + // local preview const localUrl = URL.createObjectURL(file); setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); @@ -320,25 +356,48 @@ export default function ProfileImage({ try { const url = await uploadImage(file); - // 상위에서 team/profile PATCH 분기하고 싶으면 여기서 처리 가능 - onUploadedUrl?.(url); - - // 기존 동작 유지: 기본은 user PATCH - await patchUserImage(url); + if (variant === 'team') { + if (!teamGroupId) { + throw new Error('teamGroupId is required when variant="team" and enableApi=true'); + } + await patchTeamImage(url, teamGroupId); + } else { + await patchProfileImage(url); + } + // 서버 url로 교체 setPreviewUrl((prev) => { if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); return url; }); } catch (err) { + // 실패 시 사용자가 "바뀐 줄" 착각하지 않게 되돌림 + setPreviewUrl(prevPreview ?? null); + + const stage: ErrorStage = + err instanceof Error && err.message.includes('upload failed') + ? 'upload' + : variant === 'team' + ? 'patch-team' + : 'patch-profile'; + + onError?.(err, { stage }); console.error(err); - onError?.(err); } } e.target.value = ''; }, - [enableApi, uploadImage, patchUserImage, onUploadedUrl, onError], + [ + enableApi, + uploadImage, + patchProfileImage, + patchTeamImage, + variant, + teamGroupId, + onError, + previewUrl, + ], ); const styleVars = useMemo(() => { @@ -381,14 +440,13 @@ export default function ProfileImage({
-
+
{ From 4bb1ef63c790403a5f39dc7984d7e1a100de21a5 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Mon, 2 Feb 2026 11:32:33 +0900 Subject: [PATCH 5/8] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/profile-img/ProfileImage.tsx | 68 +++++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx index f824bd9..185e66b 100644 --- a/src/components/profile-img/ProfileImage.tsx +++ b/src/components/profile-img/ProfileImage.tsx @@ -31,7 +31,11 @@ * @param teamGroupId variant='team'일 때 PATCH /groups/{id} 호출에 필요한 그룹 id * @param onError 업로드/패치 실패 시 상위에서 토스트 등 처리할 수 있게 콜백 제공 * - * - fetchApi는 JSON 전용(Content-Type 강제)이라 업로드(multipart)는 fetch로 유지 + * 아토믹 대응 추가: + * @param apiBaseUrl API Base URL (예: process.env.NEXT_PUBLIC_API_BASE_URL) + * @param teamId teamId (예: "20-1") + * + * - 업로드(multipart)는 fetch로 유지 */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -47,9 +51,6 @@ import PencilSmall from '@/assets/buttons/edit/editButtonSmall.svg'; import TeamDefault from '@/assets/icons/img/img.svg'; -import { fetchApi } from '@/shared/apis/fetchApi'; -import { BASE_URL, TEAM_ID } from '@/shared/apis/config'; - export type ProfileImageSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type ProfileImageVariant = 'profile' | 'team'; export type ProfileImageRadius = 'r8' | 'r12' | 'r20' | 'r32'; @@ -77,6 +78,10 @@ export type ProfileImageProps = { enableApi?: boolean; + /** 아토믹 대응: API base url / team id */ + apiBaseUrl?: string; + teamId?: string; + /** variant='team'에서 그룹 이미지 PATCH에 필요한 groupId */ teamGroupId?: number; @@ -114,6 +119,27 @@ const BORDER_BY_SIZE: Record = { xs: 0, }; +function stripTrailingSlash(url: string) { + return url.endsWith('/') ? url.slice(0, -1) : url; +} + +function buildTeamUrl(apiBaseUrl: string, teamId: string, path: string) { + const base = stripTrailingSlash(apiBaseUrl); + const p = path.startsWith('/') ? path : `/${path}`; + return `${base}/${teamId}${p}`; +} + +async function fetchJson(url: string, options: RequestInit = {}) { + const headers = new Headers(options.headers); + + // PATCH/POST JSON만 Content-Type 강제 + if (options.body != null && typeof options.body === 'string' && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + + return fetch(url, { ...options, headers }); +} + function getDefaultResponsiveSize( baseSize: ProfileImageSize, ): Record { @@ -202,6 +228,8 @@ export default function ProfileImage({ clickToEdit, showBorder, enableApi = true, + apiBaseUrl, + teamId, teamGroupId, onError, authHeaders, @@ -282,12 +310,16 @@ export default function ProfileImage({ const uploadImage = useCallback( async (file: File) => { - if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); + const base = apiBaseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL; + const tId = teamId; + + if (!base) throw new Error('apiBaseUrl is missing'); + if (!tId) throw new Error('teamId is missing'); const formData = new FormData(); formData.append('image', file); - const res = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { + const res = await fetch(`${stripTrailingSlash(base)}/${tId}/images/upload`, { method: 'POST', body: formData, headers: mergedUploadHeaders, @@ -301,12 +333,18 @@ export default function ProfileImage({ const data = (await res.json()) as { url: string }; return data.url; }, - [mergedUploadHeaders], + [apiBaseUrl, teamId, mergedUploadHeaders], ); const patchProfileImage = useCallback( async (url: string) => { - const res = await fetchApi('/user', { + const base = apiBaseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL; + const tId = teamId; + + if (!base) throw new Error('apiBaseUrl is missing'); + if (!tId) throw new Error('teamId is missing'); + + const res = await fetchJson(buildTeamUrl(base, tId, '/user'), { method: 'PATCH', body: JSON.stringify({ image: url }), headers: authHeaders, @@ -317,12 +355,18 @@ export default function ProfileImage({ throw new Error(`patch profile failed ${res.status}: ${text}`); } }, - [authHeaders], + [apiBaseUrl, teamId, authHeaders], ); const patchTeamImage = useCallback( async (url: string, groupId: number) => { - const res = await fetchApi(`/groups/${groupId}`, { + const base = apiBaseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL; + const tId = teamId; + + if (!base) throw new Error('apiBaseUrl is missing'); + if (!tId) throw new Error('teamId is missing'); + + const res = await fetchJson(buildTeamUrl(base, tId, `/groups/${groupId}`), { method: 'PATCH', body: JSON.stringify({ image: url }), headers: authHeaders, @@ -333,7 +377,7 @@ export default function ProfileImage({ throw new Error(`patch team failed ${res.status}: ${text}`); } }, - [authHeaders], + [apiBaseUrl, teamId, authHeaders], ); const handleFileChange = useCallback( @@ -371,7 +415,7 @@ export default function ProfileImage({ return url; }); } catch (err) { - // 실패 시 사용자가 "바뀐 줄" 착각하지 않게 되돌림 + // 실패 시 원복 setPreviewUrl(prevPreview ?? null); const stage: ErrorStage = From f0046075f1acead1eb67d8e525dbfd65e1fc77b1 Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Mon, 2 Feb 2026 11:38:32 +0900 Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20page.tsx=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=20=20=EB=B6=88=ED=95=84=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/page.tsx | 1 - src/components/profile-img/ProfileImage.tsx | 68 ++++----------------- 2 files changed, 12 insertions(+), 57 deletions(-) diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index 226ec7c..f115b26 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -5,7 +5,6 @@ import ProfileImage from '@/components/profile-img/ProfileImage'; export default function Home() { return (
-

프로젝트 준비

); diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx index 185e66b..f824bd9 100644 --- a/src/components/profile-img/ProfileImage.tsx +++ b/src/components/profile-img/ProfileImage.tsx @@ -31,11 +31,7 @@ * @param teamGroupId variant='team'일 때 PATCH /groups/{id} 호출에 필요한 그룹 id * @param onError 업로드/패치 실패 시 상위에서 토스트 등 처리할 수 있게 콜백 제공 * - * 아토믹 대응 추가: - * @param apiBaseUrl API Base URL (예: process.env.NEXT_PUBLIC_API_BASE_URL) - * @param teamId teamId (예: "20-1") - * - * - 업로드(multipart)는 fetch로 유지 + * - fetchApi는 JSON 전용(Content-Type 강제)이라 업로드(multipart)는 fetch로 유지 */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -51,6 +47,9 @@ import PencilSmall from '@/assets/buttons/edit/editButtonSmall.svg'; import TeamDefault from '@/assets/icons/img/img.svg'; +import { fetchApi } from '@/shared/apis/fetchApi'; +import { BASE_URL, TEAM_ID } from '@/shared/apis/config'; + export type ProfileImageSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type ProfileImageVariant = 'profile' | 'team'; export type ProfileImageRadius = 'r8' | 'r12' | 'r20' | 'r32'; @@ -78,10 +77,6 @@ export type ProfileImageProps = { enableApi?: boolean; - /** 아토믹 대응: API base url / team id */ - apiBaseUrl?: string; - teamId?: string; - /** variant='team'에서 그룹 이미지 PATCH에 필요한 groupId */ teamGroupId?: number; @@ -119,27 +114,6 @@ const BORDER_BY_SIZE: Record = { xs: 0, }; -function stripTrailingSlash(url: string) { - return url.endsWith('/') ? url.slice(0, -1) : url; -} - -function buildTeamUrl(apiBaseUrl: string, teamId: string, path: string) { - const base = stripTrailingSlash(apiBaseUrl); - const p = path.startsWith('/') ? path : `/${path}`; - return `${base}/${teamId}${p}`; -} - -async function fetchJson(url: string, options: RequestInit = {}) { - const headers = new Headers(options.headers); - - // PATCH/POST JSON만 Content-Type 강제 - if (options.body != null && typeof options.body === 'string' && !headers.has('Content-Type')) { - headers.set('Content-Type', 'application/json'); - } - - return fetch(url, { ...options, headers }); -} - function getDefaultResponsiveSize( baseSize: ProfileImageSize, ): Record { @@ -228,8 +202,6 @@ export default function ProfileImage({ clickToEdit, showBorder, enableApi = true, - apiBaseUrl, - teamId, teamGroupId, onError, authHeaders, @@ -310,16 +282,12 @@ export default function ProfileImage({ const uploadImage = useCallback( async (file: File) => { - const base = apiBaseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL; - const tId = teamId; - - if (!base) throw new Error('apiBaseUrl is missing'); - if (!tId) throw new Error('teamId is missing'); + if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); const formData = new FormData(); formData.append('image', file); - const res = await fetch(`${stripTrailingSlash(base)}/${tId}/images/upload`, { + const res = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { method: 'POST', body: formData, headers: mergedUploadHeaders, @@ -333,18 +301,12 @@ export default function ProfileImage({ const data = (await res.json()) as { url: string }; return data.url; }, - [apiBaseUrl, teamId, mergedUploadHeaders], + [mergedUploadHeaders], ); const patchProfileImage = useCallback( async (url: string) => { - const base = apiBaseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL; - const tId = teamId; - - if (!base) throw new Error('apiBaseUrl is missing'); - if (!tId) throw new Error('teamId is missing'); - - const res = await fetchJson(buildTeamUrl(base, tId, '/user'), { + const res = await fetchApi('/user', { method: 'PATCH', body: JSON.stringify({ image: url }), headers: authHeaders, @@ -355,18 +317,12 @@ export default function ProfileImage({ throw new Error(`patch profile failed ${res.status}: ${text}`); } }, - [apiBaseUrl, teamId, authHeaders], + [authHeaders], ); const patchTeamImage = useCallback( async (url: string, groupId: number) => { - const base = apiBaseUrl ?? process.env.NEXT_PUBLIC_API_BASE_URL; - const tId = teamId; - - if (!base) throw new Error('apiBaseUrl is missing'); - if (!tId) throw new Error('teamId is missing'); - - const res = await fetchJson(buildTeamUrl(base, tId, `/groups/${groupId}`), { + const res = await fetchApi(`/groups/${groupId}`, { method: 'PATCH', body: JSON.stringify({ image: url }), headers: authHeaders, @@ -377,7 +333,7 @@ export default function ProfileImage({ throw new Error(`patch team failed ${res.status}: ${text}`); } }, - [apiBaseUrl, teamId, authHeaders], + [authHeaders], ); const handleFileChange = useCallback( @@ -415,7 +371,7 @@ export default function ProfileImage({ return url; }); } catch (err) { - // 실패 시 원복 + // 실패 시 사용자가 "바뀐 줄" 착각하지 않게 되돌림 setPreviewUrl(prevPreview ?? null); const stage: ErrorStage = From 694815a0cb8c7026bd89536c29aaf431bb37285b Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Mon, 2 Feb 2026 11:53:57 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=EC=82=AD=EC=A0=9C=EB=AA=BB=ED=95=9C=20=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/page.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index f115b26..cfeabf1 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -1,7 +1,5 @@ 'use client'; -import ProfileImage from '@/components/profile-img/ProfileImage'; - export default function Home() { return (
From acfc6e123aa5a005ec94a46de182290c670145ba Mon Sep 17 00:00:00 2001 From: HWAN0218 Date: Tue, 3 Feb 2026 15:50:35 +0900 Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20api=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=EC=83=89=EC=83=81=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/(root)/page.tsx | 4 +- .../profile-img/ProfileImage.module.css | 10 +- src/components/profile-img/ProfileImage.tsx | 173 ++---------------- 3 files changed, 22 insertions(+), 165 deletions(-) diff --git a/src/app/(root)/page.tsx b/src/app/(root)/page.tsx index cfeabf1..b02535f 100644 --- a/src/app/(root)/page.tsx +++ b/src/app/(root)/page.tsx @@ -1,9 +1,7 @@ -'use client'; - export default function Home() { return (

프로젝트 준비

); -} +} \ No newline at end of file diff --git a/src/components/profile-img/ProfileImage.module.css b/src/components/profile-img/ProfileImage.module.css index 870e7b7..d509485 100644 --- a/src/components/profile-img/ProfileImage.module.css +++ b/src/components/profile-img/ProfileImage.module.css @@ -56,7 +56,7 @@ /* border: breakpoint별 var로 대응 */ .avatarBorder { border-style: solid; - border-color: #e2e8f0; + border-color: #e2e8f0; border-width: var(--pi-border-base); box-sizing: border-box; } @@ -94,9 +94,9 @@ right: 3px; bottom: 6px; - background: var(--color-background-secondary); + background: var(--color-background-tertiary); border-radius: 50%; - border: none; + border: none; padding: 0; cursor: pointer; @@ -137,6 +137,7 @@ } } +/* md 이상 */ @media (min-width: 768px) { .box { width: var(--pi-box-md); @@ -154,6 +155,9 @@ right: 0; width: 32px; height: 32px; + + /* ✅ md 버전에서만 흰색 보더 */ + border: 1px solid var(--color-background-inverse); } .pencilSmall { diff --git a/src/components/profile-img/ProfileImage.tsx b/src/components/profile-img/ProfileImage.tsx index f824bd9..ce4daaf 100644 --- a/src/components/profile-img/ProfileImage.tsx +++ b/src/components/profile-img/ProfileImage.tsx @@ -1,37 +1,29 @@ 'use client'; /** - * ProfileImage Component + * ProfileImage Component (Atomic) * + * - 파일 선택은 onChange로 상위에 전달 * - * Props 요약 + * Props * @param src 표시할 이미지 URL (없으면 fallback) - * @param variant 'profile' | 'team' (마스크 배경색/기본 fallback 분기) - * @param size 'xl' | 'lg' | 'md' | 'sm' | 'xs' (기본 스펙 프리셋) + * @param variant 'profile' | 'team' (fallback 분기) + * @param size 'xl' | 'lg' | 'md' | 'sm' | 'xs' * @param responsiveSize 브레이크포인트별 size 오버라이드 (선택) * @param responsiveSpec 브레이크포인트별 box/image px 직접 지정 (선택) - * @param radius 'r8' | 'r12' | 'r20' | 'r32' (컨테이너/마스크/이미지 동일 적용) + * @param radius 'r8' | 'r12' | 'r20' | 'r32' * * @param editable true면 input(file) 활성화 + edit 버튼 표시 가능 - * @param showEditButton edit 버튼 자체 표시 여부 (default true) + * @param showEditButton edit 버튼 표시 여부 (default true) * @param clickToEdit showEditButton=false일 때 avatar 클릭으로 업로드 열기 (선택) * * @param showBorder 보더 표시 제어 (undefined=기본: xl/lg만 2px, true=항상 2px, false=없음) * - * @param enableApi true면 업로드 -> PATCH까지 실행 (default true) - * @param authHeaders 토큰 기반 인증 헤더를 상위에서 주입 (upload + patch 둘 다 사용) - * 예) { Authorization: `Bearer ${token}` } - * @param uploadHeaders (하위호환) 업로드에만 붙일 헤더. authHeaders와 병합됨. + * @param onFileChange 파일 선택 시 상위로 File 전달 (API는 상위에서 처리) * - * @param priority above-the-fold일 때 true로 주면 LCP warning 줄어듦 (next/image) + * @param priority above-the-fold일 때 true (next/image) * @param alt 이미지 alt * @param className wrapper 클래스 추가 - * - * 추가: - * @param teamGroupId variant='team'일 때 PATCH /groups/{id} 호출에 필요한 그룹 id - * @param onError 업로드/패치 실패 시 상위에서 토스트 등 처리할 수 있게 콜백 제공 - * - * - fetchApi는 JSON 전용(Content-Type 강제)이라 업로드(multipart)는 fetch로 유지 */ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; @@ -47,9 +39,6 @@ import PencilSmall from '@/assets/buttons/edit/editButtonSmall.svg'; import TeamDefault from '@/assets/icons/img/img.svg'; -import { fetchApi } from '@/shared/apis/fetchApi'; -import { BASE_URL, TEAM_ID } from '@/shared/apis/config'; - export type ProfileImageSize = 'xl' | 'lg' | 'md' | 'sm' | 'xs'; export type ProfileImageVariant = 'profile' | 'team'; export type ProfileImageRadius = 'r8' | 'r12' | 'r20' | 'r32'; @@ -59,8 +48,6 @@ type SizeSpec = { box: number; image: number }; type ResponsiveSize = Partial>; type ResponsiveSpec = Partial>; -type ErrorStage = 'upload' | 'patch-profile' | 'patch-team'; - export type ProfileImageProps = { src?: string | null; variant?: ProfileImageVariant; @@ -75,19 +62,7 @@ export type ProfileImageProps = { showBorder?: boolean; - enableApi?: boolean; - - /** variant='team'에서 그룹 이미지 PATCH에 필요한 groupId */ - teamGroupId?: number; - - /** 업로드/패치 실패 시 상위에서 처리 (토스트 등) */ - onError?: (error: unknown, ctx: { stage: ErrorStage }) => void; - - /** 업로드 + PATCH 공용 인증 헤더(권장) */ - authHeaders?: HeadersInit; - - /** (하위 호환) 업로드 전용 헤더 */ - uploadHeaders?: HeadersInit; + onFileChange?: (file: File) => void; priority?: boolean; alt?: string; @@ -201,11 +176,7 @@ export default function ProfileImage({ showEditButton = true, clickToEdit, showBorder, - enableApi = true, - teamGroupId, - onError, - authHeaders, - uploadHeaders, + onFileChange, priority = false, alt = 'profile image', className, @@ -263,88 +234,13 @@ export default function ProfileImage({ [editable, shouldClickToEdit, handleEditClick], ); - const mergedUploadHeaders = useMemo(() => { - if (!authHeaders && !uploadHeaders) return undefined; - - const h = new Headers(); - - if (authHeaders) { - const a = new Headers(authHeaders); - a.forEach((v, k) => h.set(k, v)); - } - if (uploadHeaders) { - const u = new Headers(uploadHeaders); - u.forEach((v, k) => h.set(k, v)); - } - - return h; - }, [authHeaders, uploadHeaders]); - - const uploadImage = useCallback( - async (file: File) => { - if (!BASE_URL) throw new Error('NEXT_PUBLIC_API_BASE_URL is missing'); - - const formData = new FormData(); - formData.append('image', file); - - const res = await fetch(`${BASE_URL}/${TEAM_ID}/images/upload`, { - method: 'POST', - body: formData, - headers: mergedUploadHeaders, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`upload failed ${res.status}: ${text}`); - } - - const data = (await res.json()) as { url: string }; - return data.url; - }, - [mergedUploadHeaders], - ); - - const patchProfileImage = useCallback( - async (url: string) => { - const res = await fetchApi('/user', { - method: 'PATCH', - body: JSON.stringify({ image: url }), - headers: authHeaders, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`patch profile failed ${res.status}: ${text}`); - } - }, - [authHeaders], - ); - - const patchTeamImage = useCallback( - async (url: string, groupId: number) => { - const res = await fetchApi(`/groups/${groupId}`, { - method: 'PATCH', - body: JSON.stringify({ image: url }), - headers: authHeaders, - }); - - if (!res.ok) { - const text = await res.text(); - throw new Error(`patch team failed ${res.status}: ${text}`); - } - }, - [authHeaders], - ); - const handleFileChange = useCallback( - async (e: ChangeEvent) => { + (e: ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; setErroredSrc(null); - const prevPreview = previewUrl; - // local preview const localUrl = URL.createObjectURL(file); setPreviewUrl((prev) => { @@ -352,52 +248,11 @@ export default function ProfileImage({ return localUrl; }); - if (enableApi) { - try { - const url = await uploadImage(file); - - if (variant === 'team') { - if (!teamGroupId) { - throw new Error('teamGroupId is required when variant="team" and enableApi=true'); - } - await patchTeamImage(url, teamGroupId); - } else { - await patchProfileImage(url); - } - - // 서버 url로 교체 - setPreviewUrl((prev) => { - if (prev?.startsWith('blob:')) URL.revokeObjectURL(prev); - return url; - }); - } catch (err) { - // 실패 시 사용자가 "바뀐 줄" 착각하지 않게 되돌림 - setPreviewUrl(prevPreview ?? null); - - const stage: ErrorStage = - err instanceof Error && err.message.includes('upload failed') - ? 'upload' - : variant === 'team' - ? 'patch-team' - : 'patch-profile'; - - onError?.(err, { stage }); - console.error(err); - } - } + onFileChange?.(file); e.target.value = ''; }, - [ - enableApi, - uploadImage, - patchProfileImage, - patchTeamImage, - variant, - teamGroupId, - onError, - previewUrl, - ], + [onFileChange], ); const styleVars = useMemo(() => {