From d75d42283bad16102d0d0bcc74d8e01e611ab534 Mon Sep 17 00:00:00 2001 From: fryzke Date: Fri, 20 Feb 2026 23:35:38 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=EB=A7=88=EC=9D=B4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=A7=80=20=ED=83=AD=20=EC=8B=9C=EC=8A=A4=ED=85=9C=20?= =?UTF-8?q?=EA=B5=AC=EC=B6=95=20=EB=B0=8F=20=ED=99=9C=EB=8F=99=20=EB=82=B4?= =?UTF-8?q?=EC=97=AD=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 프로필 설정 및 활동 내역(좋아요/내가 쓴 글) 탭 분리 - URL 파라미터 연동으로 새로고침 시에도 탭 상태 보존 - Relative Time(상대 시간) 로직 적용 및 카드 클릭 핸들러 추가 - 이벤트 전파 방지를 통한 복사 버튼 기능 독립성 확보 --- app/(afterLogin)/mypage/MypageActivity.tsx | 125 ++++++++++ app/(afterLogin)/mypage/MypageLeftSection.tsx | 99 ++++++++ .../mypage/MypageRightSection.tsx | 221 ++++++++++++++++++ app/(afterLogin)/mypage/layout.tsx | 14 ++ app/(afterLogin)/mypage/page.tsx | 74 +++++- .../(auth)/signup/component/Detail.tsx | 7 +- .../(auth)/signup/component/StatusSelect.tsx | 4 +- .../signup/component/TargetJobsSelect.tsx | 6 +- app/types/type.ts | 15 ++ components/mypage/articleCard.tsx | 76 ++++++ components/mypage/user-experience.tsx | 86 +++++++ components/mypage/user-keyword.tsx | 124 ++++++++++ components/mypage/user-profile.tsx | 95 ++++++++ lib/utils.ts | 7 + package-lock.json | 11 + package.json | 1 + proxy.ts | 2 +- stories/Mypage/UserExperience.stories.tsx | 104 +++++++++ stories/Mypage/UserKeyword.stories.tsx | 106 +++++++++ stories/Mypage/UserProfile.stories.tsx | 58 +++++ 20 files changed, 1228 insertions(+), 7 deletions(-) create mode 100644 app/(afterLogin)/mypage/MypageActivity.tsx create mode 100644 app/(afterLogin)/mypage/MypageLeftSection.tsx create mode 100644 app/(afterLogin)/mypage/MypageRightSection.tsx create mode 100644 app/(afterLogin)/mypage/layout.tsx create mode 100644 components/mypage/articleCard.tsx create mode 100644 components/mypage/user-experience.tsx create mode 100644 components/mypage/user-keyword.tsx create mode 100644 components/mypage/user-profile.tsx create mode 100644 stories/Mypage/UserExperience.stories.tsx create mode 100644 stories/Mypage/UserKeyword.stories.tsx create mode 100644 stories/Mypage/UserProfile.stories.tsx diff --git a/app/(afterLogin)/mypage/MypageActivity.tsx b/app/(afterLogin)/mypage/MypageActivity.tsx new file mode 100644 index 0000000..d16a297 --- /dev/null +++ b/app/(afterLogin)/mypage/MypageActivity.tsx @@ -0,0 +1,125 @@ +"use client"; + +import { useRouter, useSearchParams } from "next/navigation"; + +import { LikedPrompt, MyPrompt } from "@/app/types/type"; +import { + LikedArticleCard, + MyArticleCard, +} from "@/components/mypage/articleCard"; +import { toasts } from "@/components/shared/toast"; +import { cn } from "@/lib/utils"; + +export default function MypageActivitySection() { + const router = useRouter(); + const searchParams = useSearchParams(); + + const activeSub = searchParams.get("sub") || "liked"; + + const handleSubTabChange = (sub: "liked" | "posted") => { + const params = new URLSearchParams(searchParams.toString()); + params.set("sub", sub); + router.push(`/mypage?${params.toString()}`, { scroll: false }); + }; + + const handleCopy = async (e: React.MouseEvent, content: string) => { + e.stopPropagation(); // 카드의 onClick이 실행되지 않도록 차단 + try { + await navigator.clipboard.writeText(content); + toasts.success("프롬프트가 클립보드에 복사되었습니다!"); + } catch { + alert("복사에 실패했습니다."); + } + }; + + const handleCardClick = (id: number) => { + router.push(`/prompts/${id}`); + }; + + // 실제 API 데이터 구조에 맞춘 임시 데이터 + // 1. 좋아요 한 프롬프트 데이터 + const mockLikedPrompts = [ + { + promptId: 1024, + title: "좋아요 - 자소서를 위한 GPT 프롬프트", + description: "이 프롬프트는 좋아요 탭에서만 보입니다.", + isLiked: true, + }, + { + promptId: 1025, + title: "좋아요 - 면접 대비 프롬프트", + description: "상세한 설명이 들어가는 영역입니다.", + isLiked: true, + }, + ]; + + // 2. 내가 게시한 프롬프트 데이터 (createdAt 포함) + const mockMyPrompts = [ + { + promptId: 2048, + title: "게시글 - 나만의 비밀 프롬프트", + description: "내가 직접 작성해서 게시한 프롬프트입니다.", + createdAt: "2026-02-18T10:00:00", // n시간 전/일 전 테스트용 + }, + { + promptId: 2049, + title: "게시글 - 효율적인 코딩 프롬프트", + description: "코딩 효율을 200% 높여주는 마법의 문장들.", + createdAt: "2026-01-20T13:50:00", + }, + ]; + const currentList = activeSub === "liked" ? mockLikedPrompts : mockMyPrompts; + + return ( +
+ {/* 서브 탭 메뉴 */} +
+ + +
+ + {/* 리스트 영역 */} +
+ {activeSub === "liked" + ? (mockLikedPrompts as LikedPrompt[]).map((prompt) => ( + handleCopy(e, prompt.description)} + onClick={() => handleCardClick(prompt.promptId)} + /> + )) + : (mockMyPrompts as MyPrompt[]).map((prompt) => ( + handleCardClick(prompt.promptId)} + /> + ))} +
+
+ ); +} diff --git a/app/(afterLogin)/mypage/MypageLeftSection.tsx b/app/(afterLogin)/mypage/MypageLeftSection.tsx new file mode 100644 index 0000000..ca4802c --- /dev/null +++ b/app/(afterLogin)/mypage/MypageLeftSection.tsx @@ -0,0 +1,99 @@ +"use client"; + +import { useRouter } from "next/navigation"; +import { useEffect, useState } from "react"; + +import UserProfile from "@/components/mypage/user-profile"; +import { BaseButton } from "@/components/shared/button"; + +interface UserData { + nickname: string; + email: string; + profileImage: string; + introduction: string; + provider: "KAKAO" | "NAVER"; +} + +export default function MypageLeftSection() { + const router = useRouter(); + const [userData, setUserData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadUserData = async () => { + try { + //나중에 다른것들처럼 mock api생성 후 연결 예정 + // 테스트용 더미 데이터 + setUserData({ + nickname: "개구리", + email: "frog@example.com", + profileImage: "", + introduction: "반갑습니다!", + provider: "KAKAO", + }); + } catch (error) { + console.error("유저 정보 로드 실패:", error); + } finally { + setIsLoading(false); + } + }; + + loadUserData(); + }, []); + + const handleLogout = async () => { + if (confirm("로그아웃 하시겠습니까?")) { + try { + //로그아웃 호출 + + alert("로그아웃 되었습니다."); + router.push("/"); //로그아웃되고 어느 페이지로 이동? + router.refresh(); + } catch (error) { + console.error("Logout failed:", error); + } + } + }; + + if (isLoading) return
로딩 중...
; + if (!userData) return
유저 정보가 없습니다.
; + + const handleWithdraw = async () => { + const isConfirmed = confirm( + "정말로 탈퇴하시겠습니까?\n탈퇴 시 모든 데이터가 삭제되며 복구할 수 없습니다." + ); + + if (isConfirmed) { + try { + // 탈퇴 기능 호출 + alert("회원 탈퇴가 완료되었습니다."); + router.push("/"); + } catch (error) { + alert("탈퇴 처리 중 오류가 발생했습니다."); + } + } + }; + return ( +
+ + + + 로그아웃 + + + +
+ ); +} diff --git a/app/(afterLogin)/mypage/MypageRightSection.tsx b/app/(afterLogin)/mypage/MypageRightSection.tsx new file mode 100644 index 0000000..ba57694 --- /dev/null +++ b/app/(afterLogin)/mypage/MypageRightSection.tsx @@ -0,0 +1,221 @@ +"use client"; + +import Link from "next/link"; +import { useState } from "react"; + +import { + EDUCATION_OPTIONS, + JobType, + STATE_VALUES, +} from "@/app/(beforeLogin)/(auth)/constant"; +import { StatusSelect } from "@/app/(beforeLogin)/(auth)/signup/component/StatusSelect"; +import { TargetJobsSelect } from "@/app/(beforeLogin)/(auth)/signup/component/TargetJobsSelect"; +import { useSignupStore } from "@/app/store/signUpStore"; +import UserExperience from "@/components/mypage/user-experience"; +import UserKeyword from "@/components/mypage/user-keyword"; +import { BaseButton } from "@/components/shared/button"; +import { BaseCheckBox } from "@/components/shared/checkbox"; +import { BaseInput } from "@/components/shared/inputs"; +import { SelectBox } from "@/components/shared/select-box"; + +export default function MypageRightSection() { + const [otherInput, setOtherInput] = useState(""); + const [experiences, setExperiences] = useState([]); + const [keywords, setKeywords] = useState([]); + + // 개인정보 마케팅 동의 상태 (예시) + const [marketingAgree, setMarketingAgree] = useState(false); + + const { + targetJobs, + setTargetJobs, + currentState, + career, + educationLevel, + updateField, + } = useSignupStore(); + + // 직무 클릭 핸들러 + const handleJobClick = (option: string) => { + const castedOption = option as JobType; + if (targetJobs.includes(castedOption)) { + setTargetJobs(targetJobs.filter((j) => j !== castedOption)); + } else { + setTargetJobs([...targetJobs, castedOption]); + } + }; + + // 섹션별 저장 핸들러 + const handleSaveEmployment = async () => { + const payload = { + currentState: + currentState === STATE_VALUES.OTHER ? otherInput : currentState, + targetJobs, + educationLevel, + career, + }; + console.log("취업/경력 저장:", payload); + alert("취업/경력 정보가 저장되었습니다."); + }; + + const handleSaveProfile = async () => { + const payload = { experiences, keywords }; + console.log("프로필 저장:", payload); + alert("프로필 정보가 저장되었습니다."); + }; + + const handleSavePrivacy = async () => { + console.log("개인정보 설정 저장:", { marketingAgree }); + alert("개인정보 설정이 변경되었습니다."); + }; + + const handleCancel = () => { + if (confirm("변경사항이 저장되지 않을 수 있습니다. 취소하시겠습니까?")) { + window.location.reload(); + } + }; + + return ( +
+ {/* 1. 취업/경력 섹션 */} +
+

{"취업/경력"}

+ { + updateField("currentState", val); + if (val !== STATE_VALUES.OTHER) setOtherInput(""); + }} + onOtherChange={setOtherInput} + /> + +
+
+ + updateField("educationLevel", val)} + selectOptions={EDUCATION_OPTIONS} + /> +
+
+ + { + const val = e.target.value.replace(/[^0-9]/g, ""); + updateField("career", val === "" ? 0 : Number(val)); + }} + /> +
+
+
+ + 취소 + + + 변경사항 저장 + +
+
+ + {/* 2. 자기소개 프로필 섹션 */} +
+
+

{"자기소개 프로필"}

+

+ 자소서 미리보기에 활용될 정보입니다 +

+
+ + +
+ + 취소 + + + 변경사항 저장 + +
+
+ + {/* 3. 개인정보 관리 섹션 */} +
+
+

{"개인정보 관리"}

+

+ 현재 적용 중인 개인정보 수집 및 이용 동의 현황입니다. +

+
+ +
+ setMarketingAgree(!!checked)} + /> + + 보기 + +
+ +
+ + 취소 + + + 변경사항 저장 + +
+
+
+ ); +} diff --git a/app/(afterLogin)/mypage/layout.tsx b/app/(afterLogin)/mypage/layout.tsx new file mode 100644 index 0000000..710287f --- /dev/null +++ b/app/(afterLogin)/mypage/layout.tsx @@ -0,0 +1,14 @@ +import { Header } from "@/components/header/header"; + +export default function DashboardLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( +
+
+
{children}
+
+ ); +} diff --git a/app/(afterLogin)/mypage/page.tsx b/app/(afterLogin)/mypage/page.tsx index 06752e9..07c5e02 100644 --- a/app/(afterLogin)/mypage/page.tsx +++ b/app/(afterLogin)/mypage/page.tsx @@ -1,5 +1,77 @@ +"use client"; +import { useRouter, useSearchParams } from "next/navigation"; + +import { BaseButton } from "@/components/shared/button"; +import { PageTitleGroup } from "@/components/shared/page-title-group"; +import { cn } from "@/lib/utils"; + +import MypageActivitySection from "./MypageActivity"; +import MypageLeftSection from "./MypageLeftSection"; +import MypageRightSection from "./MypageRightSection"; + export default function MyPage() { - return
MyPage
; + const router = useRouter(); + const searchParams = useSearchParams(); + + // URL에서 tab 파라미터를 가져옴 (기본값: profile) + const activeTab = + (searchParams.get("tab") as "profile" | "activity") || "profile"; + + // 탭 변경 시 URL 업데이트 + const handleTabChange = (tab: "profile" | "activity") => { + router.push(`/mypage?tab=${tab}`, { scroll: false }); + }; + + return ( +
+
+
+ +
+
+
+ handleTabChange("profile")} + className={cn( + "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", + activeTab === "profile" + ? "bg-frog-600" + : "bg-gray-0 text-gray-700 border border-gray-100" + )} + > + 프로필 설정 + + handleTabChange("activity")} + className={cn( + "px-4 py-2.5 min-w-[197px] shadow-sm transition-colors", + activeTab === "activity" + ? "bg-frog-600" + : "bg-gray-0 text-gray-700 border border-gray-100 hover:text-gray-0" + )} + > + 내 활동 + +
+
+
+
+ {/* 왼쪽: 유저 프로필 */} + + + {/* 오른쪽: 정보수정 */} +
+ {activeTab === "profile" ? ( + + ) : ( + + )} +
+
+
+ ); } /** diff --git a/app/(beforeLogin)/(auth)/signup/component/Detail.tsx b/app/(beforeLogin)/(auth)/signup/component/Detail.tsx index a8dc608..06ff7f6 100644 --- a/app/(beforeLogin)/(auth)/signup/component/Detail.tsx +++ b/app/(beforeLogin)/(auth)/signup/component/Detail.tsx @@ -86,6 +86,7 @@ export default function Detail() { />
{ @@ -95,7 +96,11 @@ export default function Detail() { onOtherChange={setOtherInput} /> - +
diff --git a/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx b/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx index 8b9409b..c68bc04 100644 --- a/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx +++ b/app/(beforeLogin)/(auth)/signup/component/StatusSelect.tsx @@ -3,6 +3,7 @@ import { BaseInput } from "@/components/shared/inputs"; import { STATE_OPTIONS, STATE_VALUES, StateType } from "../../constant"; interface StatusSelectProps { + label: string; value: StateType | ""; otherValue: string; onSelect: (status: StateType) => void; @@ -10,6 +11,7 @@ interface StatusSelectProps { } export const StatusSelect = ({ + label, value, otherValue, onSelect, @@ -17,7 +19,7 @@ export const StatusSelect = ({ }: StatusSelectProps) => { return (
-

현재 상태를 선택해주세요.

+

{label}

{STATE_OPTIONS.map((option) => ( +
+ ))} +
+ + {/* 2. 입력 창 (최대 개수 미달 시에만 노출) */} + {value.length < maxItems && ( +
+ setInputValue(e.target.value.slice(0, 200))} + onKeyDown={(e) => e.key === "Enter" && addItem()} + placeholder={placeholder} + maxLength={200} + viewLength + /> +
+ )} + + {/* 3. 추가하기 버튼 */} + = maxItems} + className="disabled:!bg-gray-100 disabled:!text-gray-400 disabled:!cursor-not-allowed" + > + +

{label} 추가하기

+
+ + ); +} diff --git a/components/mypage/user-keyword.tsx b/components/mypage/user-keyword.tsx new file mode 100644 index 0000000..8d51459 --- /dev/null +++ b/components/mypage/user-keyword.tsx @@ -0,0 +1,124 @@ +"use client"; + +import { X } from "lucide-react"; +import { KeyboardEvent, useEffect, useRef, useState } from "react"; + +import { cn } from "@/lib/utils"; + +interface KeywordInputProps { + value: string[]; + label: string; + onChange: (tags: string[]) => void; + placeholder?: string; + maxTags?: number; +} + +export default function UserKeyword({ + value = [], + label, + onChange, + placeholder = "키워드", + maxTags = 5, +}: KeywordInputProps) { + const [inputValue, setInputValue] = useState(""); + const [isFocus, setIsFocus] = useState(false); // 클릭 여부 상태 + const inputRef = useRef(null); + const spanRef = useRef(null); + const [inputWidth, setInputWidth] = useState(60); + + // 글자 길이에 따라 인풋 너비 조절 + useEffect(() => { + if (spanRef.current) { + // 텍스트가 있을 때는 텍스트 길이만큼, 없을 때는 placeholder 길이만큼 + const measuredWidth = spanRef.current.offsetWidth; + setInputWidth(Math.max(60, measuredWidth + 36)); + } + }, [inputValue, isFocus]); + + const addTag = () => { + const trimmed = inputValue.trim(); + if (trimmed && !value.includes(trimmed) && value.length < maxTags) { + onChange([...value, trimmed]); + } + setInputValue(""); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault(); + addTag(); + } else if (e.key === "Backspace" && !inputValue && value.length > 0) { + onChange(value.slice(0, -1)); + } + }; + + return ( +
+

{label}

+ +
inputRef.current?.focus()} + > + {/* 1. 확정된 태그 리스트 */} + {value.map((tag, index) => ( + + {tag} + + + ))} + + {/* 2. 입력 중인 태그 */} + {value.length < maxTags && ( +
+ {/* 너비 계산용 (화면엔 안 보임) */} + + {inputValue || (isFocus ? "" : placeholder)} + + + setIsFocus(true)} + onBlur={() => { + addTag(); + setIsFocus(false); + }} + onChange={(e) => setInputValue(e.target.value)} + onKeyDown={handleKeyDown} + placeholder={!isFocus && value.length === 0 ? placeholder : ""} + className={cn( + "w-full bg-transparent outline-none label-medium text-gray-700", + "placeholder:text-gray-400" + )} + /> +
+ )} +
+
+ ); +} diff --git a/components/mypage/user-profile.tsx b/components/mypage/user-profile.tsx new file mode 100644 index 0000000..106f5b7 --- /dev/null +++ b/components/mypage/user-profile.tsx @@ -0,0 +1,95 @@ +import { Mail, User } from "lucide-react"; +import Image from "next/image"; + +import { STATIC_IMAGES } from "@/lib/static-image"; +import { cn } from "@/lib/utils"; + +import { ProfIcon } from "../profile-icon/profile-icon"; + +const CLASSES = { + CONTAINER: + "flex flex-col gap-5 p-6 border border-gray-100 bg-gray-0 rounded-[10px] shadow-md", + AVATAR_WRAPPER: "flex items-center justify-center", + INFO_LIST: "flex flex-col gap-3", + INFO_ITEM: + "flex gap-2 text-gray-500 py-2 px-4 border border-gray-50 rounded-[5px]", + PROVIDER_BADGE: + "flex py-[10px] px-4 gap-2 shadow-md items-center justify-center rounded-[6px]", + PROVIDER_TEXT: "label-medium !font-bold", +} as const; + +interface userProfileProps { + nickname: string; + email: string; + profileImage: string; + provider: "KAKAO" | "NAVER"; + introduction: string; +} + +export default function UserProfile({ + nickname, + profileImage, + email, + provider, + introduction, +}: userProfileProps) { + const getTheme = () => { + switch (provider) { + case "NAVER": + return { + bg: "bg-[#01C73C]", + label: "네이버", + image: STATIC_IMAGES.naver, + }; + case "KAKAO": + default: + return { + bg: "bg-[#F9DB00]", + label: "카카오", + image: STATIC_IMAGES.kakao, + }; + } + }; + const theme = getTheme(); + + return ( +
+ {/* 프로필 이미지 */} +
+ +
+ + {/* 정보 리스트 */} +
+
+ +

{nickname}

+
+
+ +

{email}

+
+
+

{introduction || "등록된 소개글이 없습니다."}

+
+
+ + {/* 계정 연결 정보 배지 */} +
+ {theme.label} +

{theme.label} 계정 연결됨

+
+
+ ); +} diff --git a/lib/utils.ts b/lib/utils.ts index 883f01d..5c1a2a3 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,6 @@ import { type ClassValue, clsx } from "clsx"; +import { formatDistanceToNow, parseISO } from "date-fns"; +import { ko } from "date-fns/locale"; import { twMerge } from "tailwind-merge"; export function cn(...inputs: ClassValue[]) { @@ -14,3 +16,8 @@ export function stringToColor(str: string) { const h = Math.abs(hash) % 360; return `hsl(${h}, 70%, 60%)`; } + +export function formatRelativeDate(dateString: string) { + const date = parseISO(dateString); + return formatDistanceToNow(date, { addSuffix: true, locale: ko }); +} diff --git a/package-lock.json b/package-lock.json index 3cafa46..3bbdcc5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,6 +22,7 @@ "@types/dompurify": "^3.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.3.1", "jwt-decode": "^4.0.0", "lucide-react": "^0.562.0", @@ -9176,6 +9177,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", diff --git a/package.json b/package.json index 2954aa9..be9f1fa 100644 --- a/package.json +++ b/package.json @@ -33,6 +33,7 @@ "@types/dompurify": "^3.0.5", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "date-fns": "^4.1.0", "dompurify": "^3.3.1", "jwt-decode": "^4.0.0", "lucide-react": "^0.562.0", diff --git a/proxy.ts b/proxy.ts index ba19f0f..819348f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,7 +10,7 @@ export default auth((req) => { const isSignIn = !!session; const isNewUser = session?.isNewUser; - const protectedRoutes = [ROUTES.mypage.ROOT]; + const protectedRoutes = ["ROUTES.mypage.ROOT"]; const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route) diff --git a/stories/Mypage/UserExperience.stories.tsx b/stories/Mypage/UserExperience.stories.tsx new file mode 100644 index 0000000..b897809 --- /dev/null +++ b/stories/Mypage/UserExperience.stories.tsx @@ -0,0 +1,104 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; + +import UserExperience from "@/components/mypage/user-experience"; + +const meta: Meta = { + title: "Components/Mypage/UserExperience", + component: UserExperience, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + label: { + control: { type: "text" }, + description: "라벨", + }, + placeholder: { + control: { type: "text" }, + description: "placeholder 텍스트", + }, + maxItems: { + control: { type: "number" }, + description: "입력 가능한 최대 아이템 개수", + }, + value: { + control: "object", + description: "현재 입력된 경험 리스트", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * 1. 기본 상태 (상태 변화 확인용) + * 실제 입력과 삭제가 동작하도록 useState를 연결한 래퍼입니다. + */ +export const Interactive: Story = { + render: (args) => { + const [value, setValue] = useState(args.value || []); + return ( +
+ +
+ ); + }, + args: { + value: [], + maxItems: 3, + }, +}; + +/** + * 2. 초기 데이터가 있는 상태 + */ +export const DefaultWithData: Story = { + args: { + value: ["Google Software Engineer 인턴", "교내 해커톤 대상 수상"], + maxItems: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 3. 최대 개수에 도달한 상태 (입력창이 사라짐) + */ +export const MaxReached: Story = { + args: { + value: ["경험 1", "경험 2", "경험 3"], + maxItems: 3, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 4. 많은 개수를 허용할 때 + */ +export const ManyItemsAllowed: Story = { + args: { + value: ["리액트 공부", "타입스크립트 공부"], + maxItems: 10, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/stories/Mypage/UserKeyword.stories.tsx b/stories/Mypage/UserKeyword.stories.tsx new file mode 100644 index 0000000..1381255 --- /dev/null +++ b/stories/Mypage/UserKeyword.stories.tsx @@ -0,0 +1,106 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; +import { useState } from "react"; + +import UserKeyword from "@/components/mypage/user-keyword"; + +const meta: Meta = { + title: "Components/Mypage/UserKeyword", + component: UserKeyword, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], + argTypes: { + label: { + control: { type: "text" }, + description: "라벨 텍스트", + }, + maxTags: { + control: { type: "number" }, + description: "입력 가능한 최대 태그 개수", + }, + value: { + control: "object", + description: "현재 입력된 키워드 배열", + }, + }, +}; + +export default meta; +type Story = StoryObj; + +/** + * 1. 인터렉티브 상태 (실제 동작 확인) + */ +export const Interactive: Story = { + render: (args) => { + const [value, setValue] = useState(args.value || []); + return ( +
+ +
+ ); + }, + args: { + value: [], + maxTags: 5, + placeholder: "키워드 입력 후 Enter", + }, +}; + +/** + * 2. 태그가 이미 여러 개 있는 상태 + */ +export const WithTags: Story = { + args: { + value: ["React", "Next.js", "TypeScript", "TailwindCSS"], + maxTags: 5, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 3. 최대 개수에 도달한 상태 (Disabled) + * 인풋이 비활성화됩니다. + */ +export const MaxTagsReached: Story = { + args: { + value: ["열정맨", "성실함", "소통왕", "문제해결사", "긍정주의자"], + maxTags: 5, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +/** + * 4. 긴 텍스트 태그 테스트 + * 태그가 길어질 때 줄바꿈(flex-wrap)이 잘 일어나는지 확인합니다. + */ +export const LongTagTest: Story = { + args: { + value: [ + "아주아주긴키워드테스트입니다", + "이것또한매우매우매우매우긴키워드", + "짧은키워드", + ], + maxTags: 8, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; diff --git a/stories/Mypage/UserProfile.stories.tsx b/stories/Mypage/UserProfile.stories.tsx new file mode 100644 index 0000000..87181e9 --- /dev/null +++ b/stories/Mypage/UserProfile.stories.tsx @@ -0,0 +1,58 @@ +import type { Meta, StoryObj } from "@storybook/nextjs"; + +import UserProfile from "@/components/mypage/user-profile"; + +const meta: Meta = { + title: "Components/Mypage/UserProfile", + component: UserProfile, + tags: ["autodocs"], + argTypes: { + provider: { + control: "radio", + options: ["KAKAO", "NAVER"], + }, + }, + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; + +export default meta; +type Story = StoryObj; + +/** 1. 카카오 로그인 사용자 케이스 */ +export const KakaoProfile: Story = { + args: { + nickname: "라이언", + email: "ryan@kakao.com", + provider: "KAKAO", + introduction: "안녕하세요, 카카오에서 온 라이언입니다. 즐거운 하루 되세요!", + }, +}; + +/** 2. 네이버 로그인 사용자 케이스 */ +export const NaverProfile: Story = { + args: { + nickname: "그린팩토리", + email: "naver_user@naver.com", + provider: "NAVER", + introduction: "네이버 프로필입니다. 초록색 배경이 특징이에요.", + }, +}; + +/** 3. 긴 소개글 테스트 케이스 */ +export const LongIntroduction: Story = { + args: { + nickname: "글자수테스트", + email: "test@example.com", + provider: "KAKAO", + introduction: + "소개글이 아주 길어질 경우 레이아웃이 어떻게 변하는지 확인하기 위한 케이스입니다. ".repeat( + 3 + ), + }, +}; From 176e1fdb69263534fbe7d873f7d602cefc74b36c Mon Sep 17 00:00:00 2001 From: fryzke Date: Fri, 20 Feb 2026 23:54:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20UserProfile=20=EB=82=B4=20=EC=9E=98?= =?UTF-8?q?=EB=AA=BB=EB=90=9C=20=ED=94=84=EB=A1=9C=ED=8D=BC=ED=8B=B0=20?= =?UTF-8?q?=EC=B0=B8=EC=A1=B0=20=EC=88=98=EC=A0=95=20(icon=20->=20image)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/mypage/user-profile.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/components/mypage/user-profile.tsx b/components/mypage/user-profile.tsx index 106f5b7..fb92fec 100644 --- a/components/mypage/user-profile.tsx +++ b/components/mypage/user-profile.tsx @@ -83,10 +83,10 @@ export default function UserProfile({ {/* 계정 연결 정보 배지 */}
{theme.label}

{theme.label} 계정 연결됨

From 5887045977da4a7ff628666354fec7752b9ca32c Mon Sep 17 00:00:00 2001 From: fryzke Date: Sat, 21 Feb 2026 00:01:24 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20proxy=20=EB=AC=B8=EC=9E=90=EC=97=B4?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=ED=95=9C=20=EB=B6=80=EB=B6=84=20=EB=90=98?= =?UTF-8?q?=EB=8F=8C=EB=A6=AC=EA=B8=B0,=20=EA=B2=BD=ED=97=98,=20=ED=82=A4?= =?UTF-8?q?=EC=9B=8C=EB=93=9C=20=20=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8?= =?UTF-8?q?=EC=97=90=20=EC=A3=BC=EC=84=9D=20=EC=B6=94=EA=B0=80(=EC=B5=9C?= =?UTF-8?q?=EB=8C=80=EA=B0=AF=EC=88=98)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- components/mypage/user-experience.tsx | 2 +- components/mypage/user-keyword.tsx | 2 +- proxy.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/components/mypage/user-experience.tsx b/components/mypage/user-experience.tsx index d6c35be..b37bc5d 100644 --- a/components/mypage/user-experience.tsx +++ b/components/mypage/user-experience.tsx @@ -19,7 +19,7 @@ export default function UserExperience({ onChange, label, placeholder, - maxItems = 3, + maxItems = 3, //TODO: 최대 몇개까지인지 (추후 정해지는대로 수정) }: userExperienceProps) { const [inputValue, setInputValue] = useState(""); diff --git a/components/mypage/user-keyword.tsx b/components/mypage/user-keyword.tsx index 8d51459..47cff5b 100644 --- a/components/mypage/user-keyword.tsx +++ b/components/mypage/user-keyword.tsx @@ -18,7 +18,7 @@ export default function UserKeyword({ label, onChange, placeholder = "키워드", - maxTags = 5, + maxTags = 5, //TODO: 최대 몇개까지인지 (추후 정해지는대로 수정) }: KeywordInputProps) { const [inputValue, setInputValue] = useState(""); const [isFocus, setIsFocus] = useState(false); // 클릭 여부 상태 diff --git a/proxy.ts b/proxy.ts index 819348f..ba19f0f 100644 --- a/proxy.ts +++ b/proxy.ts @@ -10,7 +10,7 @@ export default auth((req) => { const isSignIn = !!session; const isNewUser = session?.isNewUser; - const protectedRoutes = ["ROUTES.mypage.ROOT"]; + const protectedRoutes = [ROUTES.mypage.ROOT]; const isProtectedRoute = protectedRoutes.some((route) => pathname.startsWith(route)