11"use client" ;
22
3- import { useState , useEffect , useCallback } from "react" ;
3+ import { useState , useEffect , useCallback , useMemo } from "react" ;
44import {
55 Sheet ,
66 SheetContent ,
77 SheetTitle ,
88} from "@/components/ui/sheet" ;
9- import { getStorageFolders , createStorageFolder , getStorageFoldersByItemId , type StorageFolder } from "@/lib/api/storage" ;
9+ import { getStorageFolders , createStorageFolder , getStorageFoldersByItemId , getStorageNews , deleteNewsFromStorage , saveNewsToStorage , getStorageTerms , deleteTermFromStorage , saveTermToStorage , type StorageFolder } from "@/lib/api/storage" ;
1010
1111type Category = {
1212 category_id : number ;
@@ -18,12 +18,13 @@ type NewCategoryBottomSheetProps = {
1818 open : boolean ;
1919 onOpenChange : ( open : boolean ) => void ;
2020 categories : Category [ ] ;
21- onSelectCategory : ( categoryId : number | null ) => void ;
21+ onSelectCategory ? : ( categoryId : number | null ) => void ; // 선택적: 내부에서 처리할 수도 있음
2222 onAddNewCategory : ( ) => void ;
2323 itemId ?: number ; // 뉴스 ID 또는 단어 ID
2424 folderType ?: "NEWS" | "TERM" ; // 폴더 타입
2525 title ?: string ; // 바텀시트 제목
2626 subtitle ?: string ; // 바텀시트 부제목
27+ onToggleComplete ?: ( ) => void ; // 저장/삭제 완료 후 콜백
2728} ;
2829
2930// 새 카테고리 생성 폼 컴포넌트
@@ -222,14 +223,22 @@ export function NewCategoryBottomSheet({
222223 folderType = "NEWS" ,
223224 title,
224225 subtitle,
226+ onToggleComplete,
225227} : NewCategoryBottomSheetProps ) {
226228 const [ isCreatingNewCategory , setIsCreatingNewCategory ] = useState ( false ) ;
227229 const [ newCategoryName , setNewCategoryName ] = useState ( "" ) ;
228230 const [ folders , setFolders ] = useState < Category [ ] > ( [ ] ) ;
231+ const [ optimisticFolders , setOptimisticFolders ] = useState < Category [ ] | null > ( null ) ;
229232 const [ loading , setLoading ] = useState ( false ) ;
230233 const [ savedFolderIds , setSavedFolderIds ] = useState < number [ ] > ( [ ] ) ;
231234
232- // 폴더 목록 조회 함수
235+ // displayCategories: optimisticFolders가 있으면 사용, 없으면 folders가 있으면 folders 사용, 없으면 categories prop 사용
236+ const displayCategories = useMemo ( ( ) => {
237+ if ( optimisticFolders ) return optimisticFolders ;
238+ return folders . length > 0 ? folders : categories ;
239+ } , [ optimisticFolders , folders , categories ] ) ;
240+
241+ // 폴더 목록 조회 함수 (로딩 상태 포함)
233242 const fetchFolders = useCallback ( async ( ) => {
234243 try {
235244 setLoading ( true ) ;
@@ -240,14 +249,33 @@ export function NewCategoryBottomSheet({
240249 count : folder . itemCount ,
241250 } ) ) ;
242251 setFolders ( folderList ) ;
252+ return response ;
243253 } catch ( err ) {
244254 console . warn ( "폴더 목록 조회 실패:" , err ) ;
245255 setFolders ( [ ] ) ;
256+ throw err ;
246257 } finally {
247258 setLoading ( false ) ;
248259 }
249260 } , [ folderType ] ) ;
250261
262+ // 폴더 목록 조회 함수 (백그라운드 동기화용, 로딩 상태 없음)
263+ const syncFolders = useCallback ( async ( ) => {
264+ try {
265+ const response = await getStorageFolders ( folderType ) ;
266+ const folderList : Category [ ] = response . data . map ( ( folder : StorageFolder ) => ( {
267+ category_id : folder . folderId ,
268+ name : folder . folderName ,
269+ count : folder . itemCount ,
270+ } ) ) ;
271+ setFolders ( folderList ) ;
272+ return response ;
273+ } catch ( err ) {
274+ console . warn ( "폴더 목록 동기화 실패:" , err ) ;
275+ throw err ;
276+ }
277+ } , [ folderType ] ) ;
278+
251279 // 저장된 폴더 목록 조회 함수
252280 const fetchSavedFolders = useCallback ( async ( ) => {
253281 if ( ! itemId ) {
@@ -281,9 +309,117 @@ export function NewCategoryBottomSheet({
281309 }
282310 } , [ open ] ) ;
283311
284- const handleCategorySelect = ( categoryId : number | null ) => {
285- onSelectCategory ( categoryId ) ;
286- onOpenChange ( false ) ;
312+ // 폴더에서 아이템 삭제 (savedItemId 찾기)
313+ const findSavedItemId = useCallback ( async ( folderId : number ) : Promise < number | null > => {
314+ if ( ! itemId ) return null ;
315+
316+ try {
317+ if ( folderType === "NEWS" ) {
318+ // 뉴스의 경우: 폴더의 뉴스 목록에서 해당 newsId 찾기
319+ const response = await getStorageNews ( { folderId, size : 1000 } ) ;
320+ const savedItem = response . data . news . find ( ( item ) => item . newsId === itemId ) ;
321+ return savedItem ?. savedItemId || null ;
322+ } else {
323+ // 용어의 경우: 폴더의 용어 목록에서 해당 termId 찾기
324+ const response = await getStorageTerms ( { folderId, size : 1000 } ) ;
325+ const savedItem = response . data . terms . find ( ( item ) => item . termId === itemId ) ;
326+ return savedItem ?. savedItemId || null ;
327+ }
328+ } catch ( err ) {
329+ console . error ( "savedItemId 조회 실패:" , err ) ;
330+ return null ;
331+ }
332+ } , [ itemId , folderType ] ) ;
333+
334+ const handleCategorySelect = async ( categoryId : number | null ) => {
335+ if ( ! itemId || categoryId === null ) {
336+ if ( onSelectCategory ) {
337+ onSelectCategory ( categoryId ) ;
338+ }
339+ onOpenChange ( false ) ;
340+ return ;
341+ }
342+
343+ const isSaved = savedFolderIds . includes ( categoryId ) ;
344+
345+ // Optimistic update: 로컬 상태 먼저 업데이트
346+ const prevSavedFolderIds = [ ...savedFolderIds ] ;
347+ const currentDisplayCategories = displayCategories ;
348+ const prevDisplayCategories = [ ...currentDisplayCategories ] ;
349+
350+ if ( isSaved ) {
351+ // UI 먼저 업데이트 (삭제)
352+ setSavedFolderIds ( ( prev ) => prev . filter ( ( id ) => id !== categoryId ) ) ;
353+ setOptimisticFolders (
354+ currentDisplayCategories . map ( ( category ) =>
355+ category . category_id === categoryId
356+ ? { ...category , count : Math . max ( 0 , ( category . count || 0 ) - 1 ) }
357+ : category
358+ )
359+ ) ;
360+ } else {
361+ // UI 먼저 업데이트 (저장)
362+ setSavedFolderIds ( ( prev ) => [ ...prev , categoryId ] ) ;
363+ setOptimisticFolders (
364+ currentDisplayCategories . map ( ( category ) =>
365+ category . category_id === categoryId
366+ ? { ...category , count : ( category . count || 0 ) + 1 }
367+ : category
368+ )
369+ ) ;
370+ }
371+
372+ try {
373+ if ( isSaved ) {
374+ // 이미 저장된 폴더면 삭제
375+ const savedItemId = await findSavedItemId ( categoryId ) ;
376+ if ( savedItemId ) {
377+ if ( folderType === "NEWS" ) {
378+ await deleteNewsFromStorage ( savedItemId ) ;
379+ } else {
380+ await deleteTermFromStorage ( savedItemId ) ;
381+ }
382+ }
383+ } else {
384+ // 저장되지 않은 폴더면 저장
385+ if ( folderType === "NEWS" ) {
386+ await saveNewsToStorage ( itemId , [ categoryId ] ) ;
387+ } else {
388+ await saveTermToStorage ( itemId , [ categoryId ] ) ;
389+ }
390+ }
391+
392+ // 백그라운드에서 동기화 (에러가 나도 UI는 이미 업데이트됨, 로딩 상태 없이 조용히 업데이트)
393+ Promise . all ( [
394+ fetchSavedFolders ( ) ,
395+ syncFolders ( ) ,
396+ ] ) . then ( ( ) => {
397+ // 동기화 완료 후 optimistic 상태 초기화
398+ setOptimisticFolders ( null ) ;
399+ } ) . catch ( ( err ) => {
400+ console . error ( "동기화 실패:" , err ) ;
401+ // 에러 발생 시 이전 상태로 롤백
402+ setSavedFolderIds ( prevSavedFolderIds ) ;
403+ setOptimisticFolders ( prevDisplayCategories ) ;
404+ } ) ;
405+
406+ // 기존 onSelectCategory 콜백 호출 (호환성 유지)
407+ if ( onSelectCategory ) {
408+ onSelectCategory ( categoryId ) ;
409+ }
410+
411+ // 완료 콜백 호출
412+ if ( onToggleComplete ) {
413+ onToggleComplete ( ) ;
414+ }
415+
416+ // 바텀시트는 닫지 않음 (여러 폴더 선택 가능하도록)
417+ } catch ( err ) {
418+ console . error ( "폴더 저장/삭제 실패:" , err ) ;
419+ // 에러 발생 시 이전 상태로 롤백
420+ setSavedFolderIds ( prevSavedFolderIds ) ;
421+ setOptimisticFolders ( prevDisplayCategories ) ;
422+ }
287423 } ;
288424
289425 const handleAddNewCategoryClick = ( ) => {
@@ -328,7 +464,7 @@ export function NewCategoryBottomSheet({
328464 />
329465 ) : (
330466 < CategorySelectList
331- categories = { folders }
467+ categories = { displayCategories . length > 0 ? displayCategories : folders . length > 0 ? folders : categories }
332468 onSelectCategory = { handleCategorySelect }
333469 onAddNewCategory = { handleAddNewCategoryClick }
334470 loading = { loading }
0 commit comments