-
Notifications
You must be signed in to change notification settings - Fork 2
[feat 21] 모임 생성 UI + 모임 검색 UI 반응형 조절 #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
📝 WalkthroughWalkthroughThis PR removes GitHub automation workflows and issue templates, then introduces extensive new UI components, pages, and styling infrastructure for a book club and group management platform. It adds group creation wizard and search functionality with responsive layouts, component library additions (book stories, profiles, news, search elements), TypeScript type definitions for group-related data, and updates to styling and build configuration. Changes
Sequence Diagram(s)sequenceDiagram
participant User
participant CreateClubWizardPreview
participant StepDot
participant FormInputs as Form Inputs
participant State as Local State
participant Validation
User->>CreateClubWizardPreview: Load wizard
activate CreateClubWizardPreview
CreateClubWizardPreview->>StepDot: Render step indicators
CreateClubWizardPreview->>FormInputs: Render step 1 (basic info)
deactivate CreateClubWizardPreview
User->>FormInputs: Enter club name
activate FormInputs
FormInputs->>State: Update name
State->>Validation: Check name duplication
Validation-->>State: duplicate/available status
FormInputs->>CreateClubWizardPreview: Enable/disable next button
deactivate FormInputs
User->>CreateClubWizardPreview: Click next
activate CreateClubWizardPreview
CreateClubWizardPreview->>State: Move to step 2
CreateClubWizardPreview->>FormInputs: Render image upload
deactivate CreateClubWizardPreview
User->>FormInputs: Upload profile image
activate FormInputs
FormInputs->>State: Store image preview
FormInputs->>CreateClubWizardPreview: Show preview
deactivate FormInputs
loop Steps 3-4
User->>CreateClubWizardPreview: Navigate steps
activate CreateClubWizardPreview
CreateClubWizardPreview->>State: Update form data
CreateClubWizardPreview->>StepDot: Highlight current step
deactivate CreateClubWizardPreview
end
User->>CreateClubWizardPreview: Submit final step
activate CreateClubWizardPreview
CreateClubWizardPreview->>State: Collect all form data
State-->>CreateClubWizardPreview: Complete club object
deactivate CreateClubWizardPreview
sequenceDiagram
participant User
participant Searchpage
participant SearchGroupSearch
participant SearchClubListItem
participant SearchClubApplyModal
participant State
User->>Searchpage: Load group search page
activate Searchpage
Searchpage->>SearchGroupSearch: Render search bar
Searchpage->>SearchClubListItem: Render club list
deactivate Searchpage
User->>SearchGroupSearch: Enter search query/filter
activate SearchGroupSearch
SearchGroupSearch->>State: Update search/filter state
State->>Searchpage: Trigger re-render
deactivate SearchGroupSearch
Searchpage->>SearchClubListItem: Update filtered clubs
activate SearchClubListItem
SearchClubListItem->>User: Display filtered results
deactivate SearchClubListItem
User->>SearchClubListItem: Click apply button
activate SearchClubListItem
SearchClubListItem->>State: Set applyClubId
State->>SearchClubApplyModal: Open modal with club
deactivate SearchClubListItem
activate SearchClubApplyModal
SearchClubApplyModal->>User: Show club details & reason form
deactivate SearchClubApplyModal
User->>SearchClubApplyModal: Enter reason & submit
activate SearchClubApplyModal
SearchClubApplyModal->>Searchpage: Call onSubmitApply callback
Searchpage->>State: Log apply (API TODO)
State->>SearchClubApplyModal: Close modal
deactivate SearchClubApplyModal
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~25 minutes Possibly related issues
Possibly related PRs
Suggested reviewers
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Summary of ChangesHello @hongik-luke, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed! 이 PR은 모임 생성 및 검색 기능의 반응형 사용자 인터페이스를 구현하고, Tailwind CSS 브레이크포인트 적용 문제를 해결하는 데 중점을 둡니다. 전반적인 사용자 경험을 향상시키고 향후 기능을 위한 기반을 마련하기 위해 여러 새로운 UI 컴포넌트와 스타일 정의가 도입되었습니다. Highlights
🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console. Ignored Files
Using Gemini Code AssistThe full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips. Invoking Gemini You can request assistance from Gemini at any point by creating a comment using either
Customization To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a Limitations & Feedback Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here. You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension. Footnotes
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 8
Note
Due to the large number of review comments, Critical, Major severity comments were prioritized as inline comments.
🤖 Fix all issues with AI agents
In @src/app/groups/page.tsx:
- Around line 19-29: Remove the erroneous method declaration from the
ClubSummary interface: delete the line declaring reason(clubId: number, reason:
string): void so ClubSummary only contains data properties (clubId, name,
profileImageUrl, category, public, applytype, region, participantTypes). Verify
there are no consumers expecting a reason method and, if any exist, refactor
them to accept the DTO as data (or move that method to the appropriate
service/class) and update groupSearchDummy.ts to remain valid with the cleaned
interface.
In @src/components/base-ui/BookStory/bookstory_choosebook.tsx:
- Around line 1-2: This component (bookstory_choosebook.tsx) is interactive (has
an onClick handler) but lacks the required 'use client' directive; fix it by
adding the exact line 'use client' as the very first line of the file (before
any imports) so the component becomes a client component, and verify that the
exported component/function with the onClick handler continues to only use
client-safe APIs (no server-only modules).
In @src/components/base-ui/Group-Search/search_club_apply_modal.tsx:
- Line 169: onSubmit is typed as (club: number, reason: string) => void but is
being called with only reason; change the call site to pass the club id first
(e.g., onSubmit(club, reason)) so the parent receives (club: number, reason:
string). Locate the component prop/type that declares the club id (the parameter
named "club" in the prop type) and use that identifier when invoking onSubmit in
the onClick handler.
In @src/components/base-ui/Group-Search/search_groupsearch.tsx:
- Around line 49-60: The outside-click handler never fires because the ref
created with useRef (wrapRef) is never attached to the DOM; update the component
JSX to pass wrapRef to the dropdown/container element (e.g., add ref={wrapRef}
on the element that wraps the searchable dropdown), ensuring the useEffect's
onDown can access wrapRef.current and call setOpen(false) correctly; keep the
existing useEffect, onDown, useRef, and setOpen logic unchanged.
In @src/components/base-ui/home/list_subscribe_element.tsx:
- Around line 1-2: This component uses an onClick handler but is missing the
Next.js client component directive; add the string "use client" as the very
first line of src/components/base-ui/home/list_subscribe_element.tsx (before any
imports) so the component becomes a client component and its event handler (the
onClick in this file) will work correctly.
In @src/components/base-ui/Profile/mypage_profile.tsx:
- Around line 96-99: The span uses a non-existent utility class subhead_4 so its
styles won't apply; update the class to one of the defined utilities (e.g.,
subhead_4_1 or one of subhead_1/2/3/3_1/3_2 depending on intended size) by
replacing subhead_4 in the span's className, and verify the visual matches
design; ensure no other occurrences of subhead_4 remain (search for "subhead_4")
and run the app to confirm styling.
In @src/components/base-ui/Profile/notification_element.tsx:
- Around line 1-50: Duplicate NotificationElement component exists in two
places; consolidate by extracting the NotificationElement function (and its
props type NotificationElementProps) into a single shared/common component
module, export it, update all imports that currently reference the duplicated
copies to import the shared NotificationElement, and then remove the redundant
duplicate file so only the shared implementation remains.
In @src/components/base-ui/Settings/setting_report_list.tsx:
- Around line 22-28: The fixed-width class w-[1000px] in the SettingsReportList
container causes overflow on small screens; replace it with responsive sizing
(e.g., use w-full or max-w-[1000px] plus breakpoint aliases) so the component
expands to full width on mobile and caps at 1000px on larger screens—update the
class string in setting_report_list.tsx (the div with className containing
w-[1000px]) to something like w-full sm:w-[1000px] or w-full max-w-[1000px]
(preserving existing padding, gap, border and ${className}).
🟡 Minor comments (16)
src/app/(main)/ui-test/page.tsx-3-4 (1)
3-4: Remove unused imports.
Subscribe_elementsis imported but never used. TheuseStateimport is also unnecessary if the unused state is removed.Suggested cleanup
"use client"; -import React from "react"; -import { useState } from "react"; -import Subscribe_elements from "@/components/base-ui/Profile/subscribe_element"; import Setting_ReportList from "@/components/base-ui/Settings/setting_report_list";src/app/(main)/ui-test/page.tsx-7-9 (1)
7-9: Remove unused state.The
subsstate variable and its settersetSubsare defined but never used in the component.Suggested cleanup
export default function UiTestClient() { - const [subs, setSubs] = useState(false); - return (Note: If the unused imports and state are removed, the
'use client'directive can also be removed since the component would become purely presentational.src/app/globals.css-116-118 (1)
116-118: Unexpectedtruncateinbody_1utility.The
body_1utility includestruncatewhich forces single-line text with ellipsis. This is likely unintentional for a general-purpose body text utility and will cause unexpected text truncation whereverbody_1is applied. Consider removing it or creating a separatebody_1_truncatevariant.🔧 Suggested fix
@utility body_1 { - @apply text-[14px] font-semibold leading-[145%] tracking-[-0.014px] truncate; + @apply text-[14px] font-semibold leading-[145%] tracking-[-0.014px]; }src/components/base-ui/Profile/others_profile.tsx-6-21 (1)
6-21: UnusedonSettingsprop declared in type.The
onSettingsprop is defined inOthersProfileProps(line 17) but is never destructured or used in the component. Either remove it from the type definition or implement the functionality.🔧 Suggested fix - remove unused prop
type OthersProfileProps = { profileImgSrc?: string; // default: /profile.svg name: string; followingCount: number; // 구독중 followerCount: number; // 구독자 isSubscribed: boolean; onToggleSubscribe: (newState: boolean) => void; intro: string; - onSettings?: () => void; onReportClick?: () => void; className?: string; };src/app/layout.tsx-14-17 (1)
14-17: Update placeholder metadata.The metadata still contains default Next.js scaffold values. Consider updating
titleanddescriptionto reflect the actual application (e.g., the 모임/group management platform).📝 Suggested update
export const metadata: Metadata = { - title: 'Create Next App', - description: 'Generated by create next app', + title: '체크모', // or appropriate app name + description: '모임 관리 플랫폼', // or appropriate description };src/components/base-ui/News/news_list.tsx-48-51 (1)
48-51: Comment-code mismatch: comment says 120px but code uses 180px.The comment states "최소 120px 확보" but the actual class uses
min-w-[180px]. Update either the comment or the value to be consistent.tsconfig.json-35-39 (1)
35-39: Remove stalecheckmo/prefixed paths fromincludearray.The
checkmodirectory does not exist in the repository. The pathscheckmo/.next/types/**/*.tsandcheckmo/.next/dev/types/**/*.tsappear to be leftover from a previous configuration and should be removed, keeping only the root-level.next/types/**/*.tsand.next/dev/types/**/*.tspatterns.src/components/base-ui/Search/search_bookresult.tsx-66-68 (1)
66-68: Typo:flex1should beflex-1.This appears to be a typo in the Tailwind class name.
Proposed fix
-<p className="flex1 h-full text-[color:var(--Gray_4,#8D8D8D)] body_1_2 line-clamp-6"> +<p className="flex-1 h-full text-[color:var(--Gray_4,#8D8D8D)] body_1_2 line-clamp-6"> {clippedDetail} </p>src/app/groups/create/page.tsx-411-416 (1)
411-416: MissingmaxLengthenforcement on activity area input.The placeholder mentions a 40 character limit, but there's no
maxLengthattribute to enforce it.Proposed fix
<input value={activityArea} onChange={(e) => setActivityArea(e.target.value)} placeholder="활동 지역을 입력해주세요 (40자 제한)" + maxLength={40} className="..." />src/app/groups/create/page.tsx-195-216 (1)
195-216: MissingmaxLengthenforcement on textarea.The placeholder mentions a 500 character limit, but there's no
maxLengthattribute to enforce it.Proposed fix
<textarea value={clubDescription} onChange={(e) => { setClubDescription(e.target.value); autoResize(e.currentTarget); }} onInput={(e) => autoResize(e.currentTarget)} placeholder="자유롭게 입력해주세요! (500자 제한)" + maxLength={500} className="..." />src/components/base-ui/Group-Search/search_mybookclub.tsx-62-65 (1)
62-65: Invalid/empty Tailwind classh-[].
h-[]is an incomplete arbitrary value class and will have no effect. Either remove it or specify an actual height value.Proposed fix
-<div className="h-[] flex items-center justify-center py-4 t:py-10 d:py-20"> +<div className="flex items-center justify-center py-4 t:py-10 d:py-20"> <Image src="/logo2.svg" alt="로고" height={300} width={300} /> </div>src/app/groups/create/page.tsx-572-584 (1)
572-584: Button text should indicate final step.On step 4 (the last step), the button still says "다음" (Next). It should say something like "완료" (Complete) or "생성" (Create) to indicate this is the final action.
Proposed fix
<button type="button" onClick={onNext} disabled={!canNext} className={cx(...)} > - 다음 + 완료 </button>src/app/groups/create/page.tsx-57-65 (1)
57-65: Memory leak risk:setTimeoutis not cleaned up on unmount.If the component unmounts during the 500ms delay, the callback will still fire and attempt to update state on an unmounted component.
Proposed fix
const fakeCheckName = () => { if (!clubName.trim()) return; setNameCheck("checking"); - setTimeout(() => { - // 그냥 프리뷰용: 이름에 "중복" 들어가면 duplicate 처리 - if (clubName.includes("중복")) setNameCheck("duplicate"); - else setNameCheck("available"); - }, 500); + const timer = setTimeout(() => { + if (clubName.includes("중복")) setNameCheck("duplicate"); + else setNameCheck("available"); + }, 500); + return timer; };Then store and clear the timer in a
useEffectcleanup or use a ref to track it.src/app/groups/groupSearchDummy.ts-7-17 (1)
7-17: Duplicate IDs will cause React key warnings.IDs
'1','2','3','4'appear twice inmydummyGroup. Even for mock data, duplicate keys cause React warnings and can lead to rendering bugs.Suggested fix
export const mydummyGroup: GroupSummary[] = [ { id: '1', name: '모임1' }, { id: '2', name: '모임2' }, { id: '3', name: '모임3' }, { id: '4', name: '모임4' }, - { id: '1', name: '모임11241' }, - { id: '2', name: '모임51212' }, - { id: '3', name: '모임125153' }, - { id: '4', name: '모임12512514' }, + { id: '5', name: '모임11241' }, + { id: '6', name: '모임51212' }, + { id: '7', name: '모임125153' }, + { id: '8', name: '모임12512514' }, ];src/components/base-ui/Search/search_recommendbook.tsx-44-58 (1)
44-58: Add accessible label to the like button.The button contains only an image with
alt="". Screen reader users won't understand the button's purpose. Add anaria-labelfor accessibility.Suggested fix
<button type="button" + aria-label={liked ? '좋아요 취소' : '좋아요'} onClick={(e) => { e.stopPropagation(); onLikeChange(!liked); }} className="w-[24px] h-[24px] shrink-0" >src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx-51-51 (1)
51-51: Typo:item-centershould beitems-center.The Tailwind CSS class
item-centeris invalid. The correct class isitems-centerfor flexbox vertical alignment.🐛 Proposed fix
- 'h-[21px] my-auto py-[1px] inline-flex item-center justify-center body_1_2', + 'h-[21px] my-auto py-[1px] inline-flex items-center justify-center body_1_2',
🧹 Nitpick comments (43)
src/components/base-ui/home/notification_element.tsx (2)
32-32: Fixed width may break responsive layout.The component uses a hardcoded
w-[364px]width, which conflicts with the PR's responsive design goals. Consider usingw-fullor responsive width utilities to allow the parent container to control sizing.♻️ Suggested fix
- <div className="flex w-[364px] px-[16px] py-[20px] justify-between items-center border-b border-b-[color:var(--Subbrown_4,#EAE5E2)] bg-[color:var(--White,#FFF)]"> + <div className="flex w-full px-[16px] py-[20px] justify-between items-center border-b border-b-[color:var(--Subbrown_4,#EAE5E2)] bg-[color:var(--White,#FFF)]">
35-39: Consider adding accessible label for the status indicator.The red dot indicator for
isLatestconveys meaning visually but is not accessible to screen readers. Consider adding anaria-labelorsr-onlytext.♻️ Suggested fix
{isLatest ? ( - <span className="w-[12px] h-[12px] rounded-full bg-red-500 shrink-0" /> + <span className="w-[12px] h-[12px] rounded-full bg-red-500 shrink-0" aria-label="새 알림" role="img" /> ) : ( <span className="w-[12px] h-[12px] shrink-0" /> )}src/components/base-ui/Settings/setting_report_list.tsx (1)
1-1: Consider removing'use client'directive.This component is purely presentational with no client-side interactivity (no state, effects, or event handlers). It can be rendered as a Server Component, reducing the client bundle size.
src/app/globals.css (3)
128-130: Duplicate typography definitions:body_2_1andcaption_1_1are identical.Both utilities apply the exact same styles (
text-[12px] font-semibold leading-[145%] tracking-[-0.012px]). Consider whether both are needed or if one should be an alias/removed to reduce maintenance overhead.Also applies to: 139-141
147-150: Consider consolidating@themeblocks.There are two separate
@themeblocks (lines 33-64 and 147-150). While functionally valid, consolidating them into a single block improves maintainability and makes the design system easier to understand at a glance.
1-1: Pin the font CDN version for production stability.Using
@latestin the CDN URL means builds may receive different font versions over time, potentially causing visual inconsistencies or breaking changes. Pin to a specific version (e.g.,@v1.3.9) for reproducible builds.🔧 Suggested fix
-@import url("https://cdn.jsdelivr.net/gh/orioncactus/pretendard@latest/dist/web/static/pretendard.css"); +@import url("https://cdn.jsdelivr.net/gh/orioncactus/[email protected]/dist/web/static/pretendard.css");src/components/base-ui/Profile/mypage_profile.tsx (2)
109-116: Remove emptyclassNameattribute.The
className=""on line 114 serves no purpose and adds noise. Remove it for cleaner code.🔧 Suggested fix
<Image src="/setting_icon.svg" alt="" width={24} height={24} - className="" />
36-51: Consider extracting shared profile header logic.The profile header section (avatar, name, follow counts, intro) is nearly identical between
MypageProfileandOthersProfile. Consider extracting a sharedProfileHeadercomponent to reduce duplication and ensure consistent styling across both variants.Also applies to: 53-82
src/components/base-ui/Profile/subscribe_element.tsx (1)
46-60: Consider using thecaption_1_1utility instead of inline font styles.The button's font styles (
text-[12px] font-semibold leading-[100%] tracking-[-0.012px]) closely match thecaption_1_1utility defined inglobals.css. Using the utility would improve consistency and reduce duplication.Note: The
leading-[100%]differs fromcaption_1_1'sleading-[145%], so verify if the tighter line-height is intentional for this button context.src/components/base-ui/button_without_img.tsx (1)
52-62: Consider CSS-based hover for better performance.Using
useStatefor hover state causes React re-renders on every mouse enter/leave. For a button that may appear many times on a page, this can impact performance. Consider using CSS custom properties with:hoverpseudo-class, or Tailwind'shover:variants.💡 Alternative approach using CSS variables and :hover
You could set CSS variables as inline styles and use a
:hoverselector in the className:// Simplified approach - define base and hover colors as CSS vars <button style={{ '--btn-bg': toCssColor(bgColorVar), '--btn-bg-hover': toCssColor(hoverBgColorVar ?? bgColorVar), // ... other colors } as React.CSSProperties} className={` bg-[var(--btn-bg)] hover:bg-[var(--btn-bg-hover)] ... `} >This eliminates re-renders while preserving the dynamic color capability.
Also applies to: 69-70
tailwind.config.js (1)
3-10: Consider more descriptive breakpoint names for clarity.Single-letter abbreviations (
t,d) are cryptic. More descriptive names liketabletanddesktopwould improve readability across the codebase without adding significant verbosity.Also, there's inconsistent indentation—lines 3–10 use 4-space indentation while nested properties use 2-space.
♻️ Suggested improvement
module.exports = { content: ['./src/**/*.{js,ts,jsx,tsx}', './app/**/*.{js,ts,jsx,tsx}'], - theme: { - extend: { - screens: { - t: "768px", - d: "1440px", - }, - }, - }, + theme: { + extend: { + screens: { + tablet: "768px", + desktop: "1440px", + }, + }, + }, plugins: [], - }; +};src/app/groups/create/layout.tsx (1)
4-11: Unnecessary fragment wrapper.The React fragment (
<>...</>) serves no purpose here since it wraps only a single<main>element. Returning<main>directly simplifies the code.♻️ Suggested simplification
export default function MainLayout({ children }: { children: ReactNode }) { - return ( - <> - - <main>{children}</main> - </> - ); + return <main>{children}</main>; }src/components/base-ui/Group-Create/StepDot.tsx (2)
1-1: Unused React import.In modern React (17+) with the new JSX transform, explicit
import React from "react"is unnecessary unless you're using React APIs directly (e.g.,React.useState).♻️ Remove unused import
-import React from "react"; -
3-5: Consider usingclsxorclassnameslibrary.Rolling a custom
cxhelper is fine for simple cases, but using a well-established library likeclsxprovides better edge-case handling and is a common convention in React/Next.js projects.If
clsxis already a project dependency or you'd like to add it:-function cx(...classes: (string | false | null | undefined)[]) { - return classes.filter(Boolean).join(" "); -} +import clsx from "clsx";Then replace
cx(...)calls withclsx(...).src/app/layout.tsx (1)
24-26: Consider settinglangattribute to match target audience.The application appears to target Korean users (based on the PR objectives mentioning 모임 생성/검색), but
lang="en"is set. This affects accessibility tools, search engines, and browser behavior.♻️ Suggested fix
- <html lang="en"> + <html lang="ko">src/components/base-ui/News/news_list.tsx (1)
1-1: Consider removing'use client'directive.This component doesn't use any client-side hooks (useState, useEffect, event handlers, etc.). It could be rendered as a Server Component, which would reduce the client-side JavaScript bundle.
src/components/layout/Header.tsx (1)
55-91: Reconsiderpriorityon icon images.All three icon images have
priority={true}, which preloads them. Thepriorityprop should typically be reserved for above-the-fold LCP (Largest Contentful Paint) elements like the logo. For small navigation icons, this may unnecessarily delay more important resources.♻️ Suggested change
<Link href="/search" aria-label="검색" className="relative h-6 w-6"> <Image src="/search_light.svg" alt="검색" fill className="object-contain" - priority /> </Link> <Link href="/notification" aria-label="알림" className="relative h-6 w-6" > <Image src="/notification.svg" alt="알림" fill className="object-contain" - priority /> </Link> <Link href="/profile" aria-label="프로필" className="relative h-6 w-6" > <Image src="/profile.svg" alt="프로필" fill className="object-contain" - priority /> </Link>src/app/(main)/layout.tsx (1)
4-10: Layout structure is clean, but duplicated withgroups/layout.tsx.This layout is identical to
src/app/groups/layout.tsx. If both sections should share the same layout, consider using a shared layout higher in the route hierarchy or extracting a common layout component to avoid duplication.However, if the layouts are expected to diverge (e.g., groups section will have additional UI), keeping them separate is acceptable.
src/app/groups/layout.tsx (1)
4-10: Consider renaming toGroupsLayoutfor clarity.Both this file and
src/app/(main)/layout.tsxexport a function namedMainLayout. While Next.js uses file-based routing and the export name doesn't affect routing, using a more specific name likeGroupsLayoutimproves code clarity and debugging experience.♻️ Suggested change
-export default function MainLayout({ children }: { children: ReactNode }) { +export default function GroupsLayout({ children }: { children: ReactNode }) {src/components/base-ui/Group-Create/Chip.tsx (1)
3-5: Consider extracting thecxutility to a shared module.This helper is duplicated in
src/app/groups/create/page.tsx. Extract it to a shared utility (e.g.,src/utils/cx.ts) or use a library likeclsx/classnamesto avoid duplication.src/app/groups/create/page.tsx (2)
7-7: Unused imports detected.
PARTICIPANT_LABEL_TO_TYPEandParticipantTypeare imported but not used in this file. Remove them to keep imports clean.Proposed fix
-import { BOOK_CATEGORIES, BookCategory, PARTICIPANT_LABEL_TO_TYPE, ParticipantLabel, PARTICIPANTS, ParticipantType } from "@/types/groups/groups"; +import { BOOK_CATEGORIES, BookCategory, ParticipantLabel, PARTICIPANTS } from "@/types/groups/groups";
73-77: Indentation issue: function appears outside component scope.The
toggleWithLimithelper has inconsistent indentation which makes it look like it's outside the component. While syntactically this still works (it's inside the function), the formatting should be corrected for clarity.Proposed fix
- const toggleWithLimit = <T,>(arr: T[], item: T, limit: number) => { - if (arr.includes(item)) return arr.filter((x) => x !== item); - if (arr.length >= limit) return arr; - return [...arr, item]; -}; + const toggleWithLimit = <T,>(arr: T[], item: T, limit: number) => { + if (arr.includes(item)) return arr.filter((x) => x !== item); + if (arr.length >= limit) return arr; + return [...arr, item]; + };src/components/base-ui/Search/search_bookresult.tsx (1)
88-101: Pencil button renders even whenonPencilClickis undefined.Since
onPencilClickis optional, consider conditionally rendering the pencil button to avoid showing a non-functional button.Proposed fix
-<button - type="button" - onClick={(e) => { - e.stopPropagation(); - onPencilClick?.(); - }} - className="..." -> - <Image src="/pencil_icon.svg" alt="" width={20} height={20} /> -</button> +{onPencilClick && ( + <button + type="button" + onClick={(e) => { + e.stopPropagation(); + onPencilClick(); + }} + className="..." + > + <Image src="/pencil_icon.svg" alt="" width={20} height={20} /> + </button> +)}src/components/base-ui/Group-Search/search_mybookclub.tsx (1)
69-74: Redundantflex-colclass withgridlayout.When using
grid, theflex-colclass has no effect since grid layout takes precedence. Remove it for clarity.Proposed fix
-"grid grid-cols-1 t:grid-cols-2 d:grid-cols-1 flex-col gap-2", +"grid grid-cols-1 t:grid-cols-2 d:grid-cols-1 gap-2",src/components/base-ui/Group-Search/search_groupsearch.tsx (1)
6-13: DuplicateCategorytype definition.The
Categorytype is already defined insrc/types/groups/groups.ts. Import it from there to maintain a single source of truth.Proposed fix
+'use client'; + +import Image from 'next/image'; +import { useEffect, useRef, useState } from 'react'; +import { Category } from '@/types/groups/groups'; -'use client'; - -import Image from 'next/image'; -import { useEffect, useRef, useState } from 'react'; - -export type Category = - | '전체' - | '대학생' - | '직장인' - | '온라인' - | '동아리' - | '모임' - | '대면';src/utils/groupMapper.ts (1)
1-1: Import types from the dedicated types module instead of the page component.The
CategoryandParticipantTypetypes are defined insrc/types/groups/groups.ts(as shown in the relevant code snippets). Importing from a page component couples this utility to presentation code and may cause circular dependency issues.Suggested fix
-import { Category, ParticipantType } from "@/app/groups/page"; +import { Category, ParticipantType } from "@/types/groups/groups";src/components/base-ui/home/list_subscribe.tsx (2)
25-25: Removeconsole.logbefore production.Development logging should be replaced with actual subscription logic or a no-op placeholder.
Suggested fix
- onSubscribeClick={() => console.log('subscribe', u.id)} + onSubscribeClick={() => { + // TODO: implement subscription logic + }}
5-11: Consider extracting mock data or accepting users as a prop.The hardcoded users array limits reusability. For a more flexible component, consider accepting
usersas a prop or importing from a dedicated mock data file.src/components/base-ui/home/NewsBannerSlider.tsx (2)
17-17: Fixed width may break responsiveness.The
w-[1040px]constraint won't adapt to smaller viewports. Consider using responsive widths or max-width with percentage-based sizing given the PR's responsive focus.Suggested approach
- <div className="relative h-[424px] w-[1040px] overflow-hidden rounded-[10px]"> + <div className="relative h-[424px] w-full max-w-[1040px] overflow-hidden rounded-[10px]">
6-11: Duplicate banner entry.
"/news_sample.svg"appears at both index 0 and 3. If intentional as placeholder data, consider adding a comment; otherwise, replace with unique assets.src/app/(main)/page.tsx (2)
11-11: Fixed width limits responsiveness.
w-[1400px]creates a rigid layout that won't adapt to smaller screens. Given the PR focuses on responsive adjustments, consider using max-width or responsive breakpoints.Suggested approach
- <div className="mx-auto w-[1400px]"> + <div className="mx-auto w-full max-w-[1400px] px-4 d:px-0">
29-60: Hardcoded BookStoryCard data—consider extracting to mock data or TODO comment.Three identical cards with hardcoded props are fine for initial scaffolding. Adding a TODO or extracting to a mock data array (like
groupSearchDummy.ts) would clarify intent.src/components/base-ui/News/recommendbook_element.tsx (2)
29-34: Consider accessibility for the clickable card.The clickable
<div>is not keyboard-accessible. Users navigating with keyboard won't be able to focus or activate the card click handler.♿ Suggested fix for keyboard accessibility
<div onClick={onCardClick} + role={onCardClick ? 'button' : undefined} + tabIndex={onCardClick ? 0 : undefined} + onKeyDown={onCardClick ? (e) => { if (e.key === 'Enter' || e.key === ' ') onCardClick(); } : undefined} className={`relative flex w-[244px] h-[320px] p-[12px] flex-col justify-end items-start gap-[10px] overflow-hidden ${ onCardClick ? 'cursor-pointer' : '' } ${className}`} >
44-58: Add accessible label to the like button.The button has no accessible name for screen reader users. Consider adding
aria-labeloraria-pressedto convey state.♿ Proposed fix
<button type="button" + aria-label={liked ? '좋아요 취소' : '좋아요'} + aria-pressed={liked} onClick={(e) => { e.stopPropagation(); onLikeChange(!liked); }} className="w-[24px] h-[24px] shrink-0" >src/components/base-ui/BookStory/bookstory_choosebook.tsx (1)
29-36: Add fallback for potentially emptybookUrl.
bookUrlis typed as requiredstring, but if an empty string is passed, Next.js Image will fail. Consider adding a fallback similar to other components in this PR.🛡️ Proposed fix
+ const coverSrc = bookUrl && bookUrl.length > 0 ? bookUrl : '/booksample.svg'; + <div className="relative w-[100px] h-[145px] shrink-0"> <Image - src={bookUrl} + src={coverSrc} alt={bookName} fill className="object-cover rounded-[4px]" sizes="100px" /> </div>src/components/base-ui/BookStory/bookstory_card.tsx (2)
18-30: Handle invalid date input intimeAgo.If
isois an invalid date string,new Date(iso).getTime()returnsNaN, causing the function to return"NaN일 전". Consider adding validation.🛡️ Proposed defensive fix
function timeAgo(iso: string) { - const diff = Date.now() - new Date(iso).getTime(); + const date = new Date(iso); + if (isNaN(date.getTime())) return ''; + const diff = Date.now() - date.getTime(); const minutes = Math.floor(diff / 60000);
79-88: Addsizesprop to the cover Image.The
Imagecomponent usesfillbut lacks asizesprop. This can negatively impact loading performance. Other images in this file correctly specifysizes.⚡ Proposed fix
<Image src={coverImgSrc} alt="bookstory cover" fill className="object-cover" + sizes="336px" />src/components/base-ui/home/list_subscribe_element.tsx (2)
24-32: Fixsizesprop mismatch.The image container is
32x32butsizes="42px"is specified. This should match the actual rendered size for optimal image loading.⚡ Proposed fix
sizes="42px" + sizes="32px"
44-50: Remove redundant color classes.
text-whiteandtext-[color:var(--White,#FFF)]are redundant—both set the same white color.🧹 Proposed cleanup
- className="flex px-[17px] py-[8px] justify-center items-center gap-[10px] rounded-[8px] bg-[#9A7A6B] text-white text-[color:var(--White,#FFF)] text-[12px] font-semibold leading-[100%] tracking-[-0.012px] whitespace-nowrap" + className="flex px-[17px] py-[8px] justify-center items-center gap-[10px] rounded-[8px] bg-[#9A7A6B] text-white text-[12px] font-semibold leading-[100%] tracking-[-0.012px] whitespace-nowrap"src/components/base-ui/home/home_bookclub.tsx (1)
32-33: Use Next.jsImagecomponent consistently.This uses a native
<img>tag while the rest of the component and codebase usesnext/image. For consistency and optimization benefits, consider usingImage.🔄 Proposed fix
- <img src="logo2.svg" alt="로고" className="mx-auto mb-4 mt-[118px]" /> + <Image src="/logo2.svg" alt="로고" width={100} height={100} className="mx-auto mb-4 mt-[118px]" />Note: Adjust width/height values to match the actual logo dimensions.
src/components/base-ui/BookStory/bookstory_text.tsx (1)
21-27: ConsideruseEffectinstead ofuseLayoutEffectfor SSR compatibility.
useLayoutEffectcan cause warnings in Next.js SSR environments. Since this is a client component and the height adjustment is non-critical for initial paint,useEffectwould work similarly without SSR warnings.♻️ Optional alternative
-import React, { useCallback, useLayoutEffect, useRef } from 'react'; +import React, { useCallback, useEffect, useRef } from 'react'; ... - useLayoutEffect(() => { + useEffect(() => {src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx (2)
36-36:onCloseApplyprop is unused.The
onCloseApplyprop is declared as required but never used within this component. Either remove it from the props or implement the close behavior where needed (e.g., when the apply form is dismissed).♻️ Option 1: Remove unused prop
type Props = { club: ClubSummary; onClickVisit?: (clubId: number) => void; onClickApply?: (clubId: number) => void; onSubmitApply: (clubId: number, reason: string) => void; - onCloseApply: () => void; applyOpenId: number | null; }; export default function SearchClubListItem({ club, onClickVisit, onClickApply, applyOpenId, - onCloseApply, onSubmitApply, }: Props) {
12-19: Consider extractingPARTICIPANT_KOto a shared constants file.This mapping is duplicated in
search_club_apply_modal.tsx. Extracting it to a shared location (e.g.,@/constants/groups.tsor alongside the types in@/types/groups/groups.ts) would reduce duplication and make future updates easier.
📜 Review details
Configuration used: defaults
Review profile: CHILL
Plan: Pro
⛔ Files ignored due to path filters (42)
package-lock.jsonis excluded by!**/package-lock.jsonpublic/ArrowDown.svgis excluded by!**/*.svgpublic/ArrowTop.svgis excluded by!**/*.svgpublic/BrownCheck.svgis excluded by!**/*.svgpublic/CheckBox_No.svgis excluded by!**/*.svgpublic/CheckBox_Yes.svgis excluded by!**/*.svgpublic/ClubDefaultImg.svgis excluded by!**/*.svgpublic/Edit_icon.svgis excluded by!**/*.svgpublic/GreenCheck.svgis excluded by!**/*.svgpublic/Lock.svgis excluded by!**/*.svgpublic/Polygon.svgis excluded by!**/*.svgpublic/RadioOff.svgis excluded by!**/*.svgpublic/RadioOn.svgis excluded by!**/*.svgpublic/Setting_icon.svgis excluded by!**/*.svgpublic/Unlock.svgis excluded by!**/*.svgpublic/blank_heart.svgis excluded by!**/*.svgpublic/booksample.svgis excluded by!**/*.svgpublic/bookstorycard.svgis excluded by!**/*.svgpublic/cancle_button.svgis excluded by!**/*.svgpublic/comment.svgis excluded by!**/*.svgpublic/default_profile_1.svgis excluded by!**/*.svgpublic/default_profile_2.svgis excluded by!**/*.svgpublic/gray_heart.svgis excluded by!**/*.svgpublic/icon_minus_1.svgis excluded by!**/*.svgpublic/icon_plus.svgis excluded by!**/*.svgpublic/icon_plus_1.svgis excluded by!**/*.svgpublic/logo.svgis excluded by!**/*.svgpublic/logo2.svgis excluded by!**/*.svgpublic/news_sample.svgis excluded by!**/*.svgpublic/news_sample2.pngis excluded by!**/*.pngpublic/news_sample3.pngis excluded by!**/*.pngpublic/notification.svgis excluded by!**/*.svgpublic/pencil_icon.svgis excluded by!**/*.svgpublic/plus.svgis excluded by!**/*.svgpublic/profile.svgis excluded by!**/*.svgpublic/profile2.svgis excluded by!**/*.svgpublic/profile3.svgis excluded by!**/*.svgpublic/profile4.svgis excluded by!**/*.svgpublic/profile5.svgis excluded by!**/*.svgpublic/red_heart.svgis excluded by!**/*.svgpublic/search.svgis excluded by!**/*.svgpublic/search_light.svgis excluded by!**/*.svg
📒 Files selected for processing (50)
.github/ISSUE_TEMPLATE/custom.md.github/PULL_REQUEST_TEMPLATE.md.github/workflows/deploy.yml.github/workflows/pr-merge-cleanup.yml.github/workflows/preview.ymlsrc/app/(main)/layout.tsxsrc/app/(main)/page.tsxsrc/app/(main)/ui-test/page.tsxsrc/app/globals.csssrc/app/groups/create/layout.tsxsrc/app/groups/create/page.tsxsrc/app/groups/groupSearchDummy.tssrc/app/groups/layout.tsxsrc/app/groups/page.tsxsrc/app/layout.tsxsrc/app/page.backup.tsxsrc/components/base-ui/BookStory/bookstory_card.tsxsrc/components/base-ui/BookStory/bookstory_choosebook.tsxsrc/components/base-ui/BookStory/bookstory_detail.tsxsrc/components/base-ui/BookStory/bookstory_text.tsxsrc/components/base-ui/Group-Create/Chip.tsxsrc/components/base-ui/Group-Create/StepDot.tsxsrc/components/base-ui/Group-Search/search_club_apply_modal.tsxsrc/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsxsrc/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsxsrc/components/base-ui/Group-Search/search_groupsearch.tsxsrc/components/base-ui/Group-Search/search_mybookclub.tsxsrc/components/base-ui/News/news_list.tsxsrc/components/base-ui/News/recommendbook_element.tsxsrc/components/base-ui/Profile/mypage_profile.tsxsrc/components/base-ui/Profile/notification_element.tsxsrc/components/base-ui/Profile/others_profile.tsxsrc/components/base-ui/Profile/subscribe_element.tsxsrc/components/base-ui/Search/search_bookresult.tsxsrc/components/base-ui/Search/search_recommendbook.tsxsrc/components/base-ui/Settings/setting_news_list.tsxsrc/components/base-ui/Settings/setting_report_list.tsxsrc/components/base-ui/button_without_img.tsxsrc/components/base-ui/home/NewsBannerSlider.tsxsrc/components/base-ui/home/home_bookclub.tsxsrc/components/base-ui/home/list_subscribe.tsxsrc/components/base-ui/home/list_subscribe_element.tsxsrc/components/base-ui/home/notification_element.tsxsrc/components/layout/Header.tsxsrc/components/layout/NavItem.tsxsrc/types/groups/groups.tssrc/utils/.keepsrc/utils/groupMapper.tstailwind.config.jstsconfig.json
💤 Files with no reviewable changes (5)
- .github/PULL_REQUEST_TEMPLATE.md
- .github/ISSUE_TEMPLATE/custom.md
- .github/workflows/preview.yml
- .github/workflows/pr-merge-cleanup.yml
- .github/workflows/deploy.yml
🧰 Additional context used
🧬 Code graph analysis (14)
src/components/layout/Header.tsx (1)
src/components/layout/NavItem.tsx (1)
NavItem(9-28)
src/app/(main)/layout.tsx (1)
src/components/layout/Header.tsx (1)
Header(15-97)
src/app/groups/groupSearchDummy.ts (1)
src/app/groups/page.tsx (1)
ClubSummary(19-29)
src/components/base-ui/home/list_subscribe.tsx (1)
src/components/base-ui/home/list_subscribe_element.tsx (1)
ListSubscribeElement(13-53)
src/components/base-ui/home/notification_element.tsx (1)
src/components/base-ui/Profile/notification_element.tsx (1)
NotificationElement(13-50)
src/utils/groupMapper.ts (1)
src/types/groups/groups.ts (2)
Category(3-10)ParticipantType(12-18)
src/components/base-ui/Group-Search/search_club_apply_modal.tsx (2)
src/app/groups/page.tsx (1)
ClubSummary(19-29)src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx (1)
ClubCategoryTags(36-64)
src/app/groups/page.tsx (2)
src/types/groups/groups.ts (2)
ApplyType(1-1)ParticipantType(12-18)src/app/groups/groupSearchDummy.ts (2)
dummyClubs(19-170)mydummyGroup(7-17)
src/app/(main)/ui-test/page.tsx (1)
src/components/base-ui/Settings/setting_report_list.tsx (1)
Setting_ReportList(14-65)
src/components/base-ui/Profile/notification_element.tsx (1)
src/components/base-ui/home/notification_element.tsx (1)
NotificationElement(13-50)
src/app/(main)/page.tsx (4)
src/components/base-ui/home/home_bookclub.tsx (1)
HomeBookclub(12-111)src/components/base-ui/home/list_subscribe.tsx (1)
ListSubscribe(5-31)src/components/base-ui/home/NewsBannerSlider.tsx (1)
NewsBannerSlider(13-46)src/components/base-ui/BookStory/bookstory_card.tsx (1)
BookStoryCard(32-120)
src/app/groups/layout.tsx (1)
src/components/layout/Header.tsx (1)
Header(15-97)
src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx (2)
src/app/groups/page.tsx (1)
ClubSummary(19-29)src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx (1)
ClubCategoryTags(36-64)
src/app/groups/create/page.tsx (3)
src/types/groups/groups.ts (3)
BookCategory(39-39)BOOK_CATEGORIES(21-37)PARTICIPANTS(41-48)src/components/base-ui/Group-Create/StepDot.tsx (1)
StepDot(7-22)src/components/base-ui/Group-Create/Chip.tsx (1)
Chip(14-36)
🔇 Additional comments (23)
src/components/base-ui/home/notification_element.tsx (1)
13-50: Component logic looks good.The message construction, conditional styling, and layout structure are implemented correctly. The truncation for long messages is a good touch.
src/components/base-ui/Profile/others_profile.tsx (2)
35-38: Fixed pixel widths may break responsive layouts.The container (
w-[734px]) and subscribe button (w-[532px]) use fixed pixel widths. Given the PR objective is to implement responsive adjustments, consider using responsive utilities (e.g.,w-full max-w-[734px]) or breakpoint-based classes (t:w-[734px]) to ensure proper rendering on mobile/tablet.Is this component intended only for desktop views, or should it adapt to mobile/tablet screens?
Also applies to: 88-100
41-48: LGTM on Image implementation.Good use of Next.js
Imagewithfill,object-cover, and explicitsizesprop for optimal image loading.src/components/base-ui/Profile/subscribe_element.tsx (1)
15-61: LGTM on the component structure.Good use of optional chaining for
onToggleSubscribe, sensible defaults, and clean conditional styling for the subscribe state toggle.src/components/base-ui/button_without_img.tsx (2)
25-31: LGTM ontoCssColorutility.Clean helper that handles multiple input formats (var() wrapped, bare CSS variable names, and literal colors). Good defensive coding with the early return for undefined values.
64-87: Button implementation is functional and well-structured.The disabled state handling, type safety, and composable className approach are all solid. The component provides good flexibility for styling variations.
src/components/base-ui/Group-Create/StepDot.tsx (1)
7-21: LGTM!The component logic correctly distinguishes between active, done, and pending states. The conditional styling approach is clear and functional.
src/components/layout/NavItem.tsx (1)
9-27: LGTM!The component is well-structured with clear conditional styling for the active state. The interface is properly typed.
One minor consideration: the fixed
w-32width may cause layout issues if nav labels vary significantly in length. If this becomes a problem, considerw-autoormin-w-[...]for flexibility.src/components/layout/Header.tsx (1)
34-50: Navigation implementation looks good.The active state detection logic correctly handles the root path
/with an exact match while usingstartsWithfor nested routes. This prevents/from being marked active on all pages.src/components/base-ui/Settings/setting_news_list.tsx (1)
1-55: LGTM!The component is well-structured with proper use of Next.js
Imagecomponent, appropriate accessibility attributes (alt={title}), and clean flexbox layout for responsive content handling.src/components/base-ui/Group-Create/Chip.tsx (1)
14-35: LGTM!The Chip component is well-implemented with proper button semantics, disabled state handling, and responsive styling using the
t:breakpoint prefix.src/types/groups/groups.ts (1)
1-70: LGTM!Well-structured type definitions with proper use of
as constassertions, derived types from constant arrays, and bidirectional mappings for label/type translations. This provides a solid foundation for type-safe group-related operations.src/utils/groupMapper.ts (1)
30-36: LGTM on the mapping functions.The bidirectional mapping logic is well-structured. Handling
"전체"asnullfor API filtering and the reverse mapping viatoCategoriesare both correct.src/components/base-ui/BookStory/bookstory_detail.tsx (1)
26-98: LGTM!The component is well-structured with proper TypeScript typing, sensible defaults, and clean layout logic. The author link derivation and button handling are implemented correctly.
src/components/base-ui/Search/search_recommendbook.tsx (1)
17-71: LGTM on component structure.Good use of
stopPropagationon the like button to prevent card click, proper fallback for missing images, and clean prop handling.src/app/groups/groupSearchDummy.ts (1)
4-4: No action needed.
ClubSummaryis defined only insrc/app/groups/page.tsxand does not exist insrc/types/groups/groups.ts. The suggested import path is not feasible. Additionally, the referenced pattern (groupMapper.ts) also imports types frompage.tsxrather than a types module, so the coupling concern noted applies equally to the reference example.src/components/base-ui/home/home_bookclub.tsx (1)
77-106: Action buttons lack click handlers.The "모임 검색하기" and "모임 생성하기" buttons have no
onClickhandlers and won't navigate or trigger any action. If this is intentional for a future implementation, consider adding TODO comments or passing handler props similar toonSubscribeClickpattern used elsewhere.src/components/base-ui/Group-Search/search_club_apply_modal.tsx (1)
72-183: LGTM on the modal UI structure.The modal correctly handles overlay clicks for closing, uses proper accessibility patterns with ESC key support, and implements auto-resizing textarea. The layout is well-structured with proper responsive considerations.
src/components/base-ui/BookStory/bookstory_text.tsx (1)
30-50: LGTM on Tab key handling.The Tab indentation logic correctly prevents default behavior, calculates the new string with inserted spaces, and uses
requestAnimationFrameto properly restore cursor position after the state update.src/components/base-ui/Group-Search/search_clublist/search_club_category_tags.tsx (1)
36-63: LGTM on the category tag rendering logic.The deduplication, filtering, sorting, and dynamic styling based on category ranges are well implemented.
src/app/groups/page.tsx (2)
133-140: Verify modal integration after fixing theonSubmitsignature mismatch.The
onSubmitApplyhandler expects(clubId: number, reason: string), but the modal currently callsonSubmit(reason). Once the modal is fixed (see review comment onsearch_club_apply_modal.tsx), this integration will work correctly.
31-59: LGTM on state management and handlers.The state setup for search filters and apply modal control is well-structured. The
onSubmitApplyhandler correctly validates input and includes a TODO for API integration.src/components/base-ui/Group-Search/search_clublist/search_clublist_item.tsx (1)
191-211: LGTM on the mobile apply form.The inline form correctly handles the apply flow: character limit enforcement, disabled state when empty, and properly passes both
club.clubIdandreasontoonSubmitApply.
| export interface ClubSummary { | ||
| reason(clubId: number, reason: string): void; | ||
| clubId: number; | ||
| name: string; | ||
| profileImageUrl?: string | null; // 없으면 기본 이미지 쓰면 됨 | ||
| category: number[]; // 복수 가능 | ||
| public: boolean; | ||
| applytype: ApplyType; | ||
| region: string; | ||
| participantTypes: ParticipantType[]; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Remove the reason method from the ClubSummary interface.
ClubSummary appears to be a data transfer object representing club information. The reason(clubId: number, reason: string): void method declaration on line 20 doesn't belong here—it looks like it was accidentally included. The dummy data in groupSearchDummy.ts confirms that clubs are pure data objects.
🐛 Proposed fix
export interface ClubSummary {
- reason(clubId: number, reason: string): void;
clubId: number;
name: string;
profileImageUrl?: string | null;
category: number[];
public: boolean;
applytype: ApplyType;
region: string;
participantTypes: ParticipantType[];
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export interface ClubSummary { | |
| reason(clubId: number, reason: string): void; | |
| clubId: number; | |
| name: string; | |
| profileImageUrl?: string | null; // 없으면 기본 이미지 쓰면 됨 | |
| category: number[]; // 복수 가능 | |
| public: boolean; | |
| applytype: ApplyType; | |
| region: string; | |
| participantTypes: ParticipantType[]; | |
| } | |
| export interface ClubSummary { | |
| clubId: number; | |
| name: string; | |
| profileImageUrl?: string | null; // 없으면 기본 이미지 쓰면 됨 | |
| category: number[]; // 복수 가능 | |
| public: boolean; | |
| applytype: ApplyType; | |
| region: string; | |
| participantTypes: ParticipantType[]; | |
| } |
🤖 Prompt for AI Agents
In @src/app/groups/page.tsx around lines 19 - 29, Remove the erroneous method
declaration from the ClubSummary interface: delete the line declaring
reason(clubId: number, reason: string): void so ClubSummary only contains data
properties (clubId, name, profileImageUrl, category, public, applytype, region,
participantTypes). Verify there are no consumers expecting a reason method and,
if any exist, refactor them to accept the DTO as data (or move that method to
the appropriate service/class) and update groupSearchDummy.ts to remain valid
with the cleaned interface.
| import React from 'react'; | ||
| import Image from 'next/image'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing 'use client' directive for interactive component.
This component has an onClick handler (line 57), but lacks the 'use client' directive. In Next.js App Router, event handlers require client components.
🐛 Proposed fix
+'use client';
+
import React from 'react';
import Image from 'next/image';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import React from 'react'; | |
| import Image from 'next/image'; | |
| 'use client'; | |
| import React from 'react'; | |
| import Image from 'next/image'; |
🤖 Prompt for AI Agents
In @src/components/base-ui/BookStory/bookstory_choosebook.tsx around lines 1 -
2, This component (bookstory_choosebook.tsx) is interactive (has an onClick
handler) but lacks the required 'use client' directive; fix it by adding the
exact line 'use client' as the very first line of the file (before any imports)
so the component becomes a client component, and verify that the exported
component/function with the onClick handler continues to only use client-safe
APIs (no server-only modules).
| <div className="w-full flex justify-end"> | ||
| <button | ||
| type="button" | ||
| onClick={() => onSubmit(reason)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Critical: onSubmit called with wrong arguments.
The onSubmit prop is typed as (club: number, reason: string) => void (line 24), but here it's called with only reason. This will cause the parent handler to receive incorrect arguments.
🐛 Proposed fix
- onClick={() => onSubmit(reason)}
+ onClick={() => onSubmit(club.clubId, reason)}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| onClick={() => onSubmit(reason)} | |
| onClick={() => onSubmit(club.clubId, reason)} |
🤖 Prompt for AI Agents
In @src/components/base-ui/Group-Search/search_club_apply_modal.tsx at line 169,
onSubmit is typed as (club: number, reason: string) => void but is being called
with only reason; change the call site to pass the club id first (e.g.,
onSubmit(club, reason)) so the parent receives (club: number, reason: string).
Locate the component prop/type that declares the club id (the parameter named
"club" in the prop type) and use that identifier when invoking onSubmit in the
onClick handler.
| const [open, setOpen] = useState(false); | ||
| const wrapRef = useRef<HTMLDivElement | null>(null); | ||
| // 바깥 클릭하면 닫기 | ||
| useEffect(() => { | ||
| const onDown = (e: MouseEvent) => { | ||
| const el = wrapRef.current; | ||
| if (!el) return; | ||
| if (!el.contains(e.target as Node)) setOpen(false); | ||
| }; | ||
| document.addEventListener('mousedown', onDown); | ||
| return () => document.removeEventListener('mousedown', onDown); | ||
| }, []); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: wrapRef is never assigned to an element.
The ref wrapRef is created but never attached to any DOM element, so the outside-click detection in the useEffect will never work (el will always be null).
Proposed fix
Attach the ref to the dropdown container:
-<div className="relative">
+<div className="relative" ref={wrapRef}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
...
>🤖 Prompt for AI Agents
In @src/components/base-ui/Group-Search/search_groupsearch.tsx around lines 49 -
60, The outside-click handler never fires because the ref created with useRef
(wrapRef) is never attached to the DOM; update the component JSX to pass wrapRef
to the dropdown/container element (e.g., add ref={wrapRef} on the element that
wraps the searchable dropdown), ensuring the useEffect's onDown can access
wrapRef.current and call setOpen(false) correctly; keep the existing useEffect,
onDown, useRef, and setOpen logic unchanged.
| import React from 'react'; | ||
| import Image from 'next/image'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Missing 'use client' directive for interactive component.
This component has an onClick handler (line 46), but lacks the 'use client' directive. Event handlers require client components in Next.js App Router.
🐛 Proposed fix
+'use client';
+
import React from 'react';
import Image from 'next/image';📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| import React from 'react'; | |
| import Image from 'next/image'; | |
| 'use client'; | |
| import React from 'react'; | |
| import Image from 'next/image'; |
🤖 Prompt for AI Agents
In @src/components/base-ui/home/list_subscribe_element.tsx around lines 1 - 2,
This component uses an onClick handler but is missing the Next.js client
component directive; add the string "use client" as the very first line of
src/components/base-ui/home/list_subscribe_element.tsx (before any imports) so
the component becomes a client component and its event handler (the onClick in
this file) will work correctly.
| <Image src="/Edit_icon.svg" alt="" width={24} height={24} /> | ||
| <span className="text-[color:var(--Gray_5,#5C5C5C)] subhead_4 whitespace-nowrap"> | ||
| 프로필 편집 | ||
| </span> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Undefined utility class subhead_4.
The class subhead_4 (line 97) is not defined in globals.css. The available subhead utilities are: subhead_1, subhead_2, subhead_3, subhead_3_1, subhead_3_2, and subhead_4_1. This will result in the styling not being applied.
🔧 Suggested fix
<Image src="/Edit_icon.svg" alt="" width={24} height={24} />
- <span className="text-[color:var(--Gray_5,#5C5C5C)] subhead_4 whitespace-nowrap">
+ <span className="text-[color:var(--Gray_5,#5C5C5C)] subhead_4_1 whitespace-nowrap">
프로필 편집
</span>📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| <Image src="/Edit_icon.svg" alt="" width={24} height={24} /> | |
| <span className="text-[color:var(--Gray_5,#5C5C5C)] subhead_4 whitespace-nowrap"> | |
| 프로필 편집 | |
| </span> | |
| <Image src="/Edit_icon.svg" alt="" width={24} height={24} /> | |
| <span className="text-[color:var(--Gray_5,#5C5C5C)] subhead_4_1 whitespace-nowrap"> | |
| 프로필 편집 | |
| </span> |
🤖 Prompt for AI Agents
In @src/components/base-ui/Profile/mypage_profile.tsx around lines 96 - 99, The
span uses a non-existent utility class subhead_4 so its styles won't apply;
update the class to one of the defined utilities (e.g., subhead_4_1 or one of
subhead_1/2/3/3_1/3_2 depending on intended size) by replacing subhead_4 in the
span's className, and verify the visual matches design; ensure no other
occurrences of subhead_4 remain (search for "subhead_4") and run the app to
confirm styling.
| import React from 'react'; | ||
|
|
||
| type NotificationType = 'like' | 'comment'; | ||
|
|
||
| type NotificationElementProps = { | ||
| nickname: string; | ||
| type: NotificationType; | ||
| commentContent?: string; | ||
| timeLabel: string; // "지금", "2분 전" | ||
| isLatest?: boolean; | ||
| }; | ||
|
|
||
| export default function NotificationElement({ | ||
| nickname, | ||
| type, | ||
| commentContent, | ||
| timeLabel, | ||
| isLatest = false, | ||
| }: NotificationElementProps) { | ||
| const textColorClass = isLatest | ||
| ? 'text-[color:var(--Gray_7,#2C2C2C)]' | ||
| : 'text-[color:var(--Gray_3,#BBB)]'; | ||
|
|
||
| const message = | ||
| type === 'like' | ||
| ? `${nickname}님이 좋아요를 눌렀습니다.` | ||
| : `${nickname}님이 댓글을 남겼습니다.${ | ||
| commentContent ? `: ${commentContent}` : '' | ||
| }`; | ||
|
|
||
| return ( | ||
| <div className="flex w-[364px] px-[16px] py-[20px] justify-between items-center border-b border-b-[color:var(--Subbrown_4,#EAE5E2)] bg-[color:var(--White,#FFF)]"> | ||
| {/* left */} | ||
| <div className="flex items-center gap-[12px] min-w-0"> | ||
| {isLatest ? ( | ||
| <span className="w-[12px] h-[12px] rounded-full bg-red-500 shrink-0" /> | ||
| ) : ( | ||
| <span className="w-[12px] h-[12px] shrink-0" /> | ||
| )} | ||
|
|
||
| <p className={`${textColorClass} body_1_2 truncate`}>{message}</p> | ||
| </div> | ||
|
|
||
| {/* right */} | ||
| <span className="shrink-0 text-[color:var(--Gray_3,#BBB)] body_1_2"> | ||
| {timeLabel} | ||
| </span> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion | 🟠 Major
Duplicate component — consolidate into a shared location.
This file is identical to src/components/base-ui/home/notification_element.tsx. Having two copies of the same component violates DRY and creates maintenance burden — any bug fix or enhancement must be applied in both places.
Consider moving this component to a shared location (e.g., src/components/shared/ or src/components/common/) and importing it where needed.
#!/bin/bash
# Verify the files are identical
diff src/components/base-ui/home/notification_element.tsx src/components/base-ui/Profile/notification_element.tsx
echo "Exit code: $? (0 means files are identical)"🤖 Prompt for AI Agents
In @src/components/base-ui/Profile/notification_element.tsx around lines 1 - 50,
Duplicate NotificationElement component exists in two places; consolidate by
extracting the NotificationElement function (and its props type
NotificationElementProps) into a single shared/common component module, export
it, update all imports that currently reference the duplicated copies to import
the shared NotificationElement, and then remove the redundant duplicate file so
only the shared implementation remains.
| <div | ||
| className={` | ||
| flex w-[1000px] p-[20px] items-start gap-[40px] | ||
| rounded-[8px] border border-[color:var(--Subbrown_4,#EAE5E2)] | ||
| bg-[color:var(--White,#FFF)] | ||
| ${className} | ||
| `} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fixed width breaks responsiveness on smaller screens.
The hardcoded w-[1000px] will cause horizontal overflow on mobile and tablet devices. Given the PR objective is to adjust UI for responsive behavior, consider using responsive utilities.
Suggested responsive fix
<div
className={`
- flex w-[1000px] p-[20px] items-start gap-[40px]
+ flex w-full max-w-[1000px] p-[20px] items-start gap-[20px] t:gap-[40px]
rounded-[8px] border border-[color:var(--Subbrown_4,#EAE5E2)]
bg-[color:var(--White,#FFF)]
${className}
`}
>🤖 Prompt for AI Agents
In @src/components/base-ui/Settings/setting_report_list.tsx around lines 22 -
28, The fixed-width class w-[1000px] in the SettingsReportList container causes
overflow on small screens; replace it with responsive sizing (e.g., use w-full
or max-w-[1000px] plus breakpoint aliases) so the component expands to full
width on mobile and caps at 1000px on larger screens—update the class string in
setting_report_list.tsx (the div with className containing w-[1000px]) to
something like w-full sm:w-[1000px] or w-full max-w-[1000px] (preserving
existing padding, gap, border and ${className}).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Code Review
이번 PR은 모임 생성 UI와 모임 검색 UI를 구현하고 반응형 디자인을 적용하는 등 많은 기능이 추가되었네요. 전반적으로 새로운 기능들이 잘 구현되었고, 특히 Tailwind CSS를 활용한 디자인 시스템 구축과 반응형 처리가 인상적입니다. 몇 가지 코드 구조 개선 및 잠재적 버그 수정에 대한 제안을 드립니다. 이 피드백들이 코드의 유지보수성과 안정성을 높이는 데 도움이 되기를 바랍니다. 수고하셨습니다!
| <div className="w-full flex justify-end"> | ||
| <button | ||
| type="button" | ||
| onClick={() => onSubmit(reason)} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
가입 신청 버튼의 onClick 핸들러에서 onSubmit 함수를 호출할 때 club.clubId가 누락되었습니다. onSubmit 함수의 시그니처는 (clubId: number, reason: string) => void 이므로, 첫 번째 인자로 club.clubId를 전달해야 합니다. 이 부분이 수정되지 않으면 가입 신청 기능이 정상적으로 동작하지 않을 수 있습니다.
| onClick={() => onSubmit(reason)} | |
| onClick={() => onSubmit(club.clubId, reason)} |
| export default function CreateClubWizardPreview() { | ||
| const [step, setStep] = useState(1); | ||
|
|
||
| // Step 1 | ||
| const [clubName, setClubName] = useState(""); | ||
| const [clubDescription, setClubDescription] = useState(""); | ||
| const [nameCheck, setNameCheck] = useState<NameCheckState>("idle"); | ||
| const DuplicationCheckisConfirmed = nameCheck === "available"; | ||
| const DuplicationCheckisDisabled = !clubName.trim() || nameCheck === "checking" || DuplicationCheckisConfirmed ; | ||
|
|
||
| // Step 2 | ||
| const [profileMode, setProfileMode] = useState<"default" | "upload">("default"); | ||
| const [selectedImageUrl, setSelectedImageUrl] = useState<string | null>(null); | ||
| const [visibility, setVisibility] = useState<"공개" | "비공개" | null>(null); | ||
| const fileRef = useRef<HTMLInputElement | null>(null); | ||
| // Step 3 | ||
| const [selectedCategories, setSelectedCategories] = useState<BookCategory[]>([]); | ||
| const [selectedParticipants, setSelectedParticipants] = useState<ParticipantLabel[]>([]); | ||
| const [activityArea, setActivityArea] = useState(""); | ||
|
|
||
| // Step 4 | ||
| const [links, setLinks] = useState<SnsLink[]>([{ label: "", url: "" }]); | ||
|
|
||
| const canNext = useMemo(() => { | ||
| if (step === 1) return Boolean(clubName.trim() && clubDescription.trim() && nameCheck === "available"); | ||
| if (step === 2) return Boolean(visibility); | ||
| if (step === 3) return selectedCategories.length > 0 && selectedParticipants.length > 0 && activityArea.trim(); | ||
| if (step === 4) return true; // 선택 | ||
| return false; | ||
| }, [step, clubName, clubDescription,nameCheck ,visibility, selectedCategories, selectedParticipants, activityArea]); | ||
|
|
||
| const onPrev = () => setStep((v) => Math.max(1, v - 1)); | ||
| const onNext = () => setStep((v) => Math.min(4, v + 1)); | ||
|
|
||
| const fakeCheckName = () => { | ||
| if (!clubName.trim()) return; | ||
| setNameCheck("checking"); | ||
| setTimeout(() => { | ||
| // 그냥 프리뷰용: 이름에 "중복" 들어가면 duplicate 처리 | ||
| if (clubName.includes("중복")) setNameCheck("duplicate"); | ||
| else setNameCheck("available"); | ||
| }, 500); | ||
| }; | ||
|
|
||
| const pickImage = (file: File) => { | ||
| const reader = new FileReader(); | ||
| reader.onloadend = () => setSelectedImageUrl(reader.result as string); | ||
| reader.readAsDataURL(file); | ||
| }; | ||
|
|
||
| const toggleWithLimit = <T,>(arr: T[], item: T, limit: number) => { | ||
| if (arr.includes(item)) return arr.filter((x) => x !== item); | ||
| if (arr.length >= limit) return arr; | ||
| return [...arr, item]; | ||
| }; | ||
|
|
||
| const updateLink = (idx: number, patch: Partial<SnsLink>) => { | ||
| setLinks((prev) => prev.map((it, i) => (i === idx ? { ...it, ...patch } : it))); | ||
| }; | ||
|
|
||
| const addLinkRow = () => { | ||
| setLinks((prev) => [...prev, { label: "", url: "" }]); | ||
| }; | ||
|
|
||
| const removeLinkRow = (idx: number) => { | ||
| setLinks((prev) => prev.filter((_, i) => i !== idx)); | ||
| }; | ||
|
|
||
| return ( | ||
| <div className="min-h-screen max-w-[1440px] mx-auto "> | ||
|
|
||
|
|
||
| {/* breadcrumb */} | ||
| <div className="flex gap-5 px-[10px] py-3 border-b border-Gray-2 t:mx-4 "> | ||
| <Link href="/groups" className="shrink-0"> | ||
| <p className="body_1 t:subhead_4_1 cursor-pointer">모임</p> | ||
| </Link> | ||
|
|
||
| <Image | ||
| className="block t:hidden shrink-0" | ||
| src="/Polygon.svg" | ||
| alt="" | ||
| width={9} | ||
| height={9} | ||
| /> | ||
|
|
||
| <Image | ||
| className="hidden t:block shrink-0" | ||
| src="/Polygon.svg" | ||
| alt="" | ||
| width={16} | ||
| height={16} | ||
| /> | ||
|
|
||
| <p className="body_1 t:subhead_4_1">새 모임 생성</p> | ||
| </div> | ||
|
|
||
|
|
||
| <div className="w-full max-w-[1040px] mx-auto px-6 t:px-10 pt-6 pb-16 "> | ||
| {/* step dots */} | ||
| <div className="mb-7"> | ||
| <div className="flex items-center gap-6"> | ||
| <StepDot n={1} current={step} /> | ||
| <StepDot n={2} current={step} /> | ||
| <StepDot n={3} current={step} /> | ||
| <StepDot n={4} current={step} /> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* 본문 박스 */} | ||
| <main className=""> | ||
|
|
||
| {/* STEP 1 */} | ||
| {step === 1 && ( | ||
| <section> | ||
| <h2 className="subhead_4_1 t:subhead_1"> | ||
| 독서 모임 이름을 입력해주세요! | ||
| </h2> | ||
|
|
||
| <div className="mt-4 flex items-center gap-5"> | ||
| <input | ||
| value={clubName} | ||
| onChange={(e) => { | ||
| setClubName(e.target.value); | ||
| setNameCheck("idle"); | ||
| }} | ||
| placeholder="독서 모임 이름을 입력해주세요." | ||
| className="w-full h-[44px] t:h-[56px] rounded-[8px] border border-[#EAE5E2] p-4 outline-none bg-white body_1_3 t:subhead_4_1" | ||
| /> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={fakeCheckName} | ||
| disabled={DuplicationCheckisDisabled} | ||
| aria-busy={nameCheck === "checking"} | ||
| className={cx( | ||
| ` | ||
| flex justify-center items-center | ||
| w-[100px] h-[48px] t:w-[128px] t:h-[56px] | ||
| px-1 py-3 rounded-[8px] | ||
| body_1_2 | ||
| hover:opacity-90 active:opacity-80 | ||
| disabled:opacity-50 disabled:cursor-not-allowed | ||
| `, | ||
| DuplicationCheckisConfirmed | ||
| ? "bg-primary-1 text-White border border-primary-1 disabled:opacity-100 disabled:cursor-default" | ||
| : "border border-Subbrown-3 bg-Subbrown-4 text-primary-3" | ||
| )} | ||
| > | ||
| {nameCheck === "checking" ? "확인중" : "중복확인"} | ||
| </button> | ||
|
|
||
| </div> | ||
|
|
||
| <div className="body_2_3 t:body_1_3 text-Gray-3 mt-3"> | ||
| 다른 이름을 입력하거나, 기수 또는 지역명을 추가해 구분해주세요. | ||
| <br /> | ||
| 예) 독서재량 2기, 독서재량 서울, 북적북적 인문학팀 | ||
| </div> | ||
|
|
||
| <div className="mt-1 text-[12px]"> | ||
| {nameCheck === "available" && ( | ||
| <p className="text-[#367216] body_1_4">사용 가능한 모임 이름입니다.</p> | ||
| )} | ||
| {nameCheck === "duplicate" && ( | ||
| <p className="text-[#FF8045] body_1_4">이미 존재하는 모임 이름입니다.</p> | ||
| )} | ||
| </div> | ||
|
|
||
| <h2 className="mt-[56px] subhead_1 subhead_4_1 t:subhead_1"> | ||
| 모임의 소개글을 입력해주세요! | ||
| </h2> | ||
| <textarea | ||
| value={clubDescription} | ||
| onChange={(e) => { | ||
| setClubDescription(e.target.value); | ||
| autoResize(e.currentTarget); | ||
| }} | ||
| onInput={(e) => autoResize(e.currentTarget)} // 초기/붙여넣기 대응 | ||
| placeholder="자유롭게 입력해주세요! (500자 제한)" | ||
| className=" | ||
| w-full | ||
| min-h-[200px] t:min-h-[260px] | ||
| rounded-[8px] | ||
| border border-Subbrown-4 | ||
| bg-White | ||
| p-5 mt-5 | ||
| no-scrollbar | ||
| resize-none | ||
| outline-none | ||
| Body_1_3 | ||
| placeholder:text-Gray-3 | ||
| " | ||
| /> | ||
| <div className="mt-6 flex justify-end"> | ||
| <button | ||
| type="button" | ||
| onClick={onNext} | ||
| disabled={!canNext} | ||
| className={cx( | ||
| "flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "w-full t:w-[148px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 다음 | ||
| </button> | ||
|
|
||
| </div> | ||
| </section> | ||
| )} | ||
| {/* STEP 2 */} | ||
| {step === 2 && ( | ||
| <section> | ||
| <h2 className="subhead_4_1 t:subhead_1 text-Gray-7"> | ||
| 모임의 프로필 사진을 업로드 해주세요! | ||
| </h2> | ||
|
|
||
| <div className="mt-4 flex items-start gap-6"> | ||
| {/* profile preview */} | ||
| <div className="relative w-[96px] h-[80px] t:w-[194px] t:h-[162px] rounded-[10px] overflow-hidden bg-Subbrown-4 flex items-center justify-center"> | ||
| {selectedImageUrl ? ( | ||
| <img | ||
| src={selectedImageUrl} | ||
| alt="preview" | ||
| className="w-full h-full object-cover" | ||
| /> | ||
| ) : ( | ||
| <> | ||
| {/* mobile default */} | ||
| <Image | ||
| src="/default_profile_2.svg" | ||
| alt="기본 프로필" | ||
| fill | ||
| sizes="96px" | ||
| className="object-contain t:hidden" | ||
| priority | ||
| /> | ||
| {/* t~ desktop default */} | ||
| <Image | ||
| src="/default_profile_1.svg" | ||
| alt="기본 프로필" | ||
| fill | ||
| sizes="194px" | ||
| className="object-contain hidden t:block" | ||
| priority | ||
| /> | ||
| </> | ||
| )} | ||
| </div> | ||
|
|
||
| {/* action buttons */} | ||
| <div className="flex flex-col gap-2"> | ||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setSelectedImageUrl(null); | ||
| setProfileMode("default"); | ||
| }} | ||
| className={cx( | ||
| "flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3", | ||
| "hover:opacity-90 active:opacity-80", | ||
| profileMode === "default" | ||
| ? "bg-primary-1 border-primary-1 text-White" | ||
| : "bg-Subbrown-4 border-Subbrown-3 text-primary-3" | ||
| )} | ||
| > | ||
| 기본 프로필 사용하기 | ||
| </button> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={() => { | ||
| setProfileMode("upload"); | ||
| fileRef.current?.click(); | ||
| }} | ||
| className={cx( | ||
| "flex justify-center items-center gap-[10px] w-[200px] h-[36px] px-4 py-3 rounded-[8px] border body_1_3", | ||
| "hover:opacity-90 active:opacity-80", | ||
| profileMode === "upload" | ||
| ? "bg-primary-1 border-primary-1 text-White" | ||
| : "bg-Subbrown-4 border-Subbrown-3 text-primary-3" | ||
| )} | ||
| > | ||
| 사진 업로드하기 | ||
| </button> | ||
|
|
||
| <input | ||
| ref={fileRef} | ||
| type="file" | ||
| accept="image/*" | ||
| className="hidden" | ||
| onChange={(e) => { | ||
| const f = e.target.files?.[0]; | ||
| if (f) { | ||
| setProfileMode("upload"); | ||
| pickImage(f); | ||
| } | ||
| }} | ||
| /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <h2 className="mt-7 subhead_4_1 t:subhead_1 text-Gray-7"> | ||
| 모임의 공개여부를 알려주세요! | ||
| </h2> | ||
|
|
||
| <div className="mt-4 flex flex-col gap-3 text-Gray-7"> | ||
| <button | ||
| type="button" | ||
| onClick={() => setVisibility("공개")} | ||
| className="flex items-center gap-3 cursor-pointer select-none text-left" | ||
| > | ||
| <Image | ||
| src={visibility === "공개" ? "/CheckBox_Yes.svg" : "/CheckBox_No.svg"} | ||
| alt="" | ||
| width={24} | ||
| height={24} | ||
| /> | ||
| <span className="subhead_4_1">공개</span> | ||
| </button> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={() => setVisibility("비공개")} | ||
| className="flex items-center gap-3 cursor-pointer select-none text-left" | ||
| > | ||
| <Image | ||
| src={visibility === "비공개" ? "/CheckBox_Yes.svg" : "/CheckBox_No.svg"} | ||
| alt="" | ||
| width={24} | ||
| height={24} | ||
| /> | ||
| <span className="subhead_4_1">비공개</span> | ||
| </button> | ||
| </div> | ||
|
|
||
| {/* bottom buttons: mobile = 다음 풀폭, t~ = 이전/다음 오른쪽 정렬 */} | ||
| <div className="mt-7 flex justify-end t:gap-3"> | ||
| <button | ||
| type="button" | ||
| onClick={onPrev} | ||
| className={cx( | ||
| "hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 이전 | ||
| </button> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={onNext} | ||
| disabled={!canNext} | ||
| className={cx( | ||
| "flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "w-full t:w-[148px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 다음 | ||
| </button> | ||
| </div> | ||
| </section> | ||
| )} | ||
| {/* STEP 3 */} | ||
| {step === 3 && ( | ||
| <section> | ||
| <h2 className="subhead_4_1 t:subhead_1"> | ||
| 선호하는 독서 카테고리를 선택해주세요!{" "} | ||
| <span className="hidden t:box Gray_4 caption_1_1">(최대 6개)</span> | ||
| </h2> | ||
|
|
||
| <div className="mt-4 flex flex-wrap gap-2.5"> | ||
| {BOOK_CATEGORIES.map((c) => ( | ||
| <Chip | ||
| key={c} | ||
| label={c} | ||
| selected={selectedCategories.includes(c)} | ||
| onClick={() => setSelectedCategories((prev) => toggleWithLimit(prev, c, 6))} | ||
| /> | ||
| ))} | ||
| </div> | ||
|
|
||
| <h2 className="mt-10 text-[18px] font-semibold text-[#2C2C2C]">활동 지역을 입력해주세요!</h2> | ||
| <input | ||
| value={activityArea} | ||
| onChange={(e) => setActivityArea(e.target.value)} | ||
| placeholder="활동 지역을 입력해주세요 (40자 제한)" | ||
| className="mt-4 w-full h-[44px] t:h-[56px] rounded-[8px] border border-[#EAE5E2] body_1_3 bg-white px-4 outline-none" | ||
| /> | ||
|
|
||
| <h2 className="mt-10 text-[18px] font-semibold text-[#2C2C2C]"> | ||
| 모임의 대상을 선택해주세요! <span className="text-[12px] text-[#8D8D8D]">(최대 6개)</span> | ||
| </h2> | ||
|
|
||
| <div className="mt-4 flex flex-wrap gap-2.5"> | ||
| {PARTICIPANTS.map((p) => ( | ||
| <Chip | ||
| key={p} | ||
| label={p} | ||
| selected={selectedParticipants.includes(p)} | ||
| onClick={() => setSelectedParticipants((prev) => toggleWithLimit(prev, p, 6))} | ||
| /> | ||
| ))} | ||
| </div> | ||
|
|
||
|
|
||
| {/* bottom buttons: mobile = 다음 풀폭, t~ = 이전/다음 오른쪽 정렬 */} | ||
| <div className="mt-7 flex justify-end t:gap-3"> | ||
| <button | ||
| type="button" | ||
| onClick={onPrev} | ||
| className={cx( | ||
| "hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 이전 | ||
| </button> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={onNext} | ||
| disabled={!canNext} | ||
| className={cx( | ||
| "flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "w-full t:w-[148px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 다음 | ||
| </button> | ||
| </div> | ||
| </section> | ||
| )} | ||
|
|
||
| {/* STEP 4 */} | ||
| {step === 4 && ( | ||
| <section> | ||
| <h2 className="subhead_4_1 t:subhead_1 mb-4"> | ||
| SNS나 링크 연동이 있다면 해주세요! (선택) | ||
| </h2> | ||
|
|
||
| <div className=""> | ||
| {links.map((it, idx) => ( | ||
| <div | ||
| key={idx} | ||
| className=" | ||
| flex flex-col gap-4 py-3 | ||
| t:flex-row t:items-center | ||
| " | ||
| > | ||
| <input | ||
| value={it.label} | ||
| onChange={(e) => updateLink(idx, { label: e.target.value })} | ||
| placeholder="링크 대체 텍스트 입력(최대 20자)" | ||
| maxLength={20} | ||
| className=" | ||
| w-full t:w-[35%] h-[44px] t:h-[56px] | ||
| rounded-[8px] | ||
| border border-Subbrown-4 | ||
| bg-White | ||
| px-4 | ||
| outline-none | ||
| body_1_3 | ||
| placeholder:text-Gray-3 | ||
| " | ||
| /> | ||
|
|
||
| <div className="flex gap-3 w-full t:flex-1"> | ||
| <input | ||
| value={it.url} | ||
| onChange={(e) => updateLink(idx, { url: e.target.value })} | ||
| placeholder="링크 입력(최대 100자)" | ||
| maxLength={100} | ||
| className=" | ||
| flex-1 | ||
| h-[44px] t:h-[56px] | ||
| rounded-[8px] | ||
| border border-Subbrown-4 bg-White | ||
| px-4 | ||
| outline-none | ||
| body_1_3 | ||
| placeholder:text-Gray-3 | ||
| " | ||
| /> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={() => removeLinkRow(idx)} | ||
| disabled={links.length <= 1} | ||
| className=" | ||
| w-[44px] h-[44px] t:w-[56px] t:h-[56px] | ||
| rounded-[8px] | ||
| bg-Gray-1 | ||
| flex items-center justify-center | ||
| hover:bg-Gray-2 | ||
| disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-Gray-1 | ||
| " | ||
| title="삭제" | ||
| > | ||
| <Image | ||
| src={"/icon_minus_1.svg"} | ||
| alt="" | ||
| width={24} | ||
| height={24} | ||
| /> | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ))} | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={addLinkRow} | ||
| className=" | ||
| w-full h-[56px] rounded-[8px] | ||
| bg-Gray-1 | ||
| flex items-center justify-center | ||
| hover:bg-Gray-2 | ||
| " | ||
| title="추가" | ||
| > | ||
| <Image src={"/icon_plus_1.svg"} alt="" width={24} height={24} /> | ||
| </button> | ||
| </div> | ||
|
|
||
|
|
||
|
|
||
|
|
||
| <div className="mt-10 flex justify-between"> | ||
| <button | ||
| type="button" | ||
| onClick={onPrev} | ||
| className={cx( | ||
| "hidden t:flex justify-center items-center gap-[10px] w-[148px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 이전 | ||
| </button> | ||
|
|
||
| <button | ||
| type="button" | ||
| onClick={onNext} | ||
| disabled={!canNext} | ||
| className={cx( | ||
| "flex justify-center items-center gap-[10px] h-[48px] px-4 py-3 rounded-[8px]", | ||
| "w-full t:w-[148px]", | ||
| "bg-primary-1 hover:bg-primary-3 text-White", | ||
| "disabled:bg-Gray-2 disabled:hover:bg-Gray-2 disabled:cursor-not-allowed" | ||
| )} | ||
| > | ||
| 다음 | ||
| </button> | ||
|
|
||
| </div> | ||
| </section> | ||
| )} | ||
| </main> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| @@ -0,0 +1,36 @@ | |||
| import { Category, ParticipantType } from "@/app/groups/page"; | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Category와 ParticipantType을 page.tsx 파일에서 가져오고 있습니다. 타입 정의는 src/types/groups/groups.ts와 같은 중앙 관리 파일에서 가져오는 것이 올바른 의존성 관리 방식입니다. 페이지 컴포넌트에서 타입을 가져오면 불필요한 의존성이 생기고 코드 구조가 복잡해질 수 있습니다.
| import { Category, ParticipantType } from "@/app/groups/page"; | |
| import { Category, ParticipantType } from '@/types/groups/groups'; |
| <BookStoryCard | ||
| authorName="hy_0716" | ||
| createdAt="2026.01.03" | ||
| viewCount={302} | ||
| title="나는 나이든 왕자다" | ||
| content="나는 나이든 왕자다. 그 누가 숫자가 중요하다가 했던가. 세고 또 세는 그런 마법같은 경험을 한사람은 놀랍도록 이세상에 얼마 안된다! 나는 숲이 아닌 바다란걸..." | ||
| likeCount={1} | ||
| commentCount={1} | ||
| subscribeText={"구독"} | ||
| /> | ||
| <BookStoryCard | ||
| authorName="hy_0716" | ||
| createdAt="2026.01.03" | ||
| viewCount={302} | ||
| title="나는 나이든 왕자다" | ||
| content="나는 나이든 왕자다. 그 누가 숫자가 중요하다가 했던가. 세고 또 세는 그런 마법같은 경험을 한사람은 놀랍도록 이세상에 얼마 안된다! 나는 숲이 아닌 바다란걸..." | ||
| likeCount={1} | ||
| commentCount={1} | ||
| subscribeText={"구독"} | ||
| /> | ||
| <BookStoryCard | ||
| authorName="hy_0716" | ||
| createdAt="2026.01.03" | ||
| viewCount={302} | ||
| title="나는 나이든 왕자다" | ||
| content="나는 나이든 왕자다. 그 누가 숫자가 중요하다가 했던가. 세고 또 세는 그런 마법같은 경험을 한사람은 놀랍도록 이세상에 얼마 안된다! 나는 숲이 아닌 바다란걸..." | ||
| likeCount={1} | ||
| commentCount={1} | ||
| subscribeText={"구독"} | ||
| /> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
현재 BookStoryCard 컴포넌트가 세 번 반복되어 사용되고 있습니다. 이는 코드 중복을 유발하고 향후 데이터가 동적으로 변경될 때 수정이 번거로워질 수 있습니다. 데이터를 배열로 관리하고 .map() 함수를 사용하여 렌더링하는 방식으로 리팩터링하는 것을 추천합니다. 이렇게 하면 코드가 더 간결해지고 유지보수하기 쉬워집니다.
const bookStories = [
{
authorName: "hy_0716",
createdAt: "2026.01.03",
viewCount: 302,
title: "나는 나이든 왕자다",
content: "나는 나이든 왕자다. 그 누가 숫자가 중요하다가 했던가. 세고 또 세는 그런 마법같은 경험을 한사람은 놀랍도록 이세상에 얼마 안된다! 나는 숲이 아닌 바다란걸...",
likeCount: 1,
commentCount: 1,
subscribeText: "구독",
},
{
authorName: "hy_0716",
createdAt: "2026.01.03",
viewCount: 302,
title: "나는 나이든 왕자다",
content: "나는 나이든 왕자다. 그 누가 숫자가 중요하다가 했던가. 세고 또 세는 그런 마법같은 경험을 한사람은 놀랍도록 이세상에 얼마 안된다! 나는 숲이 아닌 바다란걸...",
likeCount: 1,
commentCount: 1,
subscribeText: "구독",
},
{
authorName: "hy_0716",
createdAt: "2026.01.03",
viewCount: 302,
title: "나는 나이든 왕자다",
content: "나는 나이든 왕자다. 그 누가 숫자가 중요하다가 했던가. 세고 또 세는 그런 마법같은 경험을 한사람은 놀랍도록 이세상에 얼마 안된다! 나는 숲이 아닌 바다란걸...",
likeCount: 1,
commentCount: 1,
subscribeText: "구독",
},
];
// ...
<div className="mt-6 grid grid-cols-3 gap-5">
{bookStories.map((story, index) => (
<BookStoryCard key={index} {...story} />
))}
</div>
| const toggleWithLimit = <T,>(arr: T[], item: T, limit: number) => { | ||
| if (arr.includes(item)) return arr.filter((x) => x !== item); | ||
| if (arr.length >= limit) return arr; | ||
| return [...arr, item]; | ||
| }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| export interface ClubSummary { | ||
| reason(clubId: number, reason: string): void; | ||
| clubId: number; | ||
| name: string; | ||
| profileImageUrl?: string | null; // 없으면 기본 이미지 쓰면 됨 | ||
| category: number[]; // 복수 가능 | ||
| public: boolean; | ||
| applytype: ApplyType; | ||
| region: string; | ||
| participantTypes: ParticipantType[]; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 To Reviewers
tailwind 일부버전의 경우에
global.css 에
@theme {
--breakpoint-t: 768px;
--breakpoint-d: 1440px;
}
를 넣어야 적용이 된다는 사실을 알고 수정했습니다.
🔥 작업 내용 (가능한 구체적으로 작성해 주세요)
📸 작업 결과 (스크린샷)
모임 생성 UI
테블릿 / 데스크탑
2026-01-13.151905.mp4
모바일
2026-01-13.152329.mp4
모임 검색 UI 반응형 조절
2026-01-13.152703.mp4
🔗 관련 이슈
Summary by CodeRabbit
New Features
UI/Style Updates
✏️ Tip: You can customize this high-level summary in your review settings.