-
Notifications
You must be signed in to change notification settings - Fork 1
[feat] 주제 제안 UI 및 기능 구현 #68
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| export * from './topicQueryKeys' | ||
| export * from './useConfirmedTopics' | ||
| export * from './useCreateTopic' | ||
| export * from './useDeleteTopic' | ||
| export * from './useLikeTopic' | ||
| export * from './useProposedTopics' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| /** | ||
| * @file useCreateTopic.ts | ||
| * @description 주제 제안 mutation 훅 | ||
| */ | ||
|
|
||
| import { useMutation, useQueryClient } from '@tanstack/react-query' | ||
|
|
||
| import { ApiError } from '@/api/errors' | ||
|
|
||
| import { createTopic } from '../topics.api' | ||
| import type { CreateTopicParams, CreateTopicResponse } from '../topics.types' | ||
| import { topicQueryKeys } from './topicQueryKeys' | ||
|
|
||
| /** | ||
| * 주제 제안 mutation 훅 | ||
| * | ||
| * @description | ||
| * 주제를 제안하고 관련 쿼리 캐시를 무효화합니다. | ||
| * - 제안된 주제 리스트 캐시 무효화 | ||
| * | ||
| * @example | ||
| * ```tsx | ||
| * const createMutation = useCreateTopic() | ||
| * createMutation.mutate( | ||
| * { gatheringId: 1, meetingId: 2, body: { title: '주제 제목', topicType: 'FREE' } }, | ||
| * { | ||
| * onSuccess: () => { | ||
| * console.log('주제가 제안되었습니다.') | ||
| * }, | ||
| * onError: (error) => { | ||
| * console.error('제안 실패:', error.userMessage) | ||
| * }, | ||
| * } | ||
| * ) | ||
| * ``` | ||
| */ | ||
| export const useCreateTopic = () => { | ||
| const queryClient = useQueryClient() | ||
|
|
||
| return useMutation<CreateTopicResponse, ApiError, CreateTopicParams>({ | ||
| mutationFn: (params: CreateTopicParams) => createTopic(params), | ||
| onSuccess: (_, variables) => { | ||
| // 제안된 주제 목록 캐시 무효화 | ||
| queryClient.invalidateQueries({ | ||
| queryKey: topicQueryKeys.proposedList({ | ||
| gatheringId: variables.gatheringId, | ||
| meetingId: variables.meetingId, | ||
| }), | ||
| }) | ||
| }, | ||
| }) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import type { TopicType } from '@/features/topics/topics.types' | ||
|
|
||
| export const TOPIC_TYPE_META: Record< | ||
| TopicType, | ||
| { | ||
| label: string | ||
| hint: string | ||
| } | ||
| > = { | ||
| FREE: { | ||
| label: '자유형', | ||
| hint: '자유롭게 이야기 나누는 주제입니다', | ||
| }, | ||
| DISCUSSION: { | ||
| label: '토론형', | ||
| hint: '찬반 토론이나 다양한 관점을 나누는 주제입니다', | ||
| }, | ||
| EMOTION: { | ||
| label: '감정 공유형', | ||
| hint: '책을 읽으며 느낀 감정을 공유하는 주제입니다', | ||
| }, | ||
| EXPERIENCE: { | ||
| label: '경험 연결형', | ||
| hint: '책의 내용을 개인 경험과 연결하는 주제입니다', | ||
| }, | ||
| CHARACTER_ANALYSIS: { | ||
| label: '인물 분석형', | ||
| hint: '등장인물의 성격, 동기, 변화를 분석하는 주제입니다', | ||
| }, | ||
| COMPARISON: { | ||
| label: '비교 분석형', | ||
| hint: '다른 작품이나 현실과 비교하는 주제입니다', | ||
| }, | ||
| STRUCTURE: { | ||
| label: '구조 분석형', | ||
| hint: '책의 구성, 서술 방식, 문제를 분석하는 주제입니다', | ||
| }, | ||
| IN_DEPTH: { | ||
| label: '심층 분석형', | ||
| hint: '주제, 상징, 메시지를 깊이 있게 분석하는 주제입니다', | ||
| }, | ||
| CREATIVE: { | ||
| label: '창작형', | ||
| hint: '후속 이야기나 다른 결말을 창작하는 주제입니다', | ||
| }, | ||
| CUSTOM: { | ||
| label: '질문형', | ||
| hint: '궁금한 점을 질문하고 함께 답을 찾는 주제입니다', | ||
| }, | ||
| } | ||
|
|
||
| export const TOPIC_TYPE_OPTIONS = (Object.keys(TOPIC_TYPE_META) as TopicType[]).map((key) => ({ | ||
| value: key, | ||
| ...TOPIC_TYPE_META[key], | ||
| })) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| import { ChevronLeft, Info } from 'lucide-react' | ||
| import { useState } from 'react' | ||
| import { useNavigate, useParams } from 'react-router-dom' | ||
|
|
||
| import { | ||
| TOPIC_TYPE_META, | ||
| TOPIC_TYPE_OPTIONS, | ||
| type TopicType, | ||
| useCreateTopic, | ||
| } from '@/features/topics' | ||
| import { | ||
| Button, | ||
| Container, | ||
| Input, | ||
| Textarea, | ||
| TopicTypeSelectGroup, | ||
| TopicTypeSelectItem, | ||
| } from '@/shared/ui' | ||
| import { useGlobalModalStore } from '@/store' | ||
|
|
||
| export default function TopicCreatePage() { | ||
| const navigate = useNavigate() | ||
| const { gatheringId, meetingId } = useParams<{ gatheringId: string; meetingId: string }>() | ||
| const { openError } = useGlobalModalStore() | ||
| const createMutation = useCreateTopic() | ||
|
|
||
| const [topicType, setTopicType] = useState<TopicType>('FREE') | ||
| const [title, setTitle] = useState('') | ||
| const [description, setDescription] = useState('') | ||
| const [errors, setErrors] = useState<{ title?: string }>({}) | ||
|
|
||
| const topicTypeHint = TOPIC_TYPE_META[topicType].hint | ||
|
|
||
| const validateForm = () => { | ||
| const newErrors: { title?: string } = {} | ||
|
|
||
| if (!title.trim()) { | ||
| newErrors.title = '주제 제목을 입력해주세요.' | ||
| } | ||
|
|
||
| setErrors(newErrors) | ||
| return Object.keys(newErrors).length === 0 | ||
| } | ||
|
|
||
| const handleSubmit = () => { | ||
| if (!validateForm()) return | ||
|
|
||
| const parsedGatheringId = Number(gatheringId) | ||
| const parsedMeetingId = Number(meetingId) | ||
| if ( | ||
| Number.isNaN(parsedGatheringId) || | ||
| parsedGatheringId <= 0 || | ||
| Number.isNaN(parsedMeetingId) || | ||
| parsedMeetingId <= 0 | ||
| ) | ||
| return | ||
|
|
||
| createMutation.mutate( | ||
| { | ||
| gatheringId: parsedGatheringId, | ||
| meetingId: parsedMeetingId, | ||
| body: { | ||
| title: title.trim(), | ||
| description: description.trim() || null, | ||
| topicType, | ||
| }, | ||
| }, | ||
| { | ||
| onSuccess: () => { | ||
| // TODO : 토스트로 교체 | ||
| alert('주제 제안이 완료되었습니다.') | ||
| navigate(-1) | ||
| }, | ||
| onError: (error) => { | ||
| openError('주제 제안 실패', error.userMessage) | ||
| }, | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| return ( | ||
| <> | ||
| {/* 공통컴포넌트로 교체 예정 */} | ||
| <div className="sticky top-0 bg-white -mt-xlarge"> | ||
| <p className="flex typo-body3 text-grey-600 gap-xtiny py-medium"> | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 뒤로 가기 공통 컴포넌트는 제가 레이아웃 정리 작업할 때 같이 진행하면 되는건가요?
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 이거 제가 작업해둔 거 있어요
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 땡큐베리 감사합니다 |
||
| <ChevronLeft size={16} /> 뒤로가기 | ||
| </p> | ||
haruyam15 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| <div className="flex justify-between py-large"> | ||
| <p className="text-black typo-heading3">주제 제안하기</p> | ||
| <Button | ||
| className="w-fit" | ||
| size="small" | ||
| onClick={handleSubmit} | ||
| disabled={createMutation.isPending} | ||
| > | ||
| {createMutation.isPending ? '...' : '제안하기'} | ||
| </Button> | ||
| </div> | ||
| </div> | ||
| {/* 공통컴포넌트로 교체 예정 */} | ||
| <div className="flex flex-col gap-base pb-xlarge"> | ||
| <Container> | ||
| <Container.Title className="typo-subtitle3" required> | ||
| 주제 타입선택 | ||
| </Container.Title> | ||
| <Container.Content> | ||
| <div className="flex flex-col gap-base"> | ||
| {/* TopicTypeSelectGroup이 제네릭 타입 지원해주면 캐스팅 제거 가능 */} | ||
| <TopicTypeSelectGroup | ||
| type="single" | ||
| value={topicType} | ||
| onChange={(value) => setTopicType(value as TopicType)} | ||
| className="grid grid-cols-3 gap-xsmall lg:grid-cols-4 xl:grid-cols-5" | ||
| > | ||
| {TOPIC_TYPE_OPTIONS.map(({ value, label }) => ( | ||
| <TopicTypeSelectItem key={value} value={value} className="typo-body3"> | ||
| {label} | ||
| </TopicTypeSelectItem> | ||
| ))} | ||
| </TopicTypeSelectGroup> | ||
| <p className="typo-body3 text-purple-200 flex gap-tiny items-center"> | ||
| <Info size="16" /> {topicTypeHint} | ||
| </p> | ||
| </div> | ||
| </Container.Content> | ||
| </Container> | ||
|
|
||
| <Container> | ||
| <Container.Title className="typo-subtitle3" required> | ||
| 주제 제목 | ||
| </Container.Title> | ||
| <Container.Content> | ||
| <Input | ||
| maxLength={24} | ||
| placeholder="예: 주인공의 선택은 옳았을까요?" | ||
| value={title} | ||
| onChange={(e) => setTitle(e.target.value)} | ||
| error={!!errors.title} | ||
| errorMessage={errors.title} | ||
| /> | ||
| </Container.Content> | ||
| </Container> | ||
|
|
||
| <Container> | ||
| <Container.Title className="typo-subtitle3">주제 설명</Container.Title> | ||
| <Container.Content> | ||
| <Textarea | ||
| maxLength={150} | ||
| placeholder="어떤 내용을 이야기 나누고 싶은지 자세히 설명해주세요" | ||
| height={150} | ||
| value={description} | ||
| onChange={(e) => setDescription(e.target.value)} | ||
| /> | ||
| </Container.Content> | ||
| </Container> | ||
| </div> | ||
| </> | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export { default as TopicCreatePage } from './TopicCreatePage' |
Uh oh!
There was an error while loading. Please reload this page.