diff --git a/apps/native/src/apis/controller/student/scrap/putMoveScraps.ts b/apps/native/src/apis/controller/student/scrap/putMoveScraps.ts index 08995776..d4e06e31 100644 --- a/apps/native/src/apis/controller/student/scrap/putMoveScraps.ts +++ b/apps/native/src/apis/controller/student/scrap/putMoveScraps.ts @@ -24,11 +24,11 @@ export const useMoveScraps = () => { }); return data as MoveScrapsResponse; }, - // 낙관적 업데이트: 이동된 항목을 현재 폴더에서 즉시 제거 + // 낙관적 업데이트: 이동된 스크랩의 folderId 변경 onMutate: async (request) => { // scrapIds를 items 형태로 변환 (타입은 항상 SCRAP) const items = request.scrapIds.map((id) => ({ id, type: 'SCRAP' as const })); - return await optimisticMoveScrap(queryClient, items); + return await optimisticMoveScrap(queryClient, items, request.targetFolderId); }, // 에러 발생 시 롤백 onError: (error, request, context) => { diff --git a/apps/native/src/apis/controller/student/scrap/utils/optimisticHelpers.ts b/apps/native/src/apis/controller/student/scrap/utils/optimisticHelpers.ts index d6696630..93c2d279 100644 --- a/apps/native/src/apis/controller/student/scrap/utils/optimisticHelpers.ts +++ b/apps/native/src/apis/controller/student/scrap/utils/optimisticHelpers.ts @@ -33,12 +33,25 @@ export const optimisticDeleteScrap = async ( // 이전 데이터 백업 const previousQueries = queryClient.getQueriesData(searchQueryFilters); - // 낙관적 업데이트: 삭제된 항목을 즉시 제거 + // 낙관적 업데이트: 삭제된 항목을 즉시 제거 + 폴더 정보 업데이트 queryClient.setQueriesData(searchQueryFilters, (old) => { if (!old) return old; + // 삭제되는 스크랩들의 folderId별 개수 계산 + const folderCountDeltas = new Map(); + old.scraps?.forEach((scrap) => { + if (deletedIds.has(`SCRAP-${scrap.id}`) && scrap.folderId != null) { + folderCountDeltas.set(scrap.folderId, (folderCountDeltas.get(scrap.folderId) ?? 0) + 1); + } + }); + return { - folders: old.folders?.filter((folder) => !deletedIds.has(`FOLDER-${folder.id}`)), + folders: old.folders + ?.filter((folder) => !deletedIds.has(`FOLDER-${folder.id}`)) + .map((folder) => ({ + ...folder, + scrapCount: (folder.scrapCount ?? 0) - (folderCountDeltas.get(folder.id) ?? 0), + })), scraps: old.scraps?.filter((scrap) => !deletedIds.has(`SCRAP-${scrap.id}`)), }; }); @@ -48,11 +61,13 @@ export const optimisticDeleteScrap = async ( /** * 스크랩 이동 낙관적 업데이트 + * @param targetFolderId 이동할 폴더 ID (undefined면 전체 스크랩으로 이동) * @returns 롤백을 위한 이전 데이터 */ export const optimisticMoveScrap = async ( queryClient: QueryClient, - items: Array<{ id: number; type: string }> + items: Array<{ id: number; type: string }>, + targetFolderId?: number ) => { const movedIds = createDeletedIdsSet(items); const searchQueryFilters = createSearchQueryFilters(); @@ -63,13 +78,32 @@ export const optimisticMoveScrap = async ( // 이전 데이터 백업 const previousQueries = queryClient.getQueriesData(searchQueryFilters); - // 낙관적 업데이트: 이동된 항목을 현재 폴더에서 제거 + // 낙관적 업데이트: 이동된 스크랩의 folderId 변경 + // 스크랩 이동 시 폴더 정보도 업데이트 queryClient.setQueriesData(searchQueryFilters, (old) => { if (!old) return old; + // 이동되는 스크랩들의 원래 folderId 수집 + const sourceFolderCounts = new Map(); + old.scraps?.forEach((scrap) => { + if (movedIds.has(`SCRAP-${scrap.id}`) && scrap.folderId != null) { + sourceFolderCounts.set(scrap.folderId, (sourceFolderCounts.get(scrap.folderId) ?? 0) + 1); + } + }); + return { - folders: old.folders?.filter((folder) => !movedIds.has(`FOLDER-${folder.id}`)), - scraps: old.scraps?.filter((scrap) => !movedIds.has(`SCRAP-${scrap.id}`)), + folders: old.folders?.map((folder) => { + const removedCount = sourceFolderCounts.get(folder.id) ?? 0; + const addedCount = folder.id === targetFolderId ? items.length : 0; + + return { + ...folder, + scrapCount: (folder.scrapCount ?? 0) - removedCount + addedCount, + }; + }), + scraps: old.scraps?.map((scrap) => + movedIds.has(`SCRAP-${scrap.id}`) ? { ...scrap, folderId: targetFolderId } : scrap + ), }; }); diff --git a/apps/native/src/apis/controller/student/study/index.ts b/apps/native/src/apis/controller/student/study/index.ts index 17f113d2..4feb7a32 100644 --- a/apps/native/src/apis/controller/student/study/index.ts +++ b/apps/native/src/apis/controller/student/study/index.ts @@ -6,6 +6,8 @@ import useGetProblem from './useGetProblem'; import useGetPublishDetail from './useGetPublishDetail'; import useGetWeeklyProgress from './useGetWeeklyProgress'; import useGetWeeklyPublish from './useGetWeeklyPublish'; +import useGetEntireProblemPointing from './useGetEntireProblemPointing'; +import useGetEntireProblem from './useGetEntireProblem'; export { getPublishDetailById, @@ -16,4 +18,6 @@ export { useGetPublishDetail, useGetWeeklyProgress, useGetWeeklyPublish, + useGetEntireProblem, + useGetEntireProblemPointing, }; diff --git a/apps/native/src/apis/controller/student/study/useGetEntireProblem.ts b/apps/native/src/apis/controller/student/study/useGetEntireProblem.ts new file mode 100644 index 00000000..bd985a0a --- /dev/null +++ b/apps/native/src/apis/controller/student/study/useGetEntireProblem.ts @@ -0,0 +1,20 @@ +import { TanstackQueryClient } from '@/apis/client'; + +const useGetEntireProblem = (problemId: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/study/problem/entire/{problemId}', + { + params: { + path: { + problemId: problemId, + }, + }, + }, + { + enabled: enabled, + } + ); +}; + +export default useGetEntireProblem; diff --git a/apps/native/src/apis/controller/student/study/useGetEntireProblemPointing.ts b/apps/native/src/apis/controller/student/study/useGetEntireProblemPointing.ts new file mode 100644 index 00000000..0a0013ab --- /dev/null +++ b/apps/native/src/apis/controller/student/study/useGetEntireProblemPointing.ts @@ -0,0 +1,20 @@ +import { TanstackQueryClient } from '@/apis/client'; + +const useGetEntireProblemPointing = (problemId: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/study/pointing/entire/{problemId}', + { + params: { + path: { + problemId: problemId, + }, + }, + }, + { + enabled: enabled, + } + ); +}; + +export default useGetEntireProblemPointing; diff --git a/apps/native/src/components/common/ImageWithSkeleton.tsx b/apps/native/src/components/common/ImageWithSkeleton.tsx index 330d687f..a4768084 100644 --- a/apps/native/src/components/common/ImageWithSkeleton.tsx +++ b/apps/native/src/components/common/ImageWithSkeleton.tsx @@ -23,6 +23,8 @@ type ImageWithSkeletonProps = { fallback?: React.ReactNode; /** 대각선 레이아웃 사용 여부 (true면 대각선 배치, false면 전체 영역에 표시) */ isDiagonalLayout?: boolean; + /** 선택된 이미지인지 여부 */ + isHovered?: boolean; }; // 스켈레톤 컴포넌트 @@ -79,6 +81,7 @@ const ImageWithSkeletonComponent = ({ uniqueId = 'default', fallback, isDiagonalLayout = false, + isHovered = false, }: ImageWithSkeletonProps) => { // 이미지 로딩 상태 관리 const [isLoading, setIsLoading] = useState(true); @@ -176,6 +179,8 @@ const ImageWithSkeletonComponent = ({ width: '80%', height: '80%', borderRadius: borderRadius, + borderWidth: 4, + borderColor: isHovered ? colors['gray-300'] : colors['gray-100'], }}> @@ -198,6 +204,9 @@ const ImageWithSkeletonComponent = ({ height: '80%', backgroundColor: colors['gray-400'], borderRadius: borderRadius, + overflow: 'hidden', + borderWidth: 4, + borderColor: isHovered ? colors['gray-300'] : colors['gray-100'], }} /> )} @@ -210,6 +219,8 @@ const ImageWithSkeletonComponent = ({ width: '80%', height: '80%', borderRadius: borderRadius, + borderWidth: 4, + borderColor: isHovered ? colors['gray-300'] : colors['gray-100'], }}> @@ -302,6 +314,7 @@ export const ImageWithSkeleton = React.memo(ImageWithSkeletonComponent, (prevPro prevProps.height === nextProps.height && prevProps.aspectRatio === nextProps.aspectRatio && prevProps.borderRadius === nextProps.borderRadius && - prevProps.resizeMode === nextProps.resizeMode + prevProps.resizeMode === nextProps.resizeMode && + prevProps.isHovered === nextProps.isHovered ); }); diff --git a/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx b/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx index 41285155..e13e4dc5 100644 --- a/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx +++ b/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx @@ -5,7 +5,7 @@ import { Path, Svg } from 'react-native-svg'; const ChevronUpFilledIcon = React.forwardRef, LucideProps>( ({ color = '#1E1E21', size = 20, ...rest }, ref) => ( - + ) ) as LucideIcon; diff --git a/apps/native/src/components/system/icons/ScrapDefalutIcon.tsx b/apps/native/src/components/system/icons/ScrapDefalutIcon.tsx index c0d0f985..1a0bcedd 100644 --- a/apps/native/src/components/system/icons/ScrapDefalutIcon.tsx +++ b/apps/native/src/components/system/icons/ScrapDefalutIcon.tsx @@ -11,7 +11,7 @@ const ScrapDefaultIcon = React.forwardRef, Lucide const numericSize = typeof size === 'number' ? size : Number(size) || 48; const svgProps = hasStyleSize ? { ...rest, style } - : { width: numericSize, height: (numericSize * 116) / 99, ...rest, style }; + : { width: numericSize, height: numericSize, ...rest, style }; return ( diff --git a/apps/native/src/components/system/icons/ScrapFolderDefalutIcon.tsx b/apps/native/src/components/system/icons/ScrapFolderDefalutIcon.tsx index 6a2f890a..d6ed5a24 100644 --- a/apps/native/src/components/system/icons/ScrapFolderDefalutIcon.tsx +++ b/apps/native/src/components/system/icons/ScrapFolderDefalutIcon.tsx @@ -3,28 +3,31 @@ import { Svg, Rect, Path } from 'react-native-svg'; import type { LucideIcon, LucideProps } from 'lucide-react-native'; import { StyleSheet } from 'react-native'; -const ScrapFolderDefaultIcon = React.forwardRef, LucideProps>( - ({ size = 124, style, ...rest }, ref) => { - // style에 width나 height가 있으면 size를 무시 - const flattenedStyle = StyleSheet.flatten(style); - const hasStyleSize = !!flattenedStyle && (flattenedStyle.width || flattenedStyle.height); - const svgProps = hasStyleSize - ? { ...rest, style } - : { width: size, height: size, ...rest, style }; +const ScrapFolderDefaultIcon = React.forwardRef< + React.ComponentRef, + LucideProps & { isHovered?: boolean } +>(({ size = 124, style, isHovered = false, ...rest }, ref) => { + // style에 width나 height가 있으면 size를 무시 + const flattenedStyle = StyleSheet.flatten(style); + const hasStyleSize = !!flattenedStyle && (flattenedStyle.width || flattenedStyle.height); + const svgProps = hasStyleSize + ? { ...rest, style } + : { width: size, height: size, ...rest, style }; - return ( - - - - - - - - ); - } -) as LucideIcon; + const fillColor = isHovered ? '#EDEEF2' : '#F8F9FC'; + + return ( + + + + + + + + ); +}); export default ScrapFolderDefaultIcon; diff --git a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx index e0987737..e15e5628 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -40,7 +40,7 @@ interface ScrapGridProps { } export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { const [containerWidth, setContainerWidth] = useState(0); - const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); + const { numColumns, gap, itemWidth } = useGridLayout(containerWidth); const finalData = addPlaceholders(data as ScrapItem[], numColumns); return ( @@ -86,8 +86,9 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { const spacingStyle = { width: itemWidth, - height: itemHeight, marginRight: isLastColumn ? 0 : gap, + + maxHeight: 216, }; // Check for placeholder first @@ -155,7 +156,7 @@ interface SearchScrapGridProps { export const SearchScrapGrid = ({ data, searchQuery }: SearchScrapGridProps) => { const [containerWidth, setContainerWidth] = useState(0); - const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); + const { numColumns, gap, itemWidth } = useGridLayout(containerWidth); const finalData = addPlaceholders(data, numColumns); return ( @@ -196,8 +197,9 @@ export const SearchScrapGrid = ({ data, searchQuery }: SearchScrapGridProps) => const spacingStyle = { width: itemWidth, - height: itemHeight, marginRight: isLastColumn ? 0 : gap, + + maxHeight: 216, }; if ('placeholder' in item && item.placeholder) { @@ -255,7 +257,7 @@ interface TrashScrapGridProps { export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridProps) => { const [containerWidth, setContainerWidth] = useState(0); - const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); + const { numColumns, gap, itemWidth } = useGridLayout(containerWidth); const finalData = addPlaceholders(data, numColumns); return ( @@ -296,8 +298,9 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP const spacingStyle = { width: itemWidth, - height: itemHeight, marginRight: isLastColumn ? 0 : gap, + + maxHeight: 216, }; // Check for placeholder first diff --git a/apps/native/src/features/student/scrap/components/Card/cards/RecentScrapCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/RecentScrapCard.tsx index 522f1937..47c27041 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/RecentScrapCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/RecentScrapCard.tsx @@ -3,13 +3,12 @@ import { Pressable, View, Text, ImageBackground } from 'react-native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; import { StudentRootStackParamList } from '@/navigation/student/types'; -import type { ScrapDetailResp } from '@/features/student/scrap/utils/types'; +import type { ScrapListItemResp } from '@/features/student/scrap/utils/types'; import { useNoteStore } from '@/features/student/scrap/stores/scrapNoteStore'; -import { useRecentScrapStore } from '@/features/student/scrap/stores/recentScrapStore'; import { formatToMinute } from '../../../utils/formatters/formatToMinute'; type RecentScrapCardProps = { - scrap: ScrapDetailResp & { type: 'SCRAP' }; + scrap: ScrapListItemResp & { type: 'SCRAP' }; }; export const RecentScrapCard = ({ scrap }: RecentScrapCardProps) => { diff --git a/apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx index 0425bd79..f5f174d0 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx @@ -13,7 +13,6 @@ import { useNavigation } from '@react-navigation/native'; import type { ScrapListItemProps } from '../types'; import { isItemSelected } from '../../../utils/reducer'; import { useNoteStore } from '@/features/student/scrap/stores/scrapNoteStore'; -import { useRecentScrapStore } from '@/features/student/scrap/stores/recentScrapStore'; import { colors } from '@/theme/tokens'; import { ImageWithSkeleton } from '@/components/common'; import { formatToMinute } from '../../../utils/formatters/formatToMinute'; @@ -25,7 +24,6 @@ export const ScrapCard = (props: ScrapListItemProps) => { const isSelected = isItemSelected(state.selectedItems, props.id, props.type); const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); - const addScrap = useRecentScrapStore((state) => state.addScrap); const { openMoveScrapModal } = useScrapModal(); const folderId = props.type === 'SCRAP' ? props.folderId : undefined; @@ -40,13 +38,24 @@ export const ScrapCard = (props: ScrapListItemProps) => { if (props.type === 'FOLDER') { return ( - + ); } else if (props.type === 'SCRAP') { return ( - + ); } @@ -54,42 +63,42 @@ export const ScrapCard = (props: ScrapListItemProps) => { }; const cardContent = ( - - - - - {state.isSelecting && ( - - - - )} - - - - - - {props.name} - - {!state.isSelecting && ( + + + {state.isSelecting && ( + + {isSelected && } + + )} + + + + + {props.name} + + {!state.isSelecting && ( + } + from={} triggerBorderRadius={4} children={(close) => ( { /> )} /> - )} - - {props.type === 'FOLDER' && props.scrapCount !== undefined && ( - {props.scrapCount} + )} - - {props.updatedAt - ? formatToMinute(new Date(props.updatedAt)) - : formatToMinute(new Date(props.createdAt))} - + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( + {props.scrapCount} + )} + + {props.updatedAt + ? formatToMinute(new Date(props.updatedAt)) + : formatToMinute(new Date(props.createdAt))} + ); @@ -124,7 +133,7 @@ export const ScrapCard = (props: ScrapListItemProps) => { return ( <> { if (state.isSelecting) { props.onCheckPress?.(); diff --git a/apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx index c370d70f..b0062bc3 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx @@ -1,16 +1,9 @@ import { colors } from '@/theme/tokens'; import { Check, Plus } from 'lucide-react-native'; import { Pressable, View, Text } from 'react-native'; -import { TooltipPopover, AddItemTooltipBox, ReviewItemTooltipBox } from '../../Tooltip'; +import { TooltipPopover, AddItemTooltipBox } from '../../Tooltip'; import { Placement } from 'react-native-popover-view/dist/Types'; -import { BookmarkFilledIcon, ChevronDownFilledIcon } from '@/components/system/icons'; -import { ScrapListItemProps } from '../types'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { StudentRootStackParamList } from '@/navigation/student/types'; -import { useState } from 'react'; -import { CreateFolderModal } from '../../Modal/CreateFolderModal'; -import { LoadQnaImageModal } from '../../Modal/LoadQnaImageModal'; +import { BookmarkFilledIcon } from '@/components/system/icons'; import { isItemSelected, State } from '../../../utils/reducer'; import { useScrapModal } from '../../../contexts/ScrapModalsContext'; @@ -20,19 +13,18 @@ interface ScrapHeadCardProps { } export const ScrapAddCard = (props: ScrapHeadCardProps) => { - const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); const isSelecting = props?.reducerState.isSelecting ?? false; - const { openCreateFolderModal } = useScrapModal(); + const { openCreateFolderModal, openLoadQnaImageModal } = useScrapModal(); const addItemContent = ( - + - - - - - 추가하기 + + + + + 추가하기 ); @@ -56,7 +48,7 @@ export const ScrapAddCard = (props: ScrapHeadCardProps) => { onOpenQnaImgModal={() => { close(); setTimeout(() => { - setisQnaImageModalVisible(true); + openLoadQnaImageModal(); }, 200); }} /> @@ -64,11 +56,6 @@ export const ScrapAddCard = (props: ScrapHeadCardProps) => { from={addItemContent} /> )} - setisQnaImageModalVisible(false)} - onSuccess={() => {}} - /> ); }; @@ -78,7 +65,7 @@ export const ScrapAllCard = (props: ScrapHeadCardProps) => { return ( { if (props.reducerState.isSelecting) { props.onCheckPress?.(); @@ -102,7 +89,6 @@ export const ScrapAllCard = (props: ScrapHeadCardProps) => { )} - 전체 스크랩 diff --git a/apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx index 5e308dea..d8efd0fd 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx @@ -58,8 +58,8 @@ export const SearchResultCard = (props: SearchResultCardProps) => { fallback={renderFallback()} /> - - + + { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; const isSelected = isItemSelected(state.selectedItems, props.id, props.type); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const [isTooltipOpen, setIsTooltipOpen] = useState(false); const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); + + const shouldShowHover = state.isSelecting ? isSelected : isTooltipOpen; const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; const { imageSources, isDiagonalLayout } = useCardImageSources( @@ -44,7 +47,10 @@ export const TrashCard = (props: TrashListItemProps) => { if (props.type === 'FOLDER') { return ( - + ); } else if (props.type === 'SCRAP') { @@ -57,11 +63,12 @@ export const TrashCard = (props: TrashListItemProps) => { return ; }; - const cardContent = ( + const cardContent = () => ( { onPress={props.onCheckPress} className={ isSelected - ? 'absolute h-4 w-4 items-center justify-center rounded bg-blue-500' - : 'absolute h-4 w-4 items-center justify-center rounded border border-gray-700 bg-white' + ? 'absolute h-[18px] w-[18px] items-center justify-center rounded bg-blue-500' + : 'absolute h-[18px] w-[18px] items-center justify-center rounded border border-gray-700 bg-white' } - style={{ bottom: 10 }}> - + style={{ top: 108 }}> + {isSelected && } )} - - - - + + + + {props.name} @@ -98,7 +105,7 @@ export const TrashCard = (props: TrashListItemProps) => { )} {props.daysUntilPermanentDelete}일 남음 @@ -119,10 +126,11 @@ export const TrashCard = (props: TrashListItemProps) => { }} disabled={!state.isSelecting}> {state.isSelecting ? ( - cardContent + cardContent() ) : ( ( = ({ labelField='label' valueField='value' value={orderValue} - onChange={(item) => setOrderValue(item.value)} + onChange={(item) => { + if (item.value === orderValue) { + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC')); + } else { + setOrderValue(item.value); + } + }} onFocus={() => setIsFocus(true)} onBlur={() => setIsFocus(false)} renderRightIcon={() => ( - { - e.stopPropagation(); - setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC')); - }} - style={styles.sortOrderButton}> + <> {sortOrder === 'ASC' ? ( ) : ( )} - + )} renderItem={(item) => { const isSelected = item.value === orderValue; @@ -129,15 +130,15 @@ export default SortDropdown; const styles = StyleSheet.create({ dropdown: { - width: 80, + width: 71, height: 29, - gap: 2, alignItems: 'center', paddingTop: 4, paddingRight: 4, paddingLeft: 8, paddingBottom: 4, borderRadius: 4, + gap: 4, }, sortOrderButton: { width: 20, diff --git a/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx index 9ec15eac..b51119df 100644 --- a/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx @@ -68,19 +68,19 @@ const DeletedScrapHeader = ({ {!isAllSelected ? '전체 선택' : '전체 해제'} - 스크랩 + 휴지통 완료 - + { if (isActionEnabled && actions.onRestore) actions.onRestore(); }} - className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] px-[10px] py-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> 복구 @@ -88,7 +88,7 @@ const DeletedScrapHeader = ({ onPress={() => { if (isActionEnabled && actions.onDelete) actions.onDelete(); }} - className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] px-[10px] py-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> 영구 삭제 diff --git a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx index b300fc6f..7979fb8f 100644 --- a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -63,7 +63,7 @@ const ScrapHeader = ({ {navigateback ? ( {title} @@ -94,24 +94,24 @@ const ScrapHeader = ({ {reducerState.isSelecting && ( - - + + {!isAllSelected ? '전체 선택' : '전체 해제'} - {title} - - + {title} + + 완료 - + { if (isActionEnabled && actions.onMove) actions.onMove(); }} - className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] px-[10px] py-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> 이동 @@ -119,9 +119,9 @@ const ScrapHeader = ({ onPress={() => { if (isActionEnabled && actions.onDelete) actions.onDelete(); }} - className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] px-[10px] py-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> - 삭제 + 휴지통으로 이동 diff --git a/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx index 4e3bbeda..b0299ab4 100644 --- a/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx @@ -1,3 +1,4 @@ +import { CircleXFilledIcon } from '@/components/system/icons'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { colors } from '@/theme/tokens'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -19,8 +20,7 @@ const SearchScrapHeader = ({ setQuery, onSubmitEditing, }: SearchScrapHeaderProps) => { - - const inputRef = useRef(null); + const inputRef = useRef(null); useEffect(() => { // 컴포넌트가 마운트될 때 자동으로 포커스 @@ -30,19 +30,18 @@ const SearchScrapHeader = ({ return () => clearTimeout(timer); }, []); - + return ( - + setQuery('')}> - + )} diff --git a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx index 8567f395..5113a25b 100644 --- a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -52,8 +52,7 @@ export const CreateFolderModal = () => { ]); return files[0].id; } catch (error: any) { - showToast('error', error.message); - throw error; // 에러를 다시 throw하여 상위에서 처리 가능하도록 + console.log('error', error.message); } } return null; @@ -77,10 +76,12 @@ export const CreateFolderModal = () => { thumbnailImageId: uploadedImageId ?? undefined, }); - showToast('success', '폴더가 추가되었습니다.'); closeCreateFolderModal(); refetchFolders?.(); refetchScraps?.(); + setTimeout(() => { + showToast('success', '폴더가 추가되었습니다.'); + }, 0); } catch (error: any) { showToast('error', error.message); } finally { @@ -99,8 +100,6 @@ export const CreateFolderModal = () => { return () => clearTimeout(timer); }, []); - - return ( { )} - + void; - onSuccess?: () => void; -} - -export const LoadQnaImageModal = ({ visible, onClose, onSuccess }: LoadQnaImageModalProps) => { +export const LoadQnaImageModal = () => { + const { isLoadQnaImageModalVisible, closeLoadQnaImageModal, refetchScraps } = useScrapModal(); const { mutateAsync: createScrapFromImage } = useCreateScrapFromImage(); const [containerWidth, setContainerWidth] = useState(0); @@ -23,7 +19,7 @@ export const LoadQnaImageModal = ({ visible, onClose, onSuccess }: LoadQnaImageM const [previewImage, setPreviewImage] = useState(null); const [sortKey, setSortKey] = useState('DATE'); - const [sortOrder, setSortOrder] = useState('ASC'); + const [sortOrder, setSortOrder] = useState('DESC'); const [isCreating, setIsCreating] = useState(false); @@ -36,6 +32,13 @@ export const LoadQnaImageModal = ({ visible, onClose, onSuccess }: LoadQnaImageM order: sortOrder, }); + // 모달이 닫힐 때 상태 초기화 + useEffect(() => { + if (!isLoadQnaImageModalVisible) { + setSelectedId(null); + } + }, [isLoadQnaImageModalVisible]); + const NUM_COLUMNS = 4; const GAP = 5; const IMAGE_SIZE = (containerWidth - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS; @@ -56,9 +59,11 @@ export const LoadQnaImageModal = ({ visible, onClose, onSuccess }: LoadQnaImageM await createScrapFromImage({ imageId: selectedId, }); - showToast('success', '스크랩이 생성되었습니다.'); - onClose(); - onSuccess?.(); + closeLoadQnaImageModal(); + refetchScraps?.(); + setTimeout(() => { + showToast('success', '스크랩이 생성되었습니다.'); + }, 0); } catch (error: any) { showToast('error', error.message); } finally { @@ -67,16 +72,16 @@ export const LoadQnaImageModal = ({ visible, onClose, onSuccess }: LoadQnaImageM }; useEffect(() => { - if (visible) { + if (isLoadQnaImageModalVisible) { refetch(); } - }, [visible, refetch]); + }, [isLoadQnaImageModalVisible, refetch]); return ( <> { if (!isCreating) { handleComplete(); diff --git a/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx index e2aa0c40..3fc469de 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx @@ -9,6 +9,7 @@ import { TooltipContainer } from './TooltipContainer'; import { TooltipMenuItem } from './TooltipMenuItem'; import { showToast } from '../Notification/Toast'; import { useState } from 'react'; +import { useScrapModal } from '../../contexts/ScrapModalsContext'; export interface AddScrapTooltipProps { onClose?: () => void; @@ -26,6 +27,7 @@ export const AddScrapTooltip = ({ }: AddScrapTooltipProps) => { const { mutateAsync: createScrapFromImage } = useCreateScrapFromImage(); const { mutateAsync: uploadFile } = useUploadFile(); + const { refetchScraps } = useScrapModal(); const [isCreating, setIsCreating] = useState(false); @@ -46,6 +48,7 @@ export const AddScrapTooltip = ({ showToast('success', '스크랩이 생성되었습니다.'); onClose?.(); + refetchScraps?.(); } catch (error) { showToast('error', (error as any).message || '스크랩 생성에 실패했습니다.'); } finally { diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx index bbb0d6a9..12322ab8 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx @@ -1,15 +1,7 @@ import { colors } from '@/theme/tokens'; -import { - ArrowRightLeft, - BookImage, - BookOpenText, - FileSymlink, - FolderOpen, - ImagePlay, - Trash2, -} from 'lucide-react-native'; -import { useState } from 'react'; -import { TextInput, View, Alert } from 'react-native'; +import { ArrowRightLeft, BookImage, BookOpenText, Trash2 } from 'lucide-react-native'; +import { useEffect, useState } from 'react'; +import { TextInput, View } from 'react-native'; import { showToast } from '../Notification/Toast'; import { ScrapListItemProps } from '../Card/types'; import { useNavigation } from '@react-navigation/native'; @@ -17,24 +9,18 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { useUpdateScrapName, - useUpdateFolder, useUpdateFolderName, useUpdateFolderThumbnail, useDeleteScrap, useGetScrapDetail, useGetFolders, useUploadFile, - useGetScrapsByFolder, } from '@/apis'; import { useNoteStore } from '@/features/student/scrap/stores/scrapNoteStore'; -import { - openImageLibrary, - openImageLibraryWithErrorHandling, -} from '../../utils/images/imagePicker'; +import { openImageLibraryWithErrorHandling } from '../../utils/images/imagePicker'; import { TooltipContainer } from './TooltipContainer'; import { TooltipMenuItem } from './TooltipMenuItem'; -import { useRecentScrapStore } from '../../stores/recentScrapStore'; import { invalidateScrapSearchQueries } from '@/apis/controller/student/scrap/utils'; import { useQueryClient } from '@tanstack/react-query'; @@ -51,17 +37,12 @@ export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemToolt const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); - const closeNote = useNoteStore((state) => state.closeNote); - const removeScrap = useRecentScrapStore((state) => state.removeScrap); - const closeNotesByScrapIds = useNoteStore((state) => state.closeNotesByScrapIds); // API hooks const { mutateAsync: updateScrapName } = useUpdateScrapName(); const { mutateAsync: updateFolderName } = useUpdateFolderName(); const { mutateAsync: updateFolderThumbnail } = useUpdateFolderThumbnail(); const { mutateAsync: deleteScrap } = useDeleteScrap(); - const removeScrapsByScrapIds = useRecentScrapStore((state) => state.removeScrapsByIds); - const { data: folderScrapsData } = useGetScrapsByFolder({ folderId: Number(props.id) }); const queryClient = useQueryClient(); // 스크랩 상세 정보 가져오기 (필요한 경우) @@ -112,13 +93,16 @@ export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemToolt } }; - // 초기 제목 설정 - const initialTitle = + const sourceTitle = props.type === 'SCRAP' - ? scrapDetail?.name || props.name - : foldersData?.data?.find((f) => f.id === props.id)?.name || props.name; + ? (scrapDetail?.name ?? props.name) + : (foldersData?.data?.find((f) => f.id === props.id)?.name ?? props.name); - const [text, setText] = useState(initialTitle); + const [text, setText] = useState(sourceTitle); + + useEffect(() => { + setText(sourceTitle); + }, [sourceTitle]); const handleClose = () => { onClose?.(); @@ -143,23 +127,6 @@ export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemToolt }, 100); }; - const cleanupAfterDelete = async (id: number) => { - if (props.type === 'SCRAP') { - removeScrap(id); - closeNote(id); - } else if (props.type === 'FOLDER') { - // 폴더 삭제 시: 폴더 내 스크랩 ID 목록을 가져와서 노트 닫기 - const folderScrapIds = - folderScrapsData?.data?.filter((item) => item.type === 'SCRAP').map((item) => item.id) || - []; - - if (folderScrapIds.length > 0) { - closeNotesByScrapIds(folderScrapIds); - removeScrapsByScrapIds(folderScrapIds); - } - } - }; - const handleDelete = async () => { handleClose(); @@ -168,7 +135,6 @@ export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemToolt items: [{ id: props.id, type: props.type as 'FOLDER' | 'SCRAP' }], }); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); - cleanupAfterDelete(props.id); } catch (error: any) { showToast('error', '삭제 중 오류가 발생했습니다.'); } finally { @@ -183,13 +149,12 @@ export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemToolt { const trimmedText = text.trim(); - if (trimmedText.length > 0 && trimmedText !== initialTitle) { + if (trimmedText.length > 0 && trimmedText !== sourceTitle) { try { if (props.type === 'FOLDER') { await updateFolderName({ @@ -209,7 +174,7 @@ export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemToolt } invalidateScrapSearchQueries(queryClient); } else { - setText(initialTitle); + setText(sourceTitle); } }} /> diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx index edd1ac78..ce974208 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx @@ -11,6 +11,7 @@ export interface TooltipPopoverProps { popoverStyle?: ViewStyle; triggerBorderRadius?: number; triggerBackgroundColor?: string; + onOpenChange?: (isOpen: boolean) => void; } const TooltipPopover = ({ @@ -20,17 +21,24 @@ const TooltipPopover = ({ popoverStyle, triggerBorderRadius = 10, triggerBackgroundColor = colors['gray-300'], + onOpenChange, }: TooltipPopoverProps) => { const [isVisible, setIsVisible] = React.useState(false); + const open = () => { + setIsVisible(true); + onOpenChange?.(true); + }; + const close = () => { setIsVisible(false); + onOpenChange?.(false); }; // from을 Pressable로 감싸서 클릭 시 열리도록 함 const triggerElement = ( setIsVisible(true)} + onPress={open} style={{ borderRadius: triggerBorderRadius, backgroundColor: isVisible ? triggerBackgroundColor : 'transparent', @@ -50,8 +58,6 @@ const TooltipPopover = ({ popoverStyle={{ alignItems: 'center', borderRadius: 10, - borderWidth: 1, - borderColor: colors['gray-400'], shadowColor: '#0F0F12', shadowOffset: { width: 0, height: 6 }, shadowOpacity: 0.1, diff --git a/apps/native/src/features/student/scrap/components/scrap/AnalysisSection.tsx b/apps/native/src/features/student/scrap/components/scrap/AnalysisSection.tsx new file mode 100644 index 00000000..fbf0699a --- /dev/null +++ b/apps/native/src/features/student/scrap/components/scrap/AnalysisSection.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +import { View, Text } from 'react-native'; +import { BookmarkIcon } from 'lucide-react-native'; +import { colors } from '@/theme/tokens'; +import ProblemViewer from '../../../problem/components/ProblemViewer'; + +interface AnalysisSectionProps { + label: string; + content: string; + isScraped?: boolean; + showBookmark?: boolean; + minHeight?: number; + padding?: number; +} + +export const AnalysisSection = ({ + label, + content, + isScraped = false, + showBookmark = true, + minHeight = 100, + padding = 14, +}: AnalysisSectionProps) => { + return ( + + + + {label} + + {showBookmark && ( + + + + )} + + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/scrap/PointingsList.tsx b/apps/native/src/features/student/scrap/components/scrap/PointingsList.tsx index 30e7deb4..1bf22ce5 100644 --- a/apps/native/src/features/student/scrap/components/scrap/PointingsList.tsx +++ b/apps/native/src/features/student/scrap/components/scrap/PointingsList.tsx @@ -43,7 +43,7 @@ export const PointingsList = ({ pointingsWithLabels, shouldShowPointing }: Point )} diff --git a/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx b/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx index aafa4bed..e1bfd63b 100644 --- a/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx +++ b/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx @@ -16,6 +16,11 @@ interface ScrapModalsContextValue { openMoveScrapModal: (props: { currentFolderId?: number; selectedItems: SelectedItem[] }) => void; closeMoveScrapModal: () => void; + // LoadQnaImageModal 상태 + isLoadQnaImageModalVisible: boolean; + openLoadQnaImageModal: () => void; + closeLoadQnaImageModal: () => void; + // 폴더 목록 refetch 함수 refetchFolders?: () => void; setRefetchFolders: (refetch: () => void) => void; @@ -55,6 +60,7 @@ interface ScrapModalsProviderProps { export const ScrapModalsProvider = ({ children }: ScrapModalsProviderProps) => { const [isCreateFolderModalVisible, setIsCreateFolderModalVisible] = useState(false); const [isMoveScrapModalVisible, setIsMoveScrapModalVisible] = useState(false); + const [isLoadQnaImageModalVisible, setIsLoadQnaImageModalVisible] = useState(false); const [moveScrapModalProps, setMoveScrapModalProps] = useState<{ currentFolderId?: number; selectedItems: SelectedItem[]; @@ -87,6 +93,14 @@ export const ScrapModalsProvider = ({ children }: ScrapModalsProviderProps) => { setIsMoveScrapModalVisible(false); }, []); + const openLoadQnaImageModal = useCallback(() => { + setIsLoadQnaImageModalVisible(true); + }, []); + + const closeLoadQnaImageModal = useCallback(() => { + setIsLoadQnaImageModalVisible(false); + }, []); + const setRefetchFolders = useCallback((refetch: () => void) => { setRefetchFoldersState(() => refetch); }, []); @@ -107,6 +121,9 @@ export const ScrapModalsProvider = ({ children }: ScrapModalsProviderProps) => { moveScrapModalProps, openMoveScrapModal, closeMoveScrapModal, + isLoadQnaImageModalVisible, + openLoadQnaImageModal, + closeLoadQnaImageModal, refetchFolders, setRefetchFolders, refetchScraps, diff --git a/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx b/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx index 16a65766..7d63ac2d 100644 --- a/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx +++ b/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx @@ -2,6 +2,7 @@ import React from 'react'; import { ScrapModalProvider } from '../contexts/ScrapModalsContext'; import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; +import { LoadQnaImageModal } from '../components/Modal/LoadQnaImageModal'; /** * 스크랩 모달들을 자동으로 추가하는 HOC (Higher-Order Component) @@ -23,6 +24,7 @@ export const withScrapModals =

(Component: React.ComponentType return ( + diff --git a/apps/native/src/features/student/scrap/hooks/index.ts b/apps/native/src/features/student/scrap/hooks/index.ts index 893959e3..f37cbde3 100644 --- a/apps/native/src/features/student/scrap/hooks/index.ts +++ b/apps/native/src/features/student/scrap/hooks/index.ts @@ -1,2 +1,3 @@ export { useCardImageSources } from './useCardImageSources'; export { useScrapSelection } from './useScrapSelection'; +export { useScrapStoreSync } from './useScrapStoreSync'; diff --git a/apps/native/src/features/student/scrap/hooks/useScrapStoreSync.ts b/apps/native/src/features/student/scrap/hooks/useScrapStoreSync.ts new file mode 100644 index 00000000..33569a7a --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/useScrapStoreSync.ts @@ -0,0 +1,55 @@ +import { useEffect, useRef } from 'react'; +import { useRecentScrapStore } from '../stores/recentScrapStore'; +import { useNoteStore } from '../stores/scrapNoteStore'; + +/** + * 유효한 스크랩 ID 목록과 store를 동기화하는 훅 + * - recentScrapStore: 최근 본 스크랩 중 삭제된 항목 제거 + * - scrapNoteStore: 열린 노트 탭 중 삭제된 항목 닫기 + * + * @param validScrapIds 현재 유효한 스크랩 ID 배열 (undefined면 로딩 중으로 스킵) + */ +export const useScrapStoreSync = (validScrapIds: number[] | undefined) => { + // recentScrapStore + const recentScrapIds = useRecentScrapStore((state) => state.scrapIds); + const removeScrapIds = useRecentScrapStore((state) => state.removeScrapIds); + + // scrapNoteStore + const openNotes = useNoteStore((state) => state.openNotes); + const closeNotesByScrapIds = useNoteStore((state) => state.closeNotesByScrapIds); + + // 초기 마운트 시에는 동기화 스킵 (불필요한 정리 방지) + const isInitialMount = useRef(true); + + useEffect(() => { + // 첫 마운트 시에는 스킵 + if (isInitialMount.current) { + isInitialMount.current = false; + return; + } + + // undefined면 아직 로딩 중 → 스킵 + if (validScrapIds === undefined) { + return; + } + + // 빈 배열이면 모든 스크랩이 삭제된 상태 → 정리 실행 + const validSet = new Set(validScrapIds); + + // recentScrapStore 정리 + const invalidRecentIds = recentScrapIds.filter((id) => !validSet.has(id)); + + if (invalidRecentIds.length > 0) { + removeScrapIds(invalidRecentIds); + } + + // scrapNoteStore 정리 (열린 탭 중 삭제된 스크랩 닫기) + const invalidNoteIds = openNotes + .filter((note) => !validSet.has(note.id)) + .map((note) => note.id); + + if (invalidNoteIds.length > 0) { + closeNotesByScrapIds(invalidNoteIds); + } + }, [validScrapIds]); +}; diff --git a/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx index be367445..fac687bc 100644 --- a/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx @@ -1,7 +1,7 @@ import { View } from 'react-native'; import ScrapHeader from '../components/Header/ScrapHeader'; -import { useMemo, useState, useEffect } from 'react'; -import { sortScrapData, mapUIKeyToAPIKey } from '../utils/formatters/sortScrap'; +import { useState, useEffect, useMemo } from 'react'; +import { mapUIKeyToAPIKey, sortScrapData } from '../utils/formatters/sortScrap'; import type { UISortKey, SortOrder } from '../utils/types'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -15,9 +15,6 @@ import { useScrapModal } from '../contexts/ScrapModalsContext'; import { useScrapSelection } from '../hooks'; import { validateOnlyScrapCanMove } from '../utils/validation'; import { withScrapModals } from '../hoc'; -import { useRecentScrapStore } from '../stores/recentScrapStore'; -import { useNoteStore } from '../stores/scrapNoteStore'; -import { SelectedItem } from '../utils/reducer'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; type FolderScrapRouteProp = RouteProp; @@ -31,11 +28,10 @@ const FolderScrapScreenContent = () => { const [sortOrder, setSortOrder] = useState('DESC'); const navigation = useNavigation>(); const { openMoveScrapModal, setRefetchScraps, setRefetchFolders } = useScrapModal(); - const removeScrap = useRecentScrapStore((state) => state.removeScrap); - const closeNote = useNoteStore((state) => state.closeNote); // API 호출 - const { data: foldersData, refetch: refetchFolders } = useGetFolders(); + const { data: foldersData, refetch: refetchFolders } = useGetFolders(); // 폴더 정보 가져오기 + const { data: data, isLoading, @@ -43,7 +39,15 @@ const FolderScrapScreenContent = () => { } = useGetScrapsByFolder( { folderId: Number(id) }, { sortOption: mapUIKeyToAPIKey(sortKey), order: sortOrder } - ); + ); // 해당 폴더의 스크랩 가져오기 + + // 폴더 변경 시 폴더 목록 refetch + useEffect(() => { + if (refetchFolders) { + setRefetchFolders(refetchFolders); + } + }, [refetchFolders, setRefetchFolders]); + const { mutateAsync: deleteScrap } = useDeleteScrap(); // refetch를 context에 등록 @@ -52,30 +56,14 @@ const FolderScrapScreenContent = () => { setRefetchScraps(() => refetch); } }, [refetch, setRefetchScraps]); - useEffect(() => { - if (refetchFolders) { - setRefetchFolders(refetchFolders); - } - }, [refetchFolders, setRefetchFolders]); // 폴더 정보 가져오기 - const folder = data?.data?.find((f) => f.id === Number(id)); + const folder = foldersData?.data?.find((f) => f.id === Number(id)); const contents = data?.data || []; - // // 정렬된 데이터 - // const sortedData = useMemo( - // () => sortScrapData(contents, sortKey, sortOrder), - // [contents, sortKey, sortOrder] - // ); - - const cleanupAfterDelete = (items: SelectedItem[]) => { - items.forEach((item) => { - if (item.type === 'SCRAP') { - removeScrap(item.id as number); - closeNote(item.id as number); - } - }); - }; + const sortedData = useMemo(() => { + return sortScrapData(contents, sortKey, sortOrder); + }, [contents, sortKey, sortOrder]); const isAllSelected = reducerState.selectedItems.length === contents.length && contents.length > 0; @@ -125,7 +113,6 @@ const FolderScrapScreenContent = () => { items: items.map((item) => ({ id: item.id as number, type: item.type })), }); dispatch({ type: 'CLEAR_SELECTION' }); - cleanupAfterDelete(items); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); } catch (error: any) { showToast('error', error.message); @@ -148,7 +135,7 @@ const FolderScrapScreenContent = () => { {isLoading ? ( ) : ( - + )} diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx index 561eaff9..ed0d42fa 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx @@ -10,7 +10,7 @@ import { useWindowDimensions, Pressable, } from 'react-native'; -import { RouteProp, useRoute, useNavigation } from '@react-navigation/native'; +import { RouteProp, useRoute, useNavigation, useFocusEffect } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { SafeAreaView } from 'react-native-safe-area-context'; import Animated, { @@ -23,7 +23,13 @@ import Animated, { } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { StudentRootStackParamList } from '@/navigation/student/types'; -import { TanstackQueryClient, useGetScrapDetail, useUpdateScrapName } from '@/apis'; +import { + TanstackQueryClient, + useGetScrapDetail, + useUpdateScrapName, + useGetEntireProblemPointing, + useGetEntireProblem, +} from '@/apis'; import { LoadingScreen } from '@/components/common'; import { useNoteStore } from '@/features/student/scrap/stores/scrapNoteStore'; import { toAlphabetSequence } from '../utils/formatters/toAlphabetSequence'; @@ -35,6 +41,7 @@ import { ScrapDetailHeader } from '../components/Header/ScrapDetailHeader'; import { TabNavigator } from '../components/scrap/TabNavigator'; import { FilterBar } from '../components/scrap/FilterBar'; import { ProblemSection } from '../components/scrap/ProblemSection'; +import { AnalysisSection } from '../components/scrap/AnalysisSection'; import { PointingsList } from '../components/scrap/PointingsList'; import { DrawingToolbar } from '../components/scrap/DrawingToolbar'; import { ProblemExpansionModal } from '../components/scrap/ProblemExpansionModal'; @@ -51,6 +58,7 @@ import { shouldShowProblem, shouldShowPointing, hasVisiblePointings, + shouldShowAnalysisSection, } from '../utils/scrapFilters'; import { showToast } from '../components/Notification/Toast'; import { withScrapModals } from '../hoc/withScrapModals'; @@ -61,7 +69,7 @@ import { useQueryClient } from '@tanstack/react-query'; type ScrapDetailRouteProp = RouteProp; -const DRAG_HANDLE_WIDTH = 4; +const DRAG_HANDLE_WIDTH = 6; const DIVIDER_WIDTH = 8; const DRAG_HANDLE_GAP = 10; @@ -77,7 +85,16 @@ const ScrapDetailScreen = () => { isLoading, refetch: refetchScrapDetail, } = useGetScrapDetail(scrapId, !!id); - const addScrap = useRecentScrapStore((state) => state.addScrap); + + const { data: entireProblemPointing, refetch: refetchEntireProblemPointing } = + useGetEntireProblemPointing(scrapDetail?.problem?.id as number, !!scrapDetail?.problem?.id); + + const { data: entireProblem, refetch: refetchEntireProblem } = useGetEntireProblem( + scrapDetail?.problem?.id as number, + !!scrapDetail?.problem?.id + ); + + const addScrapId = useRecentScrapStore((state) => state.addScrapId); const { mutateAsync: updateScrapName } = useUpdateScrapName(); const { openNotes, activeNoteId, setActiveNote, closeNote, reorderNotes, updateNoteTitle } = useNoteStore(); @@ -87,9 +104,9 @@ const ScrapDetailScreen = () => { React.useEffect(() => { if (scrapDetail) { - addScrap(scrapDetail); + addScrapId(scrapDetail.id); } - }, [scrapDetail, addScrap]); + }, [scrapDetail, addScrapId]); // scrapDetail이 로드되면 scrapName 동기화 useEffect(() => { @@ -149,13 +166,28 @@ const ScrapDetailScreen = () => { const MIN_RIGHT_WIDTH = SCREEN_WIDTH * 0.25; const DEFAULT_LEFT_WIDTH = SCREEN_WIDTH * 0.5; - // refetchScrapDetail을 context에 등록 useEffect(() => { if (refetchScrapDetail) { setRefetchScrapDetail(refetchScrapDetail); } }, [refetchScrapDetail, setRefetchScrapDetail]); + useEffect(() => { + if (scrapDetail?.problem?.id) { + refetchEntireProblemPointing(); + refetchEntireProblem(); + } + }, [scrapDetail?.problem?.id, refetchEntireProblemPointing, refetchEntireProblem]); + + useFocusEffect( + useCallback(() => { + if (activeNoteId !== scrapId) { + return; + } + refetchScrapDetail(); + }, [refetchScrapDetail, activeNoteId, scrapId]) + ); + useEffect(() => { return () => { queryClient.invalidateQueries({ @@ -204,7 +236,11 @@ const ScrapDetailScreen = () => { // Active note sync useEffect(() => { if (activeNoteId && activeNoteId !== scrapId) { - navigation.setParams({ id: String(activeNoteId) }); + try { + navigation.setParams({ id: String(activeNoteId) }); + } catch (error) { + showToast('error', '스크랩 상세 페이지 이동에 실패했습니다.'); + } } }, [activeNoteId, scrapId, navigation]); @@ -268,8 +304,7 @@ const ScrapDetailScreen = () => { clearTimeout(indicatorTimeoutRef.current); } }; - // uiState 객체 대신 필요한 메서드만 dependency에 포함 - }, []); + }, [scrapId]); // Derived state - Pointings with labels const pointingsWithLabels = useMemo(() => { @@ -288,20 +323,35 @@ const ScrapDetailScreen = () => { // Visible content checks const showProblem = shouldShowProblem(uiState.selectedFilter); const hasPointings = hasVisiblePointings(scrapDetail, uiState.selectedFilter); + const hasReadingTip = shouldShowAnalysisSection( + uiState.selectedFilter, + 'readingTip', + pointingsWithLabels.length, + scrapDetail + ); + const hasOneStepMore = shouldShowAnalysisSection( + uiState.selectedFilter, + 'oneStepMore', + pointingsWithLabels.length, + scrapDetail + ); + const showExplanation = uiState.selectedFilter === 0; const hasExplanation = !!( scrapDetail?.pointings && scrapDetail.pointings.some((pointing) => pointing.commentContent) ); + // Handlers const handleViewAllPointings = useCallback(() => { - const group = convertScrapToGroup(scrapDetail!); + const group = convertScrapToGroup(entireProblem?.data || [], entireProblemPointing?.data || []); if (!group) return; navigation.navigate('AllPointings', { group, - problemSetTitle: scrapDetail?.name || '스크랩', + // problemSetTitle: scrapDetail?.name || '스크랩', + // publishAt: scrapDetail?.createdAt, }); - }, [scrapDetail, navigation]); + }, [scrapDetail, navigation, entireProblemPointing, entireProblem]); const handleTabLayout = useCallback((noteId: number, event: LayoutChangeEvent) => { const { x, width } = event.nativeEvent.layout; @@ -454,6 +504,7 @@ const ScrapDetailScreen = () => { {/* Header */} { selectedFilter={uiState.selectedFilter} onFilterChange={uiState.setSelectedFilter} showViewAll={ - !!scrapDetail.pointings && - scrapDetail.pointings.length > 0 && - !!scrapDetail.problem + (!!scrapDetail.problem && + ((scrapDetail.problem?.readingTipContent && + scrapDetail.problem?.readingTipContent.length > 0) || + (scrapDetail.problem?.oneStepMoreContent && + scrapDetail.problem?.oneStepMoreContent.length > 0))) || + (!!scrapDetail.pointings && scrapDetail.pointings.length > 0) } onViewAll={handleViewAllPointings} /> @@ -525,6 +579,7 @@ const ScrapDetailScreen = () => { {showProblem && (scrapDetail.problem?.problemContent || scrapDetail.thumbnailUrl) && ( { // .join('\n') || '' // scrapDetail.pointings[0]?.commentContent || '' mergeTipTapDocs( - (scrapDetail.pointings || []).map((pointing) => pointing.commentContent), + (entireProblemPointing?.data || []).map( + (pointing) => pointing.commentContent + ), false ) || '' } @@ -563,6 +620,24 @@ const ScrapDetailScreen = () => { shouldShowPointing={(idx) => shouldShowPointing(uiState.selectedFilter, idx)} /> )} + + {hasPointings && } + + {/* AnalysisSection */} + {hasReadingTip && ( + + )} + {hasOneStepMore && ( + + )} @@ -611,6 +686,7 @@ const ScrapDetailScreen = () => { drawingAreaWidth={currentDrawingWidth} /> { const [reducerState, dispatch] = useScrapSelection(); const [sortKey, setSortKey] = useState('DATE'); const [sortOrder, setSortOrder] = useState('DESC'); const navigation = useNavigation>(); - const recentScraps = useRecentScrapStore((state) => state.scraps); + const recentScrapIds = useRecentScrapStore((state) => state.scrapIds); const { openMoveScrapModal, setRefetchScraps } = useScrapModal(); - const queryClient = useQueryClient(); const { data: searchData, @@ -41,10 +39,6 @@ const ScrapScreenContent = () => { }); const { mutateAsync: deleteScrap } = useDeleteScrap(); - const removeScrap = useRecentScrapStore((state) => state.removeScrap); - const removeScrapsByIds = useRecentScrapStore((state) => state.removeScrapsByIds); - const closeNote = useNoteStore((state) => state.closeNote); - const closeNotesByScrapIds = useNoteStore((state) => state.closeNotesByScrapIds); // refetch를 context에 등록 React.useEffect(() => { @@ -53,14 +47,30 @@ const ScrapScreenContent = () => { } }, [refetch, setRefetchScraps]); + // 화면 포커스 시 데이터 동기화 (다른 화면에서 변경 후 돌아왔을 때) + useFocusEffect( + useCallback(() => { + refetch(); + }, [refetch]) + ); + + // 최근 본 스크랩 데이터 (searchData에서 찾아서 최신 정보 표시) const recentScrapsData = useMemo(() => { - if (recentScraps.length === 0) return []; + if (recentScrapIds.length === 0 || !searchData) return []; - return recentScraps.map((item) => ({ - ...item.scrapDetail, - type: 'SCRAP' as const, - })); - }, [recentScraps]); + const typedSearchData = searchData as ScrapSearchResponse; + const allScraps = typedSearchData.scraps || []; + const scrapsMap = new Map(allScraps.map((scrap) => [scrap.id, scrap])); + + // recentScrapIds 순서대로 스크랩 찾기 (존재하는 것만) + return recentScrapIds + .map((id) => scrapsMap.get(id)) + .filter((scrap): scrap is NonNullable => scrap != null) + .map((scrap) => ({ + ...scrap, + type: 'SCRAP' as const, + })); + }, [recentScrapIds, searchData]); // ScrapSearchResponse는 folders와 scraps를 각각 반환하므로 합쳐야 함 const data = useMemo(() => { @@ -74,19 +84,18 @@ const ScrapScreenContent = () => { return [...folders, ...scraps]; }, [searchData]); - // // 클라이언트 사이드 정렬 (TYPE 정렬 등 추가 정렬 로직 적용) - // deprecated - // const sortedData = useMemo( - // () => sortScrapData(data, sortKey, sortOrder), - // [data, sortKey, sortOrder] - // ); - - const cleanupAfterDelete = (scrapIdsToRemove: number[]) => { - if (scrapIdsToRemove.length > 0) { - removeScrapsByIds(scrapIdsToRemove); - closeNotesByScrapIds(scrapIdsToRemove); - } - }; + // 유효한 스크랩 ID 목록 (폴더 내 스크랩 포함) + const validScrapIds = useMemo(() => { + if (!searchData) return undefined; + const typedSearchData = searchData as ScrapSearchResponse; + return (typedSearchData.scraps || []).map((scrap) => scrap.id); + }, [searchData]); + + useScrapStoreSync(validScrapIds); + + const sortedData = useMemo(() => { + return sortScrapData(data, sortKey, sortOrder); + }, [data, sortKey, sortOrder]); const isAllSelected = data.length > 0 && reducerState.selectedItems.length === data.length; @@ -135,36 +144,13 @@ const ScrapScreenContent = () => { } const items = reducerState.selectedItems; - const scrapIdsToRemove: number[] = []; - - // 삭제 전에 폴더 내 스크랩 ID 목록을 미리 수집 - for (const item of items) { - if (item.type === 'SCRAP') { - scrapIdsToRemove.push(item.id as number); - } else if (item.type === 'FOLDER' && item.id !== undefined) { - try { - const folderScrapsData = await getScrapsByFolder(queryClient, { - folderId: item.id, - }); - - const folderScrapIds = - folderScrapsData?.data - ?.filter((d: any) => d.type === 'SCRAP') - .map((d: any) => d.id) || []; - - scrapIdsToRemove.push(...folderScrapIds); - } catch (error: any) { - showToast('error', error.message); - } - } - } try { await deleteScrap({ items: items.map((item) => ({ id: item.id as number, type: item.type })), }); dispatch({ type: 'CLEAR_SELECTION' }); - cleanupAfterDelete(scrapIdsToRemove); + // 스크랩 삭제 후 쿼리 refetch → useScrapStoreSync가 자동으로 store 정리 showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); } catch (error: any) { showToast('error', error.message); @@ -198,7 +184,7 @@ const ScrapScreenContent = () => { ) : ( diff --git a/apps/native/src/features/student/scrap/stores/recentScrapStore.ts b/apps/native/src/features/student/scrap/stores/recentScrapStore.ts index 0e9084ab..f9629890 100644 --- a/apps/native/src/features/student/scrap/stores/recentScrapStore.ts +++ b/apps/native/src/features/student/scrap/stores/recentScrapStore.ts @@ -1,21 +1,16 @@ import { create } from 'zustand'; import { persist, createJSONStorage } from 'zustand/middleware'; import AsyncStorage from '@react-native-async-storage/async-storage'; -import type { ScrapDetailResp } from '../utils/types'; - -interface RecentScrapItem { - scrapDetail: ScrapDetailResp; -} interface RecentScrapStore { - /** 최근 본 스크랩 목록 (최신순) */ - scraps: RecentScrapItem[]; - /** 스크랩 추가 (이미 존재하면 최신 정보로 업데이트) */ - addScrap: (scrapDetail: ScrapDetailResp) => void; - /** 스크랩 제거 */ - removeScrap: (scrapId: number) => void; + /** 최근 본 스크랩 ID 목록 (최신순) */ + scrapIds: number[]; + /** 스크랩 ID 추가 (이미 존재하면 맨 앞으로 이동) */ + addScrapId: (scrapId: number) => void; + /** 스크랩 ID 제거 */ + removeScrapId: (scrapId: number) => void; /** 스크랩 ID 목록으로 일괄 제거 */ - removeScrapsByIds: (scrapIds: number[]) => void; + removeScrapIds: (scrapIds: number[]) => void; /** 전체 초기화 */ clear: () => void; } @@ -23,43 +18,31 @@ interface RecentScrapStore { export const useRecentScrapStore = create()( persist( (set) => ({ - scraps: [], + scrapIds: [], - addScrap: (scrapDetail) => + addScrapId: (scrapId) => set((state) => { - // 이미 존재하는 스크랩인지 확인 - const existingIndex = state.scraps.findIndex( - (item) => item.scrapDetail.id === scrapDetail.id - ); - - if (existingIndex !== -1) { - // 이미 존재하면 최신 정보로 업데이트하고 맨 앞으로 이동 - const filtered = state.scraps.filter((item) => item.scrapDetail.id !== scrapDetail.id); - return { - scraps: [{ scrapDetail }, ...filtered].slice(0, 30), - }; - } else { - // 새 스크랩이면 맨 앞에 추가 - return { - scraps: [{ scrapDetail }, ...state.scraps].slice(0, 30), - }; - } + // 이미 존재하면 제거 후 맨 앞에 추가 + const filtered = state.scrapIds.filter((id) => id !== scrapId); + return { + scrapIds: [scrapId, ...filtered].slice(0, 30), + }; }), - removeScrap: (scrapId) => + removeScrapId: (scrapId) => set((state) => ({ - scraps: state.scraps.filter((item) => item.scrapDetail.id !== scrapId), + scrapIds: state.scrapIds.filter((id) => id !== scrapId), })), - removeScrapsByIds: (scrapIds) => + removeScrapIds: (scrapIds) => set((state) => { const scrapIdsSet = new Set(scrapIds); return { - scraps: state.scraps.filter((item) => !scrapIdsSet.has(item.scrapDetail.id)), + scrapIds: state.scrapIds.filter((id) => !scrapIdsSet.has(id)), }; }), - clear: () => set({ scraps: [] }), + clear: () => set({ scrapIds: [] }), }), { name: 'recent-scrap-store', diff --git a/apps/native/src/features/student/scrap/stores/searchHistoryStore.ts b/apps/native/src/features/student/scrap/stores/searchHistoryStore.ts index 1f0d553d..1c05d01c 100644 --- a/apps/native/src/features/student/scrap/stores/searchHistoryStore.ts +++ b/apps/native/src/features/student/scrap/stores/searchHistoryStore.ts @@ -39,59 +39,3 @@ export const useSearchHistoryStore = create()( } ) ); - -interface ScrapUIStore { - /** 현재 선택된 폴더 ID */ - currentFolderId: number | null; - /** 선택 모드 활성화 여부 */ - isSelectionMode: boolean; - /** 현재 필터 (ALL/FOLDER/SCRAP) */ - currentFilter: FilterType; - /** 현재 정렬 키 (CREATED_AT/NAME) */ - currentSort: ApiSortKey; - /** 현재 정렬 방향 (ASC/DESC) */ - currentOrder: SortOrder; - - /** 현재 폴더 ID 설정 */ - setCurrentFolderId: (id: number | null) => void; - /** 선택 모드 설정 */ - setSelectionMode: (enabled: boolean) => void; - /** 필터 설정 */ - setFilter: (filter: FilterType) => void; - /** 정렬 설정 */ - setSort: (sort: ApiSortKey, order: SortOrder) => void; - /** 상태 초기화 */ - reset: () => void; -} - -const initialScrapUIState = { - currentFolderId: null, - isSelectionMode: false, - currentFilter: 'ALL' as FilterType, - currentSort: 'CREATED_AT' as ApiSortKey, - currentOrder: 'DESC' as SortOrder, -}; -export const useScrapUIStore = create()( - persist( - (set) => ({ - ...initialScrapUIState, - - setCurrentFolderId: (id) => set({ currentFolderId: id }), - setSelectionMode: (enabled) => set({ isSelectionMode: enabled }), - setFilter: (filter) => set({ currentFilter: filter }), - setSort: (sort, order) => set({ currentSort: sort, currentOrder: order }), - - reset: () => set(initialScrapUIState), - }), - { - name: 'scrap-ui-store', - storage: createJSONStorage(() => AsyncStorage), - - partialize: (state) => ({ - currentFilter: state.currentFilter, - currentSort: state.currentSort, - currentOrder: state.currentOrder, - }), - } - ) -); diff --git a/apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts b/apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts index 380c91bf..b9437a19 100644 --- a/apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts +++ b/apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts @@ -1,4 +1,19 @@ export function formatToMinute(date: Date, locale: string = 'ko-KR'): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + + const hours = date.getHours(); + const minutes = String(date.getMinutes()).padStart(2, '0'); + + if (locale === 'ko-KR') { + const period = hours < 12 ? '오전' : '오후'; + const hour12 = hours % 12 === 0 ? 12 : hours % 12; + + return `${year}-${month}-${day} ${period} ${hour12}:${minutes}`; + } + + // 기본 fallback (다른 로케일일 경우) return date.toLocaleString(locale, { year: 'numeric', month: '2-digit', diff --git a/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts b/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts index 4767e332..d1e7756b 100644 --- a/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts +++ b/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts @@ -6,6 +6,11 @@ import { parseTimestamp, } from '@/features/student/scrap/utils/types'; +function getSortTimestamp(item: ScrapItem | TrashItem): string { + if ('updatedAt' in item && item.updatedAt) return item.updatedAt; + return item.createdAt; // TrashItem은 createdAt에 deletedAt이 매핑됨 +} + /** * 스크랩 데이터 정렬 함수 * @@ -34,20 +39,20 @@ export const sortScrapData = ( return (a.type === 'FOLDER' ? -1 : 1) * mul; } // 같은 타입: 생성일시 기준 - const timestampA = parseTimestamp(a.createdAt); - const timestampB = parseTimestamp(b.createdAt); + const timestampA = parseTimestamp(getSortTimestamp(a)); + const timestampB = parseTimestamp(getSortTimestamp(b)); return (timestampA - timestampB) * mul; } - case 'TITLE': { + case 'NAME': { // 이름 기준 (한글 로케일 지원) return a.name.localeCompare(b.name, 'ko', { numeric: true }) * mul; } case 'DATE': { // 생성일시 기준 - const timestampA = parseTimestamp(a.createdAt); - const timestampB = parseTimestamp(b.createdAt); + const timestampA = parseTimestamp(getSortTimestamp(a)); + const timestampB = parseTimestamp(getSortTimestamp(b)); return (timestampA - timestampB) * mul; } @@ -75,7 +80,7 @@ export const mapUIKeyToAPIKey = (uiKey: UISortKey): 'CREATED_AT' | 'NAME' | 'TYP return 'TYPE'; case 'DATE': return 'CREATED_AT'; - case 'TITLE': + case 'NAME': return 'NAME'; default: return 'CREATED_AT'; diff --git a/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts b/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts index 66517679..ee7588b7 100644 --- a/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts +++ b/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts @@ -5,7 +5,7 @@ import { GRID_CONFIG } from '../constants'; * @returns Object containing numColumns, gap, and itemWidth */ export const useGridLayout = (containerWidth: number) => { - const { GAP, MIN_ITEM_WIDTH, ITEM_HEIGHT_RATIO, MIN_COLUMNS } = GRID_CONFIG; + const { GAP, MIN_ITEM_WIDTH, MIN_COLUMNS } = GRID_CONFIG; // 컬럼 수 계산 let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM_WIDTH + GAP)); @@ -17,12 +17,10 @@ export const useGridLayout = (containerWidth: number) => { const itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; // 비율 기반 height - const itemHeight = itemWidth * ITEM_HEIGHT_RATIO; return { numColumns, gap: GAP, itemWidth, - itemHeight, }; }; diff --git a/apps/native/src/features/student/scrap/utils/scrapFilters.ts b/apps/native/src/features/student/scrap/utils/scrapFilters.ts index 7d362d04..2494d0b8 100644 --- a/apps/native/src/features/student/scrap/utils/scrapFilters.ts +++ b/apps/native/src/features/student/scrap/utils/scrapFilters.ts @@ -31,6 +31,13 @@ export function generateFilterOptions( }); } + if (scrapDetail.isReadingTipScrapped) { + options.push('문제를 읽어내려갈 때'); + } + if (scrapDetail.isOneStepMoreScrapped) { + options.push('한 걸음 더'); + } + return options; } @@ -55,6 +62,44 @@ export function shouldShowPointing(selectedFilter: number, pointingIndex: number return selectedFilter === pointingIndex + 2; // 특정 포인팅만 } +export function shouldShowAnalysisSection( + selectedFilter: number, + sectionType: 'readingTip' | 'oneStepMore', + pointingsCount: number, + scrapDetail?: ScrapExtendResp // 추가 +): boolean { + // 전체 선택 시 모두 표시 + if (selectedFilter === 0) { + // content가 실제로 있는지 확인 + if (sectionType === 'readingTip') { + return !!(scrapDetail?.problem?.readingTipContent && scrapDetail.isReadingTipScrapped); + } + if (sectionType === 'oneStepMore') { + return !!(scrapDetail?.problem?.oneStepMoreContent && scrapDetail.isOneStepMoreScrapped); + } + return false; + } + + // readingTip 인덱스: pointingsCount + 2 + // oneStepMore 인덱스: pointingsCount + 3 + const readingTipIndex = pointingsCount + 2; + const oneStepMoreIndex = pointingsCount + 3; + + if (sectionType === 'readingTip') { + return ( + selectedFilter === readingTipIndex && + !!(scrapDetail?.problem?.readingTipContent && scrapDetail.isReadingTipScrapped) + ); + } + if (sectionType === 'oneStepMore') { + return ( + selectedFilter === oneStepMoreIndex && + !!(scrapDetail?.problem?.oneStepMoreContent && scrapDetail.isOneStepMoreScrapped) + ); + } + return false; +} + /** * 표시할 포인팅이 있는지 확인합니다. * @param scrapDetail - 스크랩 상세 정보 diff --git a/apps/native/src/features/student/scrap/utils/scrapTransformers.ts b/apps/native/src/features/student/scrap/utils/scrapTransformers.ts index 3a9cbd83..7f50a581 100644 --- a/apps/native/src/features/student/scrap/utils/scrapTransformers.ts +++ b/apps/native/src/features/student/scrap/utils/scrapTransformers.ts @@ -1,75 +1,63 @@ import { components } from '@schema'; -type ScrapDetailResp = components['schemas']['ScrapDetailResp']; type PublishProblemGroupResp = components['schemas']['PublishProblemGroupResp']; type PointingWithFeedbackResp = components['schemas']['PointingWithFeedbackResp']; type ProblemWithStudyInfoResp = components['schemas']['ProblemWithStudyInfoResp']; - +type ProblemEntireResp = components['schemas']['ProblemEntireResp']; +type PointingEntireResp = components['schemas']['PointingEntireResp']; /** * 스크랩 상세 정보를 AllPointingsScreen에서 사용할 수 있는 형식으로 변환합니다. * 스크랩 데이터 구조(ScrapDetailResp)를 문제 그룹 구조(PublishProblemGroupResp)로 매핑합니다. * - * @param scrapDetail - 변환할 스크랩 상세 정보 + * @param entireProblem - 전체 문제 데이터 + * @param problemEntirePointing - 문제 전체 포인팅 데이터 * @returns 변환된 문제 그룹 데이터, 문제가 없는 경우 null 반환 */ -export function convertScrapToGroup(scrapDetail: ScrapDetailResp): PublishProblemGroupResp | null { - if (!scrapDetail?.problem) { +export function convertScrapToGroup( + entireProblem: ProblemEntireResp[], + entirePointing: PointingEntireResp[] +): PublishProblemGroupResp | null { + if (!entireProblem || entireProblem.length === 0) { + return null; + } + + const mainProblem = entireProblem.find((p) => p.problemType === 'MAIN_PROBLEM'); + const childProblemsData = entireProblem.filter((p) => p.problemType === 'CHILD_PROBLEM'); + + if (!mainProblem) { return null; } - // 포인팅 데이터 변환: PointingResp -> PointingWithFeedbackResp - // 스크랩에서 가져온 포인팅은 이미 스크랩된 상태이므로 isScrapped를 true로 설정 + const pointingsForProblem = entirePointing.filter((p) => p.problemId === mainProblem.problemId); + + // 포인팅 데이터 변환 const pointings: PointingWithFeedbackResp[] = - scrapDetail.pointings?.map((pointing) => ({ - id: pointing.id, - no: pointing.no, - questionContent: pointing.questionContent, - commentContent: pointing.commentContent, - concepts: pointing.concepts, - isUnderstood: undefined, - isScrapped: true, + pointingsForProblem.map((pointing) => ({ + ...pointing, })) ?? []; - // 문제 데이터 변환: ProblemExtendResp -> ProblemWithStudyInfoResp - // 학습 관련 필드들은 스크랩에서는 사용하지 않으므로 기본값으로 설정 + // childProblems를 ProblemWithStudyInfoResp 형태로 변환 + const transformedChildProblems = childProblemsData.map((childProblem) => { + const childPointings = entirePointing.filter((p) => p.problemId === childProblem.problemId); + return { + ...childProblem, + pointings: childPointings.map((p) => ({ ...p })), + childProblems: [], + } as unknown as ProblemWithStudyInfoResp; + }); + + // 문제 데이터 변환 const problem: ProblemWithStudyInfoResp = { - id: scrapDetail.problem.id, - problemType: scrapDetail.problem.problemType, - parentProblem: scrapDetail.problem.parentProblem, - parentProblemTitle: scrapDetail.problem.parentProblemTitle, - customId: scrapDetail.problem.customId, - createType: scrapDetail.problem.createType, - practiceTest: scrapDetail.problem.practiceTest, - practiceTestNo: scrapDetail.problem.practiceTestNo, - problemContent: scrapDetail.problem.problemContent, - title: scrapDetail.problem.title, - answerType: scrapDetail.problem.answerType, - answer: scrapDetail.problem.answer, - difficulty: scrapDetail.problem.difficulty, - recommendedTimeSec: scrapDetail.problem.recommendedTimeSec, - memo: scrapDetail.problem.memo, - concepts: scrapDetail.problem.concepts, - mainAnalysisImage: scrapDetail.problem.mainAnalysisImage, - mainHandAnalysisImage: scrapDetail.problem.mainHandAnalysisImage, - readingTipContent: scrapDetail.problem.readingTipContent, - oneStepMoreContent: scrapDetail.problem.oneStepMoreContent, + ...mainProblem, pointings, - progress: 'NONE', - submitAnswer: 0, - isCorrect: false, - isDone: false, - childProblems: [], - }; + childProblems: transformedChildProblems, + } as unknown as ProblemWithStudyInfoResp; - // 문제 그룹 데이터 생성 - // 스크랩은 단일 문제만 포함하므로 no는 1로, progress는 DONE으로 설정 return { - no: 1, - problemId: scrapDetail.problem.id, - progress: 'DONE', + problemId: mainProblem.problemId, problem, - childProblems: [], - }; + childProblems: transformedChildProblems, + } as PublishProblemGroupResp; } /** diff --git a/apps/native/src/features/student/scrap/utils/types.ts b/apps/native/src/features/student/scrap/utils/types.ts index ababb94d..35a0fffb 100644 --- a/apps/native/src/features/student/scrap/utils/types.ts +++ b/apps/native/src/features/student/scrap/utils/types.ts @@ -41,12 +41,12 @@ export type TrashItem = TrashItemResp & { * 정렬 키 타입 * API의 sort 파라미터에 맞춤 (CREATED_AT, NAME) */ -export type ApiSortKey = 'CREATED_AT' | 'NAME'; +export type ApiSortKey = 'CREATED_AT' | 'NAME' | 'TYPE' | 'SIMILARITY'; /** * UI 정렬 키 타입 (TYPE은 클라이언트 전용) */ -export type UISortKey = 'TYPE' | 'TITLE' | 'DATE'; +export type UISortKey = 'TYPE' | 'NAME' | 'DATE'; /** * 정렬 방향 타입 diff --git a/apps/native/src/types/api/schema.d.ts b/apps/native/src/types/api/schema.d.ts index 88c0835e..c7962e07 100644 --- a/apps/native/src/types/api/schema.d.ts +++ b/apps/native/src/types/api/schema.d.ts @@ -990,6 +990,26 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/me/push/settings/init': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 초기 푸시 설정 + * @description 전체 푸시 관련 설정(마스터 토글, 서비스 알림, QnA 알림, 마케팅 알림)을 한 번에 true/false로 세팅합니다. + */ + post: operations['initPushSettings']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/me/push/allow/toggle': { parameters: { query?: never; @@ -1561,6 +1581,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/fcm/test': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 특정 토큰으로 FCM 푸시 테스트 발송 */ + post: operations['sendTestPush']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/diagnosis': { parameters: { query?: never; @@ -1987,6 +2024,40 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/study/problem/entire/{problemId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 문제 그룹 전체 조회 (부모+형제 문제) */ + get: operations['getEntireProblems']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/study/pointing/entire/{problemId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 문제의 전체 포인팅 조회 (부모+새끼문제) */ + get: operations['getEntirePointings']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/scrap/{id}': { parameters: { query?: never; @@ -2503,6 +2574,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/fcm/status': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** FCM 초기화 상태 확인 */ + get: operations['getStatus']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/auth/issue-admin-token': { parameters: { query?: never; @@ -3638,6 +3726,10 @@ export interface components { 'StudentPushDTO.UpdateTokenRequest': { fcmToken: string; }; + 'StudentPushDTO.InitPushRequest': { + /** @description 푸시 허용 여부 (true: 전체 허용, false: 전체 차단) */ + allow: boolean; + }; 'StudentPasswordDTO.UpdatePasswordRequest': { newPassword: string; }; @@ -4093,6 +4185,38 @@ export interface components { /** @description FCM 지원 여부 */ fcmSupported: boolean; }; + /** @description FCM 테스트 발송 요청 */ + FcmTestReq: { + /** + * @description FCM 토큰 + * @example dGVzdC10b2tlbi1mb3ItZmNtLXB1c2gtbm90aWZpY2F0aW9u + */ + token: string; + /** + * @description 알림 제목 + * @example 테스트 알림 + */ + title: string; + /** + * @description 알림 내용 + * @example FCM 푸시 테스트 메시지입니다. + */ + body: string; + /** + * @description 클릭 시 이동할 URL (선택) + * @example /home + */ + url?: string; + }; + /** @description FCM 테스트 발송 응답 */ + FcmTestResp: { + /** @description 발송 성공 여부 */ + success?: boolean; + /** @description 결과 메시지 */ + message?: string; + /** @description 에러 상세 (실패 시) */ + errorDetail?: string; + }; DiagnosisCreateReq: { /** Format: int64 */ studentId?: number; @@ -4191,6 +4315,72 @@ export interface components { total: number; data: components['schemas']['NoticeResp'][]; }; + ListRespProblemEntireResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['ProblemEntireResp'][]; + }; + ProblemEntireResp: { + /** + * Format: int64 + * @description 문제 ID + */ + problemId: number; + /** @description 부모문제 여부 (true: 부모문제, false: 새끼문제) */ + isParent: boolean; + /** Format: int64 */ + id: number; + /** @enum {string} */ + problemType: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; + /** Format: int64 */ + parentProblem?: number; + parentProblemTitle?: string; + customId: string; + /** @enum {string} */ + createType: 'GICHUL_PROBLEM' | 'VARIANT_PROBLEM' | 'CREATION_PROBLEM'; + practiceTest: components['schemas']['PracticeTestResp']; + /** Format: int32 */ + practiceTestNo: number; + problemContent: string; + title: string; + /** @enum {string} */ + answerType: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER'; + /** Format: int32 */ + answer: number; + /** Format: int32 */ + difficulty: number; + /** Format: int32 */ + recommendedTimeSec: number; + memo: string; + concepts: components['schemas']['ConceptResp'][]; + mainAnalysisImage: components['schemas']['UploadFileResp']; + mainHandAnalysisImage: components['schemas']['UploadFileResp']; + readingTipContent: string; + oneStepMoreContent: string; + }; + ListRespPointingEntireResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['PointingEntireResp'][]; + }; + PointingEntireResp: { + /** + * Format: int64 + * @description 포인팅이 속한 문제 ID + */ + problemId: number; + /** @description 부모문제 여부 (true: 부모문제, false: 새끼문제) */ + isParent: boolean; + /** Format: int64 */ + id: number; + /** Format: int32 */ + no: number; + questionContent: string; + commentContent: string; + concepts: components['schemas']['ConceptResp'][]; + isUnderstood?: boolean; + isScrapped?: boolean; + }; ListRespTrashItemResp: { /** Format: int32 */ total: number; @@ -4510,6 +4700,11 @@ export interface components { lastPage: number; data: components['schemas']['PracticeTestResp'][]; }; + /** @description FCM 상태 응답 */ + FcmStatusResp: { + /** @description Firebase 초기화 상태 */ + initialized?: boolean; + }; PageRespConceptResp: { /** Format: int32 */ page: number; @@ -6747,6 +6942,30 @@ export interface operations { }; }; }; + initPushSettings: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['StudentPushDTO.InitPushRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudentPushDTO.SettingsResponse']; + }; + }; + }; + }; toggleAllowPush_1: { parameters: { query?: never; @@ -7656,6 +7875,30 @@ export interface operations { }; }; }; + sendTestPush: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['FcmTestReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['FcmTestResp']; + }; + }; + }; + }; gets_1: { parameters: { query: { @@ -8288,6 +8531,50 @@ export interface operations { }; }; }; + getEntireProblems: { + parameters: { + query?: never; + header?: never; + path: { + problemId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespProblemEntireResp']; + }; + }; + }; + }; + getEntirePointings: { + parameters: { + query?: never; + header?: never; + path: { + problemId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespPointingEntireResp']; + }; + }; + }; + }; getScrap: { parameters: { query?: never; @@ -9032,6 +9319,26 @@ export interface operations { }; }; }; + getStatus: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['FcmStatusResp']; + }; + }; + }; + }; issueTemporaryToken: { parameters: { query: {