diff --git a/src/assets/icons/people.svg b/src/assets/icons/people.svg new file mode 100644 index 0000000..7b03b9e --- /dev/null +++ b/src/assets/icons/people.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx b/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx index 6d9bedd..3bf41dc 100644 --- a/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx +++ b/src/features/Calendar/components/CustomCalendar/CalendarModals.tsx @@ -18,6 +18,7 @@ type CalendarModalsProps = { eventActions: { onEventColorChange: (eventId: CalendarEvent['id'], color: CalendarEvent['color']) => void onEventTitleConfirm: (eventId: CalendarEvent['id'], title: CalendarEvent['title']) => void + onEventSharedChange: (eventId: CalendarEvent['id'], isShared: boolean) => void onEventTypeChange: (eventId: CalendarEvent['id'], type: 'todo' | 'schedule') => void onEventTimingChange: ( eventId: CalendarEvent['id'], @@ -91,6 +92,7 @@ const DraftBackedModal = ({ onDraftChange={setDraftValues} onEventColorChange={eventActions.onEventColorChange} onEventTitleConfirm={eventActions.onEventTitleConfirm} + onEventSharedChange={eventActions.onEventSharedChange} onEventTypeChange={eventActions.onEventTypeChange} onEventTimingChange={eventActions.onEventTimingChange} /> diff --git a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx index 2e59791..8f17620 100644 --- a/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx +++ b/src/features/Calendar/components/CustomCalendar/CustomCalendar.tsx @@ -100,6 +100,7 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { updateEventTiming, updateEventType, updateEventTitle, + updateEventShared, toggleEventDone, removeEvent, } = useCalendarEvents({ initialEvents: apiEvents }) @@ -494,10 +495,11 @@ const CustomCalendar = ({ onSelectedDateChange }: CustomCalendarProps) => { () => ({ onEventColorChange: updateEventColor, onEventTitleConfirm: updateEventTitle, + onEventSharedChange: updateEventShared, onEventTypeChange: updateEventType, onEventTimingChange: updateEventTiming, }), - [updateEventColor, updateEventTitle, updateEventType, updateEventTiming], + [updateEventColor, updateEventShared, updateEventTitle, updateEventType, updateEventTiming], ) useEffect(() => { onSelectedDateChange?.(selectedDate ?? date) diff --git a/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts b/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts index 7cf1ca3..d067623 100644 --- a/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts +++ b/src/features/Calendar/components/CustomEvent/CustomEvent.style.ts @@ -98,7 +98,7 @@ export const WeekEventContainer = styled.div<{ export const EventTitle = styled.div` font-weight: 400; font-size: 12px; - width: 38px; + width: 75px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; diff --git a/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx b/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx index 960d96c..8f8b7f0 100644 --- a/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx +++ b/src/features/Calendar/components/CustomEvent/CustomMonthEvent.tsx @@ -3,6 +3,8 @@ import { type MouseEvent, useRef, useState } from 'react' import type { EventProps } from 'react-big-calendar' import { createPortal } from 'react-dom' +import People from '@/assets/icons/people.svg?react' + import { getColorPalette } from '../../utils/colorPalette' import type { CalendarEvent } from '../CustomView/CustomDayView' import * as S from './CustomEvent.style' @@ -116,6 +118,14 @@ const CustomMonthEvent = ({ onToggleTodo?.(event.id) }} /> + ) : event.isShared ? ( + ) : ( )} diff --git a/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx b/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx index b39e45c..a0ea818 100644 --- a/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx +++ b/src/features/Calendar/components/CustomEvent/CustomWeekEvent.tsx @@ -2,6 +2,8 @@ import moment from 'moment' import React, { type MouseEvent, useRef, useState } from 'react' import { createPortal } from 'react-dom' +import People from '@/assets/icons/people.svg?react' + import { getColorPalette } from '../../utils/colorPalette' import type { CalendarEvent } from '../CustomView/CustomDayView' import * as S from './CustomEvent.style' @@ -117,6 +119,14 @@ const CustomWeekEvent: React.FC = ({ onToggleTodo?.(event.id) }} /> + ) : event.isShared ? ( + ) : ( )} diff --git a/src/features/Calendar/components/CustomView/CustomWeekView.tsx b/src/features/Calendar/components/CustomView/CustomWeekView.tsx index f0faac2..9d6c6da 100644 --- a/src/features/Calendar/components/CustomView/CustomWeekView.tsx +++ b/src/features/Calendar/components/CustomView/CustomWeekView.tsx @@ -3,6 +3,7 @@ import React from 'react' import type { NavigateAction, ViewStatic } from 'react-big-calendar' import type { EventInteractionArgs } from 'react-big-calendar/lib/addons/dragAndDrop' +import People from '@/assets/icons/people.svg?react' import { getColorPalette } from '@/features/Calendar/utils/colorPalette' import { compareByStart, @@ -286,6 +287,14 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ onToggleTodo?.(segment.event.id) }} /> + ) : segment.event.isShared ? ( + ) : ( )} @@ -346,6 +355,14 @@ const CustomWeekView: React.ComponentType & ViewStatic = (({ onToggleTodo?.(event.id) }} /> + ) : event.isShared ? ( + ) : ( )} diff --git a/src/features/Calendar/components/CustomView/dayView/renderers.tsx b/src/features/Calendar/components/CustomView/dayView/renderers.tsx index 1b8f4a2..d6121ed 100644 --- a/src/features/Calendar/components/CustomView/dayView/renderers.tsx +++ b/src/features/Calendar/components/CustomView/dayView/renderers.tsx @@ -1,6 +1,8 @@ import moment from 'moment' import type { MutableRefObject, Ref } from 'react' +import People from '@/assets/icons/people.svg?react' + import type { CalendarEvent } from '../../../../../shared/types/calendar/types' import { TIMED_SLOT_CONFIG } from '../../../domain/constants' import { getColorPalette } from '../../../utils/colorPalette' @@ -79,6 +81,14 @@ export const renderAllDayEventBadges = ( onToggleTodo?.(event.id) }} /> + ) : event.isShared ? ( + ) : ( )} @@ -236,6 +246,14 @@ export const renderTimeOverlayColumn = ({ onToggleTodo?.(event.id) }} /> + ) : event.isShared ? ( + ) : ( )} diff --git a/src/features/Calendar/hooks/useCalendarEvents.ts b/src/features/Calendar/hooks/useCalendarEvents.ts index b3b5985..c7653be 100644 --- a/src/features/Calendar/hooks/useCalendarEvents.ts +++ b/src/features/Calendar/hooks/useCalendarEvents.ts @@ -135,6 +135,12 @@ export const useCalendarEvents = (options: UseCalendarEventsOptions = {}) => { [], ) + const updateEventShared = useCallback((eventId: CalendarEvent['id'], isShared: boolean) => { + setEvents((prev) => + prev.map((event) => (event.id === eventId ? { ...event, isShared } : event)), + ) + }, []) + // 서버 응답 후 임시 이벤트 id를 실제 id로 교체 const updateEventId = useCallback((tempId: CalendarEvent['id'], nextId: CalendarEvent['id']) => { setEvents((prev) => @@ -171,6 +177,7 @@ export const useCalendarEvents = (options: UseCalendarEventsOptions = {}) => { updateEventTiming, updateEventType, updateEventTitle, + updateEventShared, updateEventId, toggleEventDone, removeEvent, diff --git a/src/shared/constants/scheduleShareFriend.ts b/src/shared/constants/scheduleShareFriend.ts new file mode 100644 index 0000000..04b9a01 --- /dev/null +++ b/src/shared/constants/scheduleShareFriend.ts @@ -0,0 +1,12 @@ +import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' + +// TODO: 친구 목록 API 연동 필요 +export const MOCK_SCHEDULE_SHARE_FRIENDS: ScheduleShareFriend[] = [ + { userName: '김철수', userId: '1', email: 'asdf@asdf.com' }, + { userName: '김철수', userId: '5', email: 'asdf@asdf.com' }, + { userName: '김철수', userId: '6', email: 'asdf@asdf.com' }, + { userName: '김철수', userId: '7', email: 'asdf@asdf.com' }, + { userName: '이영희', userId: '2', email: 'asdf@asdf.com' }, + { userName: '박민수', userId: '3', email: 'asdf@asdf.com' }, + { userName: '최지우', userId: '4', email: 'asdf@asdf.com' }, +] diff --git a/src/shared/hooks/addSchedule/useScheduleFooter.tsx b/src/shared/hooks/addSchedule/useScheduleFooter.tsx index 83913c6..ba48d71 100644 --- a/src/shared/hooks/addSchedule/useScheduleFooter.tsx +++ b/src/shared/hooks/addSchedule/useScheduleFooter.tsx @@ -8,7 +8,7 @@ import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType, ScheduleEditorFormValues } from '@/shared/types/event/event' import type { RecurrenceEventScope } from '@/shared/types/recurrence/recurrence' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' -import SelectColor from '@/shared/ui/common/SelectColor/SelectColor' +import SelectColor from '@/shared/ui/scheduleTodo/SelectColor/SelectColor' type UseScheduleFooterProps = { repeatConfig: RepeatConfig diff --git a/src/shared/hooks/addTodo/useTodoFooter.tsx b/src/shared/hooks/addTodo/useTodoFooter.tsx index fad326a..dab52ff 100644 --- a/src/shared/hooks/addTodo/useTodoFooter.tsx +++ b/src/shared/hooks/addTodo/useTodoFooter.tsx @@ -6,7 +6,7 @@ import type { CalendarEvent } from '@/shared/types/calendar/types' import type { EventColorType } from '@/shared/types/event/event' import type { TodoEditorFormProps } from '@/shared/types/modal/todoEditor' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' -import SelectColor from '@/shared/ui/common/SelectColor/SelectColor' +import SelectColor from '@/shared/ui/scheduleTodo/SelectColor/SelectColor' type UseTodoFooterProps = { repeatConfig: RepeatConfig diff --git a/src/shared/styles/theme.ts b/src/shared/styles/theme.ts index 053f01a..c4f57d8 100644 --- a/src/shared/styles/theme.ts +++ b/src/shared/styles/theme.ts @@ -8,7 +8,10 @@ export const colors = { textColor3: '#757575', // --Text-Color-3 inputColor: '#F7F7F7', red: '#E94B43', // --Red - + share: { + base: '#ECEBFF', + point: '#594FCA', + }, white: '#FDFDFD', // 메뉴 탭 background: '#FAFAFA', // 전체 바탕 black: '#111827', // 모든 블랙 대체 diff --git a/src/shared/types/calendar/types.ts b/src/shared/types/calendar/types.ts index 8ba97e8..72242ee 100644 --- a/src/shared/types/calendar/types.ts +++ b/src/shared/types/calendar/types.ts @@ -14,6 +14,7 @@ export type Event = { isAllDay: boolean color: EventColorType recurrenceGroup: RecurrenceGroup | null + isShared?: boolean } export type CalendarEvent = Omit & { diff --git a/src/shared/types/modal/itemEditor.ts b/src/shared/types/modal/itemEditor.ts index 4f750ef..8901725 100644 --- a/src/shared/types/modal/itemEditor.ts +++ b/src/shared/types/modal/itemEditor.ts @@ -1,5 +1,7 @@ import type { EventColorType, RepeatConfigSchema } from '@/shared/types/event/event' +export type ItemType = 'todo' | 'schedule' + export type ItemEditorDraft = { title: string description: string diff --git a/src/shared/types/modal/scheduleEditor.ts b/src/shared/types/modal/scheduleEditor.ts index 8403bf2..a1ae774 100644 --- a/src/shared/types/modal/scheduleEditor.ts +++ b/src/shared/types/modal/scheduleEditor.ts @@ -14,10 +14,12 @@ export type ScheduleEditorFormProps = { eventId: CalendarEvent['id'] onClose: () => void isEditing?: boolean + isShared?: boolean headerTitlePortalTarget?: HTMLElement | null initialEvent?: CalendarEvent | null onEventColorChange?: (eventId: CalendarEvent['id'], color: EventColorType) => void onEventTitleConfirm?: (eventId: CalendarEvent['id'], title: string) => void + onSharedChange?: (isShared: boolean) => void onEventTimingChange?: ( eventId: CalendarEvent['id'], start: Date, diff --git a/src/shared/types/schedule/shareFriend.ts b/src/shared/types/schedule/shareFriend.ts new file mode 100644 index 0000000..b95b1e9 --- /dev/null +++ b/src/shared/types/schedule/shareFriend.ts @@ -0,0 +1,5 @@ +export type ScheduleShareFriend = { + userName: string + userId: string + email: string +} diff --git a/src/shared/types/schedule/types.ts b/src/shared/types/schedule/types.ts new file mode 100644 index 0000000..d62e23a --- /dev/null +++ b/src/shared/types/schedule/types.ts @@ -0,0 +1,46 @@ +export type PlaceResult = kakao.maps.services.PlacesSearchResultItem + +export type PlaceMarker = { + id: string + placeName: string + position: { + lat: number + lng: number + } +} + +export type SearchPlaceSelectionOptions = { + closeAfterSelect?: boolean + address?: string | null +} + +export type SearchPlaceProps = { + selectedLocation?: string + onSelectLocation: (location: string, options?: SearchPlaceSelectionOptions) => void +} + +export type SearchState = 'idle' | 'loading' | 'success' | 'zero' | 'error' +export type ResultsView = 'hidden' | 'inline' | 'expanded' + +export type SearchPlacePanelProps = { + panelTitle: string + panelCaption: string | null + panelMessage: string + panelPlaces: PlaceResult[] + recentSearches: string[] + selectedPlaceId: string | null + shouldShowRecentSearches: boolean + showExpandedResults: boolean + showPreviewResults: boolean + onRecentSearchClick: (value: string) => void + onRemoveRecentSearch: (value: string) => void + onSelectPlace: (place: PlaceResult) => void +} + +export type UseSearchPlaceParams = Pick< + SearchPlaceProps, + 'onSelectLocation' | 'selectedLocation' +> & { + isKakaoLoading: boolean + kakaoAppKey: string +} diff --git a/src/shared/types/scheduleTodo/titleSuggestionInput.ts b/src/shared/types/scheduleTodo/titleSuggestionInput.ts new file mode 100644 index 0000000..604ce1b --- /dev/null +++ b/src/shared/types/scheduleTodo/titleSuggestionInput.ts @@ -0,0 +1,29 @@ +import type { + FieldValues, + Path, + UseFormRegister, + UseFormSetValue, + UseFormWatch, +} from 'react-hook-form' + +export type TitleSuggestionInputFormController = { + register: UseFormRegister + watch: UseFormWatch + setValue: UseFormSetValue +} + +export type TitleSuggestionInputProps = { + fieldName: Path + placeholder?: string + suggestions?: string[] + autoFocus?: boolean + formController?: TitleSuggestionInputFormController + inputColor?: string + onConfirm?: (value: string) => void + onLiveChange?: (value: string) => void +} + +export type HighlightedSegment = { + text: string + highlight?: boolean +} diff --git a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts index c52c117..00f24ce 100644 --- a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts +++ b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.style.ts @@ -22,7 +22,6 @@ export const ModalOverlay = styled.div` ${media.down(theme.breakPoints.tablet)} { position: fixed; align-items: flex-start; - padding-top: 60px; box-sizing: border-box; gap: 0; } @@ -56,7 +55,6 @@ export const ModalWrapper = styled.div<{ mode: 'modal' | 'inline' }>` inset: 0; ${media.down(theme.breakPoints.desktop)} { position: fixed; - padding-top: 60px; align-self: center; justify-self: center; gap: 0; @@ -105,12 +103,14 @@ export const ModalContent = styled.div` display: flex; flex-direction: column; overflow-y: scroll; + overflow-x: visible; + max-height: 600px; scrollbar-gutter: stable both-edges; ` export const ModalFooter = styled.div` display: flex; justify-content: flex-end; - padding: 0px 24px 24px 24px; + padding: 24px; ` export const Button = styled.button` background-color: ${theme.colors.primary2}; @@ -120,6 +120,11 @@ export const Button = styled.button` display: flex; align-items: center; justify-content: center; + gap: 6px; + color: ${theme.colors.white}; + font-size: 14px; + font-weight: 500; + white-space: nowrap; cursor: pointer; ` @@ -130,6 +135,8 @@ export const FooterRight = styled.div` ` export const FooterLeft = styled.div` flex: 1; + display: flex; + align-items: center; ` export const InlineWrapper = styled.div` diff --git a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx index e25f4e4..fdc3282 100644 --- a/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx +++ b/src/shared/ui/Modals/ItemEditorModal/EditorModalLayout.tsx @@ -15,6 +15,7 @@ const EditorModalLayout = ({ submitFormId, handleDelete, headerExtras, + submitButtonLabel, mode, headerTitleContainerRef, modalWrapperRef, @@ -28,6 +29,7 @@ const EditorModalLayout = ({ mode: 'modal' | 'inline' handleDelete?: () => void headerExtras?: React.ReactNode + submitButtonLabel?: string headerTitleContainerRef?: Ref modalWrapperRef?: Ref }) => { @@ -64,6 +66,7 @@ const EditorModalLayout = ({ } } > + {submitButtonLabel && {submitButtonLabel}} diff --git a/src/shared/ui/Modals/ItemEditorModal/index.tsx b/src/shared/ui/Modals/ItemEditorModal/index.tsx index 4b96831..cfea258 100644 --- a/src/shared/ui/Modals/ItemEditorModal/index.tsx +++ b/src/shared/ui/Modals/ItemEditorModal/index.tsx @@ -1,29 +1,17 @@ -import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import type { CalendarEvent } from '@/shared/types/calendar/types' -import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' +import type { ItemEditorDraft, ItemType } from '@/shared/types/modal/itemEditor' import ScheduleEditorForm from '@/shared/ui/Modals/ScheduleEditor/ScheduleEditorForm' import TodoEditorForm from '@/shared/ui/Modals/TodoEditor/TodoEditorForm' -import { buildDefaultItemEditorDraft } from '@/shared/utils' +import { useEditorDraft } from '@/shared/utils/useEditorDraft' +import { useEditorRegistry } from '@/shared/utils/useEditorRegistry' +import { useEditorTypeSync } from '@/shared/utils/useEditorTypeSync' import EditorModalLayout from './EditorModalLayout' import * as S from './ItemEditorModal.style' -type ItemType = 'todo' | 'schedule' - -const buildDateTime = (fallbackDate: string, dateValue?: Date | null, timeValue?: string) => { - const nextDate = dateValue ? new Date(dateValue) : new Date(fallbackDate) - if (!timeValue) { - nextDate.setHours(0, 0, 0, 0) - return nextDate - } - - const [hour, minute] = timeValue.split(':').map((value) => Number.parseInt(value, 10)) - nextDate.setHours(Number.isNaN(hour) ? 0 : hour, Number.isNaN(minute) ? 0 : minute, 0, 0) - return nextDate -} - type ItemEditorModalProps = { onClose: () => void date: string @@ -35,6 +23,7 @@ type ItemEditorModalProps = { initialEvent?: CalendarEvent | null onEventColorChange?: (eventId: CalendarEvent['id'], color: CalendarEvent['color']) => void onEventTitleConfirm?: (eventId: CalendarEvent['id'], title: CalendarEvent['title']) => void + onEventSharedChange?: (eventId: CalendarEvent['id'], isShared: boolean) => void onEventTypeChange?: (eventId: CalendarEvent['id'], type: ItemType) => void onEventTimingChange?: ( eventId: CalendarEvent['id'], @@ -58,135 +47,43 @@ const ItemEditorModal = ({ initialEvent = null, onEventColorChange, onEventTitleConfirm, + onEventSharedChange, onEventTypeChange, onEventTimingChange, draftValues: externalDraftValues, onDraftChange: onExternalDraftChange, }: ItemEditorModalProps) => { - const [activeType, setActiveType] = useState(initialType) - const [internalDraftValues, setInternalDraftValues] = useState(() => - isEditing ? null : buildDefaultItemEditorDraft(date, initialType, initialEvent), - ) - const draftValues = externalDraftValues ?? internalDraftValues - const draftValuesRef = useRef(draftValues) - const setDraftValues = useCallback( - (draft: ItemEditorDraft | null) => { - if (onExternalDraftChange) { - onExternalDraftChange(draft) - return - } - setInternalDraftValues(draft) - }, - [onExternalDraftChange], - ) - const [footerChildren, setFooterChildren] = useState(null) - const [deleteHandler, setDeleteHandler] = useState<() => void>(() => () => undefined) - const [closeGuard, setCloseGuard] = useState boolean)>(null) - const [modalWrapperElement, setModalWrapperElement] = useState(null) - const modalWrapperRef = useRef(null) - const previousActiveTypeRef = useRef(activeType) - const noopDeleteHandler = useCallback(() => undefined, []) - - useEffect(() => { - draftValuesRef.current = draftValues - }, [draftValues]) - - const registerDeleteHandler = useCallback( - (handler?: (() => void) | null) => { - setDeleteHandler(() => handler ?? noopDeleteHandler) - }, - [noopDeleteHandler], - ) - - const registerFooterChildren = useCallback((node: React.ReactNode | null) => { - setFooterChildren((prev) => (prev === node ? prev : node)) - }, []) - - const registerCloseGuard = useCallback((guard?: (() => boolean) | null) => { - setCloseGuard((prev) => { - const next = guard ?? null - return prev === next ? prev : next - }) - }, []) - - const handleClose = useCallback(() => { - if (closeGuard && !closeGuard()) return - onClose() - }, [closeGuard, onClose]) - - useEffect(() => { - setActiveType(initialType) - }, [initialType]) - - useEffect(() => { - if (externalDraftValues !== undefined) return - setInternalDraftValues( - isEditing ? null : buildDefaultItemEditorDraft(date, initialType, initialEvent), - ) - }, [date, externalDraftValues, initialEvent, initialType, isEditing]) - - useEffect(() => { - if (eventId == null || eventId === 0) return - onEventTypeChange?.(eventId, activeType) - }, [activeType, eventId, onEventTypeChange]) - - useEffect(() => { - if (!showTypeTabs) return - if (previousActiveTypeRef.current === activeType) return - previousActiveTypeRef.current = activeType - if (eventId == null || eventId === 0) return - if (!onEventTimingChange) return - - const latestDraftValues = draftValuesRef.current - const startDate = - latestDraftValues?.startDate ?? (initialEvent?.start ? new Date(initialEvent.start) : null) - const endDate = - latestDraftValues?.endDate ?? (initialEvent?.end ? new Date(initialEvent.end) : startDate) - const isAllDay = latestDraftValues?.isAllday ?? initialEvent?.isAllDay ?? false - const occurrenceDate = initialEvent?.occurrenceDate - - if (activeType === 'todo') { - if (isAllDay) { - const start = new Date(startDate ?? new Date(date)) - start.setHours(0, 0, 0, 0) - const end = new Date(start) - end.setHours(23, 59, 59, 999) - onEventTimingChange(eventId, start, end, true, occurrenceDate) - return - } - - const point = buildDateTime( - date, - startDate, - latestDraftValues?.endTime ?? latestDraftValues?.startTime, - ) - onEventTimingChange(eventId, point, point, false, occurrenceDate) - return - } - - if (isAllDay) { - const start = new Date(startDate ?? new Date(date)) - start.setHours(0, 0, 0, 0) - const end = new Date(endDate ?? start) - end.setHours(23, 59, 59, 999) - onEventTimingChange(eventId, start, end, true, occurrenceDate) - return - } - - const start = buildDateTime(date, startDate, latestDraftValues?.startTime) - const end = buildDateTime(date, endDate ?? startDate, latestDraftValues?.endTime) - onEventTimingChange(eventId, start, end, false, occurrenceDate) - }, [ - activeType, + const { draftValues, draftValuesRef, setDraftValues } = useEditorDraft({ date, + initialType, + initialEvent, + isEditing, + externalDraftValues, + onExternalDraftChange, + }) + + const { + footerChildren, + deleteHandler, + handleClose, + registerDeleteHandler, + registerFooterChildren, + registerCloseGuard, + } = useEditorRegistry(onClose) + + const { activeType, setActiveType, isScheduleShared, setIsScheduleShared } = useEditorTypeSync({ + initialType, + showTypeTabs, eventId, - initialEvent?.end, - initialEvent?.isAllDay, - initialEvent?.occurrenceDate, - initialEvent?.start, + date, + initialEvent, + draftValuesRef, + onEventTypeChange, onEventTimingChange, - showTypeTabs, - ]) + }) + + const [modalWrapperElement, setModalWrapperElement] = useState(null) + const modalWrapperRef = useRef(null) const handleSubmit = useCallback(() => { const submitFormId = activeType === 'todo' ? 'add-todo-form' : 'add-schedule-form' @@ -230,6 +127,7 @@ const ItemEditorModal = ({ if (typeof document === 'undefined') return null return document.getElementById('modal-root') }, []) + const [headerTitlePortalTarget, setHeaderTitlePortalTarget] = useState(null) const handleHeaderTitleRef = useCallback((node: HTMLDivElement | null) => { setHeaderTitlePortalTarget(node) @@ -238,6 +136,15 @@ const ItemEditorModal = ({ modalWrapperRef.current = node setModalWrapperElement((prev) => (prev === node ? prev : node)) }, []) + const handleSharedChange = useCallback( + (isShared: boolean) => { + setIsScheduleShared(isShared) + if (eventId == null || eventId === 0) return + onEventSharedChange?.(eventId, isShared) + }, + [eventId, onEventSharedChange, setIsScheduleShared], + ) + const layout = ( )} diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx index dc9414c..4b246cc 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleDateTimeSection.tsx @@ -6,9 +6,9 @@ import { useScheduleCalendarOverlay } from '@/shared/hooks/addSchedule' import { useCalendarFieldPicker } from '@/shared/hooks/form/useCalendarFieldPicker' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import type { ScheduleEditorFormProps } from '@/shared/types/modal/scheduleEditor' +import CustomDatePicker from '@/shared/ui/calendar/CustomDatePicker/CustomDatePicker' +import CustomTimePicker from '@/shared/ui/calendar/CustomTimePicker/CustomTimePicker' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' -import CustomDatePicker from '@/shared/ui/common/CustomDatePicker/CustomDatePicker' -import CustomTimePicker from '@/shared/ui/common/CustomTimePicker/CustomTimePicker' import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' import { formatDisplayDate } from '@/shared/utils/date' diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx index 0a435d1..c6b48ef 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleDetailsSection.tsx @@ -5,8 +5,8 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useSchedulePlaceOverlay } from '@/shared/hooks/addSchedule' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' import type { ScheduleEditorFormProps } from '@/shared/types/modal/scheduleEditor' -import SearchPlace from '@/shared/ui/common/SearchPlace/SearchPlace' import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' +import SearchPlace from '@/shared/ui/scheduleTodo/SearchPlace/SearchPlace' type ScheduleDetailsSectionProps = Pick diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx index 0579599..a080d93 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleEditorContent.tsx @@ -29,6 +29,7 @@ const ScheduleEditorContent = ({ registerCloseGuard, registerFooterChildren, isEditing = false, + isShared = false, headerTitlePortalTarget, modalWrapperElement, initialEvent, @@ -36,6 +37,7 @@ const ScheduleEditorContent = ({ onEventColorChange, onEventTitleConfirm, onEventTimingChange, + onSharedChange, schedule, }: ScheduleEditorContentProps) => { const { @@ -179,12 +181,14 @@ const ScheduleEditorContent = ({ & { updateConfig: (changes: Partial) => void handleRepeatType: (value: RepeatType) => void handleAllDayToggle: () => void onTitleConfirm: (value: string) => void + onSharedChange?: (isShared: boolean) => void } const ScheduleEditorFields = ({ headerTitlePortalTarget, isEditing = false, + isShared = false, modalWrapperElement, mode = 'modal', updateConfig, handleRepeatType, handleAllDayToggle, onTitleConfirm, + onSharedChange, }: ScheduleEditorFieldsProps) => { return ( <> @@ -33,12 +38,14 @@ const ScheduleEditorFields = ({ + > ) } diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx index 047e61c..af8cdf7 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleRepeatSection.tsx @@ -1,12 +1,13 @@ // 일정 반복 규칙과 종료 조건을 편집하는 섹션입니다. +import { useState } from 'react' import { useFormContext, useWatch } from 'react-hook-form' import type { RepeatConfigSchema, ScheduleEditorFormValues } from '@/shared/types/event/event' import type { RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' -import CustomBasisPanel from '@/shared/ui/common/CustomBasisPanel/CustomBasisPanel' -import RepeatTypeGroup from '@/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup' -import TerminationPanel from '@/shared/ui/common/TerminationPanel/TerminationPanel' +import CustomBasisPanel from '@/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel' import * as S from '@/shared/ui/Modals/ScheduleEditor/index.style' +import RepeatTypeGroup from '@/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup' +import TerminationPanel from '@/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel' type ScheduleRepeatSectionProps = { updateConfig: (changes: Partial) => void @@ -17,32 +18,52 @@ const ScheduleRepeatSection = ({ updateConfig, handleRepeatType }: ScheduleRepea const { control } = useFormContext() const repeatConfig = useWatch({ control, name: 'repeatConfig' }) as RepeatConfigSchema const eventEndDate = useWatch({ control, name: 'eventEndDate' }) ?? null + const [isRepeatDetailOpen, setIsRepeatDetailOpen] = useState(false) + const hasRepeatType = Boolean(repeatConfig && repeatConfig.repeatType !== 'none') if (!repeatConfig) return null + const handleToggleRepeatType = (value: RepeatType) => { + const willClearRepeatType = repeatConfig.repeatType === value && value !== 'custom' + const willClearCustomRepeat = repeatConfig.repeatType === 'custom' && value === 'custom' + + handleRepeatType(value) + if (willClearRepeatType || willClearCustomRepeat) { + setIsRepeatDetailOpen(false) + } + } + return ( { + if (!hasRepeatType) return + setIsRepeatDetailOpen((previous) => !previous) + }} /> - - {repeatConfig.repeatType === 'custom' && ( - - )} - {repeatConfig.repeatType !== 'none' && ( - - )} - + {hasRepeatType && isRepeatDetailOpen && ( + + {repeatConfig.repeatType === 'custom' && ( + + )} + {repeatConfig.repeatType !== 'none' && ( + + )} + + )} ) } diff --git a/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx b/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx index e184750..06c54c4 100644 --- a/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx +++ b/src/shared/ui/Modals/ScheduleEditor/ScheduleTitleField.tsx @@ -4,18 +4,21 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useThrottledValue } from '@/shared/hooks/common/useThrottledValue' import { useEventTitleHistoryQuery } from '@/shared/hooks/query/useCalendarQueries' +import { theme } from '@/shared/styles/theme' import type { ScheduleEditorFormValues } from '@/shared/types/event/event' -import TitleSuggestionInput from '@/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput' +import TitleSuggestionInput from '@/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput' type ScheduleTitleFieldProps = { portalTarget?: HTMLElement | null autoFocus?: boolean + isShared?: boolean onTitleConfirm: (value: string) => void } const ScheduleTitleField = ({ portalTarget, autoFocus = true, + isShared = false, onTitleConfirm, }: ScheduleTitleFieldProps) => { const { control } = useFormContext() @@ -33,6 +36,7 @@ const ScheduleTitleField = ({ placeholder="새로운 일정" autoFocus={autoFocus} suggestions={suggestions} + inputColor={isShared ? theme.colors.share.point : undefined} onLiveChange={onTitleConfirm} onConfirm={onTitleConfirm} /> diff --git a/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx new file mode 100644 index 0000000..24ec701 --- /dev/null +++ b/src/shared/ui/Modals/ScheduleEditor/ShareSchedulePanel.tsx @@ -0,0 +1,81 @@ +import { useEffect, useState } from 'react' + +import Arrow from '@/shared/assets/icons/chevron.svg?react' +import Close from '@/shared/assets/icons/close.svg?react' +import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' +import SearchFriend from '@/shared/ui/scheduleTodo/SearchFriend/SearchFriend' + +import * as S from './index.style' + +type ShareSchedulePanelProps = { + onSharedChange?: (isShared: boolean) => void +} + +const ShareSchedulePanel = ({ onSharedChange }: ShareSchedulePanelProps) => { + const [isOpen, setIsOpen] = useState(false) + const [selectedFriends, setSelectedFriends] = useState([]) + + useEffect(() => { + onSharedChange?.(selectedFriends.length > 0) + }, [onSharedChange, selectedFriends.length]) + + const handleToggleFriend = (friend: ScheduleShareFriend) => { + setSelectedFriends((previous) => { + const isSelected = previous.some((selectedFriend) => selectedFriend.userId === friend.userId) + + if (isSelected) { + return previous.filter((selectedFriend) => selectedFriend.userId !== friend.userId) + } + + return [...previous, friend] + }) + } + + const handleRemoveFriend = (friendId: ScheduleShareFriend['userId']) => { + setSelectedFriends((previous) => previous.filter((friend) => friend.userId !== friendId)) + } + + return ( + + setIsOpen(!isOpen)} + isOpen={isOpen} + isShared={selectedFriends.length > 0} + > + + + 공유 일정 + + + + {isOpen && ( + + + + + {selectedFriends.map((friend) => ( + + {friend.userName} + { + event.stopPropagation() + handleRemoveFriend(friend.userId) + }} + aria-label={`${friend.userName} 삭제`} + title={`${friend.userName} 삭제`} + > + + + + ))} + + + )} + + ) +} + +export default ShareSchedulePanel diff --git a/src/shared/ui/Modals/ScheduleEditor/index.style.ts b/src/shared/ui/Modals/ScheduleEditor/index.style.ts index 099837b..4235a4c 100644 --- a/src/shared/ui/Modals/ScheduleEditor/index.style.ts +++ b/src/shared/ui/Modals/ScheduleEditor/index.style.ts @@ -139,8 +139,6 @@ export const CalendarPlaceholder = styled.div` export const Section = styled.div` display: flex; flex-direction: column; - padding: 0 20px; - gap: 20px; margin-top: 24px; ` export const TextareaWrapper = styled.div` @@ -183,3 +181,74 @@ export const BottomSection = styled.div` flex-direction: column; gap: 12px; ` + +export const FriendWrapper = styled.div` + display: flex; + width: 100%; + flex-direction: column; +` +export const FriendSection = styled.div` + display: flex; + flex-direction: column; + gap: 12px; + padding: 12px 30px; + background-color: #f7f7f7; + .added-friend-list { + display: flex; + gap: 6px; + flex-wrap: wrap; + } + .added-friend { + border-radius: 40px; + font-size: 12px; + border: 1px solid #d5d4e3; + background: #fff; + display: flex; + padding: 6px 8px 6px 12px; + justify-content: center; + align-items: center; + gap: 5px; + width: fit-content; + color: #5d5b71; + } + .remove-friend-button { + border: none; + background: transparent; + color: #d5d4e3; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + } +` +export const FriendSectionOpenButton = styled.button<{ isOpen: boolean; isShared: boolean }>` + display: flex; + width: 100%; + justify-content: space-between; + padding: 12px 20px; + cursor: pointer; + align-items: center; + .arrow { + transform: ${({ isOpen }) => (isOpen ? 'rotate(-90deg)' : 'rotate(180deg)')}; + } + .section-title { + display: flex; + gap: 4px; + align-items: center; + border-radius: 40px; + padding: 6px 8px; + background-color: ${({ isShared }) => (isShared ? theme.colors.share.base : '#f7f7f7')}; + border: 1px solid ${({ isShared }) => (isShared ? '#f0f0f0' : '#eaeaea')}; + font-size: 12px; + font-weight: 600; + color: ${({ isShared }) => (isShared ? theme.colors.share.point : theme.colors.textColor3)}; + } + .dot { + width: 5px; + height: 5px; + border-radius: 50%; + background-color: ${({ isShared }) => + isShared ? theme.colors.share.point : theme.colors.textColor3}; + } +` diff --git a/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx b/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx index 488d280..84d7c64 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoDateTimeSection.tsx @@ -5,9 +5,9 @@ import { useFormContext, useWatch } from 'react-hook-form' import { useTodoCalendarOverlay } from '@/shared/hooks/addTodo' import type { TodoEditorFormValues } from '@/shared/types/event/event' import type { TodoEditorFormProps } from '@/shared/types/modal/todoEditor' +import CustomDatePicker from '@/shared/ui/calendar/CustomDatePicker/CustomDatePicker' +import CustomTimePicker from '@/shared/ui/calendar/CustomTimePicker/CustomTimePicker' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' -import CustomDatePicker from '@/shared/ui/common/CustomDatePicker/CustomDatePicker' -import CustomTimePicker from '@/shared/ui/common/CustomTimePicker/CustomTimePicker' import * as S from '@/shared/ui/Modals/TodoEditor/index.style' import { formatDisplayDate } from '@/shared/utils/date' diff --git a/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx b/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx index fc4f519..f9c11fe 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoRepeatSection.tsx @@ -1,12 +1,13 @@ // 할 일 반복 규칙과 종료 조건을 편집하는 섹션입니다. +import { useState } from 'react' import { useFormContext, useWatch } from 'react-hook-form' import type { RepeatConfigSchema, TodoEditorFormValues } from '@/shared/types/event/event' import type { RepeatConfig, RepeatType } from '@/shared/types/recurrence/repeat' -import CustomBasisPanel from '@/shared/ui/common/CustomBasisPanel/CustomBasisPanel' -import RepeatTypeGroup from '@/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup' -import TerminationPanel from '@/shared/ui/common/TerminationPanel/TerminationPanel' +import CustomBasisPanel from '@/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel' import * as S from '@/shared/ui/Modals/TodoEditor/index.style' +import RepeatTypeGroup from '@/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup' +import TerminationPanel from '@/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel' type TodoRepeatSectionProps = { updateConfig: (changes: Partial) => void @@ -17,9 +18,21 @@ const TodoRepeatSection = ({ updateConfig, handleRepeatType }: TodoRepeatSection const { control } = useFormContext() const repeatConfig = useWatch({ control, name: 'repeatConfig' }) as RepeatConfigSchema const todoDate = useWatch({ control, name: 'todoDate' }) ?? null + const [isRepeatDetailOpen, setIsRepeatDetailOpen] = useState(false) + const hasRepeatType = Boolean(repeatConfig && repeatConfig.repeatType !== 'none') if (!repeatConfig) return null + const handleToggleRepeatType = (value: RepeatType) => { + const willClearRepeatType = repeatConfig.repeatType === value && value !== 'custom' + const willClearCustomRepeat = repeatConfig.repeatType === 'custom' && value === 'custom' + + handleRepeatType(value) + if (willClearRepeatType || willClearCustomRepeat) { + setIsRepeatDetailOpen(false) + } + } + const repeatEndDate = todoDate ? new Date(todoDate) : null repeatEndDate?.setHours(0, 0, 0, 0) @@ -28,24 +41,32 @@ const TodoRepeatSection = ({ updateConfig, handleRepeatType }: TodoRepeatSection { + if (!hasRepeatType) return + setIsRepeatDetailOpen((previous) => !previous) + }} /> - - {repeatConfig.repeatType === 'custom' && ( - - )} - {repeatConfig.repeatType !== 'none' && ( - - )} - + {hasRepeatType && isRepeatDetailOpen && ( + + {repeatConfig.repeatType === 'custom' && ( + + )} + {repeatConfig.repeatType !== 'none' && ( + + )} + + )} ) } diff --git a/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx b/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx index 0192299..03412c2 100644 --- a/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx +++ b/src/shared/ui/Modals/TodoEditor/TodoTitleField.tsx @@ -6,7 +6,7 @@ import { useThrottledValue } from '@/shared/hooks/common/useThrottledValue' import { useGetTodoTitleHistoryQuery } from '@/shared/hooks/query/useTodoQueries' import type { CalendarEvent } from '@/shared/types/calendar/types' import type { TodoEditorFormValues } from '@/shared/types/event/event' -import TitleSuggestionInput from '@/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput' +import TitleSuggestionInput from '@/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput' type TodoTitleFieldProps = { eventId: CalendarEvent['id'] diff --git a/src/shared/ui/Modals/TodoEditor/index.style.ts b/src/shared/ui/Modals/TodoEditor/index.style.ts index 1092682..f3766b6 100644 --- a/src/shared/ui/Modals/TodoEditor/index.style.ts +++ b/src/shared/ui/Modals/TodoEditor/index.style.ts @@ -182,9 +182,7 @@ export const CalendarPlaceholder = styled.div` export const Section = styled.div` display: flex; flex-direction: column; - padding: 0 20px; - gap: 20px; - margin-top: 24px; + margin-top: 10px; ` export const TextareaWrapper = styled.div` width: 100%; diff --git a/src/shared/ui/common/CustomBasisPanel/CustomBasisPanel.tsx b/src/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel.tsx similarity index 97% rename from src/shared/ui/common/CustomBasisPanel/CustomBasisPanel.tsx rename to src/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel.tsx index 33c1c4a..9f1cd58 100644 --- a/src/shared/ui/common/CustomBasisPanel/CustomBasisPanel.tsx +++ b/src/shared/ui/calendar/CustomBasisPanel/CustomBasisPanel.tsx @@ -8,7 +8,7 @@ import { MonthlyRepeatPanel, WeeklyRepeatPanel, YearlyRepeatPanel, -} from '@/shared/ui/common/RepeatPanel' +} from '@/shared/ui/scheduleTodo/RepeatPanel' type Props = { config: RepeatConfigSchema diff --git a/src/shared/ui/common/CustomDatePicker/CustomDatePicker.style.ts b/src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.style.ts similarity index 87% rename from src/shared/ui/common/CustomDatePicker/CustomDatePicker.style.ts rename to src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.style.ts index 78a276b..3f11f57 100644 --- a/src/shared/ui/common/CustomDatePicker/CustomDatePicker.style.ts +++ b/src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.style.ts @@ -21,6 +21,22 @@ export const CustomCalendarTitle = styled.span` color: ${theme.colors.textColor2}; ` +export const NavButton = styled.button` + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + padding: 4px; + border-radius: 4px; + cursor: pointer; + color: ${theme.colors.textColor2}; + + &:hover { + background-color: rgba(0, 0, 0, 0.06); + } +` + export const CustomCalendarClose = styled.button` border: none; background: transparent; diff --git a/src/shared/ui/common/CustomDatePicker/CustomDatePicker.tsx b/src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.tsx similarity index 89% rename from src/shared/ui/common/CustomDatePicker/CustomDatePicker.tsx rename to src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.tsx index e83383a..bedfcbf 100644 --- a/src/shared/ui/common/CustomDatePicker/CustomDatePicker.tsx +++ b/src/shared/ui/calendar/CustomDatePicker/CustomDatePicker.tsx @@ -46,20 +46,23 @@ const CustomDatePicker = ({ selectedDate, onSelectDate }: DatePickerRenderProps) {`${displayMonth.getFullYear()}`} - setMonthOffset((prev) => prev - 1)} - /> - + > + + + {`${(displayMonth.getMonth() + 1).toString().padStart(2, '0')}월`} - setMonthOffset((prev) => prev + 1)} - css={{ rotate: '180deg' }} - /> + > + + {WEEKDAYS.map((label, index) => ( diff --git a/src/shared/ui/common/CustomTimePicker/CustomTimePicker.style.ts b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.style.ts similarity index 100% rename from src/shared/ui/common/CustomTimePicker/CustomTimePicker.style.ts rename to src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.style.ts diff --git a/src/shared/ui/common/CustomTimePicker/CustomTimePicker.tsx b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx similarity index 95% rename from src/shared/ui/common/CustomTimePicker/CustomTimePicker.tsx rename to src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx index 5cad401..87bd130 100644 --- a/src/shared/ui/common/CustomTimePicker/CustomTimePicker.tsx +++ b/src/shared/ui/calendar/CustomTimePicker/CustomTimePicker.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from 'react' +import { type ChangeEvent, useEffect, useRef } from 'react' import type { TimePickerRenderProps } from '@/shared/types/event/event' @@ -28,7 +28,7 @@ const CustomTimePicker = ({ value = '09:00', onChange }: TimePickerRenderProps) const sanitizeDigits = (value: string) => value.replace(/[^0-9]/g, '').slice(0, 2) const formatTwoDigits = (value?: string) => value?.padStart(2, '0') ?? '00' - const handleInputChange = (e: React.ChangeEvent, type: 'hour' | 'minute') => { + const handleInputChange = (e: ChangeEvent, type: 'hour' | 'minute') => { const max = type === 'hour' ? 23 : 59 const digits = sanitizeDigits(e.target.value) let val = digits diff --git a/src/shared/ui/common/Checkbox/Checkbox.style.tsx b/src/shared/ui/common/Checkbox/Checkbox.style.tsx index 2550548..b483b29 100644 --- a/src/shared/ui/common/Checkbox/Checkbox.style.tsx +++ b/src/shared/ui/common/Checkbox/Checkbox.style.tsx @@ -7,9 +7,9 @@ export const Checkbox = styled.input<{ isChecked?: boolean }>` appearance: none; width: 100%; height: 100%; - border: none; + border: ${(props) => (props.isChecked ? 'none' : '1px solid #c4c4c4')}; border-radius: 4px; - background-color: ${(props) => (props.isChecked ? theme.colors.primary2 : '#D2D3D2')}; + background-color: ${(props) => (props.isChecked ? theme.colors.primary2 : 'transparent')}; cursor: pointer; transition: background-color 150ms ease; ` diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.types.ts b/src/shared/ui/common/SearchPlace/SearchPlace.types.ts deleted file mode 100644 index f9dcdcf..0000000 --- a/src/shared/ui/common/SearchPlace/SearchPlace.types.ts +++ /dev/null @@ -1,23 +0,0 @@ -export type PlaceResult = kakao.maps.services.PlacesSearchResultItem - -export type PlaceMarker = { - id: string - placeName: string - position: { - lat: number - lng: number - } -} - -export type SearchPlaceSelectionOptions = { - closeAfterSelect?: boolean - address?: string | null -} - -export type SearchPlaceProps = { - selectedLocation?: string - onSelectLocation: (location: string, options?: SearchPlaceSelectionOptions) => void -} - -export type SearchState = 'idle' | 'loading' | 'success' | 'zero' | 'error' -export type ResultsView = 'hidden' | 'inline' | 'expanded' diff --git a/src/shared/ui/common/SearchPlace/useSearchPlace.ts b/src/shared/ui/common/SearchPlace/useSearchPlace.ts deleted file mode 100644 index 7da6e93..0000000 --- a/src/shared/ui/common/SearchPlace/useSearchPlace.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react' - -import type { - PlaceMarker, - PlaceResult, - ResultsView, - SearchPlaceProps, - SearchState, -} from './SearchPlace.types' -import { - AUTO_SEARCH_DELAY_MS, - getPlaceAddressValue, - getPlaceLocationValue, - MAX_RECENT_SEARCHES, - readRecentSearches, - toMarker, - writeRecentSearches, -} from './SearchPlace.utils' - -type UseSearchPlaceParams = Pick & { - isKakaoLoading: boolean - kakaoAppKey: string -} - -const useSearchPlace = ({ - onSelectLocation, - selectedLocation = '', - isKakaoLoading, - kakaoAppKey, -}: UseSearchPlaceParams) => { - const trimmedSelectedLocation = selectedLocation.trim() - const [keyword, setKeyword] = useState(selectedLocation) - const [map, setMap] = useState(null) - const [markers, setMarkers] = useState([]) - const [places, setPlaces] = useState([]) - const [selectedPlaceId, setSelectedPlaceId] = useState(null) - const [recentSearches, setRecentSearches] = useState(() => readRecentSearches()) - const [searchState, setSearchState] = useState('idle') - const [resultsView, setResultsView] = useState('hidden') - const [isSearching, setIsSearching] = useState(false) - const [statusMessage, setStatusMessage] = useState('') - const restoredLocationRef = useRef('') - const lastRequestedKeywordRef = useRef('') - const requestIdRef = useRef(0) - const trimmedKeyword = keyword.trim() - const inlinePlaces = places.slice(0, 4) - const isTypedLocationSelected = - trimmedKeyword.length > 0 && - trimmedKeyword === trimmedSelectedLocation && - selectedPlaceId === null - const showPreviewResults = - resultsView === 'inline' && searchState === 'success' && inlinePlaces.length > 0 - const showExpandedResults = - resultsView === 'expanded' && searchState === 'success' && places.length > 0 - const shouldShowRecentSearches = resultsView === 'hidden' && recentSearches.length > 0 - const hasSelectedPlacePreview = - resultsView === 'hidden' && - searchState === 'success' && - selectedPlaceId !== null && - trimmedSelectedLocation.length > 0 - const panelTitle = showExpandedResults - ? '검색 결과' - : showPreviewResults - ? '자동 검색' - : hasSelectedPlacePreview - ? '선택된 장소' - : shouldShowRecentSearches - ? '최근 검색어' - : trimmedKeyword - ? '검색 상태' - : '장소 찾기' - const panelCaption = showExpandedResults - ? `${places.length}개` - : showPreviewResults - ? `${inlinePlaces.length}개 미리보기` - : hasSelectedPlacePreview - ? '지도에 표시 중' - : shouldShowRecentSearches - ? `최대 ${MAX_RECENT_SEARCHES}개` - : null - const panelMessage = - resultsView === 'expanded' || resultsView === 'inline' - ? statusMessage - : hasSelectedPlacePreview - ? '현재 선택된 장소를 기준으로 지도를 보여주고 있습니다.' - : '최근 검색어가 없습니다.' - const panelPlaces = showExpandedResults ? places : inlinePlaces - - const saveRecentSearch = useCallback((value: string) => { - setRecentSearches((previous) => { - const next = [value, ...previous.filter((item) => item !== value)].slice( - 0, - MAX_RECENT_SEARCHES, - ) - - writeRecentSearches(next) - return next - }) - }, []) - - const removeRecentSearch = useCallback((value: string) => { - setRecentSearches((previous) => { - const next = previous.filter((item) => item !== value) - writeRecentSearches(next) - return next - }) - }, []) - - const selectPlace = useCallback( - (place: PlaceResult) => { - const nextLocation = getPlaceLocationValue(place) - const nextAddress = getPlaceAddressValue(place) - setSelectedPlaceId(place.id) - setKeyword(nextLocation) - setSearchState('success') - saveRecentSearch(trimmedKeyword || nextLocation) - onSelectLocation(nextLocation, { closeAfterSelect: true, address: nextAddress }) - - if (!map) return - map.panTo(new kakao.maps.LatLng(Number(place.y), Number(place.x))) - map.setLevel(3) - }, - [map, onSelectLocation, saveRecentSearch, trimmedKeyword], - ) - - const searchPlaces = useCallback( - (rawKeyword: string, options?: { view?: ResultsView }) => { - const nextKeyword = rawKeyword.trim() - const nextView = options?.view ?? 'expanded' - - if (!nextKeyword) { - setPlaces([]) - setMarkers([]) - setSelectedPlaceId(null) - setSearchState('idle') - setResultsView('hidden') - setStatusMessage('검색어를 입력해주세요.') - return - } - - if (!kakaoAppKey) { - setSearchState('error') - setResultsView(nextView) - setStatusMessage('카카오맵 API 키가 설정되지 않았습니다.') - return - } - - if (isKakaoLoading || typeof kakao === 'undefined' || !kakao.maps.services) { - setSearchState('loading') - setResultsView(nextView) - setStatusMessage('지도를 불러오는 중입니다. 잠시 후 다시 시도해주세요.') - return - } - - const requestId = requestIdRef.current + 1 - requestIdRef.current = requestId - lastRequestedKeywordRef.current = nextKeyword - setIsSearching(true) - setSearchState('loading') - setResultsView(nextView) - setStatusMessage(`"${nextKeyword}" 검색 중...`) - - const placesService = new kakao.maps.services.Places() - placesService.keywordSearch(nextKeyword, (result, status) => { - if (requestId !== requestIdRef.current) return - - setIsSearching(false) - - if (status === kakao.maps.services.Status.OK) { - const nextMarkers = result.map(toMarker) - const matchedPlace = result.find( - (place) => getPlaceLocationValue(place) === trimmedSelectedLocation, - ) - - setPlaces(result) - setMarkers(nextMarkers) - setSelectedPlaceId(matchedPlace?.id ?? null) - setSearchState('success') - setStatusMessage( - nextView === 'expanded' - ? `${result.length}개의 장소를 찾았습니다.` - : `${result.length}개 장소가 검색되었습니다.`, - ) - return - } - - setPlaces([]) - setMarkers([]) - setSelectedPlaceId(null) - - if (status === kakao.maps.services.Status.ZERO_RESULT) { - setSearchState('zero') - setStatusMessage('검색 결과가 없습니다.') - return - } - - setSearchState('error') - setStatusMessage('장소 검색 중 오류가 발생했습니다. 다시 시도해주세요.') - }) - }, - [isKakaoLoading, kakaoAppKey, trimmedSelectedLocation], - ) - - useEffect(() => { - if (!map || markers.length === 0 || typeof kakao === 'undefined') return - - const bounds = new kakao.maps.LatLngBounds() - - markers.forEach((marker) => { - bounds.extend(new kakao.maps.LatLng(marker.position.lat, marker.position.lng)) - }) - - map.setBounds(bounds) - }, [map, markers]) - - const handleUseTypedLocation = useCallback(() => { - if (!trimmedKeyword) return - - setSelectedPlaceId(null) - setSearchState('zero') - setResultsView('hidden') - saveRecentSearch(trimmedKeyword) - onSelectLocation(trimmedKeyword, { closeAfterSelect: true, address: null }) - }, [onSelectLocation, saveRecentSearch, trimmedKeyword]) - - const handleSubmit = useCallback(() => { - if (!trimmedKeyword) return - - if ( - lastRequestedKeywordRef.current === trimmedKeyword && - (searchState === 'success' || searchState === 'zero') - ) { - saveRecentSearch(trimmedKeyword) - setResultsView('expanded') - return - } - - saveRecentSearch(trimmedKeyword) - searchPlaces(trimmedKeyword, { view: 'expanded' }) - }, [saveRecentSearch, searchPlaces, searchState, trimmedKeyword]) - - const handleMarkerClick = useCallback( - (markerId: string) => { - const place = places.find((item) => item.id === markerId) - if (!place) return - selectPlace(place) - }, - [places, selectPlace], - ) - - const handleRecentSearchClick = useCallback( - (value: string) => { - setKeyword(value) - saveRecentSearch(value) - searchPlaces(value, { view: 'expanded' }) - }, - [saveRecentSearch, searchPlaces], - ) - - const handleKeywordChange = useCallback((nextValue: string) => { - setKeyword(nextValue) - setSelectedPlaceId(null) - setResultsView('hidden') - setIsSearching(false) - requestIdRef.current += 1 - restoredLocationRef.current = '' - lastRequestedKeywordRef.current = '' - - if (nextValue.trim()) { - setPlaces([]) - setMarkers([]) - setSearchState('idle') - setStatusMessage('') - return - } - - setPlaces([]) - setMarkers([]) - setSearchState('idle') - setStatusMessage('장소명을 입력해주세요.') - }, []) - - useEffect(() => { - if (!map || !trimmedSelectedLocation || isKakaoLoading) return - if (restoredLocationRef.current === trimmedSelectedLocation) return - if (lastRequestedKeywordRef.current === trimmedSelectedLocation) { - restoredLocationRef.current = trimmedSelectedLocation - return - } - - const timeoutId = window.setTimeout(() => { - restoredLocationRef.current = trimmedSelectedLocation - searchPlaces(trimmedSelectedLocation, { view: 'hidden' }) - }, 0) - - return () => window.clearTimeout(timeoutId) - }, [isKakaoLoading, map, searchPlaces, trimmedSelectedLocation]) - - useEffect(() => { - if (!trimmedKeyword || isKakaoLoading) return - if ( - trimmedKeyword === trimmedSelectedLocation && - restoredLocationRef.current === trimmedSelectedLocation - ) { - return - } - if (lastRequestedKeywordRef.current === trimmedKeyword) return - - const timeoutId = window.setTimeout(() => { - searchPlaces(trimmedKeyword, { view: 'inline' }) - }, AUTO_SEARCH_DELAY_MS) - - return () => window.clearTimeout(timeoutId) - }, [isKakaoLoading, searchPlaces, trimmedKeyword, trimmedSelectedLocation]) - - const handleMapCreate = useCallback((createdMap: kakao.maps.Map) => { - setMap(createdMap) - }, []) - - return { - handleKeywordChange, - handleMapCreate, - handleMarkerClick, - handleRecentSearchClick, - handleSubmit, - handleUseTypedLocation, - isSearching, - isTypedLocationSelected, - keyword, - markers, - panelCaption, - panelMessage, - panelPlaces, - panelTitle, - recentSearches, - removeRecentSearch, - selectedPlaceId, - showExpandedResults, - showPreviewResults, - shouldShowRecentSearches, - trimmedKeyword, - selectPlace, - } -} - -export default useSearchPlace diff --git a/src/shared/ui/common/RecentSearch/RecentSearch.style.ts b/src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.style.ts similarity index 100% rename from src/shared/ui/common/RecentSearch/RecentSearch.style.ts rename to src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.style.ts diff --git a/src/shared/ui/common/RecentSearch/RecentSearch.tsx b/src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.tsx similarity index 100% rename from src/shared/ui/common/RecentSearch/RecentSearch.tsx rename to src/shared/ui/scheduleTodo/RecentSearch/RecentSearch.tsx diff --git a/src/shared/ui/common/RepeatPanel/DailyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/DailyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/DailyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/DailyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/MonthlyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/MonthlyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/MonthlyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/MonthlyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/RepeatPanel.style.ts b/src/shared/ui/scheduleTodo/RepeatPanel/RepeatPanel.style.ts similarity index 94% rename from src/shared/ui/common/RepeatPanel/RepeatPanel.style.ts rename to src/shared/ui/scheduleTodo/RepeatPanel/RepeatPanel.style.ts index 4d03194..ef571ed 100644 --- a/src/shared/ui/common/RepeatPanel/RepeatPanel.style.ts +++ b/src/shared/ui/scheduleTodo/RepeatPanel/RepeatPanel.style.ts @@ -10,7 +10,7 @@ export const InlineInput = styled.input` border: 1px solid ${theme.colors.lightGray}; border-radius: 8px; padding: 6px 10px; - width: 70px; + width: 55px; background: ${theme.colors.white}; font-size: 16px; color: ${theme.colors.textColor2}; @@ -34,12 +34,11 @@ export const MultiSelectMonthGrid = styled.div` export const DayChip = styled.button<{ isActive?: boolean }>` background: ${(props) => (props.isActive ? '#e9f4f7' : theme.colors.white)}; color: ${(props) => (props.isActive ? theme.colors.textPrimary : theme.colors.textColor2)}; - border-radius: 16px; - width: 36px; - height: 36px; + border-radius: 50%; + width: 33px; + height: 33px; font-size: 14px; min-width: 32px; - box-shadow: 0 0 1.6px 0 rgba(0, 0, 0, 0.16); cursor: pointer; ` @@ -88,8 +87,12 @@ export const NumberRepeat = styled.div` ` export const DayGrid = styled.div` display: flex; - justify-content: center; align-items: center; width: 100%; gap: 6px; + font-size: 14px; + .text { + margin-right: 16px; + color: #757575; + } ` diff --git a/src/shared/ui/common/RepeatPanel/WeeklyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/WeeklyRepeatPanel.tsx similarity index 97% rename from src/shared/ui/common/RepeatPanel/WeeklyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/WeeklyRepeatPanel.tsx index 18c4101..e29d60d 100644 --- a/src/shared/ui/common/RepeatPanel/WeeklyRepeatPanel.tsx +++ b/src/shared/ui/scheduleTodo/RepeatPanel/WeeklyRepeatPanel.tsx @@ -40,6 +40,7 @@ const WeeklyRepeatPanel = ({ config, updateConfig }: Props) => { 주마다 반복 + 요일 지정 {WEEKDAYS.map((day) => { const isActive = config.customWeeklyDays?.includes(day.key) ?? false const toggleDay = () => { diff --git a/src/shared/ui/common/RepeatPanel/YearlyRepeatPanel.tsx b/src/shared/ui/scheduleTodo/RepeatPanel/YearlyRepeatPanel.tsx similarity index 100% rename from src/shared/ui/common/RepeatPanel/YearlyRepeatPanel.tsx rename to src/shared/ui/scheduleTodo/RepeatPanel/YearlyRepeatPanel.tsx diff --git a/src/shared/ui/common/RepeatPanel/index.ts b/src/shared/ui/scheduleTodo/RepeatPanel/index.ts similarity index 100% rename from src/shared/ui/common/RepeatPanel/index.ts rename to src/shared/ui/scheduleTodo/RepeatPanel/index.ts diff --git a/src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.style.ts b/src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.style.ts similarity index 71% rename from src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.style.ts rename to src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.style.ts index 83bf590..0b35ff0 100644 --- a/src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.style.ts +++ b/src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.style.ts @@ -7,13 +7,35 @@ export const RepeatRow = styled.div` gap: 8px; align-items: center; height: fit-content; + width: 100%; + padding: 0 20px; + ${media.down(theme.breakPoints.mobile)} { + align-items: flex-start; + } +` + +export const IconButton = styled.button<{ isOpen: boolean }>` + border: none; + background: transparent; + color: ${theme.colors.textColor3}; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + .icon { - ${media.down(theme.breakPoints.mobile)} { - display: none; - } + transform: ${({ isOpen }) => (isOpen ? 'rotate(180deg)' : 'rotate(0deg)')}; + transition: transform 0.2s ease; } + + &:disabled { + cursor: not-allowed; + opacity: 0.4; + } + ${media.down(theme.breakPoints.mobile)} { - align-items: flex-start; + display: none; } ` diff --git a/src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.tsx b/src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.tsx similarity index 64% rename from src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.tsx rename to src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.tsx index 153ebf2..c38ee4c 100644 --- a/src/shared/ui/common/RepeatTypeGroup/RepeatTypeGroup.tsx +++ b/src/shared/ui/scheduleTodo/RepeatTypeGroup/RepeatTypeGroup.tsx @@ -8,12 +8,31 @@ type Props = { repeatType: RepeatType customBasis?: CustomRepeatBasis | null onToggleType: (value: RepeatType) => void + canToggleDetail: boolean + isDetailOpen: boolean + onToggleDetail: () => void } -const RepeatTypeGroup = ({ repeatType, customBasis, onToggleType }: Props) => ( +const RepeatTypeGroup = ({ + repeatType, + customBasis, + onToggleType, + canToggleDetail, + isDetailOpen, + onToggleDetail, +}: Props) => ( 반복 - + + + {MAIN_OPTIONS.map((option) => ( ` + position: fixed; + left: ${({ position }) => position.left}px; + top: ${({ position }) => position.top}px; + width: ${({ position }) => position.width}px; + z-index: 22000; + background-color: ${theme.colors.white}; + border-radius: 20px; + padding: 12px; + gap: 4px; + display: flex; + flex-direction: column; + max-height: 158px; + overflow-y: auto; + overflow-anchor: none; +` + +export const SearchResultItem = styled.button<{ isAdded: boolean }>` + width: 100%; + border: none; + padding: 7px 24px; + border-radius: 16px; + align-items: center; + cursor: pointer; + display: flex; + font-size: 14px; + font-weight: 500; + transition: + background-color 0.2s ease, + color 0.2s ease; + background-color: ${(props) => (props.isAdded ? theme.colors.share.base : '#f1f1f1')}; + + .divider { + width: 1px; + height: 16px; + background-color: #d2d3d2; + margin: 0 8px; + } + .plus { + margin-left: auto; + } + .close { + margin-left: auto; + } +` + +export const EmptySearchResult = styled.div` + width: 100%; + padding: 14px 16px; + border-radius: 16px; + background-color: #f1f1f1; + color: ${theme.colors.textColor3}; + font-size: 14px; + font-weight: 500; + text-align: center; +` + +export const Name = styled.div<{ isAdded: boolean }>` + color: ${(props) => (props.isAdded ? '#484569' : '#655446')}; +` + +export const Email = styled.div` + color: ${theme.colors.textColor2}; + font-weight: 400; +` diff --git a/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx new file mode 100644 index 0000000..cf69d2c --- /dev/null +++ b/src/shared/ui/scheduleTodo/SearchFriend/SearchFriend.tsx @@ -0,0 +1,177 @@ +import { useEffect, useMemo, useRef, useState } from 'react' +import { createPortal } from 'react-dom' + +import Close from '@/shared/assets/icons/close.svg?react' +import Plus from '@/shared/assets/icons/plus.svg?react' +import Search from '@/shared/assets/icons/search.svg?react' +import { MOCK_SCHEDULE_SHARE_FRIENDS } from '@/shared/constants/scheduleShareFriend' +import { theme } from '@/shared/styles' +import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' +import { isElementVisible } from '@/shared/utils/domVisibility' +import { filterScheduleShareFriends } from '@/shared/utils/scheduleShareFriend' + +import * as S from './SearchFriend.style' + +type SearchFriendProps = { + selectedFriends: ScheduleShareFriend[] + onToggleFriend: (friend: ScheduleShareFriend) => void +} + +const SearchFriend = ({ selectedFriends, onToggleFriend }: SearchFriendProps) => { + const [keyword, setKeyword] = useState('') + const [isOpen, setIsOpen] = useState(false) + const [resultPosition, setResultPosition] = useState({ left: 0, top: 0, width: 0 }) + const inputWrapperRef = useRef(null) + const resultRef = useRef(null) + const selectedFriendIds = useMemo( + () => new Set(selectedFriends.map((friend) => friend.userId)), + [selectedFriends], + ) + const trimmedKeyword = keyword.trim().toLowerCase() + const filteredFriends = useMemo( + () => filterScheduleShareFriends(MOCK_SCHEDULE_SHARE_FRIENDS, trimmedKeyword), + [trimmedKeyword], + ) + + const shouldShowResult = isOpen && Boolean(trimmedKeyword) && filteredFriends.length > 0 + + const updateResultPosition = () => { + const inputWrapper = inputWrapperRef.current + if (!inputWrapper) return + + if (!isElementVisible(inputWrapper)) { + setIsOpen(false) + return + } + + const rect = inputWrapper.getBoundingClientRect() + + setResultPosition({ + left: rect.left + 12, + top: rect.bottom, + width: rect.width - 24, + }) + } + + const renderFriendResult = () => { + if (!shouldShowResult || typeof document === 'undefined') return null + + return createPortal( + + {filteredFriends.map((friend) => { + const isSelected = selectedFriendIds.has(friend.userId) + + return ( + onToggleFriend(friend)} + > + {friend.userName} + + {friend.email} + {isSelected ? ( + + ) : ( + + )} + + ) + })} + , + document.body, + ) + } + + useEffect(() => { + if (!shouldShowResult) return + + const animationFrameId = window.requestAnimationFrame(updateResultPosition) + + window.addEventListener('resize', updateResultPosition) + window.addEventListener('scroll', updateResultPosition, true) + + return () => { + window.cancelAnimationFrame(animationFrameId) + window.removeEventListener('resize', updateResultPosition) + window.removeEventListener('scroll', updateResultPosition, true) + } + }, [selectedFriends.length, shouldShowResult]) + + useEffect(() => { + if (!isOpen) return undefined + + const handlePointerDown = (event: PointerEvent) => { + const target = event.target + + if (!(target instanceof Node)) return + if (inputWrapperRef.current?.contains(target)) return + if (resultRef.current?.contains(target)) return + + setIsOpen(false) + } + + document.addEventListener('pointerdown', handlePointerDown) + + return () => { + document.removeEventListener('pointerdown', handlePointerDown) + } + }, [isOpen]) + + const handleKeywordChange = (value: string) => { + const hasKeyword = value.trim().length > 0 + + setKeyword(value) + setIsOpen(hasKeyword) + } + + return ( + + + handleKeywordChange(event.target.value)} + onKeyDown={(event) => { + if (event.key !== 'Enter') return + event.preventDefault() + event.stopPropagation() + setIsOpen(Boolean(trimmedKeyword)) + }} + /> + + { + setIsOpen(true) + }} + aria-label="친구 검색" + title="친구 검색" + > + + + + + {renderFriendResult()} + + ) +} + +export default SearchFriend diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.style.ts b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.style.ts similarity index 100% rename from src/shared/ui/common/SearchPlace/SearchPlace.style.ts rename to src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.style.ts diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.tsx b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.tsx similarity index 96% rename from src/shared/ui/common/SearchPlace/SearchPlace.tsx rename to src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.tsx index dbb2dc7..998cc57 100644 --- a/src/shared/ui/common/SearchPlace/SearchPlace.tsx +++ b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlace.tsx @@ -2,12 +2,12 @@ import { Map, MapMarker, useKakaoLoader } from 'react-kakao-maps-sdk' import Check from '@/shared/assets/icons/check.svg?react' import Search from '@/shared/assets/icons/search.svg?react' +import type { SearchPlaceProps } from '@/shared/types/schedule/types' +import { DEFAULT_CENTER } from '@/shared/utils/searchPlace' +import useSearchPlace from '@/shared/utils/useSearchPlace' import * as S from './SearchPlace.style' -import type { SearchPlaceProps } from './SearchPlace.types' -import { DEFAULT_CENTER } from './SearchPlace.utils' import SearchPlacePanel from './SearchPlacePanel' -import useSearchPlace from './useSearchPlace' const SearchPlace = ({ selectedLocation = '', onSelectLocation }: SearchPlaceProps) => { const kakaoAppKey = import.meta.env.VITE_KAKAO_API ?? '' diff --git a/src/shared/ui/common/SearchPlace/SearchPlacePanel.tsx b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlacePanel.tsx similarity index 77% rename from src/shared/ui/common/SearchPlace/SearchPlacePanel.tsx rename to src/shared/ui/scheduleTodo/SearchPlace/SearchPlacePanel.tsx index ff8f069..9c4b85b 100644 --- a/src/shared/ui/common/SearchPlace/SearchPlacePanel.tsx +++ b/src/shared/ui/scheduleTodo/SearchPlace/SearchPlacePanel.tsx @@ -1,23 +1,8 @@ -import RecentSearch from '@/shared/ui/common/RecentSearch/RecentSearch' +import type { SearchPlacePanelProps } from '@/shared/types/schedule/types' +import RecentSearch from '@/shared/ui/scheduleTodo/RecentSearch/RecentSearch' +import { getPlaceAddressValue } from '@/shared/utils/searchPlace' import * as S from './SearchPlace.style' -import type { PlaceResult } from './SearchPlace.types' -import { getPlaceAddressValue } from './SearchPlace.utils' - -type SearchPlacePanelProps = { - panelTitle: string - panelCaption: string | null - panelMessage: string - panelPlaces: PlaceResult[] - recentSearches: string[] - selectedPlaceId: string | null - shouldShowRecentSearches: boolean - showExpandedResults: boolean - showPreviewResults: boolean - onRecentSearchClick: (value: string) => void - onRemoveRecentSearch: (value: string) => void - onSelectPlace: (place: PlaceResult) => void -} const SearchPlacePanel = ({ panelTitle, diff --git a/src/shared/ui/common/SelectColor/SelectColor.style.ts b/src/shared/ui/scheduleTodo/SelectColor/SelectColor.style.ts similarity index 87% rename from src/shared/ui/common/SelectColor/SelectColor.style.ts rename to src/shared/ui/scheduleTodo/SelectColor/SelectColor.style.ts index 59cb7eb..498c066 100644 --- a/src/shared/ui/common/SelectColor/SelectColor.style.ts +++ b/src/shared/ui/scheduleTodo/SelectColor/SelectColor.style.ts @@ -1,10 +1,12 @@ import styled from '@emotion/styled' -export const CircleOption = styled.div<{ color: string; isSelected?: boolean }>` +export const CircleOption = styled.button<{ color: string; isSelected?: boolean }>` width: 20px; height: 20px; border-radius: 50%; background-color: ${(props) => props.color}; + border: none; + padding: 0; position: relative; cursor: pointer; transition: transform 0.2s; @@ -31,17 +33,23 @@ export const Circle = styled.div<{ color: string }>` border-radius: 50%; background-color: ${(props) => props.color}; ` + export const ColorDropdown = styled.div` + position: relative; + width: fit-content; +` + +export const TriggerButton = styled.button` display: flex; gap: 10px; - position: relative; padding: 8px 4px 8px 10px; - width: fit-content; align-items: center; background-color: ${(props) => props.theme.colors.inputColor}; border-radius: 8px; + border: none; cursor: pointer; ` + export const ColorOptions = styled.div` position: absolute; top: calc(100% + 8px); diff --git a/src/shared/ui/common/SelectColor/SelectColor.tsx b/src/shared/ui/scheduleTodo/SelectColor/SelectColor.tsx similarity index 60% rename from src/shared/ui/common/SelectColor/SelectColor.tsx rename to src/shared/ui/scheduleTodo/SelectColor/SelectColor.tsx index 761f995..a59d4b0 100644 --- a/src/shared/ui/common/SelectColor/SelectColor.tsx +++ b/src/shared/ui/scheduleTodo/SelectColor/SelectColor.tsx @@ -18,25 +18,23 @@ const SelectColor = ({ value, onChange }: SelectColorProps) => { const dropdownRef = useRef(null) useEffect(() => { - if (!dropdownOpen) { - return - } + if (!dropdownOpen) return const handleOutsideClick = (event: MouseEvent | TouchEvent) => { const targetNode = event.target as Node | null - const clickedInsideDropdown = !!( - dropdownRef.current && - targetNode && - dropdownRef.current.contains(targetNode) - ) - if (!clickedInsideDropdown) { + if (dropdownRef.current && targetNode && !dropdownRef.current.contains(targetNode)) { setDropdownOpen(false) } } + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') setDropdownOpen(false) + } document.addEventListener('mousedown', handleOutsideClick) document.addEventListener('touchstart', handleOutsideClick) + document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('mousedown', handleOutsideClick) document.removeEventListener('touchstart', handleOutsideClick) + document.removeEventListener('keydown', handleKeyDown) } }, [dropdownOpen]) const resolvedValue = EVENT_COLORS.includes(value as EventColorType) @@ -44,15 +42,36 @@ const SelectColor = ({ value, onChange }: SelectColorProps) => { : EVENT_COLORS[0] const palette = theme.colors[resolvedValue] + const COLOR_LABELS: Record = { + BLUE: '파랑', + GREEN: '초록', + PINK: '분홍', + PURPLE: '보라', + GRAY: '회색', + YELLOW: '노랑', + } + return ( - setDropdownOpen((prev) => !prev)}> - - + + setDropdownOpen((prev) => !prev)} + > + + + {dropdownOpen && ( - event.stopPropagation()}> + {EVENT_COLORS.map((colorName) => ( { diff --git a/src/shared/ui/common/TerminationPanel/TerminationPanel.style.ts b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.style.ts similarity index 99% rename from src/shared/ui/common/TerminationPanel/TerminationPanel.style.ts rename to src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.style.ts index 4315316..70fecad 100644 --- a/src/shared/ui/common/TerminationPanel/TerminationPanel.style.ts +++ b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.style.ts @@ -75,7 +75,7 @@ export const InlineInput = styled.input` border: 1px solid ${theme.colors.lightGray}; border-radius: 8px; padding: 6px 10px; - width: 100px; + width: 80px; background: ${theme.colors.white}; font-size: 16px; color: ${theme.colors.textColor2}; diff --git a/src/shared/ui/common/TerminationPanel/TerminationPanel.tsx b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.tsx similarity index 98% rename from src/shared/ui/common/TerminationPanel/TerminationPanel.tsx rename to src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.tsx index 38b3c79..9b2008c 100644 --- a/src/shared/ui/common/TerminationPanel/TerminationPanel.tsx +++ b/src/shared/ui/scheduleTodo/TerminationPanel/TerminationPanel.tsx @@ -11,8 +11,8 @@ import { createPortal } from 'react-dom' import { theme } from '@/shared/styles/theme' import type { RepeatConfigSchema } from '@/shared/types/event/event' import type { RepeatConfig } from '@/shared/types/recurrence/repeat' +import CustomDatePicker from '@/shared/ui/calendar/CustomDatePicker/CustomDatePicker' import Checkbox from '@/shared/ui/common/Checkbox/Checkbox' -import CustomDatePicker from '@/shared/ui/common/CustomDatePicker/CustomDatePicker' import { formatIsoDate } from '@/shared/utils/date' import * as S from './TerminationPanel.style' @@ -166,6 +166,7 @@ const TerminationPanel = ({ config, updateConfig, minDate }: Props) => { updateConfig({ customEndCount: Number(event.target.value) || undefined }) diff --git a/src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.style.ts b/src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.style.ts similarity index 90% rename from src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.style.ts rename to src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.style.ts index f3b95c0..148b68b 100644 --- a/src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.style.ts +++ b/src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.style.ts @@ -10,7 +10,7 @@ export const Wrapper = styled.div` gap: 6px; ` -export const Input = styled.input` +export const Input = styled.input<{ $color?: string }>` width: 100%; height: 43px; border: none; @@ -18,6 +18,7 @@ export const Input = styled.input` padding: 0 12px 0 0; font-size: 20px; font-weight: 500; + color: ${({ $color }) => $color ?? theme.colors.black}; &:focus { outline: none; border: none; diff --git a/src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.tsx b/src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.tsx similarity index 78% rename from src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.tsx rename to src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.tsx index c80b7be..e6389ec 100644 --- a/src/shared/ui/common/TitleSuggestionInput/TitleSuggestionInput.tsx +++ b/src/shared/ui/scheduleTodo/TitleSuggestionInput/TitleSuggestionInput.tsx @@ -5,59 +5,13 @@ import { useRef, useState, } from 'react' -import type { - FieldValues, - Path, - PathValue, - UseFormRegister, - UseFormSetValue, - UseFormWatch, -} from 'react-hook-form' +import type { FieldValues, PathValue } from 'react-hook-form' import { useFormContext } from 'react-hook-form' -import * as S from './TitleSuggestionInput.style' - -const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - -const getHighlightedSegments = (text: string, query: string) => { - if (!query) { - return [{ text }] - } - const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi') - const segments: Array<{ text: string; highlight?: boolean }> = [] - let lastIndex = 0 - let match: RegExpExecArray | null = null +import type { TitleSuggestionInputProps } from '@/shared/types/scheduleTodo/titleSuggestionInput' +import { getHighlightedSegments } from '@/shared/utils/titleSuggestionInput' - while ((match = regex.exec(text)) !== null) { - if (match.index > lastIndex) { - segments.push({ text: text.slice(lastIndex, match.index) }) - } - segments.push({ text: match[0], highlight: true }) - lastIndex = match.index + match[0].length - } - - if (lastIndex < text.length) { - segments.push({ text: text.slice(lastIndex) }) - } - - return segments -} - -type TitleSuggestionInputFormController = { - register: UseFormRegister - watch: UseFormWatch - setValue: UseFormSetValue -} - -type TitleSuggestionInputProps = { - fieldName: Path - placeholder?: string - suggestions?: string[] - autoFocus?: boolean - formController?: TitleSuggestionInputFormController - onConfirm?: (value: string) => void - onLiveChange?: (value: string) => void -} +import * as S from './TitleSuggestionInput.style' const TitleSuggestionInput = ({ fieldName, @@ -65,6 +19,7 @@ const TitleSuggestionInput = ({ suggestions = [], autoFocus = false, formController, + inputColor, onConfirm, onLiveChange, }: TitleSuggestionInputProps) => { @@ -157,6 +112,7 @@ const TitleSuggestionInput = ({ { diff --git a/src/shared/utils/domVisibility.ts b/src/shared/utils/domVisibility.ts new file mode 100644 index 0000000..82915b7 --- /dev/null +++ b/src/shared/utils/domVisibility.ts @@ -0,0 +1,53 @@ +const OVERFLOW_SCROLL_PATTERN = /(auto|scroll|hidden|clip)/ + +const getIntersectionRect = (firstRect: DOMRect, secondRect: DOMRect) => { + const top = Math.max(firstRect.top, secondRect.top) + const right = Math.min(firstRect.right, secondRect.right) + const bottom = Math.min(firstRect.bottom, secondRect.bottom) + const left = Math.max(firstRect.left, secondRect.left) + + return { + top, + right, + bottom, + left, + width: right - left, + height: bottom - top, + } +} + +const hasVisibleArea = (rect: Pick) => + rect.width > 0 && rect.height > 0 + +const isClippedByScrollableParent = (element: HTMLElement, parent: HTMLElement) => { + const parentStyle = window.getComputedStyle(parent) + const clipsContent = OVERFLOW_SCROLL_PATTERN.test( + `${parentStyle.overflow}${parentStyle.overflowX}${parentStyle.overflowY}`, + ) + + if (!clipsContent) return false + + const visibleRect = getIntersectionRect( + element.getBoundingClientRect(), + parent.getBoundingClientRect(), + ) + + return !hasVisibleArea(visibleRect) +} + +export const isElementVisible = (element: HTMLElement) => { + const elementRect = element.getBoundingClientRect() + const viewportRect = new DOMRect(0, 0, window.innerWidth, window.innerHeight) + const viewportVisibleRect = getIntersectionRect(elementRect, viewportRect) + + if (!hasVisibleArea(viewportVisibleRect)) return false + + let parentElement = element.parentElement + + while (parentElement && parentElement !== document.body) { + if (isClippedByScrollableParent(element, parentElement)) return false + parentElement = parentElement.parentElement + } + + return true +} diff --git a/src/shared/utils/scheduleShareFriend.ts b/src/shared/utils/scheduleShareFriend.ts new file mode 100644 index 0000000..c096756 --- /dev/null +++ b/src/shared/utils/scheduleShareFriend.ts @@ -0,0 +1,16 @@ +import type { ScheduleShareFriend } from '@/shared/types/schedule/shareFriend' + +export const filterScheduleShareFriends = ( + friends: ScheduleShareFriend[], + keyword: string, +): ScheduleShareFriend[] => { + const trimmedKeyword = keyword.trim().toLowerCase() + + if (!trimmedKeyword) return [] + + return friends.filter( + (friend) => + friend.userName.toLowerCase().includes(trimmedKeyword) || + friend.email.toLowerCase().includes(trimmedKeyword), + ) +} diff --git a/src/shared/ui/common/SearchPlace/SearchPlace.utils.ts b/src/shared/utils/searchPlace.ts similarity index 95% rename from src/shared/ui/common/SearchPlace/SearchPlace.utils.ts rename to src/shared/utils/searchPlace.ts index f232f56..3390ae3 100644 --- a/src/shared/ui/common/SearchPlace/SearchPlace.utils.ts +++ b/src/shared/utils/searchPlace.ts @@ -1,4 +1,4 @@ -import type { PlaceMarker, PlaceResult } from './SearchPlace.types' +import type { PlaceMarker, PlaceResult } from '../types/schedule/types' export const DEFAULT_CENTER = { lat: 37.566826, diff --git a/src/shared/utils/titleSuggestionInput.ts b/src/shared/utils/titleSuggestionInput.ts new file mode 100644 index 0000000..5bd8d37 --- /dev/null +++ b/src/shared/utils/titleSuggestionInput.ts @@ -0,0 +1,28 @@ +import type { HighlightedSegment } from '@/shared/types/scheduleTodo/titleSuggestionInput' + +const escapeRegExp = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + +export const getHighlightedSegments = (text: string, query: string): HighlightedSegment[] => { + if (!query) { + return [{ text }] + } + + const regex = new RegExp(`(${escapeRegExp(query)})`, 'gi') + const segments: HighlightedSegment[] = [] + let lastIndex = 0 + let match: RegExpExecArray | null = null + + while ((match = regex.exec(text)) !== null) { + if (match.index > lastIndex) { + segments.push({ text: text.slice(lastIndex, match.index) }) + } + segments.push({ text: match[0], highlight: true }) + lastIndex = match.index + match[0].length + } + + if (lastIndex < text.length) { + segments.push({ text: text.slice(lastIndex) }) + } + + return segments +} diff --git a/src/shared/utils/useEditorDraft.ts b/src/shared/utils/useEditorDraft.ts new file mode 100644 index 0000000..7404aff --- /dev/null +++ b/src/shared/utils/useEditorDraft.ts @@ -0,0 +1,56 @@ +import { useCallback, useEffect, useRef, useState } from 'react' + +import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { ItemEditorDraft } from '@/shared/types/modal/itemEditor' +import type { ItemType } from '@/shared/types/modal/itemEditor' +import { buildDefaultItemEditorDraft } from '@/shared/utils' + +type UseEditorDraftParams = { + date: string + initialType: ItemType + initialEvent: CalendarEvent | null + isEditing: boolean + externalDraftValues?: ItemEditorDraft | null + onExternalDraftChange?: (draft: ItemEditorDraft | null) => void +} + +export const useEditorDraft = ({ + date, + initialType, + initialEvent, + isEditing, + externalDraftValues, + onExternalDraftChange, +}: UseEditorDraftParams) => { + const [internalDraftValues, setInternalDraftValues] = useState(() => + isEditing ? null : buildDefaultItemEditorDraft(date, initialType, initialEvent), + ) + + const draftValues = externalDraftValues ?? internalDraftValues + const draftValuesRef = useRef(draftValues) + + useEffect(() => { + draftValuesRef.current = draftValues + }, [draftValues]) + + useEffect(() => { + if (externalDraftValues !== undefined) return + // eslint-disable-next-line react-hooks/set-state-in-effect + setInternalDraftValues( + isEditing ? null : buildDefaultItemEditorDraft(date, initialType, initialEvent), + ) + }, [date, externalDraftValues, initialEvent, initialType, isEditing]) + + const setDraftValues = useCallback( + (draft: ItemEditorDraft | null) => { + if (onExternalDraftChange) { + onExternalDraftChange(draft) + return + } + setInternalDraftValues(draft) + }, + [onExternalDraftChange], + ) + + return { draftValues, draftValuesRef, setDraftValues } +} diff --git a/src/shared/utils/useEditorRegistry.ts b/src/shared/utils/useEditorRegistry.ts new file mode 100644 index 0000000..f1b5908 --- /dev/null +++ b/src/shared/utils/useEditorRegistry.ts @@ -0,0 +1,41 @@ +import { type ReactNode, useCallback, useState } from 'react' + +export const useEditorRegistry = (onClose: () => void) => { + const [footerChildren, setFooterChildren] = useState(null) + const [deleteHandler, setDeleteHandler] = useState<() => void>(() => () => undefined) + const [closeGuard, setCloseGuard] = useState boolean)>(null) + + const noopDeleteHandler = useCallback(() => undefined, []) + + const registerDeleteHandler = useCallback( + (handler?: (() => void) | null) => { + setDeleteHandler(() => handler ?? noopDeleteHandler) + }, + [noopDeleteHandler], + ) + + const registerFooterChildren = useCallback((node: ReactNode | null) => { + setFooterChildren((prev) => (prev === node ? prev : node)) + }, []) + + const registerCloseGuard = useCallback((guard?: (() => boolean) | null) => { + setCloseGuard((prev) => { + const next = guard ?? null + return prev === next ? prev : next + }) + }, []) + + const handleClose = useCallback(() => { + if (closeGuard && !closeGuard()) return + onClose() + }, [closeGuard, onClose]) + + return { + footerChildren, + deleteHandler, + handleClose, + registerDeleteHandler, + registerFooterChildren, + registerCloseGuard, + } +} diff --git a/src/shared/utils/useEditorTypeSync.ts b/src/shared/utils/useEditorTypeSync.ts new file mode 100644 index 0000000..6b291b4 --- /dev/null +++ b/src/shared/utils/useEditorTypeSync.ts @@ -0,0 +1,128 @@ +import { type RefObject, useEffect, useRef, useState } from 'react' + +import type { CalendarEvent } from '@/shared/types/calendar/types' +import type { ItemEditorDraft, ItemType } from '@/shared/types/modal/itemEditor' + +const buildDateTime = (fallbackDate: string, dateValue?: Date | null, timeValue?: string) => { + const nextDate = dateValue ? new Date(dateValue) : new Date(fallbackDate) + if (!timeValue) { + nextDate.setHours(0, 0, 0, 0) + return nextDate + } + const [hour, minute] = timeValue.split(':').map((value) => Number.parseInt(value, 10)) + nextDate.setHours(Number.isNaN(hour) ? 0 : hour, Number.isNaN(minute) ? 0 : minute, 0, 0) + return nextDate +} + +type UseEditorTypeSyncParams = { + initialType: ItemType + showTypeTabs: boolean + eventId: CalendarEvent['id'] + date: string + initialEvent: CalendarEvent | null + draftValuesRef: RefObject + onEventTypeChange?: (eventId: CalendarEvent['id'], type: ItemType) => void + onEventTimingChange?: ( + eventId: CalendarEvent['id'], + start: Date, + end: Date, + allDay: boolean, + occurrenceDate?: CalendarEvent['occurrenceDate'], + ) => void +} + +export const useEditorTypeSync = ({ + initialType, + showTypeTabs, + eventId, + date, + initialEvent, + draftValuesRef, + onEventTypeChange, + onEventTimingChange, +}: UseEditorTypeSyncParams) => { + const [activeType, setActiveType] = useState(initialType) + const [isScheduleShared, setIsScheduleShared] = useState( + initialType === 'schedule' && Boolean(initialEvent?.isShared), + ) + const previousActiveTypeRef = useRef(activeType) + + useEffect(() => { + setActiveType(initialType) + }, [initialType]) + + useEffect(() => { + setIsScheduleShared(initialType === 'schedule' && Boolean(initialEvent?.isShared)) + }, [initialEvent?.isShared, initialType]) + + useEffect(() => { + if (activeType !== 'schedule') { + setIsScheduleShared(false) + } + }, [activeType]) + + useEffect(() => { + if (eventId == null || eventId === 0) return + onEventTypeChange?.(eventId, activeType) + }, [activeType, eventId, onEventTypeChange]) + + useEffect(() => { + if (!showTypeTabs) return + if (previousActiveTypeRef.current === activeType) return + previousActiveTypeRef.current = activeType + if (eventId == null || eventId === 0) return + if (!onEventTimingChange) return + + const latestDraftValues = draftValuesRef.current + const startDate = + latestDraftValues?.startDate ?? (initialEvent?.start ? new Date(initialEvent.start) : null) + const endDate = + latestDraftValues?.endDate ?? (initialEvent?.end ? new Date(initialEvent.end) : startDate) + const isAllDay = latestDraftValues?.isAllday ?? initialEvent?.isAllDay ?? false + const occurrenceDate = initialEvent?.occurrenceDate + + if (activeType === 'todo') { + if (isAllDay) { + const start = new Date(startDate ?? new Date(date)) + start.setHours(0, 0, 0, 0) + const end = new Date(start) + end.setHours(23, 59, 59, 999) + onEventTimingChange(eventId, start, end, true, occurrenceDate) + return + } + const point = buildDateTime( + date, + startDate, + latestDraftValues?.endTime ?? latestDraftValues?.startTime, + ) + onEventTimingChange(eventId, point, point, false, occurrenceDate) + return + } + + if (isAllDay) { + const start = new Date(startDate ?? new Date(date)) + start.setHours(0, 0, 0, 0) + const end = new Date(endDate ?? start) + end.setHours(23, 59, 59, 999) + onEventTimingChange(eventId, start, end, true, occurrenceDate) + return + } + + const start = buildDateTime(date, startDate, latestDraftValues?.startTime) + const end = buildDateTime(date, endDate ?? startDate, latestDraftValues?.endTime) + onEventTimingChange(eventId, start, end, false, occurrenceDate) + }, [ + activeType, + date, + draftValuesRef, + eventId, + initialEvent?.end, + initialEvent?.isAllDay, + initialEvent?.occurrenceDate, + initialEvent?.start, + onEventTimingChange, + showTypeTabs, + ]) + + return { activeType, setActiveType, isScheduleShared, setIsScheduleShared } +} diff --git a/src/shared/utils/usePlaceSearch.ts b/src/shared/utils/usePlaceSearch.ts new file mode 100644 index 0000000..8615215 --- /dev/null +++ b/src/shared/utils/usePlaceSearch.ts @@ -0,0 +1,216 @@ +import { useCallback, useReducer, useRef } from 'react' + +import type { PlaceResult, ResultsView, SearchState } from '@/shared/types/schedule/types' +import { getPlaceLocationValue } from '@/shared/utils/searchPlace' + +type SearchPlacesOptions = { + view?: ResultsView +} + +type PlaceSearchState = { + issue: 'missing_key' | 'sdk_loading' | 'search_failed' | null + places: PlaceResult[] + requestedKeyword: string + resultsView: ResultsView + searchState: SearchState + selectedPlaceId: string | null +} + +type PlaceSearchAction = + | { type: 'clear' } + | { type: 'expand_results' } + | { type: 'loading'; view: ResultsView; keyword: string } + | { type: 'missing_key'; view: ResultsView } + | { type: 'sdk_loading'; view: ResultsView } + | { type: 'success'; places: PlaceResult[]; selectedPlaceId: string | null } + | { type: 'zero_result' } + | { type: 'error' } + | { type: 'select_place'; placeId: string } + | { type: 'use_typed_location' } + +const initialPlaceSearchState: PlaceSearchState = { + issue: null, + places: [], + requestedKeyword: '', + resultsView: 'hidden', + searchState: 'idle', + selectedPlaceId: null, +} + +const placeSearchReducer = ( + state: PlaceSearchState, + action: PlaceSearchAction, +): PlaceSearchState => { + switch (action.type) { + case 'clear': + return initialPlaceSearchState + case 'expand_results': + return { ...state, resultsView: 'expanded' } + case 'loading': + return { + ...state, + requestedKeyword: action.keyword, + resultsView: action.view, + searchState: 'loading', + issue: null, + } + case 'missing_key': + return { + ...state, + resultsView: action.view, + searchState: 'error', + issue: 'missing_key', + } + case 'sdk_loading': + return { + ...state, + requestedKeyword: '', + resultsView: action.view, + searchState: 'loading', + issue: 'sdk_loading', + } + case 'success': + return { + ...state, + issue: null, + places: action.places, + searchState: 'success', + selectedPlaceId: action.selectedPlaceId, + } + case 'zero_result': + return { + ...state, + issue: null, + places: [], + searchState: 'zero', + selectedPlaceId: null, + } + case 'error': + return { + ...state, + issue: 'search_failed', + places: [], + searchState: 'error', + selectedPlaceId: null, + } + case 'select_place': + return { + ...state, + issue: null, + searchState: 'success', + selectedPlaceId: action.placeId, + } + case 'use_typed_location': + return { + ...state, + issue: null, + resultsView: 'hidden', + searchState: 'zero', + selectedPlaceId: null, + } + } +} + +type UsePlaceSearchParams = { + isKakaoLoading: boolean + kakaoAppKey: string + selectedLocation: string +} + +const usePlaceSearch = ({ + isKakaoLoading, + kakaoAppKey, + selectedLocation, +}: UsePlaceSearchParams) => { + const [state, dispatch] = useReducer(placeSearchReducer, initialPlaceSearchState) + const lastRequestedKeywordRef = useRef('') + const requestIdRef = useRef(0) + + const cancelPendingSearch = useCallback(() => { + requestIdRef.current += 1 + lastRequestedKeywordRef.current = '' + }, []) + + const clearSearch = useCallback(() => { + dispatch({ type: 'clear' }) + }, []) + + const expandResults = useCallback(() => { + dispatch({ type: 'expand_results' }) + }, []) + + const markTypedLocationSelected = useCallback(() => { + dispatch({ type: 'use_typed_location' }) + }, []) + + const selectPlaceInSearch = useCallback((placeId: string) => { + dispatch({ type: 'select_place', placeId }) + }, []) + + const searchPlaces = useCallback( + (rawKeyword: string, options?: SearchPlacesOptions) => { + const nextKeyword = rawKeyword.trim() + const nextView = options?.view ?? 'expanded' + + if (!nextKeyword) { + dispatch({ type: 'clear' }) + return + } + + if (!kakaoAppKey) { + dispatch({ type: 'missing_key', view: nextView }) + return + } + + if (isKakaoLoading || typeof kakao === 'undefined' || !kakao.maps.services) { + dispatch({ type: 'sdk_loading', view: nextView }) + return + } + + const requestId = requestIdRef.current + 1 + requestIdRef.current = requestId + lastRequestedKeywordRef.current = nextKeyword + dispatch({ type: 'loading', view: nextView, keyword: nextKeyword }) + + const placesService = new kakao.maps.services.Places() + placesService.keywordSearch(nextKeyword, (result, status) => { + if (requestId !== requestIdRef.current) return + + if (status === kakao.maps.services.Status.OK) { + const matchedPlace = result.find( + (place) => getPlaceLocationValue(place) === selectedLocation, + ) + + dispatch({ + type: 'success', + places: result, + selectedPlaceId: matchedPlace?.id ?? null, + }) + return + } + + if (status === kakao.maps.services.Status.ZERO_RESULT) { + dispatch({ type: 'zero_result' }) + return + } + + dispatch({ type: 'error' }) + }) + }, + [isKakaoLoading, kakaoAppKey, selectedLocation], + ) + + return { + ...state, + cancelPendingSearch, + clearSearch, + expandResults, + isSearching: state.searchState === 'loading', + lastRequestedKeywordRef, + markTypedLocationSelected, + searchPlaces, + selectPlaceInSearch, + } +} + +export default usePlaceSearch diff --git a/src/shared/utils/useRecentPlaceSearches.ts b/src/shared/utils/useRecentPlaceSearches.ts new file mode 100644 index 0000000..22df961 --- /dev/null +++ b/src/shared/utils/useRecentPlaceSearches.ts @@ -0,0 +1,39 @@ +import { useCallback, useState } from 'react' + +import { + MAX_RECENT_SEARCHES, + readRecentSearches, + writeRecentSearches, +} from '@/shared/utils/searchPlace' + +const useRecentPlaceSearches = () => { + const [recentSearches, setRecentSearches] = useState(() => readRecentSearches()) + + const saveRecentSearch = useCallback((value: string) => { + setRecentSearches((previous) => { + const next = [value, ...previous.filter((item) => item !== value)].slice( + 0, + MAX_RECENT_SEARCHES, + ) + + writeRecentSearches(next) + return next + }) + }, []) + + const removeRecentSearch = useCallback((value: string) => { + setRecentSearches((previous) => { + const next = previous.filter((item) => item !== value) + writeRecentSearches(next) + return next + }) + }, []) + + return { + recentSearches, + removeRecentSearch, + saveRecentSearch, + } +} + +export default useRecentPlaceSearches diff --git a/src/shared/utils/useSearchPlace.ts b/src/shared/utils/useSearchPlace.ts new file mode 100644 index 0000000..e188172 --- /dev/null +++ b/src/shared/utils/useSearchPlace.ts @@ -0,0 +1,228 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { PlaceResult, UseSearchPlaceParams } from '@/shared/types/schedule/types' +import { + AUTO_SEARCH_DELAY_MS, + getPlaceAddressValue, + getPlaceLocationValue, + toMarker, +} from '@/shared/utils/searchPlace' + +import usePlaceSearch from './usePlaceSearch' +import useRecentPlaceSearches from './useRecentPlaceSearches' +import useSearchPlacePanelModel from './useSearchPlacePanelModel' + +const useSearchPlace = ({ + onSelectLocation, + selectedLocation = '', + isKakaoLoading, + kakaoAppKey, +}: UseSearchPlaceParams) => { + const trimmedSelectedLocation = selectedLocation.trim() + const [keyword, setKeyword] = useState(selectedLocation) + const [map, setMap] = useState(null) + const restoredLocationRef = useRef('') + const trimmedKeyword = keyword.trim() + const { recentSearches, removeRecentSearch, saveRecentSearch } = useRecentPlaceSearches() + const { + cancelPendingSearch, + clearSearch, + expandResults, + issue, + isSearching, + lastRequestedKeywordRef, + markTypedLocationSelected, + places, + requestedKeyword, + resultsView, + searchPlaces, + searchState, + selectedPlaceId, + selectPlaceInSearch, + } = usePlaceSearch({ + isKakaoLoading, + kakaoAppKey, + selectedLocation: trimmedSelectedLocation, + }) + const markers = useMemo(() => places.map(toMarker), [places]) + const isTypedLocationSelected = + trimmedKeyword.length > 0 && + trimmedKeyword === trimmedSelectedLocation && + selectedPlaceId === null + const { + panelCaption, + panelMessage, + panelPlaces, + panelTitle, + showExpandedResults, + showPreviewResults, + shouldShowRecentSearches, + } = useSearchPlacePanelModel({ + places, + issue, + recentSearches, + requestedKeyword, + resultsView, + searchState, + selectedPlaceId, + trimmedKeyword, + trimmedSelectedLocation, + }) + + const selectPlace = useCallback( + (place: PlaceResult) => { + const nextLocation = getPlaceLocationValue(place) + const nextAddress = getPlaceAddressValue(place) + selectPlaceInSearch(place.id) + setKeyword(nextLocation) + saveRecentSearch(trimmedKeyword || nextLocation) + onSelectLocation(nextLocation, { closeAfterSelect: true, address: nextAddress }) + + if (!map) return + map.panTo(new kakao.maps.LatLng(Number(place.y), Number(place.x))) + map.setLevel(3) + }, + [map, onSelectLocation, saveRecentSearch, selectPlaceInSearch, trimmedKeyword], + ) + + useEffect(() => { + if (!map || markers.length === 0 || typeof kakao === 'undefined') return + + const bounds = new kakao.maps.LatLngBounds() + + markers.forEach((marker) => { + bounds.extend(new kakao.maps.LatLng(marker.position.lat, marker.position.lng)) + }) + + map.setBounds(bounds) + }, [map, markers]) + + const handleUseTypedLocation = useCallback(() => { + if (!trimmedKeyword) return + + markTypedLocationSelected() + saveRecentSearch(trimmedKeyword) + onSelectLocation(trimmedKeyword, { closeAfterSelect: true, address: null }) + }, [markTypedLocationSelected, onSelectLocation, saveRecentSearch, trimmedKeyword]) + + const handleSubmit = useCallback(() => { + if (!trimmedKeyword) return + + if ( + lastRequestedKeywordRef.current === trimmedKeyword && + (searchState === 'success' || searchState === 'zero') + ) { + saveRecentSearch(trimmedKeyword) + expandResults() + return + } + + saveRecentSearch(trimmedKeyword) + searchPlaces(trimmedKeyword, { view: 'expanded' }) + }, [ + expandResults, + lastRequestedKeywordRef, + saveRecentSearch, + searchPlaces, + searchState, + trimmedKeyword, + ]) + + const handleMarkerClick = useCallback( + (markerId: string) => { + const place = places.find((item) => item.id === markerId) + if (!place) return + selectPlace(place) + }, + [places, selectPlace], + ) + + const handleRecentSearchClick = useCallback( + (value: string) => { + setKeyword(value) + saveRecentSearch(value) + searchPlaces(value, { view: 'expanded' }) + }, + [saveRecentSearch, searchPlaces], + ) + + const handleKeywordChange = useCallback( + (nextValue: string) => { + setKeyword(nextValue) + clearSearch() + cancelPendingSearch() + restoredLocationRef.current = '' + }, + [cancelPendingSearch, clearSearch], + ) + + useEffect(() => { + if (!map || !trimmedSelectedLocation || isKakaoLoading) return + if (restoredLocationRef.current === trimmedSelectedLocation) return + if (lastRequestedKeywordRef.current === trimmedSelectedLocation) { + restoredLocationRef.current = trimmedSelectedLocation + return + } + + const timeoutId = window.setTimeout(() => { + restoredLocationRef.current = trimmedSelectedLocation + searchPlaces(trimmedSelectedLocation, { view: 'hidden' }) + }, 0) + + return () => window.clearTimeout(timeoutId) + }, [isKakaoLoading, lastRequestedKeywordRef, map, searchPlaces, trimmedSelectedLocation]) + + useEffect(() => { + if (!trimmedKeyword || isKakaoLoading) return + if ( + trimmedKeyword === trimmedSelectedLocation && + restoredLocationRef.current === trimmedSelectedLocation + ) { + return + } + if (lastRequestedKeywordRef.current === trimmedKeyword) return + + const timeoutId = window.setTimeout(() => { + searchPlaces(trimmedKeyword, { view: 'inline' }) + }, AUTO_SEARCH_DELAY_MS) + + return () => window.clearTimeout(timeoutId) + }, [ + isKakaoLoading, + lastRequestedKeywordRef, + searchPlaces, + trimmedKeyword, + trimmedSelectedLocation, + ]) + + const handleMapCreate = useCallback((createdMap: kakao.maps.Map) => { + setMap(createdMap) + }, []) + + return { + handleKeywordChange, + handleMapCreate, + handleMarkerClick, + handleRecentSearchClick, + handleSubmit, + handleUseTypedLocation, + isSearching, + isTypedLocationSelected, + keyword, + markers, + panelCaption, + panelMessage, + panelPlaces, + panelTitle, + recentSearches, + removeRecentSearch, + selectedPlaceId, + showExpandedResults, + showPreviewResults, + shouldShowRecentSearches, + trimmedKeyword, + selectPlace, + } +} + +export default useSearchPlace diff --git a/src/shared/utils/useSearchPlacePanelModel.ts b/src/shared/utils/useSearchPlacePanelModel.ts new file mode 100644 index 0000000..a9204c3 --- /dev/null +++ b/src/shared/utils/useSearchPlacePanelModel.ts @@ -0,0 +1,132 @@ +import { useMemo } from 'react' + +import type { PlaceResult, ResultsView, SearchState } from '@/shared/types/schedule/types' +import { MAX_RECENT_SEARCHES } from '@/shared/utils/searchPlace' + +type UseSearchPlacePanelModelParams = { + issue: 'missing_key' | 'sdk_loading' | 'search_failed' | null + places: PlaceResult[] + recentSearches: string[] + requestedKeyword: string + resultsView: ResultsView + searchState: SearchState + selectedPlaceId: string | null + trimmedKeyword: string + trimmedSelectedLocation: string +} + +const getSearchMessage = ({ + placesCount, + issue, + requestedKeyword, + resultsView, + searchState, +}: { + issue: 'missing_key' | 'sdk_loading' | 'search_failed' | null + placesCount: number + requestedKeyword: string + resultsView: ResultsView + searchState: SearchState +}) => { + if (searchState === 'loading') { + if (issue === 'sdk_loading') { + return '지도를 불러오는 중입니다. 잠시 후 다시 시도해주세요.' + } + + return `"${requestedKeyword}" 검색 중...` + } + + if (searchState === 'success') { + return resultsView === 'expanded' + ? `${placesCount}개의 장소를 찾았습니다.` + : `${placesCount}개 장소가 검색되었습니다.` + } + + if (searchState === 'zero') return '검색 결과가 없습니다.' + if (issue === 'missing_key') return '카카오맵 API 키가 설정되지 않았습니다.' + if (searchState === 'error') return '장소 검색 중 오류가 발생했습니다. 다시 시도해주세요.' + + return '최근 검색어가 없습니다.' +} + +const useSearchPlacePanelModel = ({ + issue, + places, + recentSearches, + requestedKeyword, + resultsView, + searchState, + selectedPlaceId, + trimmedKeyword, + trimmedSelectedLocation, +}: UseSearchPlacePanelModelParams) => + useMemo(() => { + const inlinePlaces = places.slice(0, 4) + const showPreviewResults = + resultsView === 'inline' && searchState === 'success' && inlinePlaces.length > 0 + const showExpandedResults = + resultsView === 'expanded' && searchState === 'success' && places.length > 0 + const shouldShowRecentSearches = resultsView === 'hidden' && recentSearches.length > 0 + const hasSelectedPlacePreview = + resultsView === 'hidden' && + searchState === 'success' && + selectedPlaceId !== null && + trimmedSelectedLocation.length > 0 + + const panelTitle = showExpandedResults + ? '검색 결과' + : showPreviewResults + ? '자동 검색' + : hasSelectedPlacePreview + ? '선택된 장소' + : shouldShowRecentSearches + ? '최근 검색어' + : trimmedKeyword + ? '검색 상태' + : '장소 찾기' + + const panelCaption = showExpandedResults + ? `${places.length}개` + : showPreviewResults + ? `${inlinePlaces.length}개 미리보기` + : hasSelectedPlacePreview + ? '지도에 표시 중' + : shouldShowRecentSearches + ? `최대 ${MAX_RECENT_SEARCHES}개` + : null + + const panelMessage = + resultsView === 'expanded' || resultsView === 'inline' + ? getSearchMessage({ + issue, + placesCount: places.length, + requestedKeyword, + resultsView, + searchState, + }) + : hasSelectedPlacePreview + ? '현재 선택된 장소를 기준으로 지도를 보여주고 있습니다.' + : '최근 검색어가 없습니다.' + + return { + panelCaption, + panelMessage, + panelPlaces: showExpandedResults ? places : inlinePlaces, + panelTitle, + showExpandedResults, + showPreviewResults, + shouldShowRecentSearches, + } + }, [ + places, + issue, + recentSearches.length, + requestedKeyword, + resultsView, + searchState, + selectedPlaceId, + trimmedKeyword, + trimmedSelectedLocation, + ]) + +export default useSearchPlacePanelModel