From ba2fdeb40c1ad608769dcbb95edc88957066c13a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:34:19 +0900 Subject: [PATCH 001/140] chore: Add Dropdown Package --- pnpm-lock.yaml | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5651772..3973e009 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -282,6 +282,9 @@ importers: react-native-css-interop: specifier: ^0.2.1 version: 0.2.1(react-native-reanimated@4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-svg@15.15.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)(tailwindcss@3.4.18(tsx@4.20.3)(yaml@2.8.1)) + react-native-element-dropdown: + specifier: ^2.12.4 + version: 2.12.4(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) @@ -297,6 +300,9 @@ importers: react-native-svg: specifier: ^15.15.0 version: 15.15.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + react-native-toast-message: + specifier: ^2.3.3 + version: 2.3.3(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) react-native-web: specifier: ~0.21.0 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -6819,6 +6825,13 @@ packages: react-native-svg: optional: true + react-native-element-dropdown@2.12.4: + resolution: {integrity: sha512-abZc5SVji9FIt7fjojRYrbuvp03CoeZJrgvezQoDoSOrpiTqkX69ix5m+j06W2AVncA0VWvbT+vCMam8SoVadw==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '*' + react-native: '*' + react-native-gesture-handler@2.28.0: resolution: {integrity: sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A==} peerDependencies: @@ -6857,6 +6870,12 @@ packages: react: '*' react-native: '*' + react-native-toast-message@2.3.3: + resolution: {integrity: sha512-4IIUHwUPvKHu4gjD0Vj2aGQzqPATiblL1ey8tOqsxOWRPGGu52iIbL8M/mCz4uyqecvPdIcMY38AfwRuUADfQQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -15748,6 +15767,12 @@ snapshots: transitivePeerDependencies: - supports-color + react-native-element-dropdown@2.12.4(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): + dependencies: + lodash: 4.17.21 + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): dependencies: '@egjs/hammerjs': 2.0.17 @@ -15791,6 +15816,11 @@ snapshots: react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) warn-once: 0.1.1 + react-native-toast-message@2.3.3(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 From d919a0f6d1bc4fed55220ca6621be33953fc4add Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:34:31 +0900 Subject: [PATCH 002/140] chore: Add Dropdown, Toast Package --- apps/native/package.json | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/native/package.json b/apps/native/package.json index 1e3a3b7c..8b1ba5b5 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -46,11 +46,13 @@ "react-dom": "19.1.0", "react-native": "0.81.5", "react-native-css-interop": "^0.2.1", + "react-native-element-dropdown": "^2.12.4", "react-native-gesture-handler": "~2.28.0", "react-native-reanimated": "~4.1.5", "react-native-safe-area-context": "~5.4.0", "react-native-screens": "~4.16.0", "react-native-svg": "^15.15.0", + "react-native-toast-message": "^2.3.3", "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1", From a4a6b4393441e04073a5af48fb670c69715ff05b Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:34:49 +0900 Subject: [PATCH 003/140] style: Add Icons --- .../system/icons/ChevronUpFilledIcon.tsx | 13 ++++++++ .../system/icons/CircleCheckDashed.tsx | 31 +++++++++++++++++++ 2 files changed, 44 insertions(+) create mode 100644 apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx create mode 100644 apps/native/src/components/system/icons/CircleCheckDashed.tsx diff --git a/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx b/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx new file mode 100644 index 00000000..41285155 --- /dev/null +++ b/apps/native/src/components/system/icons/ChevronUpFilledIcon.tsx @@ -0,0 +1,13 @@ +import type { LucideIcon, LucideProps } from 'lucide-react-native'; +import React from 'react'; +import { Path, Svg } from 'react-native-svg'; + +const ChevronUpFilledIcon = React.forwardRef, LucideProps>( + ({ color = '#1E1E21', size = 20, ...rest }, ref) => ( + + + + ) +) as LucideIcon; + +export default ChevronUpFilledIcon; diff --git a/apps/native/src/components/system/icons/CircleCheckDashed.tsx b/apps/native/src/components/system/icons/CircleCheckDashed.tsx new file mode 100644 index 00000000..43b06d1c --- /dev/null +++ b/apps/native/src/components/system/icons/CircleCheckDashed.tsx @@ -0,0 +1,31 @@ +import React from 'react'; +import { Path, Svg } from 'react-native-svg'; +import type { LucideIcon, LucideProps } from 'lucide-react-native'; + +const CircleCheckDashed = React.forwardRef, LucideProps>( + ({ color = '#3A67EE', size = 24, strokeWidth = 2, ...rest }, ref) => { + const resolvedStrokeWidth = Number(strokeWidth); + + return ( + + + + + ); + } +) as LucideIcon; + +export default CircleCheckDashed; From c8f2d0bba0a9beb6baef4f7778f0e85bb18f8caa Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:34:59 +0900 Subject: [PATCH 004/140] style: Add Icons --- apps/native/src/components/system/icons/index.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/native/src/components/system/icons/index.ts b/apps/native/src/components/system/icons/index.ts index 35372455..23158926 100644 --- a/apps/native/src/components/system/icons/index.ts +++ b/apps/native/src/components/system/icons/index.ts @@ -9,6 +9,8 @@ import HomeFilledIcon from './HomeFilledIcon'; import MessageCircleMoreFilledIcon from './MessageCircleMoreFilledIcon'; import NoNotificationBellIcon from './NoNotificationBellIcon'; import TeacherIcon from './TeacherIcon'; +import CircleCheckDashed from './CircleCheckDashed'; +import ChevronUpFilledIcon from './ChevronUpFilledIcon'; export { AlertBellButtonIcon, @@ -18,8 +20,10 @@ export { CalendarNotStartedIcon, CalendarUnavailableIcon, ChevronDownFilledIcon, + ChevronUpFilledIcon, HomeFilledIcon, MessageCircleMoreFilledIcon, NoNotificationBellIcon, TeacherIcon, + CircleCheckDashed, }; From e757c030554e307294e7a9e65087f5d6d1ae2858 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:35:23 +0900 Subject: [PATCH 005/140] feat: Implement Scrap Main Header --- .../student/scrap/components/ScrapHeader.tsx | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/ScrapHeader.tsx diff --git a/apps/native/src/features/student/scrap/components/ScrapHeader.tsx b/apps/native/src/features/student/scrap/components/ScrapHeader.tsx new file mode 100644 index 00000000..742ec2f5 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/ScrapHeader.tsx @@ -0,0 +1,99 @@ +import { Container } from '@/components/common'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { View, Text, Pressable } from 'react-native'; +import { ArrowRightLeft, Search, Trash2 } from 'lucide-react-native'; +import { CircleCheckDashed } from '@/components/system/icons'; +import { State } from '../utils/reducer'; +import { colors } from '@/theme/tokens'; + +interface ScrapHeaderProps { + ruducerState: State; + navigateSearchPress?: () => void; + navigateTrashPress?: () => void; + onEnterSelection?: () => void; + onExitSelection?: () => void; + onMove?: () => void; + onDelete?: () => void; + isAllSelected?: boolean; + onSelectAll?: () => void; +} + +const ScrapHeader = ({ + ruducerState, + navigateSearchPress, + navigateTrashPress, + onEnterSelection, + onExitSelection, + onMove, + onDelete, + isAllSelected, + onSelectAll, +}: ScrapHeaderProps) => { + const isActionEnabled = ruducerState.selectedItems.length > 0; + return ( + + {!ruducerState.isSelecting && ( + + 스크랩 + + + + + + + + + + + + + )} + + {ruducerState.isSelecting && ( + + + + + {!isAllSelected ? '전체 선택' : '전체 해제'} + + + 스크랩 + + + 완료 + + + + + { + if (isActionEnabled && onMove) onMove(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${ruducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 이동하기 + + { + if (isActionEnabled && onDelete) onDelete(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${ruducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 삭제하기 + + + + )} + + ); +}; + +export default ScrapHeader; From 19d4f26272b4da9d7ce77def28809935b8eaee1f Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:36:06 +0900 Subject: [PATCH 006/140] feat: Implement Scrap Page --- .../student/scrap/components/ScrapItem.tsx | 71 ++++++++++++++ .../student/scrap/screens/ScrapScreen.tsx | 95 ++++++++++++++++++- 2 files changed, 162 insertions(+), 4 deletions(-) create mode 100644 apps/native/src/features/student/scrap/components/ScrapItem.tsx diff --git a/apps/native/src/features/student/scrap/components/ScrapItem.tsx b/apps/native/src/features/student/scrap/components/ScrapItem.tsx new file mode 100644 index 00000000..15eac342 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/ScrapItem.tsx @@ -0,0 +1,71 @@ +import { Pressable, View, Text } from 'react-native'; +import { Action, State } from '../utils/reducer'; +import React from 'react'; +import { Check } from 'lucide-react-native'; +import { ChevronDownFilledIcon } from '@/components/system/icons'; + +interface FolderOverrides { + type: 'Folder'; + amount: number; +} + +interface ScrapOverrides { + type: 'Scrap'; + timestamp: string; +} + +interface BaseItemProps { + id: string; + title: string; + ruducerState: State; + onItemPress?: () => void; + onDownPress?: () => void; + onCheckPress?: () => void; + className?: string; +} + +export type ItemProps = BaseItemProps & (FolderOverrides | ScrapOverrides); + +const ScrapItem = (props: ItemProps) => { + const isSelected = props.ruducerState.selectedItems.includes(props.id); + return ( + + + {props.ruducerState.isSelecting && ( + + {props.ruducerState.isSelecting && } + + )} + + + + {props.title} + {!props.ruducerState.isSelecting && ( + + + + )} + + {props.type === 'Folder' && {props.amount}} + {props.type === 'Scrap' && ( + {props.timestamp} + )} + + + ); +}; + +export default ScrapItem; diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index 098adabc..ac4a27ca 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -1,10 +1,97 @@ -import React from 'react'; -import { Text, View } from 'react-native'; +import { Container, LoadingScreen } from '@/components/common'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import React, { useReducer, useState } from 'react'; +import { View, Text } from 'react-native'; +import { reducer, selectState } from '../utils/reducer'; +import ScrapHeader from '../components/ScrapHeader'; +import ScrapGrid, { DummyItem } from '../components/ScrapItemGrid'; +import SortDropdown from '../components/SortDropdown'; + +const folderDummy: DummyItem[] = Array.from({ length: 13 }, (_, i) => { + if (i % 3 === 0) { + return { + type: 'Folder', + id: i.toString(), + title: '폴더명', + amount: 127 + i, + }; + } else { + return { + type: 'Scrap', + id: i.toString(), + title: '스크랩명', + timestamp: 202511191130 - i * 11, + }; + } +}); const ScrapScreen = () => { + const [reducerState, dispatch] = useReducer(reducer, selectState); + const [data, setData] = useState(folderDummy); + const [fetchloading, setFetchLoading] = useState(false); + + const navigation = useNavigation>(); + + const orderList = [ + { label: '유형순', value: 'type' }, + { label: '이름순', value: 'name' }, + { label: '최신순', value: 'latest' }, + ]; + const [order, setOrder] = useState({ + label: '유형순', + value: 'type', + }); + + const isallSelected = reducerState.selectedItems.length === folderDummy.length; + return ( - - Scrap + + navigation.push('SearchScrap')} + onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} + onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} + isAllSelected={isallSelected} + onSelectAll={() => { + const allIds = folderDummy.map((item) => item.id); + dispatch({ type: 'SELECT_ALL', allIds: isallSelected ? [] : allIds }); + }} + onMove={() => { + const selectedSet = new Set(reducerState.selectedItems); + const selectedFolders = folderDummy.filter( + (item) => selectedSet.has(item.id) && item.type === 'Folder' + ); + if (selectedFolders.length > 0) console.log(selectedFolders); + }} + onDelete={async () => { + setFetchLoading(true); + + try { + const selectedSet = new Set(reducerState.selectedItems); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + setData((prev) => prev.filter((item) => !selectedSet.has(item.id))); + } finally { + setFetchLoading(false); + dispatch({ type: 'CLEAR_SELECTION' }); + } + }} + /> + + + + + + {fetchloading ? ( + + ) : ( + + )} + + ); }; From 6ea1442574f7b3afcc04b2cc3b0e8377ed6a81bd Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:36:16 +0900 Subject: [PATCH 007/140] feat: Implement Scrap Item Grid --- .../scrap/components/ScrapItemGrid.tsx | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx diff --git a/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx b/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx new file mode 100644 index 00000000..52de422e --- /dev/null +++ b/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx @@ -0,0 +1,72 @@ +import { FlatList, useWindowDimensions, View } from 'react-native'; +import { Action, State } from '../utils/reducer'; +import ScrapItem from './ScrapItem'; + +export type DummyItem = + | { type: 'Folder'; id: string; title: string; amount: number } + | { type: 'Scrap'; id: string; title: string; timestamp: number }; + +interface ScrapGridProps { + data: DummyItem[]; + state: State; + dispatch: React.Dispatch; +} + +const addPlaceholders = (data: DummyItem[], columns: number) => { + const fullRows = Math.floor(data.length / columns); + const totalNeeded = (fullRows + 1) * columns; + const emptyCount = totalNeeded - data.length; + + return [...data, ...Array(emptyCount).fill({ placeholder: true })]; +}; +const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { + const { width, height } = useWindowDimensions(); + const isLandscape = width > 1024 && width > height; + + let numColumns = isLandscape ? 6 : 4; + const gap = isLandscape ? 22 : 34; + + const totalGap = gap * (numColumns - 1); + const padding = isLandscape ? 256 : 120; + const itemWidth = (width - totalGap - padding) / numColumns; + + if (itemWidth < 136) { + numColumns = isLandscape ? 5 : 4; + } + + const finalData = addPlaceholders(data, numColumns); + + return ( + item.id ?? Math.random().toString()} + columnWrapperStyle={{ marginBottom: gap }} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + flex: 1, + marginRight: isLastColumn ? 0 : gap, + }; + + if ('placeholder' in item && item.placeholder) { + return ; + } + + return ( + + dispatch({ type: 'SELECTING_ITEM', id: item.id })} + /> + + ); + }} + /> + ); +}; + +export default ScrapGrid; From bdb85dcb9de507d5b580a564ad3aefdf2860d47a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:36:27 +0900 Subject: [PATCH 008/140] chore: Add Dropdown Package --- .../student/scrap/components/SortDropdown.tsx | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/SortDropdown.tsx diff --git a/apps/native/src/features/student/scrap/components/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/SortDropdown.tsx new file mode 100644 index 00000000..81cefb09 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/SortDropdown.tsx @@ -0,0 +1,118 @@ +import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; +import { colors } from '@/theme/tokens'; +import { Check } from 'lucide-react-native'; +import { useState } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Dropdown } from 'react-native-element-dropdown'; + +interface OrderItem { + label: string; + value: string; +} + +interface SortDropdownProps { + orderList: OrderItem[]; + order: OrderItem; + setOrder: (item: OrderItem) => void; +} + +const SortDropdown: React.FC = ({ orderList, order, setOrder }) => { + const [isFocus, setIsFocus] = useState(false); + + return ( + { + setOrder(item); + setIsFocus(false); + }} + onFocus={() => setIsFocus(true)} + onBlur={() => setIsFocus(false)} + renderRightIcon={() => (isFocus ? : )} + renderItem={(item) => ( + + {item.value === order.value && } + {item.label} + + )} + /> + ); +}; + +export default SortDropdown; + +const styles = StyleSheet.create({ + dropdown: { + width: 71, + height: 29, + gap: 2, + paddingTop: 4, + paddingRight: 4, + paddingLeft: 8, + paddingBottom: 4, + borderRadius: 4, + backgroundColor: colors['gray-100'], + }, + dropdownFocus: { + backgroundColor: colors['gray-400'], + }, + container: { + width: 104, + borderRadius: 8, + borderWidth: 1, + borderColor: colors['gray-400'], + gap: 2, + top: 4, + shadowColor: 'rgba(12,12,13,0.10)', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 1, + shadowRadius: 32, + padding: 4, + }, + itemContainer: { + borderRadius: 4, + height: 28, + }, + placeholder: { + alignItems: 'center', + justifyContent: 'center', + fontSize: 14, + fontWeight: '500', + fontFamily: 'Pretendard', + lineHeight: 21, + color: colors['gray-800'], + }, + selectedText: { + alignItems: 'center', + justifyContent: 'center', + fontSize: 14, + fontWeight: '500', + fontFamily: 'Pretendard', + lineHeight: 21, + color: colors['gray-800'], + }, + itemRow: { + flexDirection: 'row', + alignItems: 'flex-end', + justifyContent: 'flex-end', + gap: 10, + borderRadius: 4, + paddingHorizontal: 10, + paddingVertical: 2, + }, + itemText: { + fontSize: 16, + fontWeight: '500', + lineHeight: 24, + fontFamily: 'Pretendard', + color: colors['gray-800'], + }, +}); From 867c43e93bcf3f94588d86d20df8c17229aa8775 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:36:53 +0900 Subject: [PATCH 009/140] feat: Initialize Scrap Search Page --- .../src/features/student/scrap/index.ts | 4 ++- .../scrap/screens/SearchScrapScreen.tsx | 28 +++++++++++++++++++ .../navigation/student/StudentNavigator.tsx | 11 +++++++- apps/native/src/navigation/student/types.ts | 3 ++ 4 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx diff --git a/apps/native/src/features/student/scrap/index.ts b/apps/native/src/features/student/scrap/index.ts index 6dcbdcde..8d5cb3ca 100644 --- a/apps/native/src/features/student/scrap/index.ts +++ b/apps/native/src/features/student/scrap/index.ts @@ -1,3 +1,5 @@ import ScrapScreen from './screens/ScrapScreen'; +import DeletedScrapScreen from './screens/DeletedScrapScreen'; +import SearchScrapScreen from './screens/SearchScrapScreen'; -export { ScrapScreen }; +export { ScrapScreen, DeletedScrapScreen, SearchScrapScreen }; diff --git a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx new file mode 100644 index 00000000..b8b75d4b --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -0,0 +1,28 @@ +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { ChevronLeft } from 'lucide-react-native'; +import React from 'react'; +import { Pressable, Text, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +const SearchScrapScreen = () => { + const navigation = useNavigation>(); + return ( + + + {navigation.canGoBack() ? ( + navigation.goBack()} className='p-2'> + + + + + ) : ( + + )} + + + ); +}; + +export default SearchScrapScreen; diff --git a/apps/native/src/navigation/student/StudentNavigator.tsx b/apps/native/src/navigation/student/StudentNavigator.tsx index e5bcae3f..bebdb61a 100644 --- a/apps/native/src/navigation/student/StudentNavigator.tsx +++ b/apps/native/src/navigation/student/StudentNavigator.tsx @@ -2,10 +2,16 @@ import React from 'react'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; import NotificationScreen from '@features/student/home/screens/notifications/NotificationsScreen'; import NotificationDetailScreen from '@features/student/home/screens/notifications/NotificationDetailScreen'; -import { ProblemScreen, PointingScreen, AnalysisScreen, AllPointingsScreen } from '@features/student/problem'; +import { + ProblemScreen, + PointingScreen, + AnalysisScreen, + AllPointingsScreen, +} from '@features/student/problem'; import StudentTabs from './StudentTabs'; import { StudentRootStackParamList } from './types'; import NotificationHeader from './components/NotificationHeader'; +import { DeletedScrapScreen, ScrapScreen, SearchScrapScreen } from '@/features/student/scrap'; const StudentRootStack = createNativeStackNavigator(); @@ -33,6 +39,9 @@ const StudentNavigator = () => { + + + ); }; diff --git a/apps/native/src/navigation/student/types.ts b/apps/native/src/navigation/student/types.ts index fcd3ad76..eb5d87b9 100644 --- a/apps/native/src/navigation/student/types.ts +++ b/apps/native/src/navigation/student/types.ts @@ -22,4 +22,7 @@ export type StudentRootStackParamList = { publishAt?: string; problemSetTitle?: string; }; + Scrap: undefined; + SearchScrap: undefined; + DeletedScrap: undefined; }; From 8b0a95377458cbfe8bcd9e5fd8ab452620b413c0 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Wed, 10 Dec 2025 23:37:02 +0900 Subject: [PATCH 010/140] feat: Implement Reducer --- .../features/student/scrap/utils/reducer.ts | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/reducer.ts diff --git a/apps/native/src/features/student/scrap/utils/reducer.ts b/apps/native/src/features/student/scrap/utils/reducer.ts new file mode 100644 index 00000000..3b003058 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/reducer.ts @@ -0,0 +1,41 @@ +export interface State { + isSelecting: boolean; + selectedItems: string[]; +} + +export type Action = + | { type: 'ENTER_SELECTION' } + | { type: 'EXIT_SELECTION' } + | { type: 'SELECTING_ITEM'; id: string } + | { type: 'SELECT_ALL'; allIds: string[] } + | { type: 'CLEAR_SELECTION' }; + +export const selectState: State = { + isSelecting: false, + selectedItems: [], +}; + +export function reducer(state: State, action: Action): State { + switch (action.type) { + case 'ENTER_SELECTION': + return { ...state, isSelecting: true }; + case 'EXIT_SELECTION': + return { ...state, isSelecting: false, selectedItems: [] }; + case 'SELECTING_ITEM': + const id = action.id; + const exists = state.selectedItems.includes(id); + + return { + ...state, + selectedItems: exists + ? state.selectedItems.filter((i) => i !== id) + : [...state.selectedItems, id], + }; + case 'SELECT_ALL': + return { ...state, selectedItems: action.allIds }; + case 'CLEAR_SELECTION': + return { ...state, selectedItems: [] }; + default: + return state; + } +} From 6ac7e3f437b561afeed9ac0eeafa20cb1e713890 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:34:40 +0900 Subject: [PATCH 011/140] feat: add tooltip and toast packages --- apps/native/App.tsx | 3 +++ apps/native/package.json | 2 ++ 2 files changed, 5 insertions(+) diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 433dac32..0be03c68 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -11,6 +11,8 @@ import '@/app/providers/api'; import { LoadingScreen } from '@components/common'; import { useLoadAssets } from '@hooks'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; +import Toast from 'react-native-toast-message'; +import { toastConfig } from '@/features/student/scrap/components/Modal/Toast'; const queryClient = new QueryClient(); @@ -46,6 +48,7 @@ export default function App() { + diff --git a/apps/native/package.json b/apps/native/package.json index 8b1ba5b5..4b7cf709 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -48,11 +48,13 @@ "react-native-css-interop": "^0.2.1", "react-native-element-dropdown": "^2.12.4", "react-native-gesture-handler": "~2.28.0", + "react-native-popover-view": "^6.1.0", "react-native-reanimated": "~4.1.5", "react-native-safe-area-context": "~5.4.0", "react-native-screens": "~4.16.0", "react-native-svg": "^15.15.0", "react-native-toast-message": "^2.3.3", + "react-native-tooltips": "^1.0.3", "react-native-web": "~0.21.0", "react-native-webview": "^13.16.0", "react-native-worklets": "0.5.1", From 19391033761941626ec8c037a0ca21848b582351 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:34:57 +0900 Subject: [PATCH 012/140] feat: add scrapDataStore with search functionality --- apps/native/src/stores/scrapDataStore.ts | 49 ++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 apps/native/src/stores/scrapDataStore.ts diff --git a/apps/native/src/stores/scrapDataStore.ts b/apps/native/src/stores/scrapDataStore.ts new file mode 100644 index 00000000..7a55f5d6 --- /dev/null +++ b/apps/native/src/stores/scrapDataStore.ts @@ -0,0 +1,49 @@ +import { SearchResultItem } from '@/features/student/scrap/components/ScrapItem'; +import { DummyItem } from '@/features/student/scrap/components/ScrapItemGrid'; +import { create } from 'zustand'; + +interface ScrapStore { + data: DummyItem[]; + setData: (newData: DummyItem[]) => void; + updateItem: (id: string, newTitle: string) => void; + deleteItem: (ids: string | string[]) => void; + searchByTitle: (query: string) => { scrap: DummyItem; parentFolderId?: string }[]; +} + +export const useScrapStore = create((set, get) => ({ + data: [], + setData: (newData) => set({ data: newData }), + updateItem: (id, newTitle) => + set((state) => ({ + data: state.data.map((item) => (item.id === id ? { ...item, title: newTitle } : item)), + })), + deleteItem: (ids) => + set((state) => { + const deleteArray = Array.isArray(ids) ? ids : [ids]; + return { + data: state.data.filter((item) => !deleteArray.includes(item.id)), + }; + }), + searchByTitle: (query: string) => { + const state = get(); + const lowerQuery = query.toLowerCase(); + + const results: SearchResultItem[] = []; + + state.data.forEach((item) => { + if (item.type === 'SCRAP' && item.title.toLowerCase().includes(lowerQuery)) { + results.push({ scrap: item }); + } + + if (item.type === 'FOLDER' && item.contents) { + item.contents.forEach((c) => { + if (c.type === 'SCRAP' && c.title.toLowerCase().includes(lowerQuery)) { + results.push({ scrap: c, parentFolderName: item.title }); + } + }); + } + }); + + return results; + }, +})); From dea0524889647ef1593da99036bfbce960d3dac6 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:35:01 +0900 Subject: [PATCH 013/140] feat: add scrapDataStore with search functionality --- apps/native/src/stores/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/native/src/stores/index.ts b/apps/native/src/stores/index.ts index 8d099b8d..0072b078 100644 --- a/apps/native/src/stores/index.ts +++ b/apps/native/src/stores/index.ts @@ -1,2 +1,3 @@ export * from './authStore'; export * from './problemSessionStore'; +export * from './scrapDataStore'; From 02ceb4b34eb0f66eef34550e0a01de8054d80ae8 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:35:14 +0900 Subject: [PATCH 014/140] chore: add temporary data generation functions --- .../student/scrap/utils/testdataset.ts | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/testdataset.ts diff --git a/apps/native/src/features/student/scrap/utils/testdataset.ts b/apps/native/src/features/student/scrap/utils/testdataset.ts new file mode 100644 index 00000000..a941ee7e --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/testdataset.ts @@ -0,0 +1,53 @@ +import { DummyItem } from '../components/ScrapItemGrid'; + +const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; + +// 랜덤 제목 생성 +const randomTitle = () => '이름' + randomInt(1, 1000); + +// SCRAP 아이템 랜덤 생성 +const createRandomScrap = (): DummyItem => ({ + id: randomInt(1000, 9999).toString(), + type: 'SCRAP', + title: randomTitle() + '스크랩', + timestamp: 202511000000 - randomInt(0, 100000), +}); + +// FOLDER 안에 SCRAP 몇 개 넣기 +const createRandomFolder = (id: string, title?: string): DummyItem => { + const count = randomInt(2, 5); // 2~5개 + const contents = Array.from({ length: count }, () => createRandomScrap()); + + return { + id, + type: 'FOLDER', + title: title ?? randomTitle() + '폴더', + amount: contents.length, + timestamp: 202510000000 - randomInt(0, 100000), + contents, + }; +}; + +// 데이터 생성 +export const folderDummy: DummyItem[] = [ + // REVIEW 폴더 고정 + { + id: 'REVIEW', + type: 'FOLDER', + title: '오답노트', + amount: 3, + timestamp: 202410191130, + contents: Array.from({ length: 3 }, () => createRandomScrap()), + }, + // 랜덤 FOLDER/SCRAP 12개 + ...Array.from({ length: 12 }, (_, i) => { + const id = (i + 1).toString(); + if (Math.random() < 0.63) { + // FOLDER + return createRandomFolder(id); + } else { + // SCRAP + return createRandomScrap(); + } + }), +]; From 76258d4d2f0eaab2b85831d83645b7d5e1698b57 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:35:23 +0900 Subject: [PATCH 015/140] feat: add tooltip and toast packages --- pnpm-lock.yaml | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3973e009..c004df2e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -288,6 +288,9 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + react-native-popover-view: + specifier: ^6.1.0 + version: 6.1.0 react-native-reanimated: specifier: ~4.1.5 version: 4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) @@ -303,6 +306,9 @@ importers: react-native-toast-message: specifier: ^2.3.3 version: 2.3.3(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + react-native-tooltips: + specifier: ^1.0.3 + version: 1.0.3 react-native-web: specifier: ~0.21.0 version: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0) @@ -2514,6 +2520,9 @@ packages: resolution: {integrity: sha512-fB7M1CMOCIUudTRuj7kzxIBTVw2KXnsgbQ6+4cbqSxo8NmRRhA0Ul4ZUzZj3rFd3VznTL4Brmocv1oiN0bWZ8w==} engines: {node: '>= 20.19.4'} + '@react-native/normalize-color@2.1.0': + resolution: {integrity: sha512-Z1jQI2NpdFJCVgpY+8Dq/Bt3d+YUi1928Q+/CZm/oh66fzM0RUl54vvuXlPJKybH4pdCZey1eDTPaLHkMPNgWA==} + '@react-native/normalize-colors@0.74.89': resolution: {integrity: sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg==} @@ -4347,6 +4356,9 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + deprecated-react-native-prop-types@2.3.0: + resolution: {integrity: sha512-pWD0voFtNYxrVqvBMYf5gq3NA2GCpfodS1yNynTPc93AYA/KEMGeWDqqeUB6R2Z9ZofVhks2aeJXiuQqKNpesA==} + destroy@1.2.0: resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} @@ -6844,6 +6856,9 @@ packages: react: '*' react-native: '*' + react-native-popover-view@6.1.0: + resolution: {integrity: sha512-j1CB+yPwTKlBvIJBNb1AwiHyF/r+W5+AJIbHk79GRa+0z6PVtW4C7NWJWPqUVkCMOcJtewl6Pr6f2dc/87cVyQ==} + react-native-reanimated@4.1.5: resolution: {integrity: sha512-UA6VUbxwhRjEw2gSNrvhkusUq3upfD3Cv+AnB07V+kC8kpvwRVI+ivwY95ePbWNFkFpP+Y2Sdw1WHpHWEV+P2Q==} peerDependencies: @@ -6876,6 +6891,9 @@ packages: react: '*' react-native: '*' + react-native-tooltips@1.0.3: + resolution: {integrity: sha512-f2XEL23FlPMpIq11t6EIYGf1sl1FoWCwQRFIvQB9+6v5Jyod1O61yoKy2lgg91Egz531Szmqac0wTXuxVC9vjw==} + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -10415,6 +10433,8 @@ snapshots: '@react-native/js-polyfills@0.81.5': {} + '@react-native/normalize-color@2.1.0': {} + '@react-native/normalize-colors@0.74.89': {} '@react-native/normalize-colors@0.81.5': {} @@ -12718,6 +12738,12 @@ snapshots: depd@2.0.0: {} + deprecated-react-native-prop-types@2.3.0: + dependencies: + '@react-native/normalize-color': 2.1.0 + invariant: 2.2.4 + prop-types: 15.8.1 + destroy@1.2.0: {} detect-libc@1.0.3: {} @@ -15786,6 +15812,11 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-popover-view@6.1.0: + dependencies: + deprecated-react-native-prop-types: 2.3.0 + prop-types: 15.8.1 + react-native-reanimated@4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): dependencies: '@babel/core': 7.28.0 @@ -15821,6 +15852,8 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-tooltips@1.0.3: {} + react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0): dependencies: '@babel/runtime': 7.28.4 From 8fe18350a02621b328e48bd9723957a585f021ce Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:35:33 +0900 Subject: [PATCH 016/140] feat: add sorting functionality --- .../features/student/scrap/utils/sortScrap.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/sortScrap.ts diff --git a/apps/native/src/features/student/scrap/utils/sortScrap.ts b/apps/native/src/features/student/scrap/utils/sortScrap.ts new file mode 100644 index 00000000..857648d9 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/sortScrap.ts @@ -0,0 +1,39 @@ +import { DummyItem } from '../components/ScrapItemGrid'; + +export type SortKey = 'TYPE' | 'TITLE' | 'DATE'; +export type SortOrder = 'ASC' | 'DESC'; + +export const sortScrapData = (list: DummyItem[], key: SortKey, order: SortOrder) => { + const mul = order === 'ASC' ? 1 : -1; + + const reviewItem = list.find((item) => item.id === 'REVIEW'); + const sortable = list.filter((item) => item.id !== 'REVIEW'); + + const sorted = [...sortable]; + + switch (key) { + case 'TYPE': + sorted.sort((a, b) => { + if (a.type !== b.type) return a.type === 'Folder' ? -1 * mul : 1 * mul; + + if (a.type === 'Folder') return (b.timestamp - a.timestamp) * mul; + if (a.type === 'Scrap') return (a.timestamp - b.timestamp) * mul; + + return 0; + }); + break; + + case 'TITLE': + sorted.sort((a, b) => a.title.localeCompare(b.title) * mul); + break; + + case 'DATE': + sorted.sort((a, b) => (a.timestamp - b.timestamp) * mul); + break; + } + + if (reviewItem) { + return [reviewItem, ...sorted]; + } + return sorted; +}; From c28fb01a70ebb48f390a18339f9eedaccb5baee2 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:35:43 +0900 Subject: [PATCH 017/140] feat: implement scrap search page --- .../scrap/screens/SearchScrapScreen.tsx | 83 +++++++++++++++---- 1 file changed, 69 insertions(+), 14 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx index b8b75d4b..9fe04512 100644 --- a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -1,27 +1,82 @@ +import Container from '@/components/common/Container'; import { StudentRootStackParamList } from '@/navigation/student/types'; +import { colors } from '@/theme/tokens'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { ChevronLeft } from 'lucide-react-native'; -import React from 'react'; -import { Pressable, Text, View } from 'react-native'; +import { ChevronLeft, X } from 'lucide-react-native'; +import React, { useEffect, useState } from 'react'; +import { Pressable, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; +import { DummyItem, SearchScrapGrid } from '../components/ScrapItemGrid'; +import { useScrapStore } from '@/stores/scrapDataStore'; +import { SearchResultItem } from '../components/ScrapItem'; const SearchScrapScreen = () => { const navigation = useNavigation>(); + const [query, setQuery] = useState(''); + const searchByTitle = useScrapStore((state) => state.searchByTitle); + const [results, setResults] = useState([]); + + useEffect(() => { + if (query.trim().length === 0) { + setResults([]); + } else { + setResults(searchByTitle(query)); + } + }, [query]); + return ( - - - {navigation.canGoBack() ? ( - navigation.goBack()} className='p-2'> - - - - + + + + + {}} + /> + {query.length > 0 && ( + setQuery('')}> + + + )} + + {navigation.canGoBack() ? ( + navigation.goBack()} className='p-2'> + + + + + ) : ( + + )} + + + + {query.length === 0 ? ( + <> + 최근 검색어 + + 전체 지우기 + + ) : ( - + 검색 결과 )} - - + + + + + ); }; From f0973733504b63f349b8d41df5767c35844e64ce Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:36:17 +0900 Subject: [PATCH 018/140] feat: add sorting functionality, refactor: change ScrapGrid data prop --- .../student/scrap/screens/ScrapScreen.tsx | 91 ++++++++++--------- 1 file changed, 46 insertions(+), 45 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index ac4a27ca..4b1b40a7 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -2,49 +2,44 @@ import { Container, LoadingScreen } from '@/components/common'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import React, { useReducer, useState } from 'react'; -import { View, Text } from 'react-native'; +import React, { useEffect, useReducer, useState } from 'react'; +import { View, Text, Pressable } from 'react-native'; import { reducer, selectState } from '../utils/reducer'; -import ScrapHeader from '../components/ScrapHeader'; -import ScrapGrid, { DummyItem } from '../components/ScrapItemGrid'; -import SortDropdown from '../components/SortDropdown'; - -const folderDummy: DummyItem[] = Array.from({ length: 13 }, (_, i) => { - if (i % 3 === 0) { - return { - type: 'Folder', - id: i.toString(), - title: '폴더명', - amount: 127 + i, - }; - } else { - return { - type: 'Scrap', - id: i.toString(), - title: '스크랩명', - timestamp: 202511191130 - i * 11, - }; - } -}); +import ScrapHeader from '../components/Header/ScrapHeader'; +import { ScrapGrid } from '../components/ScrapItemGrid'; +import SortDropdown from '../components/Modal/SortDropdown'; +import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; +import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; +import { useScrapStore } from '@/stores'; +import { showToast } from '../components/Modal/Toast'; +import { folderDummy } from '../utils/testdataset'; const ScrapScreen = () => { const [reducerState, dispatch] = useReducer(reducer, selectState); - const [data, setData] = useState(folderDummy); const [fetchloading, setFetchLoading] = useState(false); - + const [sortKey, setSortKey] = useState('TYPE'); // 기본: 유형순 + const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 const navigation = useNavigation>(); - const orderList = [ - { label: '유형순', value: 'type' }, - { label: '이름순', value: 'name' }, - { label: '최신순', value: 'latest' }, - ]; - const [order, setOrder] = useState({ - label: '유형순', - value: 'type', - }); + const data = useScrapStore((state) => state.data); + const setData = useScrapStore((state) => state.setData); + const deleteItem = useScrapStore((state) => state.deleteItem); + + // Fetching Data + useEffect(() => { + setFetchLoading(true); + + setTimeout(() => { + setData(folderDummy); // 초기값 세팅 + setFetchLoading(false); + }, 800); + }, []); - const isallSelected = reducerState.selectedItems.length === folderDummy.length; + useEffect(() => { + setData(sortScrapData(data, sortKey, sortOrder)); + }, [sortKey, sortOrder]); + + const isallSelected = reducerState.selectedItems.length === data.length; return ( @@ -61,34 +56,40 @@ const ScrapScreen = () => { onMove={() => { const selectedSet = new Set(reducerState.selectedItems); const selectedFolders = folderDummy.filter( - (item) => selectedSet.has(item.id) && item.type === 'Folder' + (item) => selectedSet.has(item.id) && item.type === 'FOLDER' ); - if (selectedFolders.length > 0) console.log(selectedFolders); + if (selectedFolders.length > 0) showToast('error', '스크랩만 이동이 가능합니다.'); }} onDelete={async () => { setFetchLoading(true); - try { - const selectedSet = new Set(reducerState.selectedItems); - - await new Promise((resolve) => setTimeout(resolve, 500)); - - setData((prev) => prev.filter((item) => !selectedSet.has(item.id))); + await new Promise((resolve) => setTimeout(resolve, 100)); + deleteItem(reducerState.selectedItems); } finally { setFetchLoading(false); dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); } }} /> - + + + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> + {sortOrder === 'ASC' ? : } + + {fetchloading ? ( ) : ( - + )} From 5f742e62e0116ed7dc77be32dbf01a64b4172fe7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:36:39 +0900 Subject: [PATCH 019/140] feat: add scrap item types --- .../scrap/components/ScrapItemGrid.tsx | 103 ++++++++++++++++-- 1 file changed, 95 insertions(+), 8 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx b/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx index 52de422e..e9add1ca 100644 --- a/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx +++ b/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx @@ -1,15 +1,31 @@ import { FlatList, useWindowDimensions, View } from 'react-native'; import { Action, State } from '../utils/reducer'; -import ScrapItem from './ScrapItem'; +import { ResultScrapItem, ScrapItem, SearchResultItem } from './ScrapItem'; +import { ScrapAddItem, ScrapReviewItem } from './ScrapHeadItem'; export type DummyItem = - | { type: 'Folder'; id: string; title: string; amount: number } - | { type: 'Scrap'; id: string; title: string; timestamp: number }; + | { + id: string; + type: 'FOLDER'; + title: string; + amount: number; + timestamp: number; + contents: DummyItem[]; + } + | { id: string; type: 'SCRAP'; title: string; timestamp: number } + | { + id: 'REVIEW'; + type: 'FOLDER'; + title: string; + amount: number; + timestamp: number; + contents: DummyItem[]; + }; interface ScrapGridProps { data: DummyItem[]; - state: State; - dispatch: React.Dispatch; + state?: State; + dispatch?: React.Dispatch; } const addPlaceholders = (data: DummyItem[], columns: number) => { @@ -19,7 +35,8 @@ const addPlaceholders = (data: DummyItem[], columns: number) => { return [...data, ...Array(emptyCount).fill({ placeholder: true })]; }; -const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { + +export const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { const { width, height } = useWindowDimensions(); const isLandscape = width > 1024 && width > height; @@ -42,6 +59,7 @@ const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { data={finalData} numColumns={numColumns} keyExtractor={(item) => item.id ?? Math.random().toString()} + contentContainerStyle={{ paddingBottom: 120 }} columnWrapperStyle={{ marginBottom: gap }} renderItem={({ item, index }) => { const isLastColumn = (index + 1) % numColumns === 0; @@ -51,6 +69,22 @@ const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { marginRight: isLastColumn ? 0 : gap, }; + if (item.ADD) { + return ( + + + + ); + } + + if (item.id === 'REVIEW') { + return ( + + + + ); + } + if ('placeholder' in item && item.placeholder) { return ; } @@ -60,7 +94,7 @@ const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { dispatch({ type: 'SELECTING_ITEM', id: item.id })} + onCheckPress={() => dispatch?.({ type: 'SELECTING_ITEM', id: item.id })} /> ); @@ -69,4 +103,57 @@ const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { ); }; -export default ScrapGrid; +interface SearchScrapGridProps { + data: SearchResultItem[]; +} + +export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { + const { width, height } = useWindowDimensions(); + const isLandscape = width > 1024 && width > height; + + let numColumns = isLandscape ? 6 : 4; + const gap = isLandscape ? 22 : 34; + + const totalGap = gap * (numColumns - 1); + const padding = isLandscape ? 256 : 120; + const itemWidth = (width - totalGap - padding) / numColumns; + + if (itemWidth < 136) { + numColumns = isLandscape ? 5 : 4; + } + const mappedData = data.map((item) => ({ + ...item.scrap, + parentFolderName: item.parentFolderName, + })); + + const finalData = addPlaceholders(mappedData, numColumns); + + return ( + item.id ?? Math.random().toString()} + contentContainerStyle={{ paddingBottom: 120 }} + columnWrapperStyle={{ marginBottom: gap }} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + flex: 1, + marginRight: isLastColumn ? 0 : gap, + }; + + if ('placeholder' in item && item.placeholder) { + return ; + } + + return ( + + + + ); + }} + /> + ); +}; From f7abc0da69ca9401499b1776f3409b6a5bbfe5a2 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:36:49 +0900 Subject: [PATCH 020/140] refactor: restructure ScrapItem props --- .../student/scrap/components/ScrapItem.tsx | 95 +++++++++++++------ 1 file changed, 68 insertions(+), 27 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/ScrapItem.tsx b/apps/native/src/features/student/scrap/components/ScrapItem.tsx index 15eac342..8140c37b 100644 --- a/apps/native/src/features/student/scrap/components/ScrapItem.tsx +++ b/apps/native/src/features/student/scrap/components/ScrapItem.tsx @@ -3,37 +3,51 @@ import { Action, State } from '../utils/reducer'; import React from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; +import TooltipPopover, { ItemTooltipBox } from './Modal/TooltipBox'; +import { DummyItem } from './ScrapItemGrid'; -interface FolderOverrides { - type: 'Folder'; +interface BaseItemProps { + id: string; + title: string; + timestamp: string; // 여기서 string/number 결정 +} + +interface FolderItemProps extends BaseItemProps, OptionalItemProps { + type: 'FOLDER'; amount: number; + contents: ScrapItemProps[]; } -interface ScrapOverrides { - type: 'Scrap'; - timestamp: string; +interface ScrapItemProps extends BaseItemProps, OptionalItemProps { + type: 'SCRAP'; + parentFolderName?: string; } -interface BaseItemProps { - id: string; - title: string; - ruducerState: State; +interface ReviewFolderProps extends FolderItemProps { + id: 'REVIEW'; +} + +interface OptionalItemProps { + ruducerState?: State; + dispatch?: React.Dispatch; onItemPress?: () => void; onDownPress?: () => void; onCheckPress?: () => void; className?: string; } -export type ItemProps = BaseItemProps & (FolderOverrides | ScrapOverrides); +export type ItemProps = ScrapItemProps | FolderItemProps | ReviewFolderProps; + +export const ScrapItem = (props: ItemProps) => { + const state = props.ruducerState ?? { isSelecting: false, selectedItems: [] }; + const isSelected = state ? state.selectedItems.includes(props.id) : false; -const ScrapItem = (props: ItemProps) => { - const isSelected = props.ruducerState.selectedItems.includes(props.id); return ( - {props.ruducerState.isSelecting && ( + {state.isSelecting && ( { left: 20, top: 50, }}> - {props.ruducerState.isSelecting && } + {state.isSelecting && } )} - - - {props.title} - {!props.ruducerState.isSelecting && ( - - - + + + + + {props.title} + + {!state.isSelecting && ( + } + from={} + /> + )} + + {props.type === 'FOLDER' && ( + {props.amount} )} - {props.type === 'Folder' && {props.amount}} - {props.type === 'Scrap' && ( - {props.timestamp} - )} + {props.timestamp} ); }; -export default ScrapItem; +export interface SearchResultItem { + scrap: DummyItem; // 실제 스크랩 아이템 + parentFolderName?: string; // 속한 폴더가 있으면 이름 +} + +export const ResultScrapItem = (props: SearchResultItem) => { + return ( + + + + + {props.parentFolderName && ( + + {props.parentFolderName} + + )} + + {props.scrap.title} + + + + + ); +}; From 6a32431029c789079b951445478fc92d3d3363f0 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:37:05 +0900 Subject: [PATCH 021/140] refactor: change directory structure --- .../components/{ => Header}/ScrapHeader.tsx | 2 +- .../scrap/components/ScrapHeadItem.tsx | 54 +++++++++++++++++++ 2 files changed, 55 insertions(+), 1 deletion(-) rename apps/native/src/features/student/scrap/components/{ => Header}/ScrapHeader.tsx (98%) create mode 100644 apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx diff --git a/apps/native/src/features/student/scrap/components/ScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx similarity index 98% rename from apps/native/src/features/student/scrap/components/ScrapHeader.tsx rename to apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx index 742ec2f5..3f9ec26b 100644 --- a/apps/native/src/features/student/scrap/components/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -3,7 +3,7 @@ import { SafeAreaView } from 'react-native-safe-area-context'; import { View, Text, Pressable } from 'react-native'; import { ArrowRightLeft, Search, Trash2 } from 'lucide-react-native'; import { CircleCheckDashed } from '@/components/system/icons'; -import { State } from '../utils/reducer'; +import { State } from '../../utils/reducer'; import { colors } from '@/theme/tokens'; interface ScrapHeaderProps { diff --git a/apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx b/apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx new file mode 100644 index 00000000..ad3a85d0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx @@ -0,0 +1,54 @@ +import { colors } from '@/theme/tokens'; +import { Plus } from 'lucide-react-native'; +import { Pressable, View, Text, Image } from 'react-native'; +import TooltipPopover, { AddItemTooltipBox, ReviewItemTooltipBox } from './Modal/TooltipBox'; +import Popover from 'react-native-popover-view'; +import { Placement } from 'react-native-popover-view/dist/Types'; +import { ChevronDownFilledIcon } from '@/components/system/icons'; +import { FolderOverrides, ItemProps } from './ScrapItem'; + +export const ScrapAddItem = () => { + return ( + } + from={ + + + + + + 추가하기 + + + } + /> + ); +}; + +export const ScrapReviewItem = ({ props }: { props: ItemProps & FolderOverrides }) => { + return ( + + + + + + + {props.title} + + } + from={} + /> + + {props.amount} + + {props.timestamp} + + + ); +}; From 8db60c386616081d3c5af9bd3e80da0196b68d2a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:37:15 +0900 Subject: [PATCH 022/140] refactor: change directory structure --- .../student/scrap/components/SortDropdown.tsx | 118 ------------------ 1 file changed, 118 deletions(-) delete mode 100644 apps/native/src/features/student/scrap/components/SortDropdown.tsx diff --git a/apps/native/src/features/student/scrap/components/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/SortDropdown.tsx deleted file mode 100644 index 81cefb09..00000000 --- a/apps/native/src/features/student/scrap/components/SortDropdown.tsx +++ /dev/null @@ -1,118 +0,0 @@ -import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; -import { colors } from '@/theme/tokens'; -import { Check } from 'lucide-react-native'; -import { useState } from 'react'; -import { View, Text, StyleSheet } from 'react-native'; -import { Dropdown } from 'react-native-element-dropdown'; - -interface OrderItem { - label: string; - value: string; -} - -interface SortDropdownProps { - orderList: OrderItem[]; - order: OrderItem; - setOrder: (item: OrderItem) => void; -} - -const SortDropdown: React.FC = ({ orderList, order, setOrder }) => { - const [isFocus, setIsFocus] = useState(false); - - return ( - { - setOrder(item); - setIsFocus(false); - }} - onFocus={() => setIsFocus(true)} - onBlur={() => setIsFocus(false)} - renderRightIcon={() => (isFocus ? : )} - renderItem={(item) => ( - - {item.value === order.value && } - {item.label} - - )} - /> - ); -}; - -export default SortDropdown; - -const styles = StyleSheet.create({ - dropdown: { - width: 71, - height: 29, - gap: 2, - paddingTop: 4, - paddingRight: 4, - paddingLeft: 8, - paddingBottom: 4, - borderRadius: 4, - backgroundColor: colors['gray-100'], - }, - dropdownFocus: { - backgroundColor: colors['gray-400'], - }, - container: { - width: 104, - borderRadius: 8, - borderWidth: 1, - borderColor: colors['gray-400'], - gap: 2, - top: 4, - shadowColor: 'rgba(12,12,13,0.10)', - shadowOffset: { width: 0, height: 16 }, - shadowOpacity: 1, - shadowRadius: 32, - padding: 4, - }, - itemContainer: { - borderRadius: 4, - height: 28, - }, - placeholder: { - alignItems: 'center', - justifyContent: 'center', - fontSize: 14, - fontWeight: '500', - fontFamily: 'Pretendard', - lineHeight: 21, - color: colors['gray-800'], - }, - selectedText: { - alignItems: 'center', - justifyContent: 'center', - fontSize: 14, - fontWeight: '500', - fontFamily: 'Pretendard', - lineHeight: 21, - color: colors['gray-800'], - }, - itemRow: { - flexDirection: 'row', - alignItems: 'flex-end', - justifyContent: 'flex-end', - gap: 10, - borderRadius: 4, - paddingHorizontal: 10, - paddingVertical: 2, - }, - itemText: { - fontSize: 16, - fontWeight: '500', - lineHeight: 24, - fontFamily: 'Pretendard', - color: colors['gray-800'], - }, -}); From fd493b71875d5287bac48e954d69be60a63b1519 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Sat, 13 Dec 2025 01:37:33 +0900 Subject: [PATCH 023/140] refactor: change directory structure --- .../scrap/components/Modal/PopupModal.tsx | 30 +++ .../scrap/components/Modal/SortDropdown.tsx | 123 +++++++++++++ .../student/scrap/components/Modal/Toast.tsx | 70 +++++++ .../scrap/components/Modal/TooltipBox.tsx | 172 ++++++++++++++++++ 4 files changed, 395 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/Toast.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx diff --git a/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx b/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx new file mode 100644 index 00000000..39f79dc9 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx @@ -0,0 +1,30 @@ +import { Modal, TouchableWithoutFeedback, View } from 'react-native'; + +const PopUpModal = ({ + className, + children, + visibleState, + setVisibleState, +}: { + className?: string; + children?: React.ReactNode; + visibleState: boolean; + setVisibleState: React.Dispatch>; +}) => { + return ( + { + setVisibleState(false); + }}> + setVisibleState(false)}> + + {}}>{children} + + + + ); +}; +export default PopUpModal; diff --git a/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx new file mode 100644 index 00000000..801ec4b8 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx @@ -0,0 +1,123 @@ +import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; +import { colors } from '@/theme/tokens'; +import { Check } from 'lucide-react-native'; +import { useState } from 'react'; +import { View, Text, StyleSheet } from 'react-native'; +import { Dropdown } from 'react-native-element-dropdown'; +import { SortKey } from '../../utils/sortScrap'; + +interface OrderItem { + label: string; + value: SortKey; +} + +export const orderList: OrderItem[] = [ + { label: '유형순', value: 'TYPE' }, + { label: '이름순', value: 'TITLE' }, + { label: '최신순', value: 'DATE' }, +]; + +interface SortDropdownProps { + orderValue: SortKey; + setOrderValue: (value: SortKey) => void; +} + +const SortDropdown: React.FC = ({ orderValue, setOrderValue }) => { + const [isFocus, setIsFocus] = useState(false); + + return ( + setOrderValue(item.value)} + onFocus={() => setIsFocus(true)} + onBlur={() => setIsFocus(false)} + renderRightIcon={() => null} + renderItem={(item) => ( + + {item.value === orderValue && } + {item.label} + + )} + /> + ); +}; + +export default SortDropdown; + +const styles = StyleSheet.create({ + dropdown: { + width: 49, + height: 29, + gap: 2, + paddingTop: 4, + paddingRight: 4, + paddingLeft: 8, + paddingBottom: 4, + borderRadius: 4, + backgroundColor: colors['gray-100'], + }, + dropdownFocus: { + backgroundColor: colors['gray-400'], + }, + container: { + width: 104, + borderRadius: 8, + borderWidth: 1, + borderColor: colors['gray-400'], + justifyContent: 'center', + gap: 2, + top: 4, + shadowColor: 'rgba(12,12,13,0.10)', + shadowOffset: { width: 0, height: 16 }, + shadowOpacity: 1, + shadowRadius: 32, + padding: 4, + }, + itemContainer: { + borderRadius: 4, + height: 28, + }, + placeholder: { + alignItems: 'center', + justifyContent: 'center', + fontSize: 14, + fontWeight: '500', + fontFamily: 'Pretendard', + lineHeight: 21, + color: colors['gray-800'], + }, + selectedText: { + alignItems: 'center', + textAlign: 'right', + justifyContent: 'center', + fontSize: 14, + fontWeight: '500', + fontFamily: 'Pretendard', + lineHeight: 21, + color: colors['gray-800'], + }, + itemRow: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 10, + borderRadius: 4, + paddingHorizontal: 10, + paddingVertical: 2, + }, + itemText: { + fontSize: 16, + fontWeight: '500', + lineHeight: 24, + fontFamily: 'Pretendard', + color: colors['gray-800'], + }, +}); diff --git a/apps/native/src/features/student/scrap/components/Modal/Toast.tsx b/apps/native/src/features/student/scrap/components/Modal/Toast.tsx new file mode 100644 index 00000000..3ff2c3f2 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Toast.tsx @@ -0,0 +1,70 @@ +import Toast, { BaseToast, ToastConfig } from 'react-native-toast-message'; +import { StyleSheet } from 'react-native'; +import { Check, X } from 'lucide-react-native'; + +export const showToast = (type: string, message: string) => { + Toast.show({ + type: type, + text1: message, + topOffset: 30, // 위쪽 위치 조정 + visibilityTime: 3000, + }); +}; + +export const toastConfig: ToastConfig = { + success: (props) => ( + } + /> + ), + error: (props) => ( + } + /> + ), +}; + +const styles = StyleSheet.create({ + toastContainer: { + borderLeftColor: 'transparent', + flex: 1, + height: 46, + borderRadius: 8, + paddingVertical: 12, + paddingHorizontal: 14, + backgroundColor: '#3E3F45', + shadowColor: '#0F0F12', + shadowOffset: { width: 0, height: 6 }, + shadowOpacity: 0.1, + shadowRadius: 16, + elevation: 8, + gap: 12, + alignItems: 'center', + }, + contentContainer: { + paddingHorizontal: 0, + flexDirection: 'row', + alignItems: 'center', + }, + text1: { + fontSize: 14, + fontWeight: '700', + color: '#FFF', + lineHeight: 21, + textAlign: 'center', + }, + text2: { + fontSize: 12, + color: '#FFF', + }, +}); diff --git a/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx b/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx new file mode 100644 index 00000000..ffcbacc0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx @@ -0,0 +1,172 @@ +import { useScrapStore } from '@/stores/scrapDataStore'; +import { colors } from '@/theme/tokens'; +import { + Camera, + FileSymlink, + FolderOpen, + ImagePlay, + Trash2, + Image, + Images, + FolderPlus, +} from 'lucide-react-native'; +import { useState } from 'react'; +import { TextInput, View, Text, Pressable } from 'react-native'; +import { ItemProps } from '../ScrapItem'; +import { showToast } from './Toast'; +import React from 'react'; +import Popover from 'react-native-popover-view'; +import { ViewStyle } from 'react-native'; +import { Placement } from 'react-native-popover-view/dist/Types'; + +interface TooltipPopoverProps { + from: React.ReactNode; + children: React.ReactNode; + placement?: Placement; + popoverStyle?: ViewStyle; +} + +const TooltipPopover = ({ + from, + children, + placement = Placement.AUTO, + popoverStyle, +}: TooltipPopoverProps) => { + return ( + + {children} + + ); +}; + +export default TooltipPopover; + +export const ItemTooltipBox = ({ props }: { props: ItemProps }) => { + const data = useScrapStore((state) => state.data); + const updateItem = useScrapStore((state) => state.updateItem); + const deleteItem = useScrapStore((state) => state.deleteItem); + + const item = data.find((i) => i.id === props.id); + + const [text, setText] = useState(item?.title ?? ''); + + return ( + + + + { + if (text.length > 0) updateItem(props.id, text); + }} + /> + + + + + {props.type === 'Folder' ? ( + 폴더 열기 + ) : ( + 스크랩 열기 + )} + + + {props.type === 'Folder' ? ( + <> + + 표지 변경하기 + + ) : ( + <> + + 폴더 이동하기 + + )} + + { + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + deleteItem(props.id); + } finally { + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } + }}> + + 휴지통으로 이동 + + + ); +}; + +export const AddItemTooltipBox = () => { + return ( + + + + 사진 찍기 + + + + 이미지 선택 + + + + QnA 사진 불러오기 + + + + 폴더 추가하기 + + + ); +}; + +export const ReviewItemTooltipBox = ({ props }: { props: ItemProps }) => { + const data = useScrapStore((state) => state.data); + const updateItem = useScrapStore((state) => state.updateItem); + const item = data.find((i) => i.id === props.id); + + const [text, setText] = useState(item?.title ?? ''); + + return ( + + + + { + if (text.length > 0) updateItem(props.id, text); + }} + /> + + + + + 오답노트 열기 + + + ); +}; From 38a077853a6ab8564152a65689aeab0c14a31f9c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:48:28 +0900 Subject: [PATCH 024/140] refactor(scrap): update scrap and folder type definitions --- apps/native/src/types/test/types.ts | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 apps/native/src/types/test/types.ts diff --git a/apps/native/src/types/test/types.ts b/apps/native/src/types/test/types.ts new file mode 100644 index 00000000..fb39d5f6 --- /dev/null +++ b/apps/native/src/types/test/types.ts @@ -0,0 +1,28 @@ +// Base types for better type safety +export interface ScrapFolder { + id: string; + type: 'FOLDER'; + title: string; + timestamp: number; + contents: ScrapItem[]; +} + +export interface ScrapContent { + id: string; + type: 'SCRAP'; + title: string; + timestamp: number; + parentFolderId?: string; +} + +export interface ReviewFolder extends Omit { + id: 'REVIEW'; +} + +// Union type for all scrap items +export type ScrapItem = ScrapFolder | ScrapContent | ReviewFolder; + +// Trash item extends scrap item with deletion timestamp +export type TrashItem = ScrapItem & { + deletedAt: number; +}; From 0824af403853739d540ddccb7d22509de82710be Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:49:18 +0900 Subject: [PATCH 025/140] feat(scrap): implement scrap create, update, delete, and search logic --- apps/native/src/stores/scrapDataStore.ts | 211 ++++++++++++++++++++--- 1 file changed, 191 insertions(+), 20 deletions(-) diff --git a/apps/native/src/stores/scrapDataStore.ts b/apps/native/src/stores/scrapDataStore.ts index 7a55f5d6..d5e1df47 100644 --- a/apps/native/src/stores/scrapDataStore.ts +++ b/apps/native/src/stores/scrapDataStore.ts @@ -1,49 +1,220 @@ -import { SearchResultItem } from '@/features/student/scrap/components/ScrapItem'; -import { DummyItem } from '@/features/student/scrap/components/ScrapItemGrid'; +import { ScrapItem, TrashItem } from '@/types/test/types'; import { create } from 'zustand'; +import { deleteItemsRecursive } from '@/features/student/scrap/utils/itemHelpers'; +/** + * Search result type for better type safety + */ +export interface SearchResult { + item: ScrapItem; + parentFolderName?: string; +} + +/** + * ScrapStore manages scrap data: fetch, update, delete, search + */ interface ScrapStore { - data: DummyItem[]; - setData: (newData: DummyItem[]) => void; - updateItem: (id: string, newTitle: string) => void; - deleteItem: (ids: string | string[]) => void; - searchByTitle: (query: string) => { scrap: DummyItem; parentFolderId?: string }[]; + data: ScrapItem[]; + setData: (newData: ScrapItem[]) => void; + updateItem: (id: string, newTitle: string, parentFolderId?: string) => void; + deleteItem: (ids: string | string[], parentFolderId?: string) => void; + searchByTitle: (query: string) => SearchResult[]; + restoreItem: (item: TrashItem) => void; } export const useScrapStore = create((set, get) => ({ data: [], setData: (newData) => set({ data: newData }), - updateItem: (id, newTitle) => - set((state) => ({ - data: state.data.map((item) => (item.id === id ? { ...item, title: newTitle } : item)), - })), - deleteItem: (ids) => + + updateItem: (id, newTitle, parentFolderId) => set((state) => { - const deleteArray = Array.isArray(ids) ? ids : [ids]; + if (parentFolderId) { + // Update item in nested folder + return { + data: state.data.map((item) => { + if (item.id === parentFolderId && item.type === 'FOLDER') { + return { + ...item, + contents: item.contents.map((c) => (c.id === id ? { ...c, title: newTitle } : c)), + }; + } + return item; + }), + }; + } + + // Update item in root return { - data: state.data.filter((item) => !deleteArray.includes(item.id)), + data: state.data.map((item) => (item.id === id ? { ...item, title: newTitle } : item)), }; }), + + deleteItem: (ids, parentFolderId) => + set((state) => { + const deleteArray = Array.isArray(ids) ? ids : [ids]; + + if (parentFolderId) { + // Delete from specific folder + return { + data: state.data.map((d) => { + if (d.id === parentFolderId && d.type === 'FOLDER') { + return { + ...d, + contents: deleteItemsRecursive(d.contents, deleteArray), + }; + } + return d; + }), + }; + } + + // Delete from root + return { data: deleteItemsRecursive(state.data, deleteArray) }; + }), + searchByTitle: (query: string) => { const state = get(); - const lowerQuery = query.toLowerCase(); + const lowerQuery = query.toLowerCase().trim(); + if (!lowerQuery) return []; - const results: SearchResultItem[] = []; + const results: SearchResult[] = []; state.data.forEach((item) => { - if (item.type === 'SCRAP' && item.title.toLowerCase().includes(lowerQuery)) { - results.push({ scrap: item }); + // Check folder title + if (item.type === 'FOLDER' && item.title.toLowerCase().includes(lowerQuery)) { + results.push({ item, parentFolderName: undefined }); } + // Check contents of folder if (item.type === 'FOLDER' && item.contents) { item.contents.forEach((c) => { - if (c.type === 'SCRAP' && c.title.toLowerCase().includes(lowerQuery)) { - results.push({ scrap: c, parentFolderName: item.title }); + if (c.title.toLowerCase().includes(lowerQuery)) { + results.push({ + item: c, + parentFolderName: item.title, + }); } }); } + + // Check root level scrap items + if (item.type === 'SCRAP' && item.title.toLowerCase().includes(lowerQuery)) { + results.push({ item }); + } }); return results; }, + + restoreItem: (item) => + set((state) => { + // Remove deletedAt property when restoring + const { deletedAt, ...restoredItem } = item; + + if (restoredItem.type === 'FOLDER') { + return { + data: [...state.data, { ...restoredItem, contents: restoredItem.contents ?? [] }], + }; + } + + if (restoredItem.type === 'SCRAP') { + if (restoredItem.parentFolderId) { + // Restore to parent folder + return { + data: state.data.map((d) => { + if (d.type === 'FOLDER' && d.id === restoredItem.parentFolderId) { + return { + ...d, + contents: [...(d.contents ?? []), restoredItem], + }; + } + return d; + }), + }; + } + + // Restore to root + return { data: [...state.data, restoredItem] }; + } + + return state; + }), +})); + +{ + /* TrashStore */ +} + +interface TrashStore { + data: TrashItem[]; + + addToTrash: (items: ScrapItem | ScrapItem[]) => void; + restoreFromTrash: (ids: string | string[]) => void; + deleteForever: (ids: string | string[]) => void; +} + +export const useTrashStore = create((set) => ({ + data: [], + + addToTrash: (items) => + set((state) => { + const array = Array.isArray(items) ? items : [items]; + const trashItems: TrashItem[] = array.map((item) => ({ + ...item, + deletedAt: Date.now(), + })); + + return { data: [...state.data, ...trashItems] }; + }), + + restoreFromTrash: (ids) => + set((state) => { + const restoreIds = Array.isArray(ids) ? ids : [ids]; + return { + data: state.data.filter((item) => !restoreIds.includes(item.id)), + }; + }), + + deleteForever: (ids) => + set((state) => { + const deleteIds = Array.isArray(ids) ? ids : [ids]; + return { + data: state.data.filter((item) => !deleteIds.includes(item.id)), + }; + }), +})); + +{ + /* SearchHistoryStore works recently data add, clear in SearchScren */ +} + +interface SearchHistoryStore { + keywords: string[]; + + addKeyword: (keyword: string) => void; + removeKeyword: (keyword: string) => void; + clear: () => void; +} + +export const useSearchHistoryStore = create((set) => ({ + keywords: [], + + addKeyword: (keyword) => + set((state) => { + const trimmed = keyword.trim(); + if (!trimmed) return state; + + const filtered = state.keywords.filter((k) => k !== trimmed); + + return { + keywords: [trimmed, ...filtered].slice(0, 10), + }; + }), + + removeKeyword: (keyword) => + set((state) => ({ + keywords: state.keywords.filter((k) => k !== keyword), + })), + + clear: () => set({ keywords: [] }), })); From 4fc5cc01723cd0d760f8e82150ed2f71bc9b588e Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:50:28 +0900 Subject: [PATCH 026/140] feat(trash): add trash store with restore and permanent delete --- .../scrap/screens/DeletedScrapScreen.tsx | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx new file mode 100644 index 00000000..05b76c4e --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -0,0 +1,141 @@ +import React, { useEffect, useMemo, useReducer, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +import DeletedScrapHeader from '../components/Header/DeletedHeader'; +import { reducer, initialSelectionState } from '../utils/reducer'; +import { useNavigation } from '@react-navigation/native'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useScrapStore, useTrashStore } from '@/stores/scrapDataStore'; +import { TrashItem } from '@/types/test/types'; +import { Container, LoadingScreen } from '@/components/common'; +import { TrashScrapGrid } from '../components/ScrapCardGrid'; +import SortDropdown from '../components/Modal/SortDropdown'; +import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; +import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; +import PopUpModal from '../components/Modal/PopupModal'; +import { showToast } from '../components/Modal/Toast'; + +const DeletedScrapScreen = () => { + const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); + const [fetchloading, setFetchLoading] = useState(false); + const [sortKey, setSortKey] = useState('TYPE'); // 기본: 유형순 + const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + + const navigation = useNavigation>(); + + const fetchdata = useTrashStore((state) => state.data); + const deleteForever = useTrashStore((state) => state.deleteForever); + const restoreFromTrash = useTrashStore((state) => state.restoreFromTrash); + const restoreToScrap = useScrapStore((state) => state.restoreItem); + + useEffect(() => { + setFetchLoading(true); + + const timer = setTimeout(() => { + setFetchLoading(false); + }, 800); + + return () => clearTimeout(timer); + }, [fetchdata]); + + // 정렬된 데이터를 useMemo로 계산 + const sortedData = useMemo(() => { + return sortScrapData(fetchdata, sortKey, sortOrder); + }, [fetchdata, sortKey, sortOrder]); + + return ( + + { + const allIds = sortedData.map((item) => item.id); + const isAllSelected = + reducerState.selectedItems.length === sortedData.length && sortedData.length > 0; + dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); + }} + onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} + onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} + onDelete={() => { + if (reducerState.selectedItems.length > 0) { + setIsDeleteModalVisible(true); + } + }} + onRestore={async () => { + try { + const itemsToRestore = fetchdata.filter((item: { id: string }) => + reducerState.selectedItems.includes(item.id) + ); + await new Promise((resolve) => setTimeout(resolve, 100)); + itemsToRestore.forEach((item) => restoreToScrap(item)); + restoreFromTrash(reducerState.selectedItems); + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '선택된 파일들이 복구되었습니다.'); + } catch (error) { + showToast('error', '복구 중 오류가 발생했습니다.'); + } + }} + /> + + + + + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> + {sortOrder === 'ASC' ? : } + + + + + {fetchloading ? ( + + ) : ( + + )} + + + + + + + {reducerState.selectedItems.length === 1 + ? '스크랩을 영구적으로 삭제합니다.' + : `${reducerState.selectedItems.length}개의 스크랩을 영구적으로 삭제합니다.`} + + + {reducerState.selectedItems.length === 1 + ? '되돌릴 수 없는 작업입니다.' + : '선택하신 스크랩이 영구적으로 삭제되며\n돌릴 수 없는 작업입니다.'} + + + + setIsDeleteModalVisible(false)}> + 취소 + + { + try { + deleteForever(reducerState.selectedItems); + dispatch({ type: 'CLEAR_SELECTION' }); + setIsDeleteModalVisible(false); + showToast('success', '영구 삭제되었습니다.'); + } catch (error) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }}> + 삭제하기 + + + + + + ); +}; + +export default DeletedScrapScreen; From 96107913d8a027b2157332755c06ed31f53bb911 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:50:52 +0900 Subject: [PATCH 027/140] feat(scrap): add folder content list screen --- .../scrap/screens/ScrapContentScreen.tsx | 89 +++++++++++++++++++ .../navigation/student/StudentNavigator.tsx | 2 + apps/native/src/navigation/student/types.ts | 4 + 3 files changed, 95 insertions(+) create mode 100644 apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx new file mode 100644 index 00000000..fa8fb908 --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -0,0 +1,89 @@ +import { Pressable, View } from 'react-native'; +import ScrapHeader from '../components/Header/ScrapHeader'; +import { useMemo, useReducer, useState } from 'react'; +import { reducer, initialSelectionState } from '../utils/reducer'; +import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; +import { useScrapStore, useTrashStore } from '@/stores/scrapDataStore'; +import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { Container } from '@/components/common'; +import SortDropdown from '../components/Modal/SortDropdown'; +import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; +import { ScrapGrid } from '../components/ScrapCardGrid'; +import { showToast } from '../components/Modal/Toast'; + +type ScrapContentRouteProp = RouteProp; + +const ScrapContentScreen = () => { + const route = useRoute(); + const { id } = route.params; + + const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); + const [sortKey, setSortKey] = useState('TITLE'); // 기본: 이름순 + const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 + const navigation = useNavigation>(); + + // Get item and contents directly from store + const item = useScrapStore((state) => state.data.find((i) => i.id === id)); + const contents = useMemo(() => { + return item?.type === 'FOLDER' ? item.contents : []; + }, [item]); + + const deleteItem = useScrapStore((state) => state.deleteItem); + const addToTrash = useTrashStore((state) => state.addToTrash); + + // Sort data directly from contents + const sortedData = useMemo( + () => sortScrapData(contents, sortKey, sortOrder), + [contents, sortKey, sortOrder] + ); + + const isAllSelected = + reducerState.selectedItems.length === contents.length && contents.length > 0; + + return ( + + navigation.push('SearchScrap')} + navigateTrashPress={() => navigation.push('DeletedScrap')} + onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} + onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} + onSelectAll={() => { + const allIds = contents.map((item: { id: string }) => item.id); + dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); + }} + onDelete={async () => { + try { + const itemsToDelete = contents.filter((item: { id: string }) => + reducerState.selectedItems.includes(item.id) + ); + addToTrash(itemsToDelete); + deleteItem(reducerState.selectedItems, id); + } finally { + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } + }} + /> + + + + + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> + {sortOrder === 'ASC' ? : } + + + + + + + + + ); +}; + +export default ScrapContentScreen; diff --git a/apps/native/src/navigation/student/StudentNavigator.tsx b/apps/native/src/navigation/student/StudentNavigator.tsx index bebdb61a..0f4ac645 100644 --- a/apps/native/src/navigation/student/StudentNavigator.tsx +++ b/apps/native/src/navigation/student/StudentNavigator.tsx @@ -12,6 +12,7 @@ import StudentTabs from './StudentTabs'; import { StudentRootStackParamList } from './types'; import NotificationHeader from './components/NotificationHeader'; import { DeletedScrapScreen, ScrapScreen, SearchScrapScreen } from '@/features/student/scrap'; +import ScrapContentScreen from '@/features/student/scrap/screens/ScrapContentScreen'; const StudentRootStack = createNativeStackNavigator(); @@ -40,6 +41,7 @@ const StudentNavigator = () => { + diff --git a/apps/native/src/navigation/student/types.ts b/apps/native/src/navigation/student/types.ts index eb5d87b9..44d8f3c8 100644 --- a/apps/native/src/navigation/student/types.ts +++ b/apps/native/src/navigation/student/types.ts @@ -1,3 +1,4 @@ +import { FolderCardProps } from '@/features/student/scrap/components/ScrapCard'; import type { NavigatorScreenParams } from '@react-navigation/native'; import { components } from '@schema'; @@ -23,6 +24,9 @@ export type StudentRootStackParamList = { problemSetTitle?: string; }; Scrap: undefined; + ScrapContent: { + id: string; + }; SearchScrap: undefined; DeletedScrap: undefined; }; From f443d5a5721a5d05f87efd4d8fbe0712780a2d75 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:51:01 +0900 Subject: [PATCH 028/140] refactor(sort): improve sorting logic and usage --- .../features/student/scrap/utils/sortScrap.ts | 63 ++++++++++--------- 1 file changed, 35 insertions(+), 28 deletions(-) diff --git a/apps/native/src/features/student/scrap/utils/sortScrap.ts b/apps/native/src/features/student/scrap/utils/sortScrap.ts index 857648d9..0104773e 100644 --- a/apps/native/src/features/student/scrap/utils/sortScrap.ts +++ b/apps/native/src/features/student/scrap/utils/sortScrap.ts @@ -1,39 +1,46 @@ -import { DummyItem } from '../components/ScrapItemGrid'; +import { TrashItem, ScrapItem } from '@/types/test/types'; export type SortKey = 'TYPE' | 'TITLE' | 'DATE'; export type SortOrder = 'ASC' | 'DESC'; -export const sortScrapData = (list: DummyItem[], key: SortKey, order: SortOrder) => { +/** + * Sorts scrap items by the specified key and order. + * REVIEW items are always placed at the beginning. + */ +export const sortScrapData = ( + list: T[], + key: SortKey, + order: SortOrder +): T[] => { const mul = order === 'ASC' ? 1 : -1; + // Separate REVIEW item (always shown first) const reviewItem = list.find((item) => item.id === 'REVIEW'); const sortable = list.filter((item) => item.id !== 'REVIEW'); - const sorted = [...sortable]; - - switch (key) { - case 'TYPE': - sorted.sort((a, b) => { - if (a.type !== b.type) return a.type === 'Folder' ? -1 * mul : 1 * mul; - - if (a.type === 'Folder') return (b.timestamp - a.timestamp) * mul; - if (a.type === 'Scrap') return (a.timestamp - b.timestamp) * mul; - + const sorted = [...sortable].sort((a, b) => { + switch (key) { + case 'TYPE': + // Sort by type first (FOLDER before SCRAP) + if (a.type !== b.type) { + return (a.type === 'FOLDER' ? -1 : 1) * mul; + } + // Same type: sort by timestamp + return (a.timestamp - b.timestamp) * mul; + + case 'TITLE': + // Sort by title using Korean locale + return a.title.localeCompare(b.title, 'ko', { numeric: true }) * mul; + + case 'DATE': + // Sort by timestamp + return (a.timestamp - b.timestamp) * mul; + + default: return 0; - }); - break; - - case 'TITLE': - sorted.sort((a, b) => a.title.localeCompare(b.title) * mul); - break; - - case 'DATE': - sorted.sort((a, b) => (a.timestamp - b.timestamp) * mul); - break; - } - - if (reviewItem) { - return [reviewItem, ...sorted]; - } - return sorted; + } + }); + + // REVIEW item always comes first + return reviewItem ? ([reviewItem, ...sorted] as T[]) : sorted; }; From f616f754a16d2f91ca3634ea551f12d3b7b7cec7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:51:08 +0900 Subject: [PATCH 029/140] feat(ui): implement responsive auto grid layout --- .../student/scrap/utils/gridLayout.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/gridLayout.ts diff --git a/apps/native/src/features/student/scrap/utils/gridLayout.ts b/apps/native/src/features/student/scrap/utils/gridLayout.ts new file mode 100644 index 00000000..391642b3 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/gridLayout.ts @@ -0,0 +1,25 @@ +import { useWindowDimensions } from 'react-native'; + +/** + * Calculates grid layout parameters based on window dimensions + * @returns Object containing numColumns, gap, and itemWidth + */ +export const useGridLayout = () => { + const { width, height } = useWindowDimensions(); + const isLandscape = width > 1024 && width > height; + + let numColumns = isLandscape ? 6 : 4; + const gap = isLandscape ? 22 : 34; + const totalGap = gap * (numColumns - 1); + const padding = isLandscape ? 256 : 120; + let itemWidth = (width - totalGap - padding) / numColumns; + + // Adjust columns if item width is too small + if (itemWidth < 136) { + numColumns = isLandscape ? 5 : 4; + itemWidth = (width - gap * (numColumns - 1) - padding) / numColumns; + } + + return { numColumns, gap, itemWidth }; +}; + From 22496785b50fb50cbd826b5d0d40cb519071a924 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:51:36 +0900 Subject: [PATCH 030/140] refactor(search): improve scrap search page and results --- .../scrap/screens/SearchScrapScreen.tsx | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx index 9fe04512..db4f57d0 100644 --- a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -5,17 +5,18 @@ import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { ChevronLeft, X } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; -import { Pressable, Text, TextInput, TouchableOpacity, View } from 'react-native'; +import { Pressable, ScrollView, Text, TextInput, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { DummyItem, SearchScrapGrid } from '../components/ScrapItemGrid'; -import { useScrapStore } from '@/stores/scrapDataStore'; -import { SearchResultItem } from '../components/ScrapItem'; +import { SearchScrapGrid } from '../components/ScrapCardGrid'; +import { useScrapStore, useSearchHistoryStore, SearchResult } from '@/stores/scrapDataStore'; +import SearchScrapHeader from '../components/Header/SearchHeader'; const SearchScrapScreen = () => { const navigation = useNavigation>(); const [query, setQuery] = useState(''); const searchByTitle = useScrapStore((state) => state.searchByTitle); - const [results, setResults] = useState([]); + const { keywords, addKeyword, removeKeyword, clear } = useSearchHistoryStore(); + const [results, setResults] = useState([]); useEffect(() => { if (query.trim().length === 0) { @@ -25,53 +26,51 @@ const SearchScrapScreen = () => { } }, [query]); + const onSearch = () => { + if (!query.trim()) return; + + addKeyword(query); + setResults(searchByTitle(query)); + }; + return ( - - - - - {}} - /> - {query.length > 0 && ( - setQuery('')}> - - - )} - - {navigation.canGoBack() ? ( - navigation.goBack()} className='p-2'> - - - - - ) : ( - - )} - - - + + + {query.length === 0 ? ( - <> + 최근 검색어 - + clear()}> 전체 지우기 - + ) : ( 검색 결과 )} + {query.length === 0 && ( + + {keywords.map((item, i) => ( + + setQuery(item)}> + {item} + + removeKeyword(item)}> + + + + ))} + + )} From b605394925e0e441e107047b79e61abeb35781fb Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:52:07 +0900 Subject: [PATCH 031/140] feat(trash): add trash page and header --- .../scrap/components/Header/DeletedHeader.tsx | 110 ++++++++++++++++++ 1 file changed, 110 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx diff --git a/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx b/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx new file mode 100644 index 00000000..d4d91b91 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx @@ -0,0 +1,110 @@ +import { SafeAreaView } from 'react-native-safe-area-context'; +import { State } from '../../utils/reducer'; +import { Container } from '@/components/common'; +import { View, Text, Pressable } from 'react-native'; +import { CircleCheckDashed } from '@/components/system/icons'; +import { ArrowRightLeft, ChevronLeft, Trash2, Undo2 } from 'lucide-react-native'; +import { colors } from '@/theme/tokens'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; + +interface DeletedScrapHeaderProps { + navigateback: NativeStackNavigationProp; + reducerState: State; + navigateSearchPress?: () => void; + navigateTrashPress?: () => void; + onEnterSelection?: () => void; + onExitSelection?: () => void; + onMove?: () => void; + onDelete?: () => void; + onRestore?: () => void; + isAllSelected?: boolean; + onSelectAll?: () => void; +} + +const DeletedScrapHeader = ({ + navigateback, + reducerState, + onEnterSelection, + onExitSelection, + onMove, + onDelete, + onRestore, + isAllSelected, + onSelectAll, +}: DeletedScrapHeaderProps) => { + const isActionEnabled = reducerState.selectedItems.length > 0; + return ( + + {!reducerState.isSelecting && ( + + {navigateback.canGoBack() ? ( + navigateback.goBack()} className='p-2'> + + + + + ) : ( + + )} + 휴지통 + + + + + + + )} + + {reducerState.isSelecting && ( + + + + + {!isAllSelected ? '전체 선택' : '전체 해제'} + + + 스크랩 + + + 완료 + + + + + { + if (isActionEnabled && onRestore) onRestore(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 복구하기 + + { + if (isActionEnabled && onMove) onMove(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 이동하기 + + { + if (isActionEnabled && onDelete) onDelete(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 삭제하기 + + + + )} + + ); +}; + +export default DeletedScrapHeader; From cdd5b2814e003c145fa33c483aa98e258de67c0f Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:52:38 +0900 Subject: [PATCH 032/140] feat(trash): add tooltip and close behavior --- .../scrap/components/Modal/TooltipBox.tsx | 175 +++++++++++++++--- 1 file changed, 146 insertions(+), 29 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx b/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx index ffcbacc0..942729be 100644 --- a/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx @@ -1,4 +1,4 @@ -import { useScrapStore } from '@/stores/scrapDataStore'; +import { useScrapStore, useTrashStore } from '@/stores/scrapDataStore'; import { colors } from '@/theme/tokens'; import { Camera, @@ -9,19 +9,25 @@ import { Image, Images, FolderPlus, + Undo2, } from 'lucide-react-native'; import { useState } from 'react'; import { TextInput, View, Text, Pressable } from 'react-native'; -import { ItemProps } from '../ScrapItem'; import { showToast } from './Toast'; import React from 'react'; import Popover from 'react-native-popover-view'; import { ViewStyle } from 'react-native'; import { Placement } from 'react-native-popover-view/dist/Types'; +import { ScrapListItemProps } from '../ScrapCard'; +import { ScrapItem, TrashItem } from '@/types/test/types'; +import { findItem } from '../../utils/itemHelpers'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StudentRootStackParamList } from '@/navigation/student/types'; interface TooltipPopoverProps { from: React.ReactNode; - children: React.ReactNode; + children: React.ReactNode | ((close: () => void) => React.ReactNode); placement?: Placement; popoverStyle?: ViewStyle; } @@ -32,9 +38,20 @@ const TooltipPopover = ({ placement = Placement.AUTO, popoverStyle, }: TooltipPopoverProps) => { + const [isVisible, setIsVisible] = React.useState(false); + + const close = () => { + setIsVisible(false); + }; + + // from을 Pressable로 감싸서 클릭 시 열리도록 함 + const triggerElement = setIsVisible(true)}>{from}; + return ( - {children} + {typeof children === 'function' ? children(close) : children} ); }; export default TooltipPopover; -export const ItemTooltipBox = ({ props }: { props: ItemProps }) => { +export const ItemTooltipBox = ({ + props, + onClose, +}: { + props: ScrapListItemProps; + onClose?: () => void; +}) => { const data = useScrapStore((state) => state.data); const updateItem = useScrapStore((state) => state.updateItem); const deleteItem = useScrapStore((state) => state.deleteItem); + const addToTrash = useTrashStore((state) => state.addToTrash); + const navigation = useNavigation>(); - const item = data.find((i) => i.id === props.id); + const item = findItem(data, props.id, props.type === 'SCRAP' ? props.parentFolderId : undefined); const [text, setText] = useState(item?.title ?? ''); + const handleClose = () => { + onClose?.(); + }; + return ( @@ -75,21 +104,41 @@ export const ItemTooltipBox = ({ props }: { props: ItemProps }) => { value={text} onChangeText={setText} onEndEditing={() => { - if (text.length > 0) updateItem(props.id, text); + const trimmedText = text.trim(); + if (trimmedText.length > 0) { + updateItem( + props.id, + trimmedText, + props.type === 'SCRAP' ? props.parentFolderId : undefined + ); + } }} /> - + { + handleClose(); + // Popover가 닫히는 시간을 주기 위해 약간의 지연 + setTimeout(() => { + if (props.type === 'FOLDER') { + navigation.push('ScrapContentList', { id: props.id }); + } else { + // TODO: 스크랩 열기 기능 구현 + showToast('info', '스크랩 열기 기능은 준비 중입니다.'); + } + }, 100); + }}> - {props.type === 'Folder' ? ( + {props.type === 'FOLDER' ? ( 폴더 열기 ) : ( 스크랩 열기 )} - + - {props.type === 'Folder' ? ( + {props.type === 'FOLDER' ? ( <> 표지 변경하기 @@ -106,9 +155,18 @@ export const ItemTooltipBox = ({ props }: { props: ItemProps }) => { onPress={async () => { try { await new Promise((resolve) => setTimeout(resolve, 100)); - deleteItem(props.id); - } finally { + if (!item) { + showToast('error', '아이템을 찾을 수 없습니다.'); + return; + } + + const parentFolderId = item.type === 'SCRAP' ? item.parentFolderId : undefined; + deleteItem(item.id, parentFolderId); + addToTrash(item); + handleClose(); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error) { + showToast('error', '삭제 중 오류가 발생했습니다.'); } }}> @@ -141,32 +199,91 @@ export const AddItemTooltipBox = () => { ); }; -export const ReviewItemTooltipBox = ({ props }: { props: ItemProps }) => { - const data = useScrapStore((state) => state.data); - const updateItem = useScrapStore((state) => state.updateItem); - const item = data.find((i) => i.id === props.id); +export const ReviewItemTooltipBox = ({ + props, + onClose, +}: { + props: ScrapListItemProps; + onClose?: () => void; +}) => { + const navigation = useNavigation>(); - const [text, setText] = useState(item?.title ?? ''); + const handleClose = () => { + onClose?.(); + }; return ( - { - if (text.length > 0) updateItem(props.id, text); - }} - /> + + 오답노트 + - + { + handleClose(); + // Popover가 닫히는 시간을 주기 위해 약간의 지연 + setTimeout(() => { + navigation.push('ScrapContentList', { id: props.id }); + }, 100); + }}> 오답노트 열기 ); }; + +export const TrashItemTooltipBox = ({ + item, + onClose, + onDeletePress, +}: { + item: TrashItem; + onClose?: () => void; + onDeletePress?: () => void; +}) => { + const restoreFromTrash = useTrashStore((state) => state.restoreFromTrash); + const restoreToScrap = useScrapStore((state) => state.restoreItem); + + const handleClose = () => { + onClose?.(); + }; + + return ( + + { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (onDeletePress) { + onDeletePress(); + } else { + handleClose(); + } + }}> + + 영구 삭제하기 + + { + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + restoreToScrap(item); + restoreFromTrash(item.id); + handleClose(); + showToast('success', '선택된 파일이 복구되었습니다.'); + } catch (error) { + showToast('error', '복구 중 오류가 발생했습니다.'); + } + }}> + + 복구하기 + + + ); +}; From 2de653d6f6b012f3edf70c4329e705a41f073b9c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:52:50 +0900 Subject: [PATCH 033/140] chore(ui): rename Item components to Card and update filenames --- .../student/scrap/components/ScrapCard.tsx | 223 ++++++++++++++++++ .../scrap/components/ScrapCardGrid.tsx | 214 +++++++++++++++++ .../{ScrapHeadItem.tsx => ScrapHeadCard.tsx} | 24 +- .../student/scrap/components/ScrapItem.tsx | 112 --------- .../scrap/components/ScrapItemGrid.tsx | 159 ------------- 5 files changed, 453 insertions(+), 279 deletions(-) create mode 100644 apps/native/src/features/student/scrap/components/ScrapCard.tsx create mode 100644 apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx rename apps/native/src/features/student/scrap/components/{ScrapHeadItem.tsx => ScrapHeadCard.tsx} (64%) delete mode 100644 apps/native/src/features/student/scrap/components/ScrapItem.tsx delete mode 100644 apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx diff --git a/apps/native/src/features/student/scrap/components/ScrapCard.tsx b/apps/native/src/features/student/scrap/components/ScrapCard.tsx new file mode 100644 index 00000000..6dca2d2e --- /dev/null +++ b/apps/native/src/features/student/scrap/components/ScrapCard.tsx @@ -0,0 +1,223 @@ +import { Pressable, View, Text } from 'react-native'; +import { Action, State } from '../utils/reducer'; +import React, { useState } from 'react'; +import { Check } from 'lucide-react-native'; +import { ChevronDownFilledIcon } from '@/components/system/icons'; +import TooltipPopover, { ItemTooltipBox, TrashItemTooltipBox } from './Modal/TooltipBox'; +import { TrashItem, ScrapItem } from '@/types/test/types'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation } from '@react-navigation/native'; +import PopUpModal from './Modal/PopupModal'; +import { useTrashStore } from '@/stores/scrapDataStore'; +import { showToast } from './Modal/Toast'; + +export interface BaseItemUIProps { + id: string; + title: string; + timestamp: string; +} + +export interface SelectableUIProps { + reducerState?: State; + dispatch?: React.Dispatch; + onCheckPress?: () => void; +} + +export interface ScrapCardProps extends BaseItemUIProps, SelectableUIProps { + type: 'SCRAP'; + parentFolderName?: string; + parentFolderId?: string; +} + +export interface FolderCardProps extends BaseItemUIProps, SelectableUIProps { + type: 'FOLDER'; + contents: ScrapItem[]; // 실제 데이터 구조와 일치: 폴더와 스크랩 모두 포함 +} + +export interface ReviewFolderCardProps extends FolderCardProps { + id: 'REVIEW'; +} + +export type ScrapListItemProps = ScrapCardProps | FolderCardProps | ReviewFolderCardProps; + +export const ScrapCard = (props: ScrapListItemProps) => { + const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; + const isSelected = state.selectedItems.includes(props.id); + const navigation = useNavigation>(); + + return ( + { + if (state.isSelecting) return; + + if (props.type === 'FOLDER') navigation.push('ScrapContentList', { id: props.id }); + }}> + + + {state.isSelecting && ( + + + + )} + + + + + + {props.title} + + {!state.isSelecting && ( + } + children={(close) => } + /> + )} + + {props.type === 'FOLDER' && ( + {props.contents.length} + )} + + + {props.timestamp} + + + ); +}; + +export interface SearchResultCardProps { + item: ScrapItem; + parentFolderName?: string; +} + +export const SearchResultCard = ({ item, parentFolderName }: SearchResultCardProps) => { + const navigation = useNavigation>(); + + return ( + { + if (item.type === 'FOLDER') navigation.push('ScrapContentList', { id: item.id }); + }}> + + + + {parentFolderName && ( + + {parentFolderName} + + )} + + + + {item.title} + + + {item.type === 'FOLDER' && ( + {item.contents.length} + )} + + + + ); +}; + +export interface TrashCardProps extends SelectableUIProps { + item: TrashItem; +} + +export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) => { + const state = reducerState ?? { isSelecting: false, selectedItems: [] }; + const isSelected = state.selectedItems.includes(item.id); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const deleteForever = useTrashStore((state) => state.deleteForever); + + return ( + <> + + + {state.isSelecting && ( + + + + )} + + + + {item.title} + + + {new Date(item.deletedAt).toLocaleString()} + + + + } + children={ + !state.isSelecting + ? (close) => ( + { + close(); + setTimeout(() => { + setIsDeleteModalVisible(true); + }, 200); + }} + /> + ) + : undefined + } + /> + + + + 스크랩을 영구적으로 삭제합니다. + 되돌릴 수 없는 작업입니다. + + + setIsDeleteModalVisible(false)}> + 취소 + + { + try { + await new Promise((resolve) => setTimeout(resolve, 100)); + deleteForever(item.id); + setIsDeleteModalVisible(false); + showToast('success', '영구 삭제되었습니다.'); + } catch (error) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }}> + 삭제하기 + + + + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx b/apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx new file mode 100644 index 00000000..0f7b846f --- /dev/null +++ b/apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx @@ -0,0 +1,214 @@ +import { FlatList, View } from 'react-native'; +import { Action, State } from '../utils/reducer'; +import { ScrapCard, SearchResultCard, TrashCard } from './ScrapCard'; +import { ScrapAddItem, ScrapReviewItem } from './ScrapHeadCard'; +import { ScrapItem, TrashItem } from '@/types/test/types'; +import { useGridLayout } from '../utils/gridLayout'; + +/** + * ADD item type for ScrapGrid + */ +export type AddItem = { ADD: true }; + +/** + * Union type for ScrapGrid data items + */ +export type ScrapGridItem = ScrapItem | AddItem; + +/** + * Adds placeholder items to fill the last row in grid layout + */ +const addPlaceholders = ( + data: T[], + columns: number +): (T | { placeholder: true })[] => { + const fullRows = Math.floor(data.length / columns); + const totalNeeded = (fullRows + 1) * columns; + const emptyCount = totalNeeded - data.length; + + return [...data, ...Array(emptyCount).fill({ placeholder: true })]; +}; + +interface ScrapGridProps { + data: ScrapGridItem[]; + reducerState: State; + dispatch: React.Dispatch; +} +export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { + const { numColumns, gap } = useGridLayout(); + const finalData = addPlaceholders(data, numColumns); + + return ( + + // item may be a ScrapItem/TrashItem or a placeholder item + 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() + } + contentContainerStyle={{ paddingBottom: 120 }} + columnWrapperStyle={{ marginBottom: gap }} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + flex: 1, + marginRight: isLastColumn ? 0 : gap, + }; + + // Check for placeholder first + if ('placeholder' in item && item.placeholder) { + return ; + } + + // Handle ADD item (check before type guard since it may not have standard structure) + if ('ADD' in item && item.ADD === true) { + return ( + + + + ); + } + + // Type guard: ensure item is ScrapItem + if (!('id' in item) || !('type' in item)) { + return ; + } + + const scrapItem = item as ScrapItem; + + // Handle REVIEW item + if (scrapItem.id === 'REVIEW') { + return ( + + + + ); + } + + // Handle regular scrap items + // Convert ScrapItem to ScrapListItemProps format + return ( + + dispatch?.({ type: 'SELECTING_ITEM', id: scrapItem.id })} + /> + + ); + }} + /> + ); +}; + +interface SearchScrapGridProps { + data: Array<{ item: ScrapItem; parentFolderName?: string }>; +} +export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { + const { numColumns, gap } = useGridLayout(); + const mappedData = data.map((item) => ({ + ...item.item, + parentFolderName: item.parentFolderName, + })); + + const finalData = addPlaceholders(mappedData, numColumns); + + return ( + + 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() + } + contentContainerStyle={{ paddingBottom: 120 }} + columnWrapperStyle={{ marginBottom: gap }} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + flex: 1, + marginRight: isLastColumn ? 0 : gap, + }; + + if ('placeholder' in item && item.placeholder) { + return ; + } + + // Type guard: ensure item has required properties + if (!('id' in item) || !('type' in item)) { + return ; + } + + const scrapItem = item as ScrapItem & { parentFolderName?: string }; + + return ( + + + + ); + }} + /> + ); +}; + +interface TrashScrapGridProps { + data: TrashItem[]; + reducerState: State; + dispatch: React.Dispatch; +} + +export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridProps) => { + const { numColumns, gap } = useGridLayout(); + const finalData = addPlaceholders(data, numColumns); + + return ( + + 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() + } + contentContainerStyle={{ paddingBottom: 120 }} + columnWrapperStyle={{ marginBottom: gap }} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + flex: 1, + marginRight: isLastColumn ? 0 : gap, + }; + + if ('placeholder' in item && item.placeholder) { + return ; + } + + // Type guard: ensure item is TrashItem + if (!('id' in item) || !('type' in item) || !('deletedAt' in item)) { + return ; + } + + const trashItem = item as TrashItem; + + return ( + + dispatch({ type: 'SELECTING_ITEM', id: trashItem.id })} + /> + + ); + }} + /> + ); +}; diff --git a/apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx b/apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx similarity index 64% rename from apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx rename to apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx index ad3a85d0..8fcc35d2 100644 --- a/apps/native/src/features/student/scrap/components/ScrapHeadItem.tsx +++ b/apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx @@ -2,10 +2,12 @@ import { colors } from '@/theme/tokens'; import { Plus } from 'lucide-react-native'; import { Pressable, View, Text, Image } from 'react-native'; import TooltipPopover, { AddItemTooltipBox, ReviewItemTooltipBox } from './Modal/TooltipBox'; -import Popover from 'react-native-popover-view'; import { Placement } from 'react-native-popover-view/dist/Types'; import { ChevronDownFilledIcon } from '@/components/system/icons'; -import { FolderOverrides, ItemProps } from './ScrapItem'; +import { ScrapListItemProps } from './ScrapCard'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StudentRootStackParamList } from '@/navigation/student/types'; export const ScrapAddItem = () => { return ( @@ -13,22 +15,26 @@ export const ScrapAddItem = () => { placement={Placement.BOTTOM} children={} from={ - + 추가하기 - + } /> ); }; -export const ScrapReviewItem = ({ props }: { props: ItemProps & FolderOverrides }) => { +export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { + const navigation = useNavigation>(); + return ( - + navigation.push('ScrapContent', { id: props.id })}> } + children={(close) => } from={} /> - {props.amount} + {props.type === 'FOLDER' && ( + {props.contents.length} + )} {props.timestamp} diff --git a/apps/native/src/features/student/scrap/components/ScrapItem.tsx b/apps/native/src/features/student/scrap/components/ScrapItem.tsx deleted file mode 100644 index 8140c37b..00000000 --- a/apps/native/src/features/student/scrap/components/ScrapItem.tsx +++ /dev/null @@ -1,112 +0,0 @@ -import { Pressable, View, Text } from 'react-native'; -import { Action, State } from '../utils/reducer'; -import React from 'react'; -import { Check } from 'lucide-react-native'; -import { ChevronDownFilledIcon } from '@/components/system/icons'; -import TooltipPopover, { ItemTooltipBox } from './Modal/TooltipBox'; -import { DummyItem } from './ScrapItemGrid'; - -interface BaseItemProps { - id: string; - title: string; - timestamp: string; // 여기서 string/number 결정 -} - -interface FolderItemProps extends BaseItemProps, OptionalItemProps { - type: 'FOLDER'; - amount: number; - contents: ScrapItemProps[]; -} - -interface ScrapItemProps extends BaseItemProps, OptionalItemProps { - type: 'SCRAP'; - parentFolderName?: string; -} - -interface ReviewFolderProps extends FolderItemProps { - id: 'REVIEW'; -} - -interface OptionalItemProps { - ruducerState?: State; - dispatch?: React.Dispatch; - onItemPress?: () => void; - onDownPress?: () => void; - onCheckPress?: () => void; - className?: string; -} - -export type ItemProps = ScrapItemProps | FolderItemProps | ReviewFolderProps; - -export const ScrapItem = (props: ItemProps) => { - const state = props.ruducerState ?? { isSelecting: false, selectedItems: [] }; - const isSelected = state ? state.selectedItems.includes(props.id) : false; - - return ( - - - {state.isSelecting && ( - - {state.isSelecting && } - - )} - - - - - - {props.title} - - {!state.isSelecting && ( - } - from={} - /> - )} - - {props.type === 'FOLDER' && ( - {props.amount} - )} - - {props.timestamp} - - - ); -}; - -export interface SearchResultItem { - scrap: DummyItem; // 실제 스크랩 아이템 - parentFolderName?: string; // 속한 폴더가 있으면 이름 -} - -export const ResultScrapItem = (props: SearchResultItem) => { - return ( - - - - - {props.parentFolderName && ( - - {props.parentFolderName} - - )} - - {props.scrap.title} - - - - - ); -}; diff --git a/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx b/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx deleted file mode 100644 index e9add1ca..00000000 --- a/apps/native/src/features/student/scrap/components/ScrapItemGrid.tsx +++ /dev/null @@ -1,159 +0,0 @@ -import { FlatList, useWindowDimensions, View } from 'react-native'; -import { Action, State } from '../utils/reducer'; -import { ResultScrapItem, ScrapItem, SearchResultItem } from './ScrapItem'; -import { ScrapAddItem, ScrapReviewItem } from './ScrapHeadItem'; - -export type DummyItem = - | { - id: string; - type: 'FOLDER'; - title: string; - amount: number; - timestamp: number; - contents: DummyItem[]; - } - | { id: string; type: 'SCRAP'; title: string; timestamp: number } - | { - id: 'REVIEW'; - type: 'FOLDER'; - title: string; - amount: number; - timestamp: number; - contents: DummyItem[]; - }; - -interface ScrapGridProps { - data: DummyItem[]; - state?: State; - dispatch?: React.Dispatch; -} - -const addPlaceholders = (data: DummyItem[], columns: number) => { - const fullRows = Math.floor(data.length / columns); - const totalNeeded = (fullRows + 1) * columns; - const emptyCount = totalNeeded - data.length; - - return [...data, ...Array(emptyCount).fill({ placeholder: true })]; -}; - -export const ScrapGrid = ({ data, state, dispatch }: ScrapGridProps) => { - const { width, height } = useWindowDimensions(); - const isLandscape = width > 1024 && width > height; - - let numColumns = isLandscape ? 6 : 4; - const gap = isLandscape ? 22 : 34; - - const totalGap = gap * (numColumns - 1); - const padding = isLandscape ? 256 : 120; - const itemWidth = (width - totalGap - padding) / numColumns; - - if (itemWidth < 136) { - numColumns = isLandscape ? 5 : 4; - } - - const finalData = addPlaceholders(data, numColumns); - - return ( - item.id ?? Math.random().toString()} - contentContainerStyle={{ paddingBottom: 120 }} - columnWrapperStyle={{ marginBottom: gap }} - renderItem={({ item, index }) => { - const isLastColumn = (index + 1) % numColumns === 0; - - const spacingStyle = { - flex: 1, - marginRight: isLastColumn ? 0 : gap, - }; - - if (item.ADD) { - return ( - - - - ); - } - - if (item.id === 'REVIEW') { - return ( - - - - ); - } - - if ('placeholder' in item && item.placeholder) { - return ; - } - - return ( - - dispatch?.({ type: 'SELECTING_ITEM', id: item.id })} - /> - - ); - }} - /> - ); -}; - -interface SearchScrapGridProps { - data: SearchResultItem[]; -} - -export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { - const { width, height } = useWindowDimensions(); - const isLandscape = width > 1024 && width > height; - - let numColumns = isLandscape ? 6 : 4; - const gap = isLandscape ? 22 : 34; - - const totalGap = gap * (numColumns - 1); - const padding = isLandscape ? 256 : 120; - const itemWidth = (width - totalGap - padding) / numColumns; - - if (itemWidth < 136) { - numColumns = isLandscape ? 5 : 4; - } - const mappedData = data.map((item) => ({ - ...item.scrap, - parentFolderName: item.parentFolderName, - })); - - const finalData = addPlaceholders(mappedData, numColumns); - - return ( - item.id ?? Math.random().toString()} - contentContainerStyle={{ paddingBottom: 120 }} - columnWrapperStyle={{ marginBottom: gap }} - renderItem={({ item, index }) => { - const isLastColumn = (index + 1) % numColumns === 0; - - const spacingStyle = { - flex: 1, - marginRight: isLastColumn ? 0 : gap, - }; - - if ('placeholder' in item && item.placeholder) { - return ; - } - - return ( - - - - ); - }} - /> - ); -}; From 0c2e6ec1ea3b16304445fdd7878778b90c657ff1 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:53:51 +0900 Subject: [PATCH 034/140] refactor(reducer): make reducer exhaustive --- .../features/student/scrap/utils/reducer.ts | 29 ++++++++++++++++--- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/apps/native/src/features/student/scrap/utils/reducer.ts b/apps/native/src/features/student/scrap/utils/reducer.ts index 3b003058..6f1ae5c7 100644 --- a/apps/native/src/features/student/scrap/utils/reducer.ts +++ b/apps/native/src/features/student/scrap/utils/reducer.ts @@ -1,3 +1,6 @@ +/** + * Selection state for scrap items + */ export interface State { isSelecting: boolean; selectedItems: string[]; @@ -10,19 +13,30 @@ export type Action = | { type: 'SELECT_ALL'; allIds: string[] } | { type: 'CLEAR_SELECTION' }; -export const selectState: State = { +/** + * Initial state for selection reducer + */ +export const initialSelectionState: State = { isSelecting: false, selectedItems: [], }; +/** + * Reducer for managing selection state of scrap items + * @param state - Current selection state + * @param action - Action to perform + * @returns New selection state + */ export function reducer(state: State, action: Action): State { switch (action.type) { case 'ENTER_SELECTION': return { ...state, isSelecting: true }; + case 'EXIT_SELECTION': return { ...state, isSelecting: false, selectedItems: [] }; - case 'SELECTING_ITEM': - const id = action.id; + + case 'SELECTING_ITEM': { + const { id } = action; const exists = state.selectedItems.includes(id); return { @@ -31,11 +45,18 @@ export function reducer(state: State, action: Action): State { ? state.selectedItems.filter((i) => i !== id) : [...state.selectedItems, id], }; + } + case 'SELECT_ALL': return { ...state, selectedItems: action.allIds }; + case 'CLEAR_SELECTION': return { ...state, selectedItems: [] }; - default: + + default: { + // Exhaustive check: ensures all action types are handled + const _exhaustive: never = action; return state; + } } } From a91023d610f95daab3e8303040c43d977664aeb5 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:54:17 +0900 Subject: [PATCH 035/140] feat(scrap): implement search logic --- .../student/scrap/utils/itemHelpers.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/itemHelpers.ts diff --git a/apps/native/src/features/student/scrap/utils/itemHelpers.ts b/apps/native/src/features/student/scrap/utils/itemHelpers.ts new file mode 100644 index 00000000..15a74485 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/itemHelpers.ts @@ -0,0 +1,43 @@ +import { ScrapItem } from '@/types/test/types'; + +/** + * Finds an item in the scrap data structure. + * @param data - Array of scrap items + * @param id - ID of the item to find + * @param parentFolderId - Optional parent folder ID for nested items + * @returns The found item or null if not found + */ +export const findItem = ( + data: ScrapItem[], + id: string, + parentFolderId?: string +): ScrapItem | null => { + if (parentFolderId) { + const folder = data.find((i) => i.id === parentFolderId && i.type === 'FOLDER'); + if (folder?.type === 'FOLDER') { + return folder.contents?.find((c) => c.id === id) ?? null; + } + return null; + } + return data.find((i) => i.id === id) ?? null; +}; + +/** + * Recursively deletes items from the scrap data structure. + * @param items - Array of items to filter + * @param idsToDelete - Array of IDs to delete + * @returns Filtered array with deleted items removed + */ +export const deleteItemsRecursive = (items: ScrapItem[], idsToDelete: string[]): ScrapItem[] => { + return items + .filter((item) => !idsToDelete.includes(item.id)) + .map((item) => { + if (item.type === 'FOLDER' && item.contents) { + return { + ...item, + contents: deleteItemsRecursive(item.contents, idsToDelete), + }; + } + return item; + }); +}; From 2936c88ff2fb6910aac179c82bca8d7eac68e748 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:54:36 +0900 Subject: [PATCH 036/140] refactor(sort): improve sorting logic and usage --- .../student/scrap/components/Modal/SortDropdown.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx index 801ec4b8..177d9018 100644 --- a/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx @@ -17,12 +17,18 @@ export const orderList: OrderItem[] = [ { label: '최신순', value: 'DATE' }, ]; +export const orderContent: OrderItem[] = [ + { label: '이름순', value: 'TITLE' }, + { label: '최신순', value: 'DATE' }, +]; + interface SortDropdownProps { + ordertype: 'LIST' | 'CONTENT'; orderValue: SortKey; setOrderValue: (value: SortKey) => void; } -const SortDropdown: React.FC = ({ orderValue, setOrderValue }) => { +const SortDropdown: React.FC = ({ ordertype, orderValue, setOrderValue }) => { const [isFocus, setIsFocus] = useState(false); return ( @@ -32,7 +38,7 @@ const SortDropdown: React.FC = ({ orderValue, setOrderValue } itemContainerStyle={styles.itemContainer} placeholderStyle={styles.placeholder} selectedTextStyle={{ ...styles.selectedText, textAlign: 'right' }} - data={orderList} + data={ordertype === 'LIST' ? orderList : orderContent} labelField='label' valueField='value' value={orderValue} From a0e2b3df54f2b4510249ed37b4f7652cb35b85af Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:55:15 +0900 Subject: [PATCH 037/140] refactor(search): improve scrap search page and results --- .../scrap/components/Header/SearchHeader.tsx | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Header/SearchHeader.tsx diff --git a/apps/native/src/features/student/scrap/components/Header/SearchHeader.tsx b/apps/native/src/features/student/scrap/components/Header/SearchHeader.tsx new file mode 100644 index 00000000..1c483ef0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Header/SearchHeader.tsx @@ -0,0 +1,61 @@ +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { colors } from '@/theme/tokens'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { ChevronLeft, X } from 'lucide-react-native'; +import { Pressable, TextInput, View } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; + +interface SearchScrapHeaderProps { + navigateback: NativeStackNavigationProp; + query: string; + setQuery: React.Dispatch>; + onSubmitEditing: () => void; +} + +const SearchScrapHeader = ({ + navigateback, + query, + setQuery, + onSubmitEditing, +}: SearchScrapHeaderProps) => { + return ( + + + + {}} + /> + {query.length > 0 && ( + setQuery('')}> + + + )} + + {navigateback.canGoBack() ? ( + navigateback.goBack()} className='p-2'> + + + + + ) : ( + + )} + + + ); +}; + +export default SearchScrapHeader; From 0b2199d9a37b9b85e00916927615d94fed1def96 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:58:21 +0900 Subject: [PATCH 038/140] refactor(ui): improve modal UI --- .../src/features/student/scrap/components/Modal/PopupModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx b/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx index 39f79dc9..bd131adf 100644 --- a/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx @@ -20,7 +20,7 @@ const PopUpModal = ({ setVisibleState(false); }}> setVisibleState(false)}> - + {}}>{children} From 6c6eeb61414956c5b0b99e375ae4c316eb02362e Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:58:39 +0900 Subject: [PATCH 039/140] refactor(scrap): update scrap header props --- .../scrap/components/Header/ScrapHeader.tsx | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) 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 3f9ec26b..ca8f5c18 100644 --- a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -1,13 +1,17 @@ import { Container } from '@/components/common'; import { SafeAreaView } from 'react-native-safe-area-context'; import { View, Text, Pressable } from 'react-native'; -import { ArrowRightLeft, Search, Trash2 } from 'lucide-react-native'; +import { ArrowRightLeft, ChevronLeft, Search, Trash2 } from 'lucide-react-native'; import { CircleCheckDashed } from '@/components/system/icons'; import { State } from '../../utils/reducer'; import { colors } from '@/theme/tokens'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StudentRootStackParamList } from '@/navigation/student/types'; interface ScrapHeaderProps { - ruducerState: State; + navigateback?: NativeStackNavigationProp; + title?: string; + reducerState: State; navigateSearchPress?: () => void; navigateTrashPress?: () => void; onEnterSelection?: () => void; @@ -19,7 +23,9 @@ interface ScrapHeaderProps { } const ScrapHeader = ({ - ruducerState, + navigateback, + title = '스크랩', + reducerState, navigateSearchPress, navigateTrashPress, onEnterSelection, @@ -29,14 +35,21 @@ const ScrapHeader = ({ isAllSelected, onSelectAll, }: ScrapHeaderProps) => { - const isActionEnabled = ruducerState.selectedItems.length > 0; + const isActionEnabled = reducerState.selectedItems.length > 0; return ( - {!ruducerState.isSelecting && ( + className={`bg-${!reducerState.isSelecting ? 'background' : 'gray-200'}`}> + {!reducerState.isSelecting && ( - 스크랩 + {navigateback && navigateback.canGoBack() && ( + navigateback.goBack()} className='p-2'> + + + + + )} + {title} )} - {ruducerState.isSelecting && ( + {reducerState.isSelecting && ( @@ -77,7 +90,7 @@ const ScrapHeader = ({ onPress={() => { if (isActionEnabled && onMove) onMove(); }} - className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${ruducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> 이동하기 @@ -85,7 +98,7 @@ const ScrapHeader = ({ onPress={() => { if (isActionEnabled && onDelete) onDelete(); }} - className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${ruducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> 삭제하기 From 1b2ef378d0ff69c5277b710540b923a87e352634 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 22:59:21 +0900 Subject: [PATCH 040/140] refactor(scrap): improve scrap page implementation --- .../student/scrap/screens/ScrapScreen.tsx | 50 ++++++++++--------- 1 file changed, 27 insertions(+), 23 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index 4b1b40a7..bb8e09b4 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -2,20 +2,20 @@ import { Container, LoadingScreen } from '@/components/common'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import React, { useEffect, useReducer, useState } from 'react'; +import React, { useEffect, useMemo, useReducer, useState } from 'react'; import { View, Text, Pressable } from 'react-native'; -import { reducer, selectState } from '../utils/reducer'; +import { reducer, initialSelectionState } from '../utils/reducer'; import ScrapHeader from '../components/Header/ScrapHeader'; -import { ScrapGrid } from '../components/ScrapItemGrid'; +import { ScrapGrid } from '../components/ScrapCardGrid'; import SortDropdown from '../components/Modal/SortDropdown'; import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; -import { useScrapStore } from '@/stores'; +import { useScrapStore, useTrashStore } from '@/stores'; import { showToast } from '../components/Modal/Toast'; import { folderDummy } from '../utils/testdataset'; const ScrapScreen = () => { - const [reducerState, dispatch] = useReducer(reducer, selectState); + const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); const [fetchloading, setFetchLoading] = useState(false); const [sortKey, setSortKey] = useState('TYPE'); // 기본: 유형순 const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 @@ -24,46 +24,50 @@ const ScrapScreen = () => { const data = useScrapStore((state) => state.data); const setData = useScrapStore((state) => state.setData); const deleteItem = useScrapStore((state) => state.deleteItem); + const addToTrash = useTrashStore((state) => state.addToTrash); - // Fetching Data useEffect(() => { setFetchLoading(true); setTimeout(() => { setData(folderDummy); // 초기값 세팅 setFetchLoading(false); - }, 800); + }, 200); }, []); - useEffect(() => { - setData(sortScrapData(data, sortKey, sortOrder)); - }, [sortKey, sortOrder]); + const sortedData = useMemo( + () => sortScrapData(data, sortKey, sortOrder), + [data, sortKey, sortOrder] + ); - const isallSelected = reducerState.selectedItems.length === data.length; + const isAllSelected = data.length > 0 && reducerState.selectedItems.length === data.length; return ( navigation.push('SearchScrap')} + navigateTrashPress={() => navigation.push('DeletedScrap')} onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} - isAllSelected={isallSelected} + isAllSelected={isAllSelected} onSelectAll={() => { - const allIds = folderDummy.map((item) => item.id); - dispatch({ type: 'SELECT_ALL', allIds: isallSelected ? [] : allIds }); + const allIds = data.map((item) => item.id); + dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); }} onMove={() => { - const selectedSet = new Set(reducerState.selectedItems); - const selectedFolders = folderDummy.filter( - (item) => selectedSet.has(item.id) && item.type === 'FOLDER' + const selectedFolders = data.filter( + (item) => reducerState.selectedItems.includes(item.id) && item.type === 'FOLDER' ); - if (selectedFolders.length > 0) showToast('error', '스크랩만 이동이 가능합니다.'); + if (selectedFolders.length > 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + } }} onDelete={async () => { setFetchLoading(true); try { - await new Promise((resolve) => setTimeout(resolve, 100)); + const items = data.filter((i) => reducerState.selectedItems.includes(i.id)); + addToTrash(items); deleteItem(reducerState.selectedItems); } finally { setFetchLoading(false); @@ -75,7 +79,7 @@ const ScrapScreen = () => { - + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> {sortOrder === 'ASC' ? : } @@ -86,8 +90,8 @@ const ScrapScreen = () => { ) : ( )} From 9481c1260d5a089dae64b8340df8200846d797a0 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:04:45 +0900 Subject: [PATCH 041/140] Temp will deprecated --- .../assets/images/scrap-review-note-cover.png | Bin 0 -> 105545 bytes .../student/scrap/utils/testdataset.ts | 21 ++++++++++-------- 2 files changed, 12 insertions(+), 9 deletions(-) create mode 100644 apps/native/assets/images/scrap-review-note-cover.png diff --git a/apps/native/assets/images/scrap-review-note-cover.png b/apps/native/assets/images/scrap-review-note-cover.png new file mode 100644 index 0000000000000000000000000000000000000000..3c56ad6f40e075c21fb04d892dc6347907d543f4 GIT binary patch literal 105545 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 Math.floor(Math.random() * (max - min + 1)) + min; @@ -6,38 +6,41 @@ const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max const randomTitle = () => '이름' + randomInt(1, 1000); // SCRAP 아이템 랜덤 생성 -const createRandomScrap = (): DummyItem => ({ +const createRandomScrap = ({ + parentFolderId = undefined, +}: { + parentFolderId?: string; +}): ScrapItem => ({ id: randomInt(1000, 9999).toString(), type: 'SCRAP', title: randomTitle() + '스크랩', timestamp: 202511000000 - randomInt(0, 100000), + parentFolderId: parentFolderId, }); // FOLDER 안에 SCRAP 몇 개 넣기 -const createRandomFolder = (id: string, title?: string): DummyItem => { +const createRandomFolder = (id: string, title?: string): ScrapItem => { const count = randomInt(2, 5); // 2~5개 - const contents = Array.from({ length: count }, () => createRandomScrap()); + const contents = Array.from({ length: count }, () => createRandomScrap({ parentFolderId: id })); return { id, type: 'FOLDER', title: title ?? randomTitle() + '폴더', - amount: contents.length, timestamp: 202510000000 - randomInt(0, 100000), contents, }; }; // 데이터 생성 -export const folderDummy: DummyItem[] = [ +export const folderDummy: ScrapItem[] = [ // REVIEW 폴더 고정 { id: 'REVIEW', type: 'FOLDER', title: '오답노트', - amount: 3, timestamp: 202410191130, - contents: Array.from({ length: 3 }, () => createRandomScrap()), + contents: Array.from({ length: 3 }, () => createRandomScrap({ parentFolderId: 'REVIEW' })), }, // 랜덤 FOLDER/SCRAP 12개 ...Array.from({ length: 12 }, (_, i) => { @@ -47,7 +50,7 @@ export const folderDummy: DummyItem[] = [ return createRandomFolder(id); } else { // SCRAP - return createRandomScrap(); + return createRandomScrap({}); } }), ]; From bc141014c2514b46e98586b00a09aed12d1e8d5b Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 16 Dec 2025 23:12:46 +0900 Subject: [PATCH 042/140] fix: correct typos --- .../src/features/student/scrap/components/ScrapCard.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/ScrapCard.tsx b/apps/native/src/features/student/scrap/components/ScrapCard.tsx index 6dca2d2e..6455eff3 100644 --- a/apps/native/src/features/student/scrap/components/ScrapCard.tsx +++ b/apps/native/src/features/student/scrap/components/ScrapCard.tsx @@ -52,7 +52,7 @@ export const ScrapCard = (props: ScrapListItemProps) => { onPress={() => { if (state.isSelecting) return; - if (props.type === 'FOLDER') navigation.push('ScrapContentList', { id: props.id }); + if (props.type === 'FOLDER') navigation.push('ScrapContent', { id: props.id }); }}> @@ -105,7 +105,7 @@ export const SearchResultCard = ({ item, parentFolderName }: SearchResultCardPro { - if (item.type === 'FOLDER') navigation.push('ScrapContentList', { id: item.id }); + if (item.type === 'FOLDER') navigation.push('ScrapContent', { id: item.id }); }}> From 52a5157e97050dbcd1cbbced75e6a835d2bcabe5 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:24:10 +0900 Subject: [PATCH 043/140] feat: add scrap API integration --- .../apis/controller/scrap/deleteEmptyTrash.ts | 15 + .../apis/controller/scrap/deleteFolders.ts | 23 + .../controller/scrap/deleteHandwriting.ts | 20 + .../controller/scrap/deletePermanentTrash.ts | 21 + .../src/apis/controller/scrap/deleteScrap.ts | 26 + .../scrap/deleteUnscrapFromPointing.ts | 26 + .../scrap/deleteUnscrapFromProblem.ts | 26 + .../native/src/apis/controller/scrap/index.ts | 32 + .../apis/controller/scrap/postCreateFolder.ts | 25 + .../apis/controller/scrap/postCreateScrap.ts | 24 + .../scrap/postCreateScrapFromImage.ts | 26 + .../scrap/postCreateScrapFromPointing.ts | 26 + .../scrap/postCreateScrapFromProblem.ts | 26 + .../scrap/postToggleScrapFromPointing.ts | 26 + .../scrap/postToggleScrapFromProblem.ts | 26 + .../apis/controller/scrap/putMoveScraps.ts | 24 + .../apis/controller/scrap/putRestoreTrash.ts | 22 + .../apis/controller/scrap/putUpdateFolder.ts | 33 + .../controller/scrap/putUpdateHandwriting.ts | 36 + .../controller/scrap/putUpdateScrapName.ts | 36 + .../controller/scrap/putUpdateScrapText.ts | 35 + .../apis/controller/scrap/useGetFolders.ts | 16 + .../controller/scrap/useGetHandwriting.ts | 21 + .../controller/scrap/useGetScrapDetail.ts | 21 + .../src/apis/controller/scrap/useGetTrash.ts | 16 + .../apis/controller/scrap/useSearchScraps.ts | 22 + apps/native/src/types/api/schema.d.ts | 4306 +++++++++++++---- 27 files changed, 3915 insertions(+), 1041 deletions(-) create mode 100644 apps/native/src/apis/controller/scrap/deleteEmptyTrash.ts create mode 100644 apps/native/src/apis/controller/scrap/deleteFolders.ts create mode 100644 apps/native/src/apis/controller/scrap/deleteHandwriting.ts create mode 100644 apps/native/src/apis/controller/scrap/deletePermanentTrash.ts create mode 100644 apps/native/src/apis/controller/scrap/deleteScrap.ts create mode 100644 apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts create mode 100644 apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts create mode 100644 apps/native/src/apis/controller/scrap/index.ts create mode 100644 apps/native/src/apis/controller/scrap/postCreateFolder.ts create mode 100644 apps/native/src/apis/controller/scrap/postCreateScrap.ts create mode 100644 apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts create mode 100644 apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts create mode 100644 apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts create mode 100644 apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts create mode 100644 apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts create mode 100644 apps/native/src/apis/controller/scrap/putMoveScraps.ts create mode 100644 apps/native/src/apis/controller/scrap/putRestoreTrash.ts create mode 100644 apps/native/src/apis/controller/scrap/putUpdateFolder.ts create mode 100644 apps/native/src/apis/controller/scrap/putUpdateHandwriting.ts create mode 100644 apps/native/src/apis/controller/scrap/putUpdateScrapName.ts create mode 100644 apps/native/src/apis/controller/scrap/putUpdateScrapText.ts create mode 100644 apps/native/src/apis/controller/scrap/useGetFolders.ts create mode 100644 apps/native/src/apis/controller/scrap/useGetHandwriting.ts create mode 100644 apps/native/src/apis/controller/scrap/useGetScrapDetail.ts create mode 100644 apps/native/src/apis/controller/scrap/useGetTrash.ts create mode 100644 apps/native/src/apis/controller/scrap/useSearchScraps.ts diff --git a/apps/native/src/apis/controller/scrap/deleteEmptyTrash.ts b/apps/native/src/apis/controller/scrap/deleteEmptyTrash.ts new file mode 100644 index 00000000..6f7e09ec --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteEmptyTrash.ts @@ -0,0 +1,15 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; + +export const useEmptyTrash = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (): Promise => { + await client.DELETE('/api/student/scrap/trash/all'); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/deleteFolders.ts b/apps/native/src/apis/controller/scrap/deleteFolders.ts new file mode 100644 index 00000000..3fc4ffb7 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteFolders.ts @@ -0,0 +1,23 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type DeleteFoldersRequest = + paths['/api/student/scrap/folder']['delete']['requestBody']['content']['application/json']; + +export const useDeleteFolders = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: DeleteFoldersRequest): Promise => { + await client.DELETE('/api/student/scrap/folder', { + body: request, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'folders'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/deleteHandwriting.ts b/apps/native/src/apis/controller/scrap/deleteHandwriting.ts new file mode 100644 index 00000000..b8a4fabc --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteHandwriting.ts @@ -0,0 +1,20 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; + +export const useDeleteHandwriting = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (scrapId: number): Promise => { + await client.DELETE('/api/student/scrap/{scrapId}/handwriting', { + params: { + path: { scrapId }, + }, + }); + }, + onSuccess: (_, scrapId) => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'handwriting', scrapId] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'detail', scrapId] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts new file mode 100644 index 00000000..2be6cc12 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts @@ -0,0 +1,21 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type PermanentDeleteRequest = + paths['/api/student/scrap/trash']['delete']['requestBody']['content']['application/json']; + +export const usePermanentDeleteTrash = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: PermanentDeleteRequest): Promise => { + await client.DELETE('/api/student/scrap/trash', { + body: request, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/deleteScrap.ts b/apps/native/src/apis/controller/scrap/deleteScrap.ts new file mode 100644 index 00000000..efc65ceb --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteScrap.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type DeleteScrapRequest = + paths['/api/student/scrap']['delete']['requestBody']['content']['application/json']; + +export const useDeleteScrap = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + request: DeleteScrapRequest + ): Promise<{ success: boolean; request: DeleteScrapRequest }> => { + await client.DELETE('/api/student/scrap', { + body: request, + }); + + return { success: true, request }; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts new file mode 100644 index 00000000..a3f1b772 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UnscrapFromPointingRequest = + paths['/api/student/scrap/from-pointing']['delete']['requestBody']['content']['application/json']; + +/** + * 포인팅에서 스크랩 취소 (다른 포인팅이 없으면 휴지통 처리) + * @description 포인팅 기반 스크랩을 취소하고, 다른 포인팅이 없으면 휴지통으로 이동합니다. + */ +export const useUnscrapFromPointing = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: UnscrapFromPointingRequest): Promise => { + await client.DELETE('/api/student/scrap/from-pointing', { + body: request, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts new file mode 100644 index 00000000..1b3868db --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UnscrapFromProblemRequest = + paths['/api/student/scrap/from-problem']['delete']['requestBody']['content']['application/json']; + +/** + * 문제에서 스크랩 취소 (휴지통 처리) + * @description 문제 기반 스크랩을 취소하고 휴지통으로 이동합니다. + */ +export const useUnscrapFromProblem = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: UnscrapFromProblemRequest): Promise => { + await client.DELETE('/api/student/scrap/from-problem', { + body: request, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/index.ts b/apps/native/src/apis/controller/scrap/index.ts new file mode 100644 index 00000000..577982c2 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/index.ts @@ -0,0 +1,32 @@ +// GET APIs +export * from './useGetScrapDetail'; +export * from './useGetFolders'; +export * from './useGetTrash'; +export * from './useSearchScraps'; +export * from './useGetHandwriting'; + +// POST APIs +export * from './postCreateScrap'; +export * from './postCreateFolder'; +export * from './postCreateScrapFromProblem'; +export * from './postCreateScrapFromPointing'; +export * from './postCreateScrapFromImage'; +export * from './postToggleScrapFromProblem'; +export * from './postToggleScrapFromPointing'; + +// PUT APIs +export * from './putUpdateScrapName'; +export * from './putUpdateScrapText'; +export * from './putUpdateFolder'; +export * from './putMoveScraps'; +export * from './putRestoreTrash'; +export * from './putUpdateHandwriting'; + +// DELETE APIs +export * from './deleteScrap'; +export * from './deleteFolders'; +export * from './deletePermanentTrash'; +export * from './deleteEmptyTrash'; +export * from './deleteHandwriting'; +export * from './deleteUnscrapFromProblem'; +export * from './deleteUnscrapFromPointing'; diff --git a/apps/native/src/apis/controller/scrap/postCreateFolder.ts b/apps/native/src/apis/controller/scrap/postCreateFolder.ts new file mode 100644 index 00000000..35a31fb9 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateFolder.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type CreateFolderRequest = + paths['/api/student/scrap/folder']['post']['requestBody']['content']['application/json']; +type CreateFolderResponse = + paths['/api/student/scrap/folder']['post']['responses']['200']['content']['*/*']; + +export const useCreateFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: CreateFolderRequest): Promise => { + const { data } = await client.POST('/api/student/scrap/folder', { + body: request, + }); + return data as CreateFolderResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'folders'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrap.ts b/apps/native/src/apis/controller/scrap/postCreateScrap.ts new file mode 100644 index 00000000..4d905d4c --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrap.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type CreateScrapRequest = + paths['/api/student/scrap']['post']['requestBody']['content']['application/json']; +type CreateScrapResponse = + paths['/api/student/scrap']['post']['responses']['200']['content']['*/*']; + +export const useCreateScrap = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: CreateScrapRequest): Promise => { + const { data } = await client.POST('/api/student/scrap', { + body: request, + }); + return data as CreateScrapResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts new file mode 100644 index 00000000..faafecb2 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type CreateScrapFromImageRequest = + paths['/api/student/scrap/from-image']['post']['requestBody']['content']['application/json']; +type CreateScrapFromImageResponse = + paths['/api/student/scrap/from-image']['post']['responses']['200']['content']['*/*']; + +export const useCreateScrapFromImage = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + request: CreateScrapFromImageRequest + ): Promise => { + const { data } = await client.POST('/api/student/scrap/from-image', { + body: request, + }); + return data as CreateScrapFromImageResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts new file mode 100644 index 00000000..01e0dc8e --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type CreateScrapFromPointingRequest = + paths['/api/student/scrap/from-pointing']['post']['requestBody']['content']['application/json']; +type CreateScrapFromPointingResponse = + paths['/api/student/scrap/from-pointing']['post']['responses']['200']['content']['*/*']; + +export const useCreateScrapFromPointing = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + request: CreateScrapFromPointingRequest + ): Promise => { + const { data } = await client.POST('/api/student/scrap/from-pointing', { + body: request, + }); + return data as CreateScrapFromPointingResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts new file mode 100644 index 00000000..1c185090 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type CreateScrapFromProblemRequest = + paths['/api/student/scrap/from-problem']['post']['requestBody']['content']['application/json']; +type CreateScrapFromProblemResponse = + paths['/api/student/scrap/from-problem']['post']['responses']['200']['content']['*/*']; + +export const useCreateScrapFromProblem = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + request: CreateScrapFromProblemRequest + ): Promise => { + const { data } = await client.POST('/api/student/scrap/from-problem', { + body: request, + }); + return data as CreateScrapFromProblemResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts new file mode 100644 index 00000000..e5787671 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type ToggleScrapFromPointingRequest = + paths['/api/student/scrap/toggle/from-pointing']['post']['requestBody']['content']['application/json']; +type ToggleScrapFromPointingResponse = + paths['/api/student/scrap/toggle/from-pointing']['post']['responses']['200']['content']['*/*']; + +export const useToggleScrapFromPointing = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + request: ToggleScrapFromPointingRequest + ): Promise => { + const { data } = await client.POST('/api/student/scrap/toggle/from-pointing', { + body: request, + }); + return data as ToggleScrapFromPointingResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts new file mode 100644 index 00000000..cf58cfcb --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type ToggleScrapFromProblemRequest = + paths['/api/student/scrap/toggle/from-problem']['post']['requestBody']['content']['application/json']; +type ToggleScrapFromProblemResponse = + paths['/api/student/scrap/toggle/from-problem']['post']['responses']['200']['content']['*/*']; + +export const useToggleScrapFromProblem = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ( + request: ToggleScrapFromProblemRequest + ): Promise => { + const { data } = await client.POST('/api/student/scrap/toggle/from-problem', { + body: request, + }); + return data as ToggleScrapFromProblemResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/putMoveScraps.ts b/apps/native/src/apis/controller/scrap/putMoveScraps.ts new file mode 100644 index 00000000..b673c639 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putMoveScraps.ts @@ -0,0 +1,24 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type MoveScrapsRequest = + paths['/api/student/scrap/move']['put']['requestBody']['content']['application/json']; +type MoveScrapsResponse = + paths['/api/student/scrap/move']['put']['responses']['200']['content']['*/*']; + +export const useMoveScraps = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: MoveScrapsRequest): Promise => { + const { data } = await client.PUT('/api/student/scrap/move', { + body: request, + }); + return data as MoveScrapsResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/putRestoreTrash.ts b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts new file mode 100644 index 00000000..7bb0dc0b --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts @@ -0,0 +1,22 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type RestoreTrashRequest = + paths['/api/student/scrap/trash/restore']['put']['requestBody']['content']['application/json']; + +export const useRestoreTrash = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: RestoreTrashRequest): Promise => { + await client.PUT('/api/student/scrap/trash/restore', { + body: request, + }); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolder.ts b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts new file mode 100644 index 00000000..36fbb938 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts @@ -0,0 +1,33 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UpdateFolderRequest = + paths['/api/student/scrap/folder/{id}']['put']['requestBody']['content']['application/json']; +type UpdateFolderResponse = + paths['/api/student/scrap/folder/{id}']['put']['responses']['200']['content']['*/*']; + +interface UpdateFolderParams { + id: number; + request: UpdateFolderRequest; +} + +export const useUpdateFolder = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ id, request }: UpdateFolderParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/folder/{id}', { + params: { + path: { id }, + }, + body: request, + }); + return data as UpdateFolderResponse; + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'folders'] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/putUpdateHandwriting.ts b/apps/native/src/apis/controller/scrap/putUpdateHandwriting.ts new file mode 100644 index 00000000..b01b4a19 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateHandwriting.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UpdateHandwritingRequest = + paths['/api/student/scrap/{scrapId}/handwriting']['put']['requestBody']['content']['application/json']; +type UpdateHandwritingResponse = + paths['/api/student/scrap/{scrapId}/handwriting']['put']['responses']['200']['content']['*/*']; + +interface UpdateHandwritingParams { + scrapId: number; + request: UpdateHandwritingRequest; +} + +export const useUpdateHandwriting = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + scrapId, + request, + }: UpdateHandwritingParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/{scrapId}/handwriting', { + params: { + path: { scrapId }, + }, + body: request, + }); + return data as UpdateHandwritingResponse; + }, + onSuccess: (_, { scrapId }) => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'handwriting', scrapId] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'detail', scrapId] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts new file mode 100644 index 00000000..b6ae464b --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts @@ -0,0 +1,36 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UpdateScrapNameRequest = + paths['/api/student/scrap/{scrapId}/name']['put']['requestBody']['content']['application/json']; +type UpdateScrapNameResponse = + paths['/api/student/scrap/{scrapId}/name']['put']['responses']['200']['content']['*/*']; + +interface UpdateScrapNameParams { + scrapId: number; + request: UpdateScrapNameRequest; +} + +export const useUpdateScrapName = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + scrapId, + request, + }: UpdateScrapNameParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/{scrapId}/name', { + params: { + path: { scrapId }, + }, + body: request, + }); + return data as UpdateScrapNameResponse; + }, + onSuccess: (_, { scrapId }) => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'detail', scrapId] }); + queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/putUpdateScrapText.ts b/apps/native/src/apis/controller/scrap/putUpdateScrapText.ts new file mode 100644 index 00000000..87ee3ed7 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateScrapText.ts @@ -0,0 +1,35 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UpdateScrapTextRequest = + paths['/api/student/scrap/{scrapId}/textBox']['put']['requestBody']['content']['application/json']; +type UpdateScrapTextResponse = + paths['/api/student/scrap/{scrapId}/textBox']['put']['responses']['200']['content']['*/*']; + +interface UpdateScrapTextParams { + scrapId: number; + request: UpdateScrapTextRequest; +} + +export const useUpdateScrapText = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + scrapId, + request, + }: UpdateScrapTextParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/{scrapId}/textBox', { + params: { + path: { scrapId }, + }, + body: request, + }); + return data as UpdateScrapTextResponse; + }, + onSuccess: (_, { scrapId }) => { + queryClient.invalidateQueries({ queryKey: ['scrap', 'detail', scrapId] }); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/useGetFolders.ts b/apps/native/src/apis/controller/scrap/useGetFolders.ts new file mode 100644 index 00000000..327277e1 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetFolders.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type GetFoldersResponse = + paths['/api/student/scrap/folder']['get']['responses']['200']['content']['*/*']; + +export const useGetFolders = () => { + return useQuery({ + queryKey: ['scrap', 'folders'], + queryFn: async (): Promise => { + const { data } = await client.GET('/api/student/scrap/folder'); + return data as GetFoldersResponse; + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/useGetHandwriting.ts b/apps/native/src/apis/controller/scrap/useGetHandwriting.ts new file mode 100644 index 00000000..76e9721d --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetHandwriting.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type GetHandwritingResponse = + paths['/api/student/scrap/{scrapId}/handwriting']['get']['responses']['200']['content']['*/*']; + +export const useGetHandwriting = (scrapId: number, enabled = true) => { + return useQuery({ + queryKey: ['scrap', 'handwriting', scrapId], + queryFn: async (): Promise => { + const { data } = await client.GET('/api/student/scrap/{scrapId}/handwriting', { + params: { + path: { scrapId }, + }, + }); + return data as GetHandwritingResponse; + }, + enabled, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts b/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts new file mode 100644 index 00000000..2feec584 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts @@ -0,0 +1,21 @@ +import { useQuery } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type GetScrapDetailResponse = + paths['/api/student/scrap/{id}']['get']['responses']['200']['content']['*/*']; + +export const useGetScrapDetail = (id: number, enabled = true) => { + return useQuery({ + queryKey: ['scrap', 'detail', id], + queryFn: async (): Promise => { + const { data } = await client.GET('/api/student/scrap/{id}', { + params: { + path: { id }, + }, + }); + return data as GetScrapDetailResponse; + }, + enabled, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/useGetTrash.ts b/apps/native/src/apis/controller/scrap/useGetTrash.ts new file mode 100644 index 00000000..38f39b17 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetTrash.ts @@ -0,0 +1,16 @@ +import { useQuery } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type GetTrashResponse = + paths['/api/student/scrap/trash']['get']['responses']['200']['content']['*/*']; + +export const useGetTrash = () => { + return useQuery({ + queryKey: ['scrap', 'trash'], + queryFn: async (): Promise => { + const { data } = await client.GET('/api/student/scrap/trash'); + return data as GetTrashResponse; + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/useSearchScraps.ts b/apps/native/src/apis/controller/scrap/useSearchScraps.ts new file mode 100644 index 00000000..0d2a16c6 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useSearchScraps.ts @@ -0,0 +1,22 @@ +import { useQuery } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type SearchScrapsParams = paths['/api/student/scrap/search/all']['get']['parameters']['query']; +type SearchScrapsResponse = + paths['/api/student/scrap/search/all']['get']['responses']['200']['content']['*/*']; + +export const useSearchScraps = (params: SearchScrapsParams = {}, enabled = true) => { + return useQuery({ + queryKey: ['scrap', 'search', params], + queryFn: async (): Promise => { + const { data } = await client.GET('/api/student/scrap/search/all', { + params: { + query: params, + }, + }); + return data as SearchScrapsResponse; + }, + enabled, + }); +}; diff --git a/apps/native/src/types/api/schema.d.ts b/apps/native/src/types/api/schema.d.ts index 2d827526..9465fe48 100644 --- a/apps/native/src/types/api/schema.d.ts +++ b/apps/native/src/types/api/schema.d.ts @@ -40,6 +40,110 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/scrap/{scrapId}/textBox': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 스크랩 메모 수정 (textBox만) */ + put: operations['updateScrapText']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/{scrapId}/name': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 스크랩 이름 수정 (name만) */ + put: operations['updateScrapName']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/{scrapId}/handwriting': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 필기 데이터 조회 */ + get: operations['getHandwriting']; + /** 필기 데이터 저장/수정 */ + put: operations['updateHandwriting']; + post?: never; + /** 필기 데이터 삭제 */ + delete: operations['deleteHandwriting']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/trash/restore': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 휴지통 복원 (폴더/스크랩 혼합 배치) */ + put: operations['restoreTrash']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/move': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 스크랩 이동 (배치) */ + put: operations['moveScraps']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/folder/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 폴더 수정 */ + put: operations['updateFolder']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/qna/{qnaId}': { parameters: { query?: never; @@ -327,7 +431,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/teacher/auth/refresh': { + '/api/teacher/me/push/token': { parameters: { query?: never; header?: never; @@ -336,15 +440,15 @@ export interface paths { }; get?: never; put?: never; - /** 토큰 갱신 */ - post: operations['refresh']; + /** 푸시 토큰 등록/갱신 */ + post: operations['updatePushToken']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/auth/login/local': { + '/api/teacher/me/push/allow/toggle': { parameters: { query?: never; header?: never; @@ -353,15 +457,15 @@ export interface paths { }; get?: never; put?: never; - /** 이메일 로그인 */ - post: operations['login']; + /** 푸시 알림 허용 토글 */ + post: operations['toggleAllowPush']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/submit/pointing': { + '/api/teacher/auth/refresh': { parameters: { query?: never; header?: never; @@ -370,15 +474,15 @@ export interface paths { }; get?: never; put?: never; - /** 포인팅 피드백(이해했어요/모르겠어요) 제출 */ - post: operations['feedback']; + /** 토큰 갱신 */ + post: operations['refresh']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/submit/answer': { + '/api/teacher/auth/login/local': { parameters: { query?: never; header?: never; @@ -387,33 +491,32 @@ export interface paths { }; get?: never; put?: never; - /** 답안 제출 */ - post: operations['submit']; + /** 이메일 로그인 */ + post: operations['login']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/qna': { + '/api/student/study/submit/pointing': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Q&A 목록 조회 */ - get: operations['gets']; + get?: never; put?: never; - /** Q&A 생성 */ - post: operations['create_1']; + /** 포인팅 피드백(이해했어요/모르겠어요) 제출 */ + post: operations['feedback']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/qna/exist': { + '/api/student/study/submit/answer': { parameters: { query?: never; header?: never; @@ -422,15 +525,15 @@ export interface paths { }; get?: never; put?: never; - /** Q&A 존재 여부 확인, 사용자가 동일한 항목에 대해 QnA 작성 여부 확인 */ - post: operations['checkExists']; + /** 답안 제출 */ + post: operations['submit']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/qna/chat': { + '/api/student/scrap': { parameters: { query?: never; header?: never; @@ -439,15 +542,16 @@ export interface paths { }; get?: never; put?: never; - /** 채팅메시지 생성 */ - post: operations['addChat_1']; - delete?: never; + /** 일반 스크랩 생성(쓸지, 안쓸지 모르겠음) */ + post: operations['createScrap']; + /** 스크랩 삭제 (폴더/스크랩 혼합 배치) */ + delete: operations['deleteScraps']; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/auth/register/social': { + '/api/student/scrap/toggle/from-problem': { parameters: { query?: never; header?: never; @@ -456,15 +560,15 @@ export interface paths { }; get?: never; put?: never; - /** 소셜 로그인 이후, 정보 등록 */ - post: operations['registerSocial']; + /** 문제 기반 스크랩 토글 (있으면 삭제, 없으면 생성) */ + post: operations['toggleScrapFromProblem']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/auth/refresh': { + '/api/student/scrap/toggle/from-pointing': { parameters: { query?: never; header?: never; @@ -473,15 +577,15 @@ export interface paths { }; get?: never; put?: never; - /** 토큰 갱신 */ - post: operations['refresh_1']; + /** 포인팅 기반 스크랩 토글 (있으면 삭제, 없으면 생성) */ + post: operations['toggleScrapFromPointing']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/auth/login/social': { + '/api/student/scrap/from-problem': { parameters: { query?: never; header?: never; @@ -490,15 +594,16 @@ export interface paths { }; get?: never; put?: never; - /** 소셜 로그인 URL 요청 [네이버만 완료] */ - post: operations['getSocialLoginUrl']; - delete?: never; + /** 문제에서 스크랩 (problem + 모든 pointing) */ + post: operations['createScrapFromProblem']; + /** 문제에서 스크랩 취소 (휴지통 처리) */ + delete: operations['unscrapFromProblem']; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/common/upload-file': { + '/api/student/scrap/from-pointing': { parameters: { query?: never; header?: never; @@ -507,15 +612,16 @@ export interface paths { }; get?: never; put?: never; - /** 파일 업로드, (응답의 uploadUrl로 AWS S3 업로드 해야합니다) */ - post: operations['getPreSignedUrl']; - delete?: never; + /** 포인팅에서 스크랩 (problem + 해당 pointing만) */ + post: operations['createScrapFromPointing']; + /** 포인팅에서 스크랩 취소 (다른 포인팅이 없으면 휴지통 처리) */ + delete: operations['unscrapFromPointing']; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/user': { + '/api/student/scrap/from-image': { parameters: { query?: never; header?: never; @@ -524,69 +630,69 @@ export interface paths { }; get?: never; put?: never; - /** 관리자 계정 생성 */ - post: operations['create_2']; + /** 이미지 기반 스크랩 (문제 연결 없이) */ + post: operations['createScrapFromImage']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/teacher': { + '/api/student/scrap/folder': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search']; + /** 폴더 목록 조회 */ + get: operations['getFolders']; put?: never; - /** 생성 */ - post: operations['create_3']; - delete?: never; + /** 폴더 생성 */ + post: operations['createFolder']; + /** 폴더 삭제 */ + delete: operations['deleteFolders']; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/publish': { + '/api/student/qna': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search_1']; + /** Q&A 목록 조회 */ + get: operations['gets']; put?: never; - /** 생성 */ - post: operations['create_4']; + /** Q&A 생성 */ + post: operations['create_1']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/problem': { + '/api/student/qna/exist': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search_2']; + get?: never; put?: never; - /** 단일 생성 */ - post: operations['createProblem']; + /** Q&A 존재 여부 확인, 사용자가 동일한 항목에 대해 QnA 작성 여부 확인 */ + post: operations['checkExists']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/problem/with-child': { + '/api/student/qna/chat': { parameters: { query?: never; header?: never; @@ -595,51 +701,49 @@ export interface paths { }; get?: never; put?: never; - /** 생성(새끼문제, 일반문제 한번에 생성) */ - post: operations['createProblemWithChild']; + /** 채팅메시지 생성 */ + post: operations['addChat_1']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/problem-set': { + '/api/student/notification/read/{notificationId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search_3']; + get?: never; put?: never; - /** 생성 */ - post: operations['create_5']; + /** 알림 읽음 처리 */ + post: operations['readNotification']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/practice-test': { + '/api/student/notification/read-all': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search_4']; + get?: never; put?: never; - /** 생성 */ - post: operations['create_6']; + /** 전체 알림 읽음 처리 */ + post: operations['readAllNotifications']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/ocr': { + '/api/student/me/push/token': { parameters: { query?: never; header?: never; @@ -648,86 +752,83 @@ export interface paths { }; get?: never; put?: never; - post: operations['redirect']; + /** 푸시 토큰 등록/갱신 */ + post: operations['updatePushToken_1']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/notice': { + '/api/student/me/push/allow/toggle': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 별 공지사항 전체 조회 */ - get: operations['getsAll_1']; + get?: never; put?: never; - /** 생성 */ - post: operations['create_7']; + /** 푸시 알림 허용 토글 */ + post: operations['toggleAllowPush_1']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/diagnosis': { + '/api/student/me/password': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 진단 가져오기 */ - get: operations['gets_1']; + get?: never; put?: never; - /** 생성 */ - post: operations['create_8']; + /** 비밀번호 변경 */ + post: operations['changePassword']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/concept': { + '/api/student/auth/signup/local': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 개념 태그 검색 */ - get: operations['search_5']; + get?: never; put?: never; - /** 개념태그 생성 */ - post: operations['create_9']; + /** [학생] 이메일 회원가입 */ + post: operations['signup']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/concept/category': { + '/api/student/auth/register': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 대분류 검색 */ - get: operations['searchCategory']; + get?: never; put?: never; - /** 대분류 생성 */ - post: operations['createCategory']; + /** 초기 정보 등록 */ + post: operations['register']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/auth/refresh': { + '/api/student/auth/refresh': { parameters: { query?: never; header?: never; @@ -737,14 +838,14 @@ export interface paths { get?: never; put?: never; /** 토큰 갱신 */ - post: operations['refresh_2']; + post: operations['refresh_1']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/auth/login/local': { + '/api/student/auth/login/social': { parameters: { query?: never; header?: never; @@ -753,293 +854,374 @@ export interface paths { }; get?: never; put?: never; - /** 이메일 로그인 */ - post: operations['login_1']; + /** 소셜 로그인 URL 요청 [네이버만 완료] */ + post: operations['getSocialLoginUrl']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/your-redirect-url': { + '/api/common/upload-file': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 소셜 로그인 콜백 example */ - get: operations['oauthRedirectExample']; + get?: never; put?: never; - post?: never; + /** 파일 업로드, (응답의 uploadUrl로 AWS S3 업로드 해야합니다) */ + post: operations['getPreSignedUrl']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/json-convert/content': { + '/api/auth/phone/verify': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['content']; + get?: never; put?: never; - post?: never; + /** 휴대폰 인증 코드 검증 */ + post: operations['verify']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/json-convert/childProblem-to-Problem': { + '/api/auth/phone/send': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get: operations['problem']; + get?: never; put?: never; - post?: never; + /** 휴대폰 인증 코드 발송 */ + post: operations['send']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/publish/weekly': { + '/api/auth/phone/resend': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 주간 발행(숙제) 조회 */ - get: operations['search_6']; + get?: never; put?: never; - post?: never; + /** 휴대폰 인증 코드 재발송 */ + post: operations['resend']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/publish/monthly': { + '/api/admin/user': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 월간 발행(숙제) 조회 */ - get: operations['searchMonthly']; + get?: never; put?: never; - post?: never; + /** 관리자 계정 생성 */ + post: operations['create_2']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/publish/detail/{id}': { + '/api/admin/teacher': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 발행(숙제) 상세 조회 */ - get: operations['getPublishById']; + /** 검색 */ + get: operations['search']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_3']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/progress/weekly': { + '/api/admin/school/batch': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 주간 학습 진행 상황 조회 */ - get: operations['getWeeklyProgress']; + get?: never; put?: never; - post?: never; + /** 학교 정보 업로드 batch */ + post: operations['batch']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/problem/{publishId}/{problemId}': { + '/api/admin/publish': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 문제 상세 조회 */ - get: operations['getProblemById']; + /** 검색 */ + get: operations['search_1']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_4']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/child-problem/{publishId}/{problemId}': { + '/api/admin/problem': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 새끼 문제 상세 조회 */ - get: operations['getChildProblemById']; + /** 검색 */ + get: operations['search_2']; put?: never; - post?: never; + /** 단일 생성 */ + post: operations['createProblem']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/students': { + '/api/admin/problem/with-child': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 학생 전체 조회 */ - get: operations['getMyStudents']; + get?: never; put?: never; - post?: never; + /** 생성(새끼문제, 일반문제 한번에 생성) */ + post: operations['createProblemWithChild']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/qna': { + '/api/admin/problem-set': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Q&A 목록 조회 */ - get: operations['gets_2']; + /** 검색 */ + get: operations['search_3']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_5']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/qna/{qnaId}': { + '/api/admin/practice-test': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Q&A 상세 조회 */ - get: operations['getById_2']; + /** 검색 */ + get: operations['search_4']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_6']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/notice/available': { + '/api/admin/ocr': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 별 유효 공지사항(현재 학생이 볼 수 있는 공지사항) 조회 */ - get: operations['getsAvailable']; + get?: never; put?: never; - post?: never; + post: operations['redirect']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/me': { + '/api/admin/notification/send': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 정보 조회 */ - get: operations['getTeacherMe']; + get?: never; put?: never; - post?: never; + /** + * 알림 발송 + * @description isAll=true이면 전체 학생에게 발송, false이면 studentIds에 지정된 학생들에게만 발송 + */ + post: operations['sendNotification']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/publish/weekly': { + '/api/admin/notice': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 주간 발행(숙제) 조회 */ - get: operations['search_7']; + /** 학생 별 공지사항 전체 조회 */ + get: operations['getsAll_1']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_7']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/publish/monthly': { + '/api/admin/diagnosis': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 월간 발행(숙제) 조회 */ - get: operations['searchMonthly_1']; + /** 학생 진단 가져오기 */ + get: operations['gets_1']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_8']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/publish/detail/{id}': { + '/api/admin/concept': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 발행(숙제) 상세 조회 */ - get: operations['getPublishById_1']; + /** 개념 태그 검색 */ + get: operations['search_5']; + put?: never; + /** 개념태그 생성 */ + post: operations['create_9']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/concept/category': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 대분류 검색 */ + get: operations['searchCategory']; + put?: never; + /** 대분류 생성 */ + post: operations['createCategory']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/auth/refresh': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 토큰 갱신 */ + post: operations['refresh_2']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/auth/login/local': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 이메일 로그인 */ + post: operations['login_1']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/your-redirect-url': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 소셜 로그인 콜백 example */ + get: operations['oauthRedirectExample']; put?: never; post?: never; delete?: never; @@ -1048,15 +1230,14 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/study/progress/weekly': { + '/json-convert/content': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 주간 학습 진행 상황 조회 */ - get: operations['getWeeklyProgress_1']; + get: operations['content']; put?: never; post?: never; delete?: never; @@ -1065,15 +1246,14 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/study/problem/{publishId}/{problemId}': { + '/json-convert/childProblem-to-Problem': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 문제 상세 조회 */ - get: operations['getProblemById_1']; + get: operations['problem']; put?: never; post?: never; delete?: never; @@ -1082,15 +1262,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/notice': { + '/api/teacher/study/publish/weekly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 공지사항 목록 조회 */ - get: operations['getsAvailable_1']; + /** 학생 주간 발행(숙제) 조회 */ + get: operations['search_6']; put?: never; post?: never; delete?: never; @@ -1099,15 +1279,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/notice/count': { + '/api/teacher/study/publish/monthly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 읽지 않은 공지사항 개수 조회 */ - get: operations['countAvailable']; + /** 학생 월간 발행(숙제) 조회 */ + get: operations['searchMonthly']; put?: never; post?: never; delete?: never; @@ -1116,15 +1296,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/diagnosis': { + '/api/teacher/study/publish/detail/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 진단 리스트 가져오기 */ - get: operations['gets_3']; + /** 발행(숙제) 상세 조회 */ + get: operations['getPublishById']; put?: never; post?: never; delete?: never; @@ -1133,15 +1313,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/diagnosis/last': { + '/api/teacher/study/progress/weekly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 최신 진단 가져오기 */ - get: operations['getById_3']; + /** 학생 주간 학습 진행 상황 조회 */ + get: operations['getWeeklyProgress']; put?: never; post?: never; delete?: never; @@ -1150,15 +1330,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/diagnosis/detail/{id}': { + '/api/teacher/study/problem/{publishId}/{problemId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 진단 상세보기 */ - get: operations['getById_4']; + /** 문제 상세 조회 */ + get: operations['getProblemById']; put?: never; post?: never; delete?: never; @@ -1167,15 +1347,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/common/auth/refresh': { + '/api/teacher/study/child-problem/{publishId}/{problemId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 토큰 갱신 */ - get: operations['refresh_3']; + /** 새끼 문제 상세 조회 */ + get: operations['getChildProblemById']; put?: never; post?: never; delete?: never; @@ -1184,15 +1364,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/admin/student': { + '/api/teacher/students': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search_8']; + /** 내 학생 전체 조회 */ + get: operations['getMyStudents']; put?: never; post?: never; delete?: never; @@ -1201,33 +1381,32 @@ export interface paths { patch?: never; trace?: never; }; - '/api/admin/publish/{id}': { + '/api/teacher/qna': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 상세 조회 */ - get: operations['getById_5']; + /** Q&A 목록 조회 */ + get: operations['gets_2']; put?: never; post?: never; - /** 삭제 */ - delete: operations['delete_7']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/problem/custom-id/generate': { + '/api/teacher/qna/{qnaId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** unique customId 받기 */ - get: operations['getCustomId']; + /** Q&A 상세 조회 */ + get: operations['getById_2']; put?: never; post?: never; delete?: never; @@ -1236,7 +1415,7 @@ export interface paths { patch?: never; trace?: never; }; - '/api/admin/notice/available': { + '/api/teacher/notice/available': { parameters: { query?: never; header?: never; @@ -1244,7 +1423,7 @@ export interface paths { cookie?: never; }; /** 학생 별 유효 공지사항(현재 학생이 볼 수 있는 공지사항) 조회 */ - get: operations['getsAvailable_2']; + get: operations['getsAvailable']; put?: never; post?: never; delete?: never; @@ -1253,93 +1432,1206 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/auth/quit': { + '/api/teacher/me': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 내 정보 조회 */ + get: operations['getTeacherMe']; put?: never; post?: never; - /** 회원 탈퇴 */ - delete: operations['quit']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/common/upload-file/{id}': { + '/api/student/study/publish/weekly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 주간 발행(숙제) 조회 */ + get: operations['search_7']; put?: never; post?: never; - /** 삭제 */ - delete: operations['delete_8']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/teacher/{teacherId}': { + '/api/student/study/publish/monthly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 월간 발행(숙제) 조회 */ + get: operations['searchMonthly_1']; put?: never; post?: never; - /** 선생님 삭제 */ - delete: operations['delete_9']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/problem-set/{id}/{problemId}': { + '/api/student/study/publish/detail/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 발행(숙제) 상세 조회 */ + get: operations['getPublishById_1']; put?: never; post?: never; - /** 문제 세트에서 문제 삭제 */ - delete: operations['deleteItem']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; -} -export type webhooks = Record; -export interface components { - schemas: { + '/api/student/study/progress/weekly': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 학생 주간 학습 진행 상황 조회 */ + get: operations['getWeeklyProgress_1']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/study/problem/{publishId}/{problemId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 문제 상세 조회 */ + get: operations['getProblemById_1']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 스크랩 상세 조회 */ + get: operations['getScrap']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/trash': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 휴지통 목록 조회 */ + get: operations['getTrash']; + put?: never; + post?: never; + /** 휴지통 영구 삭제 (폴더/스크랩 혼합 배치) */ + delete: operations['permanentDelete']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/search/all': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 검색 (폴더 + 루트 스크랩, 필터/검색/정렬) */ + get: operations['searchScrapsAll']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/school': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 검색 (유사도 상위순 7개 반환) */ + get: operations['search_8']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/notification': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * 알림 목록 조회 + * @description dayLimit 파라미터로 특정 기간 내 알림만 조회 가능 + */ + get: operations['getNotifications']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/notification/count': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 읽지 않은 알림 개수 조회 */ + get: operations['countUnread']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/notice': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 공지사항 목록 조회 */ + get: operations['getsAvailable_1']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/notice/count': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 읽지 않은 공지사항 개수 조회 */ + get: operations['countAvailable']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/diagnosis': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 내 진단 리스트 가져오기 */ + get: operations['gets_3']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/diagnosis/last': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 내 최신 진단 가져오기 */ + get: operations['getById_3']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/diagnosis/detail/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 학생 진단 상세보기 */ + get: operations['getById_4']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/auth/email/exists': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** [학생] 이메일 중복확인 */ + get: operations['existsByEmail']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/exception/throw': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['throwException']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/common/auth/refresh': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 토큰 갱신 */ + get: operations['refresh_3']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/student': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 검색 */ + get: operations['search_9']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/publish/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 상세 조회 */ + get: operations['getById_5']; + put?: never; + post?: never; + /** 삭제 */ + delete: operations['delete_7']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/problem/custom-id/generate': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** unique customId 받기 */ + get: operations['getCustomId']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/notification': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 특정 학생의 알림 목록 조회 */ + get: operations['getNotificationsByStudent']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/notice/available': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 학생 별 유효 공지사항(현재 학생이 볼 수 있는 공지사항) 조회 */ + get: operations['getsAvailable_2']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/trash/all': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** 휴지통 비우기 */ + delete: operations['emptyTrash']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/auth/quit': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** 회원 탈퇴 */ + delete: operations['quit']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/common/upload-file/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** 삭제 */ + delete: operations['delete_8']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/teacher/{teacherId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** 선생님 삭제 */ + delete: operations['delete_9']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/problem-set/{id}/{problemId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** 문제 세트에서 문제 삭제 */ + delete: operations['deleteItem']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; +} +export type webhooks = Record; +export interface components { + schemas: { ChatUpdateRequest: { content: string; images?: number[]; }; - ChatResp: { - /** Format: int64 */ - id: number; - isMine: boolean; - content: string; - images: components['schemas']['UploadFileResp'][]; + ChatResp: { + /** Format: int64 */ + id: number; + isMine: boolean; + content: string; + images: components['schemas']['UploadFileResp'][]; + }; + QnAMetaResp: { + /** Format: int64 */ + id: number; + title: string; + /** @enum {string} */ + type: + | 'PROBLEM_CONTENT' + | 'PROBLEM_POINTING_QUESTION' + | 'PROBLEM_POINTING_COMMENT' + | 'PROBLEM_MAIN_ANALYSIS' + | 'PROBLEM_MAIN_HAND_ANALYSIS' + | 'PROBLEM_READING_TIP_CONTENT' + | 'PROBLEM_ONE_STEP_MORE' + | 'CHILD_PROBLEM_CONTENT' + | 'CHILD_PROBLEM_POINTING_QUESTION' + | 'CHILD_PROBLEM_POINTING_COMMENT'; + /** Format: date */ + publishDate: string; + /** Format: int64 */ + publishId?: number; + /** Format: int32 */ + unreadCount?: number; + studentName?: string; + }; + QnAResp: { + /** Format: int64 */ + id: number; + title: string; + /** @enum {string} */ + type: + | 'PROBLEM_CONTENT' + | 'PROBLEM_POINTING_QUESTION' + | 'PROBLEM_POINTING_COMMENT' + | 'PROBLEM_MAIN_ANALYSIS' + | 'PROBLEM_MAIN_HAND_ANALYSIS' + | 'PROBLEM_READING_TIP_CONTENT' + | 'PROBLEM_ONE_STEP_MORE' + | 'CHILD_PROBLEM_CONTENT' + | 'CHILD_PROBLEM_POINTING_QUESTION' + | 'CHILD_PROBLEM_POINTING_COMMENT'; + /** Format: date */ + publishDate: string; + /** Format: int64 */ + publishId?: number; + /** Format: int32 */ + unreadCount?: number; + studentName?: string; + contentTitle: string; + content: string; + question: string; + images: components['schemas']['UploadFileResp'][]; + /** Format: int64 */ + problemId?: number; + chats: components['schemas']['ChatResp'][]; + }; + UploadFileResp: { + /** Format: int64 */ + id: number; + fileName: string; + url: string; + }; + NoticeUpdateRequest: { + title: string; + /** Format: date */ + startAt: string; + /** Format: date */ + endAt: string; + content: string; + }; + NoticeResp: { + /** Format: int64 */ + id: number; + student: components['schemas']['StudentResp']; + title: string; + /** Format: date */ + startAt: string; + /** Format: date */ + endAt: string; + isRead: boolean; + content: string; + }; + SchoolResp: { + /** Format: int64 */ + id: number; + name?: string; + sido?: string; + }; + StudentResp: { + /** Format: int64 */ + id: number; + isGteFourteen?: boolean; + isAgreeServiceUsage?: boolean; + isAgreePersonalInformation?: boolean; + isAgreeReceiveMarketing?: boolean; + /** Format: date-time */ + agreeAt?: string; + email: string; + name: string; + /** Format: date */ + birth?: string; + /** @enum {string} */ + gender?: 'MALE' | 'FEMALE'; + phoneNumber?: string; + /** @enum {string} */ + mobileCarrier?: 'KT' | 'SKT' | 'LG' | 'KT_MVNO' | 'SKT_MVNO' | 'LG_MVNO'; + /** Format: date-time */ + phoneNumberVerifiedAt?: string; + /** @enum {string} */ + grade: 'ONE' | 'TWO' | 'THREE' | 'N_TIME'; + /** @enum {string} */ + selectSubject?: 'MIJUKBUN' | 'HWAKTONG' | 'KEEHA'; + school?: components['schemas']['SchoolResp']; + /** Format: int32 */ + level?: number; + nickname?: string; + isAllowPush?: boolean; + /** @enum {string} */ + provider?: 'KAKAO' | 'GOOGLE' | 'APPLE'; + isFirstLogin: boolean; + /** Format: int64 */ + teacherId?: number; + teacherName?: string; + }; + ScrapTextBoxUpdateRequest: { + /** + * @description 텍스트 메모 + * @example 메모 수정 + */ + textBox?: string; + }; + ConceptCategoryResp: { + /** Format: int64 */ + id: number; + name: string; + }; + ConceptResp: { + /** Format: int64 */ + id: number; + name: string; + category: components['schemas']['ConceptCategoryResp']; + }; + /** @description 포인팅 목록 */ + PointingResp: { + /** Format: int64 */ + id: number; + /** Format: int32 */ + no: number; + questionContent: string; + commentContent: string; + concepts: components['schemas']['ConceptResp'][]; + }; + PracticeTestResp: { + /** Format: int64 */ + id: number; + /** Format: int32 */ + year: number; + /** Format: int32 */ + month: number; + /** Format: int32 */ + grade: number; + name: string; + displayName: string; + }; + /** @description 문제 정보 */ + ProblemExtendResp: { + /** 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; + }; + ProblemMetaResp: { + /** 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'][]; + }; + /** @description 스크랩 상세 정보 */ + ScrapDetailResp: { + /** + * Format: int64 + * @description 스크랩 ID + */ + id: number; + folder?: components['schemas']['ScrapFolderResp']; + problem?: components['schemas']['ProblemExtendResp']; + /** @description 썸네일 URL */ + thumbnailUrl?: string; + /** @description 스크랩 이름 */ + name?: string; + /** @description 텍스트 메모 */ + textBox?: string; + /** @description 포인팅 목록 */ + pointings: components['schemas']['PointingResp'][]; + /** @description 필기 데이터 존재 여부 */ + hasHandwriting: boolean; + /** + * Format: date-time + * @description 생성일시 + */ + createdAt: string; + /** + * Format: date-time + * @description 수정일시 + */ + updatedAt: string; + }; + /** @description 스크랩 폴더 응답 */ + ScrapFolderResp: { + /** + * Format: int64 + * @description 폴더 ID + */ + id: number; + /** @description 폴더 이름 */ + name: string; + /** + * Format: int32 + * @description 폴더 내 스크랩 개수 + */ + scrapCount: number; + /** + * Format: date-time + * @description 생성일시 + */ + createdAt: string; + }; + ScrapNameUpdateRequest: { + /** + * @description 스크랩 이름 + * @example 미적분 정리 + */ + name?: string; + }; + ScrapHandwritingUpdateRequest: { + /** @description 필기 데이터 (Base64 인코딩) */ + data: string; + }; + /** @description 필기 데이터 응답 */ + ScrapHandwritingResp: { + /** + * Format: int64 + * @description 스크랩 ID + */ + scrapId: number; + /** @description 필기 데이터 (Base64 인코딩) */ + data: string; + /** + * Format: date-time + * @description 수정일시 + */ + updatedAt: string; + }; + /** @description 복원할 항목 목록 (폴더/스크랩 혼합) */ + Item: { + /** + * Format: int64 + * @description 항목 ID + * @example 1 + */ + id: number; + /** + * @description 항목 타입 (FOLDER/SCRAP) + * @example FOLDER + * @enum {string} + */ + type: 'FOLDER' | 'SCRAP'; + }; + ScrapBatchRestoreRequest: { + /** @description 복원할 항목 목록 (폴더/스크랩 혼합) */ + items: components['schemas']['Item'][]; + }; + ScrapBatchMoveRequest: { + /** @description 이동할 스크랩 ID 목록 */ + scrapIds: number[]; + /** + * Format: int64 + * @description 대상 폴더 ID (null이면 루트로 이동) + * @example 2 + */ + targetFolderId?: number; + }; + ListRespScrapDetailResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['ScrapDetailResp'][]; + }; + ScrapFolderUpdateRequest: { + /** + * @description 폴더 이름 + * @example 기하 오답노트 + */ + name: string; + }; + QnAUpdateRequest: { + question: string; + images?: number[]; + }; + StudentUpdateRequest: { + isGteFourteen?: boolean; + isAgreeServiceUsage?: boolean; + isAgreePersonalInformation?: boolean; + isAgreeReceiveMarketing?: boolean; + email?: string; + name?: string; + /** Format: date */ + birth?: string; + /** @enum {string} */ + gender?: 'MALE' | 'FEMALE'; + phoneNumber?: string; + /** @enum {string} */ + mobileCarrier?: 'KT' | 'SKT' | 'LG' | 'KT_MVNO' | 'SKT_MVNO' | 'LG_MVNO'; + /** Format: date-time */ + phoneNumberVerifiedAt?: string; + /** @enum {string} */ + grade?: 'ONE' | 'TWO' | 'THREE' | 'N_TIME'; + /** @enum {string} */ + selectSubject?: 'MIJUKBUN' | 'HWAKTONG' | 'KEEHA'; + /** Format: int64 */ + schoolId?: number; + /** Format: int32 */ + level?: number; + nickname?: string; + }; + TeacherUpdateRequest: { + name: string; + email: string; + newPassword?: string; + }; + TeacherResp: { + /** Format: int64 */ + id: number; + name: string; + email: string; + students: components['schemas']['StudentResp'][]; + isAllowPush?: boolean; + }; + TeacherStudentAssignReq: { + students: number[]; + }; + PointingUpdateRequest: { + /** Format: int64 */ + id?: number; + /** Format: int32 */ + no?: number; + questionContent?: string; + commentContent?: string; + concepts?: number[]; + }; + ProblemUpdateRequest: { + /** @enum {string} */ + createType?: 'GICHUL_PROBLEM' | 'VARIANT_PROBLEM' | 'CREATION_PROBLEM'; + /** Format: int64 */ + practiceTestId?: number; + /** Format: int32 */ + practiceTestNo?: number; + /** Format: int32 */ + no?: number; + title: string; + concepts?: number[]; + /** @enum {string} */ + answerType: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER'; + /** Format: int32 */ + answer: number; + /** Format: int32 */ + difficulty: number; + /** Format: int32 */ + recommendedTimeSec: number; + memo?: string; + problemContent: string; + pointings?: components['schemas']['PointingUpdateRequest'][]; + /** Format: int64 */ + mainAnalysisImageId?: number; + /** Format: int64 */ + mainHandAnalysisImageId?: number; + readingTipContent?: string; + oneStepMoreContent?: string; + }; + ProblemInfoResp: { + /** 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; + pointings: components['schemas']['PointingResp'][]; + childProblems: components['schemas']['ProblemInfoResp'][]; + }; + ProblemSetItemRequest: { + /** Format: int32 */ + no: number; + /** Format: int64 */ + problemId: number; + }; + ProblemSetUpdateRequest: { + title: string; + /** @enum {string} */ + status: 'CONFIRMED' | 'DOING'; + problems?: components['schemas']['ProblemSetItemRequest'][]; + }; + ProblemSetItemResp: { + /** Format: int64 */ + id: number; + /** Format: int32 */ + no: number; + problem: components['schemas']['ProblemMetaResp']; + }; + ProblemSetResp: { + /** Format: int64 */ + id: number; + title: string; + /** @enum {string} */ + status: 'CONFIRMED' | 'DOING'; + firstProblem: components['schemas']['ProblemMetaResp']; + problems: components['schemas']['ProblemSetItemResp'][]; + }; + Request: { + /** Format: int32 */ + year: number; + /** Format: int32 */ + month: number; + /** Format: int32 */ + grade: number; + name: string; + }; + DiagnosisUpdateReq: { + content?: string; + }; + DiagnosisResp: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + studentId?: number; + /** Format: date-time */ + createdAt?: string; + content?: string; + }; + ConceptUpdateRequest: { + name: string; + /** Format: int64 */ + categoryId: number; + }; + ConceptCategoryUpdateRequest: { + name: string; + }; + ChatCreateRequest: { + /** Format: int64 */ + qnaId: number; + content: string; + images?: number[]; + }; + NoticeCreateRequest: { + title: string; + /** Format: date */ + startAt: string; + /** Format: date */ + endAt: string; + content: string; + /** Format: int64 */ + studentId: number; + }; + 'TeacherPushDTO.UpdateTokenRequest': { + fcmToken: string; + }; + RefreshReq: { + refreshToken: string; + }; + JwtResp: { + accessToken: string; + refreshToken?: string; + }; + TeacherTokenResp: { + /** Format: int64 */ + id: number; + name: string; + email: string; + students: components['schemas']['StudentResp'][]; + isAllowPush?: boolean; + token?: components['schemas']['JwtResp']; + }; + TeacherLoginReq: { + email: string; + password: string; + }; + PointingFeedbackRequest: { + /** Format: int64 */ + pointingId: number; + isUnderstood: boolean; + }; + SubmissionRequest: { + /** Format: int64 */ + publishId: number; + /** Format: int64 */ + problemId?: number; + /** Format: int32 */ + submitAnswer?: number; + }; + SubmissionResp: { + /** @enum {string} */ + progress?: 'CORRECT' | 'INCORRECT' | 'SEMI_CORRECT' | 'NONE'; + /** Format: int32 */ + submitAnswer: number; + isCorrect: boolean; + isDone: boolean; + }; + ScrapCreateRequest: { + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + /** + * Format: int64 + * @description 문제 ID (null 가능 - 이미지만 스크랩 시) + * @example 123 + */ + problemId?: number; + /** + * Format: int64 + * @description 썸네일 이미지 ID + * @example 456 + */ + thumbnailImageId?: number; + /** + * @description 텍스트 메모 + * @example 이 문제는 미적분 개념이 핵심 + */ + textBox?: string; + /** + * @description 포인팅 ID 목록 + * @example [ + * 1, + * 2, + * 3 + * ] + */ + pointingIds?: number[]; }; - QnAMetaResp: { + ScrapFromProblemCreateRequest: { + /** + * Format: int64 + * @description 문제 ID + * @example 123 + */ + problemId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + }; + /** @description 스크랩 토글 응답 */ + ScrapToggleResp: { + /** @description 토글 후 스크랩 상태 (true: 스크랩됨, false: 스크랩 취소됨) */ + isScraped: boolean; + scrap?: components['schemas']['ScrapDetailResp']; + }; + ScrapFromPointingCreateRequest: { + /** + * Format: int64 + * @description 포인팅 ID + * @example 456 + */ + pointingId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + }; + ScrapFromImageCreateRequest: { + /** + * Format: int64 + * @description 이미지 ID + * @example 789 + */ + imageId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + /** + * @description 텍스트 메모 + * @example 외부 문제 스크랩 + */ + textBox?: string; + }; + ScrapFolderCreateRequest: { + /** + * @description 폴더 이름 + * @example 수학 오답노트 + */ + name: string; + }; + /** @description problemId, childProblemId, pointingId 중 하나만 입력 가능 */ + QnACreateRequest: { /** Format: int64 */ - id: number; - title: string; + publishId: number; /** @enum {string} */ type: | 'PROBLEM_CONTENT' @@ -1352,18 +2644,16 @@ export interface components { | 'CHILD_PROBLEM_CONTENT' | 'CHILD_PROBLEM_POINTING_QUESTION' | 'CHILD_PROBLEM_POINTING_COMMENT'; - /** Format: date */ - publishDate: string; /** Format: int64 */ - publishId?: number; - /** Format: int32 */ - unreadCount?: number; - studentName?: string; + problemId?: number; + /** Format: int64 */ + pointingId?: number; + question: string; + images?: number[]; }; - QnAResp: { + QnACheckRequest: { /** Format: int64 */ - id: number; - title: string; + publishId: number; /** @enum {string} */ type: | 'PROBLEM_CONTENT' @@ -1376,100 +2666,278 @@ export interface components { | 'CHILD_PROBLEM_CONTENT' | 'CHILD_PROBLEM_POINTING_QUESTION' | 'CHILD_PROBLEM_POINTING_COMMENT'; - /** Format: date */ - publishDate: string; + /** + * Format: int64 + * @description 메인문제ID(메인 문제에 대한 질문일 경우) + */ + problemId?: number; + /** + * Format: int64 + * @description 포인팅ID(포인팅에 대한 질문일 경우) + */ + pointingId?: number; + }; + QnACheckResp: { /** Format: int64 */ - publishId?: number; - /** Format: int32 */ - unreadCount?: number; + id: number; + isExist: boolean; + }; + NotificationResp: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + studentId: number; studentName?: string; - contentTitle: string; - content: string; - question: string; - images: components['schemas']['UploadFileResp'][]; + /** @enum {string} */ + type: 'ASSIGNMENT' | 'SYSTEM' | 'QNA' | 'MARKETING'; + title: string; /** Format: int64 */ - problemId?: number; - chats: components['schemas']['ChatResp'][]; + payload?: number; + url?: string; + isRead: boolean; + /** Format: date-time */ + createdAt: string; }; - UploadFileResp: { + 'StudentPushDTO.UpdateTokenRequest': { + fcmToken: string; + }; + 'StudentPasswordDTO.UpdatePasswordRequest': { + newPassword: string; + }; + StudentSignupReq: { + email: string; + password: string; + }; + StudentTokenResp: { /** Format: int64 */ id: number; - fileName: string; - url: string; - }; - NoticeUpdateRequest: { + isGteFourteen?: boolean; + isAgreeServiceUsage?: boolean; + isAgreePersonalInformation?: boolean; + isAgreeReceiveMarketing?: boolean; + /** Format: date-time */ + agreeAt?: string; + email: string; + name: string; /** Format: date */ - startAt: string; + birth?: string; + /** @enum {string} */ + gender?: 'MALE' | 'FEMALE'; + phoneNumber?: string; + /** @enum {string} */ + mobileCarrier?: 'KT' | 'SKT' | 'LG' | 'KT_MVNO' | 'SKT_MVNO' | 'LG_MVNO'; + /** Format: date-time */ + phoneNumberVerifiedAt?: string; + /** @enum {string} */ + grade: 'ONE' | 'TWO' | 'THREE' | 'N_TIME'; + /** @enum {string} */ + selectSubject?: 'MIJUKBUN' | 'HWAKTONG' | 'KEEHA'; + school?: components['schemas']['SchoolResp']; + /** Format: int32 */ + level?: number; + nickname?: string; + isAllowPush?: boolean; + /** @enum {string} */ + provider?: 'KAKAO' | 'GOOGLE' | 'APPLE'; + isFirstLogin: boolean; + /** Format: int64 */ + teacherId?: number; + teacherName?: string; + token: components['schemas']['JwtResp']; + }; + 'StudentInitialRegisterDTO.Req': { + isGteFourteen: boolean; + isAgreeServiceUsage: boolean; + isAgreePersonalInformation: boolean; + isAgreeReceiveMarketing?: boolean; + email?: string; + name: string; /** Format: date */ - endAt: string; - content: string; + birth?: string; + /** @enum {string} */ + gender?: 'MALE' | 'FEMALE'; + phoneNumber?: string; + /** @enum {string} */ + mobileCarrier?: 'KT' | 'SKT' | 'LG' | 'KT_MVNO' | 'SKT_MVNO' | 'LG_MVNO'; + /** Format: date-time */ + phoneNumberVerifiedAt?: string; + /** @enum {string} */ + grade: 'ONE' | 'TWO' | 'THREE' | 'N_TIME'; + /** @enum {string} */ + selectSubject?: 'MIJUKBUN' | 'HWAKTONG' | 'KEEHA'; + /** Format: int64 */ + schoolId?: number; + /** Format: int32 */ + level?: number; + nickname?: string; }; - NoticeResp: { + SocialLoginReq: { + /** @enum {string} */ + provider: 'KAKAO' | 'GOOGLE' | 'APPLE'; + redirectUri: string; + }; + SocialLoginUrlResp: { + /** @enum {string} */ + provider: 'KAKAO' | 'GOOGLE' | 'APPLE'; + loginUrl: string; + }; + PreSignedReq: { + fileName: string; + }; + PreSignedResp: { + file: components['schemas']['UploadFileResp']; + contentDisposition: string; + uploadUrl: string; + }; + /** @description 휴대폰 인증 코드 검증 요청 */ + PhoneAuthVerifyRequest: { + /** @description 휴대폰 번호 */ + phone: string; + /** @description 인증 용도 (예: signup, reset-password 등) */ + purpose?: string; + /** @description 인증 코드 */ + code: string; + }; + SimpleSuccessResp: { + success?: boolean; + message?: string; + }; + /** @description 휴대폰 인증 코드 발송 요청 */ + PhoneAuthSendRequest: { + /** @description 휴대폰 번호 */ + phone: string; + /** @description 인증 용도 (예: signup, reset-password 등) */ + purpose?: string; + }; + /** @description 휴대폰 인증 코드 재발송 요청 */ + PhoneAuthResendRequest: { + /** @description 휴대폰 번호 */ + phone: string; + /** @description 인증 용도 (예: signup, reset-password 등) */ + purpose?: string; + }; + AdminCreateRequest: { + email: string; + password: string; + }; + TeacherCreateRequest: { + name: string; + email: string; + password: string; + }; + SchoolSaveRespDTO: { + /** Format: int32 */ + count?: number; + }; + PublishCreateRequest: { /** Format: int64 */ - id: number; - student: components['schemas']['StudentResp']; - /** Format: date */ - startAt: string; + problemSetId: number; + /** Format: int64 */ + studentId: number; /** Format: date */ - endAt: string; - isRead: boolean; - content: string; + publishAt: string; }; - StudentResp: { + PointingWithFeedbackResp: { /** Format: int64 */ id: number; - name: string; /** Format: int32 */ - grade: number; - isFirstLogin: boolean; + no: number; + questionContent: string; + commentContent: string; + concepts: components['schemas']['ConceptResp'][]; + isUnderstood?: boolean; }; - QnAUpdateRequest: { - question: string; - images?: number[]; + ProblemRef: { + /** + * Format: int64 + * @description 부모문제인 경우 -> 첫 새끼문제, 새끼문제일 경우 -> 다음 새끼문제 + */ + prevChildId?: number; + /** + * Format: int64 + * @description 새끼문제일 경우에만 다음 새끼문제 + */ + nextChildId?: number; + /** + * Format: int64 + * @description 새끼문제일 경우에만 부모문제Id + */ + parentId?: number; + }; + ProblemWithStudyInfoResp: { + /** Format: int32 */ + no?: number; + /** 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; + studentDisplayParentName?: string; + studentDisplayName?: string; + pointings: components['schemas']['PointingWithFeedbackResp'][]; + /** @enum {string} */ + progress?: 'CORRECT' | 'INCORRECT' | 'SEMI_CORRECT' | 'NONE'; + /** Format: int32 */ + submitAnswer: number; + isCorrect: boolean; + isDone: boolean; + childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; + ref?: components['schemas']['ProblemRef']; }; - StudentUpdateRequest: { - name: string; + PublishProblemGroupResp: { /** Format: int32 */ - grade: number; - }; - TeacherUpdateRequest: { - name: string; - email: string; - newPassword?: string; - }; - TeacherResp: { + no: number; /** Format: int64 */ - id: number; - name: string; - email: string; - students: components['schemas']['StudentResp'][]; - }; - TeacherStudentAssignReq: { - students: number[]; + problemId: number; + /** @enum {string} */ + progress: 'DONE' | 'DOING' | 'NONE'; + problem: components['schemas']['ProblemWithStudyInfoResp']; + childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; }; - 'ChildProblemUpdateDTO.Request': { + PublishResp: { /** Format: int64 */ - id?: number; - /** Format: int32 */ - no?: number; - problemContent?: string; + id: number; + /** Format: date */ + publishAt: string; /** @enum {string} */ - answerType?: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER'; - /** Format: int32 */ - answer?: number; - concepts?: number[]; - pointings?: components['schemas']['PointingUpdateRequest'][]; + progress: 'DONE' | 'DOING' | 'NONE'; + problemSet: components['schemas']['ProblemSetResp']; + data: components['schemas']['PublishProblemGroupResp'][]; }; - PointingUpdateRequest: { - /** Format: int64 */ - id?: number; + PointingCreateRequest: { /** Format: int32 */ no?: number; questionContent?: string; commentContent?: string; concepts?: number[]; }; - ProblemUpdateRequest: { + ProblemCreateRequest: { + /** Format: int64 */ + parentProblemId?: number; /** @enum {string} */ createType?: 'GICHUL_PROBLEM' | 'VARIANT_PROBLEM' | 'CREATION_PROBLEM'; /** Format: int64 */ @@ -1478,6 +2946,8 @@ export interface components { practiceTestNo?: number; /** Format: int32 */ no?: number; + /** @enum {string} */ + problemType: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; title: string; concepts?: number[]; /** @enum {string} */ @@ -1490,623 +2960,1028 @@ export interface components { recommendedTimeSec: number; memo?: string; problemContent: string; - pointings?: components['schemas']['PointingUpdateRequest'][]; + pointings?: components['schemas']['PointingCreateRequest'][]; /** Format: int64 */ mainAnalysisImageId?: number; /** Format: int64 */ mainHandAnalysisImageId?: number; readingTipContent?: string; oneStepMoreContent?: string; - childProblems?: components['schemas']['ChildProblemUpdateDTO.Request'][]; }; - ConceptCategoryResp: { - /** Format: int64 */ - id: number; + ProblemEntireCreateRequest: { + childProblems?: components['schemas']['ProblemCreateRequest'][]; + }; + ProblemSetCreateRequest: { + title: string; + problems?: components['schemas']['ProblemSetItemRequest'][]; + }; + PracticeTestCreateRequest: { + /** Format: int32 */ + year: number; + /** Format: int32 */ + month: number; + /** Format: int32 */ + grade: number; name: string; }; - ConceptResp: { + NotificationSendRequest: { + /** + * @description 알림 종류 + * @enum {string} + */ + type: 'ASSIGNMENT' | 'SYSTEM' | 'QNA' | 'MARKETING'; + /** @description 알림 제목 */ + title: string; + /** + * Format: int64 + * @description 페이로드 (관련 리소스 ID) + */ + payload?: number; + /** @description 앱 딥링크 URL */ + url?: string; + /** @description 전체 학생 발송 여부 */ + isAll: boolean; + /** @description 발송 대상 학생 ID 리스트 (isAll=false일 때 필수) */ + studentIds?: number[]; + }; + NotificationSendResp: { + /** + * Format: int32 + * @description 발송 대상 학생 수 + */ + targetCount: number; + /** + * Format: int32 + * @description 알림 저장 성공 수 + */ + savedCount: number; + /** + * Format: int32 + * @description FCM 발송 시도 수 + */ + fcmAttemptCount: number; + /** @description FCM 지원 여부 */ + fcmSupported: boolean; + }; + DiagnosisCreateReq: { /** Format: int64 */ - id: number; + studentId?: number; + content?: string; + }; + ConceptCreateRequest: { name: string; - category: components['schemas']['ConceptCategoryResp']; + /** Format: int64 */ + categoryId: number; }; - PointingResp: { + ConceptCategoryCreateRequest: { + name: string; + }; + AdminTokenResp: { /** Format: int64 */ id: number; + email: string; + token: components['schemas']['JwtResp']; + }; + AdminLoginReq: { + email: string; + password: string; + }; + ListRespPublishResp: { /** Format: int32 */ - no: number; - questionContent: string; - commentContent: string; - concepts: components['schemas']['ConceptResp'][]; + total: number; + data: components['schemas']['PublishResp'][]; }; - PracticeTestResp: { - /** Format: int64 */ - id: number; + PublishStudentProgressResp: { + /** Format: double */ + progress: number; + }; + ListRespStudentResp: { /** Format: int32 */ - year: number; + total: number; + data: components['schemas']['StudentResp'][]; + }; + PageRespNotListQnAGroupByWeekResp: { /** Format: int32 */ - month: number; + page: number; /** Format: int32 */ - grade: number; + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['QnAGroupByWeekResp']; + }; + QnAGroupByWeekResp: { + groups?: components['schemas']['QnAGroupItem'][]; + }; + QnAGroupItem: { + /** Format: int32 */ + order?: number; + weekName?: string; + data?: components['schemas']['QnAMetaResp'][]; + }; + ListRespNoticeResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['NoticeResp'][]; + }; + ListRespTrashItemResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['TrashItemResp'][]; + }; + /** @description 휴지통 항목 */ + TrashItemResp: { + /** + * Format: int64 + * @description 항목 ID + */ + id: number; + /** + * @description 항목 유형 (FOLDER/SCRAP) + * @enum {string} + */ + type: 'FOLDER' | 'SCRAP'; + /** @description 항목 이름 (폴더명 또는 문제 제목) */ name: string; - displayName: string; + /** + * Format: int32 + * @description 포함된 스크랩 수 (폴더인 경우) + */ + itemCount?: number; + /** + * Format: date-time + * @description 삭제일시 + */ + deletedAt: string; + /** + * Format: int32 + * @description 영구삭제까지 남은 일수 + */ + daysUntilPermanentDelete: number; }; - ProblemInfoResp: { - /** Format: int64 */ + ListRespScrapListItemResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['ScrapListItemResp'][]; + }; + /** @description 스크랩/폴더 목록 아이템 */ + ScrapListItemResp: { + /** + * @description 유형 (FOLDER/SCRAP) + * @enum {string} + */ + type: 'FOLDER' | 'SCRAP'; + /** + * Format: int64 + * @description 아이템 ID + */ id: number; - /** @enum {string} */ - problemType: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; + /** @description 표시 이름 (폴더명 또는 문제 제목) */ + name: string; + /** + * Format: int64 + * @description 폴더 ID (스크랩일 때) + */ + folderId?: number; + /** @description 썸네일 URL (스크랩일 때) */ + thumbnailUrl?: string; + /** + * Format: date-time + * @description 생성일시 + */ + createdAt: string; + }; + ListRespScrapFolderResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['ScrapFolderResp'][]; + }; + ListRespSchoolResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['SchoolResp'][]; + }; + ListRespNotificationResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['NotificationResp'][]; + }; + NotificationUnreadCountResp: { + /** + * Format: int32 + * @description 전체 알림 개수 + */ + totalCount: number; + /** + * Format: int32 + * @description 읽지 않은 알림 개수 + */ + unreadCount: number; + latestNotification?: components['schemas']['NotificationResp']; + }; + NoticeUnreadCountResp: { /** Format: int64 */ - parentProblem?: number; - parentProblemTitle?: string; - customId: string; - /** @enum {string} */ - createType: 'GICHUL_PROBLEM' | 'VARIANT_PROBLEM' | 'CREATION_PROBLEM'; - practiceTest: components['schemas']['PracticeTestResp']; + totalCount?: number; + /** Format: int64 */ + unreadCount?: number; + latestNotice?: components['schemas']['NoticeResp']; + }; + ListRespDiagnosisResp: { /** Format: int32 */ - practiceTestNo: number; - problemContent: string; - title: string; - /** @enum {string} */ - answerType: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER'; + total: number; + data: components['schemas']['DiagnosisResp'][]; + }; + BooleanResp: { + value: boolean; + }; + PageRespTeacherResp: { /** Format: int32 */ - answer: number; + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['TeacherResp'][]; + }; + PageRespStudentResp: { + /** Format: int32 */ + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['StudentResp'][]; + }; + PageRespProblemMetaResp: { + /** Format: int32 */ + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['ProblemMetaResp'][]; + }; + ProblemCustomIdResp: { + customId?: string; + }; + PageRespProblemSetResp: { + /** Format: int32 */ + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['ProblemSetResp'][]; + }; + PageRespPracticeTestResp: { + /** Format: int32 */ + page: number; /** Format: int32 */ - difficulty: number; + size: number; /** Format: int32 */ - recommendedTimeSec: number; - memo: string; - concepts: components['schemas']['ConceptResp'][]; - mainAnalysisImage: components['schemas']['UploadFileResp']; - mainHandAnalysisImage: components['schemas']['UploadFileResp']; - readingTipContent: string; - oneStepMoreContent: string; - pointings: components['schemas']['PointingResp'][]; - childProblems: components['schemas']['ProblemInfoResp'][]; + lastPage: number; + data: components['schemas']['PracticeTestResp'][]; }; - ProblemMetaResp: { - /** 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'; + PageRespConceptResp: { /** Format: int32 */ - answer: number; + page: number; /** Format: int32 */ - difficulty: number; + size: number; /** Format: int32 */ - recommendedTimeSec: number; - memo: string; - concepts: components['schemas']['ConceptResp'][]; + lastPage: number; + data: components['schemas']['ConceptResp'][]; }; - ProblemSetItemRequest: { + PageRespConceptCategoryResp: { /** Format: int32 */ - no: number; - /** Format: int64 */ + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['ConceptCategoryResp'][]; + }; + ScrapBatchDeleteRequest: { + /** @description 삭제할 항목 목록 (폴더/스크랩 혼합) */ + items: components['schemas']['Item'][]; + }; + ScrapBatchPermanentDeleteRequest: { + /** @description 영구 삭제할 항목 목록 (폴더/스크랩 혼합) */ + items: components['schemas']['Item'][]; + }; + UnscrapFromProblemRequest: { + /** + * Format: int64 + * @description 문제 ID + * @example 123 + */ problemId: number; }; - ProblemSetUpdateRequest: { - title: string; - /** @enum {string} */ - status: 'CONFIRMED' | 'DOING'; - problems?: components['schemas']['ProblemSetItemRequest'][]; + UnscrapFromPointingRequest: { + /** + * Format: int64 + * @description 포인팅 ID + * @example 456 + */ + pointingId: number; }; - ProblemSetItemResp: { - /** Format: int64 */ - id: number; - /** Format: int32 */ - no: number; - problem: components['schemas']['ProblemMetaResp']; + }; + responses: never; + parameters: never; + requestBodies: never; + headers: never; + pathItems: never; +} +export type $defs = Record; +export interface operations { + updateChat: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; }; - ProblemSetResp: { - /** Format: int64 */ - id: number; - title: string; - /** @enum {string} */ - status: 'CONFIRMED' | 'DOING'; - firstProblem: components['schemas']['ProblemMetaResp']; - problems: components['schemas']['ProblemSetItemResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['ChatUpdateRequest']; + }; }; - Request: { - /** Format: int32 */ - year: number; - /** Format: int32 */ - month: number; - /** Format: int32 */ - grade: number; - name: string; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - DiagnosisUpdateReq: { - content?: string; + }; + deleteChat: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; }; - DiagnosisResp: { - /** Format: int64 */ - id: number; - /** Format: int64 */ - studentId?: number; - /** Format: date-time */ - createdAt?: string; - content?: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - ConceptUpdateRequest: { - name: string; - /** Format: int64 */ - categoryId: number; + }; + update: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NoticeUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NoticeResp']; + }; + }; + }; + }; + delete: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateScrapText: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapTextBoxUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; + }; + }; + }; + updateScrapName: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapNameUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; + }; + }; + }; + getHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapHandwritingResp']; + }; + }; }; - ConceptCategoryUpdateRequest: { - name: string; + }; + updateHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; }; - ChatCreateRequest: { - /** Format: int64 */ - qnaId: number; - content: string; - images?: number[]; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapHandwritingUpdateRequest']; + }; }; - NoticeCreateRequest: { - /** Format: date */ - startAt: string; - /** Format: date */ - endAt: string; - content: string; - /** Format: int64 */ - studentId: number; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapHandwritingResp']; + }; + }; }; - RefreshReq: { - refreshToken: string; + }; + deleteHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; }; - JwtResp: { - accessToken: string; - refreshToken?: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - TeacherTokenResp: { - /** Format: int64 */ - id: number; - name: string; - email: string; - students: components['schemas']['StudentResp'][]; - token?: components['schemas']['JwtResp']; + }; + restoreTrash: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - TeacherLoginReq: { - email: string; - password: string; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchRestoreRequest']; + }; }; - PointingFeedbackRequest: { - /** Format: int64 */ - pointingId: number; - isUnderstood: boolean; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - SubmissionRequest: { - /** Format: int64 */ - publishId: number; - /** Format: int64 */ - problemId?: number; - /** Format: int32 */ - submitAnswer?: number; + }; + moveScraps: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - SubmissionResp: { - /** @enum {string} */ - progress?: 'CORRECT' | 'INCORRECT' | 'SEMI_CORRECT' | 'NONE'; - /** Format: int32 */ - submitAnswer: number; - isCorrect: boolean; - isDone: boolean; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchMoveRequest']; + }; }; - /** @description problemId, childProblemId, pointingId 중 하나만 입력 가능 */ - QnACreateRequest: { - /** Format: int64 */ - publishId: number; - /** @enum {string} */ - type: - | 'PROBLEM_CONTENT' - | 'PROBLEM_POINTING_QUESTION' - | 'PROBLEM_POINTING_COMMENT' - | 'PROBLEM_MAIN_ANALYSIS' - | 'PROBLEM_MAIN_HAND_ANALYSIS' - | 'PROBLEM_READING_TIP_CONTENT' - | 'PROBLEM_ONE_STEP_MORE' - | 'CHILD_PROBLEM_CONTENT' - | 'CHILD_PROBLEM_POINTING_QUESTION' - | 'CHILD_PROBLEM_POINTING_COMMENT'; - /** Format: int64 */ - problemId?: number; - /** Format: int64 */ - pointingId?: number; - question: string; - images?: number[]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespScrapDetailResp']; + }; + }; }; - QnACheckRequest: { - /** Format: int64 */ - publishId: number; - /** @enum {string} */ - type: - | 'PROBLEM_CONTENT' - | 'PROBLEM_POINTING_QUESTION' - | 'PROBLEM_POINTING_COMMENT' - | 'PROBLEM_MAIN_ANALYSIS' - | 'PROBLEM_MAIN_HAND_ANALYSIS' - | 'PROBLEM_READING_TIP_CONTENT' - | 'PROBLEM_ONE_STEP_MORE' - | 'CHILD_PROBLEM_CONTENT' - | 'CHILD_PROBLEM_POINTING_QUESTION' - | 'CHILD_PROBLEM_POINTING_COMMENT'; - /** - * Format: int64 - * @description 메인문제ID(메인 문제에 대한 질문일 경우) - */ - problemId?: number; - /** - * Format: int64 - * @description 포인팅ID(포인팅에 대한 질문일 경우) - */ - pointingId?: number; + }; + updateFolder: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - QnACheckResp: { - /** Format: int64 */ - id: number; - isExist: boolean; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFolderUpdateRequest']; + }; }; - StudentTokenResp: { - /** Format: int64 */ - id: number; - name: string; - /** Format: int32 */ - grade: number; - isFirstLogin: boolean; - token: components['schemas']['JwtResp']; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapFolderResp']; + }; + }; }; - SocialLoginReq: { - /** @enum {string} */ - provider: 'KAKAO' | 'GOOGLE'; - redirectUri: string; + }; + getById: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; }; - SocialLoginUrlResp: { - /** @enum {string} */ - provider: 'KAKAO' | 'GOOGLE'; - loginUrl: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - PreSignedReq: { - fileName: string; + }; + update_1: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; }; - PreSignedResp: { - file: components['schemas']['UploadFileResp']; - contentDisposition: string; - uploadUrl: string; + requestBody: { + content: { + 'application/json': components['schemas']['QnAUpdateRequest']; + }; }; - AdminCreateRequest: { - email: string; - password: string; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - TeacherCreateRequest: { - name: string; - email: string; - password: string; + }; + delete_1: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; }; - PublishCreateRequest: { - /** Format: int64 */ - problemSetId: number; - /** Format: int64 */ - studentId: number; - /** Format: date */ - publishAt: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - PointingWithFeedbackResp: { - /** Format: int64 */ - id: number; - /** Format: int32 */ - no: number; - questionContent: string; - commentContent: string; - concepts: components['schemas']['ConceptResp'][]; - isUnderstood?: boolean; + }; + updateChat_1: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; }; - ProblemRef: { - /** - * Format: int64 - * @description 부모문제인 경우 -> 첫 새끼문제, 새끼문제일 경우 -> 다음 새끼문제 - */ - prevChildId?: number; - /** - * Format: int64 - * @description 새끼문제일 경우에만 다음 새끼문제 - */ - nextChildId?: number; - /** - * Format: int64 - * @description 새끼문제일 경우에만 부모문제Id - */ - parentId?: number; + requestBody: { + content: { + 'application/json': components['schemas']['ChatUpdateRequest']; + }; }; - ProblemWithStudyInfoResp: { - /** Format: int32 */ - no?: number; - /** 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; - studentDisplayParentName?: string; - studentDisplayName?: string; - pointings: components['schemas']['PointingWithFeedbackResp'][]; - /** @enum {string} */ - progress?: 'CORRECT' | 'INCORRECT' | 'SEMI_CORRECT' | 'NONE'; - /** Format: int32 */ - submitAnswer: number; - isCorrect: boolean; - isDone: boolean; - childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; - ref?: components['schemas']['ProblemRef']; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - PublishProblemGroupResp: { - /** Format: int32 */ - no: number; - /** Format: int64 */ - problemId: number; - /** @enum {string} */ - progress: 'DONE' | 'DOING' | 'NONE'; - problem: components['schemas']['ProblemWithStudyInfoResp']; - childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; + }; + deleteChat_1: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; }; - PublishResp: { - /** Format: int64 */ - id: number; - /** Format: date */ - publishAt: string; - /** @enum {string} */ - progress: 'DONE' | 'DOING' | 'NONE'; - problemSet: components['schemas']['ProblemSetResp']; - data: components['schemas']['PublishProblemGroupResp'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - PointingCreateRequest: { - /** Format: int32 */ - no?: number; - questionContent?: string; - commentContent?: string; - concepts?: number[]; + }; + readNotice: { + parameters: { + query?: never; + header?: never; + path: { + noticeId: number; + }; + cookie?: never; }; - ProblemCreateRequest: { - /** Format: int64 */ - parentProblemId?: number; - /** @enum {string} */ - createType?: 'GICHUL_PROBLEM' | 'VARIANT_PROBLEM' | 'CREATION_PROBLEM'; - /** Format: int64 */ - practiceTestId?: number; - /** Format: int32 */ - practiceTestNo?: number; - /** Format: int32 */ - no?: number; - /** @enum {string} */ - problemType: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; - title: string; - concepts?: number[]; - /** @enum {string} */ - answerType: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER'; - /** Format: int32 */ - answer: number; - /** Format: int32 */ - difficulty: number; - /** Format: int32 */ - recommendedTimeSec: number; - memo?: string; - problemContent: string; - pointings?: components['schemas']['PointingCreateRequest'][]; - /** Format: int64 */ - mainAnalysisImageId?: number; - /** Format: int64 */ - mainHandAnalysisImageId?: number; - readingTipContent?: string; - oneStepMoreContent?: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NoticeResp']; + }; + }; }; - ProblemEntireCreateRequest: { - childProblems?: components['schemas']['ProblemCreateRequest'][]; + }; + me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ProblemSetCreateRequest: { - title: string; - problems?: components['schemas']['ProblemSetItemRequest'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudentResp']; + }; + }; }; - PracticeTestCreateRequest: { - /** Format: int32 */ - year: number; - /** Format: int32 */ - month: number; - /** Format: int32 */ - grade: number; - name: string; + }; + update_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - DiagnosisCreateReq: { - /** Format: int64 */ - studentId?: number; - content?: string; + requestBody: { + content: { + 'application/json': components['schemas']['StudentUpdateRequest']; + }; }; - ConceptCreateRequest: { - name: string; - /** Format: int64 */ - categoryId: number; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudentResp']; + }; + }; }; - ConceptCategoryCreateRequest: { - name: string; + }; + update_3: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - AdminTokenResp: { - /** Format: int64 */ - id: number; - email: string; - token: components['schemas']['JwtResp']; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherUpdateRequest']; + }; }; - AdminLoginReq: { - email: string; - password: string; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; }; - ListRespPublishResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['PublishResp'][]; + }; + assignStudentsToTeacher: { + parameters: { + query?: never; + header?: never; + path: { + teacherId: number; + }; + cookie?: never; }; - PublishStudentProgressResp: { - /** Format: double */ - progress: number; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherStudentAssignReq']; + }; }; - ListRespStudentResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['StudentResp'][]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; }; - PageRespNotListQnAGroupByWeekResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['QnAGroupByWeekResp']; + }; + getProblem: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - QnAGroupByWeekResp: { - groups?: components['schemas']['QnAGroupItem'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; }; - QnAGroupItem: { - /** Format: int32 */ - order?: number; - weekName?: string; - data?: components['schemas']['QnAMetaResp'][]; + }; + updateProblem: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - ListRespNoticeResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['NoticeResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemUpdateRequest']; + }; }; - NoticeUnreadCountResp: { - /** Format: int64 */ - totalCount?: number; - /** Format: int64 */ - unreadCount?: number; - latestNotice?: components['schemas']['NoticeResp']; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; }; - ListRespDiagnosisResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['DiagnosisResp'][]; + }; + deleteProblem: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - PageRespTeacherResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['TeacherResp'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - PageRespStudentResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['StudentResp'][]; + }; + getProblemSet: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - PageRespProblemMetaResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ProblemMetaResp'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemSetResp']; + }; + }; }; - ProblemCustomIdResp: { - customId?: string; + }; + update_4: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - PageRespProblemSetResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ProblemSetResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemSetUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemSetResp']; + }; + }; }; - PageRespPracticeTestResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['PracticeTestResp'][]; + }; + delete_2: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - PageRespConceptResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ConceptResp'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - PageRespConceptCategoryResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ConceptCategoryResp'][]; + }; + toggleStatus: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemSetResp']; + }; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - updateChat: { + update_5: { parameters: { query?: never; header?: never; path: { - chatId: number; + id: number; }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ChatUpdateRequest']; + 'application/json': components['schemas']['Request']; }; }; responses: { @@ -2116,17 +3991,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['PracticeTestResp']; }; }; }; }; - deleteChat: { + delete_3: { parameters: { query?: never; header?: never; path: { - chatId: number; + id: number; }; cookie?: never; }; @@ -2137,13 +4012,11 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['QnAResp']; - }; + content?: never; }; }; }; - update: { + update_6: { parameters: { query?: never; header?: never; @@ -2169,7 +4042,7 @@ export interface operations { }; }; }; - delete: { + delete_4: { parameters: { query?: never; header?: never; @@ -2189,12 +4062,12 @@ export interface operations { }; }; }; - getById: { + getById_1: { parameters: { query?: never; header?: never; path: { - qnaId: number; + id: number; }; cookie?: never; }; @@ -2206,23 +4079,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['DiagnosisResp']; }; }; }; }; - update_1: { + update_7: { parameters: { query?: never; header?: never; path: { - qnaId: number; + id: number; }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['QnAUpdateRequest']; + 'application/json': components['schemas']['DiagnosisUpdateReq']; }; }; responses: { @@ -2232,17 +4105,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['DiagnosisResp']; }; }; }; }; - delete_1: { + delete_5: { parameters: { query?: never; header?: never; path: { - qnaId: number; + id: number; }; cookie?: never; }; @@ -2257,18 +4130,18 @@ export interface operations { }; }; }; - updateChat_1: { + update_8: { parameters: { query?: never; header?: never; path: { - chatId: number; + conceptId: number; }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ChatUpdateRequest']; + 'application/json': components['schemas']['ConceptUpdateRequest']; }; }; responses: { @@ -2278,17 +4151,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ConceptResp']; }; }; }; }; - deleteChat_1: { + delete_6: { parameters: { query?: never; header?: never; path: { - chatId: number; + conceptId: number; }; cookie?: never; }; @@ -2299,22 +4172,24 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['QnAResp']; - }; + content?: never; }; }; }; - readNotice: { + updateCategory: { parameters: { query?: never; header?: never; path: { - noticeId: number; + categoryId: number; }; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ConceptCategoryUpdateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2322,16 +4197,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['ConceptCategoryResp']; }; }; }; }; - me: { + deleteCategory: { parameters: { query?: never; header?: never; - path?: never; + path: { + categoryId: number; + }; cookie?: never; }; requestBody?: never; @@ -2341,13 +4218,11 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['StudentResp']; - }; + content?: never; }; }; }; - update_2: { + addChat: { parameters: { query?: never; header?: never; @@ -2356,7 +4231,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentUpdateRequest']; + 'application/json': components['schemas']['ChatCreateRequest']; }; }; responses: { @@ -2366,23 +4241,43 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - update_3: { + getsAll: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; - path: { - id: number; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespNoticeResp']; + }; }; + }; + }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['TeacherUpdateRequest']; + 'application/json': components['schemas']['NoticeCreateRequest']; }; }; responses: { @@ -2392,23 +4287,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['NoticeResp']; }; }; }; }; - assignStudentsToTeacher: { + updatePushToken: { parameters: { query?: never; header?: never; - path: { - teacherId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['TeacherStudentAssignReq']; + 'application/json': components['schemas']['TeacherPushDTO.UpdateTokenRequest']; }; }; responses: { @@ -2423,13 +4316,11 @@ export interface operations { }; }; }; - getProblem: { + toggleAllowPush: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -2440,23 +4331,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['TeacherResp']; }; }; }; }; - updateProblem: { + refresh: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ProblemUpdateRequest']; + 'application/json': components['schemas']['RefreshReq']; }; }; responses: { @@ -2466,21 +4355,47 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['TeacherTokenResp']; }; }; }; }; - deleteProblem: { + login: { parameters: { query?: never; header?: never; - path: { - id: number; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherLoginReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherTokenResp']; + }; }; + }; + }; + feedback: { + parameters: { + query?: never; + header?: never; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['PointingFeedbackRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2491,16 +4406,18 @@ export interface operations { }; }; }; - getProblemSet: { + submit: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['SubmissionRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2508,23 +4425,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['SubmissionResp']; }; }; }; }; - update_4: { + createScrap: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ProblemSetUpdateRequest']; + 'application/json': components['schemas']['ScrapCreateRequest']; }; }; responses: { @@ -2534,21 +4449,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - delete_2: { + deleteScraps: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchDeleteRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2559,16 +4476,18 @@ export interface operations { }; }; }; - toggleStatus: { + toggleScrapFromProblem: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2576,23 +4495,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - update_5: { + toggleScrapFromPointing: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['Request']; + 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; }; }; responses: { @@ -2602,43 +4519,45 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PracticeTestResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - delete_3: { + createScrapFromProblem: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; }; }; }; - update_6: { + unscrapFromProblem: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['NoticeUpdateRequest']; + 'application/json': components['schemas']['UnscrapFromProblemRequest']; }; }; responses: { @@ -2647,66 +4566,66 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['NoticeResp']; - }; + content?: never; }; }; }; - delete_4: { + createScrapFromPointing: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; }; }; }; - getById_1: { + unscrapFromPointing: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['UnscrapFromPointingRequest']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['DiagnosisResp']; - }; + content?: never; }; }; }; - update_7: { + createScrapFromImage: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['DiagnosisUpdateReq']; + 'application/json': components['schemas']['ScrapFromImageCreateRequest']; }; }; responses: { @@ -2716,18 +4635,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - delete_5: { + getFolders: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -2737,22 +4654,22 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ListRespScrapFolderResp']; + }; }; }; }; - update_8: { + createFolder: { parameters: { query?: never; header?: never; - path: { - conceptId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ConceptUpdateRequest']; + 'application/json': components['schemas']['ScrapFolderCreateRequest']; }; }; responses: { @@ -2762,21 +4679,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptResp']; + '*/*': components['schemas']['ScrapFolderResp']; }; }; }; }; - delete_6: { + deleteFolders: { parameters: { query?: never; header?: never; - path: { - conceptId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': number[]; + }; + }; responses: { /** @description OK */ 200: { @@ -2787,20 +4706,16 @@ export interface operations { }; }; }; - updateCategory: { + gets: { parameters: { - query?: never; - header?: never; - path: { - categoryId: number; + query?: { + query?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ConceptCategoryUpdateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -2808,32 +4723,36 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptCategoryResp']; + '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; }; }; }; }; - deleteCategory: { + create_1: { parameters: { query?: never; header?: never; - path: { - categoryId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['QnACreateRequest']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['QnAResp']; + }; }; }; }; - addChat: { + checkExists: { parameters: { query?: never; header?: never; @@ -2842,7 +4761,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ChatCreateRequest']; + 'application/json': components['schemas']['QnACheckRequest']; }; }; responses: { @@ -2852,21 +4771,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['QnACheckResp']; }; }; }; }; - getsAll: { + addChat_1: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ChatCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2874,23 +4795,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespNoticeResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - create: { + readNotification: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['NoticeCreateRequest']; + path: { + notificationId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -2898,36 +4817,30 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['NotificationResp']; }; }; }; }; - refresh: { + readAllNotifications: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['RefreshReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['TeacherTokenResp']; - }; + content?: never; }; }; }; - login: { + updatePushToken_1: { parameters: { query?: never; header?: never; @@ -2936,7 +4849,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['TeacherLoginReq']; + 'application/json': components['schemas']['StudentPushDTO.UpdateTokenRequest']; }; }; responses: { @@ -2946,34 +4859,32 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherTokenResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - feedback: { + toggleAllowPush_1: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['PointingFeedbackRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['StudentResp']; + }; }; }; }; - submit: { + changePassword: { parameters: { query?: never; header?: never; @@ -2982,7 +4893,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['SubmissionRequest']; + 'application/json': components['schemas']['StudentPasswordDTO.UpdatePasswordRequest']; }; }; responses: { @@ -2992,21 +4903,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SubmissionResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - gets: { + signup: { parameters: { - query?: { - query?: string; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['StudentSignupReq']; + }; + }; responses: { /** @description OK */ 200: { @@ -3014,12 +4927,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; + '*/*': components['schemas']['StudentTokenResp']; }; }; }; }; - create_1: { + register: { parameters: { query?: never; header?: never; @@ -3028,7 +4941,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['QnACreateRequest']; + 'application/json': components['schemas']['StudentInitialRegisterDTO.Req']; }; }; responses: { @@ -3038,12 +4951,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - checkExists: { + refresh_1: { parameters: { query?: never; header?: never; @@ -3052,7 +4965,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['QnACheckRequest']; + 'application/json': components['schemas']['RefreshReq']; }; }; responses: { @@ -3062,12 +4975,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnACheckResp']; + '*/*': components['schemas']['StudentTokenResp']; }; }; }; }; - addChat_1: { + getSocialLoginUrl: { parameters: { query?: never; header?: never; @@ -3076,7 +4989,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ChatCreateRequest']; + 'application/json': components['schemas']['SocialLoginReq']; }; }; responses: { @@ -3086,12 +4999,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['SocialLoginUrlResp']; }; }; }; }; - registerSocial: { + getPreSignedUrl: { parameters: { query?: never; header?: never; @@ -3100,7 +5013,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentUpdateRequest']; + 'application/json': components['schemas']['PreSignedReq']; }; }; responses: { @@ -3110,12 +5023,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['PreSignedResp']; }; }; }; }; - refresh_1: { + verify: { parameters: { query?: never; header?: never; @@ -3124,7 +5037,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['RefreshReq']; + 'application/json': components['schemas']['PhoneAuthVerifyRequest']; }; }; responses: { @@ -3134,12 +5047,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentTokenResp']; + '*/*': components['schemas']['SimpleSuccessResp']; }; }; }; }; - getSocialLoginUrl: { + send: { parameters: { query?: never; header?: never; @@ -3148,7 +5061,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['SocialLoginReq']; + 'application/json': components['schemas']['PhoneAuthSendRequest']; }; }; responses: { @@ -3158,12 +5071,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SocialLoginUrlResp']; + '*/*': components['schemas']['SimpleSuccessResp']; }; }; }; }; - getPreSignedUrl: { + resend: { parameters: { query?: never; header?: never; @@ -3172,7 +5085,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PreSignedReq']; + 'application/json': components['schemas']['PhoneAuthResendRequest']; }; }; responses: { @@ -3182,7 +5095,7 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PreSignedResp']; + '*/*': components['schemas']['SimpleSuccessResp']; }; }; }; @@ -3257,6 +5170,33 @@ export interface operations { }; }; }; + batch: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: { + content: { + 'multipart/form-data': { + /** Format: binary */ + file: string; + }; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SchoolSaveRespDTO']; + }; + }; + }; + }; search_1: { parameters: { query?: { @@ -3504,6 +5444,30 @@ export interface operations { }; }; }; + sendNotification: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['NotificationSendRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NotificationSendResp']; + }; + }; + }; + }; getsAll_1: { parameters: { query: { @@ -4159,6 +6123,183 @@ export interface operations { }; }; }; + getScrap: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; + }; + }; + }; + getTrash: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespTrashItemResp']; + }; + }; + }; + }; + permanentDelete: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchPermanentDeleteRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + searchScrapsAll: { + parameters: { + query?: { + /** + * @description 폴더 ID (null이면 루트 스크랩) + * @example 1 + */ + folderId?: number; + /** + * @description 검색어 (폴더명, 문제 제목) + * @example 미적분 + */ + query?: string; + /** + * @description 필터 (ALL/FOLDER/SCRAP) + * @example ALL + */ + filter?: 'ALL' | 'FOLDER' | 'SCRAP'; + /** + * @description 정렬 필드 (CREATED_AT/NAME) + * @example CREATED_AT + */ + sort?: 'CREATED_AT' | 'NAME'; + /** + * @description 정렬 방향 (ASC/DESC) + * @example DESC + */ + order?: 'ASC' | 'DESC'; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespScrapListItemResp']; + }; + }; + }; + }; + search_8: { + parameters: { + query?: { + query?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespSchoolResp']; + }; + }; + }; + }; + getNotifications: { + parameters: { + query?: { + /** @description 조회 기간 (일 단위, 예: 30이면 30일 전까지) */ + dayLimit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespNotificationResp']; + }; + }; + }; + }; + countUnread: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NotificationUnreadCountResp']; + }; + }; + }; + }; getsAvailable_1: { parameters: { query?: never; @@ -4261,6 +6402,48 @@ export interface operations { }; }; }; + existsByEmail: { + parameters: { + query: { + email: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['BooleanResp']; + }; + }; + }; + }; + throwException: { + parameters: { + query?: { + message?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; refresh_3: { parameters: { query?: never; @@ -4281,7 +6464,7 @@ export interface operations { }; }; }; - search_8: { + search_9: { parameters: { query?: { query?: string; @@ -4367,6 +6550,29 @@ export interface operations { }; }; }; + getNotificationsByStudent: { + parameters: { + query: { + /** @description 학생 ID */ + studentId: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespNotificationResp']; + }; + }; + }; + }; getsAvailable_2: { parameters: { query: { @@ -4389,6 +6595,24 @@ export interface operations { }; }; }; + emptyTrash: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; quit: { parameters: { query?: never; From 899aeecefe020a65ac94f0c1dd22c0bec5a7f330 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:25:14 +0900 Subject: [PATCH 044/140] refactor: separate card component --- .../scrap/components/Card/cards/ScrapCard.tsx | 71 +++++++++++ .../Card/cards/SearchResultCard.tsx | 44 +++++++ .../scrap/components/Card/cards/TrashCard.tsx | 113 ++++++++++++++++++ .../scrap/components/Card/cards/index.ts | 6 + 4 files changed, 234 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx create mode 100644 apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx create mode 100644 apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx create mode 100644 apps/native/src/features/student/scrap/components/Card/cards/index.ts 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 new file mode 100644 index 00000000..0ccd68bc --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx @@ -0,0 +1,71 @@ +import { Pressable, View, Text } from 'react-native'; +import React from 'react'; +import { Check } from 'lucide-react-native'; +import { ChevronDownFilledIcon } from '@/components/system/icons'; +import { TooltipPopover, ItemTooltipBox } from '../../Modal/Tooltip'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation } from '@react-navigation/native'; +import type { ScrapListItemProps } from '../types'; + +export const ScrapCard = (props: ScrapListItemProps) => { + const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; + const isSelected = state.selectedItems.includes(props.id); + const navigation = useNavigation>(); + + const cardContent = ( + + + + {state.isSelecting && ( + + + + )} + + + + + + {props.name} + + {!state.isSelecting && ( + } + children={(close) => } + /> + )} + + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( + {props.scrapCount} + )} + + + + {new Date(props.createdAt).toLocaleDateString()} + + + + ); + + return ( + { + if (state.isSelecting) { + props.onCheckPress?.(); + return; + } + + if (props.type === 'FOLDER') navigation.push('ScrapContent', { id: String(props.id) }); + }}> + {cardContent} + + ); +}; 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 new file mode 100644 index 00000000..fdad748a --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx @@ -0,0 +1,44 @@ +import { Pressable, View, Text } from 'react-native'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { useNavigation } from '@react-navigation/native'; +import type { ScrapItem } from '@/features/student/scrap/utils/types'; + +export interface SearchResultCardProps { + item: ScrapItem; +} + +/** + * 검색 결과 카드 컴포넌트 + */ +export const SearchResultCard = ({ item }: SearchResultCardProps) => { + const navigation = useNavigation>(); + + return ( + { + if (item.type === 'FOLDER') { + navigation.push('ScrapContent', { id: String(item.id) }); + } + // TODO: 스크랩 상세 화면으로 이동 + }}> + + + + + + {item.name} + + + {item.type === 'FOLDER' && 폴더} + + + + {new Date(item.createdAt).toLocaleDateString()} + + + + ); +}; + diff --git a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx new file mode 100644 index 00000000..081dfe12 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -0,0 +1,113 @@ +import { Pressable, View, Text } from 'react-native'; +import React, { useState } from 'react'; +import { Check } from 'lucide-react-native'; +import { TooltipPopover, TrashItemTooltipBox } from '../../Modal/Tooltip'; +import type { TrashItem } from '@/features/student/scrap/utils/types'; +import PopUpModal from '../../Modal/PopupModal'; +import { showToast } from '../../Modal/Toast'; +import { usePermanentDeleteTrash } from '@/apis'; +import type { SelectableUIProps } from '../types'; + +export interface TrashCardProps extends SelectableUIProps { + item: TrashItem; +} + +/** + * 휴지통 카드 컴포넌트 + */ +export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) => { + const state = reducerState ?? { isSelecting: false, selectedItems: [] }; + const isSelected = state.selectedItems.includes(item.id); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); + + const cardContent = ( + + + {state.isSelecting && ( + + + + )} + + + + {item.name} + + + {item.daysUntilPermanentDelete}일 후 영구 삭제 + + + + ); + + return ( + <> + {state.isSelecting ? ( + {cardContent} + ) : ( + ( + { + close(); + setTimeout(() => { + setIsDeleteModalVisible(true); + }, 200); + }} + /> + )} + /> + )} + + + + 스크랩을 영구적으로 삭제합니다. + 되돌릴 수 없는 작업입니다. + + + setIsDeleteModalVisible(false)}> + 취소 + + { + try { + await permanentDelete({ + items: [ + { + id: Number(item.id), + type: item.type as 'FOLDER' | 'SCRAP', + }, + ], + } as any); + setIsDeleteModalVisible(false); + showToast('success', '영구 삭제되었습니다.'); + } catch (error) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }}> + 삭제하기 + + + + + + ); +}; + diff --git a/apps/native/src/features/student/scrap/components/Card/cards/index.ts b/apps/native/src/features/student/scrap/components/Card/cards/index.ts new file mode 100644 index 00000000..878a3244 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/index.ts @@ -0,0 +1,6 @@ +export { ScrapCard } from './ScrapCard'; +export { SearchResultCard } from './SearchResultCard'; +export type { SearchResultCardProps } from './SearchResultCard'; +export { TrashCard } from './TrashCard'; +export type { TrashCardProps } from './TrashCard'; + From 6d0947a67a376cdcb5c22f9d13f3e23631a16646 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:26:53 +0900 Subject: [PATCH 045/140] refactor: separate tooltip component --- .../Modal/Tooltip/AddItemTooltip.tsx | 56 ++++ .../components/Modal/Tooltip/ItemTooltip.tsx | 138 +++++++++ .../Modal/Tooltip/ReviewItemTooltip.tsx | 43 +++ .../Modal/Tooltip/TooltipPopover.tsx | 53 ++++ .../Modal/Tooltip/TrashItemTooltip.tsx | 59 ++++ .../scrap/components/Modal/Tooltip/index.ts | 20 ++ .../scrap/components/Modal/TooltipBox.tsx | 289 ------------------ 7 files changed, 369 insertions(+), 289 deletions(-) create mode 100644 apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/Tooltip/TooltipPopover.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/Tooltip/index.ts delete mode 100644 apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx new file mode 100644 index 00000000..d44c3972 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx @@ -0,0 +1,56 @@ +import { Camera, Image, Images, FolderPlus } from 'lucide-react-native'; +import { View, Text, Pressable } from 'react-native'; +import { openCamera, openImageLibrary } from '../../../utils/imagePicker'; + +export interface AddItemTooltipProps { + onClose?: () => void; + onOpenFolderModal?: () => void; + onOpenQnaImgModal?: () => void; +} + +export const AddItemTooltip = ({ + onClose, + onOpenQnaImgModal, + onOpenFolderModal, +}: AddItemTooltipProps) => { + const onPressCamera = async () => { + const image = await openCamera(); + }; + + const onPressGallery = async () => { + const image = await openImageLibrary(); + }; + + return ( + + onPressCamera()}> + + 사진 찍기 + + onPressGallery()}> + + 이미지 선택 + + { + onOpenQnaImgModal?.(); + }}> + + QnA 사진 불러오기 + + { + onOpenFolderModal?.(); + }}> + + 폴더 추가하기 + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx new file mode 100644 index 00000000..060a7865 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx @@ -0,0 +1,138 @@ +import { colors } from '@/theme/tokens'; +import { FileSymlink, FolderOpen, ImagePlay, Trash2 } from 'lucide-react-native'; +import { useState } from 'react'; +import { TextInput, View, Text, Pressable } from 'react-native'; +import { showToast } from '../Toast'; +import { ScrapListItemProps } from '../../Card/types'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { + useUpdateScrapName, + useUpdateFolder, + useDeleteScrap, + useGetScrapDetail, + useGetFolders, +} from '@/apis'; + +export interface ItemTooltipProps { + props: ScrapListItemProps; + onClose?: () => void; +} + +export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { + const navigation = useNavigation>(); + + // API hooks + const { mutateAsync: updateScrapName } = useUpdateScrapName(); + const { mutateAsync: updateFolder } = useUpdateFolder(); + const { mutateAsync: deleteScrap } = useDeleteScrap(); + + // 스크랩 상세 정보 가져오기 (필요한 경우) + const { data: scrapDetail } = useGetScrapDetail(Number(props.id), props.type === 'SCRAP'); + const { data: foldersData } = useGetFolders(); + + // 초기 제목 설정 + const initialTitle = + props.type === 'SCRAP' + ? scrapDetail?.name || props.name + : foldersData?.data?.find((f) => f.id === props.id)?.name || props.name; + + const [text, setText] = useState(initialTitle); + + const handleClose = () => { + onClose?.(); + }; + + return ( + + + + { + const trimmedText = text.trim(); + if (trimmedText.length > 0 && trimmedText !== initialTitle) { + try { + if (props.type === 'FOLDER') { + await updateFolder({ + id: props.id, + request: { name: trimmedText }, + }); + } else { + await updateScrapName({ + scrapId: props.id, + request: { name: trimmedText }, + }); + } + showToast('success', '이름이 변경되었습니다.'); + } catch (error) { + showToast('error', '이름 변경에 실패했습니다.'); + setText(initialTitle); // 실패시 원래 이름으로 복구 + } + } + }} + /> + + + { + handleClose(); + setTimeout(() => { + if (props.type === 'FOLDER') { + navigation.push('ScrapContent', { id: String(props.id) }); + } else { + // TODO: 스크랩 열기 기능 구현 + showToast('info', '스크랩 열기 기능은 준비 중입니다.'); + } + }, 100); + }}> + + {props.type === 'FOLDER' ? ( + 폴더 열기 + ) : ( + 스크랩 열기 + )} + + + {props.type === 'FOLDER' ? ( + <> + + 표지 변경하기 + + ) : ( + <> + + 폴더 이동하기 + + )} + + { + try { + await deleteScrap({ + items: [ + { + id: props.id, + type: props.type as 'FOLDER' | 'SCRAP', + }, + ], + }); + + handleClose(); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }}> + + 휴지통으로 이동 + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx new file mode 100644 index 00000000..3a6f825e --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx @@ -0,0 +1,43 @@ +import { FolderOpen } from 'lucide-react-native'; +import { View, Text, Pressable } from 'react-native'; +import { ScrapListItemProps } from '../../Card/types'; +import { useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StudentRootStackParamList } from '@/navigation/student/types'; + +export interface ReviewItemTooltipProps { + props: ScrapListItemProps; + onClose?: () => void; +} + +export const ReviewItemTooltip = ({ props, onClose }: ReviewItemTooltipProps) => { + const navigation = useNavigation>(); + + const handleClose = () => { + onClose?.(); + }; + + return ( + + + + + 오답노트 + + + + { + handleClose(); + // Popover가 닫히는 시간을 주기 위해 약간의 지연 + setTimeout(() => { + navigation.push('ScrapContent', { id: String(props.id) }); + }, 100); + }}> + + 오답노트 열기 + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/TooltipPopover.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/TooltipPopover.tsx new file mode 100644 index 00000000..94fd8195 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/TooltipPopover.tsx @@ -0,0 +1,53 @@ +import { colors } from '@/theme/tokens'; +import React from 'react'; +import { Pressable, ViewStyle } from 'react-native'; +import Popover from 'react-native-popover-view'; +import { Placement } from 'react-native-popover-view/dist/Types'; + +export interface TooltipPopoverProps { + from: React.ReactNode; + children: React.ReactNode | ((close: () => void) => React.ReactNode); + placement?: Placement; + popoverStyle?: ViewStyle; +} + +const TooltipPopover = ({ + from, + children, + placement = Placement.AUTO, + popoverStyle, +}: TooltipPopoverProps) => { + const [isVisible, setIsVisible] = React.useState(false); + + const close = () => { + setIsVisible(false); + }; + + // from을 Pressable로 감싸서 클릭 시 열리도록 함 + const triggerElement = setIsVisible(true)}>{from}; + + return ( + + {typeof children === 'function' ? children(close) : children} + + ); +}; + +export default TooltipPopover; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx new file mode 100644 index 00000000..103e4155 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx @@ -0,0 +1,59 @@ +import { colors } from '@/theme/tokens'; +import { Trash2, Undo2 } from 'lucide-react-native'; +import { View, Text, Pressable } from 'react-native'; +import { showToast } from '../Toast'; +import { TrashItem } from '@/features/student/scrap/utils/types'; +import { useRestoreTrash } from '@/apis'; + +export interface TrashItemTooltipProps { + item: TrashItem; + onClose?: () => void; + onDeletePress?: () => void; +} + +export const TrashItemTooltip = ({ item, onClose, onDeletePress }: TrashItemTooltipProps) => { + const { mutateAsync: restoreTrash } = useRestoreTrash(); + + const handleClose = () => { + onClose?.(); + }; + + return ( + + { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (onDeletePress) { + onDeletePress(); + } else { + handleClose(); + } + }}> + + 영구 삭제하기 + + { + try { + await restoreTrash({ + items: [ + { + id: item.id, + type: item.type as 'FOLDER' | 'SCRAP', + }, + ], + } as any); + handleClose(); + showToast('success', '선택된 파일이 복구되었습니다.'); + } catch (error) { + showToast('error', '복구 중 오류가 발생했습니다.'); + } + }}> + + 복구하기 + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/index.ts b/apps/native/src/features/student/scrap/components/Modal/Tooltip/index.ts new file mode 100644 index 00000000..781848c1 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/index.ts @@ -0,0 +1,20 @@ +export { default as TooltipPopover } from './TooltipPopover'; +export type { TooltipPopoverProps } from './TooltipPopover'; + +export { ItemTooltip } from './ItemTooltip'; +export type { ItemTooltipProps } from './ItemTooltip'; + +export { AddItemTooltip } from './AddItemTooltip'; +export type { AddItemTooltipProps } from './AddItemTooltip'; + +export { ReviewItemTooltip } from './ReviewItemTooltip'; +export type { ReviewItemTooltipProps } from './ReviewItemTooltip'; + +export { TrashItemTooltip } from './TrashItemTooltip'; +export type { TrashItemTooltipProps } from './TrashItemTooltip'; + +// 하위 호환성을 위한 별칭 export +export { ItemTooltip as ItemTooltipBox } from './ItemTooltip'; +export { AddItemTooltip as AddItemTooltipBox } from './AddItemTooltip'; +export { ReviewItemTooltip as ReviewItemTooltipBox } from './ReviewItemTooltip'; +export { TrashItemTooltip as TrashItemTooltipBox } from './TrashItemTooltip'; diff --git a/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx b/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx deleted file mode 100644 index 942729be..00000000 --- a/apps/native/src/features/student/scrap/components/Modal/TooltipBox.tsx +++ /dev/null @@ -1,289 +0,0 @@ -import { useScrapStore, useTrashStore } from '@/stores/scrapDataStore'; -import { colors } from '@/theme/tokens'; -import { - Camera, - FileSymlink, - FolderOpen, - ImagePlay, - Trash2, - Image, - Images, - FolderPlus, - Undo2, -} from 'lucide-react-native'; -import { useState } from 'react'; -import { TextInput, View, Text, Pressable } from 'react-native'; -import { showToast } from './Toast'; -import React from 'react'; -import Popover from 'react-native-popover-view'; -import { ViewStyle } from 'react-native'; -import { Placement } from 'react-native-popover-view/dist/Types'; -import { ScrapListItemProps } from '../ScrapCard'; -import { ScrapItem, TrashItem } from '@/types/test/types'; -import { findItem } from '../../utils/itemHelpers'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { StudentRootStackParamList } from '@/navigation/student/types'; - -interface TooltipPopoverProps { - from: React.ReactNode; - children: React.ReactNode | ((close: () => void) => React.ReactNode); - placement?: Placement; - popoverStyle?: ViewStyle; -} - -const TooltipPopover = ({ - from, - children, - placement = Placement.AUTO, - popoverStyle, -}: TooltipPopoverProps) => { - const [isVisible, setIsVisible] = React.useState(false); - - const close = () => { - setIsVisible(false); - }; - - // from을 Pressable로 감싸서 클릭 시 열리도록 함 - const triggerElement = setIsVisible(true)}>{from}; - - return ( - - {typeof children === 'function' ? children(close) : children} - - ); -}; - -export default TooltipPopover; - -export const ItemTooltipBox = ({ - props, - onClose, -}: { - props: ScrapListItemProps; - onClose?: () => void; -}) => { - const data = useScrapStore((state) => state.data); - const updateItem = useScrapStore((state) => state.updateItem); - const deleteItem = useScrapStore((state) => state.deleteItem); - const addToTrash = useTrashStore((state) => state.addToTrash); - const navigation = useNavigation>(); - - const item = findItem(data, props.id, props.type === 'SCRAP' ? props.parentFolderId : undefined); - - const [text, setText] = useState(item?.title ?? ''); - - const handleClose = () => { - onClose?.(); - }; - - return ( - - - - { - const trimmedText = text.trim(); - if (trimmedText.length > 0) { - updateItem( - props.id, - trimmedText, - props.type === 'SCRAP' ? props.parentFolderId : undefined - ); - } - }} - /> - - - { - handleClose(); - // Popover가 닫히는 시간을 주기 위해 약간의 지연 - setTimeout(() => { - if (props.type === 'FOLDER') { - navigation.push('ScrapContentList', { id: props.id }); - } else { - // TODO: 스크랩 열기 기능 구현 - showToast('info', '스크랩 열기 기능은 준비 중입니다.'); - } - }, 100); - }}> - - {props.type === 'FOLDER' ? ( - 폴더 열기 - ) : ( - 스크랩 열기 - )} - - - {props.type === 'FOLDER' ? ( - <> - - 표지 변경하기 - - ) : ( - <> - - 폴더 이동하기 - - )} - - { - try { - await new Promise((resolve) => setTimeout(resolve, 100)); - if (!item) { - showToast('error', '아이템을 찾을 수 없습니다.'); - return; - } - - const parentFolderId = item.type === 'SCRAP' ? item.parentFolderId : undefined; - deleteItem(item.id, parentFolderId); - addToTrash(item); - handleClose(); - showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); - } catch (error) { - showToast('error', '삭제 중 오류가 발생했습니다.'); - } - }}> - - 휴지통으로 이동 - - - ); -}; - -export const AddItemTooltipBox = () => { - return ( - - - - 사진 찍기 - - - - 이미지 선택 - - - - QnA 사진 불러오기 - - - - 폴더 추가하기 - - - ); -}; - -export const ReviewItemTooltipBox = ({ - props, - onClose, -}: { - props: ScrapListItemProps; - onClose?: () => void; -}) => { - const navigation = useNavigation>(); - - const handleClose = () => { - onClose?.(); - }; - - return ( - - - - - 오답노트 - - - - { - handleClose(); - // Popover가 닫히는 시간을 주기 위해 약간의 지연 - setTimeout(() => { - navigation.push('ScrapContentList', { id: props.id }); - }, 100); - }}> - - 오답노트 열기 - - - ); -}; - -export const TrashItemTooltipBox = ({ - item, - onClose, - onDeletePress, -}: { - item: TrashItem; - onClose?: () => void; - onDeletePress?: () => void; -}) => { - const restoreFromTrash = useTrashStore((state) => state.restoreFromTrash); - const restoreToScrap = useScrapStore((state) => state.restoreItem); - - const handleClose = () => { - onClose?.(); - }; - - return ( - - { - await new Promise((resolve) => setTimeout(resolve, 100)); - if (onDeletePress) { - onDeletePress(); - } else { - handleClose(); - } - }}> - - 영구 삭제하기 - - { - try { - await new Promise((resolve) => setTimeout(resolve, 100)); - restoreToScrap(item); - restoreFromTrash(item.id); - handleClose(); - showToast('success', '선택된 파일이 복구되었습니다.'); - } catch (error) { - showToast('error', '복구 중 오류가 발생했습니다.'); - } - }}> - - 복구하기 - - - ); -}; From dab57322681535afa9f1153beb458d7dd538294f Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:28:16 +0900 Subject: [PATCH 046/140] feat: update header back navigation UI --- .../student/scrap/components/Header/DeletedHeader.tsx | 4 +++- .../features/student/scrap/components/Header/ScrapHeader.tsx | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx b/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx index d4d91b91..23e6a107 100644 --- a/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx @@ -41,7 +41,9 @@ const DeletedScrapHeader = ({ {!reducerState.isSelecting && ( {navigateback.canGoBack() ? ( - navigateback.goBack()} className='p-2'> + navigateback.goBack()} + className='p-2 md:right-[48px] lg:right-[96px]'> 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 ca8f5c18..0e43147c 100644 --- a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -43,7 +43,9 @@ const ScrapHeader = ({ {!reducerState.isSelecting && ( {navigateback && navigateback.canGoBack() && ( - navigateback.goBack()} className='p-2'> + navigateback.goBack()} + className='p-2 md:right-[48px] lg:right-[96px]'> From f9b4fbb04f12ea063a6e03485405cdb8f3a4d3e1 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:29:13 +0900 Subject: [PATCH 047/140] refactor: separate scrap card component --- .../components/{ => Card}/ScrapCardGrid.tsx | 47 ++-- .../scrap/components/Card/ScrapHeadCard.tsx | 185 +++++++++++++++ .../student/scrap/components/Card/index.ts | 23 ++ .../student/scrap/components/ScrapCard.tsx | 223 ------------------ .../scrap/components/ScrapHeadCard.tsx | 62 ----- 5 files changed, 226 insertions(+), 314 deletions(-) rename apps/native/src/features/student/scrap/components/{ => Card}/ScrapCardGrid.tsx (81%) create mode 100644 apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx create mode 100644 apps/native/src/features/student/scrap/components/Card/index.ts delete mode 100644 apps/native/src/features/student/scrap/components/ScrapCard.tsx delete mode 100644 apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx diff --git a/apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx similarity index 81% rename from apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx rename to apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx index 0f7b846f..c3b4edfe 100644 --- a/apps/native/src/features/student/scrap/components/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -1,9 +1,11 @@ import { FlatList, View } from 'react-native'; -import { Action, State } from '../utils/reducer'; -import { ScrapCard, SearchResultCard, TrashCard } from './ScrapCard'; +import { Action, State } from '../../utils/reducer'; +import { ScrapCard } from './cards/ScrapCard'; +import { SearchResultCard } from './cards/SearchResultCard'; +import { TrashCard } from './cards/TrashCard'; import { ScrapAddItem, ScrapReviewItem } from './ScrapHeadCard'; -import { ScrapItem, TrashItem } from '@/types/test/types'; -import { useGridLayout } from '../utils/gridLayout'; +import { ScrapItem, TrashItem } from '@/features/student/scrap/utils/types'; +import { useGridLayout } from '../../utils/gridLayout'; /** * ADD item type for ScrapGrid @@ -78,27 +80,12 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { const scrapItem = item as ScrapItem; - // Handle REVIEW item - if (scrapItem.id === 'REVIEW') { - return ( - - - - ); - } - // Handle regular scrap items - // Convert ScrapItem to ScrapListItemProps format + // ScrapItem from API already has the correct structure return ( dispatch?.({ type: 'SELECTING_ITEM', id: scrapItem.id })} /> @@ -109,17 +96,19 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { ); }; +/** + * 검색 결과 그리드 Props + */ interface SearchScrapGridProps { - data: Array<{ item: ScrapItem; parentFolderName?: string }>; + data: ScrapItem[]; } + +/** + * 검색 결과 그리드 컴포넌트 + */ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { const { numColumns, gap } = useGridLayout(); - const mappedData = data.map((item) => ({ - ...item.item, - parentFolderName: item.parentFolderName, - })); - - const finalData = addPlaceholders(mappedData, numColumns); + const finalData = addPlaceholders(data, numColumns); return ( { return ; } - const scrapItem = item as ScrapItem & { parentFolderName?: string }; + const scrapItem = item as ScrapItem; return ( - + ); }} diff --git a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx new file mode 100644 index 00000000..cae6cfd9 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx @@ -0,0 +1,185 @@ +import { colors } from '@/theme/tokens'; +import { Plus } from 'lucide-react-native'; +import { Pressable, View, Text, Image } from 'react-native'; +import { TooltipPopover, AddItemTooltipBox, ReviewItemTooltipBox } from '../Modal/Tooltip'; +import { Placement } from 'react-native-popover-view/dist/Types'; +import { 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 { AddFolderScreenModal, LoadQnaImageScreenModal } from '../Modal/FullScreenModal'; +import { ScrollView, TextInput } from 'react-native'; +import { showToast } from '../Modal/Toast'; +import { openImageLibrary } from '../../utils/imagePicker'; +import type { UISortKey, SortOrder } from '../../utils/types'; +import { Container } from '@/components/common'; +import SortDropdown from '../Modal/SortDropdown'; +import { useCreateFolder } from '@/apis'; + +export const ScrapAddItem = () => { + const [isFolderModalVisible, setIsFolderModalVisible] = useState(false); + const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); + + const [folderName, setFolderName] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const { mutateAsync: createFolder } = useCreateFolder(); + + const [sortKey, setSortKey] = useState('DATE'); + const [sortOrder, setSortOrder] = useState('DESC'); + + const onPressGallery = async () => { + const image = await openImageLibrary(); + if (image) { + setSelectedImage(image.uri); + } + }; + + const handleFolderAdd = async () => { + if (folderName.trim()) { + try { + await createFolder({ name: folderName }); + setFolderName(''); + setIsFolderModalVisible(false); + setTimeout(() => { + showToast('success', '폴더가 추가되었습니다.'); + }, 300); + } catch (error) { + showToast('error', '폴더 추가에 실패했습니다.'); + } + } else { + showToast('error', '폴더 이름을 입력해주세요.'); + } + }; + + return ( + <> + ( + { + close(); + setTimeout(() => { + setIsFolderModalVisible(true); + }, 200); + }} + onOpenQnaImgModal={() => { + close(); + setTimeout(() => { + setisQnaImageModalVisible(true); + }, 200); + }} + /> + )} + from={ + + + + + + 추가하기 + + + } + /> + { + setFolderName(''); + setIsFolderModalVisible(false); + }} + onClose={() => { + handleFolderAdd(); + }}> + + + onPressGallery()}> + {selectedImage ? ( + + ) : ( + + )} + + + + + + + + { + setisQnaImageModalVisible(false); + }} + onClose={() => { + setisQnaImageModalVisible(false); + }}> + + + + + + ); +}; + +export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { + const navigation = useNavigation>(); + + return ( + navigation.push('ScrapContent', { id: String(props.id) })}> + + + + + + + {props.name} + + } + from={} + /> + + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( + {props.scrapCount} + )} + + + {new Date(props.createdAt).toLocaleDateString()} + + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Card/index.ts b/apps/native/src/features/student/scrap/components/Card/index.ts new file mode 100644 index 00000000..c71f8118 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/index.ts @@ -0,0 +1,23 @@ +// Types +export type { + BaseItemUIProps, + SelectableUIProps, + ScrapCardProps, + FolderCardProps, + ScrapListItemProps, +} from './types'; + +// Cards +export { ScrapCard } from './cards/ScrapCard'; +export { SearchResultCard } from './cards/SearchResultCard'; +export type { SearchResultCardProps } from './cards/SearchResultCard'; +export { TrashCard } from './cards/TrashCard'; +export type { TrashCardProps } from './cards/TrashCard'; + +// Grids +export { ScrapGrid, SearchScrapGrid, TrashScrapGrid } from './ScrapCardGrid'; +export type { ScrapGridItem } from './ScrapCardGrid'; + +// Head Cards +export { ScrapAddItem, ScrapReviewItem } from './ScrapHeadCard'; + diff --git a/apps/native/src/features/student/scrap/components/ScrapCard.tsx b/apps/native/src/features/student/scrap/components/ScrapCard.tsx deleted file mode 100644 index 6455eff3..00000000 --- a/apps/native/src/features/student/scrap/components/ScrapCard.tsx +++ /dev/null @@ -1,223 +0,0 @@ -import { Pressable, View, Text } from 'react-native'; -import { Action, State } from '../utils/reducer'; -import React, { useState } from 'react'; -import { Check } from 'lucide-react-native'; -import { ChevronDownFilledIcon } from '@/components/system/icons'; -import TooltipPopover, { ItemTooltipBox, TrashItemTooltipBox } from './Modal/TooltipBox'; -import { TrashItem, ScrapItem } from '@/types/test/types'; -import { StudentRootStackParamList } from '@/navigation/student/types'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useNavigation } from '@react-navigation/native'; -import PopUpModal from './Modal/PopupModal'; -import { useTrashStore } from '@/stores/scrapDataStore'; -import { showToast } from './Modal/Toast'; - -export interface BaseItemUIProps { - id: string; - title: string; - timestamp: string; -} - -export interface SelectableUIProps { - reducerState?: State; - dispatch?: React.Dispatch; - onCheckPress?: () => void; -} - -export interface ScrapCardProps extends BaseItemUIProps, SelectableUIProps { - type: 'SCRAP'; - parentFolderName?: string; - parentFolderId?: string; -} - -export interface FolderCardProps extends BaseItemUIProps, SelectableUIProps { - type: 'FOLDER'; - contents: ScrapItem[]; // 실제 데이터 구조와 일치: 폴더와 스크랩 모두 포함 -} - -export interface ReviewFolderCardProps extends FolderCardProps { - id: 'REVIEW'; -} - -export type ScrapListItemProps = ScrapCardProps | FolderCardProps | ReviewFolderCardProps; - -export const ScrapCard = (props: ScrapListItemProps) => { - const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; - const isSelected = state.selectedItems.includes(props.id); - const navigation = useNavigation>(); - - return ( - { - if (state.isSelecting) return; - - if (props.type === 'FOLDER') navigation.push('ScrapContent', { id: props.id }); - }}> - - - {state.isSelecting && ( - - - - )} - - - - - - {props.title} - - {!state.isSelecting && ( - } - children={(close) => } - /> - )} - - {props.type === 'FOLDER' && ( - {props.contents.length} - )} - - - {props.timestamp} - - - ); -}; - -export interface SearchResultCardProps { - item: ScrapItem; - parentFolderName?: string; -} - -export const SearchResultCard = ({ item, parentFolderName }: SearchResultCardProps) => { - const navigation = useNavigation>(); - - return ( - { - if (item.type === 'FOLDER') navigation.push('ScrapContent', { id: item.id }); - }}> - - - - {parentFolderName && ( - - {parentFolderName} - - )} - - - - {item.title} - - - {item.type === 'FOLDER' && ( - {item.contents.length} - )} - - - - ); -}; - -export interface TrashCardProps extends SelectableUIProps { - item: TrashItem; -} - -export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) => { - const state = reducerState ?? { isSelecting: false, selectedItems: [] }; - const isSelected = state.selectedItems.includes(item.id); - const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - const deleteForever = useTrashStore((state) => state.deleteForever); - - return ( - <> - - - {state.isSelecting && ( - - - - )} - - - - {item.title} - - - {new Date(item.deletedAt).toLocaleString()} - - - - } - children={ - !state.isSelecting - ? (close) => ( - { - close(); - setTimeout(() => { - setIsDeleteModalVisible(true); - }, 200); - }} - /> - ) - : undefined - } - /> - - - - 스크랩을 영구적으로 삭제합니다. - 되돌릴 수 없는 작업입니다. - - - setIsDeleteModalVisible(false)}> - 취소 - - { - try { - await new Promise((resolve) => setTimeout(resolve, 100)); - deleteForever(item.id); - setIsDeleteModalVisible(false); - showToast('success', '영구 삭제되었습니다.'); - } catch (error) { - showToast('error', '삭제 중 오류가 발생했습니다.'); - } - }}> - 삭제하기 - - - - - - ); -}; diff --git a/apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx b/apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx deleted file mode 100644 index 8fcc35d2..00000000 --- a/apps/native/src/features/student/scrap/components/ScrapHeadCard.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { colors } from '@/theme/tokens'; -import { Plus } from 'lucide-react-native'; -import { Pressable, View, Text, Image } from 'react-native'; -import TooltipPopover, { AddItemTooltipBox, ReviewItemTooltipBox } from './Modal/TooltipBox'; -import { Placement } from 'react-native-popover-view/dist/Types'; -import { ChevronDownFilledIcon } from '@/components/system/icons'; -import { ScrapListItemProps } from './ScrapCard'; -import { useNavigation } from '@react-navigation/native'; -import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { StudentRootStackParamList } from '@/navigation/student/types'; - -export const ScrapAddItem = () => { - return ( - } - from={ - - - - - - 추가하기 - - - } - /> - ); -}; - -export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { - const navigation = useNavigation>(); - - return ( - navigation.push('ScrapContent', { id: props.id })}> - - - - - - {props.title} - - } - from={} - /> - - {props.type === 'FOLDER' && ( - {props.contents.length} - )} - - {props.timestamp} - - - ); -}; From fe5e74b22c344253bc102e734781331b7eeee444 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:30:41 +0900 Subject: [PATCH 048/140] refactor: remove scrap and trash stores and update store logic --- apps/native/src/stores/scrapDataStore.ts | 235 +++++------------------ 1 file changed, 45 insertions(+), 190 deletions(-) diff --git a/apps/native/src/stores/scrapDataStore.ts b/apps/native/src/stores/scrapDataStore.ts index d5e1df47..7af7c49b 100644 --- a/apps/native/src/stores/scrapDataStore.ts +++ b/apps/native/src/stores/scrapDataStore.ts @@ -1,196 +1,8 @@ -import { ScrapItem, TrashItem } from '@/types/test/types'; import { create } from 'zustand'; -import { deleteItemsRecursive } from '@/features/student/scrap/utils/itemHelpers'; - -/** - * Search result type for better type safety - */ -export interface SearchResult { - item: ScrapItem; - parentFolderName?: string; -} - -/** - * ScrapStore manages scrap data: fetch, update, delete, search - */ -interface ScrapStore { - data: ScrapItem[]; - setData: (newData: ScrapItem[]) => void; - updateItem: (id: string, newTitle: string, parentFolderId?: string) => void; - deleteItem: (ids: string | string[], parentFolderId?: string) => void; - searchByTitle: (query: string) => SearchResult[]; - restoreItem: (item: TrashItem) => void; -} - -export const useScrapStore = create((set, get) => ({ - data: [], - setData: (newData) => set({ data: newData }), - - updateItem: (id, newTitle, parentFolderId) => - set((state) => { - if (parentFolderId) { - // Update item in nested folder - return { - data: state.data.map((item) => { - if (item.id === parentFolderId && item.type === 'FOLDER') { - return { - ...item, - contents: item.contents.map((c) => (c.id === id ? { ...c, title: newTitle } : c)), - }; - } - return item; - }), - }; - } - - // Update item in root - return { - data: state.data.map((item) => (item.id === id ? { ...item, title: newTitle } : item)), - }; - }), - - deleteItem: (ids, parentFolderId) => - set((state) => { - const deleteArray = Array.isArray(ids) ? ids : [ids]; - - if (parentFolderId) { - // Delete from specific folder - return { - data: state.data.map((d) => { - if (d.id === parentFolderId && d.type === 'FOLDER') { - return { - ...d, - contents: deleteItemsRecursive(d.contents, deleteArray), - }; - } - return d; - }), - }; - } - - // Delete from root - return { data: deleteItemsRecursive(state.data, deleteArray) }; - }), - - searchByTitle: (query: string) => { - const state = get(); - const lowerQuery = query.toLowerCase().trim(); - if (!lowerQuery) return []; - - const results: SearchResult[] = []; - - state.data.forEach((item) => { - // Check folder title - if (item.type === 'FOLDER' && item.title.toLowerCase().includes(lowerQuery)) { - results.push({ item, parentFolderName: undefined }); - } - - // Check contents of folder - if (item.type === 'FOLDER' && item.contents) { - item.contents.forEach((c) => { - if (c.title.toLowerCase().includes(lowerQuery)) { - results.push({ - item: c, - parentFolderName: item.title, - }); - } - }); - } - - // Check root level scrap items - if (item.type === 'SCRAP' && item.title.toLowerCase().includes(lowerQuery)) { - results.push({ item }); - } - }); - - return results; - }, - - restoreItem: (item) => - set((state) => { - // Remove deletedAt property when restoring - const { deletedAt, ...restoredItem } = item; - - if (restoredItem.type === 'FOLDER') { - return { - data: [...state.data, { ...restoredItem, contents: restoredItem.contents ?? [] }], - }; - } - - if (restoredItem.type === 'SCRAP') { - if (restoredItem.parentFolderId) { - // Restore to parent folder - return { - data: state.data.map((d) => { - if (d.type === 'FOLDER' && d.id === restoredItem.parentFolderId) { - return { - ...d, - contents: [...(d.contents ?? []), restoredItem], - }; - } - return d; - }), - }; - } - - // Restore to root - return { data: [...state.data, restoredItem] }; - } - - return state; - }), -})); - -{ - /* TrashStore */ -} - -interface TrashStore { - data: TrashItem[]; - - addToTrash: (items: ScrapItem | ScrapItem[]) => void; - restoreFromTrash: (ids: string | string[]) => void; - deleteForever: (ids: string | string[]) => void; -} - -export const useTrashStore = create((set) => ({ - data: [], - - addToTrash: (items) => - set((state) => { - const array = Array.isArray(items) ? items : [items]; - const trashItems: TrashItem[] = array.map((item) => ({ - ...item, - deletedAt: Date.now(), - })); - - return { data: [...state.data, ...trashItems] }; - }), - - restoreFromTrash: (ids) => - set((state) => { - const restoreIds = Array.isArray(ids) ? ids : [ids]; - return { - data: state.data.filter((item) => !restoreIds.includes(item.id)), - }; - }), - - deleteForever: (ids) => - set((state) => { - const deleteIds = Array.isArray(ids) ? ids : [ids]; - return { - data: state.data.filter((item) => !deleteIds.includes(item.id)), - }; - }), -})); - -{ - /* SearchHistoryStore works recently data add, clear in SearchScren */ -} +import type { FilterType, ApiSortKey, SortOrder } from '@/features/student/scrap/utils/types'; interface SearchHistoryStore { keywords: string[]; - addKeyword: (keyword: string) => void; removeKeyword: (keyword: string) => void; clear: () => void; @@ -204,10 +16,11 @@ export const useSearchHistoryStore = create((set) => ({ const trimmed = keyword.trim(); if (!trimmed) return state; + // 중복 제거 후 맨 앞에 추가 const filtered = state.keywords.filter((k) => k !== trimmed); return { - keywords: [trimmed, ...filtered].slice(0, 10), + keywords: [trimmed, ...filtered].slice(0, 10), // 최대 10개 }; }), @@ -218,3 +31,45 @@ export const useSearchHistoryStore = create((set) => ({ clear: () => set({ keywords: [] }), })); + +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((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), +})); From d5b3dc9ab7a90f535c49a38a6d03bee15f4945f7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:31:38 +0900 Subject: [PATCH 049/140] chore: update toast config --- apps/native/App.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 0be03c68..58049bd3 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -48,7 +48,7 @@ export default function App() { - + From 11cbbb60534b257def5fc435a74cd83ee66f2ddc Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:32:10 +0900 Subject: [PATCH 050/140] feat: integrate scrap API --- apps/native/src/apis/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/native/src/apis/index.ts b/apps/native/src/apis/index.ts index 80174032..b2e63ceb 100644 --- a/apps/native/src/apis/index.ts +++ b/apps/native/src/apis/index.ts @@ -7,4 +7,5 @@ export { client, TanstackQueryClient, authMiddleware }; export * from './controller/auth'; export * from './controller/diagnosis'; export * from './controller/notice'; -export * from './controller/study'; \ No newline at end of file +export * from './controller/scrap'; +export * from './controller/study'; From 8471240d2dfe200a3cd59c5e3dfde6caf08e2e5a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:33:34 +0900 Subject: [PATCH 051/140] feat: revise screen UI and integrate API with refactored structure --- .../src/features/student/scrap/index.ts | 3 +- .../scrap/screens/DeletedScrapScreen.tsx | 95 +++++++++++-------- .../scrap/screens/ScrapContentScreen.tsx | 88 ++++++++++------- .../student/scrap/screens/ScrapScreen.tsx | 83 +++++++++------- .../scrap/screens/SearchScrapScreen.tsx | 34 ++++--- 5 files changed, 176 insertions(+), 127 deletions(-) diff --git a/apps/native/src/features/student/scrap/index.ts b/apps/native/src/features/student/scrap/index.ts index 8d5cb3ca..448c6bba 100644 --- a/apps/native/src/features/student/scrap/index.ts +++ b/apps/native/src/features/student/scrap/index.ts @@ -1,5 +1,6 @@ import ScrapScreen from './screens/ScrapScreen'; +import ScrapContentScreen from './screens/ScrapContentScreen'; import DeletedScrapScreen from './screens/DeletedScrapScreen'; import SearchScrapScreen from './screens/SearchScrapScreen'; -export { ScrapScreen, DeletedScrapScreen, SearchScrapScreen }; +export { ScrapScreen, ScrapContentScreen, DeletedScrapScreen, SearchScrapScreen }; diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index 05b76c4e..a5a29795 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -1,48 +1,46 @@ -import React, { useEffect, useMemo, useReducer, useState } from 'react'; +import React, { useMemo, useReducer, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; import DeletedScrapHeader from '../components/Header/DeletedHeader'; import { reducer, initialSelectionState } from '../utils/reducer'; import { useNavigation } from '@react-navigation/native'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import { useScrapStore, useTrashStore } from '@/stores/scrapDataStore'; -import { TrashItem } from '@/types/test/types'; import { Container, LoadingScreen } from '@/components/common'; -import { TrashScrapGrid } from '../components/ScrapCardGrid'; +import { TrashScrapGrid } from '../components/Card/ScrapCardGrid'; import SortDropdown from '../components/Modal/SortDropdown'; -import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; -import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; +import { sortScrapData } from '../utils/sortScrap'; +import type { UISortKey, SortOrder } from '../utils/types'; import PopUpModal from '../components/Modal/PopupModal'; import { showToast } from '../components/Modal/Toast'; +import { useGetTrash, useRestoreTrash, usePermanentDeleteTrash } from '@/apis'; const DeletedScrapScreen = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); - const [fetchloading, setFetchLoading] = useState(false); - const [sortKey, setSortKey] = useState('TYPE'); // 기본: 유형순 - const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 + const [sortKey, setSortKey] = useState('TYPE'); + const [sortOrder, setSortOrder] = useState('DESC'); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const navigation = useNavigation>(); - const fetchdata = useTrashStore((state) => state.data); - const deleteForever = useTrashStore((state) => state.deleteForever); - const restoreFromTrash = useTrashStore((state) => state.restoreFromTrash); - const restoreToScrap = useScrapStore((state) => state.restoreItem); + // API 호출 + const { data: trashData, isLoading } = useGetTrash(); + const { mutateAsync: restoreTrash } = useRestoreTrash(); + const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); - useEffect(() => { - setFetchLoading(true); + const trashItems = trashData?.data || []; - const timer = setTimeout(() => { - setFetchLoading(false); - }, 800); - - return () => clearTimeout(timer); - }, [fetchdata]); - - // 정렬된 데이터를 useMemo로 계산 + /** + * 휴지통 아이템 정렬 + * API의 TrashItemResp는 createdAt이 없으므로 deletedAt을 createdAt으로 사용 + */ const sortedData = useMemo(() => { - return sortScrapData(fetchdata, sortKey, sortOrder); - }, [fetchdata, sortKey, sortOrder]); + const itemsWithCreatedAt = trashItems.map((item) => ({ + ...item, + createdAt: item.deletedAt, + })); + + return sortScrapData(itemsWithCreatedAt, sortKey, sortOrder); + }, [trashItems, sortKey, sortOrder]); return ( @@ -64,12 +62,15 @@ const DeletedScrapScreen = () => { }} onRestore={async () => { try { - const itemsToRestore = fetchdata.filter((item: { id: string }) => - reducerState.selectedItems.includes(item.id) - ); - await new Promise((resolve) => setTimeout(resolve, 100)); - itemsToRestore.forEach((item) => restoreToScrap(item)); - restoreFromTrash(reducerState.selectedItems); + const items = reducerState.selectedItems.map((id) => { + const item = trashItems.find((item) => item.id === id); + return { + id, + type: item?.type || ('SCRAP' as const), + }; + }); + + await restoreTrash({ items } as any); dispatch({ type: 'CLEAR_SELECTION' }); showToast('success', '선택된 파일들이 복구되었습니다.'); } catch (error) { @@ -78,16 +79,20 @@ const DeletedScrapScreen = () => { }} /> - - - - setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> - {sortOrder === 'ASC' ? : } - - + + + 휴지통의 스크랩은 30일 이후에 영구적으로 삭제됩니다. + + - {fetchloading ? ( + {isLoading ? ( ) : ( @@ -119,9 +124,17 @@ const DeletedScrapScreen = () => { { + onPress={async () => { try { - deleteForever(reducerState.selectedItems); + const items = reducerState.selectedItems.map((id) => { + const item = trashItems.find((item) => item.id === id); + return { + id, + type: item?.type || ('SCRAP' as const), + }; + }); + + await permanentDelete({ items } as any); dispatch({ type: 'CLEAR_SELECTION' }); setIsDeleteModalVisible(false); showToast('success', '영구 삭제되었습니다.'); diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index fa8fb908..54aec767 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -1,17 +1,17 @@ -import { Pressable, View } from 'react-native'; +import { View } from 'react-native'; import ScrapHeader from '../components/Header/ScrapHeader'; import { useMemo, useReducer, useState } from 'react'; import { reducer, initialSelectionState } from '../utils/reducer'; -import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; -import { useScrapStore, useTrashStore } from '@/stores/scrapDataStore'; +import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; +import type { UISortKey, SortOrder } from '../utils/types'; import { RouteProp, useNavigation, useRoute } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; -import { Container } from '@/components/common'; +import { Container, LoadingScreen } from '@/components/common'; import SortDropdown from '../components/Modal/SortDropdown'; -import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; -import { ScrapGrid } from '../components/ScrapCardGrid'; +import { ScrapGrid } from '../components/Card/ScrapCardGrid'; import { showToast } from '../components/Modal/Toast'; +import { useSearchScraps, useDeleteScrap, useGetFolders } from '@/apis'; type ScrapContentRouteProp = RouteProp; @@ -20,24 +20,26 @@ const ScrapContentScreen = () => { const { id } = route.params; const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); - const [sortKey, setSortKey] = useState('TITLE'); // 기본: 이름순 - const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 + const [sortKey, setSortKey] = useState('TITLE'); + const [sortOrder, setSortOrder] = useState('ASC'); const navigation = useNavigation>(); - // Get item and contents directly from store - const item = useScrapStore((state) => state.data.find((i) => i.id === id)); - const contents = useMemo(() => { - return item?.type === 'FOLDER' ? item.contents : []; - }, [item]); + // API 호출 + const { data: foldersData } = useGetFolders(); + const { data: contentsData, isLoading, refetch } = useSearchScraps({ + folderId: Number(id), + filter: 'SCRAP', + sort: mapUIKeyToAPIKey(sortKey), + order: sortOrder, + }); + const { mutateAsync: deleteScrap } = useDeleteScrap(); - const deleteItem = useScrapStore((state) => state.deleteItem); - const addToTrash = useTrashStore((state) => state.addToTrash); + // 폴더 정보 가져오기 + const folder = foldersData?.data?.find((f) => f.id === Number(id)); + const contents = contentsData?.data || []; - // Sort data directly from contents - const sortedData = useMemo( - () => sortScrapData(contents, sortKey, sortOrder), - [contents, sortKey, sortOrder] - ); + // 정렬된 데이터 + const sortedData = useMemo(() => sortScrapData(contents, sortKey, sortOrder), [contents, sortKey, sortOrder]); const isAllSelected = reducerState.selectedItems.length === contents.length && contents.length > 0; @@ -46,40 +48,56 @@ const ScrapContentScreen = () => { navigation.push('SearchScrap')} navigateTrashPress={() => navigation.push('DeletedScrap')} onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} onSelectAll={() => { - const allIds = contents.map((item: { id: string }) => item.id); + const allIds = contents.map((item) => item.id); dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); }} onDelete={async () => { + if (reducerState.selectedItems.length === 0) { + showToast('error', '삭제할 항목을 선택해주세요.'); + return; + } + try { - const itemsToDelete = contents.filter((item: { id: string }) => - reducerState.selectedItems.includes(item.id) - ); - addToTrash(itemsToDelete); - deleteItem(reducerState.selectedItems, id); - } finally { + const items = reducerState.selectedItems.map((itemId) => ({ + id: itemId, + type: 'SCRAP' as 'FOLDER' | 'SCRAP', + })); + + await deleteScrap({ items }); + + // 데이터 다시 불러오기 + await refetch(); + dispatch({ type: 'CLEAR_SELECTION' }); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); } }} /> - - - setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> - {sortOrder === 'ASC' ? : } - - + - + {isLoading ? ( + + ) : ( + + )} diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index bb8e09b4..e883fb26 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -2,39 +2,37 @@ import { Container, LoadingScreen } from '@/components/common'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import React, { useEffect, useMemo, useReducer, useState } from 'react'; -import { View, Text, Pressable } from 'react-native'; +import React, { useMemo, useReducer, useState } from 'react'; +import { View } from 'react-native'; import { reducer, initialSelectionState } from '../utils/reducer'; import ScrapHeader from '../components/Header/ScrapHeader'; -import { ScrapGrid } from '../components/ScrapCardGrid'; +import { ScrapGrid } from '../components/Card/ScrapCardGrid'; import SortDropdown from '../components/Modal/SortDropdown'; -import { SortKey, SortOrder, sortScrapData } from '../utils/sortScrap'; -import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/icons'; -import { useScrapStore, useTrashStore } from '@/stores'; +import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; +import type { UISortKey, SortOrder } from '../utils/types'; import { showToast } from '../components/Modal/Toast'; -import { folderDummy } from '../utils/testdataset'; +import { useSearchScraps, useDeleteScrap } from '@/apis'; const ScrapScreen = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); - const [fetchloading, setFetchLoading] = useState(false); - const [sortKey, setSortKey] = useState('TYPE'); // 기본: 유형순 - const [sortOrder, setSortOrder] = useState('ASC'); // 기본: 오름차순 + const [sortKey, setSortKey] = useState('DATE'); + const [sortOrder, setSortOrder] = useState('DESC'); const navigation = useNavigation>(); - const data = useScrapStore((state) => state.data); - const setData = useScrapStore((state) => state.setData); - const deleteItem = useScrapStore((state) => state.deleteItem); - const addToTrash = useTrashStore((state) => state.addToTrash); + const { + data: searchData, + isLoading, + refetch, + } = useSearchScraps({ + filter: 'ALL', + sort: mapUIKeyToAPIKey(sortKey), + order: sortOrder, + }); + const { mutateAsync: deleteScrap } = useDeleteScrap(); - useEffect(() => { - setFetchLoading(true); - - setTimeout(() => { - setData(folderDummy); // 초기값 세팅 - setFetchLoading(false); - }, 200); - }, []); + const data = searchData?.data || []; + // 클라이언트 사이드 정렬 (TYPE 정렬 등 추가 정렬 로직 적용) const sortedData = useMemo( () => sortScrapData(data, sortKey, sortOrder), [data, sortKey, sortOrder] @@ -64,29 +62,44 @@ const ScrapScreen = () => { } }} onDelete={async () => { - setFetchLoading(true); + if (reducerState.selectedItems.length === 0) { + showToast('error', '삭제할 항목을 선택해주세요.'); + return; + } + try { - const items = data.filter((i) => reducerState.selectedItems.includes(i.id)); - addToTrash(items); - deleteItem(reducerState.selectedItems); - } finally { - setFetchLoading(false); + const items = reducerState.selectedItems.map((id) => { + const item = data.find((item) => item.id === id); + return { + id: id, + type: (item?.type || 'SCRAP') as 'FOLDER' | 'SCRAP', + }; + }); + + await deleteScrap({ items }); + + // 데이터 다시 불러오기 + await refetch(); + dispatch({ type: 'CLEAR_SELECTION' }); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); } }} /> - - - setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC'))}> - {sortOrder === 'ASC' ? : } - - + - {fetchloading ? ( + {isLoading ? ( ) : ( { const navigation = useNavigation>(); const [query, setQuery] = useState(''); - const searchByTitle = useScrapStore((state) => state.searchByTitle); + const [shouldSearch, setShouldSearch] = useState(false); const { keywords, addKeyword, removeKeyword, clear } = useSearchHistoryStore(); - const [results, setResults] = useState([]); - useEffect(() => { - if (query.trim().length === 0) { - setResults([]); - } else { - setResults(searchByTitle(query)); - } - }, [query]); + // API 검색 + const { data: searchData } = useSearchScraps( + { + query: query.trim(), + filter: 'ALL', + sort: 'CREATED_AT', + order: 'DESC', + }, + // 쿼리가 있을 때만 검색 + query.trim().length > 0 + ); + + const results = searchData?.data || []; const onSearch = () => { if (!query.trim()) return; - addKeyword(query); - setResults(searchByTitle(query)); + setShouldSearch(true); }; return ( From b3244e8e10a6262dcf50f1786803b84497222321 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:34:44 +0900 Subject: [PATCH 052/140] refactor: add props type to card component --- .../student/scrap/components/Card/types.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Card/types.ts diff --git a/apps/native/src/features/student/scrap/components/Card/types.ts b/apps/native/src/features/student/scrap/components/Card/types.ts new file mode 100644 index 00000000..d5777f89 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/types.ts @@ -0,0 +1,48 @@ +import { Action, State } from '../../utils/reducer'; + +/** + * 기본 UI Props - API 스키마에 맞춤 + */ +export interface BaseItemUIProps { + /** 아이템 ID (number) */ + id: number; + /** 아이템 이름 */ + name: string; + /** 생성일시 (ISO 8601 string) */ + createdAt: string; +} + +/** + * 선택 가능한 아이템 Props + */ +export interface SelectableUIProps { + reducerState?: State; + dispatch?: React.Dispatch; + onCheckPress?: () => void; +} + +/** + * 스크랩 카드 Props + */ +export interface ScrapCardProps extends BaseItemUIProps, SelectableUIProps { + type: 'SCRAP'; + /** 썸네일 URL */ + thumbnailUrl?: string; + /** 소속 폴더 ID */ + folderId?: number; +} + +/** + * 폴더 카드 Props + * API의 ScrapListItemResp에서는 폴더 내 아이템 수만 제공 + */ +export interface FolderCardProps extends BaseItemUIProps, SelectableUIProps { + type: 'FOLDER'; + /** 폴더 내 스크랩 개수 (API에서 제공하지 않으면 별도 조회 필요) */ + scrapCount?: number; +} + +/** + * 스크랩 목록 아이템 Props (Union Type) + */ +export type ScrapListItemProps = ScrapCardProps | FolderCardProps; From 0b52ed9aed4d9c256d816930691876923fd4c610 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:35:10 +0900 Subject: [PATCH 053/140] feat: implement fullscreen modal --- .../components/Modal/FullScreenModal.tsx | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx diff --git a/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx new file mode 100644 index 00000000..ffd7df9d --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx @@ -0,0 +1,78 @@ +import { Modal, View, Pressable, Text } from 'react-native'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { BlurView } from 'expo-blur'; +import Toast from 'react-native-toast-message'; +import { toastConfig } from './Toast'; + +interface FullScreenModalProps { + visible: boolean; + onCancel: () => void; + onClose: () => void; + children: React.ReactNode; +} + +export const AddFolderScreenModal = ({ + visible, + onCancel, + onClose, + children, +}: FullScreenModalProps) => { + return ( + + + + {/* Header */} + + + 취소 + + + + 새로운 폴더 생성 + + + + 완료 + + + {/* Content */} + {children} + + + + + ); +}; + +export const LoadQnaImageScreenModal = ({ + visible, + onCancel, + onClose, + children, +}: FullScreenModalProps) => { + return ( + + + + {/* Header */} + + + 취소 + + + + QnA 사진 + + + + 완료 + + + {/* Content */} + {children} + + + + + ); +}; From 7b9c5633dba7ef035a1e37ed8811fabbda43b503 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:37:55 +0900 Subject: [PATCH 054/140] refactor: deprecate unused feature --- .../student/scrap/utils/itemHelpers.ts | 43 ------------------- apps/native/src/types/test/types.ts | 28 ------------ 2 files changed, 71 deletions(-) delete mode 100644 apps/native/src/features/student/scrap/utils/itemHelpers.ts delete mode 100644 apps/native/src/types/test/types.ts diff --git a/apps/native/src/features/student/scrap/utils/itemHelpers.ts b/apps/native/src/features/student/scrap/utils/itemHelpers.ts deleted file mode 100644 index 15a74485..00000000 --- a/apps/native/src/features/student/scrap/utils/itemHelpers.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { ScrapItem } from '@/types/test/types'; - -/** - * Finds an item in the scrap data structure. - * @param data - Array of scrap items - * @param id - ID of the item to find - * @param parentFolderId - Optional parent folder ID for nested items - * @returns The found item or null if not found - */ -export const findItem = ( - data: ScrapItem[], - id: string, - parentFolderId?: string -): ScrapItem | null => { - if (parentFolderId) { - const folder = data.find((i) => i.id === parentFolderId && i.type === 'FOLDER'); - if (folder?.type === 'FOLDER') { - return folder.contents?.find((c) => c.id === id) ?? null; - } - return null; - } - return data.find((i) => i.id === id) ?? null; -}; - -/** - * Recursively deletes items from the scrap data structure. - * @param items - Array of items to filter - * @param idsToDelete - Array of IDs to delete - * @returns Filtered array with deleted items removed - */ -export const deleteItemsRecursive = (items: ScrapItem[], idsToDelete: string[]): ScrapItem[] => { - return items - .filter((item) => !idsToDelete.includes(item.id)) - .map((item) => { - if (item.type === 'FOLDER' && item.contents) { - return { - ...item, - contents: deleteItemsRecursive(item.contents, idsToDelete), - }; - } - return item; - }); -}; diff --git a/apps/native/src/types/test/types.ts b/apps/native/src/types/test/types.ts deleted file mode 100644 index fb39d5f6..00000000 --- a/apps/native/src/types/test/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Base types for better type safety -export interface ScrapFolder { - id: string; - type: 'FOLDER'; - title: string; - timestamp: number; - contents: ScrapItem[]; -} - -export interface ScrapContent { - id: string; - type: 'SCRAP'; - title: string; - timestamp: number; - parentFolderId?: string; -} - -export interface ReviewFolder extends Omit { - id: 'REVIEW'; -} - -// Union type for all scrap items -export type ScrapItem = ScrapFolder | ScrapContent | ReviewFolder; - -// Trash item extends scrap item with deletion timestamp -export type TrashItem = ScrapItem & { - deletedAt: number; -}; From a82c140aca0207f3c564a583bcf92cd054237068 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:38:00 +0900 Subject: [PATCH 055/140] refactor: deprecate unused feature --- .../student/scrap/utils/testdataset.ts | 56 ------------------- 1 file changed, 56 deletions(-) delete mode 100644 apps/native/src/features/student/scrap/utils/testdataset.ts diff --git a/apps/native/src/features/student/scrap/utils/testdataset.ts b/apps/native/src/features/student/scrap/utils/testdataset.ts deleted file mode 100644 index 5473c82d..00000000 --- a/apps/native/src/features/student/scrap/utils/testdataset.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { ScrapItem } from '@/types/test/types'; - -const randomInt = (min: number, max: number) => Math.floor(Math.random() * (max - min + 1)) + min; - -// 랜덤 제목 생성 -const randomTitle = () => '이름' + randomInt(1, 1000); - -// SCRAP 아이템 랜덤 생성 -const createRandomScrap = ({ - parentFolderId = undefined, -}: { - parentFolderId?: string; -}): ScrapItem => ({ - id: randomInt(1000, 9999).toString(), - type: 'SCRAP', - title: randomTitle() + '스크랩', - timestamp: 202511000000 - randomInt(0, 100000), - parentFolderId: parentFolderId, -}); - -// FOLDER 안에 SCRAP 몇 개 넣기 -const createRandomFolder = (id: string, title?: string): ScrapItem => { - const count = randomInt(2, 5); // 2~5개 - const contents = Array.from({ length: count }, () => createRandomScrap({ parentFolderId: id })); - - return { - id, - type: 'FOLDER', - title: title ?? randomTitle() + '폴더', - timestamp: 202510000000 - randomInt(0, 100000), - contents, - }; -}; - -// 데이터 생성 -export const folderDummy: ScrapItem[] = [ - // REVIEW 폴더 고정 - { - id: 'REVIEW', - type: 'FOLDER', - title: '오답노트', - timestamp: 202410191130, - contents: Array.from({ length: 3 }, () => createRandomScrap({ parentFolderId: 'REVIEW' })), - }, - // 랜덤 FOLDER/SCRAP 12개 - ...Array.from({ length: 12 }, (_, i) => { - const id = (i + 1).toString(); - if (Math.random() < 0.63) { - // FOLDER - return createRandomFolder(id); - } else { - // SCRAP - return createRandomScrap({}); - } - }), -]; From 0536124bd21aa106105ac8b3e5c8bcb86fa31cd0 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:39:33 +0900 Subject: [PATCH 056/140] refactor: update code to match API schema --- .../features/student/scrap/utils/reducer.ts | 26 +++--- .../features/student/scrap/utils/sortScrap.ts | 84 +++++++++++++------ 2 files changed, 76 insertions(+), 34 deletions(-) diff --git a/apps/native/src/features/student/scrap/utils/reducer.ts b/apps/native/src/features/student/scrap/utils/reducer.ts index 6f1ae5c7..0accf03b 100644 --- a/apps/native/src/features/student/scrap/utils/reducer.ts +++ b/apps/native/src/features/student/scrap/utils/reducer.ts @@ -1,20 +1,26 @@ /** - * Selection state for scrap items + * 스크랩 아이템 선택 상태 관리 + * API 스키마에 맞게 id를 number로 통일 */ export interface State { + /** 선택 모드 활성화 여부 */ isSelecting: boolean; - selectedItems: string[]; + /** 선택된 아이템 ID 목록 (number[]) */ + selectedItems: number[]; } +/** + * 선택 액션 타입 + */ export type Action = | { type: 'ENTER_SELECTION' } | { type: 'EXIT_SELECTION' } - | { type: 'SELECTING_ITEM'; id: string } - | { type: 'SELECT_ALL'; allIds: string[] } + | { type: 'SELECTING_ITEM'; id: number } + | { type: 'SELECT_ALL'; allIds: number[] } | { type: 'CLEAR_SELECTION' }; /** - * Initial state for selection reducer + * 초기 선택 상태 */ export const initialSelectionState: State = { isSelecting: false, @@ -22,10 +28,10 @@ export const initialSelectionState: State = { }; /** - * Reducer for managing selection state of scrap items - * @param state - Current selection state - * @param action - Action to perform - * @returns New selection state + * 선택 상태 리듀서 + * @param state - 현재 선택 상태 + * @param action - 수행할 액션 + * @returns 새로운 선택 상태 */ export function reducer(state: State, action: Action): State { switch (action.type) { @@ -54,7 +60,7 @@ export function reducer(state: State, action: Action): State { return { ...state, selectedItems: [] }; default: { - // Exhaustive check: ensures all action types are handled + // Exhaustive check: 모든 액션 타입이 처리되었는지 확인 const _exhaustive: never = action; return state; } diff --git a/apps/native/src/features/student/scrap/utils/sortScrap.ts b/apps/native/src/features/student/scrap/utils/sortScrap.ts index 0104773e..3b6422c3 100644 --- a/apps/native/src/features/student/scrap/utils/sortScrap.ts +++ b/apps/native/src/features/student/scrap/utils/sortScrap.ts @@ -1,46 +1,82 @@ -import { TrashItem, ScrapItem } from '@/types/test/types'; - -export type SortKey = 'TYPE' | 'TITLE' | 'DATE'; -export type SortOrder = 'ASC' | 'DESC'; +import { + type ScrapItem, + type TrashItem, + type UISortKey, + type SortOrder, + parseTimestamp, +} from '@/features/student/scrap/utils/types'; /** - * Sorts scrap items by the specified key and order. - * REVIEW items are always placed at the beginning. + * 스크랩 데이터 정렬 함수 + * + * @param list - 정렬할 스크랩/휴지통 아이템 목록 + * @param key - 정렬 키 (TYPE, TITLE, DATE) + * @param order - 정렬 방향 (ASC, DESC) + * @returns 정렬된 아이템 목록 + * + * @description + * - TYPE: 폴더가 스크랩보다 먼저 표시, 같은 타입 내에서는 생성일시 기준 + * - TITLE: 이름 기준 (한글 로케일 지원) + * - DATE: 생성일시 기준 */ export const sortScrapData = ( list: T[], - key: SortKey, + key: UISortKey, order: SortOrder ): T[] => { const mul = order === 'ASC' ? 1 : -1; - // Separate REVIEW item (always shown first) - const reviewItem = list.find((item) => item.id === 'REVIEW'); - const sortable = list.filter((item) => item.id !== 'REVIEW'); - - const sorted = [...sortable].sort((a, b) => { + const sorted = [...list].sort((a, b) => { switch (key) { - case 'TYPE': - // Sort by type first (FOLDER before SCRAP) + case 'TYPE': { + // 타입 우선 정렬 (FOLDER가 SCRAP보다 먼저) if (a.type !== b.type) { return (a.type === 'FOLDER' ? -1 : 1) * mul; } - // Same type: sort by timestamp - return (a.timestamp - b.timestamp) * mul; + // 같은 타입: 생성일시 기준 + const timestampA = parseTimestamp(a.createdAt); + const timestampB = parseTimestamp(b.createdAt); + return (timestampA - timestampB) * mul; + } - case 'TITLE': - // Sort by title using Korean locale - return a.title.localeCompare(b.title, 'ko', { numeric: true }) * mul; + case 'TITLE': { + // 이름 기준 (한글 로케일 지원) + return a.name.localeCompare(b.name, 'ko', { numeric: true }) * mul; + } - case 'DATE': - // Sort by timestamp - return (a.timestamp - b.timestamp) * mul; + case 'DATE': { + // 생성일시 기준 + const timestampA = parseTimestamp(a.createdAt); + const timestampB = parseTimestamp(b.createdAt); + return (timestampA - timestampB) * mul; + } default: return 0; } }); - // REVIEW item always comes first - return reviewItem ? ([reviewItem, ...sorted] as T[]) : sorted; + return sorted; +}; + +/** + * UI 정렬 키를 API 정렬 키로 변환 + * + * @param uiKey - UI 정렬 키 (TYPE, TITLE, DATE) + * @returns API 정렬 키 (CREATED_AT, NAME) + * + * @description + * - TYPE, DATE → CREATED_AT (서버에서 생성일시 기준 정렬) + * - TITLE → NAME (서버에서 이름 기준 정렬) + */ +export const mapUIKeyToAPIKey = (uiKey: UISortKey): 'CREATED_AT' | 'NAME' => { + switch (uiKey) { + case 'TYPE': + case 'DATE': + return 'CREATED_AT'; + case 'TITLE': + return 'NAME'; + default: + return 'CREATED_AT'; + } }; From c376f4f2c652490e6fc5c09bb1d87a1f8b5486a1 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:39:43 +0900 Subject: [PATCH 057/140] refactor: update code to match API schema --- .../src/features/student/scrap/utils/types.ts | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/types.ts diff --git a/apps/native/src/features/student/scrap/utils/types.ts b/apps/native/src/features/student/scrap/utils/types.ts new file mode 100644 index 00000000..aaad2591 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/types.ts @@ -0,0 +1,78 @@ +import { paths, components } from '@/types/api/schema'; + +/** + * API 응답 타입 추출 + */ +type ScrapSearchResponse = + paths['/api/student/scrap/search/all']['get']['responses']['200']['content']['*/*']; +type TrashResponse = paths['/api/student/scrap/trash']['get']['responses']['200']['content']['*/*']; + +/** + * API 스키마 기반 기본 타입 + */ +export type ScrapListItemResp = components['schemas']['ScrapListItemResp']; +export type ScrapDetailResp = components['schemas']['ScrapDetailResp']; +export type ScrapFolderResp = components['schemas']['ScrapFolderResp']; + +/** + * 스크랩 아이템 유니언 타입 + * - API의 ScrapListItemResp를 기반으로 함 + * - type 필드로 FOLDER/SCRAP 구분 + */ +export type ScrapItem = ScrapListItemResp; + +/** + * 휴지통 아이템 타입 (API 스키마) + * API의 TrashItemResp를 직접 사용 + */ +export type TrashItemResp = components['schemas']['TrashItemResp']; + +/** + * 휴지통 아이템 (정렬을 위한 확장 타입) + * - createdAt이 없으므로 deletedAt을 사용하여 정렬 + */ +export type TrashItem = TrashItemResp & { + /** 정렬을 위한 createdAt (deletedAt으로 설정) */ + createdAt: string; +}; + +/** + * 정렬 키 타입 + * API의 sort 파라미터에 맞춤 (CREATED_AT, NAME) + */ +export type ApiSortKey = 'CREATED_AT' | 'NAME'; + +/** + * UI 정렬 키 타입 (TYPE은 클라이언트 전용) + */ +export type UISortKey = 'TYPE' | 'TITLE' | 'DATE'; + +/** + * 정렬 방향 타입 + */ +export type SortOrder = 'ASC' | 'DESC'; + +/** + * 필터 타입 + */ +export type FilterType = 'ALL' | 'FOLDER' | 'SCRAP'; + +/** + * 검색 파라미터 타입 + */ +export type SearchScrapParams = { + folderId?: number; + query?: string; + filter?: FilterType; + sort?: ApiSortKey; + order?: SortOrder; + page?: number; + size?: number; +}; + +/** + * UI 헬퍼: timestamp를 Date로 변환 + */ +export const parseTimestamp = (dateString: string): number => { + return new Date(dateString).getTime(); +}; From 98169ef8079f3fadd2d8f7a6a99345c031d8cdf5 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:40:03 +0900 Subject: [PATCH 058/140] refactor: update code to match API schema --- .../scrap/components/Modal/SortDropdown.tsx | 132 +++++++++++++----- 1 file changed, 99 insertions(+), 33 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx index 177d9018..c862dd7c 100644 --- a/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx @@ -2,56 +2,125 @@ import { ChevronDownFilledIcon, ChevronUpFilledIcon } from '@/components/system/ import { colors } from '@/theme/tokens'; import { Check } from 'lucide-react-native'; import { useState } from 'react'; -import { View, Text, StyleSheet } from 'react-native'; +import { View, Text, StyleSheet, Pressable } from 'react-native'; import { Dropdown } from 'react-native-element-dropdown'; -import { SortKey } from '../../utils/sortScrap'; +import type { UISortKey, SortOrder } from '../../utils/types'; +/** + * 정렬 옵션 아이템 + */ interface OrderItem { label: string; - value: SortKey; + value: UISortKey; } +/** + * 목록 화면 정렬 옵션 (폴더 + 스크랩) + */ export const orderList: OrderItem[] = [ { label: '유형순', value: 'TYPE' }, { label: '이름순', value: 'TITLE' }, { label: '최신순', value: 'DATE' }, ]; +/** + * 콘텐츠 화면 정렬 옵션 (스크랩만) + */ export const orderContent: OrderItem[] = [ { label: '이름순', value: 'TITLE' }, { label: '최신순', value: 'DATE' }, ]; +/** + * 이미지 화면 정렬 옵션 + */ +export const orderImage: OrderItem[] = [{ label: '최신순', value: 'DATE' }]; + +/** + * 정렬 드롭다운 Props + */ interface SortDropdownProps { - ordertype: 'LIST' | 'CONTENT'; - orderValue: SortKey; - setOrderValue: (value: SortKey) => void; + /** 정렬 타입 (LIST: 목록, CONTENT: 콘텐츠, IMAGE: 이미지) */ + ordertype: 'LIST' | 'CONTENT' | 'IMAGE'; + /** 현재 정렬 키 */ + orderValue: UISortKey; + /** 정렬 키 변경 핸들러 */ + setOrderValue: (value: UISortKey) => void; + /** 현재 정렬 방향 */ + sortOrder: SortOrder; + /** 정렬 방향 변경 핸들러 */ + setSortOrder: (value: SortOrder | ((prev: SortOrder) => SortOrder)) => void; + /** 커스텀 색상 */ + colors?: { + text?: string; + border?: string; + focusBackground?: string; + checkIcon?: string; + background?: string; + itemBackground?: string; + }; } -const SortDropdown: React.FC = ({ ordertype, orderValue, setOrderValue }) => { +const SortDropdown: React.FC = ({ + ordertype, + orderValue, + setOrderValue, + sortOrder, + setSortOrder, + colors: customColors, +}) => { const [isFocus, setIsFocus] = useState(false); + const textColor = customColors?.text || colors['gray-800']; + const borderColor = customColors?.border || colors['gray-400']; + const focusBackgroundColor = customColors?.focusBackground || colors['gray-400']; + const checkIconColor = customColors?.checkIcon || colors['gray-800']; + const backgroundColor = customColors?.background || 'white'; + const itemBackground = customColors?.itemBackground || colors['gray-300']; + return ( setOrderValue(item.value)} onFocus={() => setIsFocus(true)} onBlur={() => setIsFocus(false)} - renderRightIcon={() => null} - renderItem={(item) => ( - - {item.value === orderValue && } - {item.label} - + renderRightIcon={() => ( + { + e.stopPropagation(); + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC')); + }} + style={styles.sortOrderButton}> + {sortOrder === 'ASC' ? ( + + ) : ( + + )} + )} + renderItem={(item) => { + const isSelected = item.value === orderValue; + return ( + + {isSelected && } + {item.label} + + ); + }} /> ); }; @@ -60,31 +129,32 @@ export default SortDropdown; const styles = StyleSheet.create({ dropdown: { - width: 49, + width: 80, height: 29, gap: 2, + alignItems: 'center', paddingTop: 4, paddingRight: 4, paddingLeft: 8, paddingBottom: 4, borderRadius: 4, - backgroundColor: colors['gray-100'], }, - dropdownFocus: { - backgroundColor: colors['gray-400'], + sortOrderButton: { + width: 20, + height: 20, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 2, }, + dropdownFocus: {}, container: { width: 104, borderRadius: 8, borderWidth: 1, - borderColor: colors['gray-400'], justifyContent: 'center', gap: 2, top: 4, - shadowColor: 'rgba(12,12,13,0.10)', - shadowOffset: { width: 0, height: 16 }, - shadowOpacity: 1, - shadowRadius: 32, + padding: 4, }, itemContainer: { @@ -98,17 +168,14 @@ const styles = StyleSheet.create({ fontWeight: '500', fontFamily: 'Pretendard', lineHeight: 21, - color: colors['gray-800'], }, selectedText: { - alignItems: 'center', - textAlign: 'right', - justifyContent: 'center', fontSize: 14, fontWeight: '500', fontFamily: 'Pretendard', lineHeight: 21, - color: colors['gray-800'], + flexShrink: 1, + marginRight: 4, }, itemRow: { flexDirection: 'row', @@ -124,6 +191,5 @@ const styles = StyleSheet.create({ fontWeight: '500', lineHeight: 24, fontFamily: 'Pretendard', - color: colors['gray-800'], }, }); From 42b247dfc1b82487f28e691c0b6978aaf26faef5 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:40:34 +0900 Subject: [PATCH 059/140] refactor: update navigator --- apps/native/src/navigation/student/StudentNavigator.tsx | 6 +++++- apps/native/src/navigation/student/types.ts | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/apps/native/src/navigation/student/StudentNavigator.tsx b/apps/native/src/navigation/student/StudentNavigator.tsx index 0f4ac645..48c633ad 100644 --- a/apps/native/src/navigation/student/StudentNavigator.tsx +++ b/apps/native/src/navigation/student/StudentNavigator.tsx @@ -11,7 +11,11 @@ import { import StudentTabs from './StudentTabs'; import { StudentRootStackParamList } from './types'; import NotificationHeader from './components/NotificationHeader'; -import { DeletedScrapScreen, ScrapScreen, SearchScrapScreen } from '@/features/student/scrap'; +import { + DeletedScrapScreen, + ScrapScreen, + SearchScrapScreen, +} from '@/features/student/scrap'; import ScrapContentScreen from '@/features/student/scrap/screens/ScrapContentScreen'; const StudentRootStack = createNativeStackNavigator(); diff --git a/apps/native/src/navigation/student/types.ts b/apps/native/src/navigation/student/types.ts index 44d8f3c8..8daf8ffa 100644 --- a/apps/native/src/navigation/student/types.ts +++ b/apps/native/src/navigation/student/types.ts @@ -1,4 +1,3 @@ -import { FolderCardProps } from '@/features/student/scrap/components/ScrapCard'; import type { NavigatorScreenParams } from '@react-navigation/native'; import { components } from '@schema'; @@ -30,3 +29,4 @@ export type StudentRootStackParamList = { SearchScrap: undefined; DeletedScrap: undefined; }; + From a6687006deee29bafd5923d16d83767fbe0f3903 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:41:08 +0900 Subject: [PATCH 060/140] chore: add expo-blur and expo-image-picker --- apps/native/package.json | 3 ++ .../student/scrap/utils/imagePicker.ts | 31 +++++++++++ pnpm-lock.yaml | 52 +++++++++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/imagePicker.ts diff --git a/apps/native/package.json b/apps/native/package.json index 4b7cf709..6bbb4738 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -24,11 +24,13 @@ "dotenv": "^17.2.3", "expo": "~54.0.25", "expo-asset": "^12.0.10", + "expo-blur": "^15.0.8", "expo-constants": "~18.0.10", "expo-file-system": "^19.0.19", "expo-font": "~14.0.9", "expo-haptics": "~15.0.7", "expo-image": "~3.0.10", + "expo-image-picker": "^17.0.10", "expo-linking": "~8.0.9", "expo-modules-core": "^3.0.26", "expo-router": "~6.0.15", @@ -48,6 +50,7 @@ "react-native-css-interop": "^0.2.1", "react-native-element-dropdown": "^2.12.4", "react-native-gesture-handler": "~2.28.0", + "react-native-image-picker": "^8.2.1", "react-native-popover-view": "^6.1.0", "react-native-reanimated": "~4.1.5", "react-native-safe-area-context": "~5.4.0", diff --git a/apps/native/src/features/student/scrap/utils/imagePicker.ts b/apps/native/src/features/student/scrap/utils/imagePicker.ts new file mode 100644 index 00000000..b809939f --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/imagePicker.ts @@ -0,0 +1,31 @@ +import * as ImagePicker from 'expo-image-picker'; + +export const openCamera = async () => { + const { status } = await ImagePicker.requestCameraPermissionsAsync(); + if (status !== 'granted') { + throw new Error('Camera permission denied'); + } + + const result = await ImagePicker.launchCameraAsync({ + mediaTypes: ['images'], + quality: 1, + }); + + if (result.canceled) return null; + return result.assets[0]; +}; + +export const openImageLibrary = async () => { + const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync(); + if (status !== 'granted') { + throw new Error('Gallery permission denied'); + } + + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ['images'], + quality: 1, + }); + + if (result.canceled) return null; + return result.assets[0]; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c004df2e..dbcc7625 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -216,6 +216,9 @@ importers: expo-asset: specifier: ^12.0.10 version: 12.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + expo-blur: + specifier: ^15.0.8 + version: 15.0.8(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) expo-constants: specifier: ~18.0.10 version: 18.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0)) @@ -231,6 +234,9 @@ importers: expo-image: specifier: ~3.0.10 version: 3.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + expo-image-picker: + specifier: ^17.0.10 + version: 17.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)) expo-linking: specifier: ~8.0.9 version: 8.0.9(expo@54.0.25)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) @@ -288,6 +294,9 @@ importers: react-native-gesture-handler: specifier: ~2.28.0 version: 2.28.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + react-native-image-picker: + specifier: ^8.2.1 + version: 8.2.1(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) react-native-popover-view: specifier: ^6.1.0 version: 6.1.0 @@ -4742,6 +4751,13 @@ packages: react: '*' react-native: '*' + expo-blur@15.0.8: + resolution: {integrity: sha512-rWyE1NBRZEu9WD+X+5l7gyPRszw7n12cW3IRNAb5i6KFzaBp8cxqT5oeaphJapqURvcqhkOZn2k5EtBSbsuU7w==} + peerDependencies: + expo: '*' + react: '*' + react-native: '*' + expo-constants@18.0.10: resolution: {integrity: sha512-Rhtv+X974k0Cahmvx6p7ER5+pNhBC0XbP1lRviL2J1Xl4sT2FBaIuIxF/0I0CbhOsySf0ksqc5caFweAy9Ewiw==} peerDependencies: @@ -4766,6 +4782,16 @@ packages: peerDependencies: expo: '*' + expo-image-loader@6.0.0: + resolution: {integrity: sha512-nKs/xnOGw6ACb4g26xceBD57FKLFkSwEUTDXEDF3Gtcu3MqF3ZIYd3YM+sSb1/z9AKV1dYT7rMSGVNgsveXLIQ==} + peerDependencies: + expo: '*' + + expo-image-picker@17.0.10: + resolution: {integrity: sha512-a2xrowp2trmvXyUWgX3O6Q2rZaa2C59AqivKI7+bm+wLvMfTEbZgldLX4rEJJhM8xtmEDTNU+lzjtObwzBRGaw==} + peerDependencies: + expo: '*' + expo-image@3.0.10: resolution: {integrity: sha512-i4qNCEf9Ur7vDqdfDdFfWnNCAF2efDTdahuDy9iELPS2nzMKBLeeGA2KxYEPuRylGCS96Rwm+SOZJu6INc2ADQ==} peerDependencies: @@ -6850,6 +6876,12 @@ packages: react: '*' react-native: '*' + react-native-image-picker@8.2.1: + resolution: {integrity: sha512-FBeGYJGFDjMdGCcyubDJgBAPCQ4L1D3hwLXyUU91jY9ahOZMTbluceVvRmrEKqnDPFJ0gF1NVhJ0nr1nROFLdg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-is-edge-to-edge@1.2.1: resolution: {integrity: sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q==} peerDependencies: @@ -13442,6 +13474,12 @@ snapshots: transitivePeerDependencies: - supports-color + expo-blur@15.0.8(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): + dependencies: + expo: 54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + expo-constants@18.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0)): dependencies: '@expo/config': 12.0.10 @@ -13467,6 +13505,15 @@ snapshots: dependencies: expo: 54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + expo-image-loader@6.0.0(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)): + dependencies: + expo: 54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + + expo-image-picker@17.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)): + dependencies: + expo: 54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + expo-image-loader: 6.0.0(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)) + expo-image@3.0.10(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): dependencies: expo: 54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) @@ -15807,6 +15854,11 @@ snapshots: react: 19.1.0 react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-image-picker@8.2.1(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): + dependencies: + react: 19.1.0 + react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-is-edge-to-edge@1.2.1(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0): dependencies: react: 19.1.0 From 239681415b97baff027d985a5ae260fcc394c6a8 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 18 Dec 2025 23:49:25 +0900 Subject: [PATCH 061/140] refactor: remove type workaround --- .../src/features/student/scrap/screens/DeletedScrapScreen.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index a5a29795..a357ca91 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -70,7 +70,7 @@ const DeletedScrapScreen = () => { }; }); - await restoreTrash({ items } as any); + await restoreTrash({ items }); dispatch({ type: 'CLEAR_SELECTION' }); showToast('success', '선택된 파일들이 복구되었습니다.'); } catch (error) { @@ -134,7 +134,7 @@ const DeletedScrapScreen = () => { }; }); - await permanentDelete({ items } as any); + await permanentDelete({ items }); dispatch({ type: 'CLEAR_SELECTION' }); setIsDeleteModalVisible(false); showToast('success', '영구 삭제되었습니다.'); From 2e6a788864cd5e9eb2f2bfd70aa470dba753297d Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:09:17 +0900 Subject: [PATCH 062/140] refactor(api): migrate to TanStack Query client --- .../apis/controller/scrap/useGetFolders.ts | 15 ++-------- .../controller/scrap/useGetHandwriting.ts | 29 ++++++++----------- .../controller/scrap/useGetScrapDetail.ts | 29 ++++++++----------- .../controller/scrap/useGetScrapsByFolder.ts | 25 ++++++++++++++++ .../src/apis/controller/scrap/useGetTrash.ts | 15 ++-------- 5 files changed, 53 insertions(+), 60 deletions(-) create mode 100644 apps/native/src/apis/controller/scrap/useGetScrapsByFolder.ts diff --git a/apps/native/src/apis/controller/scrap/useGetFolders.ts b/apps/native/src/apis/controller/scrap/useGetFolders.ts index 327277e1..08f44e42 100644 --- a/apps/native/src/apis/controller/scrap/useGetFolders.ts +++ b/apps/native/src/apis/controller/scrap/useGetFolders.ts @@ -1,16 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; -import { client } from '@/apis/client'; -import { paths } from '@/types/api/schema'; - -type GetFoldersResponse = - paths['/api/student/scrap/folder']['get']['responses']['200']['content']['*/*']; +import { TanstackQueryClient } from '@apis'; export const useGetFolders = () => { - return useQuery({ - queryKey: ['scrap', 'folders'], - queryFn: async (): Promise => { - const { data } = await client.GET('/api/student/scrap/folder'); - return data as GetFoldersResponse; - }, - }); + return TanstackQueryClient.useQuery('get', '/api/student/scrap/folder'); }; diff --git a/apps/native/src/apis/controller/scrap/useGetHandwriting.ts b/apps/native/src/apis/controller/scrap/useGetHandwriting.ts index 76e9721d..20c27241 100644 --- a/apps/native/src/apis/controller/scrap/useGetHandwriting.ts +++ b/apps/native/src/apis/controller/scrap/useGetHandwriting.ts @@ -1,21 +1,16 @@ -import { useQuery } from '@tanstack/react-query'; -import { client } from '@/apis/client'; -import { paths } from '@/types/api/schema'; - -type GetHandwritingResponse = - paths['/api/student/scrap/{scrapId}/handwriting']['get']['responses']['200']['content']['*/*']; +import { TanstackQueryClient } from '@apis'; export const useGetHandwriting = (scrapId: number, enabled = true) => { - return useQuery({ - queryKey: ['scrap', 'handwriting', scrapId], - queryFn: async (): Promise => { - const { data } = await client.GET('/api/student/scrap/{scrapId}/handwriting', { - params: { - path: { scrapId }, - }, - }); - return data as GetHandwritingResponse; + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/{scrapId}/handwriting', + { + params: { + path: { scrapId }, + }, }, - enabled, - }); + { + enabled, + } + ); }; diff --git a/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts b/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts index 2feec584..63ac63e3 100644 --- a/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts +++ b/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts @@ -1,21 +1,16 @@ -import { useQuery } from '@tanstack/react-query'; -import { client } from '@/apis/client'; -import { paths } from '@/types/api/schema'; - -type GetScrapDetailResponse = - paths['/api/student/scrap/{id}']['get']['responses']['200']['content']['*/*']; +import { TanstackQueryClient } from '@apis'; export const useGetScrapDetail = (id: number, enabled = true) => { - return useQuery({ - queryKey: ['scrap', 'detail', id], - queryFn: async (): Promise => { - const { data } = await client.GET('/api/student/scrap/{id}', { - params: { - path: { id }, - }, - }); - return data as GetScrapDetailResponse; + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/{id}', + { + params: { + path: { id }, + }, }, - enabled, - }); + { + enabled, + } + ); }; diff --git a/apps/native/src/apis/controller/scrap/useGetScrapsByFolder.ts b/apps/native/src/apis/controller/scrap/useGetScrapsByFolder.ts new file mode 100644 index 00000000..83bc87fe --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetScrapsByFolder.ts @@ -0,0 +1,25 @@ +import { TanstackQueryClient } from '@apis'; +import { paths } from '@/types/api/schema'; + +type GetScrapsByFolderResponse = + paths['/api/student/scrap/folder/{folderId}/scraps']['get']['responses']['200']['content']['*/*']; + +/** + * 폴더 내 스크랩 목록 조회 + * @description 특정 폴더에 속한 스크랩 목록을 조회합니다. + */ +export const useGetScrapsByFolder = (folderId: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/folder/{folderId}/scraps', + { + params: { + path: { folderId }, + }, + }, + { + enabled: enabled && !!folderId, + } + ); +}; + diff --git a/apps/native/src/apis/controller/scrap/useGetTrash.ts b/apps/native/src/apis/controller/scrap/useGetTrash.ts index 38f39b17..6f90e4b4 100644 --- a/apps/native/src/apis/controller/scrap/useGetTrash.ts +++ b/apps/native/src/apis/controller/scrap/useGetTrash.ts @@ -1,16 +1,5 @@ -import { useQuery } from '@tanstack/react-query'; -import { client } from '@/apis/client'; -import { paths } from '@/types/api/schema'; - -type GetTrashResponse = - paths['/api/student/scrap/trash']['get']['responses']['200']['content']['*/*']; +import { TanstackQueryClient } from '@apis'; export const useGetTrash = () => { - return useQuery({ - queryKey: ['scrap', 'trash'], - queryFn: async (): Promise => { - const { data } = await client.GET('/api/student/scrap/trash'); - return data as GetTrashResponse; - }, - }); + return TanstackQueryClient.useQuery('get', '/api/student/scrap/trash'); }; From ac65dc139d4c521a5784ac08b7a1a30a1ae45a35 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:10:42 +0900 Subject: [PATCH 063/140] fix(api): invalidate all search queries to prevent stale cache --- .../src/apis/controller/scrap/deleteScrap.ts | 39 +++++++++++++++++-- .../scrap/deleteUnscrapFromPointing.ts | 21 ++++++++-- .../scrap/deleteUnscrapFromProblem.ts | 21 ++++++++-- .../native/src/apis/controller/scrap/index.ts | 1 + .../apis/controller/scrap/postCreateFolder.ts | 21 ++++++++-- .../apis/controller/scrap/postCreateScrap.ts | 16 +++++++- .../scrap/postCreateScrapFromImage.ts | 17 +++++++- .../scrap/postCreateScrapFromPointing.ts | 16 +++++++- .../scrap/postCreateScrapFromProblem.ts | 16 +++++++- .../scrap/postToggleScrapFromPointing.ts | 16 +++++++- .../scrap/postToggleScrapFromProblem.ts | 16 +++++++- .../apis/controller/scrap/putMoveScraps.ts | 20 +++++++++- .../apis/controller/scrap/putRestoreTrash.ts | 25 ++++++++++-- .../apis/controller/scrap/putUpdateFolder.ts | 21 ++++++++-- .../controller/scrap/putUpdateScrapName.ts | 25 ++++++++++-- 15 files changed, 256 insertions(+), 35 deletions(-) diff --git a/apps/native/src/apis/controller/scrap/deleteScrap.ts b/apps/native/src/apis/controller/scrap/deleteScrap.ts index efc65ceb..7cad976f 100644 --- a/apps/native/src/apis/controller/scrap/deleteScrap.ts +++ b/apps/native/src/apis/controller/scrap/deleteScrap.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type DeleteScrapRequest = @@ -19,8 +19,41 @@ export const useDeleteScrap = () => { return { success: true, request }; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 폴더 내 스크랩 목록 갱신 (모든 폴더의 스크랩 목록 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/folder/') && + key[1].includes('/scraps') + ); + }, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts index a3f1b772..4c36f692 100644 --- a/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type UnscrapFromPointingRequest = @@ -19,8 +19,23 @@ export const useUnscrapFromPointing = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts index 1b3868db..f05d0f71 100644 --- a/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type UnscrapFromProblemRequest = @@ -19,8 +19,23 @@ export const useUnscrapFromProblem = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/index.ts b/apps/native/src/apis/controller/scrap/index.ts index 577982c2..a5e41c06 100644 --- a/apps/native/src/apis/controller/scrap/index.ts +++ b/apps/native/src/apis/controller/scrap/index.ts @@ -3,6 +3,7 @@ export * from './useGetScrapDetail'; export * from './useGetFolders'; export * from './useGetTrash'; export * from './useSearchScraps'; +export * from './useGetScrapsByFolder'; export * from './useGetHandwriting'; // POST APIs diff --git a/apps/native/src/apis/controller/scrap/postCreateFolder.ts b/apps/native/src/apis/controller/scrap/postCreateFolder.ts index 35a31fb9..b98bb232 100644 --- a/apps/native/src/apis/controller/scrap/postCreateFolder.ts +++ b/apps/native/src/apis/controller/scrap/postCreateFolder.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type CreateFolderRequest = @@ -18,8 +18,23 @@ export const useCreateFolder = () => { return data as CreateFolderResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap', 'folders'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + // 폴더 목록 갱신 (정확한 queryKey 사용) + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrap.ts b/apps/native/src/apis/controller/scrap/postCreateScrap.ts index 4d905d4c..71f5582c 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrap.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrap.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type CreateScrapRequest = @@ -18,7 +18,19 @@ export const useCreateScrap = () => { return data as CreateScrapResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts index faafecb2..c88db092 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type CreateScrapFromImageRequest = @@ -20,7 +20,20 @@ export const useCreateScrapFromImage = () => { return data as CreateScrapFromImageResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + // openapi-react-query의 queryKey 구조: ['get', '/api/student/scrap/search/all', { params: ... }] + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts index 01e0dc8e..0a8e85ef 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type CreateScrapFromPointingRequest = @@ -20,7 +20,19 @@ export const useCreateScrapFromPointing = () => { return data as CreateScrapFromPointingResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts index 1c185090..8ceb9253 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type CreateScrapFromProblemRequest = @@ -20,7 +20,19 @@ export const useCreateScrapFromProblem = () => { return data as CreateScrapFromProblemResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts index e5787671..097194ea 100644 --- a/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type ToggleScrapFromPointingRequest = @@ -20,7 +20,19 @@ export const useToggleScrapFromPointing = () => { return data as ToggleScrapFromPointingResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts index cf58cfcb..cbc03da6 100644 --- a/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type ToggleScrapFromProblemRequest = @@ -20,7 +20,19 @@ export const useToggleScrapFromProblem = () => { return data as ToggleScrapFromProblemResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putMoveScraps.ts b/apps/native/src/apis/controller/scrap/putMoveScraps.ts index b673c639..b3e1f1b6 100644 --- a/apps/native/src/apis/controller/scrap/putMoveScraps.ts +++ b/apps/native/src/apis/controller/scrap/putMoveScraps.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type MoveScrapsRequest = @@ -18,7 +18,23 @@ export const useMoveScraps = () => { return data as MoveScrapsResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putRestoreTrash.ts b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts index 7bb0dc0b..0549f82e 100644 --- a/apps/native/src/apis/controller/scrap/putRestoreTrash.ts +++ b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type RestoreTrashRequest = @@ -15,8 +15,27 @@ export const useRestoreTrash = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + }); + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolder.ts b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts index 36fbb938..2799346d 100644 --- a/apps/native/src/apis/controller/scrap/putUpdateFolder.ts +++ b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type UpdateFolderRequest = @@ -26,8 +26,23 @@ export const useUpdateFolder = () => { return data as UpdateFolderResponse; }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap', 'folders'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts index b6ae464b..f923b1cd 100644 --- a/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts +++ b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type UpdateScrapNameRequest = @@ -29,8 +29,27 @@ export const useUpdateScrapName = () => { return data as UpdateScrapNameResponse; }, onSuccess: (_, { scrapId }) => { - queryClient.invalidateQueries({ queryKey: ['scrap', 'detail', scrapId] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); + // 스크랩 상세 정보 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/{id}', { + params: { + path: { id: scrapId }, + }, + }).queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); }, }); }; From 63c40c5a1af5e2ce62bb6d226fb976c205684199 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:11:11 +0900 Subject: [PATCH 064/140] feat(api): add file upload API --- .../src/apis/controller/common/index.ts | 2 ++ .../controller/common/postGetPreSignedUrl.ts | 23 +++++++++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 apps/native/src/apis/controller/common/index.ts create mode 100644 apps/native/src/apis/controller/common/postGetPreSignedUrl.ts diff --git a/apps/native/src/apis/controller/common/index.ts b/apps/native/src/apis/controller/common/index.ts new file mode 100644 index 00000000..0649f78c --- /dev/null +++ b/apps/native/src/apis/controller/common/index.ts @@ -0,0 +1,2 @@ +export * from './postGetPreSignedUrl'; + diff --git a/apps/native/src/apis/controller/common/postGetPreSignedUrl.ts b/apps/native/src/apis/controller/common/postGetPreSignedUrl.ts new file mode 100644 index 00000000..935f5ed2 --- /dev/null +++ b/apps/native/src/apis/controller/common/postGetPreSignedUrl.ts @@ -0,0 +1,23 @@ +import { useMutation } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type GetPreSignedUrlRequest = + paths['/api/common/upload-file']['post']['requestBody']['content']['application/json']; +type GetPreSignedUrlResponse = + paths['/api/common/upload-file']['post']['responses']['200']['content']['*/*']; + +/** + * 파일 업로드를 위한 Pre-signed URL 요청 + * @description AWS S3 업로드를 위한 pre-signed URL을 받아옵니다. + */ +export const useGetPreSignedUrl = () => { + return useMutation({ + mutationFn: async (request: GetPreSignedUrlRequest): Promise => { + const { data } = await client.POST('/api/common/upload-file', { + body: request, + }); + return data as GetPreSignedUrlResponse; + }, + }); +}; From 849a5ce6f87c09141a119bfa5ef8d6e94cfa08ca Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:11:34 +0900 Subject: [PATCH 065/140] fix(api): update trash API --- .../src/apis/controller/scrap/deletePermanentTrash.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts index 2be6cc12..b1a53d3f 100644 --- a/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts +++ b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts @@ -1,5 +1,5 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; type PermanentDeleteRequest = @@ -15,7 +15,10 @@ export const usePermanentDeleteTrash = () => { }); }, onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + }); }, }); }; From f71f1f3c23b31f59552b4233c49272f1b17700b7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:12:06 +0900 Subject: [PATCH 066/140] refactor(store): rename store --- apps/native/src/stores/index.ts | 3 ++- .../src/stores/{scrapDataStore.ts => searchHistoryStore.ts} | 0 2 files changed, 2 insertions(+), 1 deletion(-) rename apps/native/src/stores/{scrapDataStore.ts => searchHistoryStore.ts} (100%) diff --git a/apps/native/src/stores/index.ts b/apps/native/src/stores/index.ts index 0072b078..44626b6d 100644 --- a/apps/native/src/stores/index.ts +++ b/apps/native/src/stores/index.ts @@ -1,3 +1,4 @@ export * from './authStore'; export * from './problemSessionStore'; -export * from './scrapDataStore'; +export * from './searchHistoryStore'; +export * from './scrapNoteStore'; diff --git a/apps/native/src/stores/scrapDataStore.ts b/apps/native/src/stores/searchHistoryStore.ts similarity index 100% rename from apps/native/src/stores/scrapDataStore.ts rename to apps/native/src/stores/searchHistoryStore.ts From e40aae2e0ed9e82a4c78b12bb68a9df5a4611e2f Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:12:22 +0900 Subject: [PATCH 067/140] refactor(api): refactor API --- apps/native/src/types/api/schema.d.ts | 162 +++++++++++++++++++++----- 1 file changed, 134 insertions(+), 28 deletions(-) diff --git a/apps/native/src/types/api/schema.d.ts b/apps/native/src/types/api/schema.d.ts index 9465fe48..90ff5ab7 100644 --- a/apps/native/src/types/api/schema.d.ts +++ b/apps/native/src/types/api/schema.d.ts @@ -134,7 +134,8 @@ export interface paths { path?: never; cookie?: never; }; - get?: never; + /** 폴더 상세 조회 */ + get: operations['getFolderDetail']; /** 폴더 수정 */ put: operations['updateFolder']; post?: never; @@ -1569,15 +1570,32 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/scrap/search/all': { + '/api/student/scrap/search': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 검색 (폴더 + 스크랩 각각 반환) */ + get: operations['searchScraps']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/folder/{folderId}/scraps': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 (폴더 + 루트 스크랩, 필터/검색/정렬) */ - get: operations['searchScrapsAll']; + /** 폴더 내 스크랩 목록 조회 */ + get: operations['getScrapsByFolder']; put?: never; post?: never; delete?: never; @@ -1861,6 +1879,22 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/auth/issue-admin-token': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get: operations['issueTemporaryToken']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/scrap/trash/all': { parameters: { query?: never; @@ -3118,11 +3152,6 @@ export interface components { */ daysUntilPermanentDelete: number; }; - ListRespScrapListItemResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['ScrapListItemResp'][]; - }; /** @description 스크랩/폴더 목록 아이템 */ ScrapListItemResp: { /** @@ -3150,11 +3179,23 @@ export interface components { */ createdAt: string; }; + /** @description 스크랩 검색 응답 */ + ScrapSearchResp: { + /** @description 검색된 폴더 목록 */ + folders: components['schemas']['ScrapFolderResp'][]; + /** @description 검색된 스크랩 목록 */ + scraps: components['schemas']['ScrapListItemResp'][]; + }; ListRespScrapFolderResp: { /** Format: int32 */ total: number; data: components['schemas']['ScrapFolderResp'][]; }; + ListRespScrapListItemResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['ScrapListItemResp'][]; + }; ListRespSchoolResp: { /** Format: int32 */ total: number; @@ -3552,6 +3593,28 @@ export interface operations { }; }; }; + getFolderDetail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapFolderResp']; + }; + }; + }; + }; updateFolder: { parameters: { query?: never; @@ -6147,7 +6210,18 @@ export interface operations { }; getTrash: { parameters: { - query?: never; + query?: { + /** + * @description 정렬 필드 (CREATED_AT/NAME/TYPE) + * @example CREATED_AT + */ + sort?: 'CREATED_AT' | 'NAME' | 'TYPE' | 'SIMILARITY'; + /** + * @description 정렬 방향 (ASC/DESC) + * @example DESC + */ + order?: 'ASC' | 'DESC'; + }; header?: never; path?: never; cookie?: never; @@ -6187,42 +6261,51 @@ export interface operations { }; }; }; - searchScrapsAll: { + searchScraps: { parameters: { query?: { - /** - * @description 폴더 ID (null이면 루트 스크랩) - * @example 1 - */ + /** @description 폴더 ID (null이면 루트 스크랩) */ folderId?: number; - /** - * @description 검색어 (폴더명, 문제 제목) - * @example 미적분 - */ + /** @description 검색어 (폴더명, 문제 제목) */ query?: string; /** - * @description 필터 (ALL/FOLDER/SCRAP) - * @example ALL - */ - filter?: 'ALL' | 'FOLDER' | 'SCRAP'; - /** - * @description 정렬 필드 (CREATED_AT/NAME) + * @description 정렬 필드 (CREATED_AT/NAME/TYPE/SIMILARITY) * @example CREATED_AT */ - sort?: 'CREATED_AT' | 'NAME'; + sort?: 'CREATED_AT' | 'NAME' | 'TYPE' | 'SIMILARITY'; /** * @description 정렬 방향 (ASC/DESC) * @example DESC */ order?: 'ASC' | 'DESC'; - page?: number; - size?: number; }; header?: never; path?: never; cookie?: never; }; requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapSearchResp']; + }; + }; + }; + }; + getScrapsByFolder: { + parameters: { + query?: never; + header?: never; + path: { + folderId: number; + }; + cookie?: never; + }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -6595,6 +6678,29 @@ export interface operations { }; }; }; + issueTemporaryToken: { + parameters: { + query: { + id: number; + type: 'ADMIN' | 'STUDENT' | 'TEACHER'; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['JwtResp']; + }; + }; + }; + }; emptyTrash: { parameters: { query?: never; From f78d31bb367edb90d76b793adc6e942695762607 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:14:46 +0900 Subject: [PATCH 068/140] fix(reducer): improve select state handling with type checks --- .../scrap/components/Card/ScrapCardGrid.tsx | 4 +-- .../scrap/components/Card/ScrapHeadCard.tsx | 3 ++ .../scrap/components/Card/cards/ScrapCard.tsx | 28 ++++++++++++--- .../scrap/components/Card/cards/TrashCard.tsx | 4 +-- .../scrap/screens/DeletedScrapScreen.tsx | 24 +++---------- .../features/student/scrap/utils/reducer.ts | 36 +++++++++++++------ 6 files changed, 60 insertions(+), 39 deletions(-) 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 c3b4edfe..aea2562a 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -87,7 +87,7 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { dispatch?.({ type: 'SELECTING_ITEM', id: scrapItem.id })} + onCheckPress={() => dispatch?.({ type: 'SELECTING_ITEM', id: scrapItem.id, itemType: scrapItem.type })} /> ); @@ -193,7 +193,7 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP dispatch({ type: 'SELECTING_ITEM', id: trashItem.id })} + onCheckPress={() => dispatch({ type: 'SELECTING_ITEM', id: trashItem.id, itemType: trashItem.type })} /> ); diff --git a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx index cae6cfd9..f5ea1c53 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx @@ -41,12 +41,14 @@ export const ScrapAddItem = () => { try { await createFolder({ name: folderName }); setFolderName(''); + setSelectedImage(''); setIsFolderModalVisible(false); setTimeout(() => { showToast('success', '폴더가 추가되었습니다.'); }, 300); } catch (error) { showToast('error', '폴더 추가에 실패했습니다.'); + setSelectedImage(''); } } else { showToast('error', '폴더 이름을 입력해주세요.'); @@ -89,6 +91,7 @@ export const ScrapAddItem = () => { visible={isFolderModalVisible} onCancel={() => { setFolderName(''); + setSelectedImage(''); setIsFolderModalVisible(false); }} onClose={() => { 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 0ccd68bc..2c2312c9 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 @@ -1,4 +1,4 @@ -import { Pressable, View, Text } from 'react-native'; +import { Pressable, View, Text, Image } from 'react-native'; import React from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; @@ -7,15 +7,28 @@ import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; import type { ScrapListItemProps } from '../types'; +import { isItemSelected } from '../../../utils/reducer'; +import { useNoteStore, Note } from '@/stores/scrapNoteStore'; export const ScrapCard = (props: ScrapListItemProps) => { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; - const isSelected = state.selectedItems.includes(props.id); + const isSelected = isItemSelected(state.selectedItems, props.id, props.type); const navigation = useNavigation>(); + const openNote = useNoteStore((state) => state.openNote); + + const thumbnailUrl = props.type === 'SCRAP' ? props.thumbnailUrl : undefined; const cardContent = ( - + {thumbnailUrl ? ( + + ) : ( + + )} {state.isSelecting && ( { - + {props.name} @@ -63,7 +76,12 @@ export const ScrapCard = (props: ScrapListItemProps) => { return; } - if (props.type === 'FOLDER') navigation.push('ScrapContent', { id: String(props.id) }); + if (props.type === 'FOLDER') { + navigation.push('ScrapContent', { id: String(props.id) }); + } else if (props.type === 'SCRAP') { + openNote({ id: props.id, title: props.name }); + navigation.push('ScrapContentDetail', { id: String(props.id) }); + } }}> {cardContent} diff --git a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx index 081dfe12..112c43f1 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -7,6 +7,7 @@ import PopUpModal from '../../Modal/PopupModal'; import { showToast } from '../../Modal/Toast'; import { usePermanentDeleteTrash } from '@/apis'; import type { SelectableUIProps } from '../types'; +import { isItemSelected } from '../../../utils/reducer'; export interface TrashCardProps extends SelectableUIProps { item: TrashItem; @@ -17,7 +18,7 @@ export interface TrashCardProps extends SelectableUIProps { */ export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) => { const state = reducerState ?? { isSelecting: false, selectedItems: [] }; - const isSelected = state.selectedItems.includes(item.id); + const isSelected = isItemSelected(state.selectedItems, item.id, item.type); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); @@ -110,4 +111,3 @@ export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) ); }; - diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index a357ca91..e1e7b0dd 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -29,10 +29,6 @@ const DeletedScrapScreen = () => { const trashItems = trashData?.data || []; - /** - * 휴지통 아이템 정렬 - * API의 TrashItemResp는 createdAt이 없으므로 deletedAt을 createdAt으로 사용 - */ const sortedData = useMemo(() => { const itemsWithCreatedAt = trashItems.map((item) => ({ ...item, @@ -48,10 +44,10 @@ const DeletedScrapScreen = () => { navigateback={navigation} reducerState={reducerState} onSelectAll={() => { - const allIds = sortedData.map((item) => item.id); + const allItems = sortedData.map((item) => ({ id: item.id, type: item.type })); const isAllSelected = reducerState.selectedItems.length === sortedData.length && sortedData.length > 0; - dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); }} onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} @@ -62,13 +58,7 @@ const DeletedScrapScreen = () => { }} onRestore={async () => { try { - const items = reducerState.selectedItems.map((id) => { - const item = trashItems.find((item) => item.id === id); - return { - id, - type: item?.type || ('SCRAP' as const), - }; - }); + const items = reducerState.selectedItems; await restoreTrash({ items }); dispatch({ type: 'CLEAR_SELECTION' }); @@ -126,13 +116,7 @@ const DeletedScrapScreen = () => { className='flex-1 items-center justify-center rounded-[12px] border border-gray-500 bg-red-400 py-[12px]' onPress={async () => { try { - const items = reducerState.selectedItems.map((id) => { - const item = trashItems.find((item) => item.id === id); - return { - id, - type: item?.type || ('SCRAP' as const), - }; - }); + const items = reducerState.selectedItems; await permanentDelete({ items }); dispatch({ type: 'CLEAR_SELECTION' }); diff --git a/apps/native/src/features/student/scrap/utils/reducer.ts b/apps/native/src/features/student/scrap/utils/reducer.ts index 0accf03b..fe8c7e87 100644 --- a/apps/native/src/features/student/scrap/utils/reducer.ts +++ b/apps/native/src/features/student/scrap/utils/reducer.ts @@ -1,12 +1,17 @@ /** * 스크랩 아이템 선택 상태 관리 - * API 스키마에 맞게 id를 number로 통일 + * id와 type을 함께 저장하여 같은 id를 가진 FOLDER와 SCRAP을 구분 */ +export type SelectedItem = { + id: number; + type: 'FOLDER' | 'SCRAP'; +}; + export interface State { /** 선택 모드 활성화 여부 */ isSelecting: boolean; - /** 선택된 아이템 ID 목록 (number[]) */ - selectedItems: number[]; + /** 선택된 아이템 목록 (id와 type을 함께 저장) */ + selectedItems: SelectedItem[]; } /** @@ -15,8 +20,8 @@ export interface State { export type Action = | { type: 'ENTER_SELECTION' } | { type: 'EXIT_SELECTION' } - | { type: 'SELECTING_ITEM'; id: number } - | { type: 'SELECT_ALL'; allIds: number[] } + | { type: 'SELECTING_ITEM'; id: number; itemType: 'FOLDER' | 'SCRAP' } + | { type: 'SELECT_ALL'; allItems: SelectedItem[] } | { type: 'CLEAR_SELECTION' }; /** @@ -27,6 +32,17 @@ export const initialSelectionState: State = { selectedItems: [], }; +/** + * 선택된 아이템인지 확인하는 헬퍼 함수 + */ +export function isItemSelected( + selectedItems: SelectedItem[], + id: number, + type: 'FOLDER' | 'SCRAP' +): boolean { + return selectedItems.some((item) => item.id === id && item.type === type); +} + /** * 선택 상태 리듀서 * @param state - 현재 선택 상태 @@ -42,19 +58,19 @@ export function reducer(state: State, action: Action): State { return { ...state, isSelecting: false, selectedItems: [] }; case 'SELECTING_ITEM': { - const { id } = action; - const exists = state.selectedItems.includes(id); + const { id, itemType } = action; + const exists = isItemSelected(state.selectedItems, id, itemType); return { ...state, selectedItems: exists - ? state.selectedItems.filter((i) => i !== id) - : [...state.selectedItems, id], + ? state.selectedItems.filter((item) => !(item.id === id && item.type === itemType)) + : [...state.selectedItems, { id, type: itemType }], }; } case 'SELECT_ALL': - return { ...state, selectedItems: action.allIds }; + return { ...state, selectedItems: action.allItems }; case 'CLEAR_SELECTION': return { ...state, selectedItems: [] }; From 2c903e9e86edf3f360a4ff4bb761c82a51d4d4cc Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:17:10 +0900 Subject: [PATCH 069/140] refactor(api): change data fetching Query --- .../scrap/screens/ScrapContentScreen.tsx | 32 +++++++++---------- .../student/scrap/screens/ScrapScreen.tsx | 31 ++++++++++-------- .../src/features/student/scrap/utils/types.ts | 2 +- 3 files changed, 34 insertions(+), 31 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index 54aec767..3f291442 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -11,7 +11,7 @@ import { Container, LoadingScreen } from '@/components/common'; import SortDropdown from '../components/Modal/SortDropdown'; import { ScrapGrid } from '../components/Card/ScrapCardGrid'; import { showToast } from '../components/Modal/Toast'; -import { useSearchScraps, useDeleteScrap, useGetFolders } from '@/apis'; +import { useGetScrapsByFolder, useDeleteScrap, useGetFolders } from '@/apis'; type ScrapContentRouteProp = RouteProp; @@ -26,12 +26,7 @@ const ScrapContentScreen = () => { // API 호출 const { data: foldersData } = useGetFolders(); - const { data: contentsData, isLoading, refetch } = useSearchScraps({ - folderId: Number(id), - filter: 'SCRAP', - sort: mapUIKeyToAPIKey(sortKey), - order: sortOrder, - }); + const { data: contentsData, isLoading } = useGetScrapsByFolder(Number(id)); const { mutateAsync: deleteScrap } = useDeleteScrap(); // 폴더 정보 가져오기 @@ -39,7 +34,10 @@ const ScrapContentScreen = () => { const contents = contentsData?.data || []; // 정렬된 데이터 - const sortedData = useMemo(() => sortScrapData(contents, sortKey, sortOrder), [contents, sortKey, sortOrder]); + const sortedData = useMemo( + () => sortScrapData(contents, sortKey, sortOrder), + [contents, sortKey, sortOrder] + ); const isAllSelected = reducerState.selectedItems.length === contents.length && contents.length > 0; @@ -54,9 +52,15 @@ const ScrapContentScreen = () => { navigateTrashPress={() => navigation.push('DeletedScrap')} onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} + isAllSelected={isAllSelected} onSelectAll={() => { - const allIds = contents.map((item) => item.id); - dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); + const allItems = contents.map((item) => ({ id: item.id, type: item.type })); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); + }} + onMove={() => { + // 폴더 내부에서는 스크랩만 있으므로 이동 기능은 필요 없지만, + // 일관성을 위해 빈 함수로 제공 + showToast('info', '이동 기능은 준비 중입니다.'); }} onDelete={async () => { if (reducerState.selectedItems.length === 0) { @@ -65,16 +69,10 @@ const ScrapContentScreen = () => { } try { - const items = reducerState.selectedItems.map((itemId) => ({ - id: itemId, - type: 'SCRAP' as 'FOLDER' | 'SCRAP', - })); + const items = reducerState.selectedItems; await deleteScrap({ items }); - // 데이터 다시 불러오기 - await refetch(); - dispatch({ type: 'CLEAR_SELECTION' }); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); } catch (error: any) { diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index e883fb26..acac3d3c 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -24,13 +24,24 @@ const ScrapScreen = () => { isLoading, refetch, } = useSearchScraps({ - filter: 'ALL', sort: mapUIKeyToAPIKey(sortKey), order: sortOrder, }); const { mutateAsync: deleteScrap } = useDeleteScrap(); - const data = searchData?.data || []; + // ScrapSearchResp는 folders와 scraps를 각각 반환하므로 합쳐야 함 + const data = useMemo(() => { + if (!searchData) return []; + const folders = (searchData.folders || []).map((folder) => ({ + type: 'FOLDER' as const, + id: folder.id, + name: folder.name, + scrapCount: folder.scrapCount, + createdAt: folder.createdAt, + })); + const scraps = searchData.scraps || []; + return [...folders, ...scraps]; + }, [searchData]); // 클라이언트 사이드 정렬 (TYPE 정렬 등 추가 정렬 로직 적용) const sortedData = useMemo( @@ -50,12 +61,12 @@ const ScrapScreen = () => { onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} isAllSelected={isAllSelected} onSelectAll={() => { - const allIds = data.map((item) => item.id); - dispatch({ type: 'SELECT_ALL', allIds: isAllSelected ? [] : allIds }); + const allItems = data.map((item) => ({ id: item.id, type: item.type })); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); }} onMove={() => { - const selectedFolders = data.filter( - (item) => reducerState.selectedItems.includes(item.id) && item.type === 'FOLDER' + const selectedFolders = reducerState.selectedItems.filter( + (selected) => selected.type === 'FOLDER' ); if (selectedFolders.length > 0) { showToast('error', '스크랩만 이동이 가능합니다.'); @@ -68,13 +79,7 @@ const ScrapScreen = () => { } try { - const items = reducerState.selectedItems.map((id) => { - const item = data.find((item) => item.id === id); - return { - id: id, - type: (item?.type || 'SCRAP') as 'FOLDER' | 'SCRAP', - }; - }); + const items = reducerState.selectedItems; await deleteScrap({ items }); diff --git a/apps/native/src/features/student/scrap/utils/types.ts b/apps/native/src/features/student/scrap/utils/types.ts index aaad2591..549b9f6b 100644 --- a/apps/native/src/features/student/scrap/utils/types.ts +++ b/apps/native/src/features/student/scrap/utils/types.ts @@ -4,7 +4,7 @@ import { paths, components } from '@/types/api/schema'; * API 응답 타입 추출 */ type ScrapSearchResponse = - paths['/api/student/scrap/search/all']['get']['responses']['200']['content']['*/*']; + paths['/api/student/scrap/search']['get']['responses']['200']['content']['*/*']; type TrashResponse = paths['/api/student/scrap/trash']['get']['responses']['200']['content']['*/*']; /** From 982a95f97d1fbac55594676c9ac58d70dd5fba90 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:18:00 +0900 Subject: [PATCH 070/140] refactor(api): change data fetching Query and add search debounce --- .../scrap/screens/SearchScrapScreen.tsx | 37 +++++++++++++++---- 1 file changed, 29 insertions(+), 8 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx index c756ea84..a3db380c 100644 --- a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -7,34 +7,55 @@ import { ChevronLeft, X } from 'lucide-react-native'; import React, { useEffect, useState } from 'react'; import { Pressable, ScrollView, Text, View } from 'react-native'; import { SearchScrapGrid } from '../components/Card/ScrapCardGrid'; -import { useSearchHistoryStore } from '@/stores/scrapDataStore'; +import { useSearchHistoryStore } from '@/stores/searchHistoryStore'; import SearchScrapHeader from '../components/Header/SearchHeader'; import { useSearchScraps } from '@/apis'; const SearchScrapScreen = () => { const navigation = useNavigation>(); const [query, setQuery] = useState(''); - const [shouldSearch, setShouldSearch] = useState(false); + const [debouncedQuery, setDebouncedQuery] = useState(''); const { keywords, addKeyword, removeKeyword, clear } = useSearchHistoryStore(); - // API 검색 + // 디바운스: 입력이 멈춘 후 300ms 후에 검색어 업데이트 + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, 300); + + return () => clearTimeout(timer); + }, [query]); + + // API 검색 (디바운스된 쿼리 사용) const { data: searchData } = useSearchScraps( { - query: query.trim(), - filter: 'ALL', + query: debouncedQuery, sort: 'CREATED_AT', order: 'DESC', }, // 쿼리가 있을 때만 검색 - query.trim().length > 0 + debouncedQuery.length > 0 ); - const results = searchData?.data || []; + // ScrapSearchResp는 folders와 scraps를 각각 반환하므로 합쳐야 함 + const results = React.useMemo(() => { + if (!searchData) return []; + const folders = (searchData.folders || []).map((folder) => ({ + type: 'FOLDER' as const, + id: folder.id, + name: folder.name, + scrapCount: folder.scrapCount, + createdAt: folder.createdAt, + })); + const scraps = searchData.scraps || []; + return [...folders, ...scraps]; + }, [searchData]); const onSearch = () => { if (!query.trim()) return; addKeyword(query); - setShouldSearch(true); + // 검색 버튼을 누르면 즉시 검색 + setDebouncedQuery(query.trim()); }; return ( From 763530672ed42fa4dd05e85c4bb35609ccf3da27 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:18:29 +0900 Subject: [PATCH 071/140] feat(scrap): add create scrap functionality --- .../Modal/Tooltip/AddItemTooltip.tsx | 119 +++++++++++++++++- 1 file changed, 116 insertions(+), 3 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx index d44c3972..d5b22928 100644 --- a/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx @@ -1,6 +1,8 @@ import { Camera, Image, Images, FolderPlus } from 'lucide-react-native'; -import { View, Text, Pressable } from 'react-native'; +import { View, Text, Pressable, Alert } from 'react-native'; import { openCamera, openImageLibrary } from '../../../utils/imagePicker'; +import { useGetPreSignedUrl } from '@/apis/controller/common'; +import { useCreateScrapFromImage } from '@/apis'; export interface AddItemTooltipProps { onClose?: () => void; @@ -13,12 +15,123 @@ export const AddItemTooltip = ({ onOpenQnaImgModal, onOpenFolderModal, }: AddItemTooltipProps) => { + const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); + const { mutate: createScrapFromImage } = useCreateScrapFromImage(); + + // S3에 파일 업로드 + const uploadFileToS3 = async ( + uploadUrl: string, + fileUri: string, + contentDisposition: string + ): Promise => { + try { + // React Native에서 파일 읽기 + const response = await fetch(fileUri); + const blob = await response.blob(); + + // S3에 PUT 요청 + const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'content-disposition': contentDisposition, + }, + body: blob, + }); + + return uploadResponse.ok; + } catch (error) { + console.error('S3 업로드 실패:', error); + return false; + } + }; + + // 이미지 선택 및 업로드 처리 + const handleImageSelect = async (image: any) => { + if (!image || !image.uri) { + return; + } + + try { + // 파일명 추출 (없으면 기본값 사용) + const fileName = image.fileName || `image_${Date.now()}.jpg`; + + // 1. Pre-signed URL 요청 + getPreSignedUrl( + { fileName }, + { + onSuccess: async (data) => { + const { uploadUrl, contentDisposition, file } = data; + + if (!uploadUrl) { + Alert.alert('오류', '업로드 URL을 받아오지 못했습니다.'); + return; + } + + // 2. S3에 파일 업로드 + const uploadSuccess = await uploadFileToS3(uploadUrl, image.uri, contentDisposition); + + if (!uploadSuccess) { + Alert.alert('오류', '파일 업로드에 실패했습니다.'); + return; + } + + // 3. 이미지 기반 스크랩 생성 + createScrapFromImage( + { + imageId: file.id, + }, + { + onSuccess: () => { + Alert.alert('성공', '스크랩이 생성되었습니다.'); + onClose?.(); + }, + onError: (error) => { + console.error('스크랩 생성 실패:', error); + Alert.alert('오류', '스크랩 생성에 실패했습니다.'); + }, + } + ); + }, + onError: (error) => { + console.error('Pre-signed URL 요청 실패:', error); + Alert.alert('오류', '파일 업로드 준비에 실패했습니다.'); + }, + } + ); + } catch (error) { + console.error('이미지 처리 실패:', error); + Alert.alert('오류', '이미지 처리 중 오류가 발생했습니다.'); + } + }; + const onPressCamera = async () => { - const image = await openCamera(); + try { + const image = await openCamera(); + if (image) { + await handleImageSelect(image); + } + } catch (error: any) { + if (error.message?.includes('permission')) { + Alert.alert('권한 필요', '카메라 권한이 필요합니다.'); + } else { + console.error('카메라 오류:', error); + } + } }; const onPressGallery = async () => { - const image = await openImageLibrary(); + try { + const image = await openImageLibrary(); + if (image) { + await handleImageSelect(image); + } + } catch (error: any) { + if (error.message?.includes('permission')) { + Alert.alert('권한 필요', '갤러리 권한이 필요합니다.'); + } else { + console.error('갤러리 오류:', error); + } + } }; return ( From adbab4a382729daf7401eb7845a5ca9e13c53f61 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:18:50 +0900 Subject: [PATCH 072/140] refactor(api): modify fetch query parameters --- .../apis/controller/scrap/useSearchScraps.ts | 29 +++++++++---------- 1 file changed, 13 insertions(+), 16 deletions(-) diff --git a/apps/native/src/apis/controller/scrap/useSearchScraps.ts b/apps/native/src/apis/controller/scrap/useSearchScraps.ts index 0d2a16c6..ebe61089 100644 --- a/apps/native/src/apis/controller/scrap/useSearchScraps.ts +++ b/apps/native/src/apis/controller/scrap/useSearchScraps.ts @@ -1,22 +1,19 @@ -import { useQuery } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { TanstackQueryClient } from '@apis'; import { paths } from '@/types/api/schema'; -type SearchScrapsParams = paths['/api/student/scrap/search/all']['get']['parameters']['query']; -type SearchScrapsResponse = - paths['/api/student/scrap/search/all']['get']['responses']['200']['content']['*/*']; +type SearchScrapsParams = paths['/api/student/scrap/search']['get']['parameters']['query']; export const useSearchScraps = (params: SearchScrapsParams = {}, enabled = true) => { - return useQuery({ - queryKey: ['scrap', 'search', params], - queryFn: async (): Promise => { - const { data } = await client.GET('/api/student/scrap/search/all', { - params: { - query: params, - }, - }); - return data as SearchScrapsResponse; + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/search', + { + params: { + query: params, + }, }, - enabled, - }); + { + enabled, + } + ); }; From ffdd0a3d95b321d96aff3e2c2f990bc517ab584a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:19:39 +0900 Subject: [PATCH 073/140] feat(scrap): implement scrap detail page --- .../components/Modal/Tooltip/ItemTooltip.tsx | 7 +- .../screens/ScrapDetailContentScreen.tsx | 476 ++++++++++++++++++ .../student/scrap/utils/toAlphabetSequence.ts | 11 + .../navigation/student/StudentNavigator.tsx | 8 +- apps/native/src/navigation/student/types.ts | 4 +- apps/native/src/stores/scrapNoteStore.ts | 68 +++ 6 files changed, 566 insertions(+), 8 deletions(-) create mode 100644 apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx create mode 100644 apps/native/src/features/student/scrap/utils/toAlphabetSequence.ts create mode 100644 apps/native/src/stores/scrapNoteStore.ts diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx index 060a7865..47210587 100644 --- a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx @@ -14,6 +14,7 @@ import { useGetScrapDetail, useGetFolders, } from '@/apis'; +import { useNoteStore } from '@/stores/scrapNoteStore'; export interface ItemTooltipProps { props: ScrapListItemProps; @@ -23,6 +24,8 @@ export interface ItemTooltipProps { export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { const navigation = useNavigation>(); + const openNote = useNoteStore((state) => state.openNote); + // API hooks const { mutateAsync: updateScrapName } = useUpdateScrapName(); const { mutateAsync: updateFolder } = useUpdateFolder(); @@ -86,8 +89,8 @@ export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { if (props.type === 'FOLDER') { navigation.push('ScrapContent', { id: String(props.id) }); } else { - // TODO: 스크랩 열기 기능 구현 - showToast('info', '스크랩 열기 기능은 준비 중입니다.'); + openNote({ id: props.id, title: props.name }); + navigation.push('ScrapContentDetail', { id: String(props.id) }); } }, 100); }}> diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx new file mode 100644 index 00000000..fe0122b6 --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx @@ -0,0 +1,476 @@ +import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import { + View, + Text, + Image, + ScrollView, + Pressable, + LayoutChangeEvent, + Modal, + Dimensions, +} from 'react-native'; +import { RouteProp, useRoute, useNavigation } from '@react-navigation/native'; +import { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { ChevronLeft, X, ChevronDown, ChevronUp, Maximize2 } from 'lucide-react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + runOnJS, +} from 'react-native-reanimated'; +import { Container, SegmentedControl, TextButton } from '@/components/common'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +import { useGetScrapDetail } from '@/apis'; +import { LoadingScreen } from '@/components/common'; +import ProblemViewer from '../../problem/components/ProblemViewer'; +import { useNoteStore, Note } from '@/stores/scrapNoteStore'; +import { toAlphabetSequence } from '../utils/toAlphabetSequence'; +import { components } from '@/types/api/schema'; + +type ScrapDetailContentRouteProp = RouteProp; + +const ScrapContentDetailScreen = () => { + const route = useRoute(); + const navigation = useNavigation>(); + const { id } = route.params; + + const { data: scrapDetail, isLoading } = useGetScrapDetail(Number(id)); + + const { openNotes, activeNoteId, setActiveNote, closeNote, reorderNotes } = useNoteStore(); + const [tabLayouts, setTabLayouts] = useState>({}); + const [expandedSections, setExpandedSections] = useState>( + {} + ); + const [selectedFilter, setSelectedFilter] = useState(0); // 0: 전체, 1: 문제, 2+: 포인팅 인덱스 + const [isProblemExpanded, setIsProblemExpanded] = useState(false); + const [isHoveringProblem, setIsHoveringProblem] = useState(false); + + useEffect(() => { + if (activeNoteId && activeNoteId !== Number(id)) { + navigation.setParams({ id: String(activeNoteId) }); + } + }, [activeNoteId, id, navigation]); + + if (isLoading) { + return ; + } + + if (!scrapDetail) { + return ( + + 스크랩을 찾을 수 없습니다. + + ); + } + + const scrap = scrapDetail; + + // 필터 옵션 생성 + const filterOptions = useMemo(() => { + const options = ['전체', '문제']; + if (scrap.pointings && scrap.pointings.length > 0) { + scrap.pointings.forEach((_, idx) => { + options.push(`포인팅 ${toAlphabetSequence(idx)}`); + }); + } + return options; + }, [scrap.pointings]); + + // 필터에 따른 표시 여부 결정 + const shouldShowProblem = selectedFilter === 0 || selectedFilter === 1; + const shouldShowPointing = (pointingIndex: number) => { + if (selectedFilter === 0) return true; // 전체 + if (selectedFilter === 1) return false; // 문제만 + return selectedFilter === pointingIndex + 2; // 특정 포인팅만 + }; + + // 표시할 포인팅이 있는지 확인 + const hasVisiblePointings = useMemo(() => { + if (!scrap.pointings || scrap.pointings.length === 0) return false; + if (selectedFilter === 1) return false; // 문제만 선택 시 포인팅 숨김 + if (selectedFilter === 0) return true; // 전체 선택 시 포인팅 표시 + // 특정 포인팅 선택 시 해당 포인팅이 존재하는지 확인 + const pointingIndex = selectedFilter - 2; + return pointingIndex >= 0 && pointingIndex < scrap.pointings.length; + }, [scrap.pointings, selectedFilter]); + + // 스크랩 데이터를 AllPointings에 전달할 형식으로 변환 + const convertScrapToGroup = useCallback((): + | components['schemas']['PublishProblemGroupResp'] + | null => { + if (!scrap.problem) return null; + + // PointingResp를 PointingWithFeedbackResp로 변환 + const pointingsWithFeedback: components['schemas']['PointingWithFeedbackResp'][] = + scrap.pointings?.map((pointing) => ({ + id: pointing.id, + no: pointing.no, + questionContent: pointing.questionContent, + commentContent: pointing.commentContent, + concepts: pointing.concepts, + isUnderstood: undefined, // 스크랩에서는 피드백 정보가 없음 + })) || []; + + // ProblemExtendResp를 ProblemWithStudyInfoResp로 변환 + const problemWithStudyInfo: components['schemas']['ProblemWithStudyInfoResp'] = { + id: scrap.problem.id, + problemType: scrap.problem.problemType, + parentProblem: scrap.problem.parentProblem, + parentProblemTitle: scrap.problem.parentProblemTitle, + customId: scrap.problem.customId, + createType: scrap.problem.createType, + practiceTest: scrap.problem.practiceTest, + practiceTestNo: scrap.problem.practiceTestNo, + problemContent: scrap.problem.problemContent, + title: scrap.problem.title, + answerType: scrap.problem.answerType, + answer: scrap.problem.answer, + difficulty: scrap.problem.difficulty, + recommendedTimeSec: scrap.problem.recommendedTimeSec, + memo: scrap.problem.memo, + concepts: scrap.problem.concepts, + mainAnalysisImage: scrap.problem.mainAnalysisImage, + mainHandAnalysisImage: scrap.problem.mainHandAnalysisImage, + readingTipContent: scrap.problem.readingTipContent, + oneStepMoreContent: scrap.problem.oneStepMoreContent, + pointings: pointingsWithFeedback, + progress: 'NONE', // 스크랩에서는 진행 상태가 없음 + submitAnswer: 0, // 스크랩에서는 제출 답안이 없음 + isCorrect: false, // 스크랩에서는 정답 여부가 없음 + isDone: false, // 스크랩에서는 완료 여부가 없음 + childProblems: [], // 스크랩에는 childProblems가 없음 + }; + + return { + no: 1, // 스크랩에서는 번호가 없으므로 1로 설정 + problemId: scrap.problem.id, + progress: 'DONE', // 스크랩된 문제는 완료된 것으로 간주 + problem: problemWithStudyInfo, + childProblems: [], + }; + }, [scrap]); + + // 전체보기 버튼 클릭 핸들러 + const handleViewAllPointings = useCallback(() => { + const group = convertScrapToGroup(); + if (!group) return; + + navigation.navigate('AllPointings', { + group, + problemSetTitle: scrap.name || '스크랩', + }); + }, [convertScrapToGroup, navigation, scrap.name]); + + return ( + + + + {navigation.canGoBack() && ( + navigation.goBack()} + className='p-2 md:right-[48px] lg:right-[96px]'> + + + + + )} + {scrap.name || '스크랩 상세'} + + + {openNotes.length > 0 && ( + + + {openNotes.map((note, index) => ( + setActiveNote(note.id)} + onClose={() => closeNote(note.id)} + onLayout={(event: LayoutChangeEvent) => { + const { x, width } = event.nativeEvent.layout; + setTabLayouts((prev) => ({ ...prev, [note.id]: { x, width } })); + }} + onDragEnd={(fromIndex, toIndex) => { + reorderNotes(fromIndex, toIndex); + }} + tabLayouts={tabLayouts} + /> + ))} + + + )} + + + + + {/* 필터 버튼 및 전체보기 */} + {filterOptions.length > 0 && ( + + + {scrap.pointings && scrap.pointings.length > 0 && scrap.problem && ( + + + 전체보기 + + + )} + + )} + {/* 문제 내용 */} + {shouldShowProblem && scrap.problem && scrap.problem.problemContent && ( + + 문제 내용 + setIsHoveringProblem(true)}> + + {isHoveringProblem != true && ( + setIsProblemExpanded(true)} + className='absolute right-2 top-2 z-10 rounded-full bg-black/50 p-2'> + + + )} + + + )} + {/* 포인팅 */} + {hasVisiblePointings && ( + + 포인팅 + + {scrap.pointings.map((pointing, idx) => { + if (!shouldShowPointing(idx)) return null; + const sectionKey = `pointing-${pointing.id}`; + const isCommentExpanded = expandedSections[sectionKey]?.comment ?? false; + + return ( + + + + 포인팅 {toAlphabetSequence(idx)} + + 포인팅 질문 + + {pointing.questionContent && ( + + + + )} + {pointing.concepts && pointing.concepts.length > 0 && ( + + {pointing.concepts.map((concept) => ( + + {concept.name} + + ))} + + )} + {pointing.commentContent && ( + + { + setExpandedSections((prev) => ({ + ...prev, + [sectionKey]: { + ...prev[sectionKey], + comment: !isCommentExpanded, + }, + })); + }} + className='flex-row items-end'> + {isCommentExpanded ? ( + + ) : ( + + )} + + {isCommentExpanded && ( + + )} + + )} + + ); + })} + + + )} + + + + + + {/* 문제 확대 모달 */} + {scrap.problem && scrap.problem.problemContent && ( + setIsProblemExpanded(false)}> + setIsProblemExpanded(false)}> + e.stopPropagation()}> + + 문제 내용 + { + setIsProblemExpanded(false); + setIsHoveringProblem(false); + }} + className='rounded-full bg-gray-200 p-2'> + + + + + + + + + + )} + + ); +}; + +interface DraggableTabProps { + note: Note; + index: number; + isActive: boolean; + onPress: () => void; + onClose: () => void; + onLayout: (event: LayoutChangeEvent) => void; + onDragEnd: (fromIndex: number, toIndex: number) => void; + tabLayouts: Record; +} + +const DraggableTab = ({ + note, + index, + isActive, + onPress, + onClose, + onLayout, + onDragEnd, + tabLayouts, +}: DraggableTabProps) => { + const translateX = useSharedValue(0); + const startX = useSharedValue(0); + const [isDragging, setIsDragging] = useState(false); + const { openNotes } = useNoteStore(); + + const panGesture = Gesture.Pan() + .onStart(() => { + startX.value = translateX.value; + runOnJS(setIsDragging)(true); + }) + .onUpdate((e) => { + translateX.value = startX.value + e.translationX; + }) + .onEnd((e) => { + const finalX = startX.value + e.translationX; + const currentLayout = tabLayouts[note.id]; + + if (!currentLayout) { + translateX.value = withSpring(0); + runOnJS(setIsDragging)(false); + return; + } + + // 드래그된 위치를 기반으로 새로운 인덱스 계산 + const newPosition = currentLayout.x + finalX; + let newIndex = index; + + // 다른 탭들의 위치와 비교하여 새로운 인덱스 결정 + openNotes.forEach((otherNote, otherIndex) => { + const otherLayout = tabLayouts[otherNote.id]; + if (otherLayout && otherIndex !== index) { + const otherCenter = otherLayout.x + otherLayout.width / 2; + const currentCenter = currentLayout.x + currentLayout.width / 2 + finalX; + + if (otherIndex < index && currentCenter < otherCenter) { + newIndex = Math.min(newIndex, otherIndex); + } else if (otherIndex > index && currentCenter > otherCenter) { + newIndex = Math.max(newIndex, otherIndex + 1); + } + } + }); + + newIndex = Math.max(0, Math.min(newIndex, openNotes.length - 1)); + + if (newIndex !== index) { + runOnJS(onDragEnd)(index, newIndex); + } + + translateX.value = withSpring(0); + runOnJS(setIsDragging)(false); + }); + + const animatedStyle = useAnimatedStyle(() => ({ + transform: [{ translateX: translateX.value }], + zIndex: isDragging ? 10 : 1, + opacity: isDragging ? 0.8 : 1, + })); + + return ( + + + + + {note.title} + + + { + e.stopPropagation(); + onClose(); + }} + className='items-center justify-center rounded-full bg-gray-300 p-0.5'> + + + + + ); +}; + +export default ScrapContentDetailScreen; diff --git a/apps/native/src/features/student/scrap/utils/toAlphabetSequence.ts b/apps/native/src/features/student/scrap/utils/toAlphabetSequence.ts new file mode 100644 index 00000000..dfb0a938 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/toAlphabetSequence.ts @@ -0,0 +1,11 @@ +export const toAlphabetSequence = (num: number) => { + let result = ''; + let n = num; + + while (n >= 0) { + result = String.fromCharCode((n % 26) + 65) + result; + n = Math.floor(n / 26) - 1; + } + + return result; +}; diff --git a/apps/native/src/navigation/student/StudentNavigator.tsx b/apps/native/src/navigation/student/StudentNavigator.tsx index 48c633ad..9d4b7177 100644 --- a/apps/native/src/navigation/student/StudentNavigator.tsx +++ b/apps/native/src/navigation/student/StudentNavigator.tsx @@ -11,12 +11,9 @@ import { import StudentTabs from './StudentTabs'; import { StudentRootStackParamList } from './types'; import NotificationHeader from './components/NotificationHeader'; -import { - DeletedScrapScreen, - ScrapScreen, - SearchScrapScreen, -} from '@/features/student/scrap'; +import { DeletedScrapScreen, ScrapScreen, SearchScrapScreen } from '@/features/student/scrap'; import ScrapContentScreen from '@/features/student/scrap/screens/ScrapContentScreen'; +import ScrapContentDetailScreen from '@/features/student/scrap/screens/ScrapDetailContentScreen'; const StudentRootStack = createNativeStackNavigator(); @@ -48,6 +45,7 @@ const StudentNavigator = () => { + ); }; diff --git a/apps/native/src/navigation/student/types.ts b/apps/native/src/navigation/student/types.ts index 8daf8ffa..3781b02b 100644 --- a/apps/native/src/navigation/student/types.ts +++ b/apps/native/src/navigation/student/types.ts @@ -28,5 +28,7 @@ export type StudentRootStackParamList = { }; SearchScrap: undefined; DeletedScrap: undefined; + ScrapContentDetail: { + id: string; + }; }; - diff --git a/apps/native/src/stores/scrapNoteStore.ts b/apps/native/src/stores/scrapNoteStore.ts new file mode 100644 index 00000000..9aa975ca --- /dev/null +++ b/apps/native/src/stores/scrapNoteStore.ts @@ -0,0 +1,68 @@ +import { create } from 'zustand'; + +export type Note = { + id: number; + title: string; +}; + +type NoteStore = { + openNotes: Note[]; + activeNoteId: number | null; + + openNote: (note: Note) => void; + closeNote: (noteId: number) => void; + setActiveNote: (noteId: number) => void; + reorderNotes: (fromIndex: number, toIndex: number) => void; +}; + +export const useNoteStore = create((set, get) => ({ + openNotes: [], + activeNoteId: null, + + openNote: (note) => { + const { openNotes } = get(); + const exists = openNotes.find((n) => n.id === note.id); + + // 이미 열려 있으면 추가 X + if (!exists) { + set({ + openNotes: [...openNotes, note], + activeNoteId: note.id, + }); + } else { + set({ activeNoteId: note.id }); + } + }, + + closeNote: (noteId) => { + const { openNotes, activeNoteId } = get(); + const filtered = openNotes.filter((n) => n.id !== noteId); + + set({ + openNotes: filtered, + activeNoteId: + noteId === activeNoteId ? (filtered[filtered.length - 1]?.id ?? null) : activeNoteId, + }); + }, + + setActiveNote: (noteId) => { + set({ activeNoteId: noteId }); + }, + + reorderNotes: (fromIndex, toIndex) => { + const { openNotes } = get(); + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= openNotes.length || + toIndex >= openNotes.length + ) { + return; + } + const newNotes = [...openNotes]; + const [moved] = newNotes.splice(fromIndex, 1); + newNotes.splice(toIndex, 0, moved); + set({ openNotes: newNotes }); + }, +})); From ebf902b12397a4deac5a62cd4bf3cd6fee0c3be6 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Fri, 19 Dec 2025 23:19:48 +0900 Subject: [PATCH 074/140] feat(scrap): implement scrap detail page --- apps/native/babel.config.js | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/native/babel.config.js b/apps/native/babel.config.js index 7d507e11..c732fb50 100644 --- a/apps/native/babel.config.js +++ b/apps/native/babel.config.js @@ -2,5 +2,6 @@ module.exports = function (api) { api.cache(true); return { presets: [['babel-preset-expo', { jsxImportSource: 'nativewind' }], 'nativewind/babel'], + plugins: ['react-native-reanimated/plugin'], }; }; From 4490a104491968bc54e06f8ebf4186c27f8a9650 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:40:39 +0900 Subject: [PATCH 075/140] chore: add react-native-skia dependency --- apps/native/package.json | 1 + pnpm-lock.yaml | 53 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+) diff --git a/apps/native/package.json b/apps/native/package.json index 6bbb4738..02c153ab 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -20,6 +20,7 @@ "@react-navigation/native": "^7.1.8", "@react-navigation/native-stack": "^7.8.0", "@react-navigation/stack": "^7.1.1", + "@shopify/react-native-skia": "2.2.12", "@tanstack/react-query": "^5.66.0", "dotenv": "^17.2.3", "expo": "~54.0.25", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dbcc7625..72ebf1a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -204,6 +204,9 @@ importers: '@react-navigation/stack': specifier: ^7.1.1 version: 7.6.7(@react-navigation/native@7.1.21(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-safe-area-context@5.4.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-screens@4.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + '@shopify/react-native-skia': + specifier: 2.2.12 + version: 2.2.12(react-native-reanimated@4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) '@tanstack/react-query': specifier: ^5.66.0 version: 5.81.5(react@19.1.0) @@ -2737,6 +2740,19 @@ packages: '@rushstack/eslint-patch@1.12.0': resolution: {integrity: sha512-5EwMtOqvJMMa3HbmxLlF74e+3/HhwBTMcvt3nqVJgGCozO6hzIPOBlwm8mGVNR9SN2IJpxSnlxczyDjcn7qIyw==} + '@shopify/react-native-skia@2.2.12': + resolution: {integrity: sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A==} + hasBin: true + peerDependencies: + react: '>=19.0' + react-native: '>=0.78' + react-native-reanimated: '>=3.19.1' + peerDependenciesMeta: + react-native: + optional: true + react-native-reanimated: + optional: true + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3650,6 +3666,9 @@ packages: peerDependencies: vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0 + '@webgpu/types@0.1.21': + resolution: {integrity: sha512-pUrWq3V5PiSGFLeLxoGqReTZmiiXwY3jRkIG5sLLKjyqNxrwm/04b4nw7LSmGWJcKk59XOM/YRTUwOzo4MMlow==} + '@xmldom/xmldom@0.8.11': resolution: {integrity: sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==} engines: {node: '>=10.0.0'} @@ -4029,6 +4048,9 @@ packages: caniuse-lite@1.0.30001726: resolution: {integrity: sha512-VQAUIUzBiZ/UnlM28fSp2CRF3ivUn1BWEvxMcVTNwpw91Py1pGbPIyIKtd+tzct9C3ouceCVdGAXxZOpZAsgdw==} + canvaskit-wasm@0.40.0: + resolution: {integrity: sha512-Od2o+ZmoEw9PBdN/yCGvzfu0WVqlufBPEWNG452wY7E9aT8RBE+ChpZF526doOlg7zumO4iCS+RAeht4P0Gbpw==} + chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} engines: {node: '>=4'} @@ -6956,6 +6978,12 @@ packages: '@types/react': optional: true + react-reconciler@0.31.0: + resolution: {integrity: sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.0.0 + react-refresh@0.14.2: resolution: {integrity: sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA==} engines: {node: '>=0.10.0'} @@ -7197,6 +7225,9 @@ packages: scheduler@0.19.1: resolution: {integrity: sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA==} + scheduler@0.25.0: + resolution: {integrity: sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==} + scheduler@0.26.0: resolution: {integrity: sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==} @@ -10658,6 +10689,15 @@ snapshots: '@rushstack/eslint-patch@1.12.0': {} + '@shopify/react-native-skia@2.2.12(react-native-reanimated@4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)': + dependencies: + canvaskit-wasm: 0.40.0 + react: 19.1.0 + react-reconciler: 0.31.0(react@19.1.0) + optionalDependencies: + react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + react-native-reanimated: 4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + '@sinclair/typebox@0.27.8': {} '@sinonjs/commons@3.0.1': @@ -11941,6 +11981,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@webgpu/types@0.1.21': {} + '@xmldom/xmldom@0.8.11': {} abort-controller@3.0.0: @@ -12399,6 +12441,10 @@ snapshots: caniuse-lite@1.0.30001726: {} + canvaskit-wasm@0.40.0: + dependencies: + '@webgpu/types': 0.1.21 + chalk@2.4.2: dependencies: ansi-styles: 3.2.1 @@ -15994,6 +16040,11 @@ snapshots: - supports-color - utf-8-validate + react-reconciler@0.31.0(react@19.1.0): + dependencies: + react: 19.1.0 + scheduler: 0.25.0 + react-refresh@0.14.2: {} react-refresh@0.17.0: {} @@ -16266,6 +16317,8 @@ snapshots: loose-envify: 1.4.0 object-assign: 4.1.1 + scheduler@0.25.0: {} + scheduler@0.26.0: {} semver@6.3.1: {} From 005d502693f69205ddbbc63c953adbd50ccdf787 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:40:49 +0900 Subject: [PATCH 076/140] feat: implement handwriting editor --- .../student/scrap/components/skia/drawing.tsx | 680 ++++++++++++++++++ 1 file changed, 680 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/skia/drawing.tsx diff --git a/apps/native/src/features/student/scrap/components/skia/drawing.tsx b/apps/native/src/features/student/scrap/components/skia/drawing.tsx new file mode 100644 index 00000000..d4431f92 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/skia/drawing.tsx @@ -0,0 +1,680 @@ +import React, { + forwardRef, + useImperativeHandle, + useRef, + useState, + useCallback, + useMemo, +} from 'react'; +import { + View, + StyleSheet, + TextInput, + Dimensions, + Pressable, + Text as RNText, + ScrollView, +} from 'react-native'; +import { + Canvas, + Path, + SkPath, + Skia, + Text, + useFont, + Circle, + Group, +} from '@shopify/react-native-skia'; +import { Gesture, GestureDetector, PointerType } from 'react-native-gesture-handler'; +import { runOnJS, useSharedValue, useDerivedValue } from 'react-native-reanimated'; +import { buildSmoothPath } from '../../utils/skia/smoothing'; + +export type Point = { x: number; y: number }; +export type Stroke = { points: Point[]; color: string; width: number }; +export type TextItem = { + id: string; + text: string; + x: number; + y: number; + fontSize: number; + color: string; +}; +export type DrawingCanvasRef = { + clear: () => void; + undo: () => void; + getStrokes: () => Stroke[]; +}; + +type Props = { + strokeColor?: string; + strokeWidth?: number; + onChange?: (strokes: Stroke[]) => void; + eraserMode?: boolean; + eraserSize?: number; + textMode?: boolean; + textFontSize?: number; +}; + +const DrawingCanvas = forwardRef( + ( + { + strokeColor = 'black', + strokeWidth = 3, + onChange, + eraserMode = false, + eraserSize = 20, + textMode = false, + textFontSize = 32, + }, + ref + ) => { + const [paths, setPaths] = useState([]); + const [strokes, setStrokes] = useState([]); + const [texts, setTexts] = useState([]); + const [, setTick] = useState(0); + const [activeTextInput, setActiveTextInput] = useState<{ + id: string; + x: number; + y: number; + value: string; + } | null>(null); + const textInputRef = useRef(null); + const containerLayout = useRef<{ x: number; y: number; width: number; height: number } | null>( + null + ); + const canvasHeight = useRef(800); // 기본 캔버스 높이 + const maxY = useRef(0); // 그려진 내용의 최대 Y 좌표 + + // 호버 좌표를 저장할 SharedValue (성능을 위해 스레드 분리) + const hoverX = useSharedValue(0); + const hoverY = useSharedValue(0); + const showHover = useSharedValue(false); + + const livePath = useRef(Skia.Path.Make()); + const currentPoints = useRef([]); + const strokesRef = useRef([]); + const eraserPoints = useRef([]); + const lastEraserTime = useRef(0); + const ERASER_THROTTLE_MS = 16; // ~60fps + + // 폰트 로드 + const font = useFont(require('@assets/fonts/PretendardVariable.ttf'), textFontSize); + + const addPoint = useCallback((x: number, y: number) => { + currentPoints.current.push({ x, y }); + // 최대 Y 좌표 업데이트 + if (y > maxY.current) { + maxY.current = y; + // 여유 공간을 위해 200px 추가 + canvasHeight.current = Math.max(800, maxY.current + 200); + setTick((t) => t + 1); + } + // 경로는 매번 재생성하되, 렌더링은 최적화 + livePath.current = buildSmoothPath(currentPoints.current); + setTick((t) => t + 1); + }, []); + + const startStroke = useCallback((x: number, y: number) => { + currentPoints.current = [{ x, y }]; + livePath.current = buildSmoothPath(currentPoints.current); + setTick((t) => t + 1); + }, []); + + const finalizeStroke = useCallback(() => { + if (currentPoints.current.length === 0) { + livePath.current = Skia.Path.Make(); + setTick((t) => t + 1); + return; + } + + const pointsToFinalize = [...currentPoints.current]; + // 최대 Y 좌표 업데이트 + const strokeMaxY = Math.max(...pointsToFinalize.map((p) => p.y)); + if (strokeMaxY > maxY.current) { + maxY.current = strokeMaxY; + canvasHeight.current = Math.max(800, maxY.current + 200); + } + + const newPath = buildSmoothPath(pointsToFinalize); + const strokeData: Stroke = { + points: pointsToFinalize, + color: strokeColor, + width: strokeWidth, + }; + + // 배치 업데이트: paths와 strokes를 함께 업데이트 + setStrokes((prev) => { + const next = [...prev, strokeData]; + setPaths((prevPaths) => [...prevPaths, newPath]); + strokesRef.current = next; + onChange?.(next); + setTick((t) => t + 1); + return next; + }); + + currentPoints.current = []; + livePath.current = Skia.Path.Make(); + }, [strokeColor, strokeWidth, onChange]); + + // 지우개: 터치한 위치에서 가까운 점들을 제거 + const eraseAtPoint = useCallback( + (x: number, y: number) => { + const now = Date.now(); + // Throttle: 너무 자주 호출되지 않도록 제한 + if (now - lastEraserTime.current < ERASER_THROTTLE_MS) { + return; + } + lastEraserTime.current = now; + + const thresholdSquared = eraserSize * eraserSize; // 제곱 비교로 sqrt 제거 + + setStrokes((prevStrokes) => { + // 1. 지울 선들을 걸러냅니다. (선 위의 점 중 하나라도 지우개 반경에 닿으면 삭제) + const nextStrokes = prevStrokes.filter((stroke) => { + // 최적화: 제곱 거리 비교 (sqrt 제거) + const isTouched = stroke.points.some((point) => { + const dx = point.x - x; + const dy = point.y - y; + const distanceSquared = dx * dx + dy * dy; + return distanceSquared < thresholdSquared; + }); + return !isTouched; // 닿지 않은 선들만 남김 + }); + + // 2. 만약 지워진 선이 있다면 Path 배열도 업데이트 + if (nextStrokes.length !== prevStrokes.length) { + // 경로를 한 번에 생성 + const newPaths = nextStrokes.map((s) => buildSmoothPath(s.points)); + setPaths(newPaths); + strokesRef.current = nextStrokes; + onChange?.(nextStrokes); + setTick((t) => t + 1); + return nextStrokes; + } + + return prevStrokes; + }); + }, + [eraserSize, onChange] + ); + + const addEraserPoint = useCallback( + (x: number, y: number) => { + eraserPoints.current.push({ x, y }); + eraseAtPoint(x, y); + }, + [eraseAtPoint] + ); + + const startEraser = useCallback( + (x: number, y: number) => { + eraserPoints.current = [{ x, y }]; + eraseAtPoint(x, y); + }, + [eraseAtPoint] + ); + + const finalizeEraser = useCallback(() => { + eraserPoints.current = []; + }, []); + + const deleteText = useCallback((textId: string) => { + setTexts((prev) => prev.filter((t) => t.id !== textId)); + setTick((t) => t + 1); + }, []); + + // 텍스트 영역과 충돌하는지 확인 (16px 여백 포함) + const isNearExistingText = useCallback( + (x: number, y: number): boolean => { + const safeDistance = 16; + const buttonSize = 20; + + for (const textItem of texts) { + // 텍스트 너비 추정 + const estimatedCharWidth = textFontSize * 0.6; + const textWidth = textItem.text.length * estimatedCharWidth; + const textHeight = textFontSize; + + // 텍스트 영역 (16px 여백 포함) + const textLeft = textItem.x - safeDistance; + const textRight = textItem.x + textWidth + safeDistance + buttonSize + 4; // X 버튼 포함 + const textTop = textItem.y - textHeight - safeDistance; + const textBottom = textItem.y + safeDistance; + + // 클릭한 위치가 텍스트 영역 내에 있는지 확인 + if (x >= textLeft && x <= textRight && y >= textTop && y <= textBottom) { + return true; + } + } + return false; + }, + [texts, textFontSize] + ); + + const addText = useCallback( + (x: number, y: number) => { + // 기존 텍스트 주변 16px 내에서는 새 텍스트 박스 생성 안 함 + if (isNearExistingText(x, y)) { + return; + } + + // 상하 16px 여백 고려 + const padding = 16; + const adjustedY = Math.max( + padding, + Math.min(y, (containerLayout.current?.height || 400) - padding) + ); + + const textId = Date.now().toString(); + setActiveTextInput({ + id: textId, + x: x, + y: adjustedY, + value: '', + }); + + // TextInput 포커스 + setTimeout(() => { + textInputRef.current?.focus(); + }, 100); + }, + [isNearExistingText] + ); + + const confirmTextInput = useCallback(() => { + if (activeTextInput && activeTextInput.value.trim()) { + const newText: TextItem = { + id: activeTextInput.id, + text: activeTextInput.value, + x: activeTextInput.x, + y: activeTextInput.y, + fontSize: textFontSize, + color: strokeColor, + }; + // 최대 Y 좌표 업데이트 + if (activeTextInput.y > maxY.current) { + maxY.current = activeTextInput.y; + canvasHeight.current = Math.max(800, maxY.current + 200); + } + setTexts((prev) => [...prev, newText]); + setTick((t) => t + 1); + } + setActiveTextInput(null); + }, [activeTextInput, textFontSize, strokeColor]); + + const handleTextInputBlur = useCallback(() => { + if (activeTextInput) { + confirmTextInput(); + } + }, [activeTextInput, confirmTextInput]); + + const handleTextInputChange = useCallback( + (text: string) => { + if (activeTextInput) { + setActiveTextInput((prev) => (prev ? { ...prev, value: text } : null)); + } + }, + [activeTextInput] + ); + + const undo = useCallback(() => { + // 활성 텍스트 입력이 있으면 먼저 취소 + if (activeTextInput) { + setActiveTextInput(null); + return; + } + + // 텍스트가 있으면 텍스트부터 제거, 없으면 스트로크 제거 + setTexts((prev) => { + if (prev.length > 0) { + setTick((t) => t + 1); + return prev.slice(0, -1); + } + return prev; + }); + + if (texts.length === 0) { + setStrokes((prev) => { + if (prev.length === 0) return prev; + const next = prev.slice(0, -1); + // paths도 함께 업데이트 + setPaths((prevPaths) => prevPaths.slice(0, -1)); + strokesRef.current = next; + onChange?.(next); + setTick((t) => t + 1); + return next; + }); + } + }, [onChange, texts.length, activeTextInput]); + + useImperativeHandle(ref, () => ({ + clear() { + setPaths([]); + setStrokes([]); + setTexts([]); + setActiveTextInput(null); + strokesRef.current = []; + livePath.current = Skia.Path.Make(); + maxY.current = 0; + canvasHeight.current = 800; + setTick((t) => t + 1); + }, + undo, + getStrokes: () => strokesRef.current, + })); + + const tap = useMemo( + () => + Gesture.Tap().onEnd((e) => { + 'worklet'; + // 텍스트 입력은 손가락도 허용 (모든 입력 타입 허용) + if (textMode && !eraserMode) { + runOnJS(addText)(e.x, e.y); + } + }), + [textMode, eraserMode, addText] + ); + + const pan = useMemo( + () => + Gesture.Pan() + .minPointers(1) + .maxPointers(1) // 한 손가락만 허용 (두 손가락은 스크롤) + .onBegin((e) => { + 'worklet'; + // 펜슬만 허용 (제스처 이벤트에서 직접 pointerType 확인) + const pointerType = e.pointerType; + if (pointerType !== PointerType.STYLUS && pointerType !== PointerType.MOUSE) { + return; + } + showHover.value = false; // 그리기 시작 시 호버 숨김 + if (textMode) return; // 텍스트 모드에서는 그리기 비활성화 + if (eraserMode) { + runOnJS(startEraser)(e.x, e.y); + } else { + runOnJS(startStroke)(e.x, e.y); + } + }) + .onUpdate((e) => { + 'worklet'; + // 펜슬만 허용 (제스처 이벤트에서 직접 pointerType 확인) + const pointerType = e.pointerType; + if (pointerType !== PointerType.STYLUS && pointerType !== PointerType.MOUSE) { + return; + } + if (textMode) return; + if (eraserMode) { + runOnJS(addEraserPoint)(e.x, e.y); + } else { + runOnJS(addPoint)(e.x, e.y); + } + }) + .onEnd(() => { + 'worklet'; + if (textMode) return; + if (eraserMode) { + runOnJS(finalizeEraser)(); + } else { + runOnJS(finalizeStroke)(); + } + }) + .minDistance(1), + [ + textMode, + eraserMode, + startStroke, + addPoint, + finalizeStroke, + startEraser, + addEraserPoint, + finalizeEraser, + ] + ); + + // 호버 제스처 (펜슬/마우스에서만 작동) + const hoverGesture = useMemo( + () => + Gesture.Hover() + .onBegin((e) => { + 'worklet'; + // 펜슬/마우스에서만 호버 표시 + const pointerType = e.pointerType; + if (pointerType === PointerType.STYLUS || pointerType === PointerType.MOUSE) { + hoverX.value = e.x; + hoverY.value = e.y; + showHover.value = true; + } + }) + .onUpdate((e) => { + 'worklet'; + // 펜슬/마우스에서만 호버 표시 + const pointerType = e.pointerType; + if (pointerType === PointerType.STYLUS || pointerType === PointerType.MOUSE) { + hoverX.value = e.x; + hoverY.value = e.y; + showHover.value = true; + } else { + showHover.value = false; + } + }) + .onEnd(() => { + 'worklet'; + showHover.value = false; + }) + .onFinalize(() => { + 'worklet'; + showHover.value = false; + }), + [] + ); + + // 호버 opacity를 위한 derived value + const hoverOpacity = useDerivedValue(() => { + return showHover.value ? 0.6 : 0; + }, [showHover]); + + const composedGesture = useMemo( + () => Gesture.Simultaneous(Gesture.Race(tap, pan), hoverGesture), + [tap, pan, hoverGesture] + ); + + // 경로 렌더링 최적화: paths 배열이 변경될 때만 재렌더링 + // 각 stroke는 저장된 width와 color를 사용 + const renderedPaths = useMemo( + () => + paths.map((p, i) => { + const stroke = strokes[i]; + return ( + + ); + }), + [paths, strokes, strokeWidth, strokeColor] + ); + + // 텍스트 렌더링 최적화 + const renderedTexts = useMemo( + () => + font + ? texts.map((textItem) => ( + + )) + : null, + [texts, font] + ); + + // 텍스트 삭제 버튼 렌더링 (텍스트 모드일 때만) + const renderedTextDeleteButtons = useMemo(() => { + if (!textMode || eraserMode) return null; + + return texts.map((textItem) => { + // 텍스트 너비 추정 + const estimatedCharWidth = textFontSize * 0.8; + const textWidth = textItem.text.length * estimatedCharWidth; + const buttonSize = 20; + const buttonX = textItem.x + textWidth + 4; + const buttonY = textItem.y - textFontSize + (textFontSize - buttonSize) / 2; + + return ( + deleteText(textItem.id)}> + × + + ); + }); + }, [texts, textMode, eraserMode, textFontSize, deleteText]); + + return ( + + + { + const { x, y, width, height } = e.nativeEvent.layout; + containerLayout.current = { x, y, width, height }; + }}> + + {renderedPaths} + {currentPoints.current.length > 0 && ( + + )} + {renderedTexts} + + + + + + + {/* 인라인 텍스트 입력 박스 */} + {activeTextInput && ( + + + + )} + + {/* 텍스트 삭제 버튼 */} + {renderedTextDeleteButtons} + + + + ); + } +); + +const styles = StyleSheet.create({ + scrollView: { + flex: 1, + }, + scrollContent: { + flexGrow: 1, + }, + container: { minHeight: 400, position: 'relative' }, + canvas: { width: '100%', backgroundColor: 'white' }, + textInputWrapper: { + position: 'absolute', + backgroundColor: 'transparent', + minWidth: 200, + maxWidth: Dimensions.get('window').width * 0.4 - 40, + }, + inlineTextInput: { + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + margin: 0, + textAlignVertical: 'top', + width: '100%', + }, + deleteButton: { + position: 'absolute', + backgroundColor: 'rgba(0, 0, 0, 0.6)', + borderRadius: 10, + justifyContent: 'center', + alignItems: 'center', + zIndex: 10, + }, + deleteButtonText: { + color: 'white', + fontSize: 16, + fontWeight: 'bold', + lineHeight: 16, + }, +}); + +export default React.memo(DrawingCanvas); From be128539fd825c8583540f6422268719c851f6ef Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Mon, 22 Dec 2025 16:41:00 +0900 Subject: [PATCH 077/140] feat: add smoothing to handwriting editor --- .../student/scrap/utils/skia/smoothing.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/skia/smoothing.ts diff --git a/apps/native/src/features/student/scrap/utils/skia/smoothing.ts b/apps/native/src/features/student/scrap/utils/skia/smoothing.ts new file mode 100644 index 00000000..032ea4ab --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/skia/smoothing.ts @@ -0,0 +1,40 @@ +import { Skia, SkPath } from '@shopify/react-native-skia'; + +type Point = { x: number; y: number }; + +export function buildSmoothPath(points: Point[]): SkPath { + const path = Skia.Path.Make(); + if (points.length === 0) return path; + + // 첫 번째 점으로 이동 + path.moveTo(points[0].x, points[0].y); + + if (points.length < 3) { + // 점이 2개뿐일 때는 단순 직선 연결 + if (points.length === 2) { + path.lineTo(points[1].x, points[1].y); + } + return path; + } + + // 쿼드라틱 베지에 곡선(Quadratic Bezier)을 이용한 부드러운 경로 생성 + for (let i = 1; i < points.length - 1; i++) { + const p0 = points[i]; + const p1 = points[i + 1]; + + // 좌표값이 유효한지 확인 (NaN 방지) + if (isNaN(p0.x) || isNaN(p0.y) || isNaN(p1.x) || isNaN(p1.y)) continue; + + const midX = (p0.x + p1.x) / 2; + const midY = (p0.y + p1.y) / 2; + + // p0를 제어점으로 사용하고 mid point를 종착점으로 사용하여 부드럽게 연결 + path.quadTo(p0.x, p0.y, midX, midY); + } + + // 마지막 점까지 연결 + const lastPoint = points[points.length - 1]; + path.lineTo(lastPoint.x, lastPoint.y); + + return path; +} From 66e9adf5efcf45ccb57e0eee0b6fb5d526b33c6e Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:54:02 +0900 Subject: [PATCH 078/140] Refactor: relocate handwriting API to dedicated directory --- .../scrap/{ => handwriting}/deleteHandwriting.ts | 0 .../scrap/{ => handwriting}/putUpdateHandwriting.ts | 0 .../scrap/{ => handwriting}/useGetHandwriting.ts | 0 apps/native/src/apis/controller/scrap/index.ts | 7 ++++--- 4 files changed, 4 insertions(+), 3 deletions(-) rename apps/native/src/apis/controller/scrap/{ => handwriting}/deleteHandwriting.ts (100%) rename apps/native/src/apis/controller/scrap/{ => handwriting}/putUpdateHandwriting.ts (100%) rename apps/native/src/apis/controller/scrap/{ => handwriting}/useGetHandwriting.ts (100%) diff --git a/apps/native/src/apis/controller/scrap/deleteHandwriting.ts b/apps/native/src/apis/controller/scrap/handwriting/deleteHandwriting.ts similarity index 100% rename from apps/native/src/apis/controller/scrap/deleteHandwriting.ts rename to apps/native/src/apis/controller/scrap/handwriting/deleteHandwriting.ts diff --git a/apps/native/src/apis/controller/scrap/putUpdateHandwriting.ts b/apps/native/src/apis/controller/scrap/handwriting/putUpdateHandwriting.ts similarity index 100% rename from apps/native/src/apis/controller/scrap/putUpdateHandwriting.ts rename to apps/native/src/apis/controller/scrap/handwriting/putUpdateHandwriting.ts diff --git a/apps/native/src/apis/controller/scrap/useGetHandwriting.ts b/apps/native/src/apis/controller/scrap/handwriting/useGetHandwriting.ts similarity index 100% rename from apps/native/src/apis/controller/scrap/useGetHandwriting.ts rename to apps/native/src/apis/controller/scrap/handwriting/useGetHandwriting.ts diff --git a/apps/native/src/apis/controller/scrap/index.ts b/apps/native/src/apis/controller/scrap/index.ts index a5e41c06..ee5c8cf2 100644 --- a/apps/native/src/apis/controller/scrap/index.ts +++ b/apps/native/src/apis/controller/scrap/index.ts @@ -1,10 +1,11 @@ // GET APIs export * from './useGetScrapDetail'; +export * from './useGetFoldersDetail'; export * from './useGetFolders'; export * from './useGetTrash'; export * from './useSearchScraps'; export * from './useGetScrapsByFolder'; -export * from './useGetHandwriting'; +export * from './handwriting/useGetHandwriting'; // POST APIs export * from './postCreateScrap'; @@ -21,13 +22,13 @@ export * from './putUpdateScrapText'; export * from './putUpdateFolder'; export * from './putMoveScraps'; export * from './putRestoreTrash'; -export * from './putUpdateHandwriting'; +export * from './handwriting/putUpdateHandwriting'; // DELETE APIs export * from './deleteScrap'; export * from './deleteFolders'; export * from './deletePermanentTrash'; export * from './deleteEmptyTrash'; -export * from './deleteHandwriting'; +export * from './handwriting/deleteHandwriting'; export * from './deleteUnscrapFromProblem'; export * from './deleteUnscrapFromPointing'; From dc06dbd36cb23ab2941500167aa0d5d7c02c5d83 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:55:48 +0900 Subject: [PATCH 079/140] Refactor Card component to support responsive sizing in grid layout --- .../scrap/components/Card/ScrapHeadCard.tsx | 6 +- .../scrap/components/Card/cards/ScrapCard.tsx | 69 ++++++++++++------- .../Card/cards/SearchResultCard.tsx | 59 +++++++++++----- 3 files changed, 87 insertions(+), 47 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx index f5ea1c53..e7ecce2b 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx @@ -77,8 +77,8 @@ export const ScrapAddItem = () => { /> )} from={ - - + + @@ -161,7 +161,7 @@ export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { return ( navigation.push('ScrapContent', { id: String(props.id) })}> + onPress={() => navigation.push('ScrapContent', { id: props.id })}> 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 2c2312c9..6a6e1a37 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 @@ -1,5 +1,5 @@ import { Pressable, View, Text, Image } from 'react-native'; -import React from 'react'; +import React, { useState } from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; import { TooltipPopover, ItemTooltipBox } from '../../Modal/Tooltip'; @@ -9,6 +9,7 @@ import { useNavigation } from '@react-navigation/native'; import type { ScrapListItemProps } from '../types'; import { isItemSelected } from '../../../utils/reducer'; import { useNoteStore, Note } from '@/stores/scrapNoteStore'; +import { MoveScrapModal } from '../../Modal/MoveScrapModal'; export const ScrapCard = (props: ScrapListItemProps) => { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; @@ -16,19 +17,23 @@ export const ScrapCard = (props: ScrapListItemProps) => { const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); + const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); + const thumbnailUrl = props.type === 'SCRAP' ? props.thumbnailUrl : undefined; const cardContent = ( - - {thumbnailUrl ? ( - - ) : ( - - )} + + + {thumbnailUrl ? ( + + ) : ( + + )} + {state.isSelecting && ( { )} - - - - {props.name} - - {!state.isSelecting && ( - } - children={(close) => } - /> - )} - + + + {props.name} + + {!state.isSelecting && ( + } + children={(close) => ( + setIsMoveModalVisible(true)} + /> + )} + /> + )} + setIsMoveModalVisible(false)} + selectedItems={[{ id: props.id, type: props.type }]} + onSuccess={() => { + // 이동 성공 후 필요한 경우 데이터 갱신 + }} + /> {props.type === 'FOLDER' && props.scrapCount !== undefined && ( {props.scrapCount} )} - {new Date(props.createdAt).toLocaleDateString()} + {new Date(props.createdAt).toLocaleDateString('ko-kr')} @@ -77,10 +94,10 @@ export const ScrapCard = (props: ScrapListItemProps) => { } if (props.type === 'FOLDER') { - navigation.push('ScrapContent', { id: String(props.id) }); + navigation.push('ScrapContent', { id: props.id }); } else if (props.type === 'SCRAP') { openNote({ id: props.id, title: props.name }); - navigation.push('ScrapContentDetail', { id: String(props.id) }); + navigation.push('ScrapContentDetail', { id: props.id }); } }}> {cardContent} 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 fdad748a..e62956f8 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 @@ -1,44 +1,67 @@ -import { Pressable, View, Text } from 'react-native'; +import { Pressable, View, Text, Image } from 'react-native'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; import type { ScrapItem } from '@/features/student/scrap/utils/types'; +import { it } from 'node:test'; +import { useGetFolderDetail } from '@/apis'; +import { useNoteStore } from '@/stores/scrapNoteStore'; export interface SearchResultCardProps { item: ScrapItem; } -/** - * 검색 결과 카드 컴포넌트 - */ export const SearchResultCard = ({ item }: SearchResultCardProps) => { const navigation = useNavigation>(); + const { data: foldersDetailData } = useGetFolderDetail(Number(item.folderId), !!item.folderId); + const folderName = foldersDetailData?.name; - return ( - { - if (item.type === 'FOLDER') { - navigation.push('ScrapContent', { id: String(item.id) }); - } - // TODO: 스크랩 상세 화면으로 이동 - }}> - + const openNote = useNoteStore((state) => state.openNote); + + const thumbnailUrl = item.type === 'SCRAP' ? item.thumbnailUrl : undefined; + + const cardContent = ( + + + {thumbnailUrl ? ( + + ) : ( + + )} + - + {folderName && {folderName}} + {item.name} - {item.type === 'FOLDER' && 폴더} + {item.type === 'FOLDER' && 폴더} - {new Date(item.createdAt).toLocaleDateString()} + {new Date(item.createdAt).toLocaleDateString('ko-kr')} + + ); + + return ( + { + if (item.type === 'FOLDER') { + navigation.push('ScrapContent', { id: item.id }); + } else if (item.type === 'SCRAP') { + openNote({ id: item.id, title: item.name }); + navigation.push('ScrapContentDetail', { id: item.id }); + } + }}> + {cardContent} ); }; - From a260fddb3cccd30bbef8b12c4a366a604302b4a5 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:57:21 +0900 Subject: [PATCH 080/140] Refactor Grid component to support responsive layout --- .../scrap/components/Card/ScrapCardGrid.tsx | 29 ++++++++++++++----- 1 file changed, 22 insertions(+), 7 deletions(-) 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 aea2562a..e5bd44d8 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -1,4 +1,4 @@ -import { FlatList, View } from 'react-native'; +import { Dimensions, FlatList, View } from 'react-native'; import { Action, State } from '../../utils/reducer'; import { ScrapCard } from './cards/ScrapCard'; import { SearchResultCard } from './cards/SearchResultCard'; @@ -6,6 +6,7 @@ import { TrashCard } from './cards/TrashCard'; import { ScrapAddItem, ScrapReviewItem } from './ScrapHeadCard'; import { ScrapItem, TrashItem } from '@/features/student/scrap/utils/types'; import { useGridLayout } from '../../utils/gridLayout'; +import { useState } from 'react'; /** * ADD item type for ScrapGrid @@ -37,7 +38,8 @@ interface ScrapGridProps { dispatch: React.Dispatch; } export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { - const { numColumns, gap } = useGridLayout(); + const [containerWidth, setContainerWidth] = useState(0); + const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); const finalData = addPlaceholders(data, numColumns); return ( @@ -45,6 +47,12 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { key={numColumns} data={finalData} numColumns={numColumns} + onLayout={(e) => { + const width = Math.floor(e.nativeEvent.layout.width); + if (width > 0 && width !== containerWidth) { + setContainerWidth(width); + } + }} keyExtractor={(item) => // item may be a ScrapItem/TrashItem or a placeholder item 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() @@ -55,7 +63,8 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { const isLastColumn = (index + 1) % numColumns === 0; const spacingStyle = { - flex: 1, + width: itemWidth, + height: itemHeight, marginRight: isLastColumn ? 0 : gap, }; @@ -80,14 +89,18 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { const scrapItem = item as ScrapItem; - // Handle regular scrap items - // ScrapItem from API already has the correct structure return ( dispatch?.({ type: 'SELECTING_ITEM', id: scrapItem.id, itemType: scrapItem.type })} + onCheckPress={() => + dispatch?.({ + type: 'SELECTING_ITEM', + id: scrapItem.id, + itemType: scrapItem.type, + }) + } /> ); @@ -193,7 +206,9 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP dispatch({ type: 'SELECTING_ITEM', id: trashItem.id, itemType: trashItem.type })} + onCheckPress={() => + dispatch({ type: 'SELECTING_ITEM', id: trashItem.id, itemType: trashItem.type }) + } /> ); From 1a15f0a7a412b22292ff0056b45cb104cddf0d4d Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:59:15 +0900 Subject: [PATCH 081/140] Refactor grid layout function for responsive design --- .../student/scrap/utils/gridLayout.ts | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/native/src/features/student/scrap/utils/gridLayout.ts b/apps/native/src/features/student/scrap/utils/gridLayout.ts index 391642b3..36b4c3b5 100644 --- a/apps/native/src/features/student/scrap/utils/gridLayout.ts +++ b/apps/native/src/features/student/scrap/utils/gridLayout.ts @@ -1,25 +1,38 @@ -import { useWindowDimensions } from 'react-native'; - /** * Calculates grid layout parameters based on window dimensions * @returns Object containing numColumns, gap, and itemWidth */ -export const useGridLayout = () => { - const { width, height } = useWindowDimensions(); - const isLandscape = width > 1024 && width > height; +export const useGridLayout = (containerWidth: number) => { + const GAP = 22; + const MIN_ITEM = 136; + const MAX_ITEM = 145.5; + const MIN_HEIGHT = 192; + const MAX_HEIGHT = 216; + const RATIO = 1.5; // 예: width:height = 1:1.5 + + // 최대 컬럼 수 (최소 아이템 기준) + let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM + GAP)); + + // 최소 2컬럼 보장 + numColumns = Math.max(2, numColumns); - let numColumns = isLandscape ? 6 : 4; - const gap = isLandscape ? 22 : 34; - const totalGap = gap * (numColumns - 1); - const padding = isLandscape ? 256 : 120; - let itemWidth = (width - totalGap - padding) / numColumns; + // 실제 itemWidth 계산 + let itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; - // Adjust columns if item width is too small - if (itemWidth < 136) { - numColumns = isLandscape ? 5 : 4; - itemWidth = (width - gap * (numColumns - 1) - padding) / numColumns; + // itemWidth가 너무 크면 컬럼 늘림 + if (itemWidth > MAX_ITEM) { + numColumns += 1; + itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; } - return { numColumns, gap, itemWidth }; -}; + // height 계산 + let itemHeight = itemWidth * RATIO; + itemHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, itemHeight)); + return { + numColumns, + gap: GAP, + itemWidth, + itemHeight, + }; +}; From e5185ab2acc414770e6fa3dd9a1082e53b22cb46 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 01:59:47 +0900 Subject: [PATCH 082/140] Implement handwriting functionality --- .../screens/ScrapDetailContentScreen.tsx | 484 ++++++++++++++---- 1 file changed, 396 insertions(+), 88 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx index fe0122b6..72b64a52 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState, useMemo, useCallback } from 'react'; +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; import { View, Text, @@ -8,6 +8,8 @@ import { LayoutChangeEvent, Modal, Dimensions, + ActivityIndicator, + Alert, } from 'react-native'; import { RouteProp, useRoute, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -22,12 +24,13 @@ import Animated, { } from 'react-native-reanimated'; import { Container, SegmentedControl, TextButton } from '@/components/common'; import { StudentRootStackParamList } from '@/navigation/student/types'; -import { useGetScrapDetail } from '@/apis'; +import { useGetHandwriting, useGetScrapDetail, useUpdateHandwriting } from '@/apis'; import { LoadingScreen } from '@/components/common'; import ProblemViewer from '../../problem/components/ProblemViewer'; import { useNoteStore, Note } from '@/stores/scrapNoteStore'; import { toAlphabetSequence } from '../utils/toAlphabetSequence'; import { components } from '@/types/api/schema'; +import DrawingCanvas, { DrawingCanvasRef, Stroke, TextItem } from '../components/skia/drawing'; type ScrapDetailContentRouteProp = RouteProp; @@ -35,8 +38,19 @@ const ScrapContentDetailScreen = () => { const route = useRoute(); const navigation = useNavigation>(); const { id } = route.params; + const scrapId = Number(id); - const { data: scrapDetail, isLoading } = useGetScrapDetail(Number(id)); + const { data: scrapDetail, isLoading } = useGetScrapDetail(scrapId, !!id); + const { data: handwritingData } = useGetHandwriting(scrapId, !!id); + const { mutate: updateHandwriting, isPending: isSaving } = useUpdateHandwriting(); + + const canvasRef = useRef(null); + const [isEraserMode, setIsEraserMode] = useState(false); + const [isTextMode, setIsTextMode] = useState(false); + const [strokeWidth, setStrokeWidth] = useState(1.5); + const [eraserSize, setEraserSize] = useState(6); + const [canUndo, setCanUndo] = useState(false); + const [canRedo, setCanRedo] = useState(false); const { openNotes, activeNoteId, setActiveNote, closeNote, reorderNotes } = useNoteStore(); const [tabLayouts, setTabLayouts] = useState>({}); @@ -48,35 +62,114 @@ const ScrapContentDetailScreen = () => { const [isHoveringProblem, setIsHoveringProblem] = useState(false); useEffect(() => { - if (activeNoteId && activeNoteId !== Number(id)) { + if (activeNoteId && activeNoteId !== scrapId) { navigation.setParams({ id: String(activeNoteId) }); } - }, [activeNoteId, id, navigation]); + }, [activeNoteId, scrapId, navigation]); - if (isLoading) { - return ; - } + // handwriting 데이터를 로드하여 canvas에 설정 + useEffect(() => { + if (handwritingData?.data && canvasRef.current) { + try { + // Base64 디코딩 (React Native에서는 atob 사용) + const decodedData = decodeURIComponent(escape(atob(handwritingData.data))); + const data = JSON.parse(decodedData); + + // 이전 형식 호환성: strokes만 있는 경우와 { strokes, texts } 형식 모두 지원 + if (Array.isArray(data)) { + // 이전 형식: strokes 배열만 + canvasRef.current.setStrokes(data); + canvasRef.current.setTexts([]); + } else { + // 새 형식: { strokes, texts } 객체 + canvasRef.current.setStrokes(data.strokes || []); + canvasRef.current.setTexts(data.texts || []); + } - if (!scrapDetail) { - return ( - - 스크랩을 찾을 수 없습니다. - - ); - } + // 로드 후 undo/redo 상태 업데이트 + setTimeout(() => { + if (canvasRef.current) { + setCanUndo(canvasRef.current.canUndo()); + setCanRedo(canvasRef.current.canRedo()); + } + }, 0); + } catch (error) { + console.error('필기 데이터 로드 실패:', error); + } + } + }, [handwritingData]); - const scrap = scrapDetail; + // 초기 undo/redo 상태 확인 + useEffect(() => { + const checkHistory = () => { + if (canvasRef.current) { + setCanUndo(canvasRef.current.canUndo()); + setCanRedo(canvasRef.current.canRedo()); + } + }; - // 필터 옵션 생성 + // 초기 확인 + checkHistory(); + + // 주기적으로 확인 (상태 변경 감지) + const interval = setInterval(checkHistory, 100); + + return () => clearInterval(interval); + }, []); + + // 저장하기 버튼 핸들러 + const handleSave = useCallback(() => { + const strokes = canvasRef.current?.getStrokes(); + const texts = canvasRef.current?.getTexts(); + + if ((!strokes || strokes.length === 0) && (!texts || texts.length === 0)) { + Alert.alert('알림', '저장할 필기 내용이 없습니다.'); + return; + } + + try { + // strokes와 texts를 함께 저장 + const data = { + strokes: strokes || [], + texts: texts || [], + }; + const jsonString = JSON.stringify(data); + const base64Data = btoa(unescape(encodeURIComponent(jsonString))); + + updateHandwriting( + { + scrapId, + request: { + data: base64Data, + }, + }, + { + onSuccess: () => { + Alert.alert('성공', '필기가 저장되었습니다.'); + }, + onError: (error) => { + console.error('필기 저장 실패:', error); + Alert.alert('오류', '필기 저장에 실패했습니다.'); + }, + } + ); + } catch (error) { + console.error('필기 데이터 변환 실패:', error); + Alert.alert('오류', '필기 데이터 변환에 실패했습니다.'); + } + }, [scrapId, updateHandwriting]); + + // 필터 옵션 생성 (scrapDetail이 없어도 Hook은 항상 호출되어야 함) const filterOptions = useMemo(() => { + if (!scrapDetail) return ['전체', '문제']; const options = ['전체', '문제']; - if (scrap.pointings && scrap.pointings.length > 0) { - scrap.pointings.forEach((_, idx) => { + if (scrapDetail.pointings && scrapDetail.pointings.length > 0) { + scrapDetail.pointings.forEach((_, idx) => { options.push(`포인팅 ${toAlphabetSequence(idx)}`); }); } return options; - }, [scrap.pointings]); + }, [scrapDetail?.pointings]); // 필터에 따른 표시 여부 결정 const shouldShowProblem = selectedFilter === 0 || selectedFilter === 1; @@ -88,23 +181,23 @@ const ScrapContentDetailScreen = () => { // 표시할 포인팅이 있는지 확인 const hasVisiblePointings = useMemo(() => { - if (!scrap.pointings || scrap.pointings.length === 0) return false; + if (!scrapDetail?.pointings || scrapDetail.pointings.length === 0) return false; if (selectedFilter === 1) return false; // 문제만 선택 시 포인팅 숨김 if (selectedFilter === 0) return true; // 전체 선택 시 포인팅 표시 // 특정 포인팅 선택 시 해당 포인팅이 존재하는지 확인 const pointingIndex = selectedFilter - 2; - return pointingIndex >= 0 && pointingIndex < scrap.pointings.length; - }, [scrap.pointings, selectedFilter]); + return pointingIndex >= 0 && pointingIndex < scrapDetail.pointings.length; + }, [scrapDetail?.pointings, selectedFilter]); // 스크랩 데이터를 AllPointings에 전달할 형식으로 변환 const convertScrapToGroup = useCallback((): | components['schemas']['PublishProblemGroupResp'] | null => { - if (!scrap.problem) return null; + if (!scrapDetail?.problem) return null; // PointingResp를 PointingWithFeedbackResp로 변환 const pointingsWithFeedback: components['schemas']['PointingWithFeedbackResp'][] = - scrap.pointings?.map((pointing) => ({ + scrapDetail.pointings?.map((pointing) => ({ id: pointing.id, no: pointing.no, questionContent: pointing.questionContent, @@ -115,26 +208,26 @@ const ScrapContentDetailScreen = () => { // ProblemExtendResp를 ProblemWithStudyInfoResp로 변환 const problemWithStudyInfo: components['schemas']['ProblemWithStudyInfoResp'] = { - id: scrap.problem.id, - problemType: scrap.problem.problemType, - parentProblem: scrap.problem.parentProblem, - parentProblemTitle: scrap.problem.parentProblemTitle, - customId: scrap.problem.customId, - createType: scrap.problem.createType, - practiceTest: scrap.problem.practiceTest, - practiceTestNo: scrap.problem.practiceTestNo, - problemContent: scrap.problem.problemContent, - title: scrap.problem.title, - answerType: scrap.problem.answerType, - answer: scrap.problem.answer, - difficulty: scrap.problem.difficulty, - recommendedTimeSec: scrap.problem.recommendedTimeSec, - memo: scrap.problem.memo, - concepts: scrap.problem.concepts, - mainAnalysisImage: scrap.problem.mainAnalysisImage, - mainHandAnalysisImage: scrap.problem.mainHandAnalysisImage, - readingTipContent: scrap.problem.readingTipContent, - oneStepMoreContent: scrap.problem.oneStepMoreContent, + 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, pointings: pointingsWithFeedback, progress: 'NONE', // 스크랩에서는 진행 상태가 없음 submitAnswer: 0, // 스크랩에서는 제출 답안이 없음 @@ -145,12 +238,12 @@ const ScrapContentDetailScreen = () => { return { no: 1, // 스크랩에서는 번호가 없으므로 1로 설정 - problemId: scrap.problem.id, + problemId: scrapDetail.problem.id, progress: 'DONE', // 스크랩된 문제는 완료된 것으로 간주 problem: problemWithStudyInfo, childProblems: [], }; - }, [scrap]); + }, [scrapDetail]); // 전체보기 버튼 클릭 핸들러 const handleViewAllPointings = useCallback(() => { @@ -159,9 +252,23 @@ const ScrapContentDetailScreen = () => { navigation.navigate('AllPointings', { group, - problemSetTitle: scrap.name || '스크랩', + problemSetTitle: scrapDetail?.name || '스크랩', }); - }, [convertScrapToGroup, navigation, scrap.name]); + }, [convertScrapToGroup, navigation, scrapDetail?.name]); + + if (isLoading) { + return ; + } + + if (!scrapDetail) { + return ( + + 스크랩을 찾을 수 없습니다. + + ); + } + + const scrap = scrapDetail; return ( @@ -224,17 +331,54 @@ const ScrapContentDetailScreen = () => { )} )} + {shouldShowProblem && + !(scrap.problem && scrap.problem.problemContent) && + scrap.thumbnailUrl && ( + + 문제 내용 + { + setIsHoveringProblem(true); + + setTimeout(() => { + setIsHoveringProblem(false); + }, 2000); + }}> + + {isHoveringProblem && ( + setIsProblemExpanded(true)} + className='absolute right-2 top-2 z-10 rounded-full bg-black/50 p-2'> + + + )} + + + )} {/* 문제 내용 */} {shouldShowProblem && scrap.problem && scrap.problem.problemContent && ( 문제 내용 - setIsHoveringProblem(true)}> + { + setIsHoveringProblem(true); + + setTimeout(() => { + setIsHoveringProblem(false); + }, 2000); + }}> - {isHoveringProblem != true && ( + {isHoveringProblem && ( setIsProblemExpanded(true)} className='absolute right-2 top-2 z-10 rounded-full bg-black/50 p-2'> @@ -312,55 +456,219 @@ const ScrapContentDetailScreen = () => { )} - + + + { + // 상태 변경 시 undo/redo 가능 여부 업데이트 + setTimeout(() => { + if (canvasRef.current) { + setCanUndo(canvasRef.current.canUndo()); + setCanRedo(canvasRef.current.canRedo()); + } + }, 0); + }} + /> + + + {/* 하단 제어 버튼 */} + + + { + setIsTextMode(false); + setIsEraserMode(false); + }} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + !isTextMode && !isEraserMode ? 'bg-blue-500' : 'bg-gray-200' + }`}> + + 필기 + + + { + setIsTextMode(true); + setIsEraserMode(false); + }} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + isTextMode && !isEraserMode ? 'bg-blue-500' : 'bg-gray-200' + }`}> + + 텍스트 + + + { + setIsEraserMode((prev) => !prev); + if (!isEraserMode) { + setIsTextMode(false); + } + }} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + isEraserMode ? 'bg-red-500' : 'bg-gray-200' + }`}> + 지우개 + + + + {/* 그리기 크기 선택 (필기 모드일 때만 표시) */} + {!isTextMode && !isEraserMode && ( + + 그리기 크기 + + {[1.5, 2, 4].map((size) => ( + setStrokeWidth(size)} + className={`flex-1 items-center justify-center rounded-lg py-2 ${ + strokeWidth === size ? 'bg-blue-500' : 'bg-gray-200' + }`}> + + {size} + + + ))} + + + )} + + {/* 지우개 크기 선택 (지우개 모드일 때만 표시) */} + {isEraserMode && ( + + 지우개 크기 + + {[6, 12, 20].map((size) => ( + setEraserSize(size)} + className={`flex-1 items-center justify-center rounded-lg py-2 ${ + eraserSize === size ? 'bg-red-500' : 'bg-gray-200' + }`}> + + {size} + + + ))} + + + )} + + + { + canvasRef.current?.undo(); + // undo 후 상태 업데이트 + setTimeout(() => { + if (canvasRef.current) { + setCanUndo(canvasRef.current.canUndo()); + setCanRedo(canvasRef.current.canRedo()); + } + }, 0); + }} + disabled={!canUndo} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + canUndo ? 'bg-gray-200' : 'bg-gray-100' + }`}> + undo + + { + canvasRef.current?.redo(); + // redo 후 상태 업데이트 + setTimeout(() => { + if (canvasRef.current) { + setCanUndo(canvasRef.current.canUndo()); + setCanRedo(canvasRef.current.canRedo()); + } + }, 0); + }} + disabled={!canRedo} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + canRedo ? 'bg-gray-200' : 'bg-gray-100' + }`}> + redo + + + {isSaving ? '저장 중...' : '저장하기'} + + + + {/* 문제 확대 모달 */} - {scrap.problem && scrap.problem.problemContent && ( - setIsProblemExpanded(false)}> + + setIsProblemExpanded(false)}> + setIsProblemExpanded(false)}> setIsProblemExpanded(false)}> - e.stopPropagation()}> - - 문제 내용 - { - setIsProblemExpanded(false); - setIsHoveringProblem(false); - }} - className='rounded-full bg-gray-200 p-2'> - - - - + onPress={(e) => e.stopPropagation()}> + + 문제 내용 + { + setIsProblemExpanded(false); + setIsHoveringProblem(false); + }} + className='rounded-full bg-gray-200 p-2'> + + + + + {scrap.problem && scrap.problem.problemContent && ( - - + )} + {!(scrap.problem && scrap.problem.problemContent) && scrap.thumbnailUrl && ( + + )} + - - )} + + ); }; From 0bf46c6837c7caa96410a3ab791287cf593b1b19 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:00:37 +0900 Subject: [PATCH 083/140] Refactor props type --- .../src/features/student/scrap/screens/ScrapContentScreen.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index 3f291442..29dc373f 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -26,7 +26,7 @@ const ScrapContentScreen = () => { // API 호출 const { data: foldersData } = useGetFolders(); - const { data: contentsData, isLoading } = useGetScrapsByFolder(Number(id)); + const { data: contentsData, isLoading } = useGetScrapsByFolder(id); const { mutateAsync: deleteScrap } = useDeleteScrap(); // 폴더 정보 가져오기 From e4e24d87f8adc836d963334fadd636dda485804c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:01:01 +0900 Subject: [PATCH 084/140] Refactor props type --- apps/native/src/navigation/student/types.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/native/src/navigation/student/types.ts b/apps/native/src/navigation/student/types.ts index 3781b02b..1a0e3fa2 100644 --- a/apps/native/src/navigation/student/types.ts +++ b/apps/native/src/navigation/student/types.ts @@ -24,11 +24,11 @@ export type StudentRootStackParamList = { }; Scrap: undefined; ScrapContent: { - id: string; + id: number; }; SearchScrap: undefined; DeletedScrap: undefined; ScrapContentDetail: { - id: string; + id: number; }; }; From f7547548503bf564f3ddf3d58baf33ccae2863e7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 02:01:11 +0900 Subject: [PATCH 085/140] Implement handwriting functionality --- .../student/scrap/components/skia/drawing.tsx | 383 ++++++++++++++++-- 1 file changed, 355 insertions(+), 28 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/skia/drawing.tsx b/apps/native/src/features/student/scrap/components/skia/drawing.tsx index d4431f92..3a8fbfad 100644 --- a/apps/native/src/features/student/scrap/components/skia/drawing.tsx +++ b/apps/native/src/features/student/scrap/components/skia/drawing.tsx @@ -5,6 +5,7 @@ import React, { useState, useCallback, useMemo, + useEffect, } from 'react'; import { View, @@ -14,6 +15,8 @@ import { Pressable, Text as RNText, ScrollView, + Keyboard, + Platform, } from 'react-native'; import { Canvas, @@ -42,7 +45,13 @@ export type TextItem = { export type DrawingCanvasRef = { clear: () => void; undo: () => void; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; getStrokes: () => Stroke[]; + setStrokes: (strokes: Stroke[]) => void; + getTexts: () => TextItem[]; + setTexts: (texts: TextItem[]) => void; }; type Props = { @@ -79,11 +88,13 @@ const DrawingCanvas = forwardRef( value: string; } | null>(null); const textInputRef = useRef(null); + const scrollViewRef = useRef(null); const containerLayout = useRef<{ x: number; y: number; width: number; height: number } | null>( null ); const canvasHeight = useRef(800); // 기본 캔버스 높이 const maxY = useRef(0); // 그려진 내용의 최대 Y 좌표 + const keyboardHeight = useRef(0); // 키보드 높이 // 호버 좌표를 저장할 SharedValue (성능을 위해 스레드 분리) const hoverX = useSharedValue(0); @@ -93,13 +104,152 @@ const DrawingCanvas = forwardRef( const livePath = useRef(Skia.Path.Make()); const currentPoints = useRef([]); const strokesRef = useRef([]); + const textsRef = useRef([]); const eraserPoints = useRef([]); const lastEraserTime = useRef(0); const ERASER_THROTTLE_MS = 16; // ~60fps + // 히스토리 관리 (모든 동작에 대한 undo 지원) + type HistoryState = { strokes: Stroke[]; texts: TextItem[] }; + const historyRef = useRef([]); + const historyIndexRef = useRef(-1); + + // 현재 상태를 히스토리에 저장 + const saveToHistory = useCallback(() => { + const currentState: HistoryState = { + strokes: JSON.parse(JSON.stringify(strokesRef.current)), // deep copy + texts: JSON.parse(JSON.stringify(textsRef.current)), // deep copy + }; + + // 현재 인덱스 이후의 히스토리 제거 (새 동작이 발생하면 redo 불가) + historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1); + + // 새 상태 추가 + historyRef.current.push(currentState); + historyIndexRef.current = historyRef.current.length - 1; + + // 히스토리 크기 제한 (메모리 관리) + if (historyRef.current.length > 50) { + historyRef.current.shift(); + historyIndexRef.current--; + } + }, []); + + // 히스토리에서 상태 복원 + const restoreFromHistory = useCallback( + (index: number) => { + if (index < 0 || index >= historyRef.current.length) return; + + const state = historyRef.current[index]; + const newPaths = state.strokes.map((stroke) => buildSmoothPath(stroke.points)); + + setStrokes(state.strokes); + setPaths(newPaths); + setTexts(state.texts); + strokesRef.current = state.strokes; + textsRef.current = state.texts; + + // 최대 Y 좌표 재계산 + let maxYValue = 0; + if (state.strokes.length > 0) { + const strokesMaxY = Math.max( + ...state.strokes.flatMap((stroke) => stroke.points.map((p) => p.y)) + ); + maxYValue = Math.max(maxYValue, strokesMaxY); + } + if (state.texts.length > 0) { + const textsMaxY = Math.max(...state.texts.map((text) => text.y)); + maxYValue = Math.max(maxYValue, textsMaxY); + } + + if (maxYValue > 0) { + maxY.current = maxYValue; + canvasHeight.current = Math.max(800, maxY.current + 200); + } else { + maxY.current = 0; + canvasHeight.current = 800; + } + + onChange?.(state.strokes); + setTick((t) => t + 1); + }, + [onChange] + ); + // 폰트 로드 const font = useFont(require('@assets/fonts/PretendardVariable.ttf'), textFontSize); + const loadStrokes = useCallback( + (newStrokes: Stroke[]) => { + // strokes와 paths를 함께 업데이트 + const newPaths = newStrokes.map((stroke) => buildSmoothPath(stroke.points)); + setStrokes(newStrokes); + setPaths(newPaths); + strokesRef.current = newStrokes; + + // 최대 Y 좌표 계산 + if (newStrokes.length > 0) { + const maxYValue = Math.max( + ...newStrokes.flatMap((stroke) => stroke.points.map((p) => p.y)) + ); + maxY.current = maxYValue; + canvasHeight.current = Math.max(800, maxYValue + 200); + } else { + maxY.current = 0; + canvasHeight.current = 800; + } + + setTick((t) => t + 1); + onChange?.(newStrokes); + + // 히스토리 초기화 및 초기 상태 저장 (외부에서 로드한 경우) + historyRef.current = []; + historyIndexRef.current = -1; + // 초기 상태를 히스토리에 저장 + setTimeout(() => { + const initialState: HistoryState = { + strokes: JSON.parse(JSON.stringify(newStrokes)), + texts: JSON.parse(JSON.stringify(textsRef.current)), + }; + historyRef.current = [initialState]; + historyIndexRef.current = 0; + }, 0); + }, + [onChange] + ); + + const loadTexts = useCallback((newTexts: TextItem[]) => { + setTexts(newTexts); + textsRef.current = newTexts; + + // 최대 Y 좌표 계산 (strokes와 texts 모두 고려) + let maxYValue = 0; + + // strokes의 최대 Y 계산 + if (strokesRef.current.length > 0) { + const strokesMaxY = Math.max( + ...strokesRef.current.flatMap((stroke) => stroke.points.map((p) => p.y)) + ); + maxYValue = Math.max(maxYValue, strokesMaxY); + } + + // texts의 최대 Y 계산 + if (newTexts.length > 0) { + const textsMaxY = Math.max(...newTexts.map((text) => text.y)); + maxYValue = Math.max(maxYValue, textsMaxY); + } + + if (maxYValue > 0) { + maxY.current = maxYValue; + canvasHeight.current = Math.max(800, maxY.current + 200); + } else { + maxY.current = 0; + canvasHeight.current = 800; + } + + setTick((t) => t + 1); + }, []); + const addPoint = useCallback((x: number, y: number) => { currentPoints.current.push({ x, y }); // 최대 Y 좌표 업데이트 @@ -149,12 +299,16 @@ const DrawingCanvas = forwardRef( strokesRef.current = next; onChange?.(next); setTick((t) => t + 1); + + // 히스토리에 저장 + setTimeout(() => saveToHistory(), 0); + return next; }); currentPoints.current = []; livePath.current = Skia.Path.Make(); - }, [strokeColor, strokeWidth, onChange]); + }, [strokeColor, strokeWidth, onChange, saveToHistory]); // 지우개: 터치한 위치에서 가까운 점들을 제거 const eraseAtPoint = useCallback( @@ -189,13 +343,17 @@ const DrawingCanvas = forwardRef( strokesRef.current = nextStrokes; onChange?.(nextStrokes); setTick((t) => t + 1); + + // 히스토리에 저장 (지우개 동작) + setTimeout(() => saveToHistory(), 0); + return nextStrokes; } return prevStrokes; }); }, - [eraserSize, onChange] + [eraserSize, onChange, saveToHistory] ); const addEraserPoint = useCallback( @@ -219,7 +377,11 @@ const DrawingCanvas = forwardRef( }, []); const deleteText = useCallback((textId: string) => { - setTexts((prev) => prev.filter((t) => t.id !== textId)); + setTexts((prev) => { + const next = prev.filter((t) => t.id !== textId); + textsRef.current = next; + return next; + }); setTick((t) => t + 1); }, []); @@ -251,6 +413,70 @@ const DrawingCanvas = forwardRef( [texts, textFontSize] ); + // 텍스트 박스 영역과 겹치는 strokes를 밀어내기 (stroke 전체를 이동하여 모양 유지) + const pushStrokesAwayFromTextArea = useCallback( + (textX: number, textY: number, textHeight: number) => { + const padding = 16; + const textTop = textY - textHeight - padding; + const textBottom = textY + padding; + + setStrokes((prevStrokes) => { + let hasChanges = false; + const updatedStrokes = prevStrokes.map((stroke) => { + // stroke의 최소/최대 Y 좌표 계산 + const strokeMinY = Math.min(...stroke.points.map((p) => p.y)); + const strokeMaxY = Math.max(...stroke.points.map((p) => p.y)); + + // stroke가 텍스트 박스 영역과 겹치는지 확인 + const overlapsTop = strokeMaxY >= textTop && strokeMaxY <= textBottom; + const overlapsBottom = strokeMinY >= textTop && strokeMinY <= textBottom; + const overlapsMiddle = strokeMinY <= textTop && strokeMaxY >= textBottom; + + if (overlapsTop || overlapsBottom || overlapsMiddle) { + hasChanges = true; + + // stroke의 중심 Y 좌표 계산 + const strokeCenterY = (strokeMinY + strokeMaxY) / 2; + + // 이동 거리 계산 + let offsetY = 0; + if (strokeCenterY < textY) { + // 텍스트 박스 위쪽에 있으면 위로 이동 + offsetY = textTop - strokeMaxY - 1; + } else { + // 텍스트 박스 아래쪽에 있으면 아래로 이동 + offsetY = textBottom - strokeMinY + 1; + } + + // stroke의 모든 점을 동일한 거리만큼 이동 (모양 유지) + const updatedPoints = stroke.points.map((point) => ({ + ...point, + y: point.y + offsetY, + })); + + return { ...stroke, points: updatedPoints }; + } + return stroke; + }); + + if (hasChanges) { + // paths 재생성 + const newPaths = updatedStrokes.map((stroke) => buildSmoothPath(stroke.points)); + setPaths(newPaths); + strokesRef.current = updatedStrokes; + onChange?.(updatedStrokes); + setTick((t) => t + 1); + + // 히스토리에 저장 (strokes 이동) + setTimeout(() => saveToHistory(), 0); + } + + return hasChanges ? updatedStrokes : prevStrokes; + }); + }, + [onChange, saveToHistory] + ); + const addText = useCallback( (x: number, y: number) => { // 기존 텍스트 주변 16px 내에서는 새 텍스트 박스 생성 안 함 @@ -265,6 +491,9 @@ const DrawingCanvas = forwardRef( Math.min(y, (containerLayout.current?.height || 400) - padding) ); + // 텍스트 박스 영역과 겹치는 strokes를 밀어내기 + pushStrokesAwayFromTextArea(x, adjustedY, textFontSize); + const textId = Date.now().toString(); setActiveTextInput({ id: textId, @@ -273,12 +502,57 @@ const DrawingCanvas = forwardRef( value: '', }); - // TextInput 포커스 + // TextInput 포커스 및 키보드 처리 setTimeout(() => { textInputRef.current?.focus(); + + // 키보드가 올라올 때 텍스트 입력 위치로 스크롤 + const showListener = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow', + (e) => { + keyboardHeight.current = e.endCoordinates.height; + + // 약간의 지연 후 스크롤 (레이아웃 업데이트 대기) + setTimeout(() => { + if (!containerLayout.current) return; + + // 텍스트 입력 위치 계산 (ScrollView 내부 기준) + const textInputY = adjustedY; + const screenHeight = Dimensions.get('window').height; + const keyboardTop = screenHeight - e.endCoordinates.height; + + // 컨테이너의 절대 위치 계산 + const containerY = containerLayout.current.y; + const textInputAbsoluteY = containerY + textInputY; + const textInputBottom = textInputAbsoluteY + textFontSize + 40; // 텍스트 높이 + 여백 + + // 키보드가 텍스트 입력을 가릴 수 있는지 확인 + if (textInputBottom > keyboardTop) { + // 스크롤할 거리 계산 (키보드 위로 여유 공간 확보) + const scrollOffset = textInputBottom - keyboardTop + 20; + + // ScrollView를 스크롤 + scrollViewRef.current?.scrollTo({ + y: Math.max(0, scrollOffset), + animated: true, + }); + } + }, 100); + } + ); + + // 키보드가 내려갈 때 리스너 제거 + const hideListener = Keyboard.addListener( + Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide', + () => { + keyboardHeight.current = 0; + showListener.remove(); + hideListener.remove(); + } + ); }, 100); }, - [isNearExistingText] + [isNearExistingText, pushStrokesAwayFromTextArea, textFontSize] ); const confirmTextInput = useCallback(() => { @@ -296,11 +570,19 @@ const DrawingCanvas = forwardRef( maxY.current = activeTextInput.y; canvasHeight.current = Math.max(800, maxY.current + 200); } - setTexts((prev) => [...prev, newText]); + setTexts((prev) => { + const next = [...prev, newText]; + textsRef.current = next; + + // 히스토리에 저장 (텍스트 추가) + setTimeout(() => saveToHistory(), 0); + + return next; + }); setTick((t) => t + 1); } setActiveTextInput(null); - }, [activeTextInput, textFontSize, strokeColor]); + }, [activeTextInput, textFontSize, strokeColor, saveToHistory]); const handleTextInputBlur = useCallback(() => { if (activeTextInput) { @@ -324,28 +606,44 @@ const DrawingCanvas = forwardRef( return; } - // 텍스트가 있으면 텍스트부터 제거, 없으면 스트로크 제거 - setTexts((prev) => { - if (prev.length > 0) { - setTick((t) => t + 1); - return prev.slice(0, -1); - } - return prev; - }); + // 히스토리에서 이전 상태로 복원 + if (historyIndexRef.current > 0) { + historyIndexRef.current--; + restoreFromHistory(historyIndexRef.current); + } else if (historyIndexRef.current === 0) { + // 첫 번째 상태로 복원 (빈 상태) + historyIndexRef.current = -1; + restoreFromHistory(0); + } + // historyIndexRef.current === -1이면 undo할 히스토리가 없음 + }, [activeTextInput, restoreFromHistory]); - if (texts.length === 0) { - setStrokes((prev) => { - if (prev.length === 0) return prev; - const next = prev.slice(0, -1); - // paths도 함께 업데이트 - setPaths((prevPaths) => prevPaths.slice(0, -1)); - strokesRef.current = next; - onChange?.(next); - setTick((t) => t + 1); - return next; - }); + const redo = useCallback(() => { + // 활성 텍스트 입력이 있으면 redo 불가 + if (activeTextInput) { + return; } - }, [onChange, texts.length, activeTextInput]); + + // 히스토리에서 다음 상태로 복원 + const nextIndex = historyIndexRef.current + 1; + if (nextIndex < historyRef.current.length) { + historyIndexRef.current = nextIndex; + restoreFromHistory(nextIndex); + } + // nextIndex >= historyRef.current.length이면 redo할 히스토리가 없음 + }, [activeTextInput, restoreFromHistory]); + + // 초기 상태를 히스토리에 저장 + useEffect(() => { + if (historyRef.current.length === 0) { + const initialState: HistoryState = { + strokes: [], + texts: [], + }; + historyRef.current = [initialState]; + historyIndexRef.current = 0; + } + }, []); useImperativeHandle(ref, () => ({ clear() { @@ -354,13 +652,40 @@ const DrawingCanvas = forwardRef( setTexts([]); setActiveTextInput(null); strokesRef.current = []; + textsRef.current = []; livePath.current = Skia.Path.Make(); maxY.current = 0; canvasHeight.current = 800; setTick((t) => t + 1); + onChange?.([]); + + // 히스토리 초기화 + historyRef.current = []; + historyIndexRef.current = -1; }, undo, + redo, + canUndo: () => { + // 활성 텍스트 입력이 있으면 undo 가능 + if (activeTextInput) return true; + // 히스토리 인덱스가 0보다 크면 undo 가능 (이전 상태가 있음) + if (historyIndexRef.current > 0) return true; + // 초기 상태만 있고 실제 변경이 없으면 undo 불가능 + // 히스토리가 1개만 있으면 (초기 상태만) undo 불가능 + if (historyRef.current.length === 1) return false; + // 히스토리가 2개 이상이면 undo 가능 + return historyIndexRef.current === 0 && historyRef.current.length > 1; + }, + canRedo: () => { + // 활성 텍스트 입력이 있으면 redo 불가 + if (activeTextInput) return false; + // 다음 히스토리가 있으면 redo 가능 + return historyIndexRef.current + 1 < historyRef.current.length; + }, getStrokes: () => strokesRef.current, + setStrokes: loadStrokes, + getTexts: () => textsRef.current, + setTexts: loadTexts, })); const tap = useMemo( @@ -550,10 +875,12 @@ const DrawingCanvas = forwardRef( return ( + nestedScrollEnabled={true} + keyboardShouldPersistTaps='handled'> Date: Thu, 25 Dec 2025 02:01:42 +0900 Subject: [PATCH 086/140] Implement useGetFolderDetail for folder data fetching --- .../apis/controller/scrap/useGetFoldersDetail.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 apps/native/src/apis/controller/scrap/useGetFoldersDetail.ts diff --git a/apps/native/src/apis/controller/scrap/useGetFoldersDetail.ts b/apps/native/src/apis/controller/scrap/useGetFoldersDetail.ts new file mode 100644 index 00000000..cfb16244 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetFoldersDetail.ts @@ -0,0 +1,16 @@ +import { TanstackQueryClient } from '@apis'; + +export const useGetFolderDetail = (id: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/folder/{id}', + { + params: { + path: { id }, + }, + }, + { + enabled, + } + ); +}; From b2a3f2a2db145254a36cfa4da72fa0a23e4af60a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:03:21 +0900 Subject: [PATCH 087/140] Refactor: separate tooltip directory --- .../{Modal => }/Tooltip/AddItemTooltip.tsx | 2 +- .../{Modal => }/Tooltip/ItemTooltip.tsx | 73 ++++++++++++++----- .../{Modal => }/Tooltip/ReviewItemTooltip.tsx | 4 +- .../{Modal => }/Tooltip/TooltipPopover.tsx | 0 .../{Modal => }/Tooltip/TrashItemTooltip.tsx | 2 +- .../components/{Modal => }/Tooltip/index.ts | 0 6 files changed, 59 insertions(+), 22 deletions(-) rename apps/native/src/features/student/scrap/components/{Modal => }/Tooltip/AddItemTooltip.tsx (98%) rename apps/native/src/features/student/scrap/components/{Modal => }/Tooltip/ItemTooltip.tsx (69%) rename apps/native/src/features/student/scrap/components/{Modal => }/Tooltip/ReviewItemTooltip.tsx (92%) rename apps/native/src/features/student/scrap/components/{Modal => }/Tooltip/TooltipPopover.tsx (100%) rename apps/native/src/features/student/scrap/components/{Modal => }/Tooltip/TrashItemTooltip.tsx (97%) rename apps/native/src/features/student/scrap/components/{Modal => }/Tooltip/index.ts (100%) diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx similarity index 98% rename from apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx index d5b22928..1124ad51 100644 --- a/apps/native/src/features/student/scrap/components/Modal/Tooltip/AddItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx @@ -1,6 +1,6 @@ import { Camera, Image, Images, FolderPlus } from 'lucide-react-native'; import { View, Text, Pressable, Alert } from 'react-native'; -import { openCamera, openImageLibrary } from '../../../utils/imagePicker'; +import { openCamera, openImageLibrary } from '../../utils/imagePicker'; import { useGetPreSignedUrl } from '@/apis/controller/common'; import { useCreateScrapFromImage } from '@/apis'; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx similarity index 69% rename from apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx index 47210587..fc35ee3d 100644 --- a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx @@ -2,8 +2,8 @@ import { colors } from '@/theme/tokens'; import { FileSymlink, FolderOpen, ImagePlay, Trash2 } from 'lucide-react-native'; import { useState } from 'react'; import { TextInput, View, Text, Pressable } from 'react-native'; -import { showToast } from '../Toast'; -import { ScrapListItemProps } from '../../Card/types'; +import { showToast } from '../Modal/Toast'; +import { ScrapListItemProps } from '../Card/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; @@ -15,13 +15,15 @@ import { useGetFolders, } from '@/apis'; import { useNoteStore } from '@/stores/scrapNoteStore'; +import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; export interface ItemTooltipProps { props: ScrapListItemProps; onClose?: () => void; + onMovePress?: () => void; // 추가 } -export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { +export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) => { const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); @@ -35,6 +37,35 @@ export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { const { data: scrapDetail } = useGetScrapDetail(Number(props.id), props.type === 'SCRAP'); const { data: foldersData } = useGetFolders(); + const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); + + // S3에 파일 업로드 + const uploadFileToS3 = async ( + uploadUrl: string, + fileUri: string, + contentDisposition: string + ): Promise => { + try { + // React Native에서 파일 읽기 + const response = await fetch(fileUri); + const blob = await response.blob(); + + // S3에 PUT 요청 + const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'content-disposition': contentDisposition, + }, + body: blob, + }); + + return uploadResponse.ok; + } catch (error) { + console.error('S3 업로드 실패:', error); + return false; + } + }; + // 초기 제목 설정 const initialTitle = props.type === 'SCRAP' @@ -87,10 +118,10 @@ export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { handleClose(); setTimeout(() => { if (props.type === 'FOLDER') { - navigation.push('ScrapContent', { id: String(props.id) }); + navigation.push('ScrapContent', { id: props.id }); } else { openNote({ id: props.id, title: props.name }); - navigation.push('ScrapContentDetail', { id: String(props.id) }); + navigation.push('ScrapContentDetail', { id: props.id }); } }, 100); }}> @@ -101,19 +132,25 @@ export const ItemTooltip = ({ props, onClose }: ItemTooltipProps) => { 스크랩 열기 )} - - {props.type === 'FOLDER' ? ( - <> - - 표지 변경하기 - - ) : ( - <> - - 폴더 이동하기 - - )} - + {props.type === 'FOLDER' && ( + + + 표지 변경하기 + + )} + {props.type === 'SCRAP' && ( + { + handleClose(); + setTimeout(() => { + onMovePress?.(); + }, 100); + }}> + + 폴더 이동하기 + + )} { diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ReviewItemTooltip.tsx similarity index 92% rename from apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/ReviewItemTooltip.tsx index 3a6f825e..d454401d 100644 --- a/apps/native/src/features/student/scrap/components/Modal/Tooltip/ReviewItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ReviewItemTooltip.tsx @@ -1,6 +1,6 @@ import { FolderOpen } from 'lucide-react-native'; import { View, Text, Pressable } from 'react-native'; -import { ScrapListItemProps } from '../../Card/types'; +import { ScrapListItemProps } from '../Card/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; @@ -32,7 +32,7 @@ export const ReviewItemTooltip = ({ props, onClose }: ReviewItemTooltipProps) => handleClose(); // Popover가 닫히는 시간을 주기 위해 약간의 지연 setTimeout(() => { - navigation.push('ScrapContent', { id: String(props.id) }); + navigation.push('ScrapContent', { id: props.id }); }, 100); }}> diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/TooltipPopover.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Modal/Tooltip/TooltipPopover.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx similarity index 97% rename from apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx index 103e4155..2920631e 100644 --- a/apps/native/src/features/student/scrap/components/Modal/Tooltip/TrashItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx @@ -1,7 +1,7 @@ import { colors } from '@/theme/tokens'; import { Trash2, Undo2 } from 'lucide-react-native'; import { View, Text, Pressable } from 'react-native'; -import { showToast } from '../Toast'; +import { showToast } from '../Modal/Toast'; import { TrashItem } from '@/features/student/scrap/utils/types'; import { useRestoreTrash } from '@/apis'; diff --git a/apps/native/src/features/student/scrap/components/Modal/Tooltip/index.ts b/apps/native/src/features/student/scrap/components/Tooltip/index.ts similarity index 100% rename from apps/native/src/features/student/scrap/components/Modal/Tooltip/index.ts rename to apps/native/src/features/student/scrap/components/Tooltip/index.ts From 1fef3cf71f20df57b19e2839090580c1349cb4a7 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:03:57 +0900 Subject: [PATCH 088/140] Fix: remove max size constraint from grid layout --- .../student/scrap/utils/gridLayout.ts | 24 ++++++------------- 1 file changed, 7 insertions(+), 17 deletions(-) diff --git a/apps/native/src/features/student/scrap/utils/gridLayout.ts b/apps/native/src/features/student/scrap/utils/gridLayout.ts index 36b4c3b5..1fa1b488 100644 --- a/apps/native/src/features/student/scrap/utils/gridLayout.ts +++ b/apps/native/src/features/student/scrap/utils/gridLayout.ts @@ -5,29 +5,19 @@ export const useGridLayout = (containerWidth: number) => { const GAP = 22; const MIN_ITEM = 136; - const MAX_ITEM = 145.5; - const MIN_HEIGHT = 192; - const MAX_HEIGHT = 216; - const RATIO = 1.5; // 예: width:height = 1:1.5 + const RATIO = 1.5; // width : height = 1 : 1.5 - // 최대 컬럼 수 (최소 아이템 기준) + // 컬럼 수 계산 let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM + GAP)); - // 최소 2컬럼 보장 + // 최소 2컬럼 numColumns = Math.max(2, numColumns); - // 실제 itemWidth 계산 - let itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; + // item width + const itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; - // itemWidth가 너무 크면 컬럼 늘림 - if (itemWidth > MAX_ITEM) { - numColumns += 1; - itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; - } - - // height 계산 - let itemHeight = itemWidth * RATIO; - itemHeight = Math.max(MIN_HEIGHT, Math.min(MAX_HEIGHT, itemHeight)); + // 비율 기반 height + const itemHeight = itemWidth * RATIO; return { numColumns, From fccb6d473c8f1c842d8547579a7f5864d061a4e9 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:05:26 +0900 Subject: [PATCH 089/140] Feat: add scrap move feature and modal --- .../scrap/components/Card/cards/ScrapCard.tsx | 61 +++---- .../scrap/components/Modal/MoveScrapModal.tsx | 160 ++++++++++++++++++ .../scrap/screens/DeletedScrapScreen.tsx | 24 +++ .../student/scrap/screens/ScrapScreen.tsx | 19 ++- 4 files changed, 234 insertions(+), 30 deletions(-) create mode 100644 apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx 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 6a6e1a37..70c08477 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 @@ -2,7 +2,7 @@ import { Pressable, View, Text, Image } from 'react-native'; import React, { useState } from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; -import { TooltipPopover, ItemTooltipBox } from '../../Modal/Tooltip'; +import { TooltipPopover, ItemTooltipBox } from '../../Tooltip'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; @@ -22,8 +22,8 @@ export const ScrapCard = (props: ScrapListItemProps) => { const thumbnailUrl = props.type === 'SCRAP' ? props.thumbnailUrl : undefined; const cardContent = ( - - + + {thumbnailUrl ? ( { )} - - - {props.name} - - {!state.isSelecting && ( - } - children={(close) => ( - setIsMoveModalVisible(true)} - /> - )} - /> - )} - setIsMoveModalVisible(false)} - selectedItems={[{ id: props.id, type: props.type }]} - onSuccess={() => { - // 이동 성공 후 필요한 경우 데이터 갱신 - }} - /> + + + + {props.name} + + {!state.isSelecting && ( + } + children={(close) => ( + setIsMoveModalVisible(true)} + /> + )} + /> + )} + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( {props.scrapCount} )} - - {new Date(props.createdAt).toLocaleDateString('ko-kr')} + {props.type === 'SCRAP' && props.updatedAt + ? new Date(props.updatedAt).toLocaleString('ko-kr') + : new Date(props.createdAt).toLocaleString('ko-kr')} + setIsMoveModalVisible(false)} + selectedItems={[{ id: props.id, type: props.type }]} + onSuccess={() => { + // 이동 성공 후 필요한 경우 데이터 갱신 + }} + /> ); diff --git a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx new file mode 100644 index 00000000..39f061e0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx @@ -0,0 +1,160 @@ +import React, { useMemo, useCallback, useEffect, useState } from 'react'; +import { View, Text, Pressable, ScrollView } from 'react-native'; +import { FolderPlus } from 'lucide-react-native'; +import PopUpModal from './PopupModal'; +import { ScrapGrid } from '../Card/ScrapCardGrid'; +import { useGetFolders, useMoveScraps } from '@/apis'; +import { showToast } from './Toast'; +import { reducer, initialSelectionState } from '../../utils/reducer'; +import { useReducer } from 'react'; +import type { SelectedItem } from '../../utils/reducer'; +import { CreateFolderModal } from './CreateFolderModal'; + +interface MoveScrapModalProps { + visible: boolean; + onClose: () => void; + selectedItems: SelectedItem[]; + onSuccess?: () => void; +} + +export const MoveScrapModal = ({ + visible, + onClose, + selectedItems, + onSuccess, +}: MoveScrapModalProps) => { + const [folderSelectionState, dispatch] = useReducer(reducer, { + ...initialSelectionState, + isSelecting: true, // 모달 내에서는 항상 선택 모드 + }); + const [isCreateFolderModalVisible, setIsCreateFolderModalVisible] = useState(false); + const { data: foldersData } = useGetFolders(); + const { mutateAsync: moveScraps } = useMoveScraps(); + + // 모달 상태에 따른 선택 모드 관리 + useEffect(() => { + if (visible) { + // 모달이 열릴 때 선택 모드 활성화 + dispatch({ type: 'ENTER_SELECTION' }); + } else { + // 모달이 닫힐 때 선택 상태 초기화 + dispatch({ type: 'CLEAR_SELECTION' }); + } + }, [visible]); + + // 폴더만 필터링 + const folders = useMemo(() => { + if (!foldersData?.data) return []; + return foldersData.data.map((folder) => ({ + type: 'FOLDER' as const, + id: folder.id, + name: folder.name, + scrapCount: folder.scrapCount, + createdAt: folder.createdAt, + })); + }, [foldersData]); + + // 선택된 폴더 ID (폴더는 하나만 선택 가능) + const selectedFolderId = folderSelectionState.selectedItems.find( + (item) => item.type === 'FOLDER' + )?.id; + + // 폴더 선택을 위한 커스텀 dispatch (하나만 선택 가능) + const folderDispatch = React.useCallback( + (action: Parameters[0]) => { + if (action.type === 'SELECTING_ITEM' && action.itemType === 'FOLDER') { + const isSelected = folderSelectionState.selectedItems.some( + (item) => item.id === action.id && item.type === 'FOLDER' + ); + if (isSelected) { + // 선택 해제 + dispatch(action); + } else { + // 다른 폴더 선택 해제 후 새로 선택 + const otherFolders = folderSelectionState.selectedItems.filter( + (item) => item.type === 'FOLDER' + ); + otherFolders.forEach((item) => { + dispatch({ type: 'SELECTING_ITEM', id: item.id, itemType: 'FOLDER' }); + }); + dispatch(action); + } + } else { + dispatch(action); + } + }, + [folderSelectionState.selectedItems] + ); + + // 이동 실행 + const handleMove = async () => { + if (!selectedFolderId) { + showToast('error', '이동할 폴더를 선택해주세요.'); + return; + } + + // 스크랩만 필터링 (폴더는 이동 불가) + const scrapsToMove = selectedItems.filter((item) => item.type === 'SCRAP'); + if (scrapsToMove.length === 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + return; + } + + try { + await moveScraps({ + scrapIds: scrapsToMove.map((item) => item.id), + targetFolderId: selectedFolderId, + }); + + showToast('success', `${scrapsToMove.length}개의 스크랩이 이동되었습니다.`); + dispatch({ type: 'CLEAR_SELECTION' }); + onSuccess?.(); + onClose(); + } catch (error) { + showToast('error', '이동 중 오류가 발생했습니다.'); + } + }; + + return ( + <> + + + + + 스크랩 이동하기 + setIsCreateFolderModalVisible(true)}> + + 새로운 폴더 + + + + + + + + + {selectedFolderId ? '스크랩 이동하기' : '이동할 폴더를 선택해주세요'} + + + + + + + setIsCreateFolderModalVisible(false)} + onSuccess={() => {}} + /> + + ); +}; diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index e1e7b0dd..70d89cfa 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -13,12 +13,14 @@ import type { UISortKey, SortOrder } from '../utils/types'; import PopUpModal from '../components/Modal/PopupModal'; import { showToast } from '../components/Modal/Toast'; import { useGetTrash, useRestoreTrash, usePermanentDeleteTrash } from '@/apis'; +import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; const DeletedScrapScreen = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); const [sortKey, setSortKey] = useState('TYPE'); const [sortOrder, setSortOrder] = useState('DESC'); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); const navigation = useNavigation>(); @@ -56,6 +58,20 @@ const DeletedScrapScreen = () => { setIsDeleteModalVisible(true); } }} + onMove={() => { + const selectedFolders = reducerState.selectedItems.filter( + (selected) => selected.type === 'FOLDER' + ); + if (selectedFolders.length > 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + setIsMoveModalVisible(true); + }} onRestore={async () => { try { const items = reducerState.selectedItems; @@ -131,6 +147,14 @@ const DeletedScrapScreen = () => { + setIsMoveModalVisible(false)} + selectedItems={reducerState.selectedItems} + onSuccess={() => { + dispatch({ type: 'CLEAR_SELECTION' }); + }} + /> ); }; diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index acac3d3c..c215003e 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -12,11 +12,13 @@ import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; import type { UISortKey, SortOrder } from '../utils/types'; import { showToast } from '../components/Modal/Toast'; import { useSearchScraps, useDeleteScrap } from '@/apis'; +import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; const ScrapScreen = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); const [sortKey, setSortKey] = useState('DATE'); const [sortOrder, setSortOrder] = useState('DESC'); + const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); const navigation = useNavigation>(); const { @@ -39,7 +41,7 @@ const ScrapScreen = () => { scrapCount: folder.scrapCount, createdAt: folder.createdAt, })); - const scraps = searchData.scraps || []; + const scraps = (searchData.scraps || []).filter((scrap) => scrap.folderId == null); return [...folders, ...scraps]; }, [searchData]); @@ -70,7 +72,13 @@ const ScrapScreen = () => { ); if (selectedFolders.length > 0) { showToast('error', '스크랩만 이동이 가능합니다.'); + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; } + setIsMoveModalVisible(true); }} onDelete={async () => { if (reducerState.selectedItems.length === 0) { @@ -115,6 +123,15 @@ const ScrapScreen = () => { )} + setIsMoveModalVisible(false)} + selectedItems={reducerState.selectedItems} + onSuccess={() => { + dispatch({ type: 'CLEAR_SELECTION' }); + refetch(); + }} + /> ); }; From d618ccc75d6baa9f123ffcdfcd5978582e6ad57a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:06:41 +0900 Subject: [PATCH 090/140] Feat: add folder creation and separate image picker modal --- .../components/Modal/CreateFolderModal.tsx | 105 ++++++++++++++++++ .../components/Modal/LoadQnaImageModal.tsx | 63 +++++++++++ 2 files changed, 168 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx create mode 100644 apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx diff --git a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx new file mode 100644 index 00000000..95b935d7 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -0,0 +1,105 @@ +import React, { useState, useEffect } from 'react'; +import { View, Text, Pressable, Image, TextInput } from 'react-native'; +import PopUpModal from './PopupModal'; +import { useCreateFolder } from '@/apis'; +import { showToast } from './Toast'; +import { openImageLibrary } from '../../utils/imagePicker'; +import { colors } from '@/theme/tokens'; + +interface CreateFolderModalProps { + visible: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export const CreateFolderModal = ({ + visible, + onClose, + onSuccess, +}: CreateFolderModalProps) => { + const [folderName, setFolderName] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const { mutateAsync: createFolder } = useCreateFolder(); + + // 모달이 닫힐 때 상태 초기화 + useEffect(() => { + if (!visible) { + setFolderName(''); + setSelectedImage(null); + } + }, [visible]); + + const onPressGallery = async () => { + const image = await openImageLibrary(); + if (image) { + setSelectedImage(image.uri); + } + }; + + const handleCreate = async () => { + if (!folderName.trim()) { + showToast('error', '폴더 이름을 입력해주세요.'); + return; + } + + try { + await createFolder({ name: folderName }); + showToast('success', '폴더가 추가되었습니다.'); + onSuccess?.(); + onClose(); + } catch (error) { + showToast('error', '폴더 추가에 실패했습니다.'); + } + }; + + const handleCancel = () => { + setFolderName(''); + setSelectedImage(null); + onClose(); + }; + + return ( + + + + + 취소 + + 새로운 폴더 생성 + + 완료 + + + + + + + {selectedImage ? ( + + ) : ( + + )} + + + + + + + + + ); +}; + diff --git a/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx b/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx new file mode 100644 index 00000000..13d4bff3 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx @@ -0,0 +1,63 @@ +import React, { useState, useEffect } from 'react'; +import { View } from 'react-native'; +import { LoadQnaImageScreenModal } from './FullScreenModal'; +import { Container } from '@/components/common'; +import SortDropdown from './SortDropdown'; +import type { UISortKey, SortOrder } from '../../utils/types'; + +interface LoadQnaImageModalProps { + visible: boolean; + onClose: () => void; + onSuccess?: () => void; +} + +export const LoadQnaImageModal = ({ + visible, + onClose, + onSuccess, +}: LoadQnaImageModalProps) => { + const [sortKey, setSortKey] = useState('DATE'); + const [sortOrder, setSortOrder] = useState('DESC'); + + // 모달이 닫힐 때 상태 초기화 (필요한 경우) + useEffect(() => { + if (!visible) { + // 필요시 상태 초기화 + } + }, [visible]); + + const handleCancel = () => { + onClose(); + }; + + const handleClose = () => { + onSuccess?.(); + onClose(); + }; + + return ( + + + + + + ); +}; + From eea566fffb095fe414fc8aecd2008adf41f2e336 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:07:20 +0900 Subject: [PATCH 091/140] Feat: add scrap move feature --- .../scrap/screens/ScrapContentScreen.tsx | 28 ++++++++++++++++--- 1 file changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index 29dc373f..395187a6 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -12,6 +12,7 @@ import SortDropdown from '../components/Modal/SortDropdown'; import { ScrapGrid } from '../components/Card/ScrapCardGrid'; import { showToast } from '../components/Modal/Toast'; import { useGetScrapsByFolder, useDeleteScrap, useGetFolders } from '@/apis'; +import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; type ScrapContentRouteProp = RouteProp; @@ -23,10 +24,11 @@ const ScrapContentScreen = () => { const [sortKey, setSortKey] = useState('TITLE'); const [sortOrder, setSortOrder] = useState('ASC'); const navigation = useNavigation>(); + const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); // API 호출 const { data: foldersData } = useGetFolders(); - const { data: contentsData, isLoading } = useGetScrapsByFolder(id); + const { data: contentsData, isLoading, refetch } = useGetScrapsByFolder(id); const { mutateAsync: deleteScrap } = useDeleteScrap(); // 폴더 정보 가져오기 @@ -58,9 +60,18 @@ const ScrapContentScreen = () => { dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); }} onMove={() => { - // 폴더 내부에서는 스크랩만 있으므로 이동 기능은 필요 없지만, - // 일관성을 위해 빈 함수로 제공 - showToast('info', '이동 기능은 준비 중입니다.'); + const selectedFolders = reducerState.selectedItems.filter( + (selected) => selected.type === 'FOLDER' + ); + if (selectedFolders.length > 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + setIsMoveModalVisible(true); }} onDelete={async () => { if (reducerState.selectedItems.length === 0) { @@ -98,6 +109,15 @@ const ScrapContentScreen = () => { )} + setIsMoveModalVisible(false)} + selectedItems={reducerState.selectedItems} + onSuccess={() => { + dispatch({ type: 'CLEAR_SELECTION' }); + refetch(); + }} + /> ); }; From 577186540d28983ea2b8eaf8366dc491c1e2652e Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:07:49 +0900 Subject: [PATCH 092/140] Fix: correct responsive layout behavior --- .../scrap/components/Card/ScrapCardGrid.tsx | 38 ++++++++++++++++--- 1 file changed, 33 insertions(+), 5 deletions(-) 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 e5bd44d8..4108ef20 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -3,7 +3,7 @@ import { Action, State } from '../../utils/reducer'; import { ScrapCard } from './cards/ScrapCard'; import { SearchResultCard } from './cards/SearchResultCard'; import { TrashCard } from './cards/TrashCard'; -import { ScrapAddItem, ScrapReviewItem } from './ScrapHeadCard'; +import { ScrapAddItem, ScrapReviewItem } from './cards/ScrapHeadCard'; import { ScrapItem, TrashItem } from '@/features/student/scrap/utils/types'; import { useGridLayout } from '../../utils/gridLayout'; import { useState } from 'react'; @@ -120,7 +120,8 @@ interface SearchScrapGridProps { * 검색 결과 그리드 컴포넌트 */ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { - const { numColumns, gap } = useGridLayout(); + const [containerWidth, setContainerWidth] = useState(0); + const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); const finalData = addPlaceholders(data, numColumns); return ( @@ -128,7 +129,14 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { key={numColumns} data={finalData} numColumns={numColumns} + onLayout={(e) => { + const width = Math.floor(e.nativeEvent.layout.width); + if (width > 0 && width !== containerWidth) { + setContainerWidth(width); + } + }} keyExtractor={(item) => + // item may be a ScrapItem/TrashItem or a placeholder item 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() } contentContainerStyle={{ paddingBottom: 120 }} @@ -137,10 +145,16 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { const isLastColumn = (index + 1) % numColumns === 0; const spacingStyle = { - flex: 1, + width: itemWidth, + height: itemHeight, marginRight: isLastColumn ? 0 : gap, }; + // Check for placeholder first + if ('placeholder' in item && item.placeholder) { + return ; + } + if ('placeholder' in item && item.placeholder) { return ; } @@ -169,7 +183,8 @@ interface TrashScrapGridProps { } export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridProps) => { - const { numColumns, gap } = useGridLayout(); + const [containerWidth, setContainerWidth] = useState(0); + const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); const finalData = addPlaceholders(data, numColumns); return ( @@ -177,7 +192,14 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP key={numColumns} data={finalData} numColumns={numColumns} + onLayout={(e) => { + const width = Math.floor(e.nativeEvent.layout.width); + if (width > 0 && width !== containerWidth) { + setContainerWidth(width); + } + }} keyExtractor={(item) => + // item may be a ScrapItem/TrashItem or a placeholder item 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() } contentContainerStyle={{ paddingBottom: 120 }} @@ -186,10 +208,16 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP const isLastColumn = (index + 1) % numColumns === 0; const spacingStyle = { - flex: 1, + width: itemWidth, + height: itemHeight, marginRight: isLastColumn ? 0 : gap, }; + // Check for placeholder first + if ('placeholder' in item && item.placeholder) { + return ; + } + if ('placeholder' in item && item.placeholder) { return ; } From 62233e021162b9370e06c6a0ff517586ebf2127c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:08:04 +0900 Subject: [PATCH 093/140] Fix: correct responsive layout behavior --- .../components/Card/cards/SearchResultCard.tsx | 6 +++--- .../scrap/components/Card/cards/TrashCard.tsx | 15 ++++++--------- 2 files changed, 9 insertions(+), 12 deletions(-) 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 e62956f8..3d446162 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 @@ -21,8 +21,8 @@ export const SearchResultCard = ({ item }: SearchResultCardProps) => { const thumbnailUrl = item.type === 'SCRAP' ? item.thumbnailUrl : undefined; const cardContent = ( - - + + {thumbnailUrl ? ( { - {new Date(item.createdAt).toLocaleDateString('ko-kr')} + {new Date(item.createdAt).toLocaleString('ko-kr')} diff --git a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx index 112c43f1..454b8a12 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -1,7 +1,7 @@ import { Pressable, View, Text } from 'react-native'; import React, { useState } from 'react'; import { Check } from 'lucide-react-native'; -import { TooltipPopover, TrashItemTooltipBox } from '../../Modal/Tooltip'; +import { TooltipPopover, TrashItemTooltipBox } from '../../Tooltip'; import type { TrashItem } from '@/features/student/scrap/utils/types'; import PopUpModal from '../../Modal/PopupModal'; import { showToast } from '../../Modal/Toast'; @@ -13,9 +13,6 @@ export interface TrashCardProps extends SelectableUIProps { item: TrashItem; } -/** - * 휴지통 카드 컴포넌트 - */ export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) => { const state = reducerState ?? { isSelecting: false, selectedItems: [] }; const isSelected = isItemSelected(state.selectedItems, item.id, item.type); @@ -23,8 +20,10 @@ export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); const cardContent = ( - - + + + + {state.isSelecting && ( {item.name} - - {item.daysUntilPermanentDelete}일 후 영구 삭제 - + {item.daysUntilPermanentDelete}일 남음 ); From 30e5a3283164981c5c02642c7f327af2d0c65367 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:08:31 +0900 Subject: [PATCH 094/140] Fix: invalidate API queries --- .../src/apis/controller/scrap/putMoveScraps.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/apps/native/src/apis/controller/scrap/putMoveScraps.ts b/apps/native/src/apis/controller/scrap/putMoveScraps.ts index b3e1f1b6..e21e51de 100644 --- a/apps/native/src/apis/controller/scrap/putMoveScraps.ts +++ b/apps/native/src/apis/controller/scrap/putMoveScraps.ts @@ -22,6 +22,20 @@ export const useMoveScraps = () => { queryClient.invalidateQueries({ queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, }); + // 폴더별 스크랩 목록 갱신 (모든 폴더의 스크랩 목록 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/folder/') && + key[1].includes('/scraps') + ); + }, + }); // 검색 결과 갱신 (모든 검색 쿼리 무효화) queryClient.invalidateQueries({ predicate: (query) => { From 4c190345e98f6679890d92279004ead78a19d199 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 25 Dec 2025 23:09:00 +0900 Subject: [PATCH 095/140] Refactor: move ScrapHeadCard directory --- .../scrap/components/Card/ScrapHeadCard.tsx | 188 ------------------ .../components/Card/cards/ScrapHeadCard.tsx | 97 +++++++++ .../student/scrap/components/Card/index.ts | 3 +- 3 files changed, 98 insertions(+), 190 deletions(-) delete mode 100644 apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx create mode 100644 apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx diff --git a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx deleted file mode 100644 index e7ecce2b..00000000 --- a/apps/native/src/features/student/scrap/components/Card/ScrapHeadCard.tsx +++ /dev/null @@ -1,188 +0,0 @@ -import { colors } from '@/theme/tokens'; -import { Plus } from 'lucide-react-native'; -import { Pressable, View, Text, Image } from 'react-native'; -import { TooltipPopover, AddItemTooltipBox, ReviewItemTooltipBox } from '../Modal/Tooltip'; -import { Placement } from 'react-native-popover-view/dist/Types'; -import { 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 { AddFolderScreenModal, LoadQnaImageScreenModal } from '../Modal/FullScreenModal'; -import { ScrollView, TextInput } from 'react-native'; -import { showToast } from '../Modal/Toast'; -import { openImageLibrary } from '../../utils/imagePicker'; -import type { UISortKey, SortOrder } from '../../utils/types'; -import { Container } from '@/components/common'; -import SortDropdown from '../Modal/SortDropdown'; -import { useCreateFolder } from '@/apis'; - -export const ScrapAddItem = () => { - const [isFolderModalVisible, setIsFolderModalVisible] = useState(false); - const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); - - const [folderName, setFolderName] = useState(''); - const [selectedImage, setSelectedImage] = useState(null); - const { mutateAsync: createFolder } = useCreateFolder(); - - const [sortKey, setSortKey] = useState('DATE'); - const [sortOrder, setSortOrder] = useState('DESC'); - - const onPressGallery = async () => { - const image = await openImageLibrary(); - if (image) { - setSelectedImage(image.uri); - } - }; - - const handleFolderAdd = async () => { - if (folderName.trim()) { - try { - await createFolder({ name: folderName }); - setFolderName(''); - setSelectedImage(''); - setIsFolderModalVisible(false); - setTimeout(() => { - showToast('success', '폴더가 추가되었습니다.'); - }, 300); - } catch (error) { - showToast('error', '폴더 추가에 실패했습니다.'); - setSelectedImage(''); - } - } else { - showToast('error', '폴더 이름을 입력해주세요.'); - } - }; - - return ( - <> - ( - { - close(); - setTimeout(() => { - setIsFolderModalVisible(true); - }, 200); - }} - onOpenQnaImgModal={() => { - close(); - setTimeout(() => { - setisQnaImageModalVisible(true); - }, 200); - }} - /> - )} - from={ - - - - - - 추가하기 - - - } - /> - { - setFolderName(''); - setSelectedImage(''); - setIsFolderModalVisible(false); - }} - onClose={() => { - handleFolderAdd(); - }}> - - - onPressGallery()}> - {selectedImage ? ( - - ) : ( - - )} - - - - - - - - { - setisQnaImageModalVisible(false); - }} - onClose={() => { - setisQnaImageModalVisible(false); - }}> - - - - - - ); -}; - -export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { - const navigation = useNavigation>(); - - return ( - navigation.push('ScrapContent', { id: props.id })}> - - - - - - - {props.name} - - } - from={} - /> - - {props.type === 'FOLDER' && props.scrapCount !== undefined && ( - {props.scrapCount} - )} - - - {new Date(props.createdAt).toLocaleDateString()} - - - - ); -}; 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 new file mode 100644 index 00000000..2be6e0c2 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx @@ -0,0 +1,97 @@ +import { colors } from '@/theme/tokens'; +import { Plus } from 'lucide-react-native'; +import { Pressable, View, Text } from 'react-native'; +import { TooltipPopover, AddItemTooltipBox, ReviewItemTooltipBox } from '../../Tooltip'; +import { Placement } from 'react-native-popover-view/dist/Types'; +import { 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'; + +export const ScrapAddItem = () => { + const [isFolderModalVisible, setIsFolderModalVisible] = useState(false); + const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); + + return ( + <> + void) => ( + { + close(); + setTimeout(() => { + setIsFolderModalVisible(true); + }, 200); + }} + onOpenQnaImgModal={() => { + close(); + setTimeout(() => { + setisQnaImageModalVisible(true); + }, 200); + }} + /> + )} + from={ + + + + + + 추가하기 + + + } + /> + setIsFolderModalVisible(false)} + onSuccess={() => {}} + /> + setisQnaImageModalVisible(false)} + onSuccess={() => {}} + /> + + ); +}; + +export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { + const navigation = useNavigation>(); + + return ( + navigation.push('ScrapContent', { id: props.id })}> + + + + + + + {props.name} + + void) => ( + + )} + from={} + /> + + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( + {props.scrapCount} + )} + + + {new Date(props.createdAt).toLocaleDateString()} + + + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Card/index.ts b/apps/native/src/features/student/scrap/components/Card/index.ts index c71f8118..b7867390 100644 --- a/apps/native/src/features/student/scrap/components/Card/index.ts +++ b/apps/native/src/features/student/scrap/components/Card/index.ts @@ -19,5 +19,4 @@ export { ScrapGrid, SearchScrapGrid, TrashScrapGrid } from './ScrapCardGrid'; export type { ScrapGridItem } from './ScrapCardGrid'; // Head Cards -export { ScrapAddItem, ScrapReviewItem } from './ScrapHeadCard'; - +export { ScrapAddItem, ScrapReviewItem } from './cards/ScrapHeadCard'; From 412f7412638323aaf63e6ec68aece05e535ba834 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:34:57 +0900 Subject: [PATCH 096/140] fix(api): handle optimistic update for API --- .../apis/controller/scrap/deleteFolders.ts | 96 +++++++++++++++++-- .../src/apis/controller/scrap/deleteScrap.ts | 51 +++++++++- 2 files changed, 139 insertions(+), 8 deletions(-) diff --git a/apps/native/src/apis/controller/scrap/deleteFolders.ts b/apps/native/src/apis/controller/scrap/deleteFolders.ts index 3fc4ffb7..48dc9bb6 100644 --- a/apps/native/src/apis/controller/scrap/deleteFolders.ts +++ b/apps/native/src/apis/controller/scrap/deleteFolders.ts @@ -1,6 +1,7 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client } from '@/apis/client'; +import { useMutation, useQueryClient, QueryFilters } from '@tanstack/react-query'; +import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import type { ScrapSearchResponse } from '@/features/student/scrap/utils/types'; type DeleteFoldersRequest = paths['/api/student/scrap/folder']['delete']['requestBody']['content']['application/json']; @@ -14,10 +15,93 @@ export const useDeleteFolders = () => { body: request, }); }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: ['scrap', 'folders'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'search'] }); - queryClient.invalidateQueries({ queryKey: ['scrap', 'trash'] }); + // 낙관적 업데이트: 삭제 전 데이터 백업 및 즉시 UI 업데이트 + onMutate: async (request) => { + const deletedFolderIds = new Set(request); + + // 폴더 목록 쿼리 취소 및 백업 + const folderQueryKey = TanstackQueryClient.queryOptions( + 'get', + '/api/student/scrap/folder' + ).queryKey; + await queryClient.cancelQueries({ queryKey: folderQueryKey }); + const previousFolders = queryClient.getQueryData(folderQueryKey); + + // 검색 쿼리 취소 및 백업 + const searchQueryFilters: QueryFilters = { + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }; + await queryClient.cancelQueries(searchQueryFilters); + const previousQueries = queryClient.getQueriesData(searchQueryFilters); + + // 낙관적 업데이트: 폴더 목록에서 삭제된 폴더 제거 + queryClient.setQueryData(folderQueryKey, (old: any) => { + if (!old?.data) return old; + return { + ...old, + data: old.data.filter((folder: any) => !deletedFolderIds.has(folder.id)), + }; + }); + + // 낙관적 업데이트: 검색 결과에서 삭제된 폴더 제거 + queryClient.setQueriesData(searchQueryFilters, (old) => { + if (!old) return old; + return { + folders: old.folders?.filter((folder) => !deletedFolderIds.has(folder.id)), + scraps: old.scraps, + }; + }); + + // 롤백을 위한 이전 데이터 반환 + return { previousFolders, previousQueries }; + }, + // 에러 발생 시 롤백 + onError: (error, request, context) => { + if (context?.previousFolders) { + const folderQueryKey = TanstackQueryClient.queryOptions( + 'get', + '/api/student/scrap/folder' + ).queryKey; + queryClient.setQueryData(folderQueryKey, context.previousFolders); + } + if (context?.previousQueries) { + context.previousQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + } + }, + // 성공/실패 관계없이 쿼리 무효화 (백그라운드에서 최신 데이터 가져오기) + onSettled: () => { + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + }); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/deleteScrap.ts b/apps/native/src/apis/controller/scrap/deleteScrap.ts index 7cad976f..adb3a4a8 100644 --- a/apps/native/src/apis/controller/scrap/deleteScrap.ts +++ b/apps/native/src/apis/controller/scrap/deleteScrap.ts @@ -1,6 +1,7 @@ -import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { useMutation, useQueryClient, QueryFilters } from '@tanstack/react-query'; import { client, TanstackQueryClient } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import type { ScrapSearchResponse } from '@/features/student/scrap/utils/types'; type DeleteScrapRequest = paths['/api/student/scrap']['delete']['requestBody']['content']['application/json']; @@ -18,7 +19,53 @@ export const useDeleteScrap = () => { return { success: true, request }; }, - onSuccess: () => { + // 낙관적 업데이트: 삭제 전 데이터 백업 및 즉시 UI 업데이트 + onMutate: async (request) => { + const deletedIds = new Set(request.items.map((item) => `${item.type}-${item.id}`)); + + // 모든 검색 쿼리의 이전 데이터 백업 및 낙관적 업데이트 + const searchQueryFilters: QueryFilters = { + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }; + + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries(searchQueryFilters); + + // 이전 데이터 백업 + const previousQueries = queryClient.getQueriesData(searchQueryFilters); + + // 낙관적 업데이트: 삭제된 항목을 즉시 제거 + queryClient.setQueriesData(searchQueryFilters, (old) => { + if (!old) return old; + + return { + folders: old.folders?.filter((folder) => !deletedIds.has(`FOLDER-${folder.id}`)), + scraps: old.scraps?.filter((scrap) => !deletedIds.has(`SCRAP-${scrap.id}`)), + }; + }); + + // 롤백을 위한 이전 데이터 반환 + return { previousQueries }; + }, + // 에러 발생 시 롤백 + onError: (error, request, context) => { + if (context?.previousQueries) { + context.previousQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey, data); + }); + } + }, + // 성공/실패 관계없이 쿼리 무효화 (백그라운드에서 최신 데이터 가져오기) + onSettled: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, From 33907d222fe677215c9411af6474e70631db82f0 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:35:32 +0900 Subject: [PATCH 097/140] feat: apply skeleton image --- .../components/common/ImageWithSkeleton.tsx | 204 ++++++++++++++++++ apps/native/src/components/common/index.ts | 10 +- 2 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 apps/native/src/components/common/ImageWithSkeleton.tsx diff --git a/apps/native/src/components/common/ImageWithSkeleton.tsx b/apps/native/src/components/common/ImageWithSkeleton.tsx new file mode 100644 index 00000000..917f5af3 --- /dev/null +++ b/apps/native/src/components/common/ImageWithSkeleton.tsx @@ -0,0 +1,204 @@ +import { colors } from '@/theme/tokens'; +import React, { useEffect, useState, useRef } from 'react'; +import { View, Image, ImageProps, ImageStyle, DimensionValue, ViewStyle } from 'react-native'; +import { useAnimatedStyle, useSharedValue, withRepeat } from 'react-native-reanimated'; +import Animated, { withTiming, interpolate } from 'react-native-reanimated'; +import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg'; + +type ImageSkeletonProps = { + width?: DimensionValue; + height?: DimensionValue; + aspectRatio?: number; + borderRadius?: number; + className?: string; + style?: ViewStyle; + uniqueId?: string | number; +}; + +export const ImageSkeleton = ({ + width = '100%', + height, + aspectRatio, + borderRadius = 10, + className = '', + style, + uniqueId = 'default', +}: ImageSkeletonProps) => { + const shimmerTranslateX = useSharedValue(-1); + + useEffect(() => { + shimmerTranslateX.value = -1; + shimmerTranslateX.value = withRepeat(withTiming(1, { duration: 1500 }), -1, false); + }, [shimmerTranslateX]); + + const shimmerAnimatedStyle = useAnimatedStyle(() => { + const translateX = interpolate(shimmerTranslateX.value, [-1, 1], [-200, 200]); + return { + transform: [{ translateX }], + }; + }); + + return ( + + + + + + + + + + + + + + + ); +}; + +type ImageWithSkeletonProps = { + source: ImageProps['source']; + width?: DimensionValue; + height?: DimensionValue; + aspectRatio?: number; + borderRadius?: number; + resizeMode?: ImageProps['resizeMode']; + className?: string; + style?: ImageStyle; + uniqueId?: string | number; + fallback?: React.ReactNode; +}; + +const ImageWithSkeletonComponent = ({ + source, + width = '100%', + height, + aspectRatio, + borderRadius = 10, + resizeMode = 'cover', + className = '', + style, + uniqueId = 'default', + fallback, +}: ImageWithSkeletonProps) => { + // source.uri를 추출하여 의존성으로 사용 (객체 참조 문제 방지) + const imageUri = typeof source === 'object' && source && 'uri' in source ? source.uri : null; + + // useRef로 이미 로드된 URI 추적 (리렌더링에 영향받지 않음) + const loadedUriRef = useRef(null); + const [isImageLoading, setIsImageLoading] = useState(() => { + // 이미 로드된 이미지인지 확인 + return imageUri !== loadedUriRef.current; + }); + + // imageUri가 실제로 변경되었을 때만 로딩 상태 리셋 + useEffect(() => { + if (imageUri && imageUri !== loadedUriRef.current) { + setIsImageLoading(true); + } + }, [imageUri]); + + if (!source && fallback) { + return <>{fallback}; + } + + if (!source) { + return ( + + ); + } + + return ( + + {isImageLoading && ( + + )} + { + // 이미 로드된 이미지가 아닐 때만 로딩 상태로 변경 + if (imageUri && imageUri !== loadedUriRef.current) { + setIsImageLoading(true); + } + }} + onLoad={() => { + setIsImageLoading(false); + if (imageUri) { + loadedUriRef.current = imageUri; + } + }} + onError={(error) => { + console.warn('Image load error:', error.nativeEvent?.error || 'Unknown error'); + setIsImageLoading(false); + // 에러가 나도 같은 URI를 다시 로드하지 않도록 (무한 루프 방지) + if (imageUri) { + loadedUriRef.current = imageUri; + } + }} + /> + + ); +}; + +// React.memo로 감싸서 props가 변경되지 않으면 리렌더링 방지 +export const ImageWithSkeleton = React.memo(ImageWithSkeletonComponent, (prevProps, nextProps) => { + // uniqueId와 source.uri가 같으면 리렌더링하지 않음 + const prevUri = typeof prevProps.source === 'object' && prevProps.source && 'uri' in prevProps.source + ? prevProps.source.uri + : null; + const nextUri = typeof nextProps.source === 'object' && nextProps.source && 'uri' in nextProps.source + ? nextProps.source.uri + : null; + + return ( + prevProps.uniqueId === nextProps.uniqueId && + prevUri === nextUri && + prevProps.width === nextProps.width && + prevProps.height === nextProps.height && + prevProps.aspectRatio === nextProps.aspectRatio && + prevProps.borderRadius === nextProps.borderRadius && + prevProps.resizeMode === nextProps.resizeMode + ); +}); diff --git a/apps/native/src/components/common/index.ts b/apps/native/src/components/common/index.ts index 14b53219..5a75c176 100644 --- a/apps/native/src/components/common/index.ts +++ b/apps/native/src/components/common/index.ts @@ -3,5 +3,13 @@ import LoadingScreen from './LoadingScreen'; import NotificationItem from './NotificationItem'; import TextButton from './TextButton'; import SegmentedControl from './SegmentedControl'; +import { ImageWithSkeleton } from './ImageWithSkeleton'; -export { Container, LoadingScreen, NotificationItem, TextButton, SegmentedControl }; +export { + Container, + LoadingScreen, + NotificationItem, + TextButton, + SegmentedControl, + ImageWithSkeleton, +}; From 974df7062ac0c2b30b1549ea7dcebcd399591054 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:36:13 +0900 Subject: [PATCH 098/140] feat: implement recent scrap --- .../components/Card/cards/RecentScrapCard.tsx | 43 +++++++++++++++++++ apps/native/src/stores/recentScrapStore.ts | 42 ++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 apps/native/src/features/student/scrap/components/Card/cards/RecentScrapCard.tsx create mode 100644 apps/native/src/stores/recentScrapStore.ts 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 new file mode 100644 index 00000000..2caf4158 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/RecentScrapCard.tsx @@ -0,0 +1,43 @@ +import React from 'react'; +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 { useNoteStore } from '@/stores/scrapNoteStore'; +import { useRecentScrapStore } from '@/stores/recentScrapStore'; + +type RecentScrapCardProps = { + scrap: ScrapDetailResp & { type: 'SCRAP' }; +}; + +export const RecentScrapCard = ({ scrap }: RecentScrapCardProps) => { + const navigation = useNavigation>(); + const openNote = useNoteStore((state) => state.openNote); + const addScrap = useRecentScrapStore((state) => state.addScrap); + + return ( + { + openNote({ id: scrap.id, title: scrap.name ?? '' }); + addScrap(scrap.id); + navigation.push('ScrapContentDetail', { id: scrap.id }); + }} + className='bg-primary-200 h-[140px] w-[140px] flex-col items-center justify-end rounded-[12px] border border-gray-300'> + + + + {scrap.name} + + + {scrap.updatedAt} + + + + ); +}; diff --git a/apps/native/src/stores/recentScrapStore.ts b/apps/native/src/stores/recentScrapStore.ts new file mode 100644 index 00000000..13fc60f4 --- /dev/null +++ b/apps/native/src/stores/recentScrapStore.ts @@ -0,0 +1,42 @@ +import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; +import AsyncStorage from '@react-native-async-storage/async-storage'; + +interface RecentScrapStore { + /** 최근 본 스크랩 ID 목록 (최신순) */ + scrapIds: number[]; + /** 스크랩 추가 */ + addScrap: (scrapId: number) => void; + /** 스크랩 제거 */ + removeScrap: (scrapId: number) => void; + /** 전체 초기화 */ + clear: () => void; +} + +export const useRecentScrapStore = create()( + persist( + (set) => ({ + scrapIds: [], + + addScrap: (scrapId) => + set((state) => { + // 중복 제거 후 맨 앞에 추가 + const filtered = state.scrapIds.filter((id) => id !== scrapId); + return { + scrapIds: [scrapId, ...filtered].slice(0, 10), + }; + }), + + removeScrap: (scrapId) => + set((state) => ({ + scrapIds: state.scrapIds.filter((id) => id !== scrapId), + })), + + clear: () => set({ scrapIds: [] }), + }), + { + name: 'recent-scrap-store', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); From ecfd4a9b1398af54d219111d1491ff83142b11f1 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:37:24 +0900 Subject: [PATCH 099/140] fix: update image util --- .../student/scrap/utils/imagePicker.ts | 41 +++++++ .../student/scrap/utils/imageUpload.ts | 102 ++++++++++++++++++ .../features/student/scrap/utils/s3Upload.ts | 32 ++++++ 3 files changed, 175 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/imageUpload.ts create mode 100644 apps/native/src/features/student/scrap/utils/s3Upload.ts diff --git a/apps/native/src/features/student/scrap/utils/imagePicker.ts b/apps/native/src/features/student/scrap/utils/imagePicker.ts index b809939f..6595657b 100644 --- a/apps/native/src/features/student/scrap/utils/imagePicker.ts +++ b/apps/native/src/features/student/scrap/utils/imagePicker.ts @@ -1,5 +1,46 @@ import * as ImagePicker from 'expo-image-picker'; +import { Alert } from 'react-native'; +// 에러 핸들러 타입 정의 +type ErrorHandler = (error: Error) => void; + +// 기본 에러 핸들러 (Alert 사용) +const defaultErrorHandler: ErrorHandler = (error: Error) => { + if (error.message?.includes('permission')) { + const permissionType = error.message.includes('Camera') ? '카메라' : '갤러리'; + Alert.alert('권한 필요', `${permissionType} 권한이 필요합니다.`); + } else { + console.error('이미지 선택 오류:', error); + } +}; + +// 카메라 열기 (에러 처리 포함) +export const openCameraWithErrorHandling = async ( + onError?: ErrorHandler +): Promise => { + try { + return await openCamera(); + } catch (error: any) { + const handler = onError || defaultErrorHandler; + handler(error); + return null; + } +}; + +// 갤러리 열기 (에러 처리 포함) +export const openImageLibraryWithErrorHandling = async ( + onError?: ErrorHandler +): Promise => { + try { + return await openImageLibrary(); + } catch (error: any) { + const handler = onError || defaultErrorHandler; + handler(error); + return null; + } +}; + +// 기존 함수들 (하위 호환성 유지) export const openCamera = async () => { const { status } = await ImagePicker.requestCameraPermissionsAsync(); if (status !== 'granted') { diff --git a/apps/native/src/features/student/scrap/utils/imageUpload.ts b/apps/native/src/features/student/scrap/utils/imageUpload.ts new file mode 100644 index 00000000..e1fa2c4c --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/imageUpload.ts @@ -0,0 +1,102 @@ +import { uploadFileToS3 } from './s3Upload'; +import * as ImagePicker from 'expo-image-picker'; + +/** + * 이미지 업로드 결과 + */ +export interface ImageUploadResult { + fileId: number; + uploadUrl: string; + contentDisposition: string; +} + +/** + * Pre-signed URL 요청 함수 타입 + */ +type GetPreSignedUrlFn = ( + params: { fileName: string }, + callbacks: { + onSuccess: (data: { + uploadUrl: string; + contentDisposition: string; + file: { id: number }; + }) => void; + onError: (error: any) => void; + } +) => void; + +/** + * 이미지를 S3에 업로드하는 공통 함수 + * @param image - 업로드할 이미지 + * @param getPreSignedUrl - Pre-signed URL을 요청하는 함수 + * @param onSuccess - 업로드 성공 후 실행할 콜백 (fileId를 받음) + * @param onError - 에러 발생 시 실행할 콜백 + * @returns Promise - 업로드 성공 여부 + */ +export const uploadImageToS3 = async ( + image: ImagePicker.ImagePickerAsset, + getPreSignedUrl: GetPreSignedUrlFn, + onSuccess: (result: ImageUploadResult) => Promise | void, + onError?: (error: string) => void +): Promise => { + if (!image || !image.uri) { + onError?.('이미지가 없습니다.'); + return false; + } + + try { + // 파일명 추출 (없으면 기본값 사용) + const fileName = image.fileName || `image_${Date.now()}.jpg`; + + // Promise로 변환하여 await 가능하게 함 + return await new Promise((resolve) => { + getPreSignedUrl( + { fileName }, + { + onSuccess: async (data) => { + try { + const { uploadUrl, contentDisposition, file } = data; + + if (!uploadUrl) { + onError?.('업로드 URL을 받아오지 못했습니다.'); + resolve(false); + return; + } + + // S3에 파일 업로드 + const uploadSuccess = await uploadFileToS3(uploadUrl, image.uri, contentDisposition); + + if (!uploadSuccess) { + onError?.('파일 업로드에 실패했습니다.'); + resolve(false); + return; + } + + // 성공 후 처리 + await onSuccess({ + fileId: file.id, + uploadUrl, + contentDisposition, + }); + + resolve(true); + } catch (error) { + console.error('이미지 업로드 후 처리 실패:', error); + onError?.('이미지 처리 중 오류가 발생했습니다.'); + resolve(false); + } + }, + onError: (error) => { + console.error('Pre-signed URL 요청 실패:', error); + onError?.('파일 업로드 준비에 실패했습니다.'); + resolve(false); + }, + } + ); + }); + } catch (error) { + console.error('이미지 업로드 실패:', error); + onError?.('이미지 업로드 중 오류가 발생했습니다.'); + return false; + } +}; diff --git a/apps/native/src/features/student/scrap/utils/s3Upload.ts b/apps/native/src/features/student/scrap/utils/s3Upload.ts new file mode 100644 index 00000000..dbedced4 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/s3Upload.ts @@ -0,0 +1,32 @@ +/** + * S3에 파일을 업로드하는 유틸리티 함수 + * @param uploadUrl - Pre-signed 업로드 URL + * @param fileUri - 업로드할 파일의 URI (React Native) + * @param contentDisposition - Content-Disposition 헤더 값 + * @returns 업로드 성공 여부 + */ +export const uploadFileToS3 = async ( + uploadUrl: string, + fileUri: string, + contentDisposition: string +): Promise => { + try { + // React Native에서 파일 읽기 + const response = await fetch(fileUri); + const blob = await response.blob(); + + // S3에 PUT 요청 + const uploadResponse = await fetch(uploadUrl, { + method: 'PUT', + headers: { + 'content-disposition': contentDisposition, + }, + body: blob, + }); + + return uploadResponse.ok; + } catch (error) { + console.error('S3 업로드 실패:', error); + return false; + } +}; From 3fe9d40c917e8ba165747dece0a53d315d442a03 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:38:05 +0900 Subject: [PATCH 100/140] feat: add recent scrap section --- .../student/scrap/screens/ScrapScreen.tsx | 197 +++++++++++------- 1 file changed, 118 insertions(+), 79 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index c215003e..3d179392 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -3,16 +3,20 @@ import { StudentRootStackParamList } from '@/navigation/student/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import React, { useMemo, useReducer, useState } from 'react'; -import { View } from 'react-native'; +import { View, Text, ScrollView, Pressable, ImageBackground } from 'react-native'; import { reducer, initialSelectionState } from '../utils/reducer'; import ScrapHeader from '../components/Header/ScrapHeader'; import { ScrapGrid } from '../components/Card/ScrapCardGrid'; import SortDropdown from '../components/Modal/SortDropdown'; +import { useRecentScrapStore } from '@/stores/recentScrapStore'; import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; -import type { UISortKey, SortOrder } from '../utils/types'; +import type { UISortKey, SortOrder, ScrapSearchResponse } from '../utils/types'; import { showToast } from '../components/Modal/Toast'; import { useSearchScraps, useDeleteScrap } from '@/apis'; import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; +import { useQueries } from '@tanstack/react-query'; +import { TanstackQueryClient } from '@/apis'; +import { RecentScrapCard } from '../components/Card/cards/RecentScrapCard'; const ScrapScreen = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); @@ -20,28 +24,53 @@ const ScrapScreen = () => { const [sortOrder, setSortOrder] = useState('DESC'); const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); const navigation = useNavigation>(); + const recentScraps = useRecentScrapStore((state) => state.scrapIds); - const { - data: searchData, - isLoading, - refetch, - } = useSearchScraps({ + const { data: searchData, isLoading } = useSearchScraps({ sort: mapUIKeyToAPIKey(sortKey), order: sortOrder, }); const { mutateAsync: deleteScrap } = useDeleteScrap(); - // ScrapSearchResp는 folders와 scraps를 각각 반환하므로 합쳐야 함 + const recentScrapsQueries = useQueries({ + queries: + recentScraps.length > 0 + ? recentScraps.map((scrapId) => ({ + ...TanstackQueryClient.queryOptions('get', '/api/student/scrap/{id}', { + params: { + path: { id: scrapId }, + }, + }), + enabled: scrapId > 0 && recentScraps.length > 0, + })) + : [], + }); + + const recentScrapsData = useMemo(() => { + if (recentScraps.length === 0) return []; + + return recentScrapsQueries + .map((query) => { + const scrapDetail = query.data; + if (!scrapDetail) return null; + + return { + ...scrapDetail, + type: 'SCRAP' as const, + }; + }) + .filter((scrap): scrap is NonNullable => scrap !== null); + }, [recentScrapsQueries, recentScraps.length]); + + // ScrapSearchResponse는 folders와 scraps를 각각 반환하므로 합쳐야 함 const data = useMemo(() => { if (!searchData) return []; - const folders = (searchData.folders || []).map((folder) => ({ + const typedSearchData = searchData as ScrapSearchResponse; + const folders = (typedSearchData.folders || []).map((folder) => ({ + ...folder, type: 'FOLDER' as const, - id: folder.id, - name: folder.name, - scrapCount: folder.scrapCount, - createdAt: folder.createdAt, })); - const scraps = (searchData.scraps || []).filter((scrap) => scrap.folderId == null); + const scraps = (typedSearchData.scraps || []).filter((scrap) => scrap.folderId == null); return [...folders, ...scraps]; }, [searchData]); @@ -54,74 +83,85 @@ const ScrapScreen = () => { const isAllSelected = data.length > 0 && reducerState.selectedItems.length === data.length; return ( - - navigation.push('SearchScrap')} - navigateTrashPress={() => navigation.push('DeletedScrap')} - onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} - onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} - isAllSelected={isAllSelected} - onSelectAll={() => { - const allItems = data.map((item) => ({ id: item.id, type: item.type })); - dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); - }} - onMove={() => { - const selectedFolders = reducerState.selectedItems.filter( - (selected) => selected.type === 'FOLDER' - ); - if (selectedFolders.length > 0) { - showToast('error', '스크랩만 이동이 가능합니다.'); - return; - } - if (reducerState.selectedItems.length === 0) { - showToast('error', '이동할 스크랩을 선택해주세요.'); - return; - } - setIsMoveModalVisible(true); - }} - onDelete={async () => { - if (reducerState.selectedItems.length === 0) { - showToast('error', '삭제할 항목을 선택해주세요.'); - return; - } + <> + + navigation.push('SearchScrap')} + navigateTrashPress={() => navigation.push('DeletedScrap')} + onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} + onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} + isAllSelected={isAllSelected} + onSelectAll={() => { + const allItems = data.map((item) => ({ id: item.id, type: item.type })); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); + }} + onMove={() => { + const selectedFolders = reducerState.selectedItems.filter( + (selected) => selected.type === 'FOLDER' + ); + if (selectedFolders.length > 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + setIsMoveModalVisible(true); + }} + onDelete={async () => { + if (reducerState.selectedItems.length === 0) { + showToast('error', '삭제할 항목을 선택해주세요.'); + return; + } - try { const items = reducerState.selectedItems; - await deleteScrap({ items }); - - // 데이터 다시 불러오기 - await refetch(); - dispatch({ type: 'CLEAR_SELECTION' }); - showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); - } catch (error: any) { - showToast('error', '삭제 중 오류가 발생했습니다.'); - } - }} - /> - - - - - - {isLoading ? ( - - ) : ( - + + try { + await deleteScrap({ items }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + // 에러 발생 시 롤백은 mutation의 onError에서 처리됨 + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }} + /> + + {recentScrapsData.length > 0 && ( + + 최근 본 + + {recentScrapsData.map((scrap) => ( + + ))} + + )} - + + 전체 스크랩 + + + + {isLoading ? ( + + ) : ( + + )} + + { selectedItems={reducerState.selectedItems} onSuccess={() => { dispatch({ type: 'CLEAR_SELECTION' }); - refetch(); }} /> - + ); }; From 06dd6434a49f7b0133a7ecb20279353ce1b00680 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:38:26 +0900 Subject: [PATCH 101/140] fix: update FlatList keyExtractor --- .../scrap/components/Card/ScrapCardGrid.tsx | 93 +++++++++++++------ 1 file changed, 65 insertions(+), 28 deletions(-) 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 4108ef20..5fd8f64e 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -45,6 +45,7 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { return ( { @@ -53,12 +54,28 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { setContainerWidth(width); } }} - keyExtractor={(item) => - // item may be a ScrapItem/TrashItem or a placeholder item - 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() - } + keyExtractor={(item, index) => { + if ('placeholder' in item && item.placeholder) { + return `placeholder-${index}`; + } + + if ('ADD' in item && item.ADD === true) { + return 'add-item'; + } + + if ('id' in item && 'type' in item) { + return `${item.type}-${item.id}`; + } + + return `fallback-${index}`; + }} contentContainerStyle={{ paddingBottom: 120 }} - columnWrapperStyle={{ marginBottom: gap }} + columnWrapperStyle={{ marginBottom: gap * 2 }} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={10} + initialNumToRender={10} renderItem={({ item, index }) => { const isLastColumn = (index + 1) % numColumns === 0; @@ -77,7 +94,7 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { if ('ADD' in item && item.ADD === true) { return ( - + ); } @@ -109,16 +126,10 @@ export const ScrapGrid = ({ data, reducerState, dispatch }: ScrapGridProps) => { ); }; -/** - * 검색 결과 그리드 Props - */ interface SearchScrapGridProps { data: ScrapItem[]; } -/** - * 검색 결과 그리드 컴포넌트 - */ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { const [containerWidth, setContainerWidth] = useState(0); const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); @@ -135,12 +146,28 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { setContainerWidth(width); } }} - keyExtractor={(item) => - // item may be a ScrapItem/TrashItem or a placeholder item - 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() - } + keyExtractor={(item, index) => { + if ('placeholder' in item && item.placeholder) { + return `placeholder-${index}`; + } + + if ('ADD' in item && item.ADD === true) { + return 'add-item'; + } + + if ('id' in item && 'type' in item) { + return `${item.type}-${item.id}`; + } + + return `fallback-${index}`; + }} contentContainerStyle={{ paddingBottom: 120 }} - columnWrapperStyle={{ marginBottom: gap }} + columnWrapperStyle={{ marginBottom: gap * 2 }} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={10} + initialNumToRender={10} renderItem={({ item, index }) => { const isLastColumn = (index + 1) % numColumns === 0; @@ -150,16 +177,10 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { marginRight: isLastColumn ? 0 : gap, }; - // Check for placeholder first - if ('placeholder' in item && item.placeholder) { - return ; - } - if ('placeholder' in item && item.placeholder) { return ; } - // Type guard: ensure item has required properties if (!('id' in item) || !('type' in item)) { return ; } @@ -198,12 +219,28 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP setContainerWidth(width); } }} - keyExtractor={(item) => - // item may be a ScrapItem/TrashItem or a placeholder item - 'id' in item && item.id !== undefined ? String(item.id) : Math.random().toString() - } + keyExtractor={(item, index) => { + if ('placeholder' in item && item.placeholder) { + return `placeholder-${index}`; + } + + if ('ADD' in item && item.ADD === true) { + return 'add-item'; + } + + if ('id' in item && 'type' in item) { + return `${item.type}-${item.id}`; + } + + return `fallback-${index}`; + }} contentContainerStyle={{ paddingBottom: 120 }} - columnWrapperStyle={{ marginBottom: gap }} + columnWrapperStyle={{ marginBottom: gap * 2 }} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={10} + initialNumToRender={10} renderItem={({ item, index }) => { const isLastColumn = (index + 1) % numColumns === 0; From 1dd92bb8ff66aac61d4382773f8a0b2297390720 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:39:05 +0900 Subject: [PATCH 102/140] feat: make Card component responsive and add skeleton image --- .../scrap/components/Card/cards/ScrapCard.tsx | 117 ++++++++++-------- .../components/Card/cards/ScrapHeadCard.tsx | 74 ++++++----- .../Card/cards/SearchResultCard.tsx | 52 ++++---- 3 files changed, 134 insertions(+), 109 deletions(-) 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 70c08477..ab3a6577 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 @@ -1,5 +1,5 @@ import { Pressable, View, Text, Image } from 'react-native'; -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; import { TooltipPopover, ItemTooltipBox } from '../../Tooltip'; @@ -8,82 +8,92 @@ import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; import type { ScrapListItemProps } from '../types'; import { isItemSelected } from '../../../utils/reducer'; -import { useNoteStore, Note } from '@/stores/scrapNoteStore'; +import { useNoteStore } from '@/stores/scrapNoteStore'; +import { useRecentScrapStore } from '@/stores/recentScrapStore'; import { MoveScrapModal } from '../../Modal/MoveScrapModal'; +import { colors } from '@/theme/tokens'; +import { ImageWithSkeleton } from '@/components/common'; export const ScrapCard = (props: ScrapListItemProps) => { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; 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 [isMoveModalVisible, setIsMoveModalVisible] = useState(false); - const thumbnailUrl = props.type === 'SCRAP' ? props.thumbnailUrl : undefined; + const imageSource = useMemo( + () => (props.thumbnailUrl ? { uri: props.thumbnailUrl } : undefined), + [props.thumbnailUrl] + ); const cardContent = ( - - - {thumbnailUrl ? ( - + + + } /> - ) : ( - - )} - - - {state.isSelecting && ( - - - - )} + {state.isSelecting && ( + + + + )} + - - - - - {props.name} - - {!state.isSelecting && ( - } - children={(close) => ( - setIsMoveModalVisible(true)} + + + + + {props.name} + + {!state.isSelecting && ( + + } + children={(close) => ( + setIsMoveModalVisible(true)} + /> + )} /> - )} - /> + + )} + + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( + {props.scrapCount} )} - {props.type === 'FOLDER' && props.scrapCount !== undefined && ( - {props.scrapCount} - )} + + {props.updatedAt + ? new Date(props.updatedAt).toLocaleString('ko-kr') + : new Date(props.createdAt).toLocaleString('ko-kr')} + - - {props.type === 'SCRAP' && props.updatedAt - ? new Date(props.updatedAt).toLocaleString('ko-kr') - : new Date(props.createdAt).toLocaleString('ko-kr')} - + setIsMoveModalVisible(false)} selectedItems={[{ id: props.id, type: props.type }]} - onSuccess={() => { - // 이동 성공 후 필요한 경우 데이터 갱신 - }} + onSuccess={() => {}} /> ); @@ -100,6 +110,7 @@ export const ScrapCard = (props: ScrapListItemProps) => { navigation.push('ScrapContent', { id: props.id }); } else if (props.type === 'SCRAP') { openNote({ id: props.id, title: props.name }); + addScrap(props.id); navigation.push('ScrapContentDetail', { id: props.id }); } }}> 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 2be6e0c2..3e3ae7d4 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 @@ -8,46 +8,56 @@ 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 { useReducer, useState } from 'react'; import { CreateFolderModal } from '../../Modal/CreateFolderModal'; import { LoadQnaImageModal } from '../../Modal/LoadQnaImageModal'; +import { State } from '../../../utils/reducer'; -export const ScrapAddItem = () => { +export const ScrapAddItem = ({ reducerState }: { reducerState: State }) => { const [isFolderModalVisible, setIsFolderModalVisible] = useState(false); const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); + const isSelecting = reducerState?.isSelecting ?? false; + + const addItemContent = ( + + + + + + + 추가하기 + + + + ); return ( <> - void) => ( - { - close(); - setTimeout(() => { - setIsFolderModalVisible(true); - }, 200); - }} - onOpenQnaImgModal={() => { - close(); - setTimeout(() => { - setisQnaImageModalVisible(true); - }, 200); - }} - /> - )} - from={ - - - - - - 추가하기 - - - } - /> + {isSelecting ? ( + {addItemContent} + ) : ( + void) => ( + { + close(); + setTimeout(() => { + setIsFolderModalVisible(true); + }, 200); + }} + onOpenQnaImgModal={() => { + close(); + setTimeout(() => { + setisQnaImageModalVisible(true); + }, 200); + }} + /> + )} + from={addItemContent} + /> + )} setIsFolderModalVisible(false)} 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 3d446162..06bc0119 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 @@ -6,6 +6,8 @@ import type { ScrapItem } from '@/features/student/scrap/utils/types'; import { it } from 'node:test'; import { useGetFolderDetail } from '@/apis'; import { useNoteStore } from '@/stores/scrapNoteStore'; +import { ImageWithSkeleton } from '@/components/common/ImageWithSkeleton'; +import { useMemo } from 'react'; export interface SearchResultCardProps { item: ScrapItem; @@ -18,35 +20,37 @@ export const SearchResultCard = ({ item }: SearchResultCardProps) => { const openNote = useNoteStore((state) => state.openNote); - const thumbnailUrl = item.type === 'SCRAP' ? item.thumbnailUrl : undefined; + // thumbnailUrl이 변경될 때만 source 객체 재생성 + const imageSource = useMemo( + () => (item.thumbnailUrl ? { uri: item.thumbnailUrl } : undefined), + [item.thumbnailUrl] + ); const cardContent = ( - - - {thumbnailUrl ? ( - - ) : ( - - )} - + + + } + /> + + {folderName && {folderName}} + + + {item.name} + + {item.type === 'FOLDER' && 폴더} + - - {folderName && {folderName}} - - - {item.name} + + {new Date(item.createdAt).toLocaleString('ko-kr')} - - {item.type === 'FOLDER' && 폴더} - - - {new Date(item.createdAt).toLocaleString('ko-kr')} - ); From fd6ce3f3dfd49408a8c74208db6ddd46b2e230a8 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:39:23 +0900 Subject: [PATCH 103/140] fix: update header --- .../features/student/scrap/components/Header/ScrapHeader.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 0e43147c..808581c9 100644 --- a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -80,7 +80,7 @@ const ScrapHeader = ({ {!isAllSelected ? '전체 선택' : '전체 해제'} - 스크랩 + {title} 완료 From d3e0e702a80273475c49b2054e665c3183a8ff52 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:40:11 +0900 Subject: [PATCH 104/140] fix: update image upload functionality --- .../components/Tooltip/AddItemTooltip.tsx | 133 ++++++------------ .../scrap/components/Tooltip/ItemTooltip.tsx | 95 ++++++++----- 2 files changed, 106 insertions(+), 122 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx index 1124ad51..f295d305 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx @@ -1,8 +1,15 @@ import { Camera, Image, Images, FolderPlus } from 'lucide-react-native'; import { View, Text, Pressable, Alert } from 'react-native'; -import { openCamera, openImageLibrary } from '../../utils/imagePicker'; +import { + openCamera, + openCameraWithErrorHandling, + openImageLibrary, + openImageLibraryWithErrorHandling, +} from '../../utils/imagePicker'; import { useGetPreSignedUrl } from '@/apis/controller/common'; import { useCreateScrapFromImage } from '@/apis'; +import { uploadFileToS3 } from '../../utils/s3Upload'; +import { uploadImageToS3 } from '../../utils/imageUpload'; export interface AddItemTooltipProps { onClose?: () => void; @@ -18,119 +25,65 @@ export const AddItemTooltip = ({ const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); const { mutate: createScrapFromImage } = useCreateScrapFromImage(); - // S3에 파일 업로드 - const uploadFileToS3 = async ( - uploadUrl: string, - fileUri: string, - contentDisposition: string - ): Promise => { - try { - // React Native에서 파일 읽기 - const response = await fetch(fileUri); - const blob = await response.blob(); - - // S3에 PUT 요청 - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'content-disposition': contentDisposition, - }, - body: blob, - }); - - return uploadResponse.ok; - } catch (error) { - console.error('S3 업로드 실패:', error); - return false; - } - }; - // 이미지 선택 및 업로드 처리 const handleImageSelect = async (image: any) => { if (!image || !image.uri) { return; } - try { - // 파일명 추출 (없으면 기본값 사용) - const fileName = image.fileName || `image_${Date.now()}.jpg`; - - // 1. Pre-signed URL 요청 - getPreSignedUrl( - { fileName }, - { - onSuccess: async (data) => { - const { uploadUrl, contentDisposition, file } = data; - - if (!uploadUrl) { - Alert.alert('오류', '업로드 URL을 받아오지 못했습니다.'); - return; - } - - // 2. S3에 파일 업로드 - const uploadSuccess = await uploadFileToS3(uploadUrl, image.uri, contentDisposition); - - if (!uploadSuccess) { - Alert.alert('오류', '파일 업로드에 실패했습니다.'); - return; - } - - // 3. 이미지 기반 스크랩 생성 - createScrapFromImage( - { - imageId: file.id, - }, - { - onSuccess: () => { - Alert.alert('성공', '스크랩이 생성되었습니다.'); - onClose?.(); - }, - onError: (error) => { - console.error('스크랩 생성 실패:', error); - Alert.alert('오류', '스크랩 생성에 실패했습니다.'); - }, - } - ); - }, - onError: (error) => { - console.error('Pre-signed URL 요청 실패:', error); - Alert.alert('오류', '파일 업로드 준비에 실패했습니다.'); + await uploadImageToS3( + image, + getPreSignedUrl, + async (result) => { + // 이미지 기반 스크랩 생성 + createScrapFromImage( + { + imageId: result.fileId, }, - } - ); - } catch (error) { - console.error('이미지 처리 실패:', error); - Alert.alert('오류', '이미지 처리 중 오류가 발생했습니다.'); - } + { + onSuccess: () => { + Alert.alert('성공', '스크랩이 생성되었습니다.'); + onClose?.(); + }, + onError: (error) => { + console.error('스크랩 생성 실패:', error); + Alert.alert('오류', '스크랩 생성에 실패했습니다.'); + }, + } + ); + }, + (error) => { + Alert.alert('오류', error); + } + ); }; const onPressCamera = async () => { - try { - const image = await openCamera(); - if (image) { - await handleImageSelect(image); - } - } catch (error: any) { + const image = await openCameraWithErrorHandling((error) => { if (error.message?.includes('permission')) { Alert.alert('권한 필요', '카메라 권한이 필요합니다.'); } else { console.error('카메라 오류:', error); } + }); + + if (image) { + await handleImageSelect(image); } }; + // onPressGallery 함수 간소화 const onPressGallery = async () => { - try { - const image = await openImageLibrary(); - if (image) { - await handleImageSelect(image); - } - } catch (error: any) { + const image = await openImageLibraryWithErrorHandling((error) => { if (error.message?.includes('permission')) { Alert.alert('권한 필요', '갤러리 권한이 필요합니다.'); } else { console.error('갤러리 오류:', error); } + }); + + if (image) { + await handleImageSelect(image); } }; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx index fc35ee3d..a6e11ea8 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx @@ -1,7 +1,15 @@ import { colors } from '@/theme/tokens'; -import { FileSymlink, FolderOpen, ImagePlay, Trash2 } from 'lucide-react-native'; +import { + ArrowRightLeft, + BookImage, + BookOpenText, + FileSymlink, + FolderOpen, + ImagePlay, + Trash2, +} from 'lucide-react-native'; import { useState } from 'react'; -import { TextInput, View, Text, Pressable } from 'react-native'; +import { TextInput, View, Text, Pressable, Alert } from 'react-native'; import { showToast } from '../Modal/Toast'; import { ScrapListItemProps } from '../Card/types'; import { useNavigation } from '@react-navigation/native'; @@ -16,6 +24,9 @@ import { } from '@/apis'; import { useNoteStore } from '@/stores/scrapNoteStore'; import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; +import { openImageLibrary, openImageLibraryWithErrorHandling } from '../../utils/imagePicker'; +import { uploadFileToS3 } from '../../utils/s3Upload'; +import { uploadImageToS3 } from '../../utils/imageUpload'; export interface ItemTooltipProps { props: ScrapListItemProps; @@ -39,30 +50,48 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); - // S3에 파일 업로드 - const uploadFileToS3 = async ( - uploadUrl: string, - fileUri: string, - contentDisposition: string - ): Promise => { - try { - // React Native에서 파일 읽기 - const response = await fetch(fileUri); - const blob = await response.blob(); - - // S3에 PUT 요청 - const uploadResponse = await fetch(uploadUrl, { - method: 'PUT', - headers: { - 'content-disposition': contentDisposition, - }, - body: blob, - }); - - return uploadResponse.ok; - } catch (error) { - console.error('S3 업로드 실패:', error); - return false; + const handleUpdateFolderCover = async (image: any) => { + if (!image || !image.uri) { + return; + } + + if (props.type !== 'FOLDER') { + return; + } + + await uploadImageToS3( + image, + getPreSignedUrl, + async (result) => { + // 폴더 표지 업데이트 + await updateFolder({ + id: props.id, + request: { + name: props.name, // 기존 이름 유지 + thumbnailImageId: result.fileId, + }, + }); + showToast('success', '표지가 변경되었습니다.'); + handleClose(); + }, + (error) => { + showToast('error', error); + } + ); + }; + + const onPressChangeCover = async () => { + const image = await openImageLibraryWithErrorHandling((error) => { + if (error.message?.includes('permission')) { + showToast('error', '갤러리 권한이 필요합니다.'); + } else { + console.error('갤러리 오류:', error); + showToast('error', '갤러리를 사용할 수 없습니다.'); + } + }); + + if (image) { + await handleUpdateFolderCover(image); } }; @@ -125,7 +154,7 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = } }, 100); }}> - + {props.type === 'FOLDER' ? ( 폴더 열기 ) : ( @@ -133,8 +162,10 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = )} {props.type === 'FOLDER' && ( - - + + 표지 변경하기 )} @@ -147,13 +178,15 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = onMovePress?.(); }, 100); }}> - + 폴더 이동하기 )} { + handleClose(); + try { await deleteScrap({ items: [ @@ -163,8 +196,6 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = }, ], }); - - handleClose(); showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); } catch (error: any) { showToast('error', '삭제 중 오류가 발생했습니다.'); From b5ac3ae75270790926903477da2e45a470383b55 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:40:31 +0900 Subject: [PATCH 105/140] fix: update drag tab UX --- .../screens/ScrapDetailContentScreen.tsx | 96 ++++++++++++++++++- 1 file changed, 92 insertions(+), 4 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx index 72b64a52..18cba8b8 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx @@ -20,6 +20,7 @@ import Animated, { useSharedValue, useAnimatedStyle, withSpring, + SharedValue, runOnJS, } from 'react-native-reanimated'; import { Container, SegmentedControl, TextButton } from '@/components/common'; @@ -54,6 +55,9 @@ const ScrapContentDetailScreen = () => { const { openNotes, activeNoteId, setActiveNote, closeNote, reorderNotes } = useNoteStore(); const [tabLayouts, setTabLayouts] = useState>({}); + const scrollViewRef = useRef(null); + const scrollX = useSharedValue(0); + const screenWidth = Dimensions.get('window').width; const [expandedSections, setExpandedSections] = useState>( {} ); @@ -284,11 +288,21 @@ const ScrapContentDetailScreen = () => { )} {scrap.name || '스크랩 상세'} + {handwritingData?.updatedAt} - {openNotes.length > 0 && ( + {openNotes.length > 1 && ( - + { + const offsetX = event.nativeEvent.contentOffset.x; + scrollX.value = offsetX; // 실시간 업데이트 + }} + scrollEventThrottle={1}> {openNotes.map((note, index) => ( { reorderNotes(fromIndex, toIndex); }} tabLayouts={tabLayouts} + scrollViewRef={scrollViewRef as React.RefObject} + scrollX={scrollX} + screenWidth={screenWidth} /> ))} @@ -673,6 +690,7 @@ const ScrapContentDetailScreen = () => { ); }; +// DraggableTabProps 인터페이스 수정 interface DraggableTabProps { note: Note; index: number; @@ -682,8 +700,12 @@ interface DraggableTabProps { onLayout: (event: LayoutChangeEvent) => void; onDragEnd: (fromIndex: number, toIndex: number) => void; tabLayouts: Record; + scrollViewRef: React.RefObject; + scrollX: SharedValue; + screenWidth: number; } +// DraggableTab 컴포넌트 수정 const DraggableTab = ({ note, index, @@ -693,21 +715,85 @@ const DraggableTab = ({ onLayout, onDragEnd, tabLayouts, + scrollViewRef, + scrollX, + screenWidth, }: DraggableTabProps) => { const translateX = useSharedValue(0); const startX = useSharedValue(0); const [isDragging, setIsDragging] = useState(false); const { openNotes } = useNoteStore(); + const autoScroll = useCallback( + (currentX: number) => { + if (!scrollViewRef.current) return; + + const currentLayout = tabLayouts[note.id]; + if (!currentLayout) return; + + // 탭의 실제 화면상 위치 (스크롤 오프셋 고려) + const absoluteX = currentLayout.x - scrollX.value + currentX; + const tabRight = absoluteX + currentLayout.width; + const tabLeft = absoluteX; + + const visibleLeft = 0; + const visibleRight = screenWidth; + const scrollThreshold = 100; + const scrollSpeed = 0.4; + + let newScrollX = scrollX.value; + let shouldScroll = false; + + // 오른쪽으로 스크롤 + if (tabRight > visibleRight - scrollThreshold) { + const distance = tabRight - (visibleRight - scrollThreshold); + newScrollX = scrollX.value + distance * scrollSpeed; + shouldScroll = true; + } + // 왼쪽으로 스크롤 + else if (tabLeft < visibleLeft + scrollThreshold) { + const distance = visibleLeft + scrollThreshold - tabLeft; + newScrollX = scrollX.value - distance * scrollSpeed; + shouldScroll = true; + } + + if (shouldScroll && Math.abs(newScrollX - scrollX.value) > 0.5) { + scrollViewRef.current.scrollTo({ + x: Math.max(0, newScrollX), + animated: false, + }); + } + }, + [tabLayouts, note.id, scrollViewRef, scrollX, screenWidth] + ); + const panGesture = Gesture.Pan() + .enabled(isActive) // 활성 탭일 때만 드래그 가능 .onStart(() => { + // 활성 탭이 아니면 드래그 시작하지 않음 + if (!isActive) return; + startX.value = translateX.value; runOnJS(setIsDragging)(true); }) .onUpdate((e) => { + // 활성 탭이 아니면 업데이트하지 않음 + if (!isActive) return; + translateX.value = startX.value + e.translationX; + + // 드래그 중 자동 스크롤 + const currentX = startX.value + e.translationX; + runOnJS(autoScroll)(currentX); }) .onEnd((e) => { + // 활성 탭이 아니면 종료하지 않음 + if (!isActive) { + translateX.value = withSpring(0); + runOnJS(setIsDragging)(false); + return; + } + const finalX = startX.value + e.translationX; const currentLayout = tabLayouts[note.id]; @@ -756,18 +842,20 @@ const DraggableTab = ({ + style={{ flexShrink: 1, textAlign: 'center' }}> {note.title} + { e.stopPropagation(); From 481ef949ae3f96fbe0721c185753dec934fd183b Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:41:33 +0900 Subject: [PATCH 106/140] fix: update MoveScrapModal props --- .../scrap/screens/ScrapContentScreen.tsx | 125 +++++++++--------- 1 file changed, 64 insertions(+), 61 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index 395187a6..df1e7f88 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -45,71 +45,74 @@ const ScrapContentScreen = () => { reducerState.selectedItems.length === contents.length && contents.length > 0; return ( - - navigation.push('SearchScrap')} - navigateTrashPress={() => navigation.push('DeletedScrap')} - onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} - onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} - isAllSelected={isAllSelected} - onSelectAll={() => { - const allItems = contents.map((item) => ({ id: item.id, type: item.type })); - dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); - }} - onMove={() => { - const selectedFolders = reducerState.selectedItems.filter( - (selected) => selected.type === 'FOLDER' - ); - if (selectedFolders.length > 0) { - showToast('error', '스크랩만 이동이 가능합니다.'); - return; - } - if (reducerState.selectedItems.length === 0) { - showToast('error', '이동할 스크랩을 선택해주세요.'); - return; - } - setIsMoveModalVisible(true); - }} - onDelete={async () => { - if (reducerState.selectedItems.length === 0) { - showToast('error', '삭제할 항목을 선택해주세요.'); - return; - } + <> + + navigation.push('SearchScrap')} + navigateTrashPress={() => navigation.push('DeletedScrap')} + onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} + onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} + isAllSelected={isAllSelected} + onSelectAll={() => { + const allItems = contents.map((item) => ({ id: item.id, type: item.type })); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); + }} + onMove={() => { + const selectedFolders = reducerState.selectedItems.filter( + (selected) => selected.type === 'FOLDER' + ); + if (selectedFolders.length > 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + setIsMoveModalVisible(true); + }} + onDelete={async () => { + if (reducerState.selectedItems.length === 0) { + showToast('error', '삭제할 항목을 선택해주세요.'); + return; + } - try { - const items = reducerState.selectedItems; + try { + const items = reducerState.selectedItems; - await deleteScrap({ items }); + await deleteScrap({ items }); - dispatch({ type: 'CLEAR_SELECTION' }); - showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); - } catch (error: any) { - showToast('error', '삭제 중 오류가 발생했습니다.'); - } - }} - /> - - - - - - {isLoading ? ( - - ) : ( - - )} - + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }} + /> + + + + + + {isLoading ? ( + + ) : ( + + )} + + setIsMoveModalVisible(false)} selectedItems={reducerState.selectedItems} @@ -118,7 +121,7 @@ const ScrapContentScreen = () => { refetch(); }} /> - + ); }; From 47ed6652a7c4d5bdf04949bea22657a1f088e01d Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:41:59 +0900 Subject: [PATCH 107/140] fix: exclude self info in MoveScrapModal --- .../scrap/components/Modal/MoveScrapModal.tsx | 31 ++++++++++--------- 1 file changed, 17 insertions(+), 14 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx index 39f061e0..97c4c071 100644 --- a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx @@ -11,6 +11,7 @@ import type { SelectedItem } from '../../utils/reducer'; import { CreateFolderModal } from './CreateFolderModal'; interface MoveScrapModalProps { + currentFolderId?: number; visible: boolean; onClose: () => void; selectedItems: SelectedItem[]; @@ -18,6 +19,7 @@ interface MoveScrapModalProps { } export const MoveScrapModal = ({ + currentFolderId, visible, onClose, selectedItems, @@ -27,8 +29,9 @@ export const MoveScrapModal = ({ ...initialSelectionState, isSelecting: true, // 모달 내에서는 항상 선택 모드 }); + const [isCreateFolderModalVisible, setIsCreateFolderModalVisible] = useState(false); - const { data: foldersData } = useGetFolders(); + const { data: foldersData, refetch: refetchFolders } = useGetFolders(); const { mutateAsync: moveScraps } = useMoveScraps(); // 모달 상태에 따른 선택 모드 관리 @@ -45,13 +48,13 @@ export const MoveScrapModal = ({ // 폴더만 필터링 const folders = useMemo(() => { if (!foldersData?.data) return []; - return foldersData.data.map((folder) => ({ - type: 'FOLDER' as const, - id: folder.id, - name: folder.name, - scrapCount: folder.scrapCount, - createdAt: folder.createdAt, - })); + + return foldersData.data + .filter((folder) => folder.id !== currentFolderId) + .map((folder) => ({ + ...folder, + type: 'FOLDER' as const, + })); }, [foldersData]); // 선택된 폴더 ID (폴더는 하나만 선택 가능) @@ -131,7 +134,7 @@ export const MoveScrapModal = ({ - + + setIsCreateFolderModalVisible(false)} + onSuccess={() => {}} + /> - setIsCreateFolderModalVisible(false)} - onSuccess={() => {}} - /> ); }; From a237b3fcd528bdfc6f834df037ef3abb40193655 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:42:13 +0900 Subject: [PATCH 108/140] fix: update image functionality --- .../components/Modal/CreateFolderModal.tsx | 74 +++++++++++++++---- 1 file changed, 58 insertions(+), 16 deletions(-) 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 95b935d7..58665934 100644 --- a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -3,23 +3,29 @@ import { View, Text, Pressable, Image, TextInput } from 'react-native'; import PopUpModal from './PopupModal'; import { useCreateFolder } from '@/apis'; import { showToast } from './Toast'; -import { openImageLibrary } from '../../utils/imagePicker'; +import { openImageLibraryWithErrorHandling } from '../../utils/imagePicker'; import { colors } from '@/theme/tokens'; +import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; +import { uploadImageToS3 } from '../../utils/imageUpload'; +import * as ImagePicker from 'expo-image-picker'; interface CreateFolderModalProps { visible: boolean; onClose: () => void; onSuccess?: () => void; + disableBackdropClose?: boolean; } export const CreateFolderModal = ({ visible, onClose, onSuccess, + disableBackdropClose = false, }: CreateFolderModalProps) => { const [folderName, setFolderName] = useState(''); - const [selectedImage, setSelectedImage] = useState(null); + const [selectedImage, setSelectedImage] = useState(null); const { mutateAsync: createFolder } = useCreateFolder(); + const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); // 모달이 닫힐 때 상태 초기화 useEffect(() => { @@ -30,9 +36,17 @@ export const CreateFolderModal = ({ }, [visible]); const onPressGallery = async () => { - const image = await openImageLibrary(); + const image = await openImageLibraryWithErrorHandling((error) => { + if (error.message?.includes('permission')) { + showToast('error', '갤러리 권한이 필요합니다.'); + } else { + console.error('갤러리 오류:', error); + showToast('error', '갤러리를 사용할 수 없습니다.'); + } + }); + if (image) { - setSelectedImage(image.uri); + setSelectedImage(image); } }; @@ -42,13 +56,44 @@ export const CreateFolderModal = ({ return; } - try { - await createFolder({ name: folderName }); - showToast('success', '폴더가 추가되었습니다.'); - onSuccess?.(); - onClose(); - } catch (error) { - showToast('error', '폴더 추가에 실패했습니다.'); + // 이미지가 있는 경우 먼저 업로드 + if (selectedImage) { + const success = await uploadImageToS3( + selectedImage, + getPreSignedUrl, + async (result) => { + // 폴더 생성 (이미지 ID 포함) + await createFolder({ + name: folderName, + thumbnailImageId: result.fileId, + }); + + showToast('success', '폴더가 추가되었습니다.'); + onSuccess?.(); + setTimeout(() => { + onClose(); + }, 0); + }, + (error) => { + showToast('error', error); + } + ); + + if (!success) { + return; + } + } else { + // 이미지가 없는 경우 이름만으로 폴더 생성 + try { + await createFolder({ name: folderName }); + showToast('success', '폴더가 추가되었습니다.'); + onSuccess?.(); + setTimeout(() => { + onClose(); + }, 0); + } catch (error) { + showToast('error', '폴더 추가에 실패했습니다.'); + } } }; @@ -73,12 +118,10 @@ export const CreateFolderModal = ({ - + {selectedImage ? ( @@ -102,4 +145,3 @@ export const CreateFolderModal = ({ ); }; - From d274db59c66cd6b537ba20d86fea004a4f2112f3 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 00:42:39 +0900 Subject: [PATCH 109/140] feat: connect AsyncStorage --- apps/native/src/stores/searchHistoryStore.ts | 76 +++++++++++++------- 1 file changed, 49 insertions(+), 27 deletions(-) diff --git a/apps/native/src/stores/searchHistoryStore.ts b/apps/native/src/stores/searchHistoryStore.ts index 7af7c49b..1f0d553d 100644 --- a/apps/native/src/stores/searchHistoryStore.ts +++ b/apps/native/src/stores/searchHistoryStore.ts @@ -1,5 +1,7 @@ import { create } from 'zustand'; +import { persist, createJSONStorage } from 'zustand/middleware'; import type { FilterType, ApiSortKey, SortOrder } from '@/features/student/scrap/utils/types'; +import AsyncStorage from '@react-native-async-storage/async-storage'; interface SearchHistoryStore { keywords: string[]; @@ -8,29 +10,35 @@ interface SearchHistoryStore { clear: () => void; } -export const useSearchHistoryStore = create((set) => ({ - keywords: [], +export const useSearchHistoryStore = create()( + persist( + (set) => ({ + keywords: [], - addKeyword: (keyword) => - set((state) => { - const trimmed = keyword.trim(); - if (!trimmed) return state; + addKeyword: (keyword) => + set((state) => { + const trimmed = keyword.trim(); + if (!trimmed) return state; - // 중복 제거 후 맨 앞에 추가 - const filtered = state.keywords.filter((k) => k !== trimmed); + const filtered = state.keywords.filter((k) => k !== trimmed); + return { + keywords: [trimmed, ...filtered].slice(0, 10), + }; + }), - return { - keywords: [trimmed, ...filtered].slice(0, 10), // 최대 10개 - }; - }), - - removeKeyword: (keyword) => - set((state) => ({ - keywords: state.keywords.filter((k) => k !== keyword), - })), + removeKeyword: (keyword) => + set((state) => ({ + keywords: state.keywords.filter((k) => k !== keyword), + })), - clear: () => set({ keywords: [] }), -})); + clear: () => set({ keywords: [] }), + }), + { + name: 'search-history-store', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); interface ScrapUIStore { /** 현재 선택된 폴더 ID */ @@ -63,13 +71,27 @@ const initialScrapUIState = { currentSort: 'CREATED_AT' as ApiSortKey, currentOrder: 'DESC' as SortOrder, }; +export const useScrapUIStore = create()( + persist( + (set) => ({ + ...initialScrapUIState, -export const useScrapUIStore = create((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), - 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), -})); + partialize: (state) => ({ + currentFilter: state.currentFilter, + currentSort: state.currentSort, + currentOrder: state.currentOrder, + }), + } + ) +); From d7250590e5cb8f9b4a1dc4fc38b06dd7ff6be28f Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:49:23 +0900 Subject: [PATCH 110/140] feat: add new API endpoints for scrap and Q&A functionalities --- apps/native/src/types/api/schema.d.ts | 1016 ++++++++++++++++++++++++- 1 file changed, 983 insertions(+), 33 deletions(-) diff --git a/apps/native/src/types/api/schema.d.ts b/apps/native/src/types/api/schema.d.ts index 90ff5ab7..9ad270eb 100644 --- a/apps/native/src/types/api/schema.d.ts +++ b/apps/native/src/types/api/schema.d.ts @@ -40,6 +40,40 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/scrap/{scrapId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 스크랩 전체 수정 */ + put: operations['updateScrap']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/{scrapId}/thumbnail': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 스크랩 썸네일 수정 (thumbnail만) */ + put: operations['updateScrapThumbnail']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/scrap/{scrapId}/textBox': { parameters: { query?: never; @@ -145,6 +179,40 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/scrap/folder/{id}/thumbnail': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 폴더 썸네일 수정 */ + put: operations['updateFolderThumbnail']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/scrap/folder/{id}/name': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 폴더 이름 수정 */ + put: operations['updateFolderName']; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/qna/{qnaId}': { parameters: { query?: never; @@ -251,6 +319,24 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/qna/chat/{chatId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** 채팅메시지 수정 */ + put: operations['updateChat_2']; + post?: never; + /** 채팅메시지 삭제 */ + delete: operations['deleteChat_2']; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/problem/{id}': { parameters: { query?: never; @@ -552,6 +638,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/scrap/toggle/from-reading-tip': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 리딩팁 콘텐츠 스크랩 토글 */ + post: operations['toggleScrapFromReadingTip']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/scrap/toggle/from-problem': { parameters: { query?: never; @@ -586,6 +689,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/student/scrap/toggle/from-one-step-more': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 원스텝모어 콘텐츠 스크랩 토글 */ + post: operations['toggleScrapFromOneStepMore']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/student/scrap/from-problem': { parameters: { query?: never; @@ -983,6 +1103,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/admin/qna/chat': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 채팅메시지 생성 */ + post: operations['addChat_2']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/admin/publish': { parameters: { query?: never; @@ -1416,6 +1553,23 @@ export interface paths { patch?: never; trace?: never; }; + '/api/teacher/qna/search': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Q&A 검색 (제목/채팅 분리) */ + get: operations['search_7']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/teacher/notice/available': { parameters: { query?: never; @@ -1458,7 +1612,7 @@ export interface paths { cookie?: never; }; /** 주간 발행(숙제) 조회 */ - get: operations['search_7']; + get: operations['search_8']; put?: never; post?: never; delete?: never; @@ -1612,7 +1766,75 @@ export interface paths { cookie?: never; }; /** 검색 (유사도 상위순 7개 반환) */ - get: operations['search_8']; + get: operations['search_9']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/qna/{qnaId}/images': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Q&A 전체 이미지 조회 (질문 + 채팅) */ + get: operations['getImages']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/qna/search': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Q&A 검색 (제목/채팅 분리) */ + get: operations['search_10']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/qna/images': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 내가 참여한 모든 Q&A 이미지 조회 (최신순) */ + get: operations['getAllImages']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/qna/admin-chat': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 출제진 채팅방 조회/생성 */ + get: operations['getOrCreateAdminChatroom']; put?: never; post?: never; delete?: never; @@ -1760,6 +1982,33 @@ export interface paths { patch?: never; trace?: never; }; + '/api/qna/{qnaId}/subscribe': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Q&A 채팅 SSE 구독 + * @description 클라이언트에서 SSE로 채팅/읽음 이벤트를 구독합니다. + * + * - 요청 예: GET /api/qna/{qnaId}/subscribe?token={accessToken} + * - 응답 Content-Type: text/event-stream + * - 이벤트 이름: + * - chat: 채팅 생성/수정/삭제 (QnAChatEvent 스키마 참조) + * - read_status: 읽음 상태 변경 (QnAReadStatusEvent 스키마 참조) + * - heartbeat: 초기 연결 확인 + */ + get: operations['subscribe']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/exception/throw': { parameters: { query?: never; @@ -1801,7 +2050,41 @@ export interface paths { cookie?: never; }; /** 검색 */ - get: operations['search_9']; + get: operations['search_11']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/qna': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 출제진 채팅방 목록 조회 */ + get: operations['gets_4']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/qna/{qnaId}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 출제진 채팅방 상세 조회 */ + get: operations['getById_5']; put?: never; post?: never; delete?: never; @@ -1818,7 +2101,7 @@ export interface paths { cookie?: never; }; /** 상세 조회 */ - get: operations['getById_5']; + get: operations['getById_6']; put?: never; post?: never; /** 삭제 */ @@ -1994,6 +2277,13 @@ export interface components { isMine: boolean; content: string; images: components['schemas']['UploadFileResp'][]; + /** + * Format: int64 + * @description 답장 대상 채팅 ID + */ + replyToId?: number; + /** @description 답장 대상 채팅 내용 미리보기 */ + replyToContent?: string; }; QnAMetaResp: { /** Format: int64 */ @@ -2010,7 +2300,8 @@ export interface components { | 'PROBLEM_ONE_STEP_MORE' | 'CHILD_PROBLEM_CONTENT' | 'CHILD_PROBLEM_POINTING_QUESTION' - | 'CHILD_PROBLEM_POINTING_COMMENT'; + | 'CHILD_PROBLEM_POINTING_COMMENT' + | 'ADMIN_CHAT'; /** Format: date */ publishDate: string; /** Format: int64 */ @@ -2018,6 +2309,13 @@ export interface components { /** Format: int32 */ unreadCount?: number; studentName?: string; + /** + * Format: date-time + * @description 최신 메시지 시간 + */ + latestMessageTime?: string; + /** @description 최신 메시지 내용 미리보기 */ + latestMessageContent?: string; }; QnAResp: { /** Format: int64 */ @@ -2034,7 +2332,8 @@ export interface components { | 'PROBLEM_ONE_STEP_MORE' | 'CHILD_PROBLEM_CONTENT' | 'CHILD_PROBLEM_POINTING_QUESTION' - | 'CHILD_PROBLEM_POINTING_COMMENT'; + | 'CHILD_PROBLEM_POINTING_COMMENT' + | 'ADMIN_CHAT'; /** Format: date */ publishDate: string; /** Format: int64 */ @@ -2042,6 +2341,13 @@ export interface components { /** Format: int32 */ unreadCount?: number; studentName?: string; + /** + * Format: date-time + * @description 최신 메시지 시간 + */ + latestMessageTime?: string; + /** @description 최신 메시지 내용 미리보기 */ + latestMessageContent?: string; contentTitle: string; content: string; question: string; @@ -2055,6 +2361,8 @@ export interface components { id: number; fileName: string; url: string; + /** @enum {string} */ + fileType: 'IMAGE' | 'DOCUMENT' | 'OTHER'; }; NoticeUpdateRequest: { title: string; @@ -2118,16 +2426,28 @@ export interface components { teacherId?: number; teacherName?: string; }; - ScrapTextBoxUpdateRequest: { + ScrapUpdateRequest: { /** - * @description 텍스트 메모 - * @example 메모 수정 + * Format: int64 + * @description 폴더 ID (null이면 루트로 이동) + * @example 1 */ - textBox?: string; - }; - ConceptCategoryResp: { - /** Format: int64 */ - id: number; + folderId?: number; + /** + * Format: int64 + * @description 썸네일 이미지 ID + * @example 456 + */ + thumbnailImageId?: number; + /** + * @description 텍스트 메모 + * @example 수정된 메모 내용 + */ + textBox?: string; + }; + ConceptCategoryResp: { + /** Format: int64 */ + id: number; name: string; }; ConceptResp: { @@ -2234,6 +2554,10 @@ export interface components { textBox?: string; /** @description 포인팅 목록 */ pointings: components['schemas']['PointingResp'][]; + /** @description 리딩팁 스크랩 여부 */ + isReadingTipScrapped: boolean; + /** @description 원스텝모어 스크랩 여부 */ + isOneStepMoreScrapped: boolean; /** @description 필기 데이터 존재 여부 */ hasHandwriting: boolean; /** @@ -2257,15 +2581,38 @@ export interface components { /** @description 폴더 이름 */ name: string; /** - * Format: int32 + * Format: int64 * @description 폴더 내 스크랩 개수 */ scrapCount: number; + /** @description 썸네일 이미지 URL */ + thumbnailUrl?: string; + /** @description 최근 스크랩 2개의 썸네일 URL (최신순) */ + top2ScrapThumbnail?: string[]; /** * Format: date-time * @description 생성일시 */ createdAt: string; + /** + * Format: date-time + * @description 수정일시 + */ + updatedAt: string; + }; + ScrapThumbnailUpdateRequest: { + /** + * Format: int64 + * @description 썸네일 이미지 ID (null이면 썸네일 삭제) + */ + thumbnailImageId?: number; + }; + ScrapTextBoxUpdateRequest: { + /** + * @description 텍스트 메모 + * @example 메모 수정 + */ + textBox?: string; }; ScrapNameUpdateRequest: { /** @@ -2333,6 +2680,25 @@ export interface components { * @example 기하 오답노트 */ name: string; + /** + * Format: int64 + * @description 썸네일 이미지 ID + */ + thumbnailImageId?: number; + }; + ScrapFolderThumbnailUpdateRequest: { + /** + * Format: int64 + * @description 썸네일 이미지 ID (null이면 썸네일 삭제) + */ + thumbnailImageId?: number; + }; + ScrapFolderNameUpdateRequest: { + /** + * @description 폴더 이름 + * @example 기하 오답노트 + */ + name: string; }; QnAUpdateRequest: { question: string; @@ -2513,6 +2879,11 @@ export interface components { qnaId: number; content: string; images?: number[]; + /** + * Format: int64 + * @description 답장 대상 채팅 ID + */ + replyToId?: number; }; NoticeCreateRequest: { title: string; @@ -2602,7 +2973,7 @@ export interface components { */ pointingIds?: number[]; }; - ScrapFromProblemCreateRequest: { + ScrapFromReadingTipCreateRequest: { /** * Format: int64 * @description 문제 ID @@ -2622,6 +2993,20 @@ export interface components { isScraped: boolean; scrap?: components['schemas']['ScrapDetailResp']; }; + ScrapFromProblemCreateRequest: { + /** + * Format: int64 + * @description 문제 ID + * @example 123 + */ + problemId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + }; ScrapFromPointingCreateRequest: { /** * Format: int64 @@ -2636,6 +3021,20 @@ export interface components { */ folderId?: number; }; + ScrapFromOneStepMoreCreateRequest: { + /** + * Format: int64 + * @description 문제 ID + * @example 123 + */ + problemId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + }; ScrapFromImageCreateRequest: { /** * Format: int64 @@ -2661,6 +3060,11 @@ export interface components { * @example 수학 오답노트 */ name: string; + /** + * Format: int64 + * @description 썸네일 이미지 ID + */ + thumbnailImageId?: number; }; /** @description problemId, childProblemId, pointingId 중 하나만 입력 가능 */ QnACreateRequest: { @@ -2677,7 +3081,8 @@ export interface components { | 'PROBLEM_ONE_STEP_MORE' | 'CHILD_PROBLEM_CONTENT' | 'CHILD_PROBLEM_POINTING_QUESTION' - | 'CHILD_PROBLEM_POINTING_COMMENT'; + | 'CHILD_PROBLEM_POINTING_COMMENT' + | 'ADMIN_CHAT'; /** Format: int64 */ problemId?: number; /** Format: int64 */ @@ -2699,7 +3104,8 @@ export interface components { | 'PROBLEM_ONE_STEP_MORE' | 'CHILD_PROBLEM_CONTENT' | 'CHILD_PROBLEM_POINTING_QUESTION' - | 'CHILD_PROBLEM_POINTING_COMMENT'; + | 'CHILD_PROBLEM_POINTING_COMMENT' + | 'ADMIN_CHAT'; /** * Format: int64 * @description 메인문제ID(메인 문제에 대한 질문일 경우) @@ -2817,6 +3223,11 @@ export interface components { }; PreSignedReq: { fileName: string; + /** + * @description 파일 유형 (지정하지 않으면 서버가 확장자로 판단) + * @enum {string} + */ + fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER'; }; PreSignedResp: { file: components['schemas']['UploadFileResp']; @@ -2880,6 +3291,7 @@ export interface components { commentContent: string; concepts: components['schemas']['ConceptResp'][]; isUnderstood?: boolean; + isScrapped?: boolean; }; ProblemRef: { /** @@ -2898,6 +3310,12 @@ export interface components { */ parentId?: number; }; + ProblemScrapInfo: { + isProblemScrapped?: boolean; + isReadingTipScrapped?: boolean; + isOneStepMoreScrapped?: boolean; + scrappedPointingIds?: number[]; + }; ProblemWithStudyInfoResp: { /** Format: int32 */ no?: number; @@ -2939,6 +3357,7 @@ export interface components { submitAnswer: number; isCorrect: boolean; isDone: boolean; + scrapInfo?: components['schemas']['ProblemScrapInfo']; childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; ref?: components['schemas']['ProblemRef']; }; @@ -3112,6 +3531,42 @@ export interface components { weekName?: string; data?: components['schemas']['QnAMetaResp'][]; }; + ChatSearchResultResp: { + /** Format: int64 */ + qnaId?: number; + qnaTitle?: string; + /** @enum {string} */ + qnaType?: + | 'PROBLEM_CONTENT' + | 'PROBLEM_POINTING_QUESTION' + | 'PROBLEM_POINTING_COMMENT' + | 'PROBLEM_MAIN_ANALYSIS' + | 'PROBLEM_MAIN_HAND_ANALYSIS' + | 'PROBLEM_READING_TIP_CONTENT' + | 'PROBLEM_ONE_STEP_MORE' + | 'CHILD_PROBLEM_CONTENT' + | 'CHILD_PROBLEM_POINTING_QUESTION' + | 'CHILD_PROBLEM_POINTING_COMMENT' + | 'ADMIN_CHAT'; + /** Format: date */ + publishDate?: string; + /** Format: int64 */ + matchedChatId?: number; + matchedChatPreview?: string; + }; + PageRespNotListListChatSearchResultResp: { + /** Format: int32 */ + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['ChatSearchResultResp'][]; + }; + QnASearchResp: { + qnaResults?: components['schemas']['PageRespNotListQnAGroupByWeekResp']; + chatResults?: components['schemas']['PageRespNotListListChatSearchResultResp']; + }; ListRespNoticeResp: { /** Format: int32 */ total: number; @@ -3141,6 +3596,10 @@ export interface components { * @description 포함된 스크랩 수 (폴더인 경우) */ itemCount?: number; + /** @description 썸네일 URL (스크랩: 스크랩 썸네일, 폴더: 폴더 썸네일) */ + thumbnailUrl?: string; + /** @description 최근 스크랩 2개의 썸네일 URL (폴더인 경우) */ + top2ScrapThumbnail?: string[]; /** * Format: date-time * @description 삭제일시 @@ -3178,6 +3637,11 @@ export interface components { * @description 생성일시 */ createdAt: string; + /** + * Format: date-time + * @description 수정일시 + */ + updatedAt: string; }; /** @description 스크랩 검색 응답 */ ScrapSearchResp: { @@ -3201,6 +3665,11 @@ export interface components { total: number; data: components['schemas']['SchoolResp'][]; }; + ListRespUploadFileResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['UploadFileResp'][]; + }; ListRespNotificationResp: { /** Format: int32 */ total: number; @@ -3234,6 +3703,81 @@ export interface components { BooleanResp: { value: boolean; }; + /** @description Q&A 채팅 이벤트 (SSE event name: chat) */ + QnAChatEvent: { + /** + * @description Q&A 채팅 이벤트 타입 + * @example CREATED + * @enum {string} + */ + type?: 'CREATED' | 'UPDATED' | 'DELETED'; + /** + * Format: int64 + * @description Q&A ID + * @example 1 + */ + qnaId?: number; + /** + * Format: int64 + * @description 채팅 메시지 ID + * @example 123 + */ + chatId?: number; + /** + * Format: int64 + * @description 발신자 ID + * @example 1 + */ + senderId?: number; + /** + * @description 발신자 타입 + * @example STUDENT + * @enum {string} + */ + senderType?: 'ADMIN' | 'STUDENT' | 'TEACHER'; + /** + * @description 메시지 내용 + * @example 안녕하세요 + */ + content?: string; + /** + * Format: int64 + * @description 답장 대상 메시지 ID (답장인 경우) + * @example 122 + */ + replyToId?: number | null; + /** + * Format: date-time + * @description 이벤트 발생 시간 + */ + timestamp?: string; + }; + /** @description Q&A 읽음 상태 이벤트 (SSE event name: read_status) */ + QnAReadStatusEvent: { + /** + * Format: int64 + * @description Q&A ID + * @example 1 + */ + qnaId?: number; + /** + * Format: int64 + * @description 읽은 사용자 ID + * @example 1 + */ + userId?: number; + /** + * @description 사용자 타입 + * @example TEACHER + * @enum {string} + */ + userType?: 'ADMIN' | 'STUDENT' | 'TEACHER'; + /** + * Format: date-time + * @description 읽은 시간 + */ + readAt?: string; + }; PageRespTeacherResp: { /** Format: int32 */ page: number; @@ -3427,6 +3971,58 @@ export interface operations { }; }; }; + updateScrap: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; + }; + }; + }; + updateScrapThumbnail: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapThumbnailUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; + }; + }; + }; updateScrapText: { parameters: { query?: never; @@ -3641,6 +4237,58 @@ export interface operations { }; }; }; + updateFolderThumbnail: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFolderThumbnailUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapFolderResp']; + }; + }; + }; + }; + updateFolderName: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFolderNameUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapFolderResp']; + }; + }; + }; + }; getById: { parameters: { query?: never; @@ -3823,18 +4471,70 @@ export interface operations { }; }; }; - update_3: { + update_3: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; + }; + }; + assignStudentsToTeacher: { + parameters: { + query?: never; + header?: never; + path: { + teacherId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherStudentAssignReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; + }; + }; + updateChat_2: { parameters: { query?: never; header?: never; path: { - id: number; + chatId: number; }; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['TeacherUpdateRequest']; + 'application/json': components['schemas']['ChatUpdateRequest']; }; }; responses: { @@ -3844,25 +4544,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - assignStudentsToTeacher: { + deleteChat_2: { parameters: { query?: never; header?: never; path: { - teacherId: number; + chatId: number; }; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['TeacherStudentAssignReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3870,7 +4566,7 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; @@ -4539,6 +5235,30 @@ export interface operations { }; }; }; + toggleScrapFromReadingTip: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromReadingTipCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapToggleResp']; + }; + }; + }; + }; toggleScrapFromProblem: { parameters: { query?: never; @@ -4587,6 +5307,30 @@ export interface operations { }; }; }; + toggleScrapFromOneStepMore: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromOneStepMoreCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapToggleResp']; + }; + }; + }; + }; createScrapFromProblem: { parameters: { query?: never; @@ -5260,6 +6004,30 @@ export interface operations { }; }; }; + addChat_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChatCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; search_1: { parameters: { query?: { @@ -6036,6 +6804,28 @@ export interface operations { }; }; }; + search_7: { + parameters: { + query?: { + query?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnASearchResp']; + }; + }; + }; + }; getsAvailable: { parameters: { query: { @@ -6078,7 +6868,7 @@ export interface operations { }; }; }; - search_7: { + search_8: { parameters: { query?: never; header?: never; @@ -6318,7 +7108,7 @@ export interface operations { }; }; }; - search_8: { + search_9: { parameters: { query?: { query?: string; @@ -6340,6 +7130,90 @@ export interface operations { }; }; }; + getImages: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespUploadFileResp']; + }; + }; + }; + }; + search_10: { + parameters: { + query?: { + query?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnASearchResp']; + }; + }; + }; + }; + getAllImages: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespUploadFileResp']; + }; + }; + }; + }; + getOrCreateAdminChatroom: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; getNotifications: { parameters: { query?: { @@ -6507,6 +7381,38 @@ export interface operations { }; }; }; + subscribe: { + parameters: { + query: { + token: string; + }; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description SSE 스트림 연결 성공. 드롭다운에서 이벤트 타입별 스키마를 확인하세요. */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + 'chat (application/json)': components['schemas']['QnAChatEvent']; + 'read_status (application/json)': components['schemas']['QnAReadStatusEvent']; + }; + }; + /** @description 토큰 검증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; throwException: { parameters: { query?: { @@ -6547,7 +7453,7 @@ export interface operations { }; }; }; - search_9: { + search_11: { parameters: { query?: { query?: string; @@ -6571,7 +7477,51 @@ export interface operations { }; }; }; + gets_4: { + parameters: { + query?: { + query?: string; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; + }; + }; + }; + }; getById_5: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + getById_6: { parameters: { query?: never; header?: never; From a1a9ea4414fe1c9aa556f96a9bd83e24e896854e Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:50:26 +0900 Subject: [PATCH 111/140] feat: enhance card types with trash functionality and update API response types --- .../scrap/components/Card/cards/index.ts | 2 -- .../student/scrap/components/Card/types.ts | 33 +++++++++++++++++-- .../src/features/student/scrap/utils/types.ts | 5 +-- 3 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Card/cards/index.ts b/apps/native/src/features/student/scrap/components/Card/cards/index.ts index 878a3244..e4aced48 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/index.ts +++ b/apps/native/src/features/student/scrap/components/Card/cards/index.ts @@ -1,6 +1,4 @@ export { ScrapCard } from './ScrapCard'; export { SearchResultCard } from './SearchResultCard'; -export type { SearchResultCardProps } from './SearchResultCard'; export { TrashCard } from './TrashCard'; -export type { TrashCardProps } from './TrashCard'; diff --git a/apps/native/src/features/student/scrap/components/Card/types.ts b/apps/native/src/features/student/scrap/components/Card/types.ts index d5777f89..b907483a 100644 --- a/apps/native/src/features/student/scrap/components/Card/types.ts +++ b/apps/native/src/features/student/scrap/components/Card/types.ts @@ -8,8 +8,11 @@ export interface BaseItemUIProps { id: number; /** 아이템 이름 */ name: string; - /** 생성일시 (ISO 8601 string) */ + /** 생성, 수정일시 (ISO 8601 string) */ createdAt: string; + updatedAt?: string; + /** 썸네일 URL */ + thumbnailUrl?: string; } /** @@ -26,8 +29,6 @@ export interface SelectableUIProps { */ export interface ScrapCardProps extends BaseItemUIProps, SelectableUIProps { type: 'SCRAP'; - /** 썸네일 URL */ - thumbnailUrl?: string; /** 소속 폴더 ID */ folderId?: number; } @@ -40,9 +41,35 @@ export interface FolderCardProps extends BaseItemUIProps, SelectableUIProps { type: 'FOLDER'; /** 폴더 내 스크랩 개수 (API에서 제공하지 않으면 별도 조회 필요) */ scrapCount?: number; + top2ScrapThumbnail?: string[]; } /** * 스크랩 목록 아이템 Props (Union Type) */ export type ScrapListItemProps = ScrapCardProps | FolderCardProps; + +/** + * 휴지통 스크랩 카드 Props + * ScrapCardProps를 기반으로 하되, BaseItemUIProps의 createdAt은 실제로 deletedAt을 의미함 + */ +export interface TrashScrapCardProps extends ScrapCardProps { + /** 영구 삭제까지 남은 일수 */ + daysUntilPermanentDelete: number; +} + +/** + * 휴지통 폴더 카드 Props + * FolderCardProps를 기반으로 하되, BaseItemUIProps의 createdAt은 실제로 deletedAt을 의미함 + */ +export interface TrashFolderCardProps extends FolderCardProps { + /** 폴더 내 아이템 개수 (scrapCount 대신 itemCount 사용) */ + itemCount?: number; + /** 영구 삭제까지 남은 일수 */ + daysUntilPermanentDelete: number; +} + +/** + * 휴지통 목록 아이템 Props (Union Type) + */ +export type TrashListItemProps = TrashScrapCardProps | TrashFolderCardProps; diff --git a/apps/native/src/features/student/scrap/utils/types.ts b/apps/native/src/features/student/scrap/utils/types.ts index 549b9f6b..ababb94d 100644 --- a/apps/native/src/features/student/scrap/utils/types.ts +++ b/apps/native/src/features/student/scrap/utils/types.ts @@ -3,9 +3,10 @@ import { paths, components } from '@/types/api/schema'; /** * API 응답 타입 추출 */ -type ScrapSearchResponse = +export type ScrapSearchResponse = paths['/api/student/scrap/search']['get']['responses']['200']['content']['*/*']; -type TrashResponse = paths['/api/student/scrap/trash']['get']['responses']['200']['content']['*/*']; +export type TrashResponse = + paths['/api/student/scrap/trash']['get']['responses']['200']['content']['*/*']; /** * API 스키마 기반 기본 타입 From 200ecc040612047c4ee66e17dc31ba15142bf570 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:50:38 +0900 Subject: [PATCH 112/140] feat: add @react-native-async-storage/async-storage dependency and update pnpm-lock.yaml --- apps/native/package.json | 1 + pnpm-lock.yaml | 27 +++++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/apps/native/package.json b/apps/native/package.json index 02c153ab..b8a71194 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -14,6 +14,7 @@ "dependencies": { "@expo/vector-icons": "^15.0.3", "@gorhom/bottom-sheet": "^5.2.7", + "@react-native-async-storage/async-storage": "^2.2.0", "@react-native-community/datetimepicker": "^8.5.1", "@react-navigation/bottom-tabs": "^7.4.0", "@react-navigation/elements": "^2.6.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 72ebf1a1..8a19956f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -186,6 +186,9 @@ importers: '@gorhom/bottom-sheet': specifier: ^5.2.7 version: 5.2.7(@types/react@19.1.8)(react-native-gesture-handler@2.28.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native-reanimated@4.1.5(@babel/core@7.28.0)(react-native-worklets@0.5.1(@babel/core@7.28.0)(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) + '@react-native-async-storage/async-storage': + specifier: ^2.2.0 + version: 2.2.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0)) '@react-native-community/datetimepicker': specifier: ^8.5.1 version: 8.5.1(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0) @@ -2471,6 +2474,11 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-native-async-storage/async-storage@2.2.0': + resolution: {integrity: sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw==} + peerDependencies: + react-native: ^0.0.0-0 || >=0.65 <1.0 + '@react-native-community/datetimepicker@8.5.1': resolution: {integrity: sha512-TuwM1ORbxCjOp1GOtONj0QnpDpVfq0F4UlfKZYPxL/vmriaLHt2Kgvw63Bv0Bpep4eOkslVVSS1IRfRI6d392g==} peerDependencies: @@ -5451,6 +5459,10 @@ packages: resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} engines: {node: '>=8'} + is-plain-obj@2.1.0: + resolution: {integrity: sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==} + engines: {node: '>=8'} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -5944,6 +5956,10 @@ packages: memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + merge-options@3.0.4: + resolution: {integrity: sha512-2Sug1+knBjkaMsMgf1ctR1Ujx+Ayku4EdJN4Z+C2+JzoeF7A3OZ9KM2GY0CpQS51NR61LTurMJrRKPhSs3ZRTQ==} + engines: {node: '>=10'} + merge-stream@2.0.0: resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} @@ -10380,6 +10396,11 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-native-async-storage/async-storage@2.2.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))': + dependencies: + merge-options: 3.0.4 + react-native: 0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0) + '@react-native-community/datetimepicker@8.5.1(expo@54.0.25(@babel/core@7.28.0)(@expo/metro-runtime@6.1.2)(expo-router@6.0.15)(react-native-webview@13.16.0(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.0)(@types/react@19.1.8)(react@19.1.0))(react@19.1.0)': dependencies: invariant: 2.2.4 @@ -14275,6 +14296,8 @@ snapshots: is-path-inside@3.0.3: {} + is-plain-obj@2.1.0: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -14737,6 +14760,10 @@ snapshots: memoize-one@6.0.0: {} + merge-options@3.0.4: + dependencies: + is-plain-obj: 2.1.0 + merge-stream@2.0.0: {} merge2@1.4.1: {} From 2f5775c4e83c2b14e53b02f60f8f0ce964e4def5 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:51:08 +0900 Subject: [PATCH 113/140] feat: add Q&A image retrieval and folder update functionalities --- apps/native/src/apis/controller/qna/index.ts | 2 + .../apis/controller/qna/useGetQnaImages.ts | 21 ++++++++ .../native/src/apis/controller/scrap/index.ts | 2 + .../controller/scrap/putUpdateFolderName.ts | 52 +++++++++++++++++++ .../scrap/putUpdateFolderThumbnail.ts | 52 +++++++++++++++++++ apps/native/src/apis/index.ts | 1 + 6 files changed, 130 insertions(+) create mode 100644 apps/native/src/apis/controller/qna/index.ts create mode 100644 apps/native/src/apis/controller/qna/useGetQnaImages.ts create mode 100644 apps/native/src/apis/controller/scrap/putUpdateFolderName.ts create mode 100644 apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts diff --git a/apps/native/src/apis/controller/qna/index.ts b/apps/native/src/apis/controller/qna/index.ts new file mode 100644 index 00000000..57cb04b0 --- /dev/null +++ b/apps/native/src/apis/controller/qna/index.ts @@ -0,0 +1,2 @@ +export * from './useGetQnaImages'; + diff --git a/apps/native/src/apis/controller/qna/useGetQnaImages.ts b/apps/native/src/apis/controller/qna/useGetQnaImages.ts new file mode 100644 index 00000000..27be527c --- /dev/null +++ b/apps/native/src/apis/controller/qna/useGetQnaImages.ts @@ -0,0 +1,21 @@ +import { TanstackQueryClient } from '@apis'; + +/** + * Q&A 전체 이미지 조회 (질문 + 채팅) + * @param qnaId - Q&A ID + * @param enabled - 쿼리 활성화 여부 + */ +export const useGetQnaImages = (qnaId: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/qna/{qnaId}/images', + { + params: { + path: { qnaId }, + }, + }, + { + enabled, + } + ); +}; diff --git a/apps/native/src/apis/controller/scrap/index.ts b/apps/native/src/apis/controller/scrap/index.ts index ee5c8cf2..4923a842 100644 --- a/apps/native/src/apis/controller/scrap/index.ts +++ b/apps/native/src/apis/controller/scrap/index.ts @@ -20,6 +20,8 @@ export * from './postToggleScrapFromPointing'; export * from './putUpdateScrapName'; export * from './putUpdateScrapText'; export * from './putUpdateFolder'; +export * from './putUpdateFolderName'; +export * from './putUpdateFolderThumbnail'; export * from './putMoveScraps'; export * from './putRestoreTrash'; export * from './handwriting/putUpdateHandwriting'; diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts b/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts new file mode 100644 index 00000000..1a6087ba --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client, TanstackQueryClient } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UpdateFolderNameRequest = + paths['/api/student/scrap/folder/{id}/name']['put']['requestBody']['content']['application/json']; +type UpdateFolderNameResponse = + paths['/api/student/scrap/folder/{id}/name']['put']['responses']['200']['content']['*/*']; + +interface UpdateFolderNameParams { + id: number; + request: UpdateFolderNameRequest; +} + +export const useUpdateFolderName = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + request, + }: UpdateFolderNameParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/folder/{id}/name', { + params: { + path: { id }, + }, + body: request, + }); + return data as UpdateFolderNameResponse; + }, + onSuccess: () => { + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); + }, + }); +}; + diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts b/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts new file mode 100644 index 00000000..24f58c44 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts @@ -0,0 +1,52 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client, TanstackQueryClient } from '@/apis/client'; +import { paths } from '@/types/api/schema'; + +type UpdateFolderThumbnailRequest = + paths['/api/student/scrap/folder/{id}/thumbnail']['put']['requestBody']['content']['application/json']; +type UpdateFolderThumbnailResponse = + paths['/api/student/scrap/folder/{id}/thumbnail']['put']['responses']['200']['content']['*/*']; + +interface UpdateFolderThumbnailParams { + id: number; + request: UpdateFolderThumbnailRequest; +} + +export const useUpdateFolderThumbnail = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + id, + request, + }: UpdateFolderThumbnailParams): Promise => { + const { data } = await client.PUT('/api/student/scrap/folder/{id}/thumbnail', { + params: { + path: { id }, + }, + body: request, + }); + return data as UpdateFolderThumbnailResponse; + }, + onSuccess: () => { + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, + }); + // 검색 결과 갱신 (모든 검색 쿼리 무효화) + queryClient.invalidateQueries({ + predicate: (query) => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); + }, + }); + }, + }); +}; + diff --git a/apps/native/src/apis/index.ts b/apps/native/src/apis/index.ts index b2e63ceb..909de70b 100644 --- a/apps/native/src/apis/index.ts +++ b/apps/native/src/apis/index.ts @@ -7,5 +7,6 @@ export { client, TanstackQueryClient, authMiddleware }; export * from './controller/auth'; export * from './controller/diagnosis'; export * from './controller/notice'; +export * from './controller/qna'; export * from './controller/scrap'; export * from './controller/study'; From 284623f63e0d31a347d8a5a9b98def573f52347c Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:51:17 +0900 Subject: [PATCH 114/140] feat: extend card exports to include trash-related types --- .../src/features/student/scrap/components/Card/index.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Card/index.ts b/apps/native/src/features/student/scrap/components/Card/index.ts index b7867390..ded76427 100644 --- a/apps/native/src/features/student/scrap/components/Card/index.ts +++ b/apps/native/src/features/student/scrap/components/Card/index.ts @@ -5,14 +5,15 @@ export type { ScrapCardProps, FolderCardProps, ScrapListItemProps, + TrashScrapCardProps, + TrashFolderCardProps, + TrashListItemProps, } from './types'; // Cards export { ScrapCard } from './cards/ScrapCard'; export { SearchResultCard } from './cards/SearchResultCard'; -export type { SearchResultCardProps } from './cards/SearchResultCard'; export { TrashCard } from './cards/TrashCard'; -export type { TrashCardProps } from './cards/TrashCard'; // Grids export { ScrapGrid, SearchScrapGrid, TrashScrapGrid } from './ScrapCardGrid'; From 6388621b6b4007d897aff6580f6a42d81ed28611 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:51:44 +0900 Subject: [PATCH 115/140] feat: refactor card components to utilize new props structure and enhance image handling --- .../scrap/components/Card/ScrapCardGrid.tsx | 72 ++++++-- .../components/Card/cards/RecentScrapCard.tsx | 3 +- .../scrap/components/Card/cards/ScrapCard.tsx | 32 +++- .../Card/cards/SearchResultCard.tsx | 86 ++++++---- .../scrap/components/Card/cards/TrashCard.tsx | 159 ++++++++++++------ .../student/scrap/utils/gridLayout.ts | 2 +- 6 files changed, 247 insertions(+), 107 deletions(-) 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 5fd8f64e..a0a7ef6a 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -138,6 +138,7 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { return ( { @@ -161,7 +162,6 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { return `fallback-${index}`; }} - contentContainerStyle={{ paddingBottom: 120 }} columnWrapperStyle={{ marginBottom: gap * 2 }} removeClippedSubviews={true} maxToRenderPerBatch={10} @@ -187,9 +187,36 @@ export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { const scrapItem = item as ScrapItem; + // ScrapItem을 ScrapListItemProps로 변환 + const baseProps = { + id: scrapItem.id, + name: scrapItem.name, + createdAt: scrapItem.createdAt, + updatedAt: scrapItem.updatedAt, + thumbnailUrl: scrapItem.thumbnailUrl, + }; + + const searchCardProps = + scrapItem.type === 'FOLDER' + ? { + ...baseProps, + type: 'FOLDER' as const, + scrapCount: ('scrapCount' in scrapItem ? scrapItem.scrapCount : undefined) as + | number + | undefined, + top2ScrapThumbnail: ('top2ScrapThumbnail' in scrapItem + ? scrapItem.top2ScrapThumbnail + : undefined) as string[] | undefined, + } + : { + ...baseProps, + type: 'SCRAP' as const, + folderId: scrapItem.folderId, + }; + return ( - + ); }} @@ -255,10 +282,6 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP return ; } - if ('placeholder' in item && item.placeholder) { - return ; - } - // Type guard: ensure item is TrashItem if (!('id' in item) || !('type' in item) || !('deletedAt' in item)) { return ; @@ -266,15 +289,38 @@ export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridP const trashItem = item as TrashItem; + // TrashItem을 TrashListItemProps로 변환 + const baseProps = { + id: trashItem.id, + name: trashItem.name, + createdAt: trashItem.createdAt, + updatedAt: ('updatedAt' in trashItem ? trashItem.updatedAt : undefined) as + | string + | undefined, + thumbnailUrl: trashItem.thumbnailUrl, + daysUntilPermanentDelete: trashItem.daysUntilPermanentDelete, + reducerState, + onCheckPress: () => + dispatch({ type: 'SELECTING_ITEM', id: trashItem.id, itemType: trashItem.type }), + }; + + const trashCardProps = + trashItem.type === 'FOLDER' + ? { + ...baseProps, + type: 'FOLDER' as const, + top2ScrapThumbnail: trashItem.top2ScrapThumbnail, + itemCount: trashItem.itemCount, + } + : { + ...baseProps, + type: 'SCRAP' as const, + folderId: undefined, + }; + return ( - - dispatch({ type: 'SELECTING_ITEM', id: trashItem.id, itemType: trashItem.type }) - } - /> + ); }} 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 2caf4158..d4316b91 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 @@ -26,8 +26,7 @@ export const RecentScrapCard = ({ scrap }: RecentScrapCardProps) => { className='bg-primary-200 h-[140px] w-[140px] flex-col items-center justify-end rounded-[12px] border border-gray-300'> 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 ab3a6577..1d019e73 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 @@ -23,10 +23,30 @@ export const ScrapCard = (props: ScrapListItemProps) => { const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); - const imageSource = useMemo( - () => (props.thumbnailUrl ? { uri: props.thumbnailUrl } : undefined), - [props.thumbnailUrl] - ); + // 폴더일 때 top2ScrapThumbnail 추출 + const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; + + const { imageSources, isDiagonalLayout } = useMemo(() => { + // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) + if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { + return { + imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), + isDiagonalLayout: true, + }; + } + + if (props.thumbnailUrl) { + return { + imageSources: [{ uri: props.thumbnailUrl }], + isDiagonalLayout: false, + }; + } + + return { + imageSources: undefined, + isDiagonalLayout: false, + }; + }, [props.thumbnailUrl, folderTop2Thumbnail]); const cardContent = ( { } /> {state.isSelecting && ( 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 06bc0119..8baa20bd 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 @@ -2,53 +2,71 @@ import { Pressable, View, Text, Image } from 'react-native'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { useNavigation } from '@react-navigation/native'; -import type { ScrapItem } from '@/features/student/scrap/utils/types'; -import { it } from 'node:test'; import { useGetFolderDetail } from '@/apis'; import { useNoteStore } from '@/stores/scrapNoteStore'; import { ImageWithSkeleton } from '@/components/common/ImageWithSkeleton'; import { useMemo } from 'react'; +import type { ScrapListItemProps } from '../types'; -export interface SearchResultCardProps { - item: ScrapItem; -} - -export const SearchResultCard = ({ item }: SearchResultCardProps) => { +export const SearchResultCard = (props: ScrapListItemProps) => { const navigation = useNavigation>(); - const { data: foldersDetailData } = useGetFolderDetail(Number(item.folderId), !!item.folderId); - const folderName = foldersDetailData?.name; const openNote = useNoteStore((state) => state.openNote); - // thumbnailUrl이 변경될 때만 source 객체 재생성 - const imageSource = useMemo( - () => (item.thumbnailUrl ? { uri: item.thumbnailUrl } : undefined), - [item.thumbnailUrl] - ); + const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; + + const { imageSources, isDiagonalLayout } = useMemo(() => { + // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) + if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { + return { + imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), + isDiagonalLayout: true, + }; + } + + if (props.thumbnailUrl) { + return { + imageSources: [{ uri: props.thumbnailUrl }], + isDiagonalLayout: false, + }; + } + + return { + imageSources: undefined, + isDiagonalLayout: false, + }; + }, [props.thumbnailUrl, folderTop2Thumbnail]); const cardContent = ( - } - /> + + } + /> + - {folderName && {folderName}} - + - {item.name} + {props.name} - {item.type === 'FOLDER' && 폴더} + {props.type === 'FOLDER' && ( + {props.scrapCount} + )} - - {new Date(item.createdAt).toLocaleString('ko-kr')} + + {props.updatedAt + ? new Date(props.updatedAt).toLocaleString('ko-kr') + : new Date(props.createdAt).toLocaleString('ko-kr')} @@ -58,11 +76,11 @@ export const SearchResultCard = ({ item }: SearchResultCardProps) => { return ( { - if (item.type === 'FOLDER') { - navigation.push('ScrapContent', { id: item.id }); - } else if (item.type === 'SCRAP') { - openNote({ id: item.id, title: item.name }); - navigation.push('ScrapContentDetail', { id: item.id }); + if (props.type === 'FOLDER') { + navigation.push('ScrapContent', { id: props.id }); + } else if (props.type === 'SCRAP') { + openNote({ id: props.id, title: props.name }); + navigation.push('ScrapContentDetail', { id: props.id }); } }}> {cardContent} diff --git a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx index 454b8a12..ad6171c4 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -1,72 +1,127 @@ import { Pressable, View, Text } from 'react-native'; -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Check } from 'lucide-react-native'; +import { ChevronDownFilledIcon } from '@/components/system/icons'; import { TooltipPopover, TrashItemTooltipBox } from '../../Tooltip'; -import type { TrashItem } from '@/features/student/scrap/utils/types'; import PopUpModal from '../../Modal/PopupModal'; import { showToast } from '../../Modal/Toast'; import { usePermanentDeleteTrash } from '@/apis'; -import type { SelectableUIProps } from '../types'; +import type { TrashListItemProps } from '../types'; import { isItemSelected } from '../../../utils/reducer'; +import { ImageWithSkeleton } from '@/components/common/ImageWithSkeleton'; +import { colors } from '@/theme/tokens'; -export interface TrashCardProps extends SelectableUIProps { - item: TrashItem; -} - -export const TrashCard = ({ item, reducerState, onCheckPress }: TrashCardProps) => { - const state = reducerState ?? { isSelecting: false, selectedItems: [] }; - const isSelected = isItemSelected(state.selectedItems, item.id, item.type); +export const TrashCard = (props: TrashListItemProps) => { + const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; + const isSelected = isItemSelected(state.selectedItems, props.id, props.type); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); + // 폴더일 때 top2ScrapThumbnail 추출 + const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; + + const { imageSources, isDiagonalLayout } = useMemo(() => { + // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) + if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { + return { + imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), + isDiagonalLayout: true, + }; + } + + if (props.thumbnailUrl) { + return { + imageSources: [{ uri: props.thumbnailUrl }], + isDiagonalLayout: false, + }; + } + + return { + imageSources: undefined, + isDiagonalLayout: false, + }; + }, [props.thumbnailUrl, folderTop2Thumbnail]); + const cardContent = ( - - - - - {state.isSelecting && ( - - - - )} + + + + } + /> + {state.isSelecting && ( + + + + )} + - - - {item.name} - - {item.daysUntilPermanentDelete}일 남음 + + + + + {props.name} + + {!state.isSelecting && ( + + } + children={(close) => ( + { + close(); + setTimeout(() => { + setIsDeleteModalVisible(true); + }, 200); + }} + /> + )} + /> + + )} + + {props.type === 'FOLDER' && props.itemCount !== undefined && ( + {props.itemCount} + )} + + + {props.daysUntilPermanentDelete}일 남음 + + ); return ( <> - {state.isSelecting ? ( - {cardContent} - ) : ( - ( - { - close(); - setTimeout(() => { - setIsDeleteModalVisible(true); - }, 200); - }} - /> - )} - /> - )} + { + if (state.isSelecting) { + props.onCheckPress?.(); + return; + } + // TrashCard는 클릭 시 아무 동작도 하지 않음 + }}> + {cardContent} + { const GAP = 22; const MIN_ITEM = 136; - const RATIO = 1.5; // width : height = 1 : 1.5 + const RATIO = 1.15; // width : height = 1 : 1.5 // 컬럼 수 계산 let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM + GAP)); From d76fd2e62b725b1aeaef435415f6716a4cc11643 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:52:08 +0900 Subject: [PATCH 116/140] feat: refactor image upload handling and enhance pre-signed URL functionality in tooltips --- .../components/Tooltip/AddItemTooltip.tsx | 27 ++++++++++++- .../scrap/components/Tooltip/ItemTooltip.tsx | 38 ++++++++++++++++--- .../student/scrap/utils/imageUpload.ts | 6 +-- 3 files changed, 60 insertions(+), 11 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx index f295d305..f7cf8bd3 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx @@ -8,7 +8,6 @@ import { } from '../../utils/imagePicker'; import { useGetPreSignedUrl } from '@/apis/controller/common'; import { useCreateScrapFromImage } from '@/apis'; -import { uploadFileToS3 } from '../../utils/s3Upload'; import { uploadImageToS3 } from '../../utils/imageUpload'; export interface AddItemTooltipProps { @@ -22,9 +21,33 @@ export const AddItemTooltip = ({ onOpenQnaImgModal, onOpenFolderModal, }: AddItemTooltipProps) => { - const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); + const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); const { mutate: createScrapFromImage } = useCreateScrapFromImage(); + // mutate를 래핑하여 uploadImageToS3가 기대하는 형식으로 변환 + const getPreSignedUrl = ( + params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, + callbacks: { + onSuccess: (data: { + uploadUrl: string; + contentDisposition: string; + file: { id: number }; + }) => void; + onError: (error: any) => void; + } + ) => { + getPreSignedUrlMutate(params, { + onSuccess: (data) => { + callbacks.onSuccess({ + uploadUrl: data.uploadUrl, + contentDisposition: data.contentDisposition, + file: { id: data.file.id }, + }); + }, + onError: callbacks.onError, + }); + }; + // 이미지 선택 및 업로드 처리 const handleImageSelect = async (image: any) => { if (!image || !image.uri) { diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx index a6e11ea8..d5ba4655 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx @@ -18,6 +18,8 @@ import { StudentRootStackParamList } from '@/navigation/student/types'; import { useUpdateScrapName, useUpdateFolder, + useUpdateFolderName, + useUpdateFolderThumbnail, useDeleteScrap, useGetScrapDetail, useGetFolders, @@ -25,7 +27,6 @@ import { import { useNoteStore } from '@/stores/scrapNoteStore'; import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; import { openImageLibrary, openImageLibraryWithErrorHandling } from '../../utils/imagePicker'; -import { uploadFileToS3 } from '../../utils/s3Upload'; import { uploadImageToS3 } from '../../utils/imageUpload'; export interface ItemTooltipProps { @@ -42,13 +43,39 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = // API hooks const { mutateAsync: updateScrapName } = useUpdateScrapName(); const { mutateAsync: updateFolder } = useUpdateFolder(); + const { mutateAsync: updateFolderName } = useUpdateFolderName(); + const { mutateAsync: updateFolderThumbnail } = useUpdateFolderThumbnail(); const { mutateAsync: deleteScrap } = useDeleteScrap(); // 스크랩 상세 정보 가져오기 (필요한 경우) const { data: scrapDetail } = useGetScrapDetail(Number(props.id), props.type === 'SCRAP'); const { data: foldersData } = useGetFolders(); - const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); + const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); + + // mutate를 래핑하여 uploadImageToS3가 기대하는 형식으로 변환 + const getPreSignedUrl = ( + params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, + callbacks: { + onSuccess: (data: { + uploadUrl: string; + contentDisposition: string; + file: { id: number }; + }) => void; + onError: (error: any) => void; + } + ) => { + getPreSignedUrlMutate(params, { + onSuccess: (data) => { + callbacks.onSuccess({ + uploadUrl: data.uploadUrl, + contentDisposition: data.contentDisposition, + file: { id: data.file.id }, + }); + }, + onError: callbacks.onError, + }); + }; const handleUpdateFolderCover = async (image: any) => { if (!image || !image.uri) { @@ -63,11 +90,10 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = image, getPreSignedUrl, async (result) => { - // 폴더 표지 업데이트 - await updateFolder({ + // 폴더 썸네일만 업데이트 + await updateFolderThumbnail({ id: props.id, request: { - name: props.name, // 기존 이름 유지 thumbnailImageId: result.fileId, }, }); @@ -121,7 +147,7 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = if (trimmedText.length > 0 && trimmedText !== initialTitle) { try { if (props.type === 'FOLDER') { - await updateFolder({ + await updateFolderName({ id: props.id, request: { name: trimmedText }, }); diff --git a/apps/native/src/features/student/scrap/utils/imageUpload.ts b/apps/native/src/features/student/scrap/utils/imageUpload.ts index e1fa2c4c..48bf9a5e 100644 --- a/apps/native/src/features/student/scrap/utils/imageUpload.ts +++ b/apps/native/src/features/student/scrap/utils/imageUpload.ts @@ -14,7 +14,7 @@ export interface ImageUploadResult { * Pre-signed URL 요청 함수 타입 */ type GetPreSignedUrlFn = ( - params: { fileName: string }, + params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, callbacks: { onSuccess: (data: { uploadUrl: string; @@ -46,12 +46,12 @@ export const uploadImageToS3 = async ( try { // 파일명 추출 (없으면 기본값 사용) - const fileName = image.fileName || `image_${Date.now()}.jpg`; + const fileName = image.fileName || `${Date.now()}.jpg`; // Promise로 변환하여 await 가능하게 함 return await new Promise((resolve) => { getPreSignedUrl( - { fileName }, + { fileName, fileType: 'IMAGE' }, { onSuccess: async (data) => { try { From 18574798d3da46ef6a2d68230b44c9749f90e2ba Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:52:17 +0900 Subject: [PATCH 117/140] feat: enhance TooltipPopover component with dynamic styling based on visibility state --- .../student/scrap/components/Tooltip/TooltipPopover.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 94fd8195..64f35449 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx @@ -24,7 +24,13 @@ const TooltipPopover = ({ }; // from을 Pressable로 감싸서 클릭 시 열리도록 함 - const triggerElement = setIsVisible(true)}>{from}; + const triggerElement = ( + setIsVisible(true)} + className={`${isVisible ? 'aspect-square rounded-[4px] bg-gray-400' : ''} items-center`}> + {from} + + ); return ( Date: Tue, 30 Dec 2025 20:52:31 +0900 Subject: [PATCH 118/140] feat: refactor SearchScrapScreen to separate folder and scrap results for improved display --- .../scrap/screens/SearchScrapScreen.tsx | 44 ++++++++++++------- 1 file changed, 27 insertions(+), 17 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx index a3db380c..917ca3a9 100644 --- a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -37,18 +37,18 @@ const SearchScrapScreen = () => { debouncedQuery.length > 0 ); - // ScrapSearchResp는 folders와 scraps를 각각 반환하므로 합쳐야 함 - const results = React.useMemo(() => { + // ScrapSearchResp는 folders와 scraps를 각각 반환하므로 분리해서 표시 + const folders = React.useMemo(() => { if (!searchData) return []; - const folders = (searchData.folders || []).map((folder) => ({ + return (searchData.folders || []).map((folder) => ({ + ...folder, type: 'FOLDER' as const, - id: folder.id, - name: folder.name, - scrapCount: folder.scrapCount, - createdAt: folder.createdAt, })); - const scraps = searchData.scraps || []; - return [...folders, ...scraps]; + }, [searchData]); + + const scraps = React.useMemo(() => { + if (!searchData) return []; + return searchData.scraps || []; }, [searchData]); const onSearch = () => { @@ -66,16 +66,14 @@ const SearchScrapScreen = () => { setQuery={setQuery} onSubmitEditing={onSearch} /> - - {query.length === 0 ? ( + + {query.length === 0 && ( 최근 검색어 clear()}> 전체 지우기 - ) : ( - 검색 결과 )} {query.length === 0 && ( { ))} )} - - - - + + + {query.length > 0 && folders.length > 0 && ( + + 폴더 + + + )} + + {query.length > 0 && scraps.length > 0 && ( + + 스크랩 + + + )} + ); }; From 7181b790afd54ee1d1573f8f9cf7c0676cebec66 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:52:44 +0900 Subject: [PATCH 119/140] feat: refactor CreateFolderModal to enhance pre-signed URL handling and improve image upload flow --- .../components/Modal/CreateFolderModal.tsx | 33 ++++++++++++++++--- 1 file changed, 28 insertions(+), 5 deletions(-) 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 58665934..24eb3bbb 100644 --- a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -25,7 +25,31 @@ export const CreateFolderModal = ({ const [folderName, setFolderName] = useState(''); const [selectedImage, setSelectedImage] = useState(null); const { mutateAsync: createFolder } = useCreateFolder(); - const { mutate: getPreSignedUrl } = useGetPreSignedUrl(); + const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); + + // mutate를 래핑하여 uploadImageToS3가 기대하는 형식으로 변환 + const getPreSignedUrl = ( + params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, + callbacks: { + onSuccess: (data: { + uploadUrl: string; + contentDisposition: string; + file: { id: number }; + }) => void; + onError: (error: any) => void; + } + ) => { + getPreSignedUrlMutate(params, { + onSuccess: (data) => { + callbacks.onSuccess({ + uploadUrl: data.uploadUrl, + contentDisposition: data.contentDisposition, + file: { id: data.file.id }, + }); + }, + onError: callbacks.onError, + }); + }; // 모달이 닫힐 때 상태 초기화 useEffect(() => { @@ -58,6 +82,9 @@ export const CreateFolderModal = ({ // 이미지가 있는 경우 먼저 업로드 if (selectedImage) { + setTimeout(() => { + onClose(); + }, 0); const success = await uploadImageToS3( selectedImage, getPreSignedUrl, @@ -67,12 +94,8 @@ export const CreateFolderModal = ({ name: folderName, thumbnailImageId: result.fileId, }); - showToast('success', '폴더가 추가되었습니다.'); onSuccess?.(); - setTimeout(() => { - onClose(); - }, 0); }, (error) => { showToast('error', error); From f41f3477ceb8343fcb4a7af6dd80d2bd5084fef4 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 20:52:56 +0900 Subject: [PATCH 120/140] feat: update TrashItemTooltip to use new TrashListItemProps for improved type safety --- .../student/scrap/components/Tooltip/TrashItemTooltip.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx index 2920631e..ebf62094 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx @@ -2,11 +2,11 @@ import { colors } from '@/theme/tokens'; import { Trash2, Undo2 } from 'lucide-react-native'; import { View, Text, Pressable } from 'react-native'; import { showToast } from '../Modal/Toast'; -import { TrashItem } from '@/features/student/scrap/utils/types'; import { useRestoreTrash } from '@/apis'; +import type { TrashListItemProps } from '../Card/types'; export interface TrashItemTooltipProps { - item: TrashItem; + item: TrashListItemProps; onClose?: () => void; onDeletePress?: () => void; } From eefe975dd0beda9fb90d0cf784db0c4f40a4bd8d Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Tue, 30 Dec 2025 21:11:50 +0900 Subject: [PATCH 121/140] refactor: simplify ScrapCard and TrashCard components by removing unnecessary height styling and consolidating background color logic --- .../student/scrap/components/Card/cards/ScrapCard.tsx | 5 ++--- .../student/scrap/components/Card/cards/TrashCard.tsx | 4 ++-- 2 files changed, 4 insertions(+), 5 deletions(-) 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 1d019e73..fa0c03b5 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 @@ -20,7 +20,6 @@ export const ScrapCard = (props: ScrapListItemProps) => { const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); const addScrap = useRecentScrapStore((state) => state.addScrap); - const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); // 폴더일 때 top2ScrapThumbnail 추출 @@ -49,8 +48,7 @@ export const ScrapCard = (props: ScrapListItemProps) => { }, [props.thumbnailUrl, folderTop2Thumbnail]); const cardContent = ( - + { return ( { if (state.isSelecting) { props.onCheckPress?.(); diff --git a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx index ad6171c4..ad3760c5 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -43,8 +43,7 @@ export const TrashCard = (props: TrashListItemProps) => { }, [props.thumbnailUrl, folderTop2Thumbnail]); const cardContent = ( - + { return ( <> { if (state.isSelecting) { props.onCheckPress?.(); From 0b2f2b3cd4487839753b8cf9d358bd67100d1499 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:07:34 +0900 Subject: [PATCH 122/140] feat: refactor scrap screens to integrate ScrapModalProvider and enhance modal handling for moving and deleting scraps --- .../scrap/screens/DeletedScrapScreen.tsx | 132 +++++++++++------- .../scrap/screens/ScrapContentScreen.tsx | 48 +++++-- .../student/scrap/screens/ScrapScreen.tsx | 42 ++++-- 3 files changed, 143 insertions(+), 79 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index 70d89cfa..887a84f4 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -14,15 +14,17 @@ import PopUpModal from '../components/Modal/PopupModal'; import { showToast } from '../components/Modal/Toast'; import { useGetTrash, useRestoreTrash, usePermanentDeleteTrash } from '@/apis'; import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; +import { ScrapModalProvider, useScrapModal } from '../contexts/ScrapModalContext'; +import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; -const DeletedScrapScreen = () => { +const DeletedScrapScreenContent = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); const [sortKey, setSortKey] = useState('TYPE'); const [sortOrder, setSortOrder] = useState('DESC'); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); - const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); const navigation = useNavigation>(); + const { openMoveScrapModal } = useScrapModal(); // API 호출 const { data: trashData, isLoading } = useGetTrash(); @@ -40,6 +42,18 @@ const DeletedScrapScreen = () => { return sortScrapData(itemsWithCreatedAt, sortKey, sortOrder); }, [trashItems, sortKey, sortOrder]); + const handlePermanentDelete = async () => { + try { + const items = reducerState.selectedItems; + await permanentDelete({ items }); + dispatch({ type: 'CLEAR_SELECTION' }); + setIsDeleteModalVisible(false); + showToast('success', '영구 삭제되었습니다.'); + } catch (error) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }; + return ( { showToast('error', '이동할 스크랩을 선택해주세요.'); return; } - setIsMoveModalVisible(true); + openMoveScrapModal({ + selectedItems: reducerState.selectedItems, + }); + dispatch({ type: 'CLEAR_SELECTION' }); }} onRestore={async () => { try { @@ -105,58 +122,69 @@ const DeletedScrapScreen = () => { )} - - - - - {reducerState.selectedItems.length === 1 - ? '스크랩을 영구적으로 삭제합니다.' - : `${reducerState.selectedItems.length}개의 스크랩을 영구적으로 삭제합니다.`} - - - {reducerState.selectedItems.length === 1 - ? '되돌릴 수 없는 작업입니다.' - : '선택하신 스크랩이 영구적으로 삭제되며\n돌릴 수 없는 작업입니다.'} - - - - setIsDeleteModalVisible(false)}> - 취소 - - { - try { - const items = reducerState.selectedItems; - - await permanentDelete({ items }); - dispatch({ type: 'CLEAR_SELECTION' }); - setIsDeleteModalVisible(false); - showToast('success', '영구 삭제되었습니다.'); - } catch (error) { - showToast('error', '삭제 중 오류가 발생했습니다.'); - } - }}> - 삭제하기 - - - - - setIsMoveModalVisible(false)} - selectedItems={reducerState.selectedItems} - onSuccess={() => { - dispatch({ type: 'CLEAR_SELECTION' }); - }} + setIsDeleteModalVisible(false)} + selectedCount={reducerState.selectedItems.length} + onConfirm={handlePermanentDelete} /> ); }; +const DeletedScrapScreen = () => { + return ( + + + + + + ); +}; + export default DeletedScrapScreen; + +interface PermanentDeleteModalProps { + visible: boolean; + onClose: () => void; + selectedCount: number; + onConfirm: () => void; +} + +const PermanentDeleteModal = ({ + visible, + onClose, + selectedCount, + onConfirm, +}: PermanentDeleteModalProps) => { + return ( + + + + + {selectedCount === 1 + ? '스크랩을 영구적으로 삭제합니다.' + : `${selectedCount}개의 스크랩을 영구적으로 삭제합니다.`} + + + {selectedCount === 1 + ? '되돌릴 수 없는 작업입니다.' + : '선택하신 스크랩이 영구적으로 삭제되며\n돌릴 수 없는 작업입니다.'} + + + + + 취소 + + + 삭제하기 + + + + + ); +}; diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index df1e7f88..ca8acd46 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -1,6 +1,6 @@ import { View } from 'react-native'; import ScrapHeader from '../components/Header/ScrapHeader'; -import { useMemo, useReducer, useState } from 'react'; +import { useMemo, useReducer, useState, useEffect } from 'react'; import { reducer, initialSelectionState } from '../utils/reducer'; import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; import type { UISortKey, SortOrder } from '../utils/types'; @@ -13,10 +13,12 @@ import { ScrapGrid } from '../components/Card/ScrapCardGrid'; import { showToast } from '../components/Modal/Toast'; import { useGetScrapsByFolder, useDeleteScrap, useGetFolders } from '@/apis'; import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; +import { ScrapModalProvider, useScrapModal } from '../contexts/ScrapModalContext'; +import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; type ScrapContentRouteProp = RouteProp; -const ScrapContentScreen = () => { +const ScrapContentScreenContent = () => { const route = useRoute(); const { id } = route.params; @@ -24,13 +26,25 @@ const ScrapContentScreen = () => { const [sortKey, setSortKey] = useState('TITLE'); const [sortOrder, setSortOrder] = useState('ASC'); const navigation = useNavigation>(); - const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); + const { openMoveScrapModal, setRefetchScraps, setRefetchFolders } = useScrapModal(); // API 호출 - const { data: foldersData } = useGetFolders(); + const { data: foldersData, refetch: refetchFolders } = useGetFolders(); const { data: contentsData, isLoading, refetch } = useGetScrapsByFolder(id); const { mutateAsync: deleteScrap } = useDeleteScrap(); + // refetch를 context에 등록 + useEffect(() => { + if (refetch) { + setRefetchScraps(() => refetch); + } + }, [refetch, setRefetchScraps]); + useEffect(() => { + if (refetchFolders) { + setRefetchFolders(refetchFolders); + } + }, [refetchFolders, setRefetchFolders]); + // 폴더 정보 가져오기 const folder = foldersData?.data?.find((f) => f.id === Number(id)); const contents = contentsData?.data || []; @@ -72,7 +86,11 @@ const ScrapContentScreen = () => { showToast('error', '이동할 스크랩을 선택해주세요.'); return; } - setIsMoveModalVisible(true); + openMoveScrapModal({ + currentFolderId: Number(id), + selectedItems: reducerState.selectedItems, + }); + dispatch({ type: 'CLEAR_SELECTION' }); }} onDelete={async () => { if (reducerState.selectedItems.length === 0) { @@ -111,18 +129,18 @@ const ScrapContentScreen = () => { - setIsMoveModalVisible(false)} - selectedItems={reducerState.selectedItems} - onSuccess={() => { - dispatch({ type: 'CLEAR_SELECTION' }); - refetch(); - }} - /> ); }; +const ScrapContentScreen = () => { + return ( + + + + + + ); +}; + export default ScrapContentScreen; diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index 3d179392..cdb2dc4c 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -17,21 +17,34 @@ import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; import { useQueries } from '@tanstack/react-query'; import { TanstackQueryClient } from '@/apis'; import { RecentScrapCard } from '../components/Card/cards/RecentScrapCard'; +import { ScrapModalProvider, useScrapModal } from '../contexts/ScrapModalContext'; +import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; -const ScrapScreen = () => { +const ScrapScreenContent = () => { const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); const [sortKey, setSortKey] = useState('DATE'); const [sortOrder, setSortOrder] = useState('DESC'); - const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); const navigation = useNavigation>(); const recentScraps = useRecentScrapStore((state) => state.scrapIds); + const { openMoveScrapModal, setRefetchScraps } = useScrapModal(); - const { data: searchData, isLoading } = useSearchScraps({ + const { + data: searchData, + isLoading, + refetch, + } = useSearchScraps({ sort: mapUIKeyToAPIKey(sortKey), order: sortOrder, }); const { mutateAsync: deleteScrap } = useDeleteScrap(); + // refetch를 context에 등록 + React.useEffect(() => { + if (refetch) { + setRefetchScraps(() => refetch); + } + }, [refetch, setRefetchScraps]); + const recentScrapsQueries = useQueries({ queries: recentScraps.length > 0 @@ -108,7 +121,10 @@ const ScrapScreen = () => { showToast('error', '이동할 스크랩을 선택해주세요.'); return; } - setIsMoveModalVisible(true); + openMoveScrapModal({ + selectedItems: reducerState.selectedItems, + }); + dispatch({ type: 'CLEAR_SELECTION' }); }} onDelete={async () => { if (reducerState.selectedItems.length === 0) { @@ -163,16 +179,18 @@ const ScrapScreen = () => { - setIsMoveModalVisible(false)} - selectedItems={reducerState.selectedItems} - onSuccess={() => { - dispatch({ type: 'CLEAR_SELECTION' }); - }} - /> ); }; +const ScrapScreen = () => { + return ( + + + + + + ); +}; + export default ScrapScreen; From 08c10ee623c3e4ff77fb61b34fb84657e6fa05bd Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:09:25 +0900 Subject: [PATCH 123/140] feat: add useGetQnaAllImages hook for retrieving all Q&A images --- apps/native/src/apis/controller/qna/index.ts | 2 +- apps/native/src/apis/controller/qna/useGetQnaAllImages.ts | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) create mode 100644 apps/native/src/apis/controller/qna/useGetQnaAllImages.ts diff --git a/apps/native/src/apis/controller/qna/index.ts b/apps/native/src/apis/controller/qna/index.ts index 57cb04b0..c3b098d8 100644 --- a/apps/native/src/apis/controller/qna/index.ts +++ b/apps/native/src/apis/controller/qna/index.ts @@ -1,2 +1,2 @@ export * from './useGetQnaImages'; - +export * from './useGetQnaAllImages'; diff --git a/apps/native/src/apis/controller/qna/useGetQnaAllImages.ts b/apps/native/src/apis/controller/qna/useGetQnaAllImages.ts new file mode 100644 index 00000000..3ba38501 --- /dev/null +++ b/apps/native/src/apis/controller/qna/useGetQnaAllImages.ts @@ -0,0 +1,8 @@ +import { TanstackQueryClient } from '@apis'; + +/** + * Q&A 내가 참여한 모든 이미지 조회 (최신순) + */ +export const useGetQnaAllImages = () => { + return TanstackQueryClient.useQuery('get', '/api/student/qna/images'); +}; From 3f385764e850ea9a53ec13ae73c44cd8a3fcc364 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:09:42 +0900 Subject: [PATCH 124/140] refactor: update ScrapCard and ScrapAddItem components to utilize ScrapModalContext for modal handling and improve code clarity --- .../scrap/components/Card/cards/ScrapCard.tsx | 65 ++++++++++--------- .../components/Card/cards/ScrapHeadCard.tsx | 12 ++-- 2 files changed, 37 insertions(+), 40 deletions(-) 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 fa0c03b5..1dc74c2f 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 @@ -1,5 +1,5 @@ import { Pressable, View, Text, Image } from 'react-native'; -import React, { useState, useMemo } from 'react'; +import React, { useMemo } from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; import { TooltipPopover, ItemTooltipBox } from '../../Tooltip'; @@ -13,6 +13,8 @@ import { useRecentScrapStore } from '@/stores/recentScrapStore'; import { MoveScrapModal } from '../../Modal/MoveScrapModal'; import { colors } from '@/theme/tokens'; import { ImageWithSkeleton } from '@/components/common'; +import { formatToMinute } from '../../../utils/formatToMinute'; +import { useScrapModal } from '../../../contexts/ScrapModalContext'; export const ScrapCard = (props: ScrapListItemProps) => { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; @@ -20,7 +22,7 @@ export const ScrapCard = (props: ScrapListItemProps) => { const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); const addScrap = useRecentScrapStore((state) => state.addScrap); - const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); + const { openMoveScrapModal } = useScrapModal(); // 폴더일 때 top2ScrapThumbnail 추출 const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; @@ -75,11 +77,10 @@ export const ScrapCard = (props: ScrapListItemProps) => { )} - - - + + {props.name} {!state.isSelecting && ( @@ -90,7 +91,12 @@ export const ScrapCard = (props: ScrapListItemProps) => { setIsMoveModalVisible(true)} + onMovePress={() => { + close(); + openMoveScrapModal({ + selectedItems: [{ id: props.id, type: props.type }], + }); + }} /> )} /> @@ -103,39 +109,34 @@ export const ScrapCard = (props: ScrapListItemProps) => { {props.updatedAt - ? new Date(props.updatedAt).toLocaleString('ko-kr') - : new Date(props.createdAt).toLocaleString('ko-kr')} + ? formatToMinute(new Date(props.updatedAt)) + : formatToMinute(new Date(props.createdAt))} - - setIsMoveModalVisible(false)} - selectedItems={[{ id: props.id, type: props.type }]} - onSuccess={() => {}} - /> ); return ( - { - if (state.isSelecting) { - props.onCheckPress?.(); - return; - } + <> + { + if (state.isSelecting) { + props.onCheckPress?.(); + return; + } - if (props.type === 'FOLDER') { - navigation.push('ScrapContent', { id: props.id }); - } else if (props.type === 'SCRAP') { - openNote({ id: props.id, title: props.name }); - addScrap(props.id); - navigation.push('ScrapContentDetail', { id: props.id }); - } - }}> - {cardContent} - + if (props.type === 'FOLDER') { + navigation.push('ScrapContent', { id: props.id }); + } else if (props.type === 'SCRAP') { + openNote({ id: props.id, title: props.name }); + addScrap(props.id); + navigation.push('ScrapContentDetail', { id: props.id }); + } + }}> + {cardContent} + + ); }; 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 3e3ae7d4..4db6674b 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 @@ -8,15 +8,16 @@ import { ScrapListItemProps } from '../types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; -import { useReducer, useState } from 'react'; +import { useState } from 'react'; import { CreateFolderModal } from '../../Modal/CreateFolderModal'; import { LoadQnaImageModal } from '../../Modal/LoadQnaImageModal'; import { State } from '../../../utils/reducer'; +import { useScrapModal } from '../../../contexts/ScrapModalContext'; export const ScrapAddItem = ({ reducerState }: { reducerState: State }) => { - const [isFolderModalVisible, setIsFolderModalVisible] = useState(false); const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); const isSelecting = reducerState?.isSelecting ?? false; + const { openCreateFolderModal } = useScrapModal(); const addItemContent = ( @@ -44,7 +45,7 @@ export const ScrapAddItem = ({ reducerState }: { reducerState: State }) => { onOpenFolderModal={() => { close(); setTimeout(() => { - setIsFolderModalVisible(true); + openCreateFolderModal(); }, 200); }} onOpenQnaImgModal={() => { @@ -58,11 +59,6 @@ export const ScrapAddItem = ({ reducerState }: { reducerState: State }) => { from={addItemContent} /> )} - setIsFolderModalVisible(false)} - onSuccess={() => {}} - /> setisQnaImageModalVisible(false)} From 79fb9c2036b5dbee8ffec9c231fe5dab00106f54 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:11:11 +0900 Subject: [PATCH 125/140] feat: implement ScrapModalContext for managing modal states and refetch functions in scrap feature --- .../scrap/contexts/ScrapModalContext.tsx | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx diff --git a/apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx b/apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx new file mode 100644 index 00000000..e43c2c99 --- /dev/null +++ b/apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx @@ -0,0 +1,97 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import type { SelectedItem } from '../utils/reducer'; + +interface ScrapModalContextValue { + // CreateFolderModal 상태 + isCreateFolderModalVisible: boolean; + openCreateFolderModal: () => void; + closeCreateFolderModal: () => void; + + // MoveScrapModal 상태 + isMoveScrapModalVisible: boolean; + moveScrapModalProps: { + currentFolderId?: number; + selectedItems: SelectedItem[]; + }; + openMoveScrapModal: (props: { currentFolderId?: number; selectedItems: SelectedItem[] }) => void; + closeMoveScrapModal: () => void; + + // 폴더 목록 refetch 함수 + refetchFolders?: () => void; + setRefetchFolders: (refetch: () => void) => void; + + // 스크랩 목록 refetch 함수 + refetchScraps?: () => void; + setRefetchScraps: (refetch: () => void) => void; +} + +const ScrapModalContext = createContext(undefined); + +export const useScrapModal = () => { + const context = useContext(ScrapModalContext); + if (!context) { + throw new Error('useScrapModal must be used within ScrapModalProvider'); + } + return context; +}; + +interface ScrapModalProviderProps { + children: ReactNode; +} + +export const ScrapModalProvider = ({ children }: ScrapModalProviderProps) => { + const [isCreateFolderModalVisible, setIsCreateFolderModalVisible] = useState(false); + const [isMoveScrapModalVisible, setIsMoveScrapModalVisible] = useState(false); + const [moveScrapModalProps, setMoveScrapModalProps] = useState<{ + currentFolderId?: number; + selectedItems: SelectedItem[]; + }>({ + selectedItems: [], + }); + const [refetchFolders, setRefetchFoldersState] = useState<(() => void) | undefined>(undefined); + const [refetchScraps, setRefetchScrapsState] = useState<(() => void) | undefined>(undefined); + + const openCreateFolderModal = useCallback(() => { + setIsCreateFolderModalVisible(true); + }, []); + + const closeCreateFolderModal = useCallback(() => { + setIsCreateFolderModalVisible(false); + }, []); + + const openMoveScrapModal = useCallback( + (props: { currentFolderId?: number; selectedItems: SelectedItem[] }) => { + setMoveScrapModalProps(props); + setIsMoveScrapModalVisible(true); + }, + [] + ); + + const closeMoveScrapModal = useCallback(() => { + setIsMoveScrapModalVisible(false); + }, []); + + const setRefetchFolders = useCallback((refetch: () => void) => { + setRefetchFoldersState(() => refetch); + }, []); + + const setRefetchScraps = useCallback((refetch: () => void) => { + setRefetchScrapsState(() => refetch); + }, []); + + const value: ScrapModalContextValue = { + isCreateFolderModalVisible, + openCreateFolderModal, + closeCreateFolderModal, + isMoveScrapModalVisible, + moveScrapModalProps, + openMoveScrapModal, + closeMoveScrapModal, + refetchFolders, + setRefetchFolders, + refetchScraps, + setRefetchScraps, + }; + + return {children}; +}; From e9363e7b91ee18d098b513e55f9040b28fdf6df2 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:11:26 +0900 Subject: [PATCH 126/140] refactor: remove unused updateFolder mutation from ItemTooltip component --- .../features/student/scrap/components/Tooltip/ItemTooltip.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx index d5ba4655..5163a6e4 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx @@ -42,7 +42,6 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = // API hooks const { mutateAsync: updateScrapName } = useUpdateScrapName(); - const { mutateAsync: updateFolder } = useUpdateFolder(); const { mutateAsync: updateFolderName } = useUpdateFolderName(); const { mutateAsync: updateFolderThumbnail } = useUpdateFolderThumbnail(); const { mutateAsync: deleteScrap } = useDeleteScrap(); From 6599edcb38702fdb316513d2846fdf1902283542 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:11:46 +0900 Subject: [PATCH 127/140] refactor: update modal components to utilize ScrapModalContext for improved state management and enhance user experience --- .../components/Modal/CreateFolderModal.tsx | 72 +++---- .../components/Modal/FullScreenModal.tsx | 50 +++-- .../components/Modal/LoadQnaImageModal.tsx | 204 ++++++++++++++---- .../scrap/components/Modal/MoveScrapModal.tsx | 117 +++++----- 4 files changed, 278 insertions(+), 165 deletions(-) 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 24eb3bbb..1d80fe43 100644 --- a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; -import { View, Text, Pressable, Image, TextInput } from 'react-native'; -import PopUpModal from './PopupModal'; +import { View, Pressable, Image, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; +import { AddFolderScreenModal } from './FullScreenModal'; import { useCreateFolder } from '@/apis'; import { showToast } from './Toast'; import { openImageLibraryWithErrorHandling } from '../../utils/imagePicker'; @@ -8,20 +8,12 @@ import { colors } from '@/theme/tokens'; import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; import { uploadImageToS3 } from '../../utils/imageUpload'; import * as ImagePicker from 'expo-image-picker'; +import { ImageIcon } from 'lucide-react-native'; +import { useScrapModal } from '../../contexts/ScrapModalContext'; -interface CreateFolderModalProps { - visible: boolean; - onClose: () => void; - onSuccess?: () => void; - disableBackdropClose?: boolean; -} - -export const CreateFolderModal = ({ - visible, - onClose, - onSuccess, - disableBackdropClose = false, -}: CreateFolderModalProps) => { +export const CreateFolderModal = () => { + const { isCreateFolderModalVisible, closeCreateFolderModal, refetchFolders, refetchScraps } = + useScrapModal(); const [folderName, setFolderName] = useState(''); const [selectedImage, setSelectedImage] = useState(null); const { mutateAsync: createFolder } = useCreateFolder(); @@ -53,11 +45,11 @@ export const CreateFolderModal = ({ // 모달이 닫힐 때 상태 초기화 useEffect(() => { - if (!visible) { + if (!isCreateFolderModalVisible) { setFolderName(''); setSelectedImage(null); } - }, [visible]); + }, [isCreateFolderModalVisible]); const onPressGallery = async () => { const image = await openImageLibraryWithErrorHandling((error) => { @@ -83,7 +75,7 @@ export const CreateFolderModal = ({ // 이미지가 있는 경우 먼저 업로드 if (selectedImage) { setTimeout(() => { - onClose(); + closeCreateFolderModal(); }, 0); const success = await uploadImageToS3( selectedImage, @@ -95,7 +87,8 @@ export const CreateFolderModal = ({ thumbnailImageId: result.fileId, }); showToast('success', '폴더가 추가되었습니다.'); - onSuccess?.(); + refetchFolders?.(); + refetchScraps?.(); }, (error) => { showToast('error', error); @@ -106,13 +99,12 @@ export const CreateFolderModal = ({ return; } } else { - // 이미지가 없는 경우 이름만으로 폴더 생성 try { await createFolder({ name: folderName }); showToast('success', '폴더가 추가되었습니다.'); - onSuccess?.(); + refetchFolders?.(); setTimeout(() => { - onClose(); + closeCreateFolderModal(); }, 0); } catch (error) { showToast('error', '폴더 추가에 실패했습니다.'); @@ -121,26 +113,20 @@ export const CreateFolderModal = ({ }; const handleCancel = () => { - setFolderName(''); - setSelectedImage(null); - onClose(); + closeCreateFolderModal(); }; return ( - - - - - 취소 - - 새로운 폴더 생성 - - 완료 - - - - - + + + + {selectedImage ? ( ) : ( - + + + )} @@ -164,7 +152,7 @@ export const CreateFolderModal = ({ - - + + ); }; diff --git a/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx index ffd7df9d..7307fed0 100644 --- a/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx @@ -18,11 +18,15 @@ export const AddFolderScreenModal = ({ children, }: FullScreenModalProps) => { return ( - - - + + + {/* Header */} - + 취소 @@ -35,9 +39,11 @@ export const AddFolderScreenModal = ({ 완료 + {/* Content */} {children} + @@ -52,27 +58,27 @@ export const LoadQnaImageScreenModal = ({ }: FullScreenModalProps) => { return ( - - - {/* Header */} - - - 취소 - - - - QnA 사진 - + + {/* Header */} + + + 취소 + - - 완료 - + + QnA 사진 - {/* Content */} - {children} - + + + 완료 + + + + {/* Content */} + {children} + - + ); }; diff --git a/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx b/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx index 13d4bff3..e61f0e36 100644 --- a/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx @@ -1,9 +1,9 @@ -import React, { useState, useEffect } from 'react'; -import { View } from 'react-native'; -import { LoadQnaImageScreenModal } from './FullScreenModal'; import { Container } from '@/components/common'; -import SortDropdown from './SortDropdown'; -import type { UISortKey, SortOrder } from '../../utils/types'; +import React, { useState } from 'react'; +import { FlatList, Image, Modal, Pressable, View, StyleSheet, Alert, Text } from 'react-native'; +import { LoadQnaImageScreenModal } from './FullScreenModal'; +import { Check } from 'lucide-react-native'; +import { useGetQnaAllImages, useCreateScrapFromImage } from '@/apis'; interface LoadQnaImageModalProps { visible: boolean; @@ -11,53 +11,165 @@ interface LoadQnaImageModalProps { onSuccess?: () => void; } -export const LoadQnaImageModal = ({ - visible, - onClose, - onSuccess, -}: LoadQnaImageModalProps) => { - const [sortKey, setSortKey] = useState('DATE'); - const [sortOrder, setSortOrder] = useState('DESC'); - - // 모달이 닫힐 때 상태 초기화 (필요한 경우) - useEffect(() => { - if (!visible) { - // 필요시 상태 초기화 - } - }, [visible]); +export const LoadQnaImageModal = ({ visible, onClose, onSuccess }: LoadQnaImageModalProps) => { + const { data: qnaAllImagesData, isLoading } = useGetQnaAllImages(); + const { mutate: createScrapFromImage } = useCreateScrapFromImage(); + + const [containerWidth, setContainerWidth] = useState(0); + const [selectedId, setSelectedId] = useState(null); + const [previewImage, setPreviewImage] = useState(null); + + const NUM_COLUMNS = 4; + const GAP = 5; + const IMAGE_SIZE = (containerWidth - GAP * (NUM_COLUMNS + 1)) / NUM_COLUMNS; - const handleCancel = () => { - onClose(); + const toggleSelect = (id: number) => { + setSelectedId((prev) => (prev === id ? null : id)); }; - const handleClose = () => { - onSuccess?.(); - onClose(); + // 선택된 이미지로 스크랩 생성 (AddItemTooltip과 동일한 로직) + const handleComplete = () => { + if (!selectedId) { + Alert.alert('알림', '이미지를 선택해주세요.'); + return; + } + + createScrapFromImage( + { + imageId: selectedId, + }, + { + onSuccess: () => { + Alert.alert('성공', '스크랩이 생성되었습니다.'); + onSuccess?.(); + onClose(); + }, + onError: (error) => { + console.error('스크랩 생성 실패:', error); + Alert.alert('오류', '스크랩 생성에 실패했습니다.'); + }, + } + ); }; return ( - - - - - + <> + + + + + + {isLoading ? ( + + 로딩 중... + + ) : !qnaAllImagesData?.data || qnaAllImagesData.data.length === 0 ? ( + + 이미지가 없습니다. + + ) : ( + item.id.toString()} + numColumns={NUM_COLUMNS} + columnWrapperStyle={{ gap: GAP }} + contentContainerStyle={{ padding: GAP, gap: GAP }} + onLayout={(e) => setContainerWidth(e.nativeEvent.layout.width)} + renderItem={({ item }) => { + const selected = selectedId === item.id; + + return ( + setPreviewImage(item.url)} + onPress={() => toggleSelect(item.id)} + style={[ + styles.imageWrapper, + selected && styles.selectedBorder, + { + width: IMAGE_SIZE, + height: IMAGE_SIZE, + }, + ]}> + + + {/* 좌측 상단 체크 아이콘 */} + toggleSelect(item.id)} + style={styles.checkIconWrapper} + hitSlop={8}> + + + + ); + }} + /> + )} + + + setPreviewImage(null)}> + {previewImage && ( + + )} + + + + ); }; +const styles = StyleSheet.create({ + imageWrapper: { + borderRadius: 8, + overflow: 'hidden', + }, + selectedBorder: { + borderWidth: 3, + borderColor: '#617AF9', // primary + }, + checkBox: { + position: 'absolute', + top: 6, + left: 6, + width: 22, + height: 22, + borderRadius: 11, + borderWidth: 2, + borderColor: '#fff', + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'center', + alignItems: 'center', + }, + checkIconWrapper: { + position: 'absolute', + top: 6, + left: 6, + backgroundColor: 'rgba(0,0,0,0.35)', + borderRadius: 12, + padding: 2, + }, + checkInner: { + width: 10, + height: 10, + borderRadius: 5, + backgroundColor: '#4F46E5', + }, + previewBackdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.9)', + justifyContent: 'center', + alignItems: 'center', + }, + previewImage: { + width: '90%', + height: '90%', + }, +}); diff --git a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx index 97c4c071..9c65bda8 100644 --- a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx @@ -1,4 +1,4 @@ -import React, { useMemo, useCallback, useEffect, useState } from 'react'; +import React, { useMemo, useCallback, useEffect } from 'react'; import { View, Text, Pressable, ScrollView } from 'react-native'; import { FolderPlus } from 'lucide-react-native'; import PopUpModal from './PopupModal'; @@ -7,43 +7,44 @@ import { useGetFolders, useMoveScraps } from '@/apis'; import { showToast } from './Toast'; import { reducer, initialSelectionState } from '../../utils/reducer'; import { useReducer } from 'react'; -import type { SelectedItem } from '../../utils/reducer'; -import { CreateFolderModal } from './CreateFolderModal'; +import { useScrapModal } from '../../contexts/ScrapModalContext'; -interface MoveScrapModalProps { - currentFolderId?: number; - visible: boolean; - onClose: () => void; - selectedItems: SelectedItem[]; - onSuccess?: () => void; -} - -export const MoveScrapModal = ({ - currentFolderId, - visible, - onClose, - selectedItems, - onSuccess, -}: MoveScrapModalProps) => { +export const MoveScrapModal = () => { + const { + isMoveScrapModalVisible, + moveScrapModalProps, + closeMoveScrapModal, + openCreateFolderModal, + setRefetchFolders, + refetchScraps, + isCreateFolderModalVisible, + } = useScrapModal(); + const { currentFolderId, selectedItems } = moveScrapModalProps; const [folderSelectionState, dispatch] = useReducer(reducer, { ...initialSelectionState, isSelecting: true, // 모달 내에서는 항상 선택 모드 }); - const [isCreateFolderModalVisible, setIsCreateFolderModalVisible] = useState(false); const { data: foldersData, refetch: refetchFolders } = useGetFolders(); const { mutateAsync: moveScraps } = useMoveScraps(); + // refetchFolders를 context에 등록 + useEffect(() => { + if (refetchFolders) { + setRefetchFolders(refetchFolders); + } + }, [refetchFolders, setRefetchFolders]); + // 모달 상태에 따른 선택 모드 관리 useEffect(() => { - if (visible) { + if (isMoveScrapModalVisible) { // 모달이 열릴 때 선택 모드 활성화 dispatch({ type: 'ENTER_SELECTION' }); } else { // 모달이 닫힐 때 선택 상태 초기화 dispatch({ type: 'CLEAR_SELECTION' }); } - }, [visible]); + }, [isMoveScrapModalVisible]); // 폴더만 필터링 const folders = useMemo(() => { @@ -111,53 +112,59 @@ export const MoveScrapModal = ({ showToast('success', `${scrapsToMove.length}개의 스크랩이 이동되었습니다.`); dispatch({ type: 'CLEAR_SELECTION' }); - onSuccess?.(); - onClose(); + refetchFolders?.(); + refetchScraps?.(); + closeMoveScrapModal(); } catch (error) { showToast('error', '이동 중 오류가 발생했습니다.'); } }; + const folderName = folders.find((folder) => folder.id === selectedFolderId)?.name; + return ( - <> - - - - - 스크랩 이동하기 - setIsCreateFolderModalVisible(true)}> - - 새로운 폴더 - - + + + + + 취소 + + + {selectedItems.length}개 스크랩 이동하기 + + openCreateFolderModal()}> + + 새로운 폴더 + + - - + + + - - - {selectedFolderId ? '스크랩 이동하기' : '이동할 폴더를 선택해주세요'} - - - + + + + {selectedFolderId + ? `'${folderName}' 폴더로 이동하기` + : '이동할 폴더를 선택해주세요'} + + - setIsCreateFolderModalVisible(false)} - onSuccess={() => {}} - /> - - + + ); }; From cfdfaf1182d3037fc560f751df1012eaba1812dc Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:11:53 +0900 Subject: [PATCH 128/140] fix: format updatedAt in RecentScrapCard to display time in minutes for better readability --- .../student/scrap/components/Card/cards/RecentScrapCard.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 d4316b91..0651c5de 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 @@ -6,6 +6,7 @@ import { StudentRootStackParamList } from '@/navigation/student/types'; import type { ScrapDetailResp } from '@/features/student/scrap/utils/types'; import { useNoteStore } from '@/stores/scrapNoteStore'; import { useRecentScrapStore } from '@/stores/recentScrapStore'; +import { formatToMinute } from '../../../utils/formatToMinute'; type RecentScrapCardProps = { scrap: ScrapDetailResp & { type: 'SCRAP' }; @@ -34,7 +35,7 @@ export const RecentScrapCard = ({ scrap }: RecentScrapCardProps) => { {scrap.name} - {scrap.updatedAt} + {formatToMinute(new Date(scrap.updatedAt))} From 6b5e2d821072fd8889b11b7a5bf5b384350a2040 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 14:14:55 +0900 Subject: [PATCH 129/140] feat: add formatToMinute utility function for consistent date formatting in scrap feature --- .../src/features/student/scrap/utils/formatToMinute.ts | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 apps/native/src/features/student/scrap/utils/formatToMinute.ts diff --git a/apps/native/src/features/student/scrap/utils/formatToMinute.ts b/apps/native/src/features/student/scrap/utils/formatToMinute.ts new file mode 100644 index 00000000..380c91bf --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/formatToMinute.ts @@ -0,0 +1,10 @@ +export function formatToMinute(date: Date, locale: string = 'ko-KR'): string { + return date.toLocaleString(locale, { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: true, + }); +} From 1642c2593bacd8bcfe32ab97fd425d086f780304 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:24:36 +0900 Subject: [PATCH 130/140] fix: resolve TypeScript errors in scrap API controller MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Refactor queryKeys to use standard @tanstack/react-query key pattern - Fix putMoveScraps to convert scrapIds array to items format for optimistic updates - Add optimisticHelpers for consistent optimistic update and rollback logic - Add invalidationHelpers for centralized query invalidation - Add queryFilters for reusable query filtering logic - Fix type issues in optimistic update functions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .../apis/controller/scrap/putMoveScraps.ts | 57 +++--- .../src/apis/controller/scrap/utils/index.ts | 11 ++ .../scrap/utils/invalidationHelpers.ts | 100 ++++++++++ .../scrap/utils/optimisticHelpers.ts | 172 ++++++++++++++++++ .../controller/scrap/utils/queryFilters.ts | 94 ++++++++++ .../apis/controller/scrap/utils/queryKeys.ts | 26 +++ ...AddItemTooltip.tsx => AddScrapTooltip.tsx} | 0 ...ItemTooltip.tsx => ReviewScrapTooltip.tsx} | 0 .../{ItemTooltip.tsx => ScrapItemTooltip.tsx} | 0 ...hItemTooltip.tsx => TrashScrapTooltip.tsx} | 0 10 files changed, 430 insertions(+), 30 deletions(-) create mode 100644 apps/native/src/apis/controller/scrap/utils/index.ts create mode 100644 apps/native/src/apis/controller/scrap/utils/invalidationHelpers.ts create mode 100644 apps/native/src/apis/controller/scrap/utils/optimisticHelpers.ts create mode 100644 apps/native/src/apis/controller/scrap/utils/queryFilters.ts create mode 100644 apps/native/src/apis/controller/scrap/utils/queryKeys.ts rename apps/native/src/features/student/scrap/components/Tooltip/{AddItemTooltip.tsx => AddScrapTooltip.tsx} (100%) rename apps/native/src/features/student/scrap/components/Tooltip/{ReviewItemTooltip.tsx => ReviewScrapTooltip.tsx} (100%) rename apps/native/src/features/student/scrap/components/Tooltip/{ItemTooltip.tsx => ScrapItemTooltip.tsx} (100%) rename apps/native/src/features/student/scrap/components/Tooltip/{TrashItemTooltip.tsx => TrashScrapTooltip.tsx} (100%) diff --git a/apps/native/src/apis/controller/scrap/putMoveScraps.ts b/apps/native/src/apis/controller/scrap/putMoveScraps.ts index e21e51de..a8600ee4 100644 --- a/apps/native/src/apis/controller/scrap/putMoveScraps.ts +++ b/apps/native/src/apis/controller/scrap/putMoveScraps.ts @@ -1,6 +1,13 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { + optimisticMoveScrap, + rollbackOptimisticUpdate, + invalidateScrapSearchQueries, + invalidateFolderScrapsQueries, + SCRAP_QUERY_KEYS, +} from './utils'; type MoveScrapsRequest = paths['/api/student/scrap/move']['put']['requestBody']['content']['application/json']; @@ -17,38 +24,28 @@ export const useMoveScraps = () => { }); return data as MoveScrapsResponse; }, - onSuccess: () => { + // 낙관적 업데이트: 이동된 항목을 현재 폴더에서 즉시 제거 + onMutate: async (request) => { + // scrapIds를 items 형태로 변환 (타입은 항상 SCRAP) + const items = request.scrapIds.map(id => ({ id, type: 'SCRAP' as const })); + return await optimisticMoveScrap(queryClient, items); + }, + // 에러 발생 시 롤백 + onError: (error, request, context) => { + if (context?.previousQueries) { + rollbackOptimisticUpdate(queryClient, context.previousQueries); + } + }, + // 성공/실패 관계없이 쿼리 무효화 + onSettled: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 폴더별 스크랩 목록 갱신 (모든 폴더의 스크랩 목록 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/folder/') && - key[1].includes('/scraps') - ); - }, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 폴더별 스크랩 목록 갱신 + invalidateFolderScrapsQueries(queryClient); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/utils/index.ts b/apps/native/src/apis/controller/scrap/utils/index.ts new file mode 100644 index 00000000..1392f5b4 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/utils/index.ts @@ -0,0 +1,11 @@ +// Query Filters +export * from './queryFilters'; + +// Invalidation Helpers +export * from './invalidationHelpers'; + +// Query Keys +export * from './queryKeys'; + +// Optimistic Update Helpers +export * from './optimisticHelpers'; diff --git a/apps/native/src/apis/controller/scrap/utils/invalidationHelpers.ts b/apps/native/src/apis/controller/scrap/utils/invalidationHelpers.ts new file mode 100644 index 00000000..22e8440f --- /dev/null +++ b/apps/native/src/apis/controller/scrap/utils/invalidationHelpers.ts @@ -0,0 +1,100 @@ +import { QueryClient } from '@tanstack/react-query'; +import { + isScrapSearchQuery, + isFolderScrapsQuery, + isFolderScrapsQueryByFolderId, + isTrashQuery, + isRecentScrapQuery, + isScrapRelatedQuery, +} from './queryFilters'; + +/** + * 스크랩 검색 쿼리를 무효화 + * 스크랩/폴더 생성, 수정, 삭제 시 사용 + */ +export const invalidateScrapSearchQueries = (queryClient: QueryClient): void => { + queryClient.invalidateQueries({ + predicate: isScrapSearchQuery, + }); +}; + +/** + * 폴더 스크랩 목록 쿼리를 무효화 + * 스크랩 이동, 삭제 시 사용 + */ +export const invalidateFolderScrapsQueries = (queryClient: QueryClient): void => { + queryClient.invalidateQueries({ + predicate: isFolderScrapsQuery, + }); +}; + +/** + * 특정 폴더의 스크랩 목록 쿼리를 무효화 + * @param queryClient - Query Client 인스턴스 + * @param folderId - 폴더 ID + */ +export const invalidateFolderScrapsByFolderId = ( + queryClient: QueryClient, + folderId: number +): void => { + queryClient.invalidateQueries({ + predicate: isFolderScrapsQueryByFolderId(folderId), + }); +}; + +/** + * 휴지통 목록 쿼리를 무효화 + * 휴지통 항목 삭제, 복구 시 사용 + */ +export const invalidateTrashQueries = (queryClient: QueryClient): void => { + queryClient.invalidateQueries({ + predicate: isTrashQuery, + }); +}; + +/** + * 최근 스크랩 목록 쿼리를 무효화 + * 스크랩 생성, 수정 시 사용 + */ +export const invalidateRecentScrapQueries = (queryClient: QueryClient): void => { + queryClient.invalidateQueries({ + predicate: isRecentScrapQuery, + }); +}; + +/** + * 스크랩 관련 모든 쿼리를 무효화 + * 대규모 변경 또는 전체 갱신이 필요한 경우 사용 + */ +export const invalidateAllScrapQueries = (queryClient: QueryClient): void => { + queryClient.invalidateQueries({ + predicate: isScrapRelatedQuery, + }); +}; + +/** + * 스크랩 검색 및 폴더 스크랩 목록 쿼리를 동시에 무효화 + * 스크랩 삭제, 이동 시 주로 사용 + */ +export const invalidateScrapSearchAndFolderQueries = (queryClient: QueryClient): void => { + invalidateScrapSearchQueries(queryClient); + invalidateFolderScrapsQueries(queryClient); +}; + +/** + * 스크랩 생성/수정 시 필요한 쿼리들을 무효화 + * 검색 쿼리와 최근 스크랩 쿼리를 갱신 + */ +export const invalidateScrapMutationQueries = (queryClient: QueryClient): void => { + invalidateScrapSearchQueries(queryClient); + invalidateRecentScrapQueries(queryClient); +}; + +/** + * 휴지통 관련 작업 시 필요한 쿼리들을 무효화 + * 휴지통 쿼리와 스크랩 검색 쿼리를 갱신 + */ +export const invalidateTrashMutationQueries = (queryClient: QueryClient): void => { + invalidateTrashQueries(queryClient); + invalidateScrapSearchQueries(queryClient); +}; diff --git a/apps/native/src/apis/controller/scrap/utils/optimisticHelpers.ts b/apps/native/src/apis/controller/scrap/utils/optimisticHelpers.ts new file mode 100644 index 00000000..d3401d8a --- /dev/null +++ b/apps/native/src/apis/controller/scrap/utils/optimisticHelpers.ts @@ -0,0 +1,172 @@ +import { QueryClient, QueryFilters } from '@tanstack/react-query'; +import type { ScrapSearchResponse } from '@/features/student/scrap/utils/types'; +import { isScrapSearchQuery } from './queryFilters'; + +/** + * 삭제할 항목 ID 세트 생성 + */ +export const createDeletedIdsSet = ( + items: Array<{ id: number; type: string }> +): Set => { + return new Set(items.map((item) => `${item.type}-${item.id}`)); +}; + +/** + * 검색 쿼리 필터 생성 + */ +export const createSearchQueryFilters = (): QueryFilters => ({ + predicate: isScrapSearchQuery, +}); + +/** + * 스크랩 삭제 낙관적 업데이트 + * @returns 롤백을 위한 이전 데이터 + */ +export const optimisticDeleteScrap = async ( + queryClient: QueryClient, + items: Array<{ id: number; type: string }> +) => { + const deletedIds = createDeletedIdsSet(items); + const searchQueryFilters = createSearchQueryFilters(); + + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries(searchQueryFilters); + + // 이전 데이터 백업 + const previousQueries = queryClient.getQueriesData(searchQueryFilters); + + // 낙관적 업데이트: 삭제된 항목을 즉시 제거 + queryClient.setQueriesData(searchQueryFilters, (old) => { + if (!old) return old; + + return { + folders: old.folders?.filter((folder) => !deletedIds.has(`FOLDER-${folder.id}`)), + scraps: old.scraps?.filter((scrap) => !deletedIds.has(`SCRAP-${scrap.id}`)), + }; + }); + + return { previousQueries }; +}; + +/** + * 스크랩 이동 낙관적 업데이트 + * @returns 롤백을 위한 이전 데이터 + */ +export const optimisticMoveScrap = async ( + queryClient: QueryClient, + items: Array<{ id: number; type: string }> +) => { + const movedIds = createDeletedIdsSet(items); + const searchQueryFilters = createSearchQueryFilters(); + + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries(searchQueryFilters); + + // 이전 데이터 백업 + const previousQueries = queryClient.getQueriesData(searchQueryFilters); + + // 낙관적 업데이트: 이동된 항목을 현재 폴더에서 제거 + queryClient.setQueriesData(searchQueryFilters, (old) => { + if (!old) return old; + + return { + folders: old.folders?.filter((folder) => !movedIds.has(`FOLDER-${folder.id}`)), + scraps: old.scraps?.filter((scrap) => !movedIds.has(`SCRAP-${scrap.id}`)), + }; + }); + + return { previousQueries }; +}; + +/** + * 폴더 생성 낙관적 업데이트 + * @returns 롤백을 위한 이전 데이터 + */ +export const optimisticCreateFolder = async ( + queryClient: QueryClient, + folderName: string +) => { + const searchQueryFilters = createSearchQueryFilters(); + + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries(searchQueryFilters); + + // 이전 데이터 백업 + const previousQueries = queryClient.getQueriesData(searchQueryFilters); + + // 임시 ID 생성 (음수로 생성하여 실제 ID와 구분) + const tempId = -Date.now(); + + // 낙관적 업데이트: 새 폴더를 즉시 추가 + queryClient.setQueriesData(searchQueryFilters, (old) => { + if (!old) return old; + + const newFolder = { + id: tempId, + name: folderName, + scrapCount: 0, + thumbnailUrl: undefined, + top2ScrapThumbnail: undefined, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }; + + return { + folders: [newFolder, ...(old.folders ?? [])], + scraps: old.scraps ?? [], + }; + }); + + return { previousQueries, tempId }; +}; + +/** + * 폴더 업데이트 낙관적 업데이트 + * @returns 롤백을 위한 이전 데이터 + */ +export const optimisticUpdateFolder = async ( + queryClient: QueryClient, + folderId: number, + updates: { name?: string; parentFolderId?: number } +) => { + const searchQueryFilters = createSearchQueryFilters(); + + // 진행 중인 쿼리 취소 + await queryClient.cancelQueries(searchQueryFilters); + + // 이전 데이터 백업 + const previousQueries = queryClient.getQueriesData(searchQueryFilters); + + // 낙관적 업데이트: 폴더 정보를 즉시 변경 + queryClient.setQueriesData(searchQueryFilters, (old) => { + if (!old) return old; + + return { + folders: old.folders?.map((folder) => + folder.id === folderId + ? { + ...folder, + ...updates, + updatedAt: new Date().toISOString(), + } + : folder + ), + scraps: old.scraps, + }; + }); + + return { previousQueries }; +}; + +/** + * 낙관적 업데이트 롤백 + * 에러 발생 시 이전 데이터로 복원 + */ +export const rollbackOptimisticUpdate = ( + queryClient: QueryClient, + previousQueries: readonly [queryKey: unknown, data: unknown][] +): void => { + previousQueries.forEach(([queryKey, data]) => { + queryClient.setQueryData(queryKey as any, data); + }); +}; diff --git a/apps/native/src/apis/controller/scrap/utils/queryFilters.ts b/apps/native/src/apis/controller/scrap/utils/queryFilters.ts new file mode 100644 index 00000000..1a3a6aa5 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/utils/queryFilters.ts @@ -0,0 +1,94 @@ +import { Query } from '@tanstack/react-query'; + +/** + * 스크랩 검색 API 쿼리 필터 + * /api/student/scrap/search 관련 쿼리를 필터링 + */ +export const isScrapSearchQuery = (query: Query): boolean => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/search') + ); +}; + +/** + * 폴더 스크랩 목록 API 쿼리 필터 + * /api/student/scrap/folder/{folderId}/scraps 관련 쿼리를 필터링 + */ +export const isFolderScrapsQuery = (query: Query): boolean => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/folder') && + key[1].includes('/scraps') + ); +}; + +/** + * 특정 폴더의 스크랩 목록 쿼리 필터 + * @param folderId - 폴더 ID + */ +export const isFolderScrapsQueryByFolderId = (folderId: number) => { + return (query: Query): boolean => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes(`/api/student/scrap/folder/${folderId}/scraps`) + ); + }; +}; + +/** + * 휴지통 목록 API 쿼리 필터 + * /api/student/scrap/trash 관련 쿼리를 필터링 + */ +export const isTrashQuery = (query: Query): boolean => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/trash') + ); +}; + +/** + * 최근 스크랩 목록 API 쿼리 필터 + * /api/student/scrap/recent 관련 쿼리를 필터링 + */ +export const isRecentScrapQuery = (query: Query): boolean => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap/recent') + ); +}; + +/** + * 스크랩 관련 모든 쿼리 필터 + * /api/student/scrap 관련 모든 쿼리를 필터링 + */ +export const isScrapRelatedQuery = (query: Query): boolean => { + const key = query.queryKey; + return ( + Array.isArray(key) && + key.length >= 2 && + key[0] === 'get' && + typeof key[1] === 'string' && + key[1].includes('/api/student/scrap') + ); +}; diff --git a/apps/native/src/apis/controller/scrap/utils/queryKeys.ts b/apps/native/src/apis/controller/scrap/utils/queryKeys.ts new file mode 100644 index 00000000..e3d7b598 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/utils/queryKeys.ts @@ -0,0 +1,26 @@ +/** + * 스크랩 관련 Query Keys 상수 + * @tanstack/react-query의 쿼리 키 패턴 사용 + */ +export const SCRAP_QUERY_KEYS = { + /** 폴더 목록 쿼리 키 */ + folderList: () => ['get', '/api/student/scrap/folder'] as const, + + /** 휴지통 목록 쿼리 키 */ + trashList: () => ['get', '/api/student/scrap/trash'] as const, + + /** 최근 스크랩 목록 쿼리 키 */ + recentScrapList: () => ['get', '/api/student/scrap/recent'] as const, + + /** 특정 폴더의 스크랩 목록 쿼리 키 */ + folderScraps: (folderId: number) => + ['get', '/api/student/scrap/folder/{folderId}/scraps', { params: { path: { folderId } } }] as const, + + /** 스크랩 상세 정보 쿼리 키 */ + scrapDetail: (scrapId: number) => + ['get', '/api/student/scrap/{scrapId}', { params: { path: { scrapId } } }] as const, + + /** 폴더 상세 정보 쿼리 키 */ + folderDetail: (folderId: number) => + ['get', '/api/student/scrap/folder/{folderId}', { params: { path: { folderId } } }] as const, +} as const; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Tooltip/AddItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ReviewItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Tooltip/ReviewItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Tooltip/ItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Tooltip/TrashItemTooltip.tsx rename to apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx From 0f9b086cb7d121c102a2ed8bdfbddfdc7b08141b Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 16:28:10 +0900 Subject: [PATCH 131/140] refactor: reorganize scrap feature directory structure and rename files for better clarity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure components into proper categories (Dialog, Dropdown, Notification, HOC) - Reorganize utils into subdirectories (images, formatters, layout, skia) - Rename files with 'Scrap' prefix for clarity (ScrapItemTooltip, AddScrapTooltip, etc.) - Rename ScrapModalContext to ScrapModalsContext for accurate naming - Rename header files to include 'Scrap' prefix (DeletedScrapHeader, SearchScrapHeader) - Rename PopupModal to ConfirmationDialog for semantic clarity - Fix all import paths to reflect new structure - Update ScrapHeader props structure to use actions object - Add index files for better module exports - Maintain backward compatibility through export aliases 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- .claude/settings.local.json | 14 + apps/native/App.tsx | 2 +- .../apis/controller/scrap/deleteFolders.ts | 56 +-- .../controller/scrap/deletePermanentTrash.ts | 5 +- .../src/apis/controller/scrap/deleteScrap.ts | 86 +---- .../scrap/deleteUnscrapFromPointing.ts | 22 +- .../scrap/deleteUnscrapFromProblem.ts | 22 +- .../apis/controller/scrap/postCreateFolder.ts | 22 +- .../apis/controller/scrap/postCreateScrap.ts | 18 +- .../scrap/postCreateScrapFromImage.ts | 19 +- .../scrap/postCreateScrapFromPointing.ts | 18 +- .../scrap/postCreateScrapFromProblem.ts | 18 +- .../scrap/postToggleScrapFromPointing.ts | 18 +- .../scrap/postToggleScrapFromProblem.ts | 18 +- .../apis/controller/scrap/putRestoreTrash.ts | 24 +- .../apis/controller/scrap/putUpdateFolder.ts | 20 +- .../controller/scrap/putUpdateFolderName.ts | 20 +- .../scrap/putUpdateFolderThumbnail.ts | 20 +- .../controller/scrap/putUpdateScrapName.ts | 24 +- .../components/common/ImageWithSkeleton.tsx | 319 ++++++++++-------- .../scrap/components/Card/ScrapCardGrid.tsx | 2 +- .../components/Card/cards/RecentScrapCard.tsx | 2 +- .../scrap/components/Card/cards/ScrapCard.tsx | 34 +- .../components/Card/cards/ScrapHeadCard.tsx | 7 +- .../Card/cards/SearchResultCard.tsx | 34 +- .../scrap/components/Card/cards/TrashCard.tsx | 34 +- .../ConfirmationDialog.tsx} | 8 +- .../student/scrap/components/Dialog/index.ts | 1 + .../{Modal => Dropdown}/SortDropdown.tsx | 0 .../scrap/components/Dropdown/index.ts | 1 + ...letedHeader.tsx => DeletedScrapHeader.tsx} | 0 .../scrap/components/Header/ScrapHeader.tsx | 53 +-- ...SearchHeader.tsx => SearchScrapHeader.tsx} | 0 .../student/scrap/components/Header/index.ts | 3 + .../components/Modal/CreateFolderModal.tsx | 36 +- .../components/Modal/FullScreenModal.tsx | 2 +- .../scrap/components/Modal/MoveScrapModal.tsx | 6 +- .../student/scrap/components/Modal/index.ts | 4 + .../{Modal => Notification}/Toast.tsx | 0 .../scrap/components/Notification/index.ts | 1 + .../components/Tooltip/AddScrapTooltip.tsx | 44 +-- .../components/Tooltip/ReviewScrapTooltip.tsx | 46 +-- .../components/Tooltip/ScrapItemTooltip.tsx | 47 +-- .../components/Tooltip/TooltipContainer.tsx | 40 +++ .../components/Tooltip/TooltipMenuItem.tsx | 49 +++ .../components/Tooltip/TrashScrapTooltip.tsx | 91 ++--- .../student/scrap/components/Tooltip/index.ts | 24 +- ...odalContext.tsx => ScrapModalsContext.tsx} | 28 +- .../src/features/student/scrap/hoc/index.ts | 1 + .../student/scrap/hoc/withScrapModals.tsx | 35 ++ .../src/features/student/scrap/hooks/index.ts | 3 + .../scrap/hooks/useCardImageSources.ts | 49 +++ .../scrap/hooks/usePreSignedUrlAdapter.ts | 45 +++ .../student/scrap/hooks/useScrapSelection.ts | 23 ++ .../scrap/screens/DeletedScrapScreen.tsx | 38 +-- .../scrap/screens/ScrapContentScreen.tsx | 104 +++--- .../screens/ScrapDetailContentScreen.tsx | 4 +- .../student/scrap/screens/ScrapScreen.tsx | 110 +++--- .../scrap/screens/SearchScrapScreen.tsx | 2 +- .../features/student/scrap/utils/constants.ts | 39 +++ .../utils/{ => formatters}/formatToMinute.ts | 0 .../student/scrap/utils/formatters/index.ts | 3 + .../scrap/utils/{ => formatters}/sortScrap.ts | 0 .../{ => formatters}/toAlphabetSequence.ts | 0 .../scrap/utils/{ => images}/imagePicker.ts | 0 .../scrap/utils/{ => images}/imageUpload.ts | 0 .../student/scrap/utils/images/index.ts | 3 + .../scrap/utils/{ => images}/s3Upload.ts | 0 .../scrap/utils/{ => layout}/gridLayout.ts | 14 +- .../student/scrap/utils/layout/index.ts | 1 + .../{components => utils}/skia/drawing.tsx | 0 .../student/scrap/utils/skia/index.ts | 3 + .../student/scrap/utils/validation.ts | 26 ++ 73 files changed, 950 insertions(+), 915 deletions(-) create mode 100644 .claude/settings.local.json rename apps/native/src/features/student/scrap/components/{Modal/PopupModal.tsx => Dialog/ConfirmationDialog.tsx} (84%) create mode 100644 apps/native/src/features/student/scrap/components/Dialog/index.ts rename apps/native/src/features/student/scrap/components/{Modal => Dropdown}/SortDropdown.tsx (100%) create mode 100644 apps/native/src/features/student/scrap/components/Dropdown/index.ts rename apps/native/src/features/student/scrap/components/Header/{DeletedHeader.tsx => DeletedScrapHeader.tsx} (100%) rename apps/native/src/features/student/scrap/components/Header/{SearchHeader.tsx => SearchScrapHeader.tsx} (100%) create mode 100644 apps/native/src/features/student/scrap/components/Header/index.ts create mode 100644 apps/native/src/features/student/scrap/components/Modal/index.ts rename apps/native/src/features/student/scrap/components/{Modal => Notification}/Toast.tsx (100%) create mode 100644 apps/native/src/features/student/scrap/components/Notification/index.ts create mode 100644 apps/native/src/features/student/scrap/components/Tooltip/TooltipContainer.tsx create mode 100644 apps/native/src/features/student/scrap/components/Tooltip/TooltipMenuItem.tsx rename apps/native/src/features/student/scrap/contexts/{ScrapModalContext.tsx => ScrapModalsContext.tsx} (75%) create mode 100644 apps/native/src/features/student/scrap/hoc/index.ts create mode 100644 apps/native/src/features/student/scrap/hoc/withScrapModals.tsx create mode 100644 apps/native/src/features/student/scrap/hooks/index.ts create mode 100644 apps/native/src/features/student/scrap/hooks/useCardImageSources.ts create mode 100644 apps/native/src/features/student/scrap/hooks/usePreSignedUrlAdapter.ts create mode 100644 apps/native/src/features/student/scrap/hooks/useScrapSelection.ts create mode 100644 apps/native/src/features/student/scrap/utils/constants.ts rename apps/native/src/features/student/scrap/utils/{ => formatters}/formatToMinute.ts (100%) create mode 100644 apps/native/src/features/student/scrap/utils/formatters/index.ts rename apps/native/src/features/student/scrap/utils/{ => formatters}/sortScrap.ts (100%) rename apps/native/src/features/student/scrap/utils/{ => formatters}/toAlphabetSequence.ts (100%) rename apps/native/src/features/student/scrap/utils/{ => images}/imagePicker.ts (100%) rename apps/native/src/features/student/scrap/utils/{ => images}/imageUpload.ts (100%) create mode 100644 apps/native/src/features/student/scrap/utils/images/index.ts rename apps/native/src/features/student/scrap/utils/{ => images}/s3Upload.ts (100%) rename apps/native/src/features/student/scrap/utils/{ => layout}/gridLayout.ts (63%) create mode 100644 apps/native/src/features/student/scrap/utils/layout/index.ts rename apps/native/src/features/student/scrap/{components => utils}/skia/drawing.tsx (100%) create mode 100644 apps/native/src/features/student/scrap/utils/skia/index.ts create mode 100644 apps/native/src/features/student/scrap/utils/validation.ts diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..725b4574 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,14 @@ +{ + "permissions": { + "allow": [ + "Bash(grep:*)", + "Bash(tree:*)", + "Bash(find:*)", + "Bash(git mv:*)", + "Bash(wc:*)", + "Bash(npx tsc:*)", + "Bash(git add:*)", + "Bash(git commit:*)" + ] + } +} diff --git a/apps/native/App.tsx b/apps/native/App.tsx index 58049bd3..92c5b468 100644 --- a/apps/native/App.tsx +++ b/apps/native/App.tsx @@ -12,7 +12,7 @@ import { LoadingScreen } from '@components/common'; import { useLoadAssets } from '@hooks'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Toast from 'react-native-toast-message'; -import { toastConfig } from '@/features/student/scrap/components/Modal/Toast'; +import { toastConfig } from '@/features/student/scrap/components/Notification/Toast'; const queryClient = new QueryClient(); diff --git a/apps/native/src/apis/controller/scrap/deleteFolders.ts b/apps/native/src/apis/controller/scrap/deleteFolders.ts index 48dc9bb6..b07dc56c 100644 --- a/apps/native/src/apis/controller/scrap/deleteFolders.ts +++ b/apps/native/src/apis/controller/scrap/deleteFolders.ts @@ -1,7 +1,13 @@ -import { useMutation, useQueryClient, QueryFilters } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; import type { ScrapSearchResponse } from '@/features/student/scrap/utils/types'; +import { + createSearchQueryFilters, + rollbackOptimisticUpdate, + invalidateScrapSearchQueries, + SCRAP_QUERY_KEYS, +} from './utils'; type DeleteFoldersRequest = paths['/api/student/scrap/folder']['delete']['requestBody']['content']['application/json']; @@ -20,26 +26,12 @@ export const useDeleteFolders = () => { const deletedFolderIds = new Set(request); // 폴더 목록 쿼리 취소 및 백업 - const folderQueryKey = TanstackQueryClient.queryOptions( - 'get', - '/api/student/scrap/folder' - ).queryKey; + const folderQueryKey = SCRAP_QUERY_KEYS.folderList(); await queryClient.cancelQueries({ queryKey: folderQueryKey }); const previousFolders = queryClient.getQueryData(folderQueryKey); // 검색 쿼리 취소 및 백업 - const searchQueryFilters: QueryFilters = { - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }; + const searchQueryFilters = createSearchQueryFilters(); await queryClient.cancelQueries(searchQueryFilters); const previousQueries = queryClient.getQueriesData(searchQueryFilters); @@ -67,40 +59,24 @@ export const useDeleteFolders = () => { // 에러 발생 시 롤백 onError: (error, request, context) => { if (context?.previousFolders) { - const folderQueryKey = TanstackQueryClient.queryOptions( - 'get', - '/api/student/scrap/folder' - ).queryKey; + const folderQueryKey = SCRAP_QUERY_KEYS.folderList(); queryClient.setQueryData(folderQueryKey, context.previousFolders); } if (context?.previousQueries) { - context.previousQueries.forEach(([queryKey, data]) => { - queryClient.setQueryData(queryKey, data); - }); + rollbackOptimisticUpdate(queryClient, context.previousQueries); } }, // 성공/실패 관계없이 쿼리 무효화 (백그라운드에서 최신 데이터 가져오기) onSettled: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); // 휴지통 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + queryKey: SCRAP_QUERY_KEYS.trashList(), }); }, }); diff --git a/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts index b1a53d3f..4cfa3be8 100644 --- a/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts +++ b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { SCRAP_QUERY_KEYS } from './utils'; type PermanentDeleteRequest = paths['/api/student/scrap/trash']['delete']['requestBody']['content']['application/json']; @@ -17,7 +18,7 @@ export const usePermanentDeleteTrash = () => { onSuccess: () => { // 휴지통 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + queryKey: SCRAP_QUERY_KEYS.trashList(), }); }, }); diff --git a/apps/native/src/apis/controller/scrap/deleteScrap.ts b/apps/native/src/apis/controller/scrap/deleteScrap.ts index adb3a4a8..d79bd671 100644 --- a/apps/native/src/apis/controller/scrap/deleteScrap.ts +++ b/apps/native/src/apis/controller/scrap/deleteScrap.ts @@ -1,7 +1,13 @@ -import { useMutation, useQueryClient, QueryFilters } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; -import type { ScrapSearchResponse } from '@/features/student/scrap/utils/types'; +import { + optimisticDeleteScrap, + rollbackOptimisticUpdate, + invalidateScrapSearchQueries, + invalidateFolderScrapsQueries, + SCRAP_QUERY_KEYS, +} from './utils'; type DeleteScrapRequest = paths['/api/student/scrap']['delete']['requestBody']['content']['application/json']; @@ -21,85 +27,27 @@ export const useDeleteScrap = () => { }, // 낙관적 업데이트: 삭제 전 데이터 백업 및 즉시 UI 업데이트 onMutate: async (request) => { - const deletedIds = new Set(request.items.map((item) => `${item.type}-${item.id}`)); - - // 모든 검색 쿼리의 이전 데이터 백업 및 낙관적 업데이트 - const searchQueryFilters: QueryFilters = { - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }; - - // 진행 중인 쿼리 취소 - await queryClient.cancelQueries(searchQueryFilters); - - // 이전 데이터 백업 - const previousQueries = queryClient.getQueriesData(searchQueryFilters); - - // 낙관적 업데이트: 삭제된 항목을 즉시 제거 - queryClient.setQueriesData(searchQueryFilters, (old) => { - if (!old) return old; - - return { - folders: old.folders?.filter((folder) => !deletedIds.has(`FOLDER-${folder.id}`)), - scraps: old.scraps?.filter((scrap) => !deletedIds.has(`SCRAP-${scrap.id}`)), - }; - }); - - // 롤백을 위한 이전 데이터 반환 - return { previousQueries }; + return await optimisticDeleteScrap(queryClient, request.items); }, // 에러 발생 시 롤백 onError: (error, request, context) => { if (context?.previousQueries) { - context.previousQueries.forEach(([queryKey, data]) => { - queryClient.setQueryData(queryKey, data); - }); + rollbackOptimisticUpdate(queryClient, context.previousQueries); } }, // 성공/실패 관계없이 쿼리 무효화 (백그라운드에서 최신 데이터 가져오기) onSettled: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 폴더 내 스크랩 목록 갱신 (모든 폴더의 스크랩 목록 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/folder/') && - key[1].includes('/scraps') - ); - }, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 폴더 내 스크랩 목록 갱신 + invalidateFolderScrapsQueries(queryClient); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); // 휴지통 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, + queryKey: SCRAP_QUERY_KEYS.trashList(), }); }, }); diff --git a/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts index 4c36f692..72c8695c 100644 --- a/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateTrashMutationQueries } from './utils'; type UnscrapFromPointingRequest = paths['/api/student/scrap/from-pointing']['delete']['requestBody']['content']['application/json']; @@ -19,23 +20,8 @@ export const useUnscrapFromPointing = () => { }); }, onSuccess: () => { - // 휴지통 목록 갱신 - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 휴지통 및 검색 쿼리 갱신 + invalidateTrashMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts index f05d0f71..16ea7706 100644 --- a/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateTrashMutationQueries } from './utils'; type UnscrapFromProblemRequest = paths['/api/student/scrap/from-problem']['delete']['requestBody']['content']['application/json']; @@ -19,23 +20,8 @@ export const useUnscrapFromProblem = () => { }); }, onSuccess: () => { - // 휴지통 목록 갱신 - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 휴지통 및 검색 쿼리 갱신 + invalidateTrashMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateFolder.ts b/apps/native/src/apis/controller/scrap/postCreateFolder.ts index b98bb232..c5a108ad 100644 --- a/apps/native/src/apis/controller/scrap/postCreateFolder.ts +++ b/apps/native/src/apis/controller/scrap/postCreateFolder.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapSearchQueries, SCRAP_QUERY_KEYS } from './utils'; type CreateFolderRequest = paths['/api/student/scrap/folder']['post']['requestBody']['content']['application/json']; @@ -18,23 +19,12 @@ export const useCreateFolder = () => { return data as CreateFolderResponse; }, onSuccess: () => { - // 폴더 목록 갱신 (정확한 queryKey 사용) + // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrap.ts b/apps/native/src/apis/controller/scrap/postCreateScrap.ts index 71f5582c..d4c4cd7b 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrap.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrap.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapMutationQueries } from './utils'; type CreateScrapRequest = paths['/api/student/scrap']['post']['requestBody']['content']['application/json']; @@ -18,19 +19,8 @@ export const useCreateScrap = () => { return data as CreateScrapResponse; }, onSuccess: () => { - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts index c88db092..5ea82959 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapMutationQueries } from './utils'; type CreateScrapFromImageRequest = paths['/api/student/scrap/from-image']['post']['requestBody']['content']['application/json']; @@ -20,20 +21,8 @@ export const useCreateScrapFromImage = () => { return data as CreateScrapFromImageResponse; }, onSuccess: () => { - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - // openapi-react-query의 queryKey 구조: ['get', '/api/student/scrap/search/all', { params: ... }] - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts index 0a8e85ef..c32dbc57 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapMutationQueries } from './utils'; type CreateScrapFromPointingRequest = paths['/api/student/scrap/from-pointing']['post']['requestBody']['content']['application/json']; @@ -20,19 +21,8 @@ export const useCreateScrapFromPointing = () => { return data as CreateScrapFromPointingResponse; }, onSuccess: () => { - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts index 8ceb9253..a1d783b5 100644 --- a/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapMutationQueries } from './utils'; type CreateScrapFromProblemRequest = paths['/api/student/scrap/from-problem']['post']['requestBody']['content']['application/json']; @@ -20,19 +21,8 @@ export const useCreateScrapFromProblem = () => { return data as CreateScrapFromProblemResponse; }, onSuccess: () => { - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts index 097194ea..95c4a9f1 100644 --- a/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapMutationQueries } from './utils'; type ToggleScrapFromPointingRequest = paths['/api/student/scrap/toggle/from-pointing']['post']['requestBody']['content']['application/json']; @@ -20,19 +21,8 @@ export const useToggleScrapFromPointing = () => { return data as ToggleScrapFromPointingResponse; }, onSuccess: () => { - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts index cbc03da6..66875585 100644 --- a/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapMutationQueries } from './utils'; type ToggleScrapFromProblemRequest = paths['/api/student/scrap/toggle/from-problem']['post']['requestBody']['content']['application/json']; @@ -20,19 +21,8 @@ export const useToggleScrapFromProblem = () => { return data as ToggleScrapFromProblemResponse; }, onSuccess: () => { - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, - }); + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putRestoreTrash.ts b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts index 0549f82e..e7ebb765 100644 --- a/apps/native/src/apis/controller/scrap/putRestoreTrash.ts +++ b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateTrashMutationQueries, SCRAP_QUERY_KEYS } from './utils'; type RestoreTrashRequest = paths['/api/student/scrap/trash/restore']['put']['requestBody']['content']['application/json']; @@ -15,26 +16,11 @@ export const useRestoreTrash = () => { }); }, onSuccess: () => { - // 휴지통 목록 갱신 - queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/trash').queryKey, - }); + // 휴지통 및 검색 쿼리 갱신 + invalidateTrashMutationQueries(queryClient); // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); }, }); diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolder.ts b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts index 2799346d..f2088589 100644 --- a/apps/native/src/apis/controller/scrap/putUpdateFolder.ts +++ b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapSearchQueries, SCRAP_QUERY_KEYS } from './utils'; type UpdateFolderRequest = paths['/api/student/scrap/folder/{id}']['put']['requestBody']['content']['application/json']; @@ -28,21 +29,10 @@ export const useUpdateFolder = () => { onSuccess: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts b/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts index 1a6087ba..ceb5b6c3 100644 --- a/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts +++ b/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapSearchQueries, SCRAP_QUERY_KEYS } from './utils'; type UpdateFolderNameRequest = paths['/api/student/scrap/folder/{id}/name']['put']['requestBody']['content']['application/json']; @@ -31,21 +32,10 @@ export const useUpdateFolderName = () => { onSuccess: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts b/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts index 24f58c44..413aa2ad 100644 --- a/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts +++ b/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapSearchQueries, SCRAP_QUERY_KEYS } from './utils'; type UpdateFolderThumbnailRequest = paths['/api/student/scrap/folder/{id}/thumbnail']['put']['requestBody']['content']['application/json']; @@ -31,21 +32,10 @@ export const useUpdateFolderThumbnail = () => { onSuccess: () => { // 폴더 목록 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/folder').queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.folderList(), }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); }, }); }; diff --git a/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts index f923b1cd..7ce3a4ad 100644 --- a/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts +++ b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts @@ -1,6 +1,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query'; -import { client, TanstackQueryClient } from '@/apis/client'; +import { client } from '@/apis/client'; import { paths } from '@/types/api/schema'; +import { invalidateScrapSearchQueries, SCRAP_QUERY_KEYS } from './utils'; type UpdateScrapNameRequest = paths['/api/student/scrap/{scrapId}/name']['put']['requestBody']['content']['application/json']; @@ -31,25 +32,10 @@ export const useUpdateScrapName = () => { onSuccess: (_, { scrapId }) => { // 스크랩 상세 정보 갱신 queryClient.invalidateQueries({ - queryKey: TanstackQueryClient.queryOptions('get', '/api/student/scrap/{id}', { - params: { - path: { id: scrapId }, - }, - }).queryKey, - }); - // 검색 결과 갱신 (모든 검색 쿼리 무효화) - queryClient.invalidateQueries({ - predicate: (query) => { - const key = query.queryKey; - return ( - Array.isArray(key) && - key.length >= 2 && - key[0] === 'get' && - typeof key[1] === 'string' && - key[1].includes('/api/student/scrap/search') - ); - }, + queryKey: SCRAP_QUERY_KEYS.scrapDetail(scrapId), }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); }, }); }; diff --git a/apps/native/src/components/common/ImageWithSkeleton.tsx b/apps/native/src/components/common/ImageWithSkeleton.tsx index 917f5af3..3d039014 100644 --- a/apps/native/src/components/common/ImageWithSkeleton.tsx +++ b/apps/native/src/components/common/ImageWithSkeleton.tsx @@ -1,85 +1,9 @@ import { colors } from '@/theme/tokens'; -import React, { useEffect, useState, useRef } from 'react'; -import { View, Image, ImageProps, ImageStyle, DimensionValue, ViewStyle } from 'react-native'; -import { useAnimatedStyle, useSharedValue, withRepeat } from 'react-native-reanimated'; -import Animated, { withTiming, interpolate } from 'react-native-reanimated'; -import Svg, { Defs, LinearGradient, Stop, Rect } from 'react-native-svg'; - -type ImageSkeletonProps = { - width?: DimensionValue; - height?: DimensionValue; - aspectRatio?: number; - borderRadius?: number; - className?: string; - style?: ViewStyle; - uniqueId?: string | number; -}; - -export const ImageSkeleton = ({ - width = '100%', - height, - aspectRatio, - borderRadius = 10, - className = '', - style, - uniqueId = 'default', -}: ImageSkeletonProps) => { - const shimmerTranslateX = useSharedValue(-1); - - useEffect(() => { - shimmerTranslateX.value = -1; - shimmerTranslateX.value = withRepeat(withTiming(1, { duration: 1500 }), -1, false); - }, [shimmerTranslateX]); - - const shimmerAnimatedStyle = useAnimatedStyle(() => { - const translateX = interpolate(shimmerTranslateX.value, [-1, 1], [-200, 200]); - return { - transform: [{ translateX }], - }; - }); - - return ( - - - - - - - - - - - - - - - ); -}; +import React, { useMemo } from 'react'; +import { View, Image, ImageProps, ImageStyle, DimensionValue } from 'react-native'; type ImageWithSkeletonProps = { - source: ImageProps['source']; + source?: ImageProps['source'] | ImageProps['source'][]; width?: DimensionValue; height?: DimensionValue; aspectRatio?: number; @@ -89,6 +13,8 @@ type ImageWithSkeletonProps = { style?: ImageStyle; uniqueId?: string | number; fallback?: React.ReactNode; + /** 대각선 레이아웃 사용 여부 (true면 대각선 배치, false면 전체 영역에 표시) */ + isDiagonalLayout?: boolean; }; const ImageWithSkeletonComponent = ({ @@ -102,29 +28,30 @@ const ImageWithSkeletonComponent = ({ style, uniqueId = 'default', fallback, + isDiagonalLayout = false, }: ImageWithSkeletonProps) => { - // source.uri를 추출하여 의존성으로 사용 (객체 참조 문제 방지) - const imageUri = typeof source === 'object' && source && 'uri' in source ? source.uri : null; - - // useRef로 이미 로드된 URI 추적 (리렌더링에 영향받지 않음) - const loadedUriRef = useRef(null); - const [isImageLoading, setIsImageLoading] = useState(() => { - // 이미 로드된 이미지인지 확인 - return imageUri !== loadedUriRef.current; - }); + // source가 배열인지 확인 + const isSourceArray = Array.isArray(source); - // imageUri가 실제로 변경되었을 때만 로딩 상태 리셋 - useEffect(() => { - if (imageUri && imageUri !== loadedUriRef.current) { - setIsImageLoading(true); - } - }, [imageUri]); + // source 배열에서 이미지 URL 추출 (메모이제이션) + const imageUrls = useMemo(() => { + const sourceArray = isSourceArray ? source : source ? [source] : []; + return sourceArray + .map((s) => { + if (typeof s === 'object' && s && 'uri' in s) { + return s.uri as string; + } + return null; + }) + .filter((uri): uri is string => uri !== null); + }, [source, isSourceArray]); + // fallback 처리 if (!source && fallback) { return <>{fallback}; } - if (!source) { + if (!source && imageUrls.length === 0) { return ( - {isImageLoading && ( - 0) { + const singleImageSource = { uri: imageUrls[0] }; + return ( + + - )} - { - // 이미 로드된 이미지가 아닐 때만 로딩 상태로 변경 - if (imageUri && imageUri !== loadedUriRef.current) { - setIsImageLoading(true); - } - }} - onLoad={() => { - setIsImageLoading(false); - if (imageUri) { - loadedUriRef.current = imageUri; - } - }} - onError={(error) => { - console.warn('Image load error:', error.nativeEvent?.error || 'Unknown error'); - setIsImageLoading(false); - // 에러가 나도 같은 URI를 다시 로드하지 않도록 (무한 루프 방지) - if (imageUri) { - loadedUriRef.current = imageUri; - } - }} - /> - + + ); + } + + // 대각선 레이아웃일 때: 대각선 배치 + if (isDiagonalLayout && imageUrls.length > 0) { + const hasSecondImage = imageUrls.length > 1 && imageUrls[1]; + const imageToShow = hasSecondImage ? imageUrls[1] : imageUrls[0]; // 1개면 오른쪽 아래에 표시 + + return ( + + {/* 왼쪽 위: 2개면 첫 번째 이미지, 1개면 회색 배경 */} + {hasSecondImage ? ( + // 2개일 때: 왼쪽 위에 첫 번째 이미지 + + + + ) : ( + // 1개일 때: 왼쪽 위에 회색 배경 + + )} + + {/* 오른쪽 아래: 이미지 표시 (2개면 두 번째, 1개면 첫 번째) */} + {imageToShow && ( + + + + )} + + ); + } + + // source가 배열이 아닌 단일 이미지인 경우 (기존 로직 호환성) + if (!Array.isArray(source) && source) { + return ( + + + + ); + } + + // fallback + return ( + ); }; // React.memo로 감싸서 props가 변경되지 않으면 리렌더링 방지 export const ImageWithSkeleton = React.memo(ImageWithSkeletonComponent, (prevProps, nextProps) => { - // uniqueId와 source.uri가 같으면 리렌더링하지 않음 - const prevUri = typeof prevProps.source === 'object' && prevProps.source && 'uri' in prevProps.source - ? prevProps.source.uri - : null; - const nextUri = typeof nextProps.source === 'object' && nextProps.source && 'uri' in nextProps.source - ? nextProps.source.uri - : null; - + // source가 배열인지 확인 + const prevIsArray = Array.isArray(prevProps.source); + const nextIsArray = Array.isArray(nextProps.source); + + // source 배열 비교 + let sourceEqual = false; + if (prevIsArray && nextIsArray) { + const prevUris = prevProps.source + .map((s) => (typeof s === 'object' && s && 'uri' in s ? s.uri : null)) + .filter((uri): uri is string => uri !== null); + const nextUris = nextProps.source + .map((s) => (typeof s === 'object' && s && 'uri' in s ? s.uri : null)) + .filter((uri): uri is string => uri !== null); + sourceEqual = + prevUris.length === nextUris.length && prevUris.every((uri, idx) => uri === nextUris[idx]); + } else if (!prevIsArray && !nextIsArray) { + const prevUri = + typeof prevProps.source === 'object' && prevProps.source && 'uri' in prevProps.source + ? prevProps.source.uri + : null; + const nextUri = + typeof nextProps.source === 'object' && nextProps.source && 'uri' in nextProps.source + ? nextProps.source.uri + : null; + sourceEqual = prevUri === nextUri; + } else { + sourceEqual = false; + } + return ( prevProps.uniqueId === nextProps.uniqueId && - prevUri === nextUri && + sourceEqual && + prevProps.isDiagonalLayout === nextProps.isDiagonalLayout && prevProps.width === nextProps.width && prevProps.height === nextProps.height && prevProps.aspectRatio === nextProps.aspectRatio && 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 a0a7ef6a..ffc6504e 100644 --- a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -5,7 +5,7 @@ import { SearchResultCard } from './cards/SearchResultCard'; import { TrashCard } from './cards/TrashCard'; import { ScrapAddItem, ScrapReviewItem } from './cards/ScrapHeadCard'; import { ScrapItem, TrashItem } from '@/features/student/scrap/utils/types'; -import { useGridLayout } from '../../utils/gridLayout'; +import { useGridLayout } from '../../utils/layout/gridLayout'; import { useState } from 'react'; /** 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 0651c5de..07c05367 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 @@ -6,7 +6,7 @@ import { StudentRootStackParamList } from '@/navigation/student/types'; import type { ScrapDetailResp } from '@/features/student/scrap/utils/types'; import { useNoteStore } from '@/stores/scrapNoteStore'; import { useRecentScrapStore } from '@/stores/recentScrapStore'; -import { formatToMinute } from '../../../utils/formatToMinute'; +import { formatToMinute } from '../../../utils/formatters/formatToMinute'; type RecentScrapCardProps = { scrap: ScrapDetailResp & { type: 'SCRAP' }; 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 1dc74c2f..0937171d 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 @@ -1,5 +1,5 @@ import { Pressable, View, Text, Image } from 'react-native'; -import React, { useMemo } from 'react'; +import React from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; import { TooltipPopover, ItemTooltipBox } from '../../Tooltip'; @@ -13,8 +13,9 @@ import { useRecentScrapStore } from '@/stores/recentScrapStore'; import { MoveScrapModal } from '../../Modal/MoveScrapModal'; import { colors } from '@/theme/tokens'; import { ImageWithSkeleton } from '@/components/common'; -import { formatToMinute } from '../../../utils/formatToMinute'; -import { useScrapModal } from '../../../contexts/ScrapModalContext'; +import { formatToMinute } from '../../../utils/formatters/formatToMinute'; +import { useScrapModal } from '../../../contexts/ScrapModalsContext'; +import { useCardImageSources } from '../../../hooks'; export const ScrapCard = (props: ScrapListItemProps) => { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; @@ -24,30 +25,11 @@ export const ScrapCard = (props: ScrapListItemProps) => { const addScrap = useRecentScrapStore((state) => state.addScrap); const { openMoveScrapModal } = useScrapModal(); - // 폴더일 때 top2ScrapThumbnail 추출 const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; - - const { imageSources, isDiagonalLayout } = useMemo(() => { - // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) - if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { - return { - imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), - isDiagonalLayout: true, - }; - } - - if (props.thumbnailUrl) { - return { - imageSources: [{ uri: props.thumbnailUrl }], - isDiagonalLayout: false, - }; - } - - return { - imageSources: undefined, - isDiagonalLayout: false, - }; - }, [props.thumbnailUrl, folderTop2Thumbnail]); + const { imageSources, isDiagonalLayout } = useCardImageSources( + props.thumbnailUrl, + folderTop2Thumbnail + ); const cardContent = ( 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 4db6674b..67017870 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 @@ -12,7 +12,8 @@ import { useState } from 'react'; import { CreateFolderModal } from '../../Modal/CreateFolderModal'; import { LoadQnaImageModal } from '../../Modal/LoadQnaImageModal'; import { State } from '../../../utils/reducer'; -import { useScrapModal } from '../../../contexts/ScrapModalContext'; +import { useScrapModal } from '../../../contexts/ScrapModalsContext'; +import { formatToMinute } from '../../../utils/formatters/formatToMinute'; export const ScrapAddItem = ({ reducerState }: { reducerState: State }) => { const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); @@ -94,9 +95,7 @@ export const ScrapReviewItem = ({ props }: { props: ScrapListItemProps }) => { {props.scrapCount} )} - - {new Date(props.createdAt).toLocaleDateString()} - + {formatToMinute(new Date(props.createdAt))} ); 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 8baa20bd..7204016f 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 @@ -5,37 +5,19 @@ import { useNavigation } from '@react-navigation/native'; import { useGetFolderDetail } from '@/apis'; import { useNoteStore } from '@/stores/scrapNoteStore'; import { ImageWithSkeleton } from '@/components/common/ImageWithSkeleton'; -import { useMemo } from 'react'; import type { ScrapListItemProps } from '../types'; +import { useCardImageSources } from '../../../hooks'; +import { formatToMinute } from '../../../utils/formatters/formatToMinute'; export const SearchResultCard = (props: ScrapListItemProps) => { const navigation = useNavigation>(); - const openNote = useNoteStore((state) => state.openNote); const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; - - const { imageSources, isDiagonalLayout } = useMemo(() => { - // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) - if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { - return { - imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), - isDiagonalLayout: true, - }; - } - - if (props.thumbnailUrl) { - return { - imageSources: [{ uri: props.thumbnailUrl }], - isDiagonalLayout: false, - }; - } - - return { - imageSources: undefined, - isDiagonalLayout: false, - }; - }, [props.thumbnailUrl, folderTop2Thumbnail]); + const { imageSources, isDiagonalLayout } = useCardImageSources( + props.thumbnailUrl, + folderTop2Thumbnail + ); const cardContent = ( @@ -65,8 +47,8 @@ export const SearchResultCard = (props: ScrapListItemProps) => { {props.updatedAt - ? new Date(props.updatedAt).toLocaleString('ko-kr') - : new Date(props.createdAt).toLocaleString('ko-kr')} + ? formatToMinute(new Date(props.updatedAt)) + : formatToMinute(new Date(props.createdAt))} diff --git a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx index ad3760c5..e0dbdd62 100644 --- a/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -1,15 +1,16 @@ import { Pressable, View, Text } from 'react-native'; -import React, { useMemo, useState } from 'react'; +import React, { useState } from 'react'; import { Check } from 'lucide-react-native'; import { ChevronDownFilledIcon } from '@/components/system/icons'; import { TooltipPopover, TrashItemTooltipBox } from '../../Tooltip'; -import PopUpModal from '../../Modal/PopupModal'; -import { showToast } from '../../Modal/Toast'; +import { PopUpModal } from '../../Dialog'; +import { showToast } from '../../Notification/Toast'; import { usePermanentDeleteTrash } from '@/apis'; import type { TrashListItemProps } from '../types'; import { isItemSelected } from '../../../utils/reducer'; import { ImageWithSkeleton } from '@/components/common/ImageWithSkeleton'; import { colors } from '@/theme/tokens'; +import { useCardImageSources } from '../../../hooks'; export const TrashCard = (props: TrashListItemProps) => { const state = props.reducerState ?? { isSelecting: false, selectedItems: [] }; @@ -17,30 +18,11 @@ export const TrashCard = (props: TrashListItemProps) => { const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); - // 폴더일 때 top2ScrapThumbnail 추출 const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; - - const { imageSources, isDiagonalLayout } = useMemo(() => { - // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) - if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { - return { - imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), - isDiagonalLayout: true, - }; - } - - if (props.thumbnailUrl) { - return { - imageSources: [{ uri: props.thumbnailUrl }], - isDiagonalLayout: false, - }; - } - - return { - imageSources: undefined, - isDiagonalLayout: false, - }; - }, [props.thumbnailUrl, folderTop2Thumbnail]); + const { imageSources, isDiagonalLayout } = useCardImageSources( + props.thumbnailUrl, + folderTop2Thumbnail + ); const cardContent = ( diff --git a/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx b/apps/native/src/features/student/scrap/components/Dialog/ConfirmationDialog.tsx similarity index 84% rename from apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx rename to apps/native/src/features/student/scrap/components/Dialog/ConfirmationDialog.tsx index bd131adf..89d501e3 100644 --- a/apps/native/src/features/student/scrap/components/Modal/PopupModal.tsx +++ b/apps/native/src/features/student/scrap/components/Dialog/ConfirmationDialog.tsx @@ -1,6 +1,6 @@ import { Modal, TouchableWithoutFeedback, View } from 'react-native'; -const PopUpModal = ({ +const ConfirmationDialog = ({ className, children, visibleState, @@ -27,4 +27,8 @@ const PopUpModal = ({ ); }; -export default PopUpModal; + +// Backward compatibility +export const PopUpModal = ConfirmationDialog; + +export default ConfirmationDialog; diff --git a/apps/native/src/features/student/scrap/components/Dialog/index.ts b/apps/native/src/features/student/scrap/components/Dialog/index.ts new file mode 100644 index 00000000..fe53a849 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Dialog/index.ts @@ -0,0 +1 @@ +export { default as ConfirmationDialog, PopUpModal } from './ConfirmationDialog'; diff --git a/apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/Dropdown/SortDropdown.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Modal/SortDropdown.tsx rename to apps/native/src/features/student/scrap/components/Dropdown/SortDropdown.tsx diff --git a/apps/native/src/features/student/scrap/components/Dropdown/index.ts b/apps/native/src/features/student/scrap/components/Dropdown/index.ts new file mode 100644 index 00000000..0a7618cf --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Dropdown/index.ts @@ -0,0 +1 @@ +export { default as SortDropdown, orderList, orderContent, orderImage } from './SortDropdown'; diff --git a/apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx b/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Header/DeletedHeader.tsx rename to apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx 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 808581c9..8dfc714c 100644 --- a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -8,34 +8,45 @@ import { colors } from '@/theme/tokens'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; -interface ScrapHeaderProps { - navigateback?: NativeStackNavigationProp; - title?: string; - reducerState: State; - navigateSearchPress?: () => void; - navigateTrashPress?: () => void; +export interface ScrapHeaderActions { + /** 검색 화면으로 이동 */ + onSearchPress?: () => void; + /** 휴지통 화면으로 이동 */ + onTrashPress?: () => void; + /** 선택 모드 진입 */ onEnterSelection?: () => void; + /** 선택 모드 종료 */ onExitSelection?: () => void; + /** 선택된 아이템 이동 */ onMove?: () => void; + /** 선택된 아이템 삭제 */ onDelete?: () => void; - isAllSelected?: boolean; + /** 전체 선택/해제 */ onSelectAll?: () => void; } +interface ScrapHeaderProps { + /** 뒤로가기 네비게이션 (옵션) */ + navigateback?: NativeStackNavigationProp; + /** 헤더 제목 */ + title?: string; + /** 선택 상태 */ + reducerState: State; + /** 전체 선택 여부 */ + isAllSelected?: boolean; + /** 액션 핸들러 객체 */ + actions: ScrapHeaderActions; +} + const ScrapHeader = ({ navigateback, title = '스크랩', reducerState, - navigateSearchPress, - navigateTrashPress, - onEnterSelection, - onExitSelection, - onMove, - onDelete, isAllSelected, - onSelectAll, + actions, }: ScrapHeaderProps) => { const isActionEnabled = reducerState.selectedItems.length > 0; + return ( + onPress={actions.onSearchPress}> + onPress={actions.onEnterSelection}> + onPress={actions.onTrashPress}> @@ -75,13 +86,13 @@ const ScrapHeader = ({ {reducerState.isSelecting && ( - + {!isAllSelected ? '전체 선택' : '전체 해제'} {title} - + 완료 @@ -90,7 +101,7 @@ const ScrapHeader = ({ { - if (isActionEnabled && onMove) onMove(); + 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'}`}> @@ -98,7 +109,7 @@ const ScrapHeader = ({ { - if (isActionEnabled && onDelete) onDelete(); + 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'}`}> diff --git a/apps/native/src/features/student/scrap/components/Header/SearchHeader.tsx b/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Header/SearchHeader.tsx rename to apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx diff --git a/apps/native/src/features/student/scrap/components/Header/index.ts b/apps/native/src/features/student/scrap/components/Header/index.ts new file mode 100644 index 00000000..c0f00e0a --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Header/index.ts @@ -0,0 +1,3 @@ +export { default as DeletedScrapHeader, default as DeletedHeader } from './DeletedScrapHeader'; +export { default as ScrapHeader } from './ScrapHeader'; +export { default as SearchScrapHeader, default as SearchHeader } from './SearchScrapHeader'; 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 1d80fe43..eddc0341 100644 --- a/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -2,14 +2,14 @@ import React, { useState, useEffect } from 'react'; import { View, Pressable, Image, TextInput, KeyboardAvoidingView, Platform } from 'react-native'; import { AddFolderScreenModal } from './FullScreenModal'; import { useCreateFolder } from '@/apis'; -import { showToast } from './Toast'; -import { openImageLibraryWithErrorHandling } from '../../utils/imagePicker'; +import { showToast } from '../Notification/Toast'; +import { openImageLibraryWithErrorHandling } from '../../utils/images/imagePicker'; import { colors } from '@/theme/tokens'; -import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; -import { uploadImageToS3 } from '../../utils/imageUpload'; +import { uploadImageToS3 } from '../../utils/images/imageUpload'; import * as ImagePicker from 'expo-image-picker'; import { ImageIcon } from 'lucide-react-native'; -import { useScrapModal } from '../../contexts/ScrapModalContext'; +import { useScrapModal } from '../../contexts/ScrapModalsContext'; +import { usePreSignedUrlAdapter } from '../../hooks'; export const CreateFolderModal = () => { const { isCreateFolderModalVisible, closeCreateFolderModal, refetchFolders, refetchScraps } = @@ -17,31 +17,7 @@ export const CreateFolderModal = () => { const [folderName, setFolderName] = useState(''); const [selectedImage, setSelectedImage] = useState(null); const { mutateAsync: createFolder } = useCreateFolder(); - const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); - - // mutate를 래핑하여 uploadImageToS3가 기대하는 형식으로 변환 - const getPreSignedUrl = ( - params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, - callbacks: { - onSuccess: (data: { - uploadUrl: string; - contentDisposition: string; - file: { id: number }; - }) => void; - onError: (error: any) => void; - } - ) => { - getPreSignedUrlMutate(params, { - onSuccess: (data) => { - callbacks.onSuccess({ - uploadUrl: data.uploadUrl, - contentDisposition: data.contentDisposition, - file: { id: data.file.id }, - }); - }, - onError: callbacks.onError, - }); - }; + const getPreSignedUrl = usePreSignedUrlAdapter(); // 모달이 닫힐 때 상태 초기화 useEffect(() => { diff --git a/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx index 7307fed0..0619953d 100644 --- a/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx @@ -2,7 +2,7 @@ import { Modal, View, Pressable, Text } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { BlurView } from 'expo-blur'; import Toast from 'react-native-toast-message'; -import { toastConfig } from './Toast'; +import { toastConfig } from '../Notification/Toast'; interface FullScreenModalProps { visible: boolean; diff --git a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx index 9c65bda8..fb436515 100644 --- a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx @@ -1,13 +1,13 @@ import React, { useMemo, useCallback, useEffect } from 'react'; import { View, Text, Pressable, ScrollView } from 'react-native'; import { FolderPlus } from 'lucide-react-native'; -import PopUpModal from './PopupModal'; +import { PopUpModal } from '../Dialog'; import { ScrapGrid } from '../Card/ScrapCardGrid'; import { useGetFolders, useMoveScraps } from '@/apis'; -import { showToast } from './Toast'; +import { showToast } from '../Notification/Toast'; import { reducer, initialSelectionState } from '../../utils/reducer'; import { useReducer } from 'react'; -import { useScrapModal } from '../../contexts/ScrapModalContext'; +import { useScrapModal } from '../../contexts/ScrapModalsContext'; export const MoveScrapModal = () => { const { diff --git a/apps/native/src/features/student/scrap/components/Modal/index.ts b/apps/native/src/features/student/scrap/components/Modal/index.ts new file mode 100644 index 00000000..6467f4fa --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/index.ts @@ -0,0 +1,4 @@ +export { CreateFolderModal } from './CreateFolderModal'; +export { AddFolderScreenModal, LoadQnaImageScreenModal } from './FullScreenModal'; +export { LoadQnaImageModal } from './LoadQnaImageModal'; +export { MoveScrapModal } from './MoveScrapModal'; diff --git a/apps/native/src/features/student/scrap/components/Modal/Toast.tsx b/apps/native/src/features/student/scrap/components/Notification/Toast.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/Modal/Toast.tsx rename to apps/native/src/features/student/scrap/components/Notification/Toast.tsx diff --git a/apps/native/src/features/student/scrap/components/Notification/index.ts b/apps/native/src/features/student/scrap/components/Notification/index.ts new file mode 100644 index 00000000..82195232 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Notification/index.ts @@ -0,0 +1 @@ +export { showToast, toastConfig } from './Toast'; 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 f7cf8bd3..4337bb8c 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx @@ -5,48 +5,27 @@ import { openCameraWithErrorHandling, openImageLibrary, openImageLibraryWithErrorHandling, -} from '../../utils/imagePicker'; -import { useGetPreSignedUrl } from '@/apis/controller/common'; +} from '../../utils/images/imagePicker'; import { useCreateScrapFromImage } from '@/apis'; -import { uploadImageToS3 } from '../../utils/imageUpload'; +import { uploadImageToS3 } from '../../utils/images/imageUpload'; +import { usePreSignedUrlAdapter } from '../../hooks'; -export interface AddItemTooltipProps { +export interface AddScrapTooltipProps { onClose?: () => void; onOpenFolderModal?: () => void; onOpenQnaImgModal?: () => void; } -export const AddItemTooltip = ({ +// Backward compatibility +export type AddItemTooltipProps = AddScrapTooltipProps; + +export const AddScrapTooltip = ({ onClose, onOpenQnaImgModal, onOpenFolderModal, -}: AddItemTooltipProps) => { - const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); +}: AddScrapTooltipProps) => { const { mutate: createScrapFromImage } = useCreateScrapFromImage(); - - // mutate를 래핑하여 uploadImageToS3가 기대하는 형식으로 변환 - const getPreSignedUrl = ( - params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, - callbacks: { - onSuccess: (data: { - uploadUrl: string; - contentDisposition: string; - file: { id: number }; - }) => void; - onError: (error: any) => void; - } - ) => { - getPreSignedUrlMutate(params, { - onSuccess: (data) => { - callbacks.onSuccess({ - uploadUrl: data.uploadUrl, - contentDisposition: data.contentDisposition, - file: { id: data.file.id }, - }); - }, - onError: callbacks.onError, - }); - }; + const getPreSignedUrl = usePreSignedUrlAdapter(); // 이미지 선택 및 업로드 처리 const handleImageSelect = async (image: any) => { @@ -143,3 +122,6 @@ export const AddItemTooltip = ({ ); }; + +// Backward compatibility +export const AddItemTooltip = AddScrapTooltip; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx index d454401d..f9baf542 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx @@ -1,43 +1,49 @@ import { FolderOpen } from 'lucide-react-native'; -import { View, Text, Pressable } from 'react-native'; +import { View, Text } from 'react-native'; import { ScrapListItemProps } from '../Card/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { StudentRootStackParamList } from '@/navigation/student/types'; +import { TooltipContainer } from './TooltipContainer'; +import { TooltipMenuItem } from './TooltipMenuItem'; -export interface ReviewItemTooltipProps { +export interface ReviewScrapTooltipProps { props: ScrapListItemProps; onClose?: () => void; } -export const ReviewItemTooltip = ({ props, onClose }: ReviewItemTooltipProps) => { +// Backward compatibility +export type ReviewItemTooltipProps = ReviewScrapTooltipProps; + +export const ReviewScrapTooltip = ({ props, onClose }: ReviewScrapTooltipProps) => { const navigation = useNavigation>(); - const handleClose = () => { + const handleOpenReview = () => { onClose?.(); + setTimeout(() => { + navigation.push('ScrapContent', { id: props.id }); + }, 100); }; return ( - - + 오답노트 - - { - handleClose(); - // Popover가 닫히는 시간을 주기 위해 약간의 지연 - setTimeout(() => { - navigation.push('ScrapContent', { id: props.id }); - }, 100); - }}> - - 오답노트 열기 - - + }> + } + label='오답노트 열기' + onPress={handleOpenReview} + isLastItem + /> + ); }; + +// Backward compatibility +export const ReviewItemTooltip = ReviewScrapTooltip; 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 5163a6e4..40f62d15 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx @@ -10,7 +10,7 @@ import { } from 'lucide-react-native'; import { useState } from 'react'; import { TextInput, View, Text, Pressable, Alert } from 'react-native'; -import { showToast } from '../Modal/Toast'; +import { showToast } from '../Notification/Toast'; import { ScrapListItemProps } from '../Card/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; @@ -25,17 +25,23 @@ import { useGetFolders, } from '@/apis'; import { useNoteStore } from '@/stores/scrapNoteStore'; -import { useGetPreSignedUrl } from '@/apis/controller/common/postGetPreSignedUrl'; -import { openImageLibrary, openImageLibraryWithErrorHandling } from '../../utils/imagePicker'; -import { uploadImageToS3 } from '../../utils/imageUpload'; +import { + openImageLibrary, + openImageLibraryWithErrorHandling, +} from '../../utils/images/imagePicker'; +import { uploadImageToS3 } from '../../utils/images/imageUpload'; +import { usePreSignedUrlAdapter } from '../../hooks'; -export interface ItemTooltipProps { +export interface ScrapItemTooltipProps { props: ScrapListItemProps; onClose?: () => void; onMovePress?: () => void; // 추가 } -export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) => { +// Backward compatibility +export type ItemTooltipProps = ScrapItemTooltipProps; + +export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemTooltipProps) => { const navigation = useNavigation>(); const openNote = useNoteStore((state) => state.openNote); @@ -50,31 +56,7 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = const { data: scrapDetail } = useGetScrapDetail(Number(props.id), props.type === 'SCRAP'); const { data: foldersData } = useGetFolders(); - const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); - - // mutate를 래핑하여 uploadImageToS3가 기대하는 형식으로 변환 - const getPreSignedUrl = ( - params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, - callbacks: { - onSuccess: (data: { - uploadUrl: string; - contentDisposition: string; - file: { id: number }; - }) => void; - onError: (error: any) => void; - } - ) => { - getPreSignedUrlMutate(params, { - onSuccess: (data) => { - callbacks.onSuccess({ - uploadUrl: data.uploadUrl, - contentDisposition: data.contentDisposition, - file: { id: data.file.id }, - }); - }, - onError: callbacks.onError, - }); - }; + const getPreSignedUrl = usePreSignedUrlAdapter(); const handleUpdateFolderCover = async (image: any) => { if (!image || !image.uri) { @@ -232,3 +214,6 @@ export const ItemTooltip = ({ props, onClose, onMovePress }: ItemTooltipProps) = ); }; + +// Backward compatibility +export const ItemTooltip = ScrapItemTooltip; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TooltipContainer.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TooltipContainer.tsx new file mode 100644 index 00000000..5957976d --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/TooltipContainer.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { View } from 'react-native'; + +export interface TooltipContainerProps { + /** 높이 클래스 (예: 'h-[88px]', 'h-[176px]') */ + height?: string; + /** 헤더 영역 (옵션) */ + header?: React.ReactNode; + /** 메뉴 아이템들 */ + children: React.ReactNode; +} + +/** + * Tooltip 컨테이너 공통 컴포넌트 + * + * @example + * } + * > + * + * + * + */ +export const TooltipContainer = ({ + height = 'h-[88px]', + header, + children, +}: TooltipContainerProps) => { + return ( + + {header && ( + + {header} + + )} + {children} + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TooltipMenuItem.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TooltipMenuItem.tsx new file mode 100644 index 00000000..fadff548 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/TooltipMenuItem.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Pressable, View, Text } from 'react-native'; + +export interface TooltipMenuItemProps { + /** 아이콘 컴포넌트 */ + icon: React.ReactNode; + /** 메뉴 라벨 */ + label: string; + /** 클릭 핸들러 */ + onPress: () => void; + /** 텍스트 색상 클래스 (기본: text-black) */ + textColor?: string; + /** 마지막 아이템 여부 (border 제거용) */ + isLastItem?: boolean; + /** 위험한 동작 여부 (빨간색 스타일) */ + isDangerous?: boolean; +} + +/** + * Tooltip 메뉴 아이템 공통 컴포넌트 + * + * @example + * } + * label="삭제" + * onPress={handleDelete} + * isDangerous + * isLastItem + * /> + */ +export const TooltipMenuItem = ({ + icon, + label, + onPress, + textColor = 'text-black', + isLastItem = false, + isDangerous = false, +}: TooltipMenuItemProps) => { + return ( + + {icon} + {label} + + ); +}; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx index ebf62094..fa7b22bc 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx +++ b/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx @@ -1,59 +1,66 @@ import { colors } from '@/theme/tokens'; import { Trash2, Undo2 } from 'lucide-react-native'; -import { View, Text, Pressable } from 'react-native'; -import { showToast } from '../Modal/Toast'; +import { showToast } from '../Notification/Toast'; import { useRestoreTrash } from '@/apis'; import type { TrashListItemProps } from '../Card/types'; +import { TooltipContainer } from './TooltipContainer'; +import { TooltipMenuItem } from './TooltipMenuItem'; -export interface TrashItemTooltipProps { +export interface TrashScrapTooltipProps { item: TrashListItemProps; onClose?: () => void; onDeletePress?: () => void; } -export const TrashItemTooltip = ({ item, onClose, onDeletePress }: TrashItemTooltipProps) => { +// Backward compatibility +export type TrashItemTooltipProps = TrashScrapTooltipProps; + +export const TrashScrapTooltip = ({ item, onClose, onDeletePress }: TrashScrapTooltipProps) => { const { mutateAsync: restoreTrash } = useRestoreTrash(); - const handleClose = () => { - onClose?.(); + const handlePermanentDelete = async () => { + await new Promise((resolve) => setTimeout(resolve, 100)); + if (onDeletePress) { + onDeletePress(); + } else { + onClose?.(); + } + }; + + const handleRestore = async () => { + try { + await restoreTrash({ + items: [ + { + id: item.id, + type: item.type as 'FOLDER' | 'SCRAP', + }, + ], + } as any); + onClose?.(); + showToast('success', '선택된 파일이 복구되었습니다.'); + } catch (error) { + showToast('error', '복구 중 오류가 발생했습니다.'); + } }; return ( - - { - await new Promise((resolve) => setTimeout(resolve, 100)); - if (onDeletePress) { - onDeletePress(); - } else { - handleClose(); - } - }}> - - 영구 삭제하기 - - { - try { - await restoreTrash({ - items: [ - { - id: item.id, - type: item.type as 'FOLDER' | 'SCRAP', - }, - ], - } as any); - handleClose(); - showToast('success', '선택된 파일이 복구되었습니다.'); - } catch (error) { - showToast('error', '복구 중 오류가 발생했습니다.'); - } - }}> - - 복구하기 - - + + } + label='영구 삭제하기' + onPress={handlePermanentDelete} + isDangerous + /> + } + label='복구하기' + onPress={handleRestore} + isLastItem + /> + ); }; + +// Backward compatibility +export const TrashItemTooltip = TrashScrapTooltip; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/index.ts b/apps/native/src/features/student/scrap/components/Tooltip/index.ts index 781848c1..55d120af 100644 --- a/apps/native/src/features/student/scrap/components/Tooltip/index.ts +++ b/apps/native/src/features/student/scrap/components/Tooltip/index.ts @@ -1,20 +1,20 @@ export { default as TooltipPopover } from './TooltipPopover'; export type { TooltipPopoverProps } from './TooltipPopover'; -export { ItemTooltip } from './ItemTooltip'; -export type { ItemTooltipProps } from './ItemTooltip'; +export { ScrapItemTooltip, ItemTooltip } from './ScrapItemTooltip'; +export type { ScrapItemTooltipProps, ItemTooltipProps } from './ScrapItemTooltip'; -export { AddItemTooltip } from './AddItemTooltip'; -export type { AddItemTooltipProps } from './AddItemTooltip'; +export { AddScrapTooltip, AddItemTooltip } from './AddScrapTooltip'; +export type { AddScrapTooltipProps, AddItemTooltipProps } from './AddScrapTooltip'; -export { ReviewItemTooltip } from './ReviewItemTooltip'; -export type { ReviewItemTooltipProps } from './ReviewItemTooltip'; +export { ReviewScrapTooltip, ReviewItemTooltip } from './ReviewScrapTooltip'; +export type { ReviewScrapTooltipProps, ReviewItemTooltipProps } from './ReviewScrapTooltip'; -export { TrashItemTooltip } from './TrashItemTooltip'; -export type { TrashItemTooltipProps } from './TrashItemTooltip'; +export { TrashScrapTooltip, TrashItemTooltip } from './TrashScrapTooltip'; +export type { TrashScrapTooltipProps, TrashItemTooltipProps } from './TrashScrapTooltip'; // 하위 호환성을 위한 별칭 export -export { ItemTooltip as ItemTooltipBox } from './ItemTooltip'; -export { AddItemTooltip as AddItemTooltipBox } from './AddItemTooltip'; -export { ReviewItemTooltip as ReviewItemTooltipBox } from './ReviewItemTooltip'; -export { TrashItemTooltip as TrashItemTooltipBox } from './TrashItemTooltip'; +export { ScrapItemTooltip as ItemTooltipBox } from './ScrapItemTooltip'; +export { AddScrapTooltip as AddItemTooltipBox } from './AddScrapTooltip'; +export { ReviewScrapTooltip as ReviewItemTooltipBox } from './ReviewScrapTooltip'; +export { TrashScrapTooltip as TrashItemTooltipBox } from './TrashScrapTooltip'; diff --git a/apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx b/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx similarity index 75% rename from apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx rename to apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx index e43c2c99..61c96889 100644 --- a/apps/native/src/features/student/scrap/contexts/ScrapModalContext.tsx +++ b/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx @@ -1,7 +1,7 @@ import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; import type { SelectedItem } from '../utils/reducer'; -interface ScrapModalContextValue { +interface ScrapModalsContextValue { // CreateFolderModal 상태 isCreateFolderModalVisible: boolean; openCreateFolderModal: () => void; @@ -25,21 +25,30 @@ interface ScrapModalContextValue { setRefetchScraps: (refetch: () => void) => void; } -const ScrapModalContext = createContext(undefined); +// Backward compatibility +export type ScrapModalContextValue = ScrapModalsContextValue; -export const useScrapModal = () => { - const context = useContext(ScrapModalContext); +const ScrapModalsContext = createContext(undefined); + +// Backward compatibility +const ScrapModalContext = ScrapModalsContext; + +export const useScrapModals = () => { + const context = useContext(ScrapModalsContext); if (!context) { - throw new Error('useScrapModal must be used within ScrapModalProvider'); + throw new Error('useScrapModals must be used within ScrapModalsProvider'); } return context; }; -interface ScrapModalProviderProps { +// Backward compatibility +export const useScrapModal = useScrapModals; + +interface ScrapModalsProviderProps { children: ReactNode; } -export const ScrapModalProvider = ({ children }: ScrapModalProviderProps) => { +export const ScrapModalsProvider = ({ children }: ScrapModalsProviderProps) => { const [isCreateFolderModalVisible, setIsCreateFolderModalVisible] = useState(false); const [isMoveScrapModalVisible, setIsMoveScrapModalVisible] = useState(false); const [moveScrapModalProps, setMoveScrapModalProps] = useState<{ @@ -93,5 +102,8 @@ export const ScrapModalProvider = ({ children }: ScrapModalProviderProps) => { setRefetchScraps, }; - return {children}; + return {children}; }; + +// Backward compatibility +export const ScrapModalProvider = ScrapModalsProvider; diff --git a/apps/native/src/features/student/scrap/hoc/index.ts b/apps/native/src/features/student/scrap/hoc/index.ts new file mode 100644 index 00000000..db79ab13 --- /dev/null +++ b/apps/native/src/features/student/scrap/hoc/index.ts @@ -0,0 +1 @@ +export { withScrapModals } from './withScrapModals'; diff --git a/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx b/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx new file mode 100644 index 00000000..16a65766 --- /dev/null +++ b/apps/native/src/features/student/scrap/hoc/withScrapModals.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import { ScrapModalProvider } from '../contexts/ScrapModalsContext'; +import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; +import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; + +/** + * 스크랩 모달들을 자동으로 추가하는 HOC (Higher-Order Component) + * + * ScrapModalProvider와 CreateFolderModal, MoveScrapModal을 자동으로 감싸줍니다. + * + * @param Component - 감쌀 컴포넌트 + * @returns 모달이 추가된 컴포넌트 + * + * @example + * const ScrapScreen = () => { + * return ; + * }; + * + * export default withScrapModals(ScrapScreen); + */ +export const withScrapModals =

(Component: React.ComponentType

) => { + const WrappedComponent = (props: P) => { + return ( + + + + + + ); + }; + + WrappedComponent.displayName = `withScrapModals(${Component.displayName || Component.name || 'Component'})`; + + return WrappedComponent; +}; diff --git a/apps/native/src/features/student/scrap/hooks/index.ts b/apps/native/src/features/student/scrap/hooks/index.ts new file mode 100644 index 00000000..8688f017 --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/index.ts @@ -0,0 +1,3 @@ +export { usePreSignedUrlAdapter } from './usePreSignedUrlAdapter'; +export { useCardImageSources } from './useCardImageSources'; +export { useScrapSelection } from './useScrapSelection'; diff --git a/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts b/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts new file mode 100644 index 00000000..d66f4575 --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts @@ -0,0 +1,49 @@ +import { useMemo } from 'react'; +import { ImageSourcePropType } from 'react-native'; + +interface CardImageSourcesResult { + imageSources: ImageSourcePropType[] | undefined; + isDiagonalLayout: boolean; +} + +/** + * 카드의 이미지 소스와 레이아웃을 계산하는 훅 + * + * @param thumbnailUrl - 썸네일 URL (스크랩 또는 폴더) + * @param folderTop2Thumbnail - 폴더의 상위 2개 스크랩 썸네일 (폴더인 경우) + * @returns imageSources와 isDiagonalLayout + * + * @example + * // 폴더 카드 + * const folderTop2 = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; + * const { imageSources, isDiagonalLayout } = useCardImageSources(props.thumbnailUrl, folderTop2); + * + * // 스크랩 카드 + * const { imageSources, isDiagonalLayout } = useCardImageSources(props.thumbnailUrl); + */ +export const useCardImageSources = ( + thumbnailUrl?: string, + folderTop2Thumbnail?: string[] +): CardImageSourcesResult => { + return useMemo(() => { + // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) + if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { + return { + imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), + isDiagonalLayout: true, + }; + } + + if (thumbnailUrl) { + return { + imageSources: [{ uri: thumbnailUrl }], + isDiagonalLayout: false, + }; + } + + return { + imageSources: undefined, + isDiagonalLayout: false, + }; + }, [thumbnailUrl, folderTop2Thumbnail]); +}; diff --git a/apps/native/src/features/student/scrap/hooks/usePreSignedUrlAdapter.ts b/apps/native/src/features/student/scrap/hooks/usePreSignedUrlAdapter.ts new file mode 100644 index 00000000..7a85355c --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/usePreSignedUrlAdapter.ts @@ -0,0 +1,45 @@ +import { useGetPreSignedUrl } from '@/apis/controller/common/'; + +/** + * PreSignedURL mutation을 래핑하여 uploadImageToS3가 기대하는 형식으로 변환하는 훅 + * + * @returns getPreSignedUrl 함수를 반환 + * + * @example + * const getPreSignedUrl = usePreSignedUrlAdapter(); + * + * await uploadImageToS3( + * image, + * getPreSignedUrl, + * async (result) => { ... }, + * (error) => { ... } + * ); + */ +export const usePreSignedUrlAdapter = () => { + const { mutate: getPreSignedUrlMutate } = useGetPreSignedUrl(); + + const getPreSignedUrl = ( + params: { fileName: string; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, + callbacks: { + onSuccess: (data: { + uploadUrl: string; + contentDisposition: string; + file: { id: number }; + }) => void; + onError: (error: any) => void; + } + ) => { + getPreSignedUrlMutate(params, { + onSuccess: (data) => { + callbacks.onSuccess({ + uploadUrl: data.uploadUrl, + contentDisposition: data.contentDisposition, + file: { id: data.file.id }, + }); + }, + onError: callbacks.onError, + }); + }; + + return getPreSignedUrl; +}; diff --git a/apps/native/src/features/student/scrap/hooks/useScrapSelection.ts b/apps/native/src/features/student/scrap/hooks/useScrapSelection.ts new file mode 100644 index 00000000..b65245ca --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/useScrapSelection.ts @@ -0,0 +1,23 @@ +import { useReducer } from 'react'; +import { reducer, initialSelectionState, State, Action } from '../utils/reducer'; + +/** + * 스크랩 아이템 선택 상태를 관리하는 커스텀 훅 + * + * @returns [reducerState, dispatch] - 선택 상태와 디스패치 함수 + * + * @example + * const [reducerState, dispatch] = useScrapSelection(); + * + * // 선택 모드 진입 + * dispatch({ type: 'ENTER_SELECTION' }); + * + * // 아이템 선택/해제 + * dispatch({ type: 'SELECTING_ITEM', id: 1, itemType: 'SCRAP' }); + * + * // 선택 모드 종료 + * dispatch({ type: 'EXIT_SELECTION' }); + */ +export const useScrapSelection = (): [State, React.Dispatch] => { + return useReducer(reducer, initialSelectionState); +}; diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index 887a84f4..6a4b1611 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -1,24 +1,24 @@ -import React, { useMemo, useReducer, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { Pressable, Text, View } from 'react-native'; -import DeletedScrapHeader from '../components/Header/DeletedHeader'; -import { reducer, initialSelectionState } from '../utils/reducer'; +import DeletedScrapHeader from '../components/Header/DeletedScrapHeader'; import { useNavigation } from '@react-navigation/native'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { Container, LoadingScreen } from '@/components/common'; import { TrashScrapGrid } from '../components/Card/ScrapCardGrid'; -import SortDropdown from '../components/Modal/SortDropdown'; -import { sortScrapData } from '../utils/sortScrap'; +import SortDropdown from '../components/Dropdown/SortDropdown'; +import { sortScrapData } from '../utils/formatters/sortScrap'; import type { UISortKey, SortOrder } from '../utils/types'; -import PopUpModal from '../components/Modal/PopupModal'; -import { showToast } from '../components/Modal/Toast'; +import { PopUpModal } from '../components/Dialog'; +import { showToast } from '../components/Notification/Toast'; import { useGetTrash, useRestoreTrash, usePermanentDeleteTrash } from '@/apis'; -import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; -import { ScrapModalProvider, useScrapModal } from '../contexts/ScrapModalContext'; -import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; +import { useScrapModal } from '../contexts/ScrapModalsContext'; +import { useScrapSelection } from '../hooks'; +import { validateOnlyScrapCanMove } from '../utils/validation'; +import { withScrapModals } from '../hoc'; const DeletedScrapScreenContent = () => { - const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); + const [reducerState, dispatch] = useScrapSelection(); const [sortKey, setSortKey] = useState('TYPE'); const [sortOrder, setSortOrder] = useState('DESC'); const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); @@ -73,11 +73,7 @@ const DeletedScrapScreenContent = () => { } }} onMove={() => { - const selectedFolders = reducerState.selectedItems.filter( - (selected) => selected.type === 'FOLDER' - ); - if (selectedFolders.length > 0) { - showToast('error', '스크랩만 이동이 가능합니다.'); + if (validateOnlyScrapCanMove(reducerState.selectedItems)) { return; } if (reducerState.selectedItems.length === 0) { @@ -133,16 +129,10 @@ const DeletedScrapScreenContent = () => { }; const DeletedScrapScreen = () => { - return ( - - - - - - ); + return ; }; -export default DeletedScrapScreen; +export default withScrapModals(DeletedScrapScreen); interface PermanentDeleteModalProps { visible: boolean; diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx index ca8acd46..bb6233ee 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx @@ -1,20 +1,20 @@ import { View } from 'react-native'; import ScrapHeader from '../components/Header/ScrapHeader'; -import { useMemo, useReducer, useState, useEffect } from 'react'; -import { reducer, initialSelectionState } from '../utils/reducer'; -import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; +import { useMemo, useState, useEffect } from 'react'; +import { sortScrapData, mapUIKeyToAPIKey } 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'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { Container, LoadingScreen } from '@/components/common'; -import SortDropdown from '../components/Modal/SortDropdown'; +import SortDropdown from '../components/Dropdown/SortDropdown'; import { ScrapGrid } from '../components/Card/ScrapCardGrid'; -import { showToast } from '../components/Modal/Toast'; +import { showToast } from '../components/Notification/Toast'; import { useGetScrapsByFolder, useDeleteScrap, useGetFolders } from '@/apis'; -import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; -import { ScrapModalProvider, useScrapModal } from '../contexts/ScrapModalContext'; -import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; +import { useScrapModal } from '../contexts/ScrapModalsContext'; +import { useScrapSelection } from '../hooks'; +import { validateOnlyScrapCanMove } from '../utils/validation'; +import { withScrapModals } from '../hoc'; type ScrapContentRouteProp = RouteProp; @@ -22,7 +22,7 @@ const ScrapContentScreenContent = () => { const route = useRoute(); const { id } = route.params; - const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); + const [reducerState, dispatch] = useScrapSelection(); const [sortKey, setSortKey] = useState('TITLE'); const [sortOrder, setSortOrder] = useState('ASC'); const navigation = useNavigation>(); @@ -65,49 +65,47 @@ const ScrapContentScreenContent = () => { reducerState={reducerState} title={folder?.name} navigateback={navigation} - navigateSearchPress={() => navigation.push('SearchScrap')} - navigateTrashPress={() => navigation.push('DeletedScrap')} - onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} - onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} isAllSelected={isAllSelected} - onSelectAll={() => { - const allItems = contents.map((item) => ({ id: item.id, type: item.type })); - dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); - }} - onMove={() => { - const selectedFolders = reducerState.selectedItems.filter( - (selected) => selected.type === 'FOLDER' - ); - if (selectedFolders.length > 0) { - showToast('error', '스크랩만 이동이 가능합니다.'); - return; - } - if (reducerState.selectedItems.length === 0) { - showToast('error', '이동할 스크랩을 선택해주세요.'); - return; - } - openMoveScrapModal({ - currentFolderId: Number(id), - selectedItems: reducerState.selectedItems, - }); - dispatch({ type: 'CLEAR_SELECTION' }); - }} - onDelete={async () => { - if (reducerState.selectedItems.length === 0) { - showToast('error', '삭제할 항목을 선택해주세요.'); - return; - } + actions={{ + onSearchPress: () => navigation.push('SearchScrap'), + onTrashPress: () => navigation.push('DeletedScrap'), + onEnterSelection: () => dispatch({ type: 'ENTER_SELECTION' }), + onExitSelection: () => dispatch({ type: 'EXIT_SELECTION' }), + onSelectAll: () => { + const allItems = contents.map((item) => ({ id: item.id, type: item.type })); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); + }, + onMove: () => { + if (validateOnlyScrapCanMove(reducerState.selectedItems)) { + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + openMoveScrapModal({ + currentFolderId: Number(id), + selectedItems: reducerState.selectedItems, + }); + dispatch({ type: 'CLEAR_SELECTION' }); + }, + onDelete: async () => { + if (reducerState.selectedItems.length === 0) { + showToast('error', '삭제할 항목을 선택해주세요.'); + return; + } - try { - const items = reducerState.selectedItems; + try { + const items = reducerState.selectedItems; - await deleteScrap({ items }); + await deleteScrap({ items }); - dispatch({ type: 'CLEAR_SELECTION' }); - showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); - } catch (error: any) { - showToast('error', '삭제 중 오류가 발생했습니다.'); - } + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }, }} /> @@ -134,13 +132,7 @@ const ScrapContentScreenContent = () => { }; const ScrapContentScreen = () => { - return ( - - - - - - ); + return ; }; -export default ScrapContentScreen; +export default withScrapModals(ScrapContentScreen); diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx index 18cba8b8..9eff6a6b 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx @@ -29,9 +29,9 @@ import { useGetHandwriting, useGetScrapDetail, useUpdateHandwriting } from '@/ap import { LoadingScreen } from '@/components/common'; import ProblemViewer from '../../problem/components/ProblemViewer'; import { useNoteStore, Note } from '@/stores/scrapNoteStore'; -import { toAlphabetSequence } from '../utils/toAlphabetSequence'; +import { toAlphabetSequence } from '../utils/formatters/toAlphabetSequence'; import { components } from '@/types/api/schema'; -import DrawingCanvas, { DrawingCanvasRef, Stroke, TextItem } from '../components/skia/drawing'; +import DrawingCanvas, { DrawingCanvasRef, Stroke, TextItem } from '../utils/skia/drawing'; type ScrapDetailContentRouteProp = RouteProp; diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index cdb2dc4c..b3a19b8d 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -2,26 +2,26 @@ import { Container, LoadingScreen } from '@/components/common'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -import React, { useMemo, useReducer, useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { View, Text, ScrollView, Pressable, ImageBackground } from 'react-native'; -import { reducer, initialSelectionState } from '../utils/reducer'; import ScrapHeader from '../components/Header/ScrapHeader'; import { ScrapGrid } from '../components/Card/ScrapCardGrid'; -import SortDropdown from '../components/Modal/SortDropdown'; +import SortDropdown from '../components/Dropdown/SortDropdown'; import { useRecentScrapStore } from '@/stores/recentScrapStore'; -import { sortScrapData, mapUIKeyToAPIKey } from '../utils/sortScrap'; +import { sortScrapData, mapUIKeyToAPIKey } from '../utils/formatters/sortScrap'; import type { UISortKey, SortOrder, ScrapSearchResponse } from '../utils/types'; -import { showToast } from '../components/Modal/Toast'; +import { showToast } from '../components/Notification/Toast'; import { useSearchScraps, useDeleteScrap } from '@/apis'; -import { MoveScrapModal } from '../components/Modal/MoveScrapModal'; +import { validateOnlyScrapCanMove } from '../utils/validation'; import { useQueries } from '@tanstack/react-query'; import { TanstackQueryClient } from '@/apis'; import { RecentScrapCard } from '../components/Card/cards/RecentScrapCard'; -import { ScrapModalProvider, useScrapModal } from '../contexts/ScrapModalContext'; -import { CreateFolderModal } from '../components/Modal/CreateFolderModal'; +import { useScrapModal } from '../contexts/ScrapModalsContext'; +import { useScrapSelection } from '../hooks'; +import { withScrapModals } from '../hoc'; const ScrapScreenContent = () => { - const [reducerState, dispatch] = useReducer(reducer, initialSelectionState); + const [reducerState, dispatch] = useScrapSelection(); const [sortKey, setSortKey] = useState('DATE'); const [sortOrder, setSortOrder] = useState('DESC'); const navigation = useNavigation>(); @@ -100,49 +100,47 @@ const ScrapScreenContent = () => { navigation.push('SearchScrap')} - navigateTrashPress={() => navigation.push('DeletedScrap')} - onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} - onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} isAllSelected={isAllSelected} - onSelectAll={() => { - const allItems = data.map((item) => ({ id: item.id, type: item.type })); - dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); - }} - onMove={() => { - const selectedFolders = reducerState.selectedItems.filter( - (selected) => selected.type === 'FOLDER' - ); - if (selectedFolders.length > 0) { - showToast('error', '스크랩만 이동이 가능합니다.'); - return; - } - if (reducerState.selectedItems.length === 0) { - showToast('error', '이동할 스크랩을 선택해주세요.'); - return; - } - openMoveScrapModal({ - selectedItems: reducerState.selectedItems, - }); - dispatch({ type: 'CLEAR_SELECTION' }); - }} - onDelete={async () => { - if (reducerState.selectedItems.length === 0) { - showToast('error', '삭제할 항목을 선택해주세요.'); - return; - } - - const items = reducerState.selectedItems; - - dispatch({ type: 'CLEAR_SELECTION' }); - - try { - await deleteScrap({ items }); - showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); - } catch (error: any) { - // 에러 발생 시 롤백은 mutation의 onError에서 처리됨 - showToast('error', '삭제 중 오류가 발생했습니다.'); - } + actions={{ + onSearchPress: () => navigation.push('SearchScrap'), + onTrashPress: () => navigation.push('DeletedScrap'), + onEnterSelection: () => dispatch({ type: 'ENTER_SELECTION' }), + onExitSelection: () => dispatch({ type: 'EXIT_SELECTION' }), + onSelectAll: () => { + const allItems = data.map((item) => ({ id: item.id, type: item.type })); + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); + }, + onMove: () => { + if (validateOnlyScrapCanMove(reducerState.selectedItems)) { + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + openMoveScrapModal({ + selectedItems: reducerState.selectedItems, + }); + dispatch({ type: 'CLEAR_SELECTION' }); + }, + onDelete: async () => { + if (reducerState.selectedItems.length === 0) { + showToast('error', '삭제할 항목을 선택해주세요.'); + return; + } + + const items = reducerState.selectedItems; + + dispatch({ type: 'CLEAR_SELECTION' }); + + try { + await deleteScrap({ items }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + // 에러 발생 시 롤백은 mutation의 onError에서 처리됨 + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }, }} /> @@ -184,13 +182,7 @@ const ScrapScreenContent = () => { }; const ScrapScreen = () => { - return ( - - - - - - ); + return ; }; -export default ScrapScreen; +export default withScrapModals(ScrapScreen); diff --git a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx index 917ca3a9..8f6cdac3 100644 --- a/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -8,7 +8,7 @@ import React, { useEffect, useState } from 'react'; import { Pressable, ScrollView, Text, View } from 'react-native'; import { SearchScrapGrid } from '../components/Card/ScrapCardGrid'; import { useSearchHistoryStore } from '@/stores/searchHistoryStore'; -import SearchScrapHeader from '../components/Header/SearchHeader'; +import SearchScrapHeader from '../components/Header/SearchScrapHeader'; import { useSearchScraps } from '@/apis'; const SearchScrapScreen = () => { diff --git a/apps/native/src/features/student/scrap/utils/constants.ts b/apps/native/src/features/student/scrap/utils/constants.ts new file mode 100644 index 00000000..867ecd26 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/constants.ts @@ -0,0 +1,39 @@ +/** + * Scrap 기능 전역 상수 + */ + +/** + * 그리드 레이아웃 설정 + */ +export const GRID_CONFIG = { + /** 그리드 아이템 간 간격 (px) */ + GAP: 22, + /** 그리드 아이템 최소 너비 (px) */ + MIN_ITEM_WIDTH: 136, + /** 그리드 아이템 너비:높이 비율 */ + ITEM_HEIGHT_RATIO: 1.15, + /** 최소 컬럼 수 */ + MIN_COLUMNS: 2, +} as const; + +/** + * 애니메이션 및 UI 딜레이 설정 (ms) + */ +export const ANIMATION_DELAYS = { + /** 팝오버 닫기 딜레이 */ + POPOVER_CLOSE: 100, + /** 모달 닫기 딜레이 */ + MODAL_CLOSE: 200, + /** 즉시 실행 (다음 이벤트 루프) */ + IMMEDIATE: 0, +} as const; + +/** + * 정렬 기본값 + */ +export const DEFAULT_SORT = { + /** 기본 정렬 키 */ + KEY: 'DATE' as const, + /** 기본 정렬 순서 */ + ORDER: 'DESC' as const, +} as const; diff --git a/apps/native/src/features/student/scrap/utils/formatToMinute.ts b/apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts similarity index 100% rename from apps/native/src/features/student/scrap/utils/formatToMinute.ts rename to apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts diff --git a/apps/native/src/features/student/scrap/utils/formatters/index.ts b/apps/native/src/features/student/scrap/utils/formatters/index.ts new file mode 100644 index 00000000..11522288 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/formatters/index.ts @@ -0,0 +1,3 @@ +export * from './formatToMinute'; +export * from './sortScrap'; +export * from './toAlphabetSequence'; diff --git a/apps/native/src/features/student/scrap/utils/sortScrap.ts b/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts similarity index 100% rename from apps/native/src/features/student/scrap/utils/sortScrap.ts rename to apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts diff --git a/apps/native/src/features/student/scrap/utils/toAlphabetSequence.ts b/apps/native/src/features/student/scrap/utils/formatters/toAlphabetSequence.ts similarity index 100% rename from apps/native/src/features/student/scrap/utils/toAlphabetSequence.ts rename to apps/native/src/features/student/scrap/utils/formatters/toAlphabetSequence.ts diff --git a/apps/native/src/features/student/scrap/utils/imagePicker.ts b/apps/native/src/features/student/scrap/utils/images/imagePicker.ts similarity index 100% rename from apps/native/src/features/student/scrap/utils/imagePicker.ts rename to apps/native/src/features/student/scrap/utils/images/imagePicker.ts diff --git a/apps/native/src/features/student/scrap/utils/imageUpload.ts b/apps/native/src/features/student/scrap/utils/images/imageUpload.ts similarity index 100% rename from apps/native/src/features/student/scrap/utils/imageUpload.ts rename to apps/native/src/features/student/scrap/utils/images/imageUpload.ts diff --git a/apps/native/src/features/student/scrap/utils/images/index.ts b/apps/native/src/features/student/scrap/utils/images/index.ts new file mode 100644 index 00000000..3f996b19 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/images/index.ts @@ -0,0 +1,3 @@ +export * from './imagePicker'; +export * from './imageUpload'; +export * from './s3Upload'; diff --git a/apps/native/src/features/student/scrap/utils/s3Upload.ts b/apps/native/src/features/student/scrap/utils/images/s3Upload.ts similarity index 100% rename from apps/native/src/features/student/scrap/utils/s3Upload.ts rename to apps/native/src/features/student/scrap/utils/images/s3Upload.ts diff --git a/apps/native/src/features/student/scrap/utils/gridLayout.ts b/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts similarity index 63% rename from apps/native/src/features/student/scrap/utils/gridLayout.ts rename to apps/native/src/features/student/scrap/utils/layout/gridLayout.ts index aa9d8573..66517679 100644 --- a/apps/native/src/features/student/scrap/utils/gridLayout.ts +++ b/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts @@ -1,23 +1,23 @@ +import { GRID_CONFIG } from '../constants'; + /** * Calculates grid layout parameters based on window dimensions * @returns Object containing numColumns, gap, and itemWidth */ export const useGridLayout = (containerWidth: number) => { - const GAP = 22; - const MIN_ITEM = 136; - const RATIO = 1.15; // width : height = 1 : 1.5 + const { GAP, MIN_ITEM_WIDTH, ITEM_HEIGHT_RATIO, MIN_COLUMNS } = GRID_CONFIG; // 컬럼 수 계산 - let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM + GAP)); + let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM_WIDTH + GAP)); - // 최소 2컬럼 - numColumns = Math.max(2, numColumns); + // 최소 컬럼 수 적용 + numColumns = Math.max(MIN_COLUMNS, numColumns); // item width const itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; // 비율 기반 height - const itemHeight = itemWidth * RATIO; + const itemHeight = itemWidth * ITEM_HEIGHT_RATIO; return { numColumns, diff --git a/apps/native/src/features/student/scrap/utils/layout/index.ts b/apps/native/src/features/student/scrap/utils/layout/index.ts new file mode 100644 index 00000000..c391dba5 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/layout/index.ts @@ -0,0 +1 @@ +export * from './gridLayout'; diff --git a/apps/native/src/features/student/scrap/components/skia/drawing.tsx b/apps/native/src/features/student/scrap/utils/skia/drawing.tsx similarity index 100% rename from apps/native/src/features/student/scrap/components/skia/drawing.tsx rename to apps/native/src/features/student/scrap/utils/skia/drawing.tsx diff --git a/apps/native/src/features/student/scrap/utils/skia/index.ts b/apps/native/src/features/student/scrap/utils/skia/index.ts new file mode 100644 index 00000000..03830ce1 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/skia/index.ts @@ -0,0 +1,3 @@ +export * from './smoothing'; +export { default as DrawingCanvas } from './drawing'; +export type { Point, Stroke, TextItem, DrawingCanvasRef } from './drawing'; diff --git a/apps/native/src/features/student/scrap/utils/validation.ts b/apps/native/src/features/student/scrap/utils/validation.ts new file mode 100644 index 00000000..31360a14 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/validation.ts @@ -0,0 +1,26 @@ +import { SelectedItem } from './reducer'; +import { showToast } from '../components/Notification/Toast'; + +/** + * 선택된 아이템 중 폴더가 포함되어 있는지 확인하고, 포함되어 있으면 에러 토스트를 표시합니다. + * 스크랩만 이동 가능한 경우에 사용합니다. + * + * @param selectedItems - 선택된 아이템 목록 + * @returns 폴더가 포함되어 있으면 true, 아니면 false + * + * @example + * if (validateOnlyScrapCanMove(reducerState.selectedItems)) { + * return; // 폴더가 포함되어 있으면 중단 + * } + * // 스크랩만 이동하는 로직 계속... + */ +export const validateOnlyScrapCanMove = (selectedItems: SelectedItem[]): boolean => { + const selectedFolders = selectedItems.filter((selected) => selected.type === 'FOLDER'); + + if (selectedFolders.length > 0) { + showToast('error', '스크랩만 이동이 가능합니다.'); + return true; // 검증 실패 (폴더가 포함됨) + } + + return false; // 검증 성공 (스크랩만 있음) +}; From 68c83518e4f919fa5b69372841d094b34d5985a2 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:05:51 +0900 Subject: [PATCH 132/140] feat: add loading skeleton to ImageWithSkeleton component for improved user experience - Introduce Skeleton component with animated pulsing effect for loading state - Manage loading state with useState hook to display skeleton while images load - Update ImageWithSkeletonComponent to show skeleton during image loading events (onLoadStart, onLoadEnd, onError) --- .../components/common/ImageWithSkeleton.tsx | 65 ++++++++++++++++++- 1 file changed, 63 insertions(+), 2 deletions(-) diff --git a/apps/native/src/components/common/ImageWithSkeleton.tsx b/apps/native/src/components/common/ImageWithSkeleton.tsx index 3d039014..738b4d07 100644 --- a/apps/native/src/components/common/ImageWithSkeleton.tsx +++ b/apps/native/src/components/common/ImageWithSkeleton.tsx @@ -1,6 +1,6 @@ import { colors } from '@/theme/tokens'; -import React, { useMemo } from 'react'; -import { View, Image, ImageProps, ImageStyle, DimensionValue } from 'react-native'; +import React, { useMemo, useState } from 'react'; +import { View, Image, ImageProps, ImageStyle, DimensionValue, Animated, StyleSheet } from 'react-native'; type ImageWithSkeletonProps = { source?: ImageProps['source'] | ImageProps['source'][]; @@ -17,6 +17,48 @@ type ImageWithSkeletonProps = { isDiagonalLayout?: boolean; }; +// 스켈레톤 컴포넌트 +const Skeleton = ({ borderRadius }: { borderRadius: number }) => { + const pulseAnim = React.useRef(new Animated.Value(0)).current; + + React.useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + animation.start(); + return () => animation.stop(); + }, [pulseAnim]); + + const opacity = pulseAnim.interpolate({ + inputRange: [0, 1], + outputRange: [0.3, 0.7], + }); + + return ( + + ); +}; + const ImageWithSkeletonComponent = ({ source, width = '100%', @@ -30,6 +72,9 @@ const ImageWithSkeletonComponent = ({ fallback, isDiagonalLayout = false, }: ImageWithSkeletonProps) => { + // 이미지 로딩 상태 관리 + const [isLoading, setIsLoading] = useState(true); + // source가 배열인지 확인 const isSourceArray = Array.isArray(source); @@ -63,13 +108,18 @@ const ImageWithSkeletonComponent = ({ // 대각선 레이아웃이 아닐 때: 전체 영역에 단일 이미지 표시 if (!isDiagonalLayout && imageUrls.length > 0) { const singleImageSource = { uri: imageUrls[0] }; + return ( + {isLoading && } setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} style={[ { width, @@ -93,6 +143,7 @@ const ImageWithSkeletonComponent = ({ + {isLoading && } {/* 왼쪽 위: 2개면 첫 번째 이미지, 1개면 회색 배경 */} {hasSecondImage ? ( // 2개일 때: 왼쪽 위에 첫 번째 이미지 @@ -106,6 +157,9 @@ const ImageWithSkeletonComponent = ({ setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} style={{ width: '100%', height: '100%', @@ -138,6 +192,9 @@ const ImageWithSkeletonComponent = ({ setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} style={{ width: '100%', height: '100%', @@ -154,10 +211,14 @@ const ImageWithSkeletonComponent = ({ if (!Array.isArray(source) && source) { return ( + {isLoading && } setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} style={[ { width, From 051c8829afcd9a9518801c672d98bb36de7eb368 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:06:04 +0900 Subject: [PATCH 133/140] fix: adjust ScrapHeader layout for conditional title rendering - Update ScrapHeader component to conditionally wrap title in a View based on navigateback prop - Ensure consistent styling and alignment of title text in both scenarios --- .../student/scrap/components/Header/ScrapHeader.tsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) 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 8dfc714c..b17ee6a0 100644 --- a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -62,7 +62,13 @@ const ScrapHeader = ({ )} - {title} + {navigateback ? ( + + {title} + + ) : ( + {title} + )} Date: Thu, 1 Jan 2026 21:06:12 +0900 Subject: [PATCH 134/140] refactor: update DeletedScrapHeader to use actions object for event handling - Restructure DeletedScrapHeader component to utilize an actions object for handling various user interactions (enter selection, exit selection, move, delete, restore, select all). - Adjust prop types to improve clarity and maintainability. - Enhance layout by wrapping the title in a View for better alignment. --- .../components/Header/DeletedScrapHeader.tsx | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) 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 23e6a107..61f0e7c3 100644 --- a/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx +++ b/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx @@ -8,30 +8,27 @@ import { colors } from '@/theme/tokens'; import { StudentRootStackParamList } from '@/navigation/student/types'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; -interface DeletedScrapHeaderProps { - navigateback: NativeStackNavigationProp; - reducerState: State; - navigateSearchPress?: () => void; - navigateTrashPress?: () => void; +export interface DeletedScrapHeaderActions { onEnterSelection?: () => void; onExitSelection?: () => void; onMove?: () => void; onDelete?: () => void; onRestore?: () => void; - isAllSelected?: boolean; onSelectAll?: () => void; } +interface DeletedScrapHeaderProps { + navigateback: NativeStackNavigationProp; + reducerState: State; + isAllSelected?: boolean; + actions: DeletedScrapHeaderActions; +} + const DeletedScrapHeader = ({ navigateback, reducerState, - onEnterSelection, - onExitSelection, - onMove, - onDelete, - onRestore, isAllSelected, - onSelectAll, + actions, }: DeletedScrapHeaderProps) => { const isActionEnabled = reducerState.selectedItems.length > 0; return ( @@ -51,11 +48,13 @@ const DeletedScrapHeader = ({ ) : ( )} - 휴지통 + + 휴지통 + + onPress={actions.onEnterSelection}> @@ -65,13 +64,13 @@ const DeletedScrapHeader = ({ {reducerState.isSelecting && ( - + {!isAllSelected ? '전체 선택' : '전체 해제'} 스크랩 - + 완료 @@ -80,7 +79,7 @@ const DeletedScrapHeader = ({ { - if (isActionEnabled && onRestore) onRestore(); + 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'}`}> @@ -88,7 +87,7 @@ const DeletedScrapHeader = ({ { - if (isActionEnabled && onMove) onMove(); + 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'}`}> @@ -96,7 +95,7 @@ const DeletedScrapHeader = ({ { - if (isActionEnabled && onDelete) onDelete(); + 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'}`}> From b1aa1926d984ef206222cfb844a225b78079d7e1 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:06:24 +0900 Subject: [PATCH 135/140] refactor: update useCardImageSources hook to prioritize thumbnailUrl over folderTop2Thumbnail - Modify logic in useCardImageSources to return thumbnailUrl if available, simplifying image source selection. - Adjust layout behavior by setting isDiagonalLayout based on the presence of thumbnailUrl. - Ensure folderTop2Thumbnail is only used when thumbnailUrl is not provided, maintaining clarity in image source handling. --- .../student/scrap/hooks/useCardImageSources.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts b/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts index d66f4575..180198c7 100644 --- a/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts +++ b/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts @@ -26,18 +26,17 @@ export const useCardImageSources = ( folderTop2Thumbnail?: string[] ): CardImageSourcesResult => { return useMemo(() => { - // folderTop2Thumbnail이 있으면 그것을 우선 사용 (최대 2개, 대각선 배치) - if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0) { + if (thumbnailUrl) { return { - imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), - isDiagonalLayout: true, + imageSources: [{ uri: thumbnailUrl }], + isDiagonalLayout: false, }; } - if (thumbnailUrl) { + if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0 && !thumbnailUrl) { return { - imageSources: [{ uri: thumbnailUrl }], - isDiagonalLayout: false, + imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), + isDiagonalLayout: true, }; } From a4821eac5353f3282308ddd2d1cd004f2fc27e18 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:06:33 +0900 Subject: [PATCH 136/140] refactor: increase recent scrap limit from 10 to 30 in useRecentScrapStore - Update the scrapIds array to allow for a maximum of 30 recent scraps instead of 10, enhancing the storage capacity for user scrap data. --- apps/native/src/stores/recentScrapStore.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/native/src/stores/recentScrapStore.ts b/apps/native/src/stores/recentScrapStore.ts index 13fc60f4..29b5af71 100644 --- a/apps/native/src/stores/recentScrapStore.ts +++ b/apps/native/src/stores/recentScrapStore.ts @@ -23,7 +23,7 @@ export const useRecentScrapStore = create()( // 중복 제거 후 맨 앞에 추가 const filtered = state.scrapIds.filter((id) => id !== scrapId); return { - scrapIds: [scrapId, ...filtered].slice(0, 10), + scrapIds: [scrapId, ...filtered].slice(0, 30), }; }), From f124915ebb83f5610955590919ec1f0380970dcf Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:06:42 +0900 Subject: [PATCH 137/140] refactor: streamline event handling in DeletedScrapScreen by using actions object - Refactor DeletedScrapScreenContent to utilize an actions object for managing user interactions (select all, enter/exit selection, delete, move, restore). - Improve code clarity and maintainability by consolidating event handlers into a single object structure. --- .../scrap/screens/DeletedScrapScreen.tsx | 72 ++++++++++--------- 1 file changed, 37 insertions(+), 35 deletions(-) diff --git a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx index 6a4b1611..09551fb8 100644 --- a/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -59,42 +59,44 @@ const DeletedScrapScreenContent = () => { { - const allItems = sortedData.map((item) => ({ id: item.id, type: item.type })); - const isAllSelected = - reducerState.selectedItems.length === sortedData.length && sortedData.length > 0; - dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); - }} - onEnterSelection={() => dispatch({ type: 'ENTER_SELECTION' })} - onExitSelection={() => dispatch({ type: 'EXIT_SELECTION' })} - onDelete={() => { - if (reducerState.selectedItems.length > 0) { - setIsDeleteModalVisible(true); - } - }} - onMove={() => { - if (validateOnlyScrapCanMove(reducerState.selectedItems)) { - return; - } - if (reducerState.selectedItems.length === 0) { - showToast('error', '이동할 스크랩을 선택해주세요.'); - return; - } - openMoveScrapModal({ - selectedItems: reducerState.selectedItems, - }); - dispatch({ type: 'CLEAR_SELECTION' }); - }} - onRestore={async () => { - try { - const items = reducerState.selectedItems; - - await restoreTrash({ items }); + actions={{ + onSelectAll: () => { + const allItems = sortedData.map((item) => ({ id: item.id, type: item.type })); + const isAllSelected = + reducerState.selectedItems.length === sortedData.length && sortedData.length > 0; + dispatch({ type: 'SELECT_ALL', allItems: isAllSelected ? [] : allItems }); + }, + onEnterSelection: () => dispatch({ type: 'ENTER_SELECTION' }), + onExitSelection: () => dispatch({ type: 'EXIT_SELECTION' }), + onDelete: () => { + if (reducerState.selectedItems.length > 0) { + setIsDeleteModalVisible(true); + } + }, + onMove: () => { + if (validateOnlyScrapCanMove(reducerState.selectedItems)) { + return; + } + if (reducerState.selectedItems.length === 0) { + showToast('error', '이동할 스크랩을 선택해주세요.'); + return; + } + openMoveScrapModal({ + selectedItems: reducerState.selectedItems, + }); dispatch({ type: 'CLEAR_SELECTION' }); - showToast('success', '선택된 파일들이 복구되었습니다.'); - } catch (error) { - showToast('error', '복구 중 오류가 발생했습니다.'); - } + }, + onRestore: async () => { + try { + const items = reducerState.selectedItems; + + await restoreTrash({ items }); + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '선택된 파일들이 복구되었습니다.'); + } catch (error) { + showToast('error', '복구 중 오류가 발생했습니다.'); + } + }, }} /> From 79ae6de2c16c116b1c52d095dc4e9bb8f79e19d8 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 21:06:54 +0900 Subject: [PATCH 138/140] refactor: enhance MoveScrapModal layout and structure for improved usability - Update MoveScrapModal component to improve layout by adjusting the positioning of elements for better visual alignment. - Refactor the header section to use absolute positioning for the item count text, ensuring it remains centered. - Simplify the main content area by removing unnecessary wrapping views and enhancing the ScrollView structure for better responsiveness. - Maintain existing functionality while improving the overall user experience and code clarity. --- .../scrap/components/Modal/MoveScrapModal.tsx | 52 +++++++++---------- 1 file changed, 25 insertions(+), 27 deletions(-) diff --git a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx index fb436515..74aa1ad0 100644 --- a/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx +++ b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx @@ -127,42 +127,40 @@ export const MoveScrapModal = () => { visibleState={isMoveScrapModalVisible && !isCreateFolderModalVisible} setVisibleState={closeMoveScrapModal}> - - + + 취소 - - {selectedItems.length}개 스크랩 이동하기 - + + + {selectedItems.length}개 스크랩 이동하기 + + openCreateFolderModal()}> 새로운 폴더 - - - - - - - - {selectedFolderId - ? `'${folderName}' 폴더로 이동하기` - : '이동할 폴더를 선택해주세요'} - - - + + + + + + + {selectedFolderId ? `'${folderName}' 폴더로 이동하기` : '이동할 폴더를 선택해주세요'} + + From 124e7d3d029c73ac0e6894742e9927e93492a63a Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:24:35 +0900 Subject: [PATCH 139/140] feat: implement FolderScrapScreen and ScrapDetailScreen for enhanced scrap management --- .../src/features/student/scrap/index.ts | 4 +- ...ontentScreen.tsx => FolderScrapScreen.tsx} | 12 +- ...ontentScreen.tsx => ScrapDetailScreen.tsx} | 219 +++++++++--------- .../navigation/student/StudentNavigator.tsx | 8 +- 4 files changed, 124 insertions(+), 119 deletions(-) rename apps/native/src/features/student/scrap/screens/{ScrapContentScreen.tsx => FolderScrapScreen.tsx} (94%) rename apps/native/src/features/student/scrap/screens/{ScrapDetailContentScreen.tsx => ScrapDetailScreen.tsx} (88%) diff --git a/apps/native/src/features/student/scrap/index.ts b/apps/native/src/features/student/scrap/index.ts index 448c6bba..0c67dca7 100644 --- a/apps/native/src/features/student/scrap/index.ts +++ b/apps/native/src/features/student/scrap/index.ts @@ -1,6 +1,6 @@ import ScrapScreen from './screens/ScrapScreen'; -import ScrapContentScreen from './screens/ScrapContentScreen'; +import FolderScrapScreen from './screens/FolderScrapScreen'; import DeletedScrapScreen from './screens/DeletedScrapScreen'; import SearchScrapScreen from './screens/SearchScrapScreen'; -export { ScrapScreen, ScrapContentScreen, DeletedScrapScreen, SearchScrapScreen }; +export { ScrapScreen, FolderScrapScreen, DeletedScrapScreen, SearchScrapScreen }; diff --git a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx b/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx similarity index 94% rename from apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx rename to apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx index bb6233ee..cfbcb1b4 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx @@ -16,10 +16,10 @@ import { useScrapSelection } from '../hooks'; import { validateOnlyScrapCanMove } from '../utils/validation'; import { withScrapModals } from '../hoc'; -type ScrapContentRouteProp = RouteProp; +type FolderScrapRouteProp = RouteProp; -const ScrapContentScreenContent = () => { - const route = useRoute(); +const FolderScrapScreenContent = () => { + const route = useRoute(); const { id } = route.params; const [reducerState, dispatch] = useScrapSelection(); @@ -131,8 +131,8 @@ const ScrapContentScreenContent = () => { ); }; -const ScrapContentScreen = () => { - return ; +const FolderScrapScreen = () => { + return ; }; -export default withScrapModals(ScrapContentScreen); +export default withScrapModals(FolderScrapScreen); diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx similarity index 88% rename from apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx rename to apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx index 9eff6a6b..2763bdb9 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapDetailContentScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx @@ -14,7 +14,7 @@ import { import { RouteProp, useRoute, useNavigation } from '@react-navigation/native'; import { NativeStackNavigationProp } from '@react-navigation/native-stack'; import { SafeAreaView } from 'react-native-safe-area-context'; -import { ChevronLeft, X, ChevronDown, ChevronUp, Maximize2 } from 'lucide-react-native'; +import { ChevronLeft, X, ChevronDown, ChevronUp, Maximize2, Save } from 'lucide-react-native'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import Animated, { useSharedValue, @@ -32,11 +32,12 @@ import { useNoteStore, Note } from '@/stores/scrapNoteStore'; import { toAlphabetSequence } from '../utils/formatters/toAlphabetSequence'; import { components } from '@/types/api/schema'; import DrawingCanvas, { DrawingCanvasRef, Stroke, TextItem } from '../utils/skia/drawing'; +import { colors } from '@/theme/tokens'; -type ScrapDetailContentRouteProp = RouteProp; +type ScrapDetailRouteProp = RouteProp; -const ScrapContentDetailScreen = () => { - const route = useRoute(); +const ScrapDetailScreen = () => { + const route = useRoute(); const navigation = useNavigation>(); const { id } = route.params; const scrapId = Number(id); @@ -52,6 +53,9 @@ const ScrapContentDetailScreen = () => { const [eraserSize, setEraserSize] = useState(6); const [canUndo, setCanUndo] = useState(false); const [canRedo, setCanRedo] = useState(false); + const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [showSave, setShowSave] = useState(false); + const lastSavedDataRef = useRef(''); const { openNotes, activeNoteId, setActiveNote, closeNote, reorderNotes } = useNoteStore(); const [tabLayouts, setTabLayouts] = useState>({}); @@ -71,6 +75,14 @@ const ScrapContentDetailScreen = () => { } }, [activeNoteId, scrapId, navigation]); + // undo/redo 상태 변경 핸들러 + const handleHistoryChange = useCallback((canUndoValue: boolean, canRedoValue: boolean) => { + setCanUndo(canUndoValue); + setCanRedo(canRedoValue); + // 변경사항이 있음을 표시 + setHasUnsavedChanges(true); + }, []); + // handwriting 데이터를 로드하여 canvas에 설정 useEffect(() => { if (handwritingData?.data && canvasRef.current) { @@ -90,90 +102,111 @@ const ScrapContentDetailScreen = () => { canvasRef.current.setTexts(data.texts || []); } - // 로드 후 undo/redo 상태 업데이트 - setTimeout(() => { - if (canvasRef.current) { - setCanUndo(canvasRef.current.canUndo()); - setCanRedo(canvasRef.current.canRedo()); - } - }, 0); + // 초기 데이터를 저장된 데이터로 설정 + lastSavedDataRef.current = handwritingData.data; + setHasUnsavedChanges(false); } catch (error) { console.error('필기 데이터 로드 실패:', error); } } }, [handwritingData]); - // 초기 undo/redo 상태 확인 - useEffect(() => { - const checkHistory = () => { - if (canvasRef.current) { - setCanUndo(canvasRef.current.canUndo()); - setCanRedo(canvasRef.current.canRedo()); - } - }; - - // 초기 확인 - checkHistory(); - - // 주기적으로 확인 (상태 변경 감지) - const interval = setInterval(checkHistory, 100); - - return () => clearInterval(interval); - }, []); - // 저장하기 버튼 핸들러 - const handleSave = useCallback(() => { - const strokes = canvasRef.current?.getStrokes(); - const texts = canvasRef.current?.getTexts(); + const handleSave = useCallback( + (isAutoSave = false) => { + const strokes = canvasRef.current?.getStrokes(); + const texts = canvasRef.current?.getTexts(); + + if ((!strokes || strokes.length === 0) && (!texts || texts.length === 0)) { + if (!isAutoSave) { + Alert.alert('알림', '저장할 필기 내용이 없습니다.'); + } + return; + } - if ((!strokes || strokes.length === 0) && (!texts || texts.length === 0)) { - Alert.alert('알림', '저장할 필기 내용이 없습니다.'); - return; - } + try { + // strokes와 texts를 함께 저장 + const data = { + strokes: strokes || [], + texts: texts || [], + }; + const jsonString = JSON.stringify(data); + const base64Data = btoa(unescape(encodeURIComponent(jsonString))); + + // 변경사항이 없으면 저장하지 않음 + if (base64Data === lastSavedDataRef.current) { + if (!isAutoSave) { + Alert.alert('알림', '변경사항이 없습니다.'); + } + return; + } - try { - // strokes와 texts를 함께 저장 - const data = { - strokes: strokes || [], - texts: texts || [], - }; - const jsonString = JSON.stringify(data); - const base64Data = btoa(unescape(encodeURIComponent(jsonString))); - - updateHandwriting( - { - scrapId, - request: { - data: base64Data, - }, - }, - { - onSuccess: () => { - Alert.alert('성공', '필기가 저장되었습니다.'); - }, - onError: (error) => { - console.error('필기 저장 실패:', error); - Alert.alert('오류', '필기 저장에 실패했습니다.'); + updateHandwriting( + { + scrapId, + request: { + data: base64Data, + }, }, + { + onSuccess: () => { + lastSavedDataRef.current = base64Data; + setHasUnsavedChanges(false); + if (!isAutoSave) { + Alert.alert('성공', '필기가 저장되었습니다.'); + setShowSave(true); + setTimeout(() => setShowSave(false), 2000); + } + }, + onError: (error) => { + console.error('필기 저장 실패:', error); + if (!isAutoSave) { + Alert.alert('오류', '필기 저장에 실패했습니다.'); + } + }, + } + ); + } catch (error) { + console.error('필기 데이터 변환 실패:', error); + if (!isAutoSave) { + Alert.alert('오류', '필기 데이터 변환에 실패했습니다.'); } - ); - } catch (error) { - console.error('필기 데이터 변환 실패:', error); - Alert.alert('오류', '필기 데이터 변환에 실패했습니다.'); - } - }, [scrapId, updateHandwriting]); + } + }, + [scrapId, updateHandwriting] + ); + + // 10초마다 자동 저장 + useEffect(() => { + const autoSaveInterval = setInterval(() => { + if (hasUnsavedChanges && !isSaving) { + handleSave(true); + } + }, 10000); // 10초마다 실행 + + return () => clearInterval(autoSaveInterval); + }, [hasUnsavedChanges, isSaving, handleSave]); + + // 포인팅 데이터에 알파벳 레이블 추가 (메모이제이션) + const pointingsWithLabels = useMemo(() => { + if (!scrapDetail?.pointings) return []; + return scrapDetail.pointings.map((pointing, idx) => ({ + ...pointing, + label: toAlphabetSequence(idx), + })); + }, [scrapDetail?.pointings]); // 필터 옵션 생성 (scrapDetail이 없어도 Hook은 항상 호출되어야 함) const filterOptions = useMemo(() => { if (!scrapDetail) return ['전체', '문제']; const options = ['전체', '문제']; - if (scrapDetail.pointings && scrapDetail.pointings.length > 0) { - scrapDetail.pointings.forEach((_, idx) => { - options.push(`포인팅 ${toAlphabetSequence(idx)}`); + if (pointingsWithLabels.length > 0) { + pointingsWithLabels.forEach((pointing) => { + options.push(`포인팅 ${pointing.label}`); }); } return options; - }, [scrapDetail?.pointings]); + }, [scrapDetail, pointingsWithLabels]); // 필터에 따른 표시 여부 결정 const shouldShowProblem = selectedFilter === 0 || selectedFilter === 1; @@ -287,9 +320,9 @@ const ScrapContentDetailScreen = () => { )} + {showSave && } {scrap.name || '스크랩 상세'} {handwritingData?.updatedAt} - {openNotes.length > 1 && ( @@ -329,7 +362,7 @@ const ScrapContentDetailScreen = () => { )} - + {/* 필터 버튼 및 전체보기 */} {filterOptions.length > 0 && ( @@ -410,7 +443,7 @@ const ScrapContentDetailScreen = () => { 포인팅 - {scrap.pointings.map((pointing, idx) => { + {pointingsWithLabels.map((pointing, idx) => { if (!shouldShowPointing(idx)) return null; const sectionKey = `pointing-${pointing.id}`; const isCommentExpanded = expandedSections[sectionKey]?.comment ?? false; @@ -418,9 +451,7 @@ const ScrapContentDetailScreen = () => { return ( - - 포인팅 {toAlphabetSequence(idx)} - + 포인팅 {pointing.label} 포인팅 질문 {pointing.questionContent && ( @@ -475,7 +506,7 @@ const ScrapContentDetailScreen = () => { { textFontSize={16} eraserMode={isEraserMode} eraserSize={eraserSize} - onChange={() => { - // 상태 변경 시 undo/redo 가능 여부 업데이트 - setTimeout(() => { - if (canvasRef.current) { - setCanUndo(canvasRef.current.canUndo()); - setCanRedo(canvasRef.current.canRedo()); - } - }, 0); - }} + onHistoryChange={handleHistoryChange} /> @@ -586,16 +609,7 @@ const ScrapContentDetailScreen = () => { { - canvasRef.current?.undo(); - // undo 후 상태 업데이트 - setTimeout(() => { - if (canvasRef.current) { - setCanUndo(canvasRef.current.canUndo()); - setCanRedo(canvasRef.current.canRedo()); - } - }, 0); - }} + onPress={() => canvasRef.current?.undo()} disabled={!canUndo} className={`flex-1 items-center justify-center rounded-lg py-3 ${ canUndo ? 'bg-gray-200' : 'bg-gray-100' @@ -603,16 +617,7 @@ const ScrapContentDetailScreen = () => { undo { - canvasRef.current?.redo(); - // redo 후 상태 업데이트 - setTimeout(() => { - if (canvasRef.current) { - setCanUndo(canvasRef.current.canUndo()); - setCanRedo(canvasRef.current.canRedo()); - } - }, 0); - }} + onPress={() => canvasRef.current?.redo()} disabled={!canRedo} className={`flex-1 items-center justify-center rounded-lg py-3 ${ canRedo ? 'bg-gray-200' : 'bg-gray-100' @@ -620,7 +625,7 @@ const ScrapContentDetailScreen = () => { redo handleSave(false)} disabled={isSaving} className={`flex-1 items-center justify-center rounded-lg py-3 ${ isSaving ? 'bg-gray-400' : 'bg-blue-600' @@ -869,4 +874,4 @@ const DraggableTab = ({ ); }; -export default ScrapContentDetailScreen; +export default ScrapDetailScreen; diff --git a/apps/native/src/navigation/student/StudentNavigator.tsx b/apps/native/src/navigation/student/StudentNavigator.tsx index 9d4b7177..27f2025c 100644 --- a/apps/native/src/navigation/student/StudentNavigator.tsx +++ b/apps/native/src/navigation/student/StudentNavigator.tsx @@ -12,8 +12,8 @@ import StudentTabs from './StudentTabs'; import { StudentRootStackParamList } from './types'; import NotificationHeader from './components/NotificationHeader'; import { DeletedScrapScreen, ScrapScreen, SearchScrapScreen } from '@/features/student/scrap'; -import ScrapContentScreen from '@/features/student/scrap/screens/ScrapContentScreen'; -import ScrapContentDetailScreen from '@/features/student/scrap/screens/ScrapDetailContentScreen'; +import FolderScrapScreen from '@/features/student/scrap/screens/FolderScrapScreen'; +import ScrapDetailScreen from '@/features/student/scrap/screens/ScrapDetailScreen'; const StudentRootStack = createNativeStackNavigator(); @@ -42,10 +42,10 @@ const StudentNavigator = () => { - + - + ); }; From b212557a7aeb59703381787d29743e0da03f4da1 Mon Sep 17 00:00:00 2001 From: b0nsu <125778250+b0nsu@users.noreply.github.com> Date: Thu, 1 Jan 2026 23:24:52 +0900 Subject: [PATCH 140/140] feat: add history management and text handling improvements in DrawingCanvas component - Introduce onHistoryChange callback to notify about undo/redo capabilities based on drawing state. - Implement text line calculation for better text rendering, supporting automatic line breaks. - Enhance text placement logic to prevent overlap with existing strokes and ensure proper spacing. - Optimize text rendering for multiline support and adjust text input positioning. - Refactor stroke handling to improve interaction with text areas, ensuring a smoother user experience. --- .../student/scrap/utils/skia/drawing.tsx | 375 +++++++++++------- 1 file changed, 232 insertions(+), 143 deletions(-) diff --git a/apps/native/src/features/student/scrap/utils/skia/drawing.tsx b/apps/native/src/features/student/scrap/utils/skia/drawing.tsx index 3a8fbfad..97641274 100644 --- a/apps/native/src/features/student/scrap/utils/skia/drawing.tsx +++ b/apps/native/src/features/student/scrap/utils/skia/drawing.tsx @@ -58,6 +58,7 @@ type Props = { strokeColor?: string; strokeWidth?: number; onChange?: (strokes: Stroke[]) => void; + onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; eraserMode?: boolean; eraserSize?: number; textMode?: boolean; @@ -70,6 +71,7 @@ const DrawingCanvas = forwardRef( strokeColor = 'black', strokeWidth = 3, onChange, + onHistoryChange, eraserMode = false, eraserSize = 20, textMode = false, @@ -114,6 +116,21 @@ const DrawingCanvas = forwardRef( const historyRef = useRef([]); const historyIndexRef = useRef(-1); + // 히스토리 상태 변경 알림 + const notifyHistoryChange = useCallback(() => { + if (!onHistoryChange) return; + + const canUndo = + activeTextInput !== null || + historyIndexRef.current > 0 || + (historyIndexRef.current === 0 && historyRef.current.length > 1); + + const canRedo = + activeTextInput === null && historyIndexRef.current + 1 < historyRef.current.length; + + onHistoryChange(canUndo, canRedo); + }, [onHistoryChange, activeTextInput]); + // 현재 상태를 히스토리에 저장 const saveToHistory = useCallback(() => { const currentState: HistoryState = { @@ -133,7 +150,9 @@ const DrawingCanvas = forwardRef( historyRef.current.shift(); historyIndexRef.current--; } - }, []); + + notifyHistoryChange(); + }, [notifyHistoryChange]); // 히스토리에서 상태 복원 const restoreFromHistory = useCallback( @@ -172,13 +191,58 @@ const DrawingCanvas = forwardRef( onChange?.(state.strokes); setTick((t) => t + 1); + notifyHistoryChange(); }, - [onChange] + [onChange, notifyHistoryChange] ); // 폰트 로드 const font = useFont(require('@assets/fonts/PretendardVariable.ttf'), textFontSize); + // 텍스트의 실제 줄 수 계산 (자동 줄바꿈 포함) + const calculateTextLineCount = useCallback( + (text: string): number => { + if (!font) return 1; + + const maxWidth = Dimensions.get('window').width * 0.5 - 40; + let totalLines = 0; + + const paragraphs = text.split('\n'); + + paragraphs.forEach((paragraph) => { + if (!paragraph) { + totalLines += 1; + return; + } + + const words = paragraph.split(' '); + let currentLine = ''; + let paragraphLines = 0; + + words.forEach((word, idx) => { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const textWidth = font.measureText(testLine).width; + + if (textWidth > maxWidth && currentLine) { + paragraphLines += 1; + currentLine = word; + } else { + currentLine = testLine; + } + + if (idx === words.length - 1) { + paragraphLines += 1; + } + }); + + totalLines += paragraphLines; + }); + + return totalLines; + }, + [font] + ); + const loadStrokes = useCallback( (newStrokes: Stroke[]) => { // strokes와 paths를 함께 업데이트 @@ -250,25 +314,65 @@ const DrawingCanvas = forwardRef( setTick((t) => t + 1); }, []); - const addPoint = useCallback((x: number, y: number) => { - currentPoints.current.push({ x, y }); - // 최대 Y 좌표 업데이트 - if (y > maxY.current) { - maxY.current = y; - // 여유 공간을 위해 200px 추가 - canvasHeight.current = Math.max(800, maxY.current + 200); + // 텍스트 영역과 충돌하는지 확인 (32px 여백 포함, 캔버스 전체 너비, 멀티라인 고려) + const isNearExistingText = useCallback( + (y: number): boolean => { + const safeDistance = 32; // 텍스트 주변 32px 보호 영역 + + for (const textItem of texts) { + // 실제 줄 수 계산 + const lineCount = calculateTextLineCount(textItem.text); + const totalTextHeight = lineCount * textFontSize; + + // 텍스트 영역의 Y 범위 (32px 여백 포함, X는 캔버스 전체 너비) + const textTop = textItem.y - textFontSize - safeDistance; + const textBottom = textItem.y + totalTextHeight - textFontSize + safeDistance; + + // Y 좌표가 텍스트 영역 내에 있으면 캔버스 전체 너비에서 필기 차단 + if (y >= textTop && y <= textBottom) { + return true; + } + } + return false; + }, + [texts, textFontSize, calculateTextLineCount] + ); + + const addPoint = useCallback( + (x: number, y: number) => { + // 텍스트 영역에서는 필기 차단 + if (isNearExistingText(y)) { + return; + } + + currentPoints.current.push({ x, y }); + // 최대 Y 좌표 업데이트 + if (y > maxY.current) { + maxY.current = y; + // 여유 공간을 위해 200px 추가 + canvasHeight.current = Math.max(800, maxY.current + 200); + setTick((t) => t + 1); + } + // 경로는 매번 재생성하되, 렌더링은 최적화 + livePath.current = buildSmoothPath(currentPoints.current); setTick((t) => t + 1); - } - // 경로는 매번 재생성하되, 렌더링은 최적화 - livePath.current = buildSmoothPath(currentPoints.current); - setTick((t) => t + 1); - }, []); + }, + [isNearExistingText] + ); - const startStroke = useCallback((x: number, y: number) => { - currentPoints.current = [{ x, y }]; - livePath.current = buildSmoothPath(currentPoints.current); - setTick((t) => t + 1); - }, []); + const startStroke = useCallback( + (x: number, y: number) => { + // 텍스트 영역에서는 필기 시작 차단 + if (isNearExistingText(y)) { + return; + } + + currentPoints.current = [{ x, y }]; + livePath.current = buildSmoothPath(currentPoints.current); + setTick((t) => t + 1); + }, + [isNearExistingText] + ); const finalizeStroke = useCallback(() => { if (currentPoints.current.length === 0) { @@ -385,120 +489,58 @@ const DrawingCanvas = forwardRef( setTick((t) => t + 1); }, []); - // 텍스트 영역과 충돌하는지 확인 (16px 여백 포함) - const isNearExistingText = useCallback( - (x: number, y: number): boolean => { - const safeDistance = 16; - const buttonSize = 20; + // 필기(stroke) 위에 텍스트 박스를 생성할 수 있는지 확인 + // 필기 위는 차단 (캔버스 전체 너비), 필기 아래 16px부터는 허용 + const canAddTextAtPosition = useCallback( + (y: number): boolean => { + const minGap = 32; // 필기 아래 최소 16px 간격 - for (const textItem of texts) { - // 텍스트 너비 추정 - const estimatedCharWidth = textFontSize * 0.6; - const textWidth = textItem.text.length * estimatedCharWidth; - const textHeight = textFontSize; - - // 텍스트 영역 (16px 여백 포함) - const textLeft = textItem.x - safeDistance; - const textRight = textItem.x + textWidth + safeDistance + buttonSize + 4; // X 버튼 포함 - const textTop = textItem.y - textHeight - safeDistance; - const textBottom = textItem.y + safeDistance; - - // 클릭한 위치가 텍스트 영역 내에 있는지 확인 - if (x >= textLeft && x <= textRight && y >= textTop && y <= textBottom) { - return true; + for (const stroke of strokes) { + // stroke의 최대 Y 좌표 (가장 아래) + const strokeMaxY = Math.max(...stroke.points.map((p) => p.y)); + + // Y 좌표가 stroke보다 위에 있으면 차단 (X 좌표 무관, 캔버스 전체 너비) + if (y < strokeMaxY + minGap) { + return false; // 필기 위 또는 너무 가까움 } } - return false; + return true; // 텍스트 생성 가능 }, - [texts, textFontSize] + [strokes] ); - // 텍스트 박스 영역과 겹치는 strokes를 밀어내기 (stroke 전체를 이동하여 모양 유지) - const pushStrokesAwayFromTextArea = useCallback( - (textX: number, textY: number, textHeight: number) => { + const addText = useCallback( + (x: number, y: number) => { const padding = 16; - const textTop = textY - textHeight - padding; - const textBottom = textY + padding; + const minGap = 32; // 필기 아래 32px - setStrokes((prevStrokes) => { - let hasChanges = false; - const updatedStrokes = prevStrokes.map((stroke) => { - // stroke의 최소/최대 Y 좌표 계산 - const strokeMinY = Math.min(...stroke.points.map((p) => p.y)); - const strokeMaxY = Math.max(...stroke.points.map((p) => p.y)); - - // stroke가 텍스트 박스 영역과 겹치는지 확인 - const overlapsTop = strokeMaxY >= textTop && strokeMaxY <= textBottom; - const overlapsBottom = strokeMinY >= textTop && strokeMinY <= textBottom; - const overlapsMiddle = strokeMinY <= textTop && strokeMaxY >= textBottom; - - if (overlapsTop || overlapsBottom || overlapsMiddle) { - hasChanges = true; - - // stroke의 중심 Y 좌표 계산 - const strokeCenterY = (strokeMinY + strokeMaxY) / 2; - - // 이동 거리 계산 - let offsetY = 0; - if (strokeCenterY < textY) { - // 텍스트 박스 위쪽에 있으면 위로 이동 - offsetY = textTop - strokeMaxY - 1; - } else { - // 텍스트 박스 아래쪽에 있으면 아래로 이동 - offsetY = textBottom - strokeMinY + 1; - } - - // stroke의 모든 점을 동일한 거리만큼 이동 (모양 유지) - const updatedPoints = stroke.points.map((point) => ({ - ...point, - y: point.y + offsetY, - })); - - return { ...stroke, points: updatedPoints }; - } - return stroke; - }); - - if (hasChanges) { - // paths 재생성 - const newPaths = updatedStrokes.map((stroke) => buildSmoothPath(stroke.points)); - setPaths(newPaths); - strokesRef.current = updatedStrokes; - onChange?.(updatedStrokes); - setTick((t) => t + 1); + // 가장 아래쪽 stroke 찾기 + let textY = padding; // 기본값: 캔버스 상단 16px - // 히스토리에 저장 (strokes 이동) - setTimeout(() => saveToHistory(), 0); - } - - return hasChanges ? updatedStrokes : prevStrokes; - }); - }, - [onChange, saveToHistory] - ); - - const addText = useCallback( - (x: number, y: number) => { - // 기존 텍스트 주변 16px 내에서는 새 텍스트 박스 생성 안 함 - if (isNearExistingText(x, y)) { - return; + if (strokes.length > 0) { + // 모든 stroke의 최대 Y 좌표 찾기 + const maxStrokeY = Math.max( + ...strokes.flatMap((stroke) => stroke.points.map((p) => p.y)) + ); + textY = maxStrokeY + minGap; // 가장 아래 필기 + 32px } - // 상하 16px 여백 고려 - const padding = 16; - const adjustedY = Math.max( - padding, - Math.min(y, (containerLayout.current?.height || 400) - padding) - ); - - // 텍스트 박스 영역과 겹치는 strokes를 밀어내기 - pushStrokesAwayFromTextArea(x, adjustedY, textFontSize); + // 기존 텍스트가 있으면 그 아래로 (멀티라인 고려) + if (texts.length > 0) { + const textBottoms = texts.map((text) => { + const lineCount = calculateTextLineCount(text.text); + const totalHeight = lineCount * textFontSize; + return text.y + totalHeight - textFontSize; + }); + const maxTextBottom = Math.max(...textBottoms); + textY = Math.max(textY, maxTextBottom + minGap); + } const textId = Date.now().toString(); setActiveTextInput({ id: textId, - x: x, - y: adjustedY, + x: padding, // 항상 캔버스 왼쪽 끝(16px)에서 시작 + y: textY, // 가장 아래 필기/텍스트 아래 32px value: '', }); @@ -517,7 +559,7 @@ const DrawingCanvas = forwardRef( if (!containerLayout.current) return; // 텍스트 입력 위치 계산 (ScrollView 내부 기준) - const textInputY = adjustedY; + const textInputY = textY; const screenHeight = Dimensions.get('window').height; const keyboardTop = screenHeight - e.endCoordinates.height; @@ -552,7 +594,14 @@ const DrawingCanvas = forwardRef( ); }, 100); }, - [isNearExistingText, pushStrokesAwayFromTextArea, textFontSize] + [ + isNearExistingText, + canAddTextAtPosition, + strokes, + texts, + textFontSize, + calculateTextLineCount, + ] ); const confirmTextInput = useCallback(() => { @@ -642,8 +691,14 @@ const DrawingCanvas = forwardRef( }; historyRef.current = [initialState]; historyIndexRef.current = 0; + notifyHistoryChange(); } - }, []); + }, [notifyHistoryChange]); + + // activeTextInput 상태 변경 시 히스토리 상태 알림 + useEffect(() => { + notifyHistoryChange(); + }, [activeTextInput, notifyHistoryChange]); useImperativeHandle(ref, () => ({ clear() { @@ -824,35 +879,69 @@ const DrawingCanvas = forwardRef( [paths, strokes, strokeWidth, strokeColor] ); - // 텍스트 렌더링 최적화 + // 텍스트 렌더링 최적화 (멀티라인 지원 + 자동 줄바꿈) const renderedTexts = useMemo( () => font - ? texts.map((textItem) => ( - - )) + ? texts.flatMap((textItem) => { + const maxWidth = Dimensions.get('window').width * 0.5 - 40; + const allLines: string[] = []; + + // 먼저 명시적 줄바꿈으로 분할 + const paragraphs = textItem.text.split('\n'); + + // 각 문단을 너비 기준으로 추가 분할 + paragraphs.forEach((paragraph) => { + if (!paragraph) { + allLines.push(''); // 빈 줄 유지 + return; + } + + const words = paragraph.split(' '); + let currentLine = ''; + + words.forEach((word, idx) => { + const testLine = currentLine ? `${currentLine} ${word}` : word; + const textWidth = font.measureText(testLine).width; + + if (textWidth > maxWidth && currentLine) { + // 현재 줄이 최대 너비를 초과하면 줄바꿈 + allLines.push(currentLine); + currentLine = word; + } else { + currentLine = testLine; + } + + // 마지막 단어인 경우 현재 줄 추가 + if (idx === words.length - 1) { + allLines.push(currentLine); + } + }); + }); + + return allLines.map((line, lineIndex) => ( + + )); + }) : null, - [texts, font] + [texts, font, textFontSize] ); - // 텍스트 삭제 버튼 렌더링 (텍스트 모드일 때만) + // 텍스트 삭제 버튼 렌더링 (텍스트 모드일 때만, 텍스트 시작 위치에 배치) const renderedTextDeleteButtons = useMemo(() => { if (!textMode || eraserMode) return null; return texts.map((textItem) => { - // 텍스트 너비 추정 - const estimatedCharWidth = textFontSize * 0.8; - const textWidth = textItem.text.length * estimatedCharWidth; const buttonSize = 20; - const buttonX = textItem.x + textWidth + 4; - const buttonY = textItem.y - textFontSize + (textFontSize - buttonSize) / 2; + const buttonX = textItem.x - buttonSize + 10; // 텍스트 시작 왼쪽에 배치 + const buttonY = textItem.y - textFontSize + (textFontSize - buttonSize) / 2 + 10; return ( ( ) ), }, - ]}> + ]} + onLayout={(e) => {}}>