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 433dac32..92c5b468 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/Notification/Toast'; const queryClient = new QueryClient(); @@ -46,6 +48,7 @@ export default function App() { + 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 00000000..3c56ad6f Binary files /dev/null and b/apps/native/assets/images/scrap-review-note-cover.png differ 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'], }; }; diff --git a/apps/native/package.json b/apps/native/package.json index 1e3a3b7c..b8a71194 100644 --- a/apps/native/package.json +++ b/apps/native/package.json @@ -14,21 +14,25 @@ "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", "@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", "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", @@ -46,11 +50,16 @@ "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-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", "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", 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; + }, + }); +}; 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..c3b098d8 --- /dev/null +++ b/apps/native/src/apis/controller/qna/index.ts @@ -0,0 +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'); +}; 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/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..b07dc56c --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteFolders.ts @@ -0,0 +1,83 @@ +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']; + +export const useDeleteFolders = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: DeleteFoldersRequest): Promise => { + await client.DELETE('/api/student/scrap/folder', { + body: request, + }); + }, + // 낙관적 업데이트: 삭제 전 데이터 백업 및 즉시 UI 업데이트 + onMutate: async (request) => { + const deletedFolderIds = new Set(request); + + // 폴더 목록 쿼리 취소 및 백업 + const folderQueryKey = SCRAP_QUERY_KEYS.folderList(); + await queryClient.cancelQueries({ queryKey: folderQueryKey }); + const previousFolders = queryClient.getQueryData(folderQueryKey); + + // 검색 쿼리 취소 및 백업 + const searchQueryFilters = createSearchQueryFilters(); + 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 = SCRAP_QUERY_KEYS.folderList(); + queryClient.setQueryData(folderQueryKey, context.previousFolders); + } + if (context?.previousQueries) { + rollbackOptimisticUpdate(queryClient, context.previousQueries); + } + }, + // 성공/실패 관계없이 쿼리 무효화 (백그라운드에서 최신 데이터 가져오기) + onSettled: () => { + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: SCRAP_QUERY_KEYS.folderList(), + }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + 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 new file mode 100644 index 00000000..4cfa3be8 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deletePermanentTrash.ts @@ -0,0 +1,25 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; + +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_QUERY_KEYS.trashList(), + }); + }, + }); +}; 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..d79bd671 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteScrap.ts @@ -0,0 +1,54 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { client } from '@/apis/client'; +import { paths } from '@/types/api/schema'; +import { + optimisticDeleteScrap, + rollbackOptimisticUpdate, + invalidateScrapSearchQueries, + invalidateFolderScrapsQueries, + SCRAP_QUERY_KEYS, +} from './utils'; + +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 }; + }, + // 낙관적 업데이트: 삭제 전 데이터 백업 및 즉시 UI 업데이트 + onMutate: async (request) => { + return await optimisticDeleteScrap(queryClient, request.items); + }, + // 에러 발생 시 롤백 + onError: (error, request, context) => { + if (context?.previousQueries) { + rollbackOptimisticUpdate(queryClient, context.previousQueries); + } + }, + // 성공/실패 관계없이 쿼리 무효화 (백그라운드에서 최신 데이터 가져오기) + onSettled: () => { + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + queryKey: SCRAP_QUERY_KEYS.folderList(), + }); + // 폴더 내 스크랩 목록 갱신 + invalidateFolderScrapsQueries(queryClient); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); + // 휴지통 목록 갱신 + queryClient.invalidateQueries({ + 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 new file mode 100644 index 00000000..72c8695c --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromPointing.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; + +/** + * 포인팅에서 스크랩 취소 (다른 포인팅이 없으면 휴지통 처리) + * @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: () => { + // 휴지통 및 검색 쿼리 갱신 + invalidateTrashMutationQueries(queryClient); + }, + }); +}; 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..16ea7706 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/deleteUnscrapFromProblem.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; + +/** + * 문제에서 스크랩 취소 (휴지통 처리) + * @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: () => { + // 휴지통 및 검색 쿼리 갱신 + invalidateTrashMutationQueries(queryClient); + }, + }); +}; diff --git a/apps/native/src/apis/controller/scrap/handwriting/deleteHandwriting.ts b/apps/native/src/apis/controller/scrap/handwriting/deleteHandwriting.ts new file mode 100644 index 00000000..b8a4fabc --- /dev/null +++ b/apps/native/src/apis/controller/scrap/handwriting/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/handwriting/putUpdateHandwriting.ts b/apps/native/src/apis/controller/scrap/handwriting/putUpdateHandwriting.ts new file mode 100644 index 00000000..b01b4a19 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/handwriting/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/handwriting/useGetHandwriting.ts b/apps/native/src/apis/controller/scrap/handwriting/useGetHandwriting.ts new file mode 100644 index 00000000..20c27241 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/handwriting/useGetHandwriting.ts @@ -0,0 +1,16 @@ +import { TanstackQueryClient } from '@apis'; + +export const useGetHandwriting = (scrapId: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/{scrapId}/handwriting', + { + params: { + path: { scrapId }, + }, + }, + { + enabled, + } + ); +}; 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..4923a842 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/index.ts @@ -0,0 +1,36 @@ +// GET APIs +export * from './useGetScrapDetail'; +export * from './useGetFoldersDetail'; +export * from './useGetFolders'; +export * from './useGetTrash'; +export * from './useSearchScraps'; +export * from './useGetScrapsByFolder'; +export * from './handwriting/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 './putUpdateFolderName'; +export * from './putUpdateFolderThumbnail'; +export * from './putMoveScraps'; +export * from './putRestoreTrash'; +export * from './handwriting/putUpdateHandwriting'; + +// DELETE APIs +export * from './deleteScrap'; +export * from './deleteFolders'; +export * from './deletePermanentTrash'; +export * from './deleteEmptyTrash'; +export * from './handwriting/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..c5a108ad --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateFolder.ts @@ -0,0 +1,30 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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_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 new file mode 100644 index 00000000..d4c4cd7b --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrap.ts @@ -0,0 +1,26 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: () => { + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); + }, + }); +}; 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..5ea82959 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromImage.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: () => { + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); + }, + }); +}; 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..c32dbc57 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromPointing.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: () => { + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); + }, + }); +}; 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..a1d783b5 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postCreateScrapFromProblem.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: () => { + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); + }, + }); +}; 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..95c4a9f1 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromPointing.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: () => { + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); + }, + }); +}; 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..66875585 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/postToggleScrapFromProblem.ts @@ -0,0 +1,28 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: () => { + // 검색 및 최근 스크랩 쿼리 갱신 + invalidateScrapMutationQueries(queryClient); + }, + }); +}; 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..a8600ee4 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putMoveScraps.ts @@ -0,0 +1,51 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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; + }, + // 낙관적 업데이트: 이동된 항목을 현재 폴더에서 즉시 제거 + 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: SCRAP_QUERY_KEYS.folderList(), + }); + // 폴더별 스크랩 목록 갱신 + invalidateFolderScrapsQueries(queryClient); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); + }, + }); +}; 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..e7ebb765 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putRestoreTrash.ts @@ -0,0 +1,27 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; + +export const useRestoreTrash = () => { + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async (request: RestoreTrashRequest): Promise => { + await client.PUT('/api/student/scrap/trash/restore', { + body: request, + }); + }, + onSuccess: () => { + // 휴지통 및 검색 쿼리 갱신 + invalidateTrashMutationQueries(queryClient); + // 폴더 목록 갱신 + queryClient.invalidateQueries({ + 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 new file mode 100644 index 00000000..f2088589 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateFolder.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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_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 new file mode 100644 index 00000000..ceb5b6c3 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateFolderName.ts @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: 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 new file mode 100644 index 00000000..413aa2ad --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateFolderThumbnail.ts @@ -0,0 +1,42 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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: 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 new file mode 100644 index 00000000..7ce3a4ad --- /dev/null +++ b/apps/native/src/apis/controller/scrap/putUpdateScrapName.ts @@ -0,0 +1,41 @@ +import { useMutation, useQueryClient } from '@tanstack/react-query'; +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']; +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_QUERY_KEYS.scrapDetail(scrapId), + }); + // 검색 결과 갱신 + invalidateScrapSearchQueries(queryClient); + }, + }); +}; 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..08f44e42 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetFolders.ts @@ -0,0 +1,5 @@ +import { TanstackQueryClient } from '@apis'; + +export const useGetFolders = () => { + return TanstackQueryClient.useQuery('get', '/api/student/scrap/folder'); +}; 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, + } + ); +}; 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..63ac63e3 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetScrapDetail.ts @@ -0,0 +1,16 @@ +import { TanstackQueryClient } from '@apis'; + +export const useGetScrapDetail = (id: number, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/{id}', + { + params: { + path: { id }, + }, + }, + { + 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 new file mode 100644 index 00000000..6f90e4b4 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useGetTrash.ts @@ -0,0 +1,5 @@ +import { TanstackQueryClient } from '@apis'; + +export const useGetTrash = () => { + return TanstackQueryClient.useQuery('get', '/api/student/scrap/trash'); +}; 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..ebe61089 --- /dev/null +++ b/apps/native/src/apis/controller/scrap/useSearchScraps.ts @@ -0,0 +1,19 @@ +import { TanstackQueryClient } from '@apis'; +import { paths } from '@/types/api/schema'; + +type SearchScrapsParams = paths['/api/student/scrap/search']['get']['parameters']['query']; + +export const useSearchScraps = (params: SearchScrapsParams = {}, enabled = true) => { + return TanstackQueryClient.useQuery( + 'get', + '/api/student/scrap/search', + { + params: { + query: params, + }, + }, + { + enabled, + } + ); +}; 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/apis/index.ts b/apps/native/src/apis/index.ts index 80174032..909de70b 100644 --- a/apps/native/src/apis/index.ts +++ b/apps/native/src/apis/index.ts @@ -7,4 +7,6 @@ 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/qna'; +export * from './controller/scrap'; +export * from './controller/study'; diff --git a/apps/native/src/components/common/ImageWithSkeleton.tsx b/apps/native/src/components/common/ImageWithSkeleton.tsx new file mode 100644 index 00000000..738b4d07 --- /dev/null +++ b/apps/native/src/components/common/ImageWithSkeleton.tsx @@ -0,0 +1,286 @@ +import { colors } from '@/theme/tokens'; +import React, { useMemo, useState } from 'react'; +import { View, Image, ImageProps, ImageStyle, DimensionValue, Animated, StyleSheet } from 'react-native'; + +type ImageWithSkeletonProps = { + source?: ImageProps['source'] | ImageProps['source'][]; + width?: DimensionValue; + height?: DimensionValue; + aspectRatio?: number; + borderRadius?: number; + resizeMode?: ImageProps['resizeMode']; + className?: string; + style?: ImageStyle; + uniqueId?: string | number; + fallback?: React.ReactNode; + /** 대각선 레이아웃 사용 여부 (true면 대각선 배치, false면 전체 영역에 표시) */ + 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%', + height, + aspectRatio, + borderRadius = 10, + resizeMode = 'cover', + className = '', + style, + uniqueId = 'default', + fallback, + isDiagonalLayout = false, +}: ImageWithSkeletonProps) => { + // 이미지 로딩 상태 관리 + const [isLoading, setIsLoading] = useState(true); + + // source가 배열인지 확인 + const isSourceArray = Array.isArray(source); + + // 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 && imageUrls.length === 0) { + return ( + + ); + } + + // 대각선 레이아웃이 아닐 때: 전체 영역에 단일 이미지 표시 + if (!isDiagonalLayout && imageUrls.length > 0) { + const singleImageSource = { uri: imageUrls[0] }; + + return ( + + {isLoading && } + setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} + style={[ + { + width, + height, + borderRadius, + aspectRatio, + }, + style, + ]} + /> + + ); + } + + // 대각선 레이아웃일 때: 대각선 배치 + if (isDiagonalLayout && imageUrls.length > 0) { + const hasSecondImage = imageUrls.length > 1 && imageUrls[1]; + const imageToShow = hasSecondImage ? imageUrls[1] : imageUrls[0]; // 1개면 오른쪽 아래에 표시 + + return ( + + {isLoading && } + {/* 왼쪽 위: 2개면 첫 번째 이미지, 1개면 회색 배경 */} + {hasSecondImage ? ( + // 2개일 때: 왼쪽 위에 첫 번째 이미지 + + setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} + style={{ + width: '100%', + height: '100%', + borderRadius: borderRadius, + }} + /> + + ) : ( + // 1개일 때: 왼쪽 위에 회색 배경 + + )} + + {/* 오른쪽 아래: 이미지 표시 (2개면 두 번째, 1개면 첫 번째) */} + {imageToShow && ( + + setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} + style={{ + width: '100%', + height: '100%', + borderRadius: borderRadius, + }} + /> + + )} + + ); + } + + // source가 배열이 아닌 단일 이미지인 경우 (기존 로직 호환성) + if (!Array.isArray(source) && source) { + return ( + + {isLoading && } + setIsLoading(true)} + onLoadEnd={() => setIsLoading(false)} + onError={() => setIsLoading(false)} + style={[ + { + width, + height, + borderRadius, + aspectRatio, + }, + style, + ]} + /> + + ); + } + + // fallback + return ( + + ); +}; + +// React.memo로 감싸서 props가 변경되지 않으면 리렌더링 방지 +export const ImageWithSkeleton = React.memo(ImageWithSkeletonComponent, (prevProps, nextProps) => { + // 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 && + sourceEqual && + prevProps.isDiagonalLayout === nextProps.isDiagonalLayout && + 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, +}; 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; 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, }; diff --git a/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx new file mode 100644 index 00000000..ffc6504e --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/ScrapCardGrid.tsx @@ -0,0 +1,329 @@ +import { Dimensions, FlatList, View } from 'react-native'; +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 './cards/ScrapHeadCard'; +import { ScrapItem, TrashItem } from '@/features/student/scrap/utils/types'; +import { useGridLayout } from '../../utils/layout/gridLayout'; +import { useState } from 'react'; + +/** + * 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 [containerWidth, setContainerWidth] = useState(0); + const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); + const finalData = addPlaceholders(data, numColumns); + + return ( + { + const width = Math.floor(e.nativeEvent.layout.width); + if (width > 0 && width !== containerWidth) { + setContainerWidth(width); + } + }} + 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 * 2 }} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={10} + initialNumToRender={10} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + width: itemWidth, + height: itemHeight, + 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; + + return ( + + + dispatch?.({ + type: 'SELECTING_ITEM', + id: scrapItem.id, + itemType: scrapItem.type, + }) + } + /> + + ); + }} + /> + ); +}; + +interface SearchScrapGridProps { + data: ScrapItem[]; +} + +export const SearchScrapGrid = ({ data }: SearchScrapGridProps) => { + const [containerWidth, setContainerWidth] = useState(0); + const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); + const finalData = addPlaceholders(data, numColumns); + + return ( + { + const width = Math.floor(e.nativeEvent.layout.width); + if (width > 0 && width !== containerWidth) { + setContainerWidth(width); + } + }} + 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}`; + }} + columnWrapperStyle={{ marginBottom: gap * 2 }} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={10} + initialNumToRender={10} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + width: itemWidth, + height: itemHeight, + marginRight: isLastColumn ? 0 : gap, + }; + + if ('placeholder' in item && item.placeholder) { + return ; + } + + if (!('id' in item) || !('type' in item)) { + return ; + } + + 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 ( + + + + ); + }} + /> + ); +}; + +interface TrashScrapGridProps { + data: TrashItem[]; + reducerState: State; + dispatch: React.Dispatch; +} + +export const TrashScrapGrid = ({ data, reducerState, dispatch }: TrashScrapGridProps) => { + const [containerWidth, setContainerWidth] = useState(0); + const { numColumns, gap, itemWidth, itemHeight } = useGridLayout(containerWidth); + const finalData = addPlaceholders(data, numColumns); + + return ( + { + const width = Math.floor(e.nativeEvent.layout.width); + if (width > 0 && width !== containerWidth) { + setContainerWidth(width); + } + }} + 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 * 2 }} + removeClippedSubviews={true} + maxToRenderPerBatch={10} + updateCellsBatchingPeriod={50} + windowSize={10} + initialNumToRender={10} + renderItem={({ item, index }) => { + const isLastColumn = (index + 1) % numColumns === 0; + + const spacingStyle = { + width: itemWidth, + height: itemHeight, + marginRight: isLastColumn ? 0 : gap, + }; + + // Check for placeholder first + 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; + + // 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 ( + + + + ); + }} + /> + ); +}; 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..07c05367 --- /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'; +import { formatToMinute } from '../../../utils/formatters/formatToMinute'; + +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} + + + {formatToMinute(new Date(scrap.updatedAt))} + + + + ); +}; 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..0937171d --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/ScrapCard.tsx @@ -0,0 +1,124 @@ +import { Pressable, View, Text, Image } from 'react-native'; +import React from 'react'; +import { Check } from 'lucide-react-native'; +import { ChevronDownFilledIcon } from '@/components/system/icons'; +import { TooltipPopover, ItemTooltipBox } from '../../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'; +import { isItemSelected } from '../../../utils/reducer'; +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'; +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: [] }; + const isSelected = isItemSelected(state.selectedItems, props.id, props.type); + const navigation = useNavigation>(); + const openNote = useNoteStore((state) => state.openNote); + const addScrap = useRecentScrapStore((state) => state.addScrap); + const { openMoveScrapModal } = useScrapModal(); + + const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; + const { imageSources, isDiagonalLayout } = useCardImageSources( + props.thumbnailUrl, + folderTop2Thumbnail + ); + + const cardContent = ( + + + + } + /> + {state.isSelecting && ( + + + + )} + + + + + + {props.name} + + {!state.isSelecting && ( + + } + children={(close) => ( + { + close(); + openMoveScrapModal({ + selectedItems: [{ id: props.id, type: props.type }], + }); + }} + /> + )} + /> + + )} + + {props.type === 'FOLDER' && props.scrapCount !== undefined && ( + {props.scrapCount} + )} + + + {props.updatedAt + ? formatToMinute(new Date(props.updatedAt)) + : formatToMinute(new Date(props.createdAt))} + + + + + ); + + 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} + + + ); +}; 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..67017870 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/ScrapHeadCard.tsx @@ -0,0 +1,102 @@ +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'; +import { State } from '../../../utils/reducer'; +import { useScrapModal } from '../../../contexts/ScrapModalsContext'; +import { formatToMinute } from '../../../utils/formatters/formatToMinute'; + +export const ScrapAddItem = ({ reducerState }: { reducerState: State }) => { + const [isQnaImageModalVisible, setisQnaImageModalVisible] = useState(false); + const isSelecting = reducerState?.isSelecting ?? false; + const { openCreateFolderModal } = useScrapModal(); + + const addItemContent = ( + + + + + + + 추가하기 + + + + ); + + return ( + <> + {isSelecting ? ( + {addItemContent} + ) : ( + void) => ( + { + close(); + setTimeout(() => { + openCreateFolderModal(); + }, 200); + }} + onOpenQnaImgModal={() => { + close(); + setTimeout(() => { + setisQnaImageModalVisible(true); + }, 200); + }} + /> + )} + from={addItemContent} + /> + )} + 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} + )} + + {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 new file mode 100644 index 00000000..7204016f --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/SearchResultCard.tsx @@ -0,0 +1,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 { useGetFolderDetail } from '@/apis'; +import { useNoteStore } from '@/stores/scrapNoteStore'; +import { ImageWithSkeleton } from '@/components/common/ImageWithSkeleton'; +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 } = useCardImageSources( + props.thumbnailUrl, + folderTop2Thumbnail + ); + + const cardContent = ( + + + + } + /> + + + + + {props.name} + + {props.type === 'FOLDER' && ( + {props.scrapCount} + )} + + + + {props.updatedAt + ? formatToMinute(new Date(props.updatedAt)) + : formatToMinute(new Date(props.createdAt))} + + + + + ); + + return ( + { + 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 new file mode 100644 index 00000000..e0dbdd62 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/TrashCard.tsx @@ -0,0 +1,147 @@ +import { Pressable, View, Text } from 'react-native'; +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 '../../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: [] }; + const isSelected = isItemSelected(state.selectedItems, props.id, props.type); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); + + const folderTop2Thumbnail = props.type === 'FOLDER' ? props.top2ScrapThumbnail : undefined; + const { imageSources, isDiagonalLayout } = useCardImageSources( + props.thumbnailUrl, + folderTop2Thumbnail + ); + + const cardContent = ( + + + + } + /> + {state.isSelecting && ( + + + + )} + + + + + + + {props.name} + + {!state.isSelecting && ( + + } + children={(close) => ( + { + close(); + setTimeout(() => { + setIsDeleteModalVisible(true); + }, 200); + }} + /> + )} + /> + + )} + + {props.type === 'FOLDER' && props.itemCount !== undefined && ( + {props.itemCount} + )} + + + {props.daysUntilPermanentDelete}일 남음 + + + + + ); + + return ( + <> + { + if (state.isSelecting) { + props.onCheckPress?.(); + return; + } + // TrashCard는 클릭 시 아무 동작도 하지 않음 + }}> + {cardContent} + + + + + 스크랩을 영구적으로 삭제합니다. + 되돌릴 수 없는 작업입니다. + + + setIsDeleteModalVisible(false)}> + 취소 + + { + try { + await permanentDelete({ + items: [ + { + id: Number(props.id), + type: props.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..e4aced48 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/cards/index.ts @@ -0,0 +1,4 @@ +export { ScrapCard } from './ScrapCard'; +export { SearchResultCard } from './SearchResultCard'; +export { TrashCard } from './TrashCard'; + 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..ded76427 --- /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, + TrashScrapCardProps, + TrashFolderCardProps, + TrashListItemProps, +} from './types'; + +// Cards +export { ScrapCard } from './cards/ScrapCard'; +export { SearchResultCard } from './cards/SearchResultCard'; +export { TrashCard } from './cards/TrashCard'; + +// Grids +export { ScrapGrid, SearchScrapGrid, TrashScrapGrid } from './ScrapCardGrid'; +export type { ScrapGridItem } from './ScrapCardGrid'; + +// Head Cards +export { ScrapAddItem, ScrapReviewItem } from './cards/ScrapHeadCard'; 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..b907483a --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Card/types.ts @@ -0,0 +1,75 @@ +import { Action, State } from '../../utils/reducer'; + +/** + * 기본 UI Props - API 스키마에 맞춤 + */ +export interface BaseItemUIProps { + /** 아이템 ID (number) */ + id: number; + /** 아이템 이름 */ + name: string; + /** 생성, 수정일시 (ISO 8601 string) */ + createdAt: string; + updatedAt?: string; + /** 썸네일 URL */ + thumbnailUrl?: string; +} + +/** + * 선택 가능한 아이템 Props + */ +export interface SelectableUIProps { + reducerState?: State; + dispatch?: React.Dispatch; + onCheckPress?: () => void; +} + +/** + * 스크랩 카드 Props + */ +export interface ScrapCardProps extends BaseItemUIProps, SelectableUIProps { + type: 'SCRAP'; + /** 소속 폴더 ID */ + folderId?: number; +} + +/** + * 폴더 카드 Props + * API의 ScrapListItemResp에서는 폴더 내 아이템 수만 제공 + */ +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/components/Dialog/ConfirmationDialog.tsx b/apps/native/src/features/student/scrap/components/Dialog/ConfirmationDialog.tsx new file mode 100644 index 00000000..89d501e3 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Dialog/ConfirmationDialog.tsx @@ -0,0 +1,34 @@ +import { Modal, TouchableWithoutFeedback, View } from 'react-native'; + +const ConfirmationDialog = ({ + className, + children, + visibleState, + setVisibleState, +}: { + className?: string; + children?: React.ReactNode; + visibleState: boolean; + setVisibleState: React.Dispatch>; +}) => { + return ( + { + setVisibleState(false); + }}> + setVisibleState(false)}> + + {}}>{children} + + + + ); +}; + +// 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/Dropdown/SortDropdown.tsx b/apps/native/src/features/student/scrap/components/Dropdown/SortDropdown.tsx new file mode 100644 index 00000000..c862dd7c --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Dropdown/SortDropdown.tsx @@ -0,0 +1,195 @@ +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, Pressable } from 'react-native'; +import { Dropdown } from 'react-native-element-dropdown'; +import type { UISortKey, SortOrder } from '../../utils/types'; + +/** + * 정렬 옵션 아이템 + */ +interface OrderItem { + label: string; + 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 { + /** 정렬 타입 (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, + 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={() => ( + { + e.stopPropagation(); + setSortOrder((prev) => (prev === 'ASC' ? 'DESC' : 'ASC')); + }} + style={styles.sortOrderButton}> + {sortOrder === 'ASC' ? ( + + ) : ( + + )} + + )} + renderItem={(item) => { + const isSelected = item.value === orderValue; + return ( + + {isSelected && } + {item.label} + + ); + }} + /> + ); +}; + +export default SortDropdown; + +const styles = StyleSheet.create({ + dropdown: { + width: 80, + height: 29, + gap: 2, + alignItems: 'center', + paddingTop: 4, + paddingRight: 4, + paddingLeft: 8, + paddingBottom: 4, + borderRadius: 4, + }, + sortOrderButton: { + width: 20, + height: 20, + alignItems: 'center', + justifyContent: 'center', + marginLeft: 2, + }, + dropdownFocus: {}, + container: { + width: 104, + borderRadius: 8, + borderWidth: 1, + justifyContent: 'center', + gap: 2, + top: 4, + + padding: 4, + }, + itemContainer: { + borderRadius: 4, + height: 28, + }, + placeholder: { + alignItems: 'center', + justifyContent: 'center', + fontSize: 14, + fontWeight: '500', + fontFamily: 'Pretendard', + lineHeight: 21, + }, + selectedText: { + fontSize: 14, + fontWeight: '500', + fontFamily: 'Pretendard', + lineHeight: 21, + flexShrink: 1, + marginRight: 4, + }, + 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', + }, +}); 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/DeletedScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx new file mode 100644 index 00000000..61f0e7c3 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Header/DeletedScrapHeader.tsx @@ -0,0 +1,111 @@ +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'; + +export interface DeletedScrapHeaderActions { + onEnterSelection?: () => void; + onExitSelection?: () => void; + onMove?: () => void; + onDelete?: () => void; + onRestore?: () => void; + onSelectAll?: () => void; +} + +interface DeletedScrapHeaderProps { + navigateback: NativeStackNavigationProp; + reducerState: State; + isAllSelected?: boolean; + actions: DeletedScrapHeaderActions; +} + +const DeletedScrapHeader = ({ + navigateback, + reducerState, + isAllSelected, + actions, +}: DeletedScrapHeaderProps) => { + const isActionEnabled = reducerState.selectedItems.length > 0; + return ( + + {!reducerState.isSelecting && ( + + {navigateback.canGoBack() ? ( + navigateback.goBack()} + className='p-2 md:right-[48px] lg:right-[96px]'> + + + + + ) : ( + + )} + + 휴지통 + + + + + + + + )} + + {reducerState.isSelecting && ( + + + + + {!isAllSelected ? '전체 선택' : '전체 해제'} + + + 스크랩 + + + 완료 + + + + + { + if (isActionEnabled && actions.onRestore) actions.onRestore(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 복구하기 + + { + 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'}`}> + + 이동하기 + + { + 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'}`}> + + 삭제하기 + + + + )} + + ); +}; + +export default DeletedScrapHeader; diff --git a/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx new file mode 100644 index 00000000..b17ee6a0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Header/ScrapHeader.tsx @@ -0,0 +1,131 @@ +import { Container } from '@/components/common'; +import { SafeAreaView } from 'react-native-safe-area-context'; +import { View, Text, Pressable } from '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'; + +export interface ScrapHeaderActions { + /** 검색 화면으로 이동 */ + onSearchPress?: () => void; + /** 휴지통 화면으로 이동 */ + onTrashPress?: () => void; + /** 선택 모드 진입 */ + onEnterSelection?: () => void; + /** 선택 모드 종료 */ + onExitSelection?: () => void; + /** 선택된 아이템 이동 */ + onMove?: () => void; + /** 선택된 아이템 삭제 */ + onDelete?: () => void; + /** 전체 선택/해제 */ + onSelectAll?: () => void; +} + +interface ScrapHeaderProps { + /** 뒤로가기 네비게이션 (옵션) */ + navigateback?: NativeStackNavigationProp; + /** 헤더 제목 */ + title?: string; + /** 선택 상태 */ + reducerState: State; + /** 전체 선택 여부 */ + isAllSelected?: boolean; + /** 액션 핸들러 객체 */ + actions: ScrapHeaderActions; +} + +const ScrapHeader = ({ + navigateback, + title = '스크랩', + reducerState, + isAllSelected, + actions, +}: ScrapHeaderProps) => { + const isActionEnabled = reducerState.selectedItems.length > 0; + + return ( + + {!reducerState.isSelecting && ( + + {navigateback && navigateback.canGoBack() && ( + navigateback.goBack()} + className='p-2 md:right-[48px] lg:right-[96px]'> + + + + + )} + {navigateback ? ( + + {title} + + ) : ( + {title} + )} + + + + + + + + + + + + + )} + + {reducerState.isSelecting && ( + + + + + {!isAllSelected ? '전체 선택' : '전체 해제'} + + + {title} + + + 완료 + + + + + { + if (isActionEnabled && actions.onMove) actions.onMove(); + }} + className={`flex-col items-center justify-center gap-0.5 rounded-[8px] p-[6px] ${reducerState.selectedItems.length > 0 ? '' : 'opacity-30'}`}> + + 이동하기 + + { + 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'}`}> + + 삭제하기 + + + + )} + + ); +}; + +export default ScrapHeader; diff --git a/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx b/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.tsx new file mode 100644 index 00000000..1c483ef0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Header/SearchScrapHeader.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; 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 new file mode 100644 index 00000000..eddc0341 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/CreateFolderModal.tsx @@ -0,0 +1,134 @@ +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 '../Notification/Toast'; +import { openImageLibraryWithErrorHandling } from '../../utils/images/imagePicker'; +import { colors } from '@/theme/tokens'; +import { uploadImageToS3 } from '../../utils/images/imageUpload'; +import * as ImagePicker from 'expo-image-picker'; +import { ImageIcon } from 'lucide-react-native'; +import { useScrapModal } from '../../contexts/ScrapModalsContext'; +import { usePreSignedUrlAdapter } from '../../hooks'; + +export const CreateFolderModal = () => { + const { isCreateFolderModalVisible, closeCreateFolderModal, refetchFolders, refetchScraps } = + useScrapModal(); + const [folderName, setFolderName] = useState(''); + const [selectedImage, setSelectedImage] = useState(null); + const { mutateAsync: createFolder } = useCreateFolder(); + const getPreSignedUrl = usePreSignedUrlAdapter(); + + // 모달이 닫힐 때 상태 초기화 + useEffect(() => { + if (!isCreateFolderModalVisible) { + setFolderName(''); + setSelectedImage(null); + } + }, [isCreateFolderModalVisible]); + + const onPressGallery = async () => { + const image = await openImageLibraryWithErrorHandling((error) => { + if (error.message?.includes('permission')) { + showToast('error', '갤러리 권한이 필요합니다.'); + } else { + console.error('갤러리 오류:', error); + showToast('error', '갤러리를 사용할 수 없습니다.'); + } + }); + + if (image) { + setSelectedImage(image); + } + }; + + const handleCreate = async () => { + if (!folderName.trim()) { + showToast('error', '폴더 이름을 입력해주세요.'); + return; + } + + // 이미지가 있는 경우 먼저 업로드 + if (selectedImage) { + setTimeout(() => { + closeCreateFolderModal(); + }, 0); + const success = await uploadImageToS3( + selectedImage, + getPreSignedUrl, + async (result) => { + // 폴더 생성 (이미지 ID 포함) + await createFolder({ + name: folderName, + thumbnailImageId: result.fileId, + }); + showToast('success', '폴더가 추가되었습니다.'); + refetchFolders?.(); + refetchScraps?.(); + }, + (error) => { + showToast('error', error); + } + ); + + if (!success) { + return; + } + } else { + try { + await createFolder({ name: folderName }); + showToast('success', '폴더가 추가되었습니다.'); + refetchFolders?.(); + setTimeout(() => { + closeCreateFolderModal(); + }, 0); + } catch (error) { + showToast('error', '폴더 추가에 실패했습니다.'); + } + } + }; + + const handleCancel = () => { + closeCreateFolderModal(); + }; + + return ( + + + + + + {selectedImage ? ( + + ) : ( + + + + )} + + + + + + + + + ); +}; 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..0619953d --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/FullScreenModal.tsx @@ -0,0 +1,84 @@ +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 '../Notification/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} + + + + + ); +}; 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..e61f0e36 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/LoadQnaImageModal.tsx @@ -0,0 +1,175 @@ +import { Container } from '@/components/common'; +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; + onClose: () => void; + onSuccess?: () => void; +} + +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 toggleSelect = (id: number) => { + setSelectedId((prev) => (prev === id ? null : id)); + }; + + // 선택된 이미지로 스크랩 생성 (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 new file mode 100644 index 00000000..74aa1ad0 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Modal/MoveScrapModal.tsx @@ -0,0 +1,168 @@ +import React, { useMemo, useCallback, useEffect } from 'react'; +import { View, Text, Pressable, ScrollView } from 'react-native'; +import { FolderPlus } from 'lucide-react-native'; +import { PopUpModal } from '../Dialog'; +import { ScrapGrid } from '../Card/ScrapCardGrid'; +import { useGetFolders, useMoveScraps } from '@/apis'; +import { showToast } from '../Notification/Toast'; +import { reducer, initialSelectionState } from '../../utils/reducer'; +import { useReducer } from 'react'; +import { useScrapModal } from '../../contexts/ScrapModalsContext'; + +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 { data: foldersData, refetch: refetchFolders } = useGetFolders(); + const { mutateAsync: moveScraps } = useMoveScraps(); + + // refetchFolders를 context에 등록 + useEffect(() => { + if (refetchFolders) { + setRefetchFolders(refetchFolders); + } + }, [refetchFolders, setRefetchFolders]); + + // 모달 상태에 따른 선택 모드 관리 + useEffect(() => { + if (isMoveScrapModalVisible) { + // 모달이 열릴 때 선택 모드 활성화 + dispatch({ type: 'ENTER_SELECTION' }); + } else { + // 모달이 닫힐 때 선택 상태 초기화 + dispatch({ type: 'CLEAR_SELECTION' }); + } + }, [isMoveScrapModalVisible]); + + // 폴더만 필터링 + const folders = useMemo(() => { + if (!foldersData?.data) return []; + + return foldersData.data + .filter((folder) => folder.id !== currentFolderId) + .map((folder) => ({ + ...folder, + type: 'FOLDER' as const, + })); + }, [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' }); + refetchFolders?.(); + refetchScraps?.(); + closeMoveScrapModal(); + } catch (error) { + showToast('error', '이동 중 오류가 발생했습니다.'); + } + }; + + const folderName = folders.find((folder) => folder.id === selectedFolderId)?.name; + + return ( + + + + + 취소 + + + + {selectedItems.length}개 스크랩 이동하기 + + + openCreateFolderModal()}> + + 새로운 폴더 + + + + + + + + + + {selectedFolderId ? `'${folderName}' 폴더로 이동하기` : '이동할 폴더를 선택해주세요'} + + + + + + ); +}; 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/Notification/Toast.tsx b/apps/native/src/features/student/scrap/components/Notification/Toast.tsx new file mode 100644 index 00000000..3ff2c3f2 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Notification/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/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 new file mode 100644 index 00000000..4337bb8c --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/AddScrapTooltip.tsx @@ -0,0 +1,127 @@ +import { Camera, Image, Images, FolderPlus } from 'lucide-react-native'; +import { View, Text, Pressable, Alert } from 'react-native'; +import { + openCamera, + openCameraWithErrorHandling, + openImageLibrary, + openImageLibraryWithErrorHandling, +} from '../../utils/images/imagePicker'; +import { useCreateScrapFromImage } from '@/apis'; +import { uploadImageToS3 } from '../../utils/images/imageUpload'; +import { usePreSignedUrlAdapter } from '../../hooks'; + +export interface AddScrapTooltipProps { + onClose?: () => void; + onOpenFolderModal?: () => void; + onOpenQnaImgModal?: () => void; +} + +// Backward compatibility +export type AddItemTooltipProps = AddScrapTooltipProps; + +export const AddScrapTooltip = ({ + onClose, + onOpenQnaImgModal, + onOpenFolderModal, +}: AddScrapTooltipProps) => { + const { mutate: createScrapFromImage } = useCreateScrapFromImage(); + const getPreSignedUrl = usePreSignedUrlAdapter(); + + // 이미지 선택 및 업로드 처리 + const handleImageSelect = async (image: any) => { + if (!image || !image.uri) { + return; + } + + await uploadImageToS3( + image, + getPreSignedUrl, + async (result) => { + // 이미지 기반 스크랩 생성 + createScrapFromImage( + { + imageId: result.fileId, + }, + { + onSuccess: () => { + Alert.alert('성공', '스크랩이 생성되었습니다.'); + onClose?.(); + }, + onError: (error) => { + console.error('스크랩 생성 실패:', error); + Alert.alert('오류', '스크랩 생성에 실패했습니다.'); + }, + } + ); + }, + (error) => { + Alert.alert('오류', error); + } + ); + }; + + const onPressCamera = async () => { + 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 () => { + const image = await openImageLibraryWithErrorHandling((error) => { + if (error.message?.includes('permission')) { + Alert.alert('권한 필요', '갤러리 권한이 필요합니다.'); + } else { + console.error('갤러리 오류:', error); + } + }); + + if (image) { + await handleImageSelect(image); + } + }; + + return ( + + onPressCamera()}> + + 사진 찍기 + + onPressGallery()}> + + 이미지 선택 + + { + onOpenQnaImgModal?.(); + }}> + + QnA 사진 불러오기 + + { + onOpenFolderModal?.(); + }}> + + 폴더 추가하기 + + + ); +}; + +// 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 new file mode 100644 index 00000000..f9baf542 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/ReviewScrapTooltip.tsx @@ -0,0 +1,49 @@ +import { FolderOpen } from 'lucide-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 ReviewScrapTooltipProps { + props: ScrapListItemProps; + onClose?: () => void; +} + +// Backward compatibility +export type ReviewItemTooltipProps = ReviewScrapTooltipProps; + +export const ReviewScrapTooltip = ({ props, onClose }: ReviewScrapTooltipProps) => { + const navigation = useNavigation>(); + + const handleOpenReview = () => { + onClose?.(); + setTimeout(() => { + navigation.push('ScrapContent', { id: props.id }); + }, 100); + }; + + return ( + + + 오답노트 + + + }> + } + 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 new file mode 100644 index 00000000..40f62d15 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/ScrapItemTooltip.tsx @@ -0,0 +1,219 @@ +import { colors } from '@/theme/tokens'; +import { + ArrowRightLeft, + BookImage, + BookOpenText, + FileSymlink, + FolderOpen, + ImagePlay, + Trash2, +} from 'lucide-react-native'; +import { useState } from 'react'; +import { TextInput, View, Text, Pressable, Alert } from 'react-native'; +import { showToast } from '../Notification/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, + useUpdateFolderName, + useUpdateFolderThumbnail, + useDeleteScrap, + useGetScrapDetail, + useGetFolders, +} from '@/apis'; +import { useNoteStore } from '@/stores/scrapNoteStore'; +import { + openImageLibrary, + openImageLibraryWithErrorHandling, +} from '../../utils/images/imagePicker'; +import { uploadImageToS3 } from '../../utils/images/imageUpload'; +import { usePreSignedUrlAdapter } from '../../hooks'; + +export interface ScrapItemTooltipProps { + props: ScrapListItemProps; + onClose?: () => void; + onMovePress?: () => void; // 추가 +} + +// Backward compatibility +export type ItemTooltipProps = ScrapItemTooltipProps; + +export const ScrapItemTooltip = ({ props, onClose, onMovePress }: ScrapItemTooltipProps) => { + const navigation = useNavigation>(); + + const openNote = useNoteStore((state) => state.openNote); + + // API hooks + const { mutateAsync: updateScrapName } = useUpdateScrapName(); + 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 getPreSignedUrl = usePreSignedUrlAdapter(); + + const handleUpdateFolderCover = async (image: any) => { + if (!image || !image.uri) { + return; + } + + if (props.type !== 'FOLDER') { + return; + } + + await uploadImageToS3( + image, + getPreSignedUrl, + async (result) => { + // 폴더 썸네일만 업데이트 + await updateFolderThumbnail({ + id: props.id, + request: { + 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); + } + }; + + // 초기 제목 설정 + 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 updateFolderName({ + 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: props.id }); + } else { + openNote({ id: props.id, title: props.name }); + navigation.push('ScrapContentDetail', { id: props.id }); + } + }, 100); + }}> + + {props.type === 'FOLDER' ? ( + 폴더 열기 + ) : ( + 스크랩 열기 + )} + + {props.type === 'FOLDER' && ( + + + 표지 변경하기 + + )} + {props.type === 'SCRAP' && ( + { + handleClose(); + setTimeout(() => { + onMovePress?.(); + }, 100); + }}> + + 폴더 이동하기 + + )} + { + handleClose(); + + try { + await deleteScrap({ + items: [ + { + id: props.id, + type: props.type as 'FOLDER' | 'SCRAP', + }, + ], + }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }}> + + 휴지통으로 이동 + + + ); +}; + +// 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/TooltipPopover.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx new file mode 100644 index 00000000..64f35449 --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/TooltipPopover.tsx @@ -0,0 +1,59 @@ +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)} + className={`${isVisible ? 'aspect-square rounded-[4px] bg-gray-400' : ''} items-center`}> + {from} + + ); + + return ( + + {typeof children === 'function' ? children(close) : children} + + ); +}; + +export default TooltipPopover; diff --git a/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx b/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx new file mode 100644 index 00000000..fa7b22bc --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/TrashScrapTooltip.tsx @@ -0,0 +1,66 @@ +import { colors } from '@/theme/tokens'; +import { Trash2, Undo2 } from 'lucide-react-native'; +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 TrashScrapTooltipProps { + item: TrashListItemProps; + onClose?: () => void; + onDeletePress?: () => void; +} + +// Backward compatibility +export type TrashItemTooltipProps = TrashScrapTooltipProps; + +export const TrashScrapTooltip = ({ item, onClose, onDeletePress }: TrashScrapTooltipProps) => { + const { mutateAsync: restoreTrash } = useRestoreTrash(); + + 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 ( + + } + 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 new file mode 100644 index 00000000..55d120af --- /dev/null +++ b/apps/native/src/features/student/scrap/components/Tooltip/index.ts @@ -0,0 +1,20 @@ +export { default as TooltipPopover } from './TooltipPopover'; +export type { TooltipPopoverProps } from './TooltipPopover'; + +export { ScrapItemTooltip, ItemTooltip } from './ScrapItemTooltip'; +export type { ScrapItemTooltipProps, ItemTooltipProps } from './ScrapItemTooltip'; + +export { AddScrapTooltip, AddItemTooltip } from './AddScrapTooltip'; +export type { AddScrapTooltipProps, AddItemTooltipProps } from './AddScrapTooltip'; + +export { ReviewScrapTooltip, ReviewItemTooltip } from './ReviewScrapTooltip'; +export type { ReviewScrapTooltipProps, ReviewItemTooltipProps } from './ReviewScrapTooltip'; + +export { TrashScrapTooltip, TrashItemTooltip } from './TrashScrapTooltip'; +export type { TrashScrapTooltipProps, TrashItemTooltipProps } from './TrashScrapTooltip'; + +// 하위 호환성을 위한 별칭 export +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/ScrapModalsContext.tsx b/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx new file mode 100644 index 00000000..61c96889 --- /dev/null +++ b/apps/native/src/features/student/scrap/contexts/ScrapModalsContext.tsx @@ -0,0 +1,109 @@ +import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react'; +import type { SelectedItem } from '../utils/reducer'; + +interface ScrapModalsContextValue { + // 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; +} + +// Backward compatibility +export type ScrapModalContextValue = ScrapModalsContextValue; + +const ScrapModalsContext = createContext(undefined); + +// Backward compatibility +const ScrapModalContext = ScrapModalsContext; + +export const useScrapModals = () => { + const context = useContext(ScrapModalsContext); + if (!context) { + throw new Error('useScrapModals must be used within ScrapModalsProvider'); + } + return context; +}; + +// Backward compatibility +export const useScrapModal = useScrapModals; + +interface ScrapModalsProviderProps { + children: ReactNode; +} + +export const ScrapModalsProvider = ({ children }: ScrapModalsProviderProps) => { + 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}; +}; + +// 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..180198c7 --- /dev/null +++ b/apps/native/src/features/student/scrap/hooks/useCardImageSources.ts @@ -0,0 +1,48 @@ +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(() => { + if (thumbnailUrl) { + return { + imageSources: [{ uri: thumbnailUrl }], + isDiagonalLayout: false, + }; + } + + if (folderTop2Thumbnail && folderTop2Thumbnail.length > 0 && !thumbnailUrl) { + return { + imageSources: folderTop2Thumbnail.slice(0, 2).map((url) => ({ uri: url })), + isDiagonalLayout: true, + }; + } + + 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/index.ts b/apps/native/src/features/student/scrap/index.ts index 6dcbdcde..0c67dca7 100644 --- a/apps/native/src/features/student/scrap/index.ts +++ b/apps/native/src/features/student/scrap/index.ts @@ -1,3 +1,6 @@ import ScrapScreen from './screens/ScrapScreen'; +import FolderScrapScreen from './screens/FolderScrapScreen'; +import DeletedScrapScreen from './screens/DeletedScrapScreen'; +import SearchScrapScreen from './screens/SearchScrapScreen'; -export { ScrapScreen }; +export { ScrapScreen, FolderScrapScreen, DeletedScrapScreen, SearchScrapScreen }; 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..09551fb8 --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/DeletedScrapScreen.tsx @@ -0,0 +1,182 @@ +import React, { useMemo, useState } from 'react'; +import { Pressable, Text, View } from 'react-native'; +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/Dropdown/SortDropdown'; +import { sortScrapData } from '../utils/formatters/sortScrap'; +import type { UISortKey, SortOrder } from '../utils/types'; +import { PopUpModal } from '../components/Dialog'; +import { showToast } from '../components/Notification/Toast'; +import { useGetTrash, useRestoreTrash, usePermanentDeleteTrash } from '@/apis'; +import { useScrapModal } from '../contexts/ScrapModalsContext'; +import { useScrapSelection } from '../hooks'; +import { validateOnlyScrapCanMove } from '../utils/validation'; +import { withScrapModals } from '../hoc'; + +const DeletedScrapScreenContent = () => { + const [reducerState, dispatch] = useScrapSelection(); + const [sortKey, setSortKey] = useState('TYPE'); + const [sortOrder, setSortOrder] = useState('DESC'); + const [isDeleteModalVisible, setIsDeleteModalVisible] = useState(false); + + const navigation = useNavigation>(); + const { openMoveScrapModal } = useScrapModal(); + + // API 호출 + const { data: trashData, isLoading } = useGetTrash(); + const { mutateAsync: restoreTrash } = useRestoreTrash(); + const { mutateAsync: permanentDelete } = usePermanentDeleteTrash(); + + const trashItems = trashData?.data || []; + + const sortedData = useMemo(() => { + const itemsWithCreatedAt = trashItems.map((item) => ({ + ...item, + createdAt: item.deletedAt, + })); + + 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 ( + + { + 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 }); + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '선택된 파일들이 복구되었습니다.'); + } catch (error) { + showToast('error', '복구 중 오류가 발생했습니다.'); + } + }, + }} + /> + + + + 휴지통의 스크랩은 30일 이후에 영구적으로 삭제됩니다. + + + + + {isLoading ? ( + + ) : ( + + )} + + + setIsDeleteModalVisible(false)} + selectedCount={reducerState.selectedItems.length} + onConfirm={handlePermanentDelete} + /> + + ); +}; + +const DeletedScrapScreen = () => { + return ; +}; + +export default withScrapModals(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/FolderScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx new file mode 100644 index 00000000..cfbcb1b4 --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/FolderScrapScreen.tsx @@ -0,0 +1,138 @@ +import { View } from 'react-native'; +import ScrapHeader from '../components/Header/ScrapHeader'; +import { useMemo, useState, useEffect } from 'react'; +import { sortScrapData, mapUIKeyToAPIKey } from '../utils/formatters/sortScrap'; +import 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/Dropdown/SortDropdown'; +import { ScrapGrid } from '../components/Card/ScrapCardGrid'; +import { showToast } from '../components/Notification/Toast'; +import { useGetScrapsByFolder, useDeleteScrap, useGetFolders } from '@/apis'; +import { useScrapModal } from '../contexts/ScrapModalsContext'; +import { useScrapSelection } from '../hooks'; +import { validateOnlyScrapCanMove } from '../utils/validation'; +import { withScrapModals } from '../hoc'; + +type FolderScrapRouteProp = RouteProp; + +const FolderScrapScreenContent = () => { + const route = useRoute(); + const { id } = route.params; + + const [reducerState, dispatch] = useScrapSelection(); + const [sortKey, setSortKey] = useState('TITLE'); + const [sortOrder, setSortOrder] = useState('ASC'); + const navigation = useNavigation>(); + const { openMoveScrapModal, setRefetchScraps, setRefetchFolders } = useScrapModal(); + + // API 호출 + 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 || []; + + // 정렬된 데이터 + const sortedData = useMemo( + () => sortScrapData(contents, sortKey, sortOrder), + [contents, sortKey, sortOrder] + ); + + const isAllSelected = + reducerState.selectedItems.length === contents.length && contents.length > 0; + + return ( + <> + + 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; + + await deleteScrap({ items }); + + dispatch({ type: 'CLEAR_SELECTION' }); + showToast('success', '휴지통으로 이동해 한 달 후 영구 삭제됩니다.'); + } catch (error: any) { + showToast('error', '삭제 중 오류가 발생했습니다.'); + } + }, + }} + /> + + + + + + {isLoading ? ( + + ) : ( + + )} + + + + + ); +}; + +const FolderScrapScreen = () => { + return ; +}; + +export default withScrapModals(FolderScrapScreen); diff --git a/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx new file mode 100644 index 00000000..2763bdb9 --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/ScrapDetailScreen.tsx @@ -0,0 +1,877 @@ +import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react'; +import { + View, + Text, + Image, + ScrollView, + Pressable, + LayoutChangeEvent, + Modal, + Dimensions, + ActivityIndicator, + Alert, +} 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, Save } from 'lucide-react-native'; +import { Gesture, GestureDetector } from 'react-native-gesture-handler'; +import Animated, { + useSharedValue, + useAnimatedStyle, + withSpring, + SharedValue, + runOnJS, +} from 'react-native-reanimated'; +import { Container, SegmentedControl, TextButton } from '@/components/common'; +import { StudentRootStackParamList } from '@/navigation/student/types'; +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/formatters/toAlphabetSequence'; +import { components } from '@/types/api/schema'; +import DrawingCanvas, { DrawingCanvasRef, Stroke, TextItem } from '../utils/skia/drawing'; +import { colors } from '@/theme/tokens'; + +type ScrapDetailRouteProp = RouteProp; + +const ScrapDetailScreen = () => { + const route = useRoute(); + const navigation = useNavigation>(); + const { id } = route.params; + const scrapId = 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 [hasUnsavedChanges, setHasUnsavedChanges] = useState(false); + const [showSave, setShowSave] = useState(false); + const lastSavedDataRef = useRef(''); + + 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>( + {} + ); + const [selectedFilter, setSelectedFilter] = useState(0); // 0: 전체, 1: 문제, 2+: 포인팅 인덱스 + const [isProblemExpanded, setIsProblemExpanded] = useState(false); + const [isHoveringProblem, setIsHoveringProblem] = useState(false); + + useEffect(() => { + if (activeNoteId && activeNoteId !== scrapId) { + navigation.setParams({ id: String(activeNoteId) }); + } + }, [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) { + 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 || []); + } + + // 초기 데이터를 저장된 데이터로 설정 + lastSavedDataRef.current = handwritingData.data; + setHasUnsavedChanges(false); + } catch (error) { + console.error('필기 데이터 로드 실패:', error); + } + } + }, [handwritingData]); + + // 저장하기 버튼 핸들러 + 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; + } + + 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; + } + + 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('오류', '필기 데이터 변환에 실패했습니다.'); + } + } + }, + [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 (pointingsWithLabels.length > 0) { + pointingsWithLabels.forEach((pointing) => { + options.push(`포인팅 ${pointing.label}`); + }); + } + return options; + }, [scrapDetail, pointingsWithLabels]); + + // 필터에 따른 표시 여부 결정 + 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 (!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 < scrapDetail.pointings.length; + }, [scrapDetail?.pointings, selectedFilter]); + + // 스크랩 데이터를 AllPointings에 전달할 형식으로 변환 + const convertScrapToGroup = useCallback((): + | components['schemas']['PublishProblemGroupResp'] + | null => { + if (!scrapDetail?.problem) return null; + + // PointingResp를 PointingWithFeedbackResp로 변환 + const pointingsWithFeedback: components['schemas']['PointingWithFeedbackResp'][] = + scrapDetail.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: 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, // 스크랩에서는 제출 답안이 없음 + isCorrect: false, // 스크랩에서는 정답 여부가 없음 + isDone: false, // 스크랩에서는 완료 여부가 없음 + childProblems: [], // 스크랩에는 childProblems가 없음 + }; + + return { + no: 1, // 스크랩에서는 번호가 없으므로 1로 설정 + problemId: scrapDetail.problem.id, + progress: 'DONE', // 스크랩된 문제는 완료된 것으로 간주 + problem: problemWithStudyInfo, + childProblems: [], + }; + }, [scrapDetail]); + + // 전체보기 버튼 클릭 핸들러 + const handleViewAllPointings = useCallback(() => { + const group = convertScrapToGroup(); + if (!group) return; + + navigation.navigate('AllPointings', { + group, + problemSetTitle: scrapDetail?.name || '스크랩', + }); + }, [convertScrapToGroup, navigation, scrapDetail?.name]); + + if (isLoading) { + return ; + } + + if (!scrapDetail) { + return ( + + 스크랩을 찾을 수 없습니다. + + ); + } + + const scrap = scrapDetail; + + return ( + + + + {navigation.canGoBack() && ( + navigation.goBack()} + className='p-2 md:right-[48px] lg:right-[96px]'> + + + + + )} + {showSave && } + {scrap.name || '스크랩 상세'} + {handwritingData?.updatedAt} + + {openNotes.length > 1 && ( + + { + const offsetX = event.nativeEvent.contentOffset.x; + scrollX.value = offsetX; // 실시간 업데이트 + }} + scrollEventThrottle={1}> + {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} + scrollViewRef={scrollViewRef as React.RefObject} + scrollX={scrollX} + screenWidth={screenWidth} + /> + ))} + + + )} + + + + + {/* 필터 버튼 및 전체보기 */} + {filterOptions.length > 0 && ( + + + {scrap.pointings && scrap.pointings.length > 0 && scrap.problem && ( + + + 전체보기 + + + )} + + )} + {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); + + setTimeout(() => { + setIsHoveringProblem(false); + }, 2000); + }}> + + {isHoveringProblem && ( + setIsProblemExpanded(true)} + className='absolute right-2 top-2 z-10 rounded-full bg-black/50 p-2'> + + + )} + + + )} + {/* 포인팅 */} + {hasVisiblePointings && ( + + 포인팅 + + {pointingsWithLabels.map((pointing, idx) => { + if (!shouldShowPointing(idx)) return null; + const sectionKey = `pointing-${pointing.id}`; + const isCommentExpanded = expandedSections[sectionKey]?.comment ?? false; + + return ( + + + 포인팅 {pointing.label} + 포인팅 질문 + + {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 && ( + + )} + + )} + + ); + })} + + + )} + + + + + + + + {/* 하단 제어 버튼 */} + + + { + 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()} + disabled={!canUndo} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + canUndo ? 'bg-gray-200' : 'bg-gray-100' + }`}> + undo + + canvasRef.current?.redo()} + disabled={!canRedo} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + canRedo ? 'bg-gray-200' : 'bg-gray-100' + }`}> + redo + + handleSave(false)} + disabled={isSaving} + className={`flex-1 items-center justify-center rounded-lg py-3 ${ + isSaving ? 'bg-gray-400' : 'bg-blue-600' + }`}> + {isSaving ? '저장 중...' : '저장하기'} + + + + + + + {/* 문제 확대 모달 */} + + setIsProblemExpanded(false)}> + setIsProblemExpanded(false)}> + 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 && ( + + )} + + + + + + ); +}; + +// DraggableTabProps 인터페이스 수정 +interface DraggableTabProps { + note: Note; + index: number; + isActive: boolean; + onPress: () => void; + onClose: () => void; + onLayout: (event: LayoutChangeEvent) => void; + onDragEnd: (fromIndex: number, toIndex: number) => void; + tabLayouts: Record; + scrollViewRef: React.RefObject; + scrollX: SharedValue; + screenWidth: number; +} + +// DraggableTab 컴포넌트 수정 +const DraggableTab = ({ + note, + index, + isActive, + onPress, + onClose, + 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]; + + 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 ScrapDetailScreen; diff --git a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx index 098adabc..b3a19b8d 100644 --- a/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx +++ b/apps/native/src/features/student/scrap/screens/ScrapScreen.tsx @@ -1,12 +1,188 @@ -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, { useMemo, useState } from 'react'; +import { View, Text, ScrollView, Pressable, ImageBackground } from 'react-native'; +import ScrapHeader from '../components/Header/ScrapHeader'; +import { ScrapGrid } from '../components/Card/ScrapCardGrid'; +import SortDropdown from '../components/Dropdown/SortDropdown'; +import { useRecentScrapStore } from '@/stores/recentScrapStore'; +import { sortScrapData, mapUIKeyToAPIKey } from '../utils/formatters/sortScrap'; +import type { UISortKey, SortOrder, ScrapSearchResponse } from '../utils/types'; +import { showToast } from '../components/Notification/Toast'; +import { useSearchScraps, useDeleteScrap } from '@/apis'; +import { validateOnlyScrapCanMove } from '../utils/validation'; +import { useQueries } from '@tanstack/react-query'; +import { TanstackQueryClient } from '@/apis'; +import { RecentScrapCard } from '../components/Card/cards/RecentScrapCard'; +import { useScrapModal } from '../contexts/ScrapModalsContext'; +import { useScrapSelection } from '../hooks'; +import { withScrapModals } from '../hoc'; + +const ScrapScreenContent = () => { + const [reducerState, dispatch] = useScrapSelection(); + const [sortKey, setSortKey] = useState('DATE'); + const [sortOrder, setSortOrder] = useState('DESC'); + const navigation = useNavigation>(); + const recentScraps = useRecentScrapStore((state) => state.scrapIds); + const { openMoveScrapModal, setRefetchScraps } = useScrapModal(); + + 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 + ? 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 typedSearchData = searchData as ScrapSearchResponse; + const folders = (typedSearchData.folders || []).map((folder) => ({ + ...folder, + type: 'FOLDER' as const, + })); + const scraps = (typedSearchData.scraps || []).filter((scrap) => scrap.folderId == null); + return [...folders, ...scraps]; + }, [searchData]); + + // 클라이언트 사이드 정렬 (TYPE 정렬 등 추가 정렬 로직 적용) + const sortedData = useMemo( + () => sortScrapData(data, sortKey, sortOrder), + [data, sortKey, sortOrder] + ); + + const isAllSelected = data.length > 0 && reducerState.selectedItems.length === data.length; -const ScrapScreen = () => { return ( - - Scrap - + <> + + 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', '삭제 중 오류가 발생했습니다.'); + } + }, + }} + /> + + {recentScrapsData.length > 0 && ( + + 최근 본 + + {recentScrapsData.map((scrap) => ( + + ))} + + + )} + + 전체 스크랩 + + + + {isLoading ? ( + + ) : ( + + )} + + + + ); }; -export default ScrapScreen; +const ScrapScreen = () => { + return ; +}; + +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 new file mode 100644 index 00000000..8f6cdac3 --- /dev/null +++ b/apps/native/src/features/student/scrap/screens/SearchScrapScreen.tsx @@ -0,0 +1,117 @@ +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, 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/searchHistoryStore'; +import SearchScrapHeader from '../components/Header/SearchScrapHeader'; +import { useSearchScraps } from '@/apis'; + +const SearchScrapScreen = () => { + const navigation = useNavigation>(); + const [query, setQuery] = useState(''); + const [debouncedQuery, setDebouncedQuery] = useState(''); + const { keywords, addKeyword, removeKeyword, clear } = useSearchHistoryStore(); + + // 디바운스: 입력이 멈춘 후 300ms 후에 검색어 업데이트 + useEffect(() => { + const timer = setTimeout(() => { + setDebouncedQuery(query.trim()); + }, 300); + + return () => clearTimeout(timer); + }, [query]); + + // API 검색 (디바운스된 쿼리 사용) + const { data: searchData } = useSearchScraps( + { + query: debouncedQuery, + sort: 'CREATED_AT', + order: 'DESC', + }, + // 쿼리가 있을 때만 검색 + debouncedQuery.length > 0 + ); + + // ScrapSearchResp는 folders와 scraps를 각각 반환하므로 분리해서 표시 + const folders = React.useMemo(() => { + if (!searchData) return []; + return (searchData.folders || []).map((folder) => ({ + ...folder, + type: 'FOLDER' as const, + })); + }, [searchData]); + + const scraps = React.useMemo(() => { + if (!searchData) return []; + return searchData.scraps || []; + }, [searchData]); + + const onSearch = () => { + if (!query.trim()) return; + addKeyword(query); + // 검색 버튼을 누르면 즉시 검색 + setDebouncedQuery(query.trim()); + }; + + return ( + + + + {query.length === 0 && ( + + 최근 검색어 + clear()}> + 전체 지우기 + + + )} + {query.length === 0 && ( + + {keywords.map((item, i) => ( + + setQuery(item)}> + {item} + + removeKeyword(item)}> + + + + ))} + + )} + + + {query.length > 0 && folders.length > 0 && ( + + 폴더 + + + )} + + {query.length > 0 && scraps.length > 0 && ( + + 스크랩 + + + )} + + + ); +}; + +export default 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/formatters/formatToMinute.ts b/apps/native/src/features/student/scrap/utils/formatters/formatToMinute.ts new file mode 100644 index 00000000..380c91bf --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/formatters/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, + }); +} 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/formatters/sortScrap.ts b/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts new file mode 100644 index 00000000..3b6422c3 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/formatters/sortScrap.ts @@ -0,0 +1,82 @@ +import { + type ScrapItem, + type TrashItem, + type UISortKey, + type SortOrder, + parseTimestamp, +} from '@/features/student/scrap/utils/types'; + +/** + * 스크랩 데이터 정렬 함수 + * + * @param list - 정렬할 스크랩/휴지통 아이템 목록 + * @param key - 정렬 키 (TYPE, TITLE, DATE) + * @param order - 정렬 방향 (ASC, DESC) + * @returns 정렬된 아이템 목록 + * + * @description + * - TYPE: 폴더가 스크랩보다 먼저 표시, 같은 타입 내에서는 생성일시 기준 + * - TITLE: 이름 기준 (한글 로케일 지원) + * - DATE: 생성일시 기준 + */ +export const sortScrapData = ( + list: T[], + key: UISortKey, + order: SortOrder +): T[] => { + const mul = order === 'ASC' ? 1 : -1; + + const sorted = [...list].sort((a, b) => { + switch (key) { + case 'TYPE': { + // 타입 우선 정렬 (FOLDER가 SCRAP보다 먼저) + if (a.type !== b.type) { + return (a.type === 'FOLDER' ? -1 : 1) * mul; + } + // 같은 타입: 생성일시 기준 + const timestampA = parseTimestamp(a.createdAt); + const timestampB = parseTimestamp(b.createdAt); + return (timestampA - timestampB) * mul; + } + + case 'TITLE': { + // 이름 기준 (한글 로케일 지원) + return a.name.localeCompare(b.name, 'ko', { numeric: true }) * mul; + } + + case 'DATE': { + // 생성일시 기준 + const timestampA = parseTimestamp(a.createdAt); + const timestampB = parseTimestamp(b.createdAt); + return (timestampA - timestampB) * mul; + } + + default: + return 0; + } + }); + + 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'; + } +}; diff --git a/apps/native/src/features/student/scrap/utils/formatters/toAlphabetSequence.ts b/apps/native/src/features/student/scrap/utils/formatters/toAlphabetSequence.ts new file mode 100644 index 00000000..dfb0a938 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/formatters/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/features/student/scrap/utils/images/imagePicker.ts b/apps/native/src/features/student/scrap/utils/images/imagePicker.ts new file mode 100644 index 00000000..6595657b --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/images/imagePicker.ts @@ -0,0 +1,72 @@ +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') { + 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/apps/native/src/features/student/scrap/utils/images/imageUpload.ts b/apps/native/src/features/student/scrap/utils/images/imageUpload.ts new file mode 100644 index 00000000..48bf9a5e --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/images/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; fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER' }, + 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 || `${Date.now()}.jpg`; + + // Promise로 변환하여 await 가능하게 함 + return await new Promise((resolve) => { + getPreSignedUrl( + { fileName, fileType: 'IMAGE' }, + { + 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/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/images/s3Upload.ts b/apps/native/src/features/student/scrap/utils/images/s3Upload.ts new file mode 100644 index 00000000..dbedced4 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/images/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; + } +}; diff --git a/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts b/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts new file mode 100644 index 00000000..66517679 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/layout/gridLayout.ts @@ -0,0 +1,28 @@ +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, MIN_ITEM_WIDTH, ITEM_HEIGHT_RATIO, MIN_COLUMNS } = GRID_CONFIG; + + // 컬럼 수 계산 + let numColumns = Math.floor((containerWidth + GAP) / (MIN_ITEM_WIDTH + GAP)); + + // 최소 컬럼 수 적용 + numColumns = Math.max(MIN_COLUMNS, numColumns); + + // item width + const itemWidth = (containerWidth - GAP * (numColumns - 1)) / numColumns; + + // 비율 기반 height + const itemHeight = itemWidth * ITEM_HEIGHT_RATIO; + + return { + numColumns, + gap: GAP, + itemWidth, + itemHeight, + }; +}; diff --git a/apps/native/src/features/student/scrap/utils/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/utils/reducer.ts b/apps/native/src/features/student/scrap/utils/reducer.ts new file mode 100644 index 00000000..fe8c7e87 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/reducer.ts @@ -0,0 +1,84 @@ +/** + * 스크랩 아이템 선택 상태 관리 + * id와 type을 함께 저장하여 같은 id를 가진 FOLDER와 SCRAP을 구분 + */ +export type SelectedItem = { + id: number; + type: 'FOLDER' | 'SCRAP'; +}; + +export interface State { + /** 선택 모드 활성화 여부 */ + isSelecting: boolean; + /** 선택된 아이템 목록 (id와 type을 함께 저장) */ + selectedItems: SelectedItem[]; +} + +/** + * 선택 액션 타입 + */ +export type Action = + | { type: 'ENTER_SELECTION' } + | { type: 'EXIT_SELECTION' } + | { type: 'SELECTING_ITEM'; id: number; itemType: 'FOLDER' | 'SCRAP' } + | { type: 'SELECT_ALL'; allItems: SelectedItem[] } + | { type: 'CLEAR_SELECTION' }; + +/** + * 초기 선택 상태 + */ +export const initialSelectionState: State = { + isSelecting: false, + selectedItems: [], +}; + +/** + * 선택된 아이템인지 확인하는 헬퍼 함수 + */ +export function isItemSelected( + selectedItems: SelectedItem[], + id: number, + type: 'FOLDER' | 'SCRAP' +): boolean { + return selectedItems.some((item) => item.id === id && item.type === type); +} + +/** + * 선택 상태 리듀서 + * @param state - 현재 선택 상태 + * @param action - 수행할 액션 + * @returns 새로운 선택 상태 + */ +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, itemType } = action; + const exists = isItemSelected(state.selectedItems, id, itemType); + + return { + ...state, + selectedItems: exists + ? state.selectedItems.filter((item) => !(item.id === id && item.type === itemType)) + : [...state.selectedItems, { id, type: itemType }], + }; + } + + case 'SELECT_ALL': + return { ...state, selectedItems: action.allItems }; + + case 'CLEAR_SELECTION': + return { ...state, selectedItems: [] }; + + default: { + // Exhaustive check: 모든 액션 타입이 처리되었는지 확인 + const _exhaustive: never = action; + return state; + } + } +} diff --git a/apps/native/src/features/student/scrap/utils/skia/drawing.tsx b/apps/native/src/features/student/scrap/utils/skia/drawing.tsx new file mode 100644 index 00000000..97641274 --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/skia/drawing.tsx @@ -0,0 +1,1096 @@ +import React, { + forwardRef, + useImperativeHandle, + useRef, + useState, + useCallback, + useMemo, + useEffect, +} from 'react'; +import { + View, + StyleSheet, + TextInput, + Dimensions, + Pressable, + Text as RNText, + ScrollView, + Keyboard, + Platform, +} 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; + redo: () => void; + canUndo: () => boolean; + canRedo: () => boolean; + getStrokes: () => Stroke[]; + setStrokes: (strokes: Stroke[]) => void; + getTexts: () => TextItem[]; + setTexts: (texts: TextItem[]) => void; +}; + +type Props = { + strokeColor?: string; + strokeWidth?: number; + onChange?: (strokes: Stroke[]) => void; + onHistoryChange?: (canUndo: boolean, canRedo: boolean) => void; + eraserMode?: boolean; + eraserSize?: number; + textMode?: boolean; + textFontSize?: number; +}; + +const DrawingCanvas = forwardRef( + ( + { + strokeColor = 'black', + strokeWidth = 3, + onChange, + onHistoryChange, + 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 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); + const hoverY = useSharedValue(0); + const showHover = useSharedValue(false); + + 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 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 = { + 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--; + } + + notifyHistoryChange(); + }, [notifyHistoryChange]); + + // 히스토리에서 상태 복원 + 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); + notifyHistoryChange(); + }, + [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를 함께 업데이트 + 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); + }, []); + + // 텍스트 영역과 충돌하는지 확인 (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); + }, + [isNearExistingText] + ); + + 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) { + 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); + + // 히스토리에 저장 + setTimeout(() => saveToHistory(), 0); + + return next; + }); + + currentPoints.current = []; + livePath.current = Skia.Path.Make(); + }, [strokeColor, strokeWidth, onChange, saveToHistory]); + + // 지우개: 터치한 위치에서 가까운 점들을 제거 + 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); + + // 히스토리에 저장 (지우개 동작) + setTimeout(() => saveToHistory(), 0); + + return nextStrokes; + } + + return prevStrokes; + }); + }, + [eraserSize, onChange, saveToHistory] + ); + + 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) => { + const next = prev.filter((t) => t.id !== textId); + textsRef.current = next; + return next; + }); + setTick((t) => t + 1); + }, []); + + // 필기(stroke) 위에 텍스트 박스를 생성할 수 있는지 확인 + // 필기 위는 차단 (캔버스 전체 너비), 필기 아래 16px부터는 허용 + const canAddTextAtPosition = useCallback( + (y: number): boolean => { + const minGap = 32; // 필기 아래 최소 16px 간격 + + 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 true; // 텍스트 생성 가능 + }, + [strokes] + ); + + const addText = useCallback( + (x: number, y: number) => { + const padding = 16; + const minGap = 32; // 필기 아래 32px + + // 가장 아래쪽 stroke 찾기 + let textY = padding; // 기본값: 캔버스 상단 16px + + if (strokes.length > 0) { + // 모든 stroke의 최대 Y 좌표 찾기 + const maxStrokeY = Math.max( + ...strokes.flatMap((stroke) => stroke.points.map((p) => p.y)) + ); + textY = maxStrokeY + minGap; // 가장 아래 필기 + 32px + } + + // 기존 텍스트가 있으면 그 아래로 (멀티라인 고려) + 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: padding, // 항상 캔버스 왼쪽 끝(16px)에서 시작 + y: textY, // 가장 아래 필기/텍스트 아래 32px + value: '', + }); + + // 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 = textY; + 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, + canAddTextAtPosition, + strokes, + texts, + textFontSize, + calculateTextLineCount, + ] + ); + + 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) => { + const next = [...prev, newText]; + textsRef.current = next; + + // 히스토리에 저장 (텍스트 추가) + setTimeout(() => saveToHistory(), 0); + + return next; + }); + setTick((t) => t + 1); + } + setActiveTextInput(null); + }, [activeTextInput, textFontSize, strokeColor, saveToHistory]); + + 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; + } + + // 히스토리에서 이전 상태로 복원 + 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]); + + const redo = useCallback(() => { + // 활성 텍스트 입력이 있으면 redo 불가 + if (activeTextInput) { + return; + } + + // 히스토리에서 다음 상태로 복원 + 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; + notifyHistoryChange(); + } + }, [notifyHistoryChange]); + + // activeTextInput 상태 변경 시 히스토리 상태 알림 + useEffect(() => { + notifyHistoryChange(); + }, [activeTextInput, notifyHistoryChange]); + + useImperativeHandle(ref, () => ({ + clear() { + setPaths([]); + setStrokes([]); + 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( + () => + 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.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, textFontSize] + ); + + // 텍스트 삭제 버튼 렌더링 (텍스트 모드일 때만, 텍스트 시작 위치에 배치) + const renderedTextDeleteButtons = useMemo(() => { + if (!textMode || eraserMode) return null; + + return texts.map((textItem) => { + const buttonSize = 20; + const buttonX = textItem.x - buttonSize + 10; // 텍스트 시작 왼쪽에 배치 + const buttonY = textItem.y - textFontSize + (textFontSize - buttonSize) / 2 + 10; + + 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', + maxWidth: Dimensions.get('window').width * 0.5 - 40, + }, + inlineTextInput: { + backgroundColor: 'transparent', + borderWidth: 0, + padding: 0, + margin: 0, + textAlignVertical: 'top', + width: '100%', // wrapper의 maxWidth에 맞춰서 자동 줄바꿈 + }, + 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); 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/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; +} 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..ababb94d --- /dev/null +++ b/apps/native/src/features/student/scrap/utils/types.ts @@ -0,0 +1,79 @@ +import { paths, components } from '@/types/api/schema'; + +/** + * API 응답 타입 추출 + */ +export type ScrapSearchResponse = + paths['/api/student/scrap/search']['get']['responses']['200']['content']['*/*']; +export 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(); +}; 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; // 검증 성공 (스크랩만 있음) +}; diff --git a/apps/native/src/navigation/student/StudentNavigator.tsx b/apps/native/src/navigation/student/StudentNavigator.tsx index e5bcae3f..27f2025c 100644 --- a/apps/native/src/navigation/student/StudentNavigator.tsx +++ b/apps/native/src/navigation/student/StudentNavigator.tsx @@ -2,10 +2,18 @@ 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'; +import FolderScrapScreen from '@/features/student/scrap/screens/FolderScrapScreen'; +import ScrapDetailScreen from '@/features/student/scrap/screens/ScrapDetailScreen'; const StudentRootStack = createNativeStackNavigator(); @@ -33,6 +41,11 @@ const StudentNavigator = () => { + + + + + ); }; diff --git a/apps/native/src/navigation/student/types.ts b/apps/native/src/navigation/student/types.ts index fcd3ad76..1a0e3fa2 100644 --- a/apps/native/src/navigation/student/types.ts +++ b/apps/native/src/navigation/student/types.ts @@ -22,4 +22,13 @@ export type StudentRootStackParamList = { publishAt?: string; problemSetTitle?: string; }; + Scrap: undefined; + ScrapContent: { + id: number; + }; + SearchScrap: undefined; + DeletedScrap: undefined; + ScrapContentDetail: { + id: number; + }; }; diff --git a/apps/native/src/stores/index.ts b/apps/native/src/stores/index.ts index 8d099b8d..44626b6d 100644 --- a/apps/native/src/stores/index.ts +++ b/apps/native/src/stores/index.ts @@ -1,2 +1,4 @@ export * from './authStore'; export * from './problemSessionStore'; +export * from './searchHistoryStore'; +export * from './scrapNoteStore'; diff --git a/apps/native/src/stores/recentScrapStore.ts b/apps/native/src/stores/recentScrapStore.ts new file mode 100644 index 00000000..29b5af71 --- /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, 30), + }; + }), + + removeScrap: (scrapId) => + set((state) => ({ + scrapIds: state.scrapIds.filter((id) => id !== scrapId), + })), + + clear: () => set({ scrapIds: [] }), + }), + { + name: 'recent-scrap-store', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); 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 }); + }, +})); diff --git a/apps/native/src/stores/searchHistoryStore.ts b/apps/native/src/stores/searchHistoryStore.ts new file mode 100644 index 00000000..1f0d553d --- /dev/null +++ b/apps/native/src/stores/searchHistoryStore.ts @@ -0,0 +1,97 @@ +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[]; + addKeyword: (keyword: string) => void; + removeKeyword: (keyword: string) => void; + clear: () => void; +} + +export const useSearchHistoryStore = create()( + persist( + (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: [] }), + }), + { + name: 'search-history-store', + storage: createJSONStorage(() => AsyncStorage), + } + ) +); + +interface ScrapUIStore { + /** 현재 선택된 폴더 ID */ + currentFolderId: number | null; + /** 선택 모드 활성화 여부 */ + isSelectionMode: boolean; + /** 현재 필터 (ALL/FOLDER/SCRAP) */ + currentFilter: FilterType; + /** 현재 정렬 키 (CREATED_AT/NAME) */ + currentSort: ApiSortKey; + /** 현재 정렬 방향 (ASC/DESC) */ + currentOrder: SortOrder; + + /** 현재 폴더 ID 설정 */ + setCurrentFolderId: (id: number | null) => void; + /** 선택 모드 설정 */ + setSelectionMode: (enabled: boolean) => void; + /** 필터 설정 */ + setFilter: (filter: FilterType) => void; + /** 정렬 설정 */ + setSort: (sort: ApiSortKey, order: SortOrder) => void; + /** 상태 초기화 */ + reset: () => void; +} + +const initialScrapUIState = { + currentFolderId: null, + isSelectionMode: false, + currentFilter: 'ALL' as FilterType, + currentSort: 'CREATED_AT' as ApiSortKey, + currentOrder: 'DESC' as SortOrder, +}; +export const useScrapUIStore = create()( + persist( + (set) => ({ + ...initialScrapUIState, + + setCurrentFolderId: (id) => set({ currentFolderId: id }), + setSelectionMode: (enabled) => set({ isSelectionMode: enabled }), + setFilter: (filter) => set({ currentFilter: filter }), + setSort: (sort, order) => set({ currentSort: sort, currentOrder: order }), + + reset: () => set(initialScrapUIState), + }), + { + name: 'scrap-ui-store', + storage: createJSONStorage(() => AsyncStorage), + + partialize: (state) => ({ + currentFilter: state.currentFilter, + currentSort: state.currentSort, + currentOrder: state.currentOrder, + }), + } + ) +); diff --git a/apps/native/src/types/api/schema.d.ts b/apps/native/src/types/api/schema.d.ts index 2d827526..9ad270eb 100644 --- a/apps/native/src/types/api/schema.d.ts +++ b/apps/native/src/types/api/schema.d.ts @@ -40,6 +40,179 @@ 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; + 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: operations['getFolderDetail']; + /** 폴더 수정 */ + put: operations['updateFolder']; + post?: never; + delete?: never; + options?: never; + head?: never; + 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; @@ -146,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; @@ -327,6 +518,40 @@ export interface paths { patch?: never; trace?: never; }; + '/api/teacher/me/push/token': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 푸시 토큰 등록/갱신 */ + post: operations['updatePushToken']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/teacher/me/push/allow/toggle': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** 푸시 알림 허용 토글 */ + post: operations['toggleAllowPush']; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; '/api/teacher/auth/refresh': { parameters: { query?: never; @@ -395,25 +620,25 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/qna': { + '/api/student/scrap': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Q&A 목록 조회 */ - get: operations['gets']; + get?: never; put?: never; - /** Q&A 생성 */ - post: operations['create_1']; - delete?: never; + /** 일반 스크랩 생성(쓸지, 안쓸지 모르겠음) */ + post: operations['createScrap']; + /** 스크랩 삭제 (폴더/스크랩 혼합 배치) */ + delete: operations['deleteScraps']; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/qna/exist': { + '/api/student/scrap/toggle/from-reading-tip': { parameters: { query?: never; header?: never; @@ -422,15 +647,15 @@ export interface paths { }; get?: never; put?: never; - /** Q&A 존재 여부 확인, 사용자가 동일한 항목에 대해 QnA 작성 여부 확인 */ - post: operations['checkExists']; + /** 리딩팁 콘텐츠 스크랩 토글 */ + post: operations['toggleScrapFromReadingTip']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/qna/chat': { + '/api/student/scrap/toggle/from-problem': { parameters: { query?: never; header?: never; @@ -439,15 +664,15 @@ export interface paths { }; get?: never; put?: never; - /** 채팅메시지 생성 */ - post: operations['addChat_1']; + /** 문제 기반 스크랩 토글 (있으면 삭제, 없으면 생성) */ + post: operations['toggleScrapFromProblem']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/auth/register/social': { + '/api/student/scrap/toggle/from-pointing': { parameters: { query?: never; header?: never; @@ -456,15 +681,15 @@ export interface paths { }; get?: never; put?: never; - /** 소셜 로그인 이후, 정보 등록 */ - post: operations['registerSocial']; + /** 포인팅 기반 스크랩 토글 (있으면 삭제, 없으면 생성) */ + post: operations['toggleScrapFromPointing']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/auth/refresh': { + '/api/student/scrap/toggle/from-one-step-more': { parameters: { query?: never; header?: never; @@ -473,15 +698,15 @@ export interface paths { }; get?: never; put?: never; - /** 토큰 갱신 */ - post: operations['refresh_1']; + /** 원스텝모어 콘텐츠 스크랩 토글 */ + post: operations['toggleScrapFromOneStepMore']; 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 +715,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 +733,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 +751,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 +822,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 +873,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 +959,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,378 +975,391 @@ 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/qna/chat': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 문제 상세 조회 */ - get: operations['getProblemById']; + get?: never; put?: never; - post?: never; + /** 채팅메시지 생성 */ + post: operations['addChat_2']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/study/child-problem/{publishId}/{problemId}': { + '/api/admin/publish': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 새끼 문제 상세 조회 */ - get: operations['getChildProblemById']; + /** 검색 */ + get: operations['search_1']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_4']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/students': { + '/api/admin/problem': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 학생 전체 조회 */ - get: operations['getMyStudents']; + /** 검색 */ + get: operations['search_2']; put?: never; - post?: never; + /** 단일 생성 */ + post: operations['createProblem']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/qna': { + '/api/admin/problem/with-child': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Q&A 목록 조회 */ - get: operations['gets_2']; + get?: never; put?: never; - post?: never; + /** 생성(새끼문제, 일반문제 한번에 생성) */ + post: operations['createProblemWithChild']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/qna/{qnaId}': { + '/api/admin/problem-set': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** Q&A 상세 조회 */ - get: operations['getById_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/notice/available': { + '/api/admin/practice-test': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 별 유효 공지사항(현재 학생이 볼 수 있는 공지사항) 조회 */ - get: operations['getsAvailable']; + /** 검색 */ + get: operations['search_4']; put?: never; - post?: never; + /** 생성 */ + post: operations['create_6']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/teacher/me': { + '/api/admin/ocr': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 정보 조회 */ - get: operations['getTeacherMe']; + get?: never; put?: never; - post?: never; + post: operations['redirect']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/publish/weekly': { + '/api/admin/notification/send': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 주간 발행(숙제) 조회 */ - get: operations['search_7']; + 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/monthly': { + '/api/admin/notice': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 월간 발행(숙제) 조회 */ - get: operations['searchMonthly_1']; + /** 학생 별 공지사항 전체 조회 */ + 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/detail/{id}': { + '/api/admin/diagnosis': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 발행(숙제) 상세 조회 */ - get: operations['getPublishById_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/progress/weekly': { + '/api/admin/concept': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 주간 학습 진행 상황 조회 */ - get: operations['getWeeklyProgress_1']; + /** 개념 태그 검색 */ + get: operations['search_5']; put?: never; - post?: never; + /** 개념태그 생성 */ + post: operations['create_9']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/study/problem/{publishId}/{problemId}': { + '/api/admin/concept/category': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 문제 상세 조회 */ - get: operations['getProblemById_1']; + /** 대분류 검색 */ + get: operations['searchCategory']; put?: never; - post?: never; + /** 대분류 생성 */ + post: operations['createCategory']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/notice': { + '/api/admin/auth/refresh': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 공지사항 목록 조회 */ - get: operations['getsAvailable_1']; + get?: never; put?: never; - post?: never; + /** 토큰 갱신 */ + post: operations['refresh_2']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/notice/count': { + '/api/admin/auth/login/local': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 읽지 않은 공지사항 개수 조회 */ - get: operations['countAvailable']; + get?: never; put?: never; - post?: never; + /** 이메일 로그인 */ + post: operations['login_1']; delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/student/diagnosis': { + '/your-redirect-url': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 진단 리스트 가져오기 */ - get: operations['gets_3']; + /** 소셜 로그인 콜백 example */ + get: operations['oauthRedirectExample']; put?: never; post?: never; delete?: never; @@ -1133,15 +1368,14 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/diagnosis/last': { + '/json-convert/content': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 내 최신 진단 가져오기 */ - get: operations['getById_3']; + get: operations['content']; put?: never; post?: never; delete?: never; @@ -1150,15 +1384,14 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/diagnosis/detail/{id}': { + '/json-convert/childProblem-to-Problem': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 진단 상세보기 */ - get: operations['getById_4']; + get: operations['problem']; put?: never; post?: never; delete?: never; @@ -1167,15 +1400,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/common/auth/refresh': { + '/api/teacher/study/publish/weekly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 토큰 갱신 */ - get: operations['refresh_3']; + /** 학생 주간 발행(숙제) 조회 */ + get: operations['search_6']; put?: never; post?: never; delete?: never; @@ -1184,15 +1417,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/admin/student': { + '/api/teacher/study/publish/monthly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 검색 */ - get: operations['search_8']; + /** 학생 월간 발행(숙제) 조회 */ + get: operations['searchMonthly']; put?: never; post?: never; delete?: never; @@ -1201,33 +1434,32 @@ export interface paths { patch?: never; trace?: never; }; - '/api/admin/publish/{id}': { + '/api/teacher/study/publish/detail/{id}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 상세 조회 */ - get: operations['getById_5']; + /** 발행(숙제) 상세 조회 */ + get: operations['getPublishById']; 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/study/progress/weekly': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** unique customId 받기 */ - get: operations['getCustomId']; + /** 학생 주간 학습 진행 상황 조회 */ + get: operations['getWeeklyProgress']; put?: never; post?: never; delete?: never; @@ -1236,15 +1468,15 @@ export interface paths { patch?: never; trace?: never; }; - '/api/admin/notice/available': { + '/api/teacher/study/problem/{publishId}/{problemId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - /** 학생 별 유효 공지사항(현재 학생이 볼 수 있는 공지사항) 조회 */ - get: operations['getsAvailable_2']; + /** 문제 상세 조회 */ + get: operations['getProblemById']; put?: never; post?: never; delete?: never; @@ -1253,860 +1485,3720 @@ export interface paths { patch?: never; trace?: never; }; - '/api/student/auth/quit': { + '/api/teacher/study/child-problem/{publishId}/{problemId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 새끼 문제 상세 조회 */ + get: operations['getChildProblemById']; put?: never; post?: never; - /** 회원 탈퇴 */ - delete: operations['quit']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/common/upload-file/{id}': { + '/api/teacher/students': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** 내 학생 전체 조회 */ + get: operations['getMyStudents']; put?: never; post?: never; - /** 삭제 */ - delete: operations['delete_8']; + delete?: never; options?: never; head?: never; patch?: never; trace?: never; }; - '/api/admin/teacher/{teacherId}': { + '/api/teacher/qna': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; - put?: never; + /** Q&A 목록 조회 */ + get: operations['gets_2']; + 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/teacher/qna/{qnaId}': { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - get?: never; + /** Q&A 상세 조회 */ + get: operations['getById_2']; 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: { - ChatUpdateRequest: { - content: string; - images?: number[]; - }; - 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; + '/api/teacher/qna/search': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - 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'][]; + /** 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; + header?: never; + path?: never; + cookie?: never; }; - UploadFileResp: { - /** Format: int64 */ - id: number; - fileName: string; - url: string; + /** 학생 별 유효 공지사항(현재 학생이 볼 수 있는 공지사항) 조회 */ + get: operations['getsAvailable']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/teacher/me': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - NoticeUpdateRequest: { - /** Format: date */ - startAt: string; - /** Format: date */ - endAt: string; - content: string; + /** 내 정보 조회 */ + get: operations['getTeacherMe']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/study/publish/weekly': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - NoticeResp: { - /** Format: int64 */ - id: number; - student: components['schemas']['StudentResp']; - /** Format: date */ - startAt: string; - /** Format: date */ - endAt: string; - isRead: boolean; - content: string; + /** 주간 발행(숙제) 조회 */ + get: operations['search_8']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/study/publish/monthly': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - StudentResp: { - /** Format: int64 */ - id: number; - name: string; - /** Format: int32 */ - grade: number; - isFirstLogin: boolean; + /** 월간 발행(숙제) 조회 */ + get: operations['searchMonthly_1']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/study/publish/detail/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - QnAUpdateRequest: { - question: string; - images?: number[]; + /** 발행(숙제) 상세 조회 */ + get: operations['getPublishById_1']; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/student/study/progress/weekly': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - StudentUpdateRequest: { - name: string; - /** Format: int32 */ - grade: number; + /** 학생 주간 학습 진행 상황 조회 */ + 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; }; - TeacherUpdateRequest: { - name: string; - email: string; - newPassword?: string; + /** 문제 상세 조회 */ + 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; }; - TeacherResp: { - /** Format: int64 */ - id: number; - name: string; - email: string; - students: components['schemas']['StudentResp'][]; + /** 스크랩 상세 조회 */ + 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': { + 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['getScrapsByFolder']; + 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_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; + 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/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; + 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_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; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + '/api/admin/publish/{id}': { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** 상세 조회 */ + get: operations['getById_6']; + 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/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; + 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'][]; + /** + * Format: int64 + * @description 답장 대상 채팅 ID + */ + replyToId?: number; + /** @description 답장 대상 채팅 내용 미리보기 */ + replyToContent?: string; + }; + 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' + | 'ADMIN_CHAT'; + /** Format: date */ + publishDate: string; + /** Format: int64 */ + publishId?: number; + /** Format: int32 */ + unreadCount?: number; + studentName?: string; + /** + * Format: date-time + * @description 최신 메시지 시간 + */ + latestMessageTime?: string; + /** @description 최신 메시지 내용 미리보기 */ + latestMessageContent?: 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' + | 'ADMIN_CHAT'; + /** Format: date */ + publishDate: string; + /** Format: int64 */ + publishId?: number; + /** Format: int32 */ + unreadCount?: number; + studentName?: string; + /** + * Format: date-time + * @description 최신 메시지 시간 + */ + latestMessageTime?: string; + /** @description 최신 메시지 내용 미리보기 */ + latestMessageContent?: 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; + /** @enum {string} */ + fileType: 'IMAGE' | 'DOCUMENT' | 'OTHER'; + }; + 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; + }; + ScrapUpdateRequest: { + /** + * Format: int64 + * @description 폴더 ID (null이면 루트로 이동) + * @example 1 + */ + folderId?: number; + /** + * Format: int64 + * @description 썸네일 이미지 ID + * @example 456 + */ + thumbnailImageId?: number; + /** + * @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 리딩팁 스크랩 여부 */ + isReadingTipScrapped: boolean; + /** @description 원스텝모어 스크랩 여부 */ + isOneStepMoreScrapped: boolean; + /** @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: 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: { + /** + * @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; + /** + * Format: int64 + * @description 썸네일 이미지 ID + */ + thumbnailImageId?: number; + }; + ScrapFolderThumbnailUpdateRequest: { + /** + * Format: int64 + * @description 썸네일 이미지 ID (null이면 썸네일 삭제) + */ + thumbnailImageId?: number; + }; + ScrapFolderNameUpdateRequest: { + /** + * @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[]; + /** + * Format: int64 + * @description 답장 대상 채팅 ID + */ + replyToId?: 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[]; + }; + ScrapFromReadingTipCreateRequest: { + /** + * 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']; + }; + ScrapFromProblemCreateRequest: { + /** + * Format: int64 + * @description 문제 ID + * @example 123 + */ + problemId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + }; + ScrapFromPointingCreateRequest: { + /** + * Format: int64 + * @description 포인팅 ID + * @example 456 + */ + pointingId: number; + /** + * Format: int64 + * @description 폴더 ID (null이면 루트에 생성) + * @example 1 + */ + folderId?: number; + }; + ScrapFromOneStepMoreCreateRequest: { + /** + * Format: int64 + * @description 문제 ID + * @example 123 + */ + problemId: 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; + /** + * Format: int64 + * @description 썸네일 이미지 ID + */ + thumbnailImageId?: number; + }; + /** @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' + | 'ADMIN_CHAT'; + /** Format: int64 */ + problemId?: number; + /** Format: int64 */ + pointingId?: number; + question: string; + images?: number[]; + }; + 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' + | 'ADMIN_CHAT'; + /** + * Format: int64 + * @description 메인문제ID(메인 문제에 대한 질문일 경우) + */ + problemId?: number; + /** + * Format: int64 + * @description 포인팅ID(포인팅에 대한 질문일 경우) + */ + pointingId?: number; + }; + QnACheckResp: { + /** Format: int64 */ + id: number; + isExist: boolean; + }; + NotificationResp: { + /** Format: int64 */ + id: number; + /** Format: int64 */ + studentId: number; + studentName?: string; + /** @enum {string} */ + type: 'ASSIGNMENT' | 'SYSTEM' | 'QNA' | 'MARKETING'; + title: string; + /** Format: int64 */ + payload?: number; + url?: string; + isRead: boolean; + /** Format: date-time */ + createdAt: string; + }; + 'StudentPushDTO.UpdateTokenRequest': { + fcmToken: string; + }; + 'StudentPasswordDTO.UpdatePasswordRequest': { + newPassword: string; + }; + StudentSignupReq: { + email: string; + password: string; + }; + StudentTokenResp: { + /** 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; + token: components['schemas']['JwtResp']; + }; + 'StudentInitialRegisterDTO.Req': { + 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; + }; + SocialLoginReq: { + /** @enum {string} */ + provider: 'KAKAO' | 'GOOGLE' | 'APPLE'; + redirectUri: string; + }; + SocialLoginUrlResp: { + /** @enum {string} */ + provider: 'KAKAO' | 'GOOGLE' | 'APPLE'; + loginUrl: string; + }; + PreSignedReq: { + fileName: string; + /** + * @description 파일 유형 (지정하지 않으면 서버가 확장자로 판단) + * @enum {string} + */ + fileType?: 'IMAGE' | 'DOCUMENT' | 'OTHER'; + }; + 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 */ + problemSetId: number; + /** Format: int64 */ + studentId: number; + /** Format: date */ + publishAt: string; + }; + PointingWithFeedbackResp: { + /** Format: int64 */ + id: number; + /** Format: int32 */ + no: number; + questionContent: string; + commentContent: string; + concepts: components['schemas']['ConceptResp'][]; + isUnderstood?: boolean; + isScrapped?: boolean; + }; + ProblemRef: { + /** + * Format: int64 + * @description 부모문제인 경우 -> 첫 새끼문제, 새끼문제일 경우 -> 다음 새끼문제 + */ + prevChildId?: number; + /** + * Format: int64 + * @description 새끼문제일 경우에만 다음 새끼문제 + */ + nextChildId?: number; + /** + * Format: int64 + * @description 새끼문제일 경우에만 부모문제Id + */ + parentId?: number; + }; + ProblemScrapInfo: { + isProblemScrapped?: boolean; + isReadingTipScrapped?: boolean; + isOneStepMoreScrapped?: boolean; + scrappedPointingIds?: 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; + scrapInfo?: components['schemas']['ProblemScrapInfo']; + childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; + ref?: components['schemas']['ProblemRef']; + }; + PublishProblemGroupResp: { + /** Format: int32 */ + no: number; + /** Format: int64 */ + problemId: number; + /** @enum {string} */ + progress: 'DONE' | 'DOING' | 'NONE'; + problem: components['schemas']['ProblemWithStudyInfoResp']; + childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; + }; + PublishResp: { + /** Format: int64 */ + id: number; + /** Format: date */ + publishAt: string; + /** @enum {string} */ + progress: 'DONE' | 'DOING' | 'NONE'; + problemSet: components['schemas']['ProblemSetResp']; + data: components['schemas']['PublishProblemGroupResp'][]; + }; + PointingCreateRequest: { + /** Format: int32 */ + no?: number; + questionContent?: string; + commentContent?: string; + concepts?: number[]; + }; + 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; + }; + 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; + }; + 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 */ + studentId?: number; + content?: string; + }; + ConceptCreateRequest: { + name: string; + /** Format: int64 */ + categoryId: number; + }; + ConceptCategoryCreateRequest: { + name: string; + }; + AdminTokenResp: { + /** Format: int64 */ + id: number; + email: string; + token: components['schemas']['JwtResp']; + }; + AdminLoginReq: { + email: string; + password: string; + }; + ListRespPublishResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['PublishResp'][]; + }; + PublishStudentProgressResp: { + /** Format: double */ + progress: number; + }; + ListRespStudentResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['StudentResp'][]; + }; + PageRespNotListQnAGroupByWeekResp: { + /** Format: int32 */ + page: number; + /** Format: int32 */ + 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'][]; + }; + 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; + 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; + /** + * Format: int32 + * @description 포함된 스크랩 수 (폴더인 경우) + */ + itemCount?: number; + /** @description 썸네일 URL (스크랩: 스크랩 썸네일, 폴더: 폴더 썸네일) */ + thumbnailUrl?: string; + /** @description 최근 스크랩 2개의 썸네일 URL (폴더인 경우) */ + top2ScrapThumbnail?: string[]; + /** + * Format: date-time + * @description 삭제일시 + */ + deletedAt: string; + /** + * Format: int32 + * @description 영구삭제까지 남은 일수 + */ + daysUntilPermanentDelete: number; + }; + /** @description 스크랩/폴더 목록 아이템 */ + ScrapListItemResp: { + /** + * @description 유형 (FOLDER/SCRAP) + * @enum {string} + */ + type: 'FOLDER' | 'SCRAP'; + /** + * Format: int64 + * @description 아이템 ID + */ + id: number; + /** @description 표시 이름 (폴더명 또는 문제 제목) */ + name: string; + /** + * Format: int64 + * @description 폴더 ID (스크랩일 때) + */ + folderId?: number; + /** @description 썸네일 URL (스크랩일 때) */ + thumbnailUrl?: string; + /** + * Format: date-time + * @description 생성일시 + */ + createdAt: string; + /** + * Format: date-time + * @description 수정일시 + */ + updatedAt: 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; + data: components['schemas']['SchoolResp'][]; + }; + ListRespUploadFileResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['UploadFileResp'][]; + }; + 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 */ + totalCount?: number; + /** Format: int64 */ + unreadCount?: number; + latestNotice?: components['schemas']['NoticeResp']; + }; + ListRespDiagnosisResp: { + /** Format: int32 */ + total: number; + data: components['schemas']['DiagnosisResp'][]; + }; + 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; + /** 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 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['PracticeTestResp'][]; + }; + PageRespConceptResp: { + /** Format: int32 */ + page: number; + /** Format: int32 */ + size: number; + /** Format: int32 */ + lastPage: number; + data: components['schemas']['ConceptResp'][]; + }; + PageRespConceptCategoryResp: { + /** Format: int32 */ + 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; + }; + UnscrapFromPointingRequest: { + /** + * Format: int64 + * @description 포인팅 ID + * @example 456 + */ + pointingId: number; + }; + }; + 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; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChatUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + deleteChat: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + 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; + }; + }; + }; + 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; + 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']; + }; + }; + }; + }; + updateHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapHandwritingUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapHandwritingResp']; + }; + }; + }; + }; + deleteHandwriting: { + parameters: { + query?: never; + header?: never; + path: { + scrapId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + restoreTrash: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchRestoreRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + moveScraps: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchMoveRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespScrapDetailResp']; + }; + }; + }; + }; + 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; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFolderUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapFolderResp']; + }; + }; + }; + }; + 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; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + update_1: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['QnAUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + delete_1: { + parameters: { + query?: never; + header?: never; + path: { + qnaId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateChat_1: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ChatUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + deleteChat_1: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; + }; + }; + readNotice: { + parameters: { + query?: never; + header?: never; + path: { + noticeId: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NoticeResp']; + }; + }; + }; + }; + me: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudentResp']; + }; + }; + }; + }; + update_2: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['StudentUpdateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['StudentResp']; + }; + }; + }; + }; + 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']; + }; }; - TeacherStudentAssignReq: { - students: number[]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; }; - 'ChildProblemUpdateDTO.Request': { - /** Format: int64 */ - id?: number; - /** Format: int32 */ - no?: number; - problemContent?: string; - /** @enum {string} */ - answerType?: 'MULTIPLE_CHOICE' | 'SHORT_ANSWER'; - /** Format: int32 */ - answer?: number; - concepts?: number[]; - pointings?: components['schemas']['PointingUpdateRequest'][]; + }; + updateChat_2: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; }; - PointingUpdateRequest: { - /** Format: int64 */ - id?: number; - /** Format: int32 */ - no?: number; - questionContent?: string; - commentContent?: string; - concepts?: number[]; + requestBody: { + content: { + 'application/json': components['schemas']['ChatUpdateRequest']; + }; }; - 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; - childProblems?: components['schemas']['ChildProblemUpdateDTO.Request'][]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - ConceptCategoryResp: { - /** Format: int64 */ - id: number; - name: string; + }; + deleteChat_2: { + parameters: { + query?: never; + header?: never; + path: { + chatId: number; + }; + cookie?: never; }; - ConceptResp: { - /** Format: int64 */ - id: number; - name: string; - category: components['schemas']['ConceptCategoryResp']; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['QnAResp']; + }; + }; }; - PointingResp: { - /** Format: int64 */ - id: number; - /** Format: int32 */ - no: number; - questionContent: string; - commentContent: string; - concepts: components['schemas']['ConceptResp'][]; + }; + getProblem: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - PracticeTestResp: { - /** Format: int64 */ - id: number; - /** Format: int32 */ - year: number; - /** Format: int32 */ - month: number; - /** Format: int32 */ - grade: number; - name: string; - displayName: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; }; - 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'][]; + }; + updateProblem: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - 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'][]; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemUpdateRequest']; + }; }; - ProblemSetItemRequest: { - /** Format: int32 */ - no: number; - /** Format: int64 */ - problemId: number; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; + }; }; - ProblemSetUpdateRequest: { - title: string; - /** @enum {string} */ - status: 'CONFIRMED' | 'DOING'; - problems?: components['schemas']['ProblemSetItemRequest'][]; + }; + deleteProblem: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - ProblemSetItemResp: { - /** Format: int64 */ - id: number; - /** Format: int32 */ - no: number; - problem: components['schemas']['ProblemMetaResp']; + }; + getProblemSet: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - ProblemSetResp: { - /** Format: int64 */ - id: number; - title: string; - /** @enum {string} */ - status: 'CONFIRMED' | 'DOING'; - firstProblem: components['schemas']['ProblemMetaResp']; - problems: components['schemas']['ProblemSetItemResp'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemSetResp']; + }; + }; }; - Request: { - /** Format: int32 */ - year: number; - /** Format: int32 */ - month: number; - /** Format: int32 */ - grade: number; - name: string; + }; + update_4: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - DiagnosisUpdateReq: { - content?: string; + requestBody: { + content: { + 'application/json': components['schemas']['ProblemSetUpdateRequest']; + }; }; - DiagnosisResp: { - /** Format: int64 */ - id: number; - /** Format: int64 */ - studentId?: number; - /** Format: date-time */ - createdAt?: string; - content?: string; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemSetResp']; + }; + }; }; - ConceptUpdateRequest: { - name: string; - /** Format: int64 */ - categoryId: number; + }; + delete_2: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - ConceptCategoryUpdateRequest: { - name: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - ChatCreateRequest: { - /** Format: int64 */ - qnaId: number; - content: string; - images?: number[]; + }; + toggleStatus: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - NoticeCreateRequest: { - /** Format: date */ - startAt: string; - /** Format: date */ - endAt: string; - content: string; - /** Format: int64 */ - studentId: number; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ProblemSetResp']; + }; + }; }; - RefreshReq: { - refreshToken: string; + }; + update_5: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - JwtResp: { - accessToken: string; - refreshToken?: string; + requestBody: { + content: { + 'application/json': components['schemas']['Request']; + }; }; - TeacherTokenResp: { - /** Format: int64 */ - id: number; - name: string; - email: string; - students: components['schemas']['StudentResp'][]; - token?: components['schemas']['JwtResp']; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PracticeTestResp']; + }; + }; }; - TeacherLoginReq: { - email: string; - password: string; + }; + delete_3: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - PointingFeedbackRequest: { - /** Format: int64 */ - pointingId: number; - isUnderstood: boolean; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - SubmissionRequest: { - /** Format: int64 */ - publishId: number; - /** Format: int64 */ - problemId?: number; - /** Format: int32 */ - submitAnswer?: number; + }; + update_6: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + 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']['NoticeUpdateRequest']; + }; }; - /** @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']['NoticeResp']; + }; + }; + }; + }; + delete_4: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - 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; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - QnACheckResp: { - /** Format: int64 */ - id: number; - isExist: boolean; + }; + getById_1: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - StudentTokenResp: { - /** Format: int64 */ - id: number; - name: string; - /** Format: int32 */ - grade: number; - isFirstLogin: boolean; - token: components['schemas']['JwtResp']; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['DiagnosisResp']; + }; + }; }; - SocialLoginReq: { - /** @enum {string} */ - provider: 'KAKAO' | 'GOOGLE'; - redirectUri: string; + }; + update_7: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - SocialLoginUrlResp: { - /** @enum {string} */ - provider: 'KAKAO' | 'GOOGLE'; - loginUrl: string; + requestBody: { + content: { + 'application/json': components['schemas']['DiagnosisUpdateReq']; + }; }; - PreSignedReq: { - fileName: string; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['DiagnosisResp']; + }; + }; }; - PreSignedResp: { - file: components['schemas']['UploadFileResp']; - contentDisposition: string; - uploadUrl: string; + }; + delete_5: { + parameters: { + query?: never; + header?: never; + path: { + id: number; + }; + cookie?: never; }; - AdminCreateRequest: { - email: string; - password: string; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - TeacherCreateRequest: { - name: string; - email: string; - password: string; + }; + update_8: { + parameters: { + query?: never; + header?: never; + path: { + conceptId: number; + }; + cookie?: never; }; - PublishCreateRequest: { - /** Format: int64 */ - problemSetId: number; - /** Format: int64 */ - studentId: number; - /** Format: date */ - publishAt: string; + requestBody: { + content: { + 'application/json': components['schemas']['ConceptUpdateRequest']; + }; }; - PointingWithFeedbackResp: { - /** Format: int64 */ - id: number; - /** Format: int32 */ - no: number; - questionContent: string; - commentContent: string; - concepts: components['schemas']['ConceptResp'][]; - isUnderstood?: boolean; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ConceptResp']; + }; + }; }; - ProblemRef: { - /** - * Format: int64 - * @description 부모문제인 경우 -> 첫 새끼문제, 새끼문제일 경우 -> 다음 새끼문제 - */ - prevChildId?: number; - /** - * Format: int64 - * @description 새끼문제일 경우에만 다음 새끼문제 - */ - nextChildId?: number; - /** - * Format: int64 - * @description 새끼문제일 경우에만 부모문제Id - */ - parentId?: number; + }; + delete_6: { + parameters: { + query?: never; + header?: never; + path: { + conceptId: number; + }; + cookie?: never; }; - 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']; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + updateCategory: { + parameters: { + query?: never; + header?: never; + path: { + categoryId: number; + }; + cookie?: never; }; - PublishProblemGroupResp: { - /** Format: int32 */ - no: number; - /** Format: int64 */ - problemId: number; - /** @enum {string} */ - progress: 'DONE' | 'DOING' | 'NONE'; - problem: components['schemas']['ProblemWithStudyInfoResp']; - childProblems: components['schemas']['ProblemWithStudyInfoResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['ConceptCategoryUpdateRequest']; + }; }; - PublishResp: { - /** Format: int64 */ - id: number; - /** Format: date */ - publishAt: string; - /** @enum {string} */ - progress: 'DONE' | 'DOING' | 'NONE'; - problemSet: components['schemas']['ProblemSetResp']; - data: components['schemas']['PublishProblemGroupResp'][]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ConceptCategoryResp']; + }; + }; }; - PointingCreateRequest: { - /** Format: int32 */ - no?: number; - questionContent?: string; - commentContent?: string; - concepts?: number[]; + }; + deleteCategory: { + parameters: { + query?: never; + header?: never; + path: { + categoryId: 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?: never; + }; }; - ProblemEntireCreateRequest: { - childProblems?: components['schemas']['ProblemCreateRequest'][]; + }; + addChat: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ProblemSetCreateRequest: { - title: string; - problems?: components['schemas']['ProblemSetItemRequest'][]; + requestBody: { + content: { + 'application/json': components['schemas']['ChatCreateRequest']; + }; }; - PracticeTestCreateRequest: { - /** 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']; + }; + }; }; - DiagnosisCreateReq: { - /** Format: int64 */ - studentId?: number; - content?: string; + }; + getsAll: { + parameters: { + query: { + studentId: number; + }; + header?: never; + path?: never; + cookie?: never; }; - ConceptCreateRequest: { - name: string; - /** Format: int64 */ - categoryId: number; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ListRespNoticeResp']; + }; + }; }; - ConceptCategoryCreateRequest: { - name: string; + }; + create: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - AdminTokenResp: { - /** Format: int64 */ - id: number; - email: string; - token: components['schemas']['JwtResp']; + requestBody: { + content: { + 'application/json': components['schemas']['NoticeCreateRequest']; + }; }; - AdminLoginReq: { - email: string; - password: string; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['NoticeResp']; + }; + }; }; - ListRespPublishResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['PublishResp'][]; + }; + updatePushToken: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - PublishStudentProgressResp: { - /** Format: double */ - progress: number; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherPushDTO.UpdateTokenRequest']; + }; }; - 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']; + }; + toggleAllowPush: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - QnAGroupByWeekResp: { - groups?: components['schemas']['QnAGroupItem'][]; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherResp']; + }; + }; }; - QnAGroupItem: { - /** Format: int32 */ - order?: number; - weekName?: string; - data?: components['schemas']['QnAMetaResp'][]; + }; + refresh: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ListRespNoticeResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['NoticeResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['RefreshReq']; + }; }; - NoticeUnreadCountResp: { - /** Format: int64 */ - totalCount?: number; - /** Format: int64 */ - unreadCount?: number; - latestNotice?: components['schemas']['NoticeResp']; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherTokenResp']; + }; + }; }; - ListRespDiagnosisResp: { - /** Format: int32 */ - total: number; - data: components['schemas']['DiagnosisResp'][]; + }; + login: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - PageRespTeacherResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['TeacherResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['TeacherLoginReq']; + }; }; - PageRespStudentResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['StudentResp'][]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['TeacherTokenResp']; + }; + }; }; - PageRespProblemMetaResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ProblemMetaResp'][]; + }; + feedback: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - ProblemCustomIdResp: { - customId?: string; + requestBody: { + content: { + 'application/json': components['schemas']['PointingFeedbackRequest']; + }; }; - PageRespProblemSetResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ProblemSetResp'][]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; - PageRespPracticeTestResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['PracticeTestResp'][]; + }; + submit: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; }; - PageRespConceptResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ConceptResp'][]; + requestBody: { + content: { + 'application/json': components['schemas']['SubmissionRequest']; + }; }; - PageRespConceptCategoryResp: { - /** Format: int32 */ - page: number; - /** Format: int32 */ - size: number; - /** Format: int32 */ - lastPage: number; - data: components['schemas']['ConceptCategoryResp'][]; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['SubmissionResp']; + }; + }; }; }; - responses: never; - parameters: never; - requestBodies: never; - headers: never; - pathItems: never; -} -export type $defs = Record; -export interface operations { - updateChat: { + createScrap: { parameters: { query?: never; header?: never; - path: { - chatId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ChatUpdateRequest']; + 'application/json': components['schemas']['ScrapCreateRequest']; }; }; responses: { @@ -2116,21 +5208,45 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - deleteChat: { + deleteScraps: { parameters: { query?: never; header?: never; - path: { - chatId: number; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchDeleteRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; }; + }; + }; + toggleScrapFromReadingTip: { + parameters: { + query?: never; + header?: never; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromReadingTipCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2138,23 +5254,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - update: { + toggleScrapFromProblem: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['NoticeUpdateRequest']; + 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; }; }; responses: { @@ -2164,41 +5278,47 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - delete: { + toggleScrapFromPointing: { 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']['ScrapToggleResp']; + }; }; }; }; - getById: { + toggleScrapFromOneStepMore: { parameters: { query?: never; header?: never; - path: { - qnaId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromOneStepMoreCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2206,23 +5326,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapToggleResp']; }; }; }; }; - update_1: { + createScrapFromProblem: { parameters: { query?: never; header?: never; - path: { - qnaId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['QnAUpdateRequest']; + 'application/json': components['schemas']['ScrapFromProblemCreateRequest']; }; }; responses: { @@ -2232,21 +5350,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - delete_1: { + unscrapFromProblem: { parameters: { query?: never; header?: never; - path: { - qnaId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['UnscrapFromProblemRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2257,18 +5377,40 @@ export interface operations { }; }; }; - updateChat_1: { + createScrapFromPointing: { parameters: { query?: never; header?: never; - path: { - chatId: number; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromPointingCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; }; + }; + }; + unscrapFromPointing: { + parameters: { + query?: never; + header?: never; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ChatUpdateRequest']; + 'application/json': components['schemas']['UnscrapFromPointingRequest']; }; }; responses: { @@ -2277,22 +5419,22 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['QnAResp']; - }; + content?: never; }; }; }; - deleteChat_1: { + createScrapFromImage: { parameters: { query?: never; header?: never; - path: { - chatId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFromImageCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2300,18 +5442,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['ScrapDetailResp']; }; }; }; }; - readNotice: { + getFolders: { parameters: { query?: never; header?: never; - path: { - noticeId: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -2322,19 +5462,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['ListRespScrapFolderResp']; }; }; }; }; - me: { + createFolder: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapFolderCreateRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2342,12 +5486,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['ScrapFolderResp']; }; }; }; }; - update_2: { + deleteFolders: { parameters: { query?: never; header?: never; @@ -2356,7 +5500,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['StudentUpdateRequest']; + 'application/json': number[]; }; }; responses: { @@ -2365,26 +5509,20 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['StudentResp']; - }; + content?: never; }; }; }; - update_3: { + gets: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query?: { + query?: string; }; + header?: never; + path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['TeacherUpdateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -2392,23 +5530,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; }; }; }; }; - assignStudentsToTeacher: { + create_1: { parameters: { query?: never; header?: never; - path: { - teacherId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['TeacherStudentAssignReq']; + 'application/json': components['schemas']['QnACreateRequest']; }; }; responses: { @@ -2418,21 +5554,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - getProblem: { + checkExists: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['QnACheckRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2440,23 +5578,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['QnACheckResp']; }; }; }; }; - updateProblem: { + addChat_1: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ProblemUpdateRequest']; + 'application/json': components['schemas']['ChatCreateRequest']; }; }; responses: { @@ -2466,17 +5602,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - deleteProblem: { + readNotification: { parameters: { query?: never; header?: never; path: { - id: number; + notificationId: number; }; cookie?: never; }; @@ -2487,17 +5623,17 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['NotificationResp']; + }; }; }; }; - getProblemSet: { + readAllNotifications: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -2507,24 +5643,20 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['ProblemSetResp']; - }; + content?: never; }; }; }; - update_4: { + updatePushToken_1: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ProblemSetUpdateRequest']; + 'application/json': components['schemas']['StudentPushDTO.UpdateTokenRequest']; }; }; responses: { @@ -2534,18 +5666,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - delete_2: { + toggleAllowPush_1: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -2555,20 +5685,24 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['StudentResp']; + }; }; }; }; - toggleStatus: { + changePassword: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['StudentPasswordDTO.UpdatePasswordRequest']; + }; + }; responses: { /** @description OK */ 200: { @@ -2576,23 +5710,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['StudentResp']; }; }; }; }; - update_5: { + signup: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['Request']; + 'application/json': components['schemas']['StudentSignupReq']; }; }; responses: { @@ -2602,43 +5734,45 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PracticeTestResp']; + '*/*': components['schemas']['StudentTokenResp']; }; }; }; }; - delete_3: { + register: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['StudentInitialRegisterDTO.Req']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['StudentResp']; + }; }; }; }; - update_6: { + refresh_1: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['NoticeUpdateRequest']; + 'application/json': components['schemas']['RefreshReq']; }; }; responses: { @@ -2648,41 +5782,47 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['StudentTokenResp']; }; }; }; }; - delete_4: { + getSocialLoginUrl: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['SocialLoginReq']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['SocialLoginUrlResp']; + }; }; }; }; - getById_1: { + getPreSignedUrl: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['PreSignedReq']; + }; + }; responses: { /** @description OK */ 200: { @@ -2690,23 +5830,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['PreSignedResp']; }; }; }; }; - update_7: { + verify: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['DiagnosisUpdateReq']; + 'application/json': components['schemas']['PhoneAuthVerifyRequest']; }; }; responses: { @@ -2716,43 +5854,45 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['SimpleSuccessResp']; }; }; }; }; - delete_5: { + send: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['PhoneAuthSendRequest']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['SimpleSuccessResp']; + }; }; }; }; - update_8: { + resend: { parameters: { query?: never; header?: never; - path: { - conceptId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ConceptUpdateRequest']; + 'application/json': components['schemas']['PhoneAuthResendRequest']; }; }; responses: { @@ -2762,18 +5902,42 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptResp']; + '*/*': components['schemas']['SimpleSuccessResp']; }; }; }; }; - delete_6: { + create_2: { parameters: { query?: never; header?: never; - path: { - conceptId: number; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + 'application/json': components['schemas']['AdminCreateRequest']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + search: { + parameters: { + query?: { + query?: string; + page?: number; + size?: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -2783,22 +5947,22 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['PageRespTeacherResp']; + }; }; }; }; - updateCategory: { + create_3: { parameters: { query?: never; header?: never; - path: { - categoryId: number; - }; + path?: never; cookie?: never; }; requestBody: { content: { - 'application/json': components['schemas']['ConceptCategoryUpdateRequest']; + 'application/json': components['schemas']['TeacherCreateRequest']; }; }; responses: { @@ -2808,32 +5972,39 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptCategoryResp']; + '*/*': components['schemas']['TeacherResp']; }; }; }; }; - deleteCategory: { + batch: { parameters: { query?: never; header?: never; - path: { - categoryId: number; - }; + path?: never; cookie?: never; }; - requestBody?: never; + requestBody?: { + content: { + 'multipart/form-data': { + /** Format: binary */ + file: string; + }; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['SchoolSaveRespDTO']; + }; }; }; }; - addChat: { + addChat_2: { parameters: { query?: never; header?: never; @@ -2857,10 +6028,12 @@ export interface operations { }; }; }; - getsAll: { + search_1: { parameters: { - query: { - studentId: number; + query?: { + year?: number; + month?: number; + studentId?: number; }; header?: never; path?: never; @@ -2874,12 +6047,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespNoticeResp']; + '*/*': components['schemas']['ListRespPublishResp']; }; }; }; }; - create: { + create_4: { parameters: { query?: never; header?: never; @@ -2888,7 +6061,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['NoticeCreateRequest']; + 'application/json': components['schemas']['PublishCreateRequest']; }; }; responses: { @@ -2898,23 +6071,26 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['PublishResp']; }; }; }; }; - refresh: { + search_2: { parameters: { - query?: never; + query?: { + customId?: string; + title?: string; + concepts?: number[]; + problemType?: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; + page?: number; + size?: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['RefreshReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -2922,12 +6098,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherTokenResp']; + '*/*': components['schemas']['PageRespProblemMetaResp']; }; }; }; }; - login: { + createProblem: { parameters: { query?: never; header?: never; @@ -2936,7 +6112,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['TeacherLoginReq']; + 'application/json': components['schemas']['ProblemCreateRequest']; }; }; responses: { @@ -2946,12 +6122,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherTokenResp']; + '*/*': components['schemas']['ProblemInfoResp']; }; }; }; }; - feedback: { + createProblemWithChild: { parameters: { query?: never; header?: never; @@ -2960,7 +6136,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PointingFeedbackRequest']; + 'application/json': components['schemas']['ProblemEntireCreateRequest']; }; }; responses: { @@ -2969,11 +6145,38 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ProblemInfoResp']; + }; }; }; }; - submit: { + search_3: { + parameters: { + query?: { + setTitle?: string; + problemTitle?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PageRespProblemSetResp']; + }; + }; + }; + }; + create_5: { parameters: { query?: never; header?: never; @@ -2982,7 +6185,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['SubmissionRequest']; + 'application/json': components['schemas']['ProblemSetCreateRequest']; }; }; responses: { @@ -2992,15 +6195,20 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SubmissionResp']; + '*/*': components['schemas']['ProblemSetResp']; }; }; }; }; - gets: { + search_4: { parameters: { query?: { query?: string; + year?: number; + month?: number; + grade?: number; + page?: number; + size?: number; }; header?: never; path?: never; @@ -3014,12 +6222,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; + '*/*': components['schemas']['PageRespPracticeTestResp']; }; }; }; }; - create_1: { + create_6: { parameters: { query?: never; header?: never; @@ -3028,7 +6236,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['QnACreateRequest']; + 'application/json': components['schemas']['PracticeTestCreateRequest']; }; }; responses: { @@ -3038,12 +6246,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['PracticeTestResp']; }; }; }; }; - checkExists: { + redirect: { parameters: { query?: never; header?: never; @@ -3052,7 +6260,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['QnACheckRequest']; + 'application/json': string; }; }; responses: { @@ -3062,12 +6270,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnACheckResp']; + '*/*': string; }; }; }; }; - addChat_1: { + sendNotification: { parameters: { query?: never; header?: never; @@ -3076,7 +6284,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['ChatCreateRequest']; + 'application/json': components['schemas']['NotificationSendRequest']; }; }; responses: { @@ -3086,23 +6294,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['NotificationSendResp']; }; }; }; }; - registerSocial: { + getsAll_1: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['StudentUpdateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3110,12 +6316,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentResp']; + '*/*': components['schemas']['ListRespNoticeResp']; }; }; }; }; - refresh_1: { + create_7: { parameters: { query?: never; header?: never; @@ -3124,7 +6330,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['RefreshReq']; + 'application/json': components['schemas']['NoticeCreateRequest']; }; }; responses: { @@ -3134,23 +6340,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['StudentTokenResp']; + '*/*': components['schemas']['NoticeResp']; }; }; }; }; - getSocialLoginUrl: { + gets_1: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['SocialLoginReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3158,12 +6362,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['SocialLoginUrlResp']; + '*/*': components['schemas']['ListRespDiagnosisResp']; }; }; }; }; - getPreSignedUrl: { + create_8: { parameters: { query?: never; header?: never; @@ -3172,7 +6376,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PreSignedReq']; + 'application/json': components['schemas']['DiagnosisCreateReq']; }; }; responses: { @@ -3182,12 +6386,36 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PreSignedResp']; + '*/*': components['schemas']['DiagnosisResp']; }; }; }; }; - create_2: { + search_5: { + parameters: { + query?: { + query?: string; + page?: number; + size?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['PageRespConceptResp']; + }; + }; + }; + }; + create_9: { parameters: { query?: never; header?: never; @@ -3196,7 +6424,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['AdminCreateRequest']; + 'application/json': components['schemas']['ConceptCreateRequest']; }; }; responses: { @@ -3205,11 +6433,13 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ConceptResp']; + }; }; }; }; - search: { + searchCategory: { parameters: { query?: { query?: string; @@ -3228,12 +6458,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespTeacherResp']; + '*/*': components['schemas']['PageRespConceptCategoryResp']; }; }; }; }; - create_3: { + createCategory: { parameters: { query?: never; header?: never; @@ -3242,7 +6472,7 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['TeacherCreateRequest']; + 'application/json': components['schemas']['ConceptCategoryCreateRequest']; }; }; responses: { @@ -3252,23 +6482,23 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['ConceptCategoryResp']; }; }; }; }; - search_1: { + refresh_2: { parameters: { - query?: { - year?: number; - month?: number; - studentId?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['RefreshReq']; + }; + }; responses: { /** @description OK */ 200: { @@ -3276,12 +6506,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespPublishResp']; + '*/*': components['schemas']['AdminTokenResp']; }; }; }; }; - create_4: { + login_1: { parameters: { query?: never; header?: never; @@ -3290,31 +6520,53 @@ export interface operations { }; requestBody: { content: { - 'application/json': components['schemas']['PublishCreateRequest']; + 'application/json': components['schemas']['AdminLoginReq']; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + '*/*': components['schemas']['AdminTokenResp']; + }; + }; + }; + }; + oauthRedirectExample: { + parameters: { + query: { + /** @description 성공 여부 */ + isSuccess: boolean; + /** @description 첫 로그인 여부 */ + isFirstLogin: boolean; + /** @description 응답 메시지 */ + message: string; + /** @description accessToken */ + accessToken: string; + /** @description refreshToken */ + refreshToken: string; }; + header?: never; + path?: never; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['PublishResp']; - }; + content?: never; }; }; }; - search_2: { + content: { parameters: { - query?: { - customId?: string; - title?: string; - concepts?: number[]; - problemType?: 'MAIN_PROBLEM' | 'CHILD_PROBLEM'; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -3326,48 +6578,38 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['PageRespProblemMetaResp']; - }; + content?: never; }; }; }; - createProblem: { + problem: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ProblemCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['ProblemInfoResp']; - }; + content?: never; }; }; }; - createProblemWithChild: { + search_6: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ProblemEntireCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3375,18 +6617,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemInfoResp']; + '*/*': components['schemas']['ListRespPublishResp']; }; }; }; }; - search_3: { + searchMonthly: { parameters: { - query?: { - setTitle?: string; - problemTitle?: string; - page?: number; - size?: number; + query: { + year: number; + month: number; + studentId: number; }; header?: never; path?: never; @@ -3400,23 +6641,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespProblemSetResp']; + '*/*': components['schemas']['ListRespPublishResp']; }; }; }; }; - create_5: { + getPublishById: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['ProblemSetCreateRequest']; + path: { + id: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3424,20 +6663,15 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemSetResp']; + '*/*': components['schemas']['PublishResp']; }; }; }; }; - search_4: { + getWeeklyProgress: { parameters: { - query?: { - query?: string; - year?: number; - month?: number; - grade?: number; - page?: number; - size?: number; + query: { + studentId: number; }; header?: never; path?: never; @@ -3451,23 +6685,24 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespPracticeTestResp']; + '*/*': components['schemas']['PublishStudentProgressResp']; }; }; }; }; - create_6: { + getProblemById: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['PracticeTestCreateRequest']; + path: { + publishId: number; + problemId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3475,23 +6710,24 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PracticeTestResp']; + '*/*': components['schemas']['ProblemWithStudyInfoResp']; }; }; }; }; - redirect: { + getChildProblemById: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': string; + path: { + publishId: number; + problemId: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3499,16 +6735,14 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': string; + '*/*': components['schemas']['ProblemWithStudyInfoResp']; }; }; }; }; - getsAll_1: { + getMyStudents: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -3521,23 +6755,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespNoticeResp']; + '*/*': components['schemas']['ListRespStudentResp']; }; }; }; }; - create_7: { + gets_2: { parameters: { - query?: never; + query?: { + query?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['NoticeCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3545,18 +6777,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeResp']; + '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; }; }; }; }; - gets_1: { + getById_2: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; - path?: never; + path: { + qnaId: number; + }; cookie?: never; }; requestBody?: never; @@ -3567,23 +6799,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespDiagnosisResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - create_8: { + search_7: { parameters: { - query?: never; + query?: { + query?: string; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['DiagnosisCreateReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3591,17 +6821,15 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['QnASearchResp']; }; }; }; }; - search_5: { + getsAvailable: { parameters: { - query?: { - query?: string; - page?: number; - size?: number; + query: { + studentId: number; }; header?: never; path?: never; @@ -3615,23 +6843,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespConceptResp']; + '*/*': components['schemas']['ListRespNoticeResp']; }; }; }; }; - create_9: { + getTeacherMe: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ConceptCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3639,18 +6863,14 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptResp']; + '*/*': components['schemas']['TeacherResp']; }; }; }; }; - searchCategory: { + search_8: { parameters: { - query?: { - query?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -3663,23 +6883,22 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespConceptCategoryResp']; + '*/*': components['schemas']['ListRespPublishResp']; }; }; }; }; - createCategory: { + searchMonthly_1: { parameters: { - query?: never; + query: { + year: number; + month: number; + }; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['ConceptCategoryCreateRequest']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3687,23 +6906,21 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ConceptCategoryResp']; + '*/*': components['schemas']['ListRespPublishResp']; }; }; }; }; - refresh_2: { + getPublishById_1: { parameters: { query?: never; header?: never; - path?: never; - cookie?: never; - }; - requestBody: { - content: { - 'application/json': components['schemas']['RefreshReq']; + path: { + id: number; }; + cookie?: never; }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3711,23 +6928,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['AdminTokenResp']; + '*/*': components['schemas']['PublishResp']; }; }; }; }; - login_1: { + getWeeklyProgress_1: { parameters: { query?: never; header?: never; path?: never; cookie?: never; }; - requestBody: { - content: { - 'application/json': components['schemas']['AdminLoginReq']; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -3735,27 +6948,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['AdminTokenResp']; + '*/*': components['schemas']['PublishStudentProgressResp']; }; }; }; }; - oauthRedirectExample: { + getProblemById_1: { parameters: { - query: { - /** @description 성공 여부 */ - isSuccess: boolean; - /** @description 첫 로그인 여부 */ - isFirstLogin: boolean; - /** @description 응답 메시지 */ - message: string; - /** @description accessToken */ - accessToken: string; - /** @description refreshToken */ - refreshToken: string; - }; + query?: never; header?: never; - path?: never; + path: { + publishId: number; + problemId: number; + }; cookie?: never; }; requestBody?: never; @@ -3765,15 +6970,19 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ProblemWithStudyInfoResp']; + }; }; }; }; - content: { + getScrap: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -3783,13 +6992,26 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ScrapDetailResp']; + }; }; }; }; - problem: { + 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; @@ -3801,38 +7023,51 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ListRespTrashItemResp']; + }; }; }; }; - search_6: { + permanentDelete: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path?: never; cookie?: never; }; - requestBody?: never; + requestBody: { + content: { + 'application/json': components['schemas']['ScrapBatchPermanentDeleteRequest']; + }; + }; responses: { /** @description OK */ 200: { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['ListRespPublishResp']; - }; + content?: never; }; }; }; - searchMonthly: { + searchScraps: { parameters: { - query: { - year: number; - month: number; - studentId: number; + query?: { + /** @description 폴더 ID (null이면 루트 스크랩) */ + folderId?: number; + /** @description 검색어 (폴더명, 문제 제목) */ + query?: string; + /** + * @description 정렬 필드 (CREATED_AT/NAME/TYPE/SIMILARITY) + * @example CREATED_AT + */ + sort?: 'CREATED_AT' | 'NAME' | 'TYPE' | 'SIMILARITY'; + /** + * @description 정렬 방향 (ASC/DESC) + * @example DESC + */ + order?: 'ASC' | 'DESC'; }; header?: never; path?: never; @@ -3846,17 +7081,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespPublishResp']; + '*/*': components['schemas']['ScrapSearchResp']; }; }; }; }; - getPublishById: { + getScrapsByFolder: { parameters: { query?: never; header?: never; path: { - id: number; + folderId: number; }; cookie?: never; }; @@ -3868,15 +7103,15 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PublishResp']; + '*/*': components['schemas']['ListRespScrapListItemResp']; }; }; }; }; - getWeeklyProgress: { + search_9: { parameters: { - query: { - studentId: number; + query?: { + query?: string; }; header?: never; path?: never; @@ -3890,20 +7125,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PublishStudentProgressResp']; + '*/*': components['schemas']['ListRespSchoolResp']; }; }; }; }; - getProblemById: { + getImages: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path: { - publishId: number; - problemId: number; + qnaId: number; }; cookie?: never; }; @@ -3915,21 +7147,38 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemWithStudyInfoResp']; + '*/*': components['schemas']['ListRespUploadFileResp']; }; }; }; }; - getChildProblemById: { + search_10: { parameters: { - query: { - studentId: number; + query?: { + query?: string; }; header?: never; - path: { - publishId: number; - problemId: number; + 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; @@ -3940,12 +7189,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemWithStudyInfoResp']; + '*/*': components['schemas']['ListRespUploadFileResp']; }; }; }; }; - getMyStudents: { + getOrCreateAdminChatroom: { parameters: { query?: never; header?: never; @@ -3960,15 +7209,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespStudentResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - gets_2: { + getNotifications: { parameters: { query?: { - query?: string; + /** @description 조회 기간 (일 단위, 예: 30이면 30일 전까지) */ + dayLimit?: number; }; header?: never; path?: never; @@ -3982,18 +7232,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; + '*/*': components['schemas']['ListRespNotificationResp']; }; }; }; }; - getById_2: { + countUnread: { parameters: { query?: never; header?: never; - path: { - qnaId: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -4004,16 +7252,14 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['QnAResp']; + '*/*': components['schemas']['NotificationUnreadCountResp']; }; }; }; }; - getsAvailable: { + getsAvailable_1: { parameters: { - query: { - studentId: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -4031,7 +7277,7 @@ export interface operations { }; }; }; - getTeacherMe: { + countAvailable: { parameters: { query?: never; header?: never; @@ -4046,12 +7292,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['TeacherResp']; + '*/*': components['schemas']['NoticeUnreadCountResp']; }; }; }; }; - search_7: { + gets_3: { parameters: { query?: never; header?: never; @@ -4066,17 +7312,14 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespPublishResp']; + '*/*': components['schemas']['ListRespDiagnosisResp']; }; }; }; }; - searchMonthly_1: { + getById_3: { parameters: { - query: { - year: number; - month: number; - }; + query?: never; header?: never; path?: never; cookie?: never; @@ -4089,12 +7332,12 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespPublishResp']; + '*/*': components['schemas']['DiagnosisResp']; }; }; }; }; - getPublishById_1: { + getById_4: { parameters: { query?: never; header?: never; @@ -4111,14 +7354,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PublishResp']; + '*/*': components['schemas']['DiagnosisResp']; }; }; }; }; - getWeeklyProgress_1: { + existsByEmail: { parameters: { - query?: never; + query: { + email: string; + }; header?: never; path?: never; cookie?: never; @@ -4131,37 +7376,48 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PublishStudentProgressResp']; + '*/*': components['schemas']['BooleanResp']; }; }; }; }; - getProblemById_1: { + subscribe: { parameters: { - query?: never; + query: { + token: string; + }; header?: never; path: { - publishId: number; - problemId: number; + qnaId: number; }; cookie?: never; }; requestBody?: never; responses: { - /** @description OK */ + /** @description SSE 스트림 연결 성공. 드롭다운에서 이벤트 타입별 스키마를 확인하세요. */ 200: { headers: { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemWithStudyInfoResp']; + 'chat (application/json)': components['schemas']['QnAChatEvent']; + 'read_status (application/json)': components['schemas']['QnAReadStatusEvent']; }; }; + /** @description 토큰 검증 실패 */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; }; }; - getsAvailable_1: { + throwException: { parameters: { - query?: never; + query?: { + message?: string; + }; header?: never; path?: never; cookie?: never; @@ -4173,13 +7429,11 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['ListRespNoticeResp']; - }; + content?: never; }; }; }; - countAvailable: { + refresh_3: { parameters: { query?: never; header?: never; @@ -4194,14 +7448,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['NoticeUnreadCountResp']; + '*/*': components['schemas']['JwtResp']; }; }; }; }; - gets_3: { + search_11: { parameters: { - query?: never; + query?: { + query?: string; + page?: number; + size?: number; + }; header?: never; path?: never; cookie?: never; @@ -4214,14 +7472,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespDiagnosisResp']; + '*/*': components['schemas']['PageRespStudentResp']; }; }; }; }; - getById_3: { + gets_4: { parameters: { - query?: never; + query?: { + query?: string; + }; header?: never; path?: never; cookie?: never; @@ -4234,17 +7494,17 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['PageRespNotListQnAGroupByWeekResp']; }; }; }; }; - getById_4: { + getById_5: { parameters: { query?: never; header?: never; path: { - id: number; + qnaId: number; }; cookie?: never; }; @@ -4256,16 +7516,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['DiagnosisResp']; + '*/*': components['schemas']['QnAResp']; }; }; }; }; - refresh_3: { + getById_6: { parameters: { query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -4276,20 +7538,18 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['JwtResp']; + '*/*': components['schemas']['PublishResp']; }; }; }; }; - search_8: { + delete_7: { parameters: { - query?: { - query?: string; - page?: number; - size?: number; - }; + query?: never; header?: never; - path?: never; + path: { + id: number; + }; cookie?: never; }; requestBody?: never; @@ -4299,19 +7559,15 @@ export interface operations { headers: { [name: string]: unknown; }; - content: { - '*/*': components['schemas']['PageRespStudentResp']; - }; + content?: never; }; }; }; - getById_5: { + getCustomId: { parameters: { query?: never; header?: never; - path: { - id: number; - }; + path?: never; cookie?: never; }; requestBody?: never; @@ -4322,18 +7578,19 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['PublishResp']; + '*/*': components['schemas']['ProblemCustomIdResp']; }; }; }; }; - delete_7: { + getNotificationsByStudent: { parameters: { - query?: never; - header?: never; - path: { - id: number; + query: { + /** @description 학생 ID */ + studentId: number; }; + header?: never; + path?: never; cookie?: never; }; requestBody?: never; @@ -4343,13 +7600,17 @@ export interface operations { headers: { [name: string]: unknown; }; - content?: never; + content: { + '*/*': components['schemas']['ListRespNotificationResp']; + }; }; }; }; - getCustomId: { + getsAvailable_2: { parameters: { - query?: never; + query: { + studentId: number; + }; header?: never; path?: never; cookie?: never; @@ -4362,15 +7623,16 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ProblemCustomIdResp']; + '*/*': components['schemas']['ListRespNoticeResp']; }; }; }; }; - getsAvailable_2: { + issueTemporaryToken: { parameters: { query: { - studentId: number; + id: number; + type: 'ADMIN' | 'STUDENT' | 'TEACHER'; }; header?: never; path?: never; @@ -4384,8 +7646,26 @@ export interface operations { [name: string]: unknown; }; content: { - '*/*': components['schemas']['ListRespNoticeResp']; + '*/*': components['schemas']['JwtResp']; + }; + }; + }; + }; + emptyTrash: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; }; + content?: never; }; }; }; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e5651772..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) @@ -204,6 +207,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) @@ -216,6 +222,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 +240,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) @@ -282,9 +294,18 @@ 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) + 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 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) @@ -297,6 +318,12 @@ 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-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) @@ -2447,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: @@ -2508,6 +2540,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==} @@ -2713,6 +2748,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==} @@ -3626,6 +3674,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'} @@ -4005,6 +4056,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'} @@ -4341,6 +4395,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} @@ -4724,6 +4781,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: @@ -4748,6 +4812,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: @@ -5385,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'} @@ -5878,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==} @@ -6819,18 +6901,34 @@ 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: 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: 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: @@ -6857,6 +6955,15 @@ packages: react: '*' react-native: '*' + react-native-toast-message@2.3.3: + resolution: {integrity: sha512-4IIUHwUPvKHu4gjD0Vj2aGQzqPATiblL1ey8tOqsxOWRPGGu52iIbL8M/mCz4uyqecvPdIcMY38AfwRuUADfQQ==} + peerDependencies: + 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: @@ -6887,6 +6994,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'} @@ -7128,6 +7241,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==} @@ -10280,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 @@ -10396,6 +10517,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': {} @@ -10587,6 +10710,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': @@ -11870,6 +12002,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@webgpu/types@0.1.21': {} + '@xmldom/xmldom@0.8.11': {} abort-controller@3.0.0: @@ -12328,6 +12462,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 @@ -12699,6 +12837,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: {} @@ -13397,6 +13541,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 @@ -13422,6 +13572,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) @@ -14137,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 @@ -14599,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: {} @@ -15748,6 +15913,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 @@ -15756,11 +15927,21 @@ 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 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 @@ -15791,6 +15972,13 @@ 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-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 @@ -15879,6 +16067,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: {} @@ -16151,6 +16344,8 @@ snapshots: loose-envify: 1.4.0 object-assign: 4.1.1 + scheduler@0.25.0: {} + scheduler@0.26.0: {} semver@6.3.1: {}